From ed5da3d11a9e9bafa1bb0a9dfea4e7a8e551a173 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Sun, 18 Oct 2026 01:20:32 +0000 Subject: [PATCH] test(web): consolidate suites around real feature behavior (#44274) --- oxlint-suppressions.json | 42 +- .../access-control/resource-pages.spec.tsx | 402 +++ web/__tests__/base/chat-flow.test.tsx | 27 +- .../billing/billing-integration.test.tsx | 5 - .../billing/cloud-plan-payment-flow.test.tsx | 23 +- .../billing/pricing-modal-flow.test.tsx | 5 - ...nteractive-component-mock-boundary.spec.ts | 2 +- .../overview/tracing/__tests__/panel.spec.tsx | 3 +- .../components/__tests__/splash.spec.tsx | 3 +- .../__tests__/mail-and-code-auth.spec.tsx | 5 - .../__tests__/mail-and-password-auth.spec.tsx | 5 - .../__tests__/home-banner.browser.spec.tsx | 23 +- .../components/access-rules-editor/index.tsx | 2 +- .../__tests__/dataset-detail-section.spec.tsx | 28 +- .../access-config/__tests__/index.spec.tsx | 465 --- .../__tests__/built-in-access-points.spec.tsx | 5 - ...eployed-environment-access-points.spec.tsx | 5 - .../app/annotation/__tests__/index.spec.tsx | 812 ++--- .../__tests__/csv-downloader.spec.tsx | 89 +- .../edit-annotation-modal/edit-item/index.tsx | 25 +- .../app-publisher/__tests__/index.spec.tsx | 1605 +++------- .../__tests__/publisher-panel.spec.tsx | 160 - .../__tests__/configuration-flow.spec.tsx | 744 +++++ .../__tests__/configuration-view.spec.tsx | 304 -- .../confirm-add-var/__tests__/index.spec.tsx | 7 +- .../config-prompt/confirm-add-var/index.tsx | 7 +- .../config/__tests__/index.spec.tsx | 259 -- .../__tests__/setting-built-in-tool.spec.tsx | 411 +-- .../generator-reflow.browser.spec.tsx | 94 +- .../__tests__/get-automatic-res.spec.tsx | 177 +- .../config/automatic/get-automatic-res.tsx | 1 + .../__tests__/get-code-generator-res.spec.tsx | 187 +- .../__tests__/config-content.spec.tsx | 63 +- .../params-config/__tests__/index.spec.tsx | 84 +- .../settings-modal/__tests__/index.spec.tsx | 77 +- .../__tests__/retrieval-section.spec.tsx | 40 +- .../debug/__tests__/index.spec.tsx | 1709 ++++------ .../__tests__/chat-item.spec.tsx | 525 ---- .../__tests__/index.spec.tsx | 746 +++-- .../model-parameter-trigger.spec.tsx | 451 +-- .../__tests__/index.spec.tsx | 931 ++---- .../debug/debug-with-single-model/index.tsx | 2 +- .../__tests__/use-configuration.spec.tsx | 817 ----- .../create-app-modal/__tests__/index.spec.tsx | 3 - .../app/deploy/__tests__/index.spec.tsx | 5 - .../environment-variable-field.spec.tsx | 173 +- .../__tests__/use-provider-icon.spec.tsx | 12 +- .../duplicate-modal/__tests__/index.spec.tsx | 3 +- .../app/log/__tests__/list.spec.tsx | 1262 +++----- web/app/components/app/log/list.tsx | 2 + .../settings/__tests__/index.spec.tsx | 49 +- .../app/workflow-log/__tests__/index.spec.tsx | 6 - .../app/workflow-log/__tests__/list.spec.tsx | 6 - .../apps/__tests__/app-card.spec.tsx | 1989 +++++------- .../components/apps/__tests__/index.spec.tsx | 494 +-- .../components/apps/__tests__/list.spec.tsx | 1637 +++------- .../agent-log-modal/__tests__/index.spec.tsx | 15 +- .../base/audio-btn/__tests__/index.spec.tsx | 45 +- .../__tests__/AudioPlayer.spec.tsx | 1 - .../base/carousel/__tests__/index.spec.tsx | 8 +- .../__tests__/chat-wrapper.spec.tsx | 1861 ++++------- .../__tests__/header-in-mobile.spec.tsx | 78 +- .../__tests__/hooks.spec.tsx | 470 ++- .../chat/chat-with-history/chat-wrapper.tsx | 7 +- .../header/__tests__/index.spec.tsx | 48 +- .../sidebar/__tests__/item.spec.tsx | 3 +- .../base/chat/chat/__tests__/hooks.spec.tsx | 442 ++- .../chat/chat/__tests__/question.spec.tsx | 146 +- .../answer/__tests__/agent-content.spec.tsx | 235 +- .../answer/__tests__/basic-content.spec.tsx | 106 +- .../__tests__/human-input-form-list.spec.tsx | 49 +- .../chat/chat/answer/__tests__/index.spec.tsx | 192 +- .../chat/answer/__tests__/operation.spec.tsx | 172 +- .../answer/__tests__/reasoning-panel.spec.tsx | 68 +- .../base/chat/chat/answer/agent-content.tsx | 12 +- .../base/chat/chat/thought/index.tsx | 2 +- .../embedded-chatbot/__tests__/hooks.spec.tsx | 386 ++- .../checkbox-list/__tests__/index.spec.tsx | 44 +- .../error-boundary/__tests__/index.spec.tsx | 15 +- .../__tests__/feature-bar.spec.tsx | 6 +- .../follow-up-setting-modal.spec.tsx | 130 +- .../__tests__/follow-up.browser.spec.tsx | 27 +- .../__tests__/follow-up.spec.tsx | 19 +- .../__tests__/index.spec.tsx | 46 +- .../__tests__/annotation-ctrl-button.spec.tsx | 15 +- .../__tests__/config-param-modal.spec.tsx | 173 +- .../annotation-reply/__tests__/index.spec.tsx | 86 +- .../__tests__/modal.spec.tsx | 49 +- .../file-upload/__tests__/index.spec.tsx | 3 +- .../__tests__/setting-modal.spec.tsx | 3 +- .../moderation/__tests__/index.spec.tsx | 15 +- .../moderation-setting-modal.spec.tsx | 317 +- .../__tests__/param-config-content.spec.tsx | 9 +- .../__tests__/file-item.spec.tsx | 3 +- .../__tests__/file-item.spec.tsx | 51 +- web/app/components/base/icon-picker/index.tsx | 2 +- .../__tests__/image-preview.browser.spec.tsx | 41 + .../__tests__/image-preview.spec.tsx | 829 ++--- .../__tests__/uploader.spec.tsx | 4 +- .../image-uploader/__tests__/utils.spec.ts | 233 +- .../base/image-uploader/image-preview.tsx | 8 +- .../text-generation-image-uploader.tsx | 10 +- .../base/image-uploader/uploader.tsx | 6 +- .../__tests__/code-block-load-error.spec.tsx | 2 - .../__tests__/code-block-loading.spec.tsx | 2 - .../prompt-editor/__tests__/hooks.spec.tsx | 9 +- .../__tests__/prompt-editor-content.spec.tsx | 3 +- .../components/base/prompt-editor/index.tsx | 2 +- .../__tests__/component.spec.tsx | 6 +- .../__tests__/component-ui.spec.tsx | 33 +- .../__tests__/variable-block.spec.tsx | 33 +- .../plugins/hitl-input-block/index.tsx | 24 +- .../__tests__/index.spec.tsx | 6 +- .../__tests__/component.spec.tsx | 27 +- .../plugins/workflow-variable-block/index.tsx | 23 +- .../base/tag-input/__tests__/index.spec.tsx | 36 +- .../voice-input/__tests__/recorder.spec.ts | 92 +- .../base/zendesk/__tests__/index.spec.tsx | 39 +- .../pricing/__tests__/content.spec.tsx | 4 - .../billing/pricing/__tests__/dialog.spec.tsx | 4 - .../__tests__/index.spec.tsx | 246 +- .../__tests__/powered-by-brand.spec.tsx | 42 - .../__tests__/use-web-app-brand.spec.tsx | 413 --- .../custom/custom-web-app-brand/index.tsx | 11 +- .../access-config/__tests__/index.spec.tsx | 443 --- .../__tests__/index.spec.tsx | 51 +- .../__tests__/index.spec.tsx | 149 +- .../__tests__/index.spec.tsx | 54 +- .../__tests__/edit-pipeline-info.spec.tsx | 51 +- .../datasets/create/__tests__/index.spec.tsx | 1208 +++---- .../__tests__/rule-detail.spec.tsx | 1 - .../file-uploader/__tests__/index.spec.tsx | 482 +-- .../__tests__/file-list-item.spec.tsx | 57 - .../__tests__/upload-dropzone.spec.tsx | 71 - .../components/upload-dropzone.tsx | 12 +- .../hooks/__tests__/use-file-upload.spec.tsx | 715 ----- .../create/step-one/__tests__/index.spec.tsx | 712 ++--- .../data-source-type-selector.spec.tsx | 52 - .../hooks/__tests__/use-preview-state.spec.ts | 29 - .../create/step-two/__tests__/index.spec.tsx | 1244 ++++---- .../general-chunking-options.spec.tsx | 123 - .../__tests__/indexing-mode-section.spec.tsx | 176 -- .../__tests__/parent-child-options.spec.tsx | 108 - .../__tests__/use-document-creation.spec.ts | 258 -- .../__tests__/use-indexing-config.spec.ts | 55 - .../__tests__/use-indexing-estimate.spec.ts | 117 - .../hooks/__tests__/use-preview-state.spec.ts | 60 - .../__tests__/use-segmentation-state.spec.ts | 269 -- .../step-two/hooks/use-document-creation.ts | 8 +- .../step-two/hooks/use-indexing-estimate.ts | 107 +- .../step-two/hooks/use-preview-state.ts | 4 +- .../step-two/hooks/use-segmentation-state.ts | 8 +- .../datasets/create/step-two/index.tsx | 14 +- .../firecrawl/__tests__/index.spec.tsx | 3 +- .../jina-reader/__tests__/index.spec.tsx | 126 +- .../watercrawl/__tests__/index.spec.tsx | 126 +- .../documents/__tests__/index.spec.tsx | 902 +++--- .../__tests__/documents-header.spec.tsx | 125 - .../__tests__/empty-element.spec.tsx | 23 - .../components/__tests__/list.spec.tsx | 155 - .../components/__tests__/operations.spec.tsx | 444 ++- .../__tests__/index.spec.tsx | 325 +- .../local-file/__tests__/index.spec.tsx | 123 +- .../__tests__/file-list-item.spec.tsx | 2 - .../local-file/components/upload-dropzone.tsx | 10 +- .../preview/__tests__/chunk-preview.spec.tsx | 3 +- .../__tests__/index.spec.tsx | 17 +- .../__tests__/index.spec.tsx | 3 +- .../steps/__tests__/step-one-content.spec.tsx | 303 +- .../documents/detail/__tests__/index.spec.tsx | 436 +-- .../__tests__/csv-downloader.spec.tsx | 45 +- .../__tests__/csv-uploader.spec.tsx | 4 - .../batch-modal/__tests__/index.spec.tsx | 1 - .../segment-card/__tests__/index.spec.tsx | 28 +- .../components/__tests__/field-info.spec.tsx | 2 - .../use-documents-page-state.spec.ts | 78 - .../__tests__/index.spec.tsx | 33 +- .../connector/__tests__/index.spec.tsx | 4 - .../hit-testing/__tests__/index.spec.tsx | 315 +- .../__tests__/modify-retrieval-modal.spec.tsx | 57 +- .../query-input/__tests__/index.spec.tsx | 15 +- .../datasets/list/__tests__/datasets.spec.tsx | 337 -- .../datasets/list/__tests__/header.spec.tsx | 119 - .../datasets/list/__tests__/index.spec.tsx | 536 ++-- .../dataset-card/__tests__/index.spec.tsx | 464 +-- .../__tests__/dataset-card-modals.spec.tsx | 39 - .../__tests__/operations-dropdown.spec.tsx | 71 +- .../__tests__/use-dataset-card-state.spec.ts | 77 - .../__tests__/index.spec.tsx | 54 - .../rename-modal/__tests__/index.spec.tsx | 41 +- .../__tests__/indexing-section.spec.tsx | 68 +- .../__tests__/reflow.browser.spec.tsx | 60 +- .../hooks/__tests__/use-form-state.spec.ts | 114 +- .../create-app-modal/__tests__/index.spec.tsx | 3 +- .../explore/try-app/__tests__/index.spec.tsx | 599 ++-- .../try-app/app-info/__tests__/index.spec.tsx | 287 +- .../__tests__/use-get-requirements.spec.ts | 364 --- .../try-app/app/__tests__/chat.spec.tsx | 184 -- .../try-app/app/__tests__/index.spec.tsx | 48 - .../app/__tests__/text-generation.spec.tsx | 168 - .../try-app/preview/__tests__/index.spec.tsx | 27 - .../goto-anything/__tests__/index.spec.tsx | 2026 ++++-------- .../actions/__tests__/agent.spec.tsx | 62 - .../actions/__tests__/app.spec.ts | 71 - .../actions/__tests__/index.spec.ts | 28 - .../actions/__tests__/knowledge.spec.ts | 52 - .../actions/__tests__/node-actions.spec.ts | 54 - .../actions/__tests__/plugin.spec.ts | 55 - .../actions/__tests__/skill.spec.tsx | 58 - .../components/__tests__/empty-state.spec.tsx | 35 - .../components/__tests__/footer.spec.tsx | 60 - .../__tests__/compliance.spec.tsx | 15 +- .../account-setting/__tests__/index.spec.tsx | 517 --- .../__tests__/settings-modal.spec.tsx | 393 ++- .../__tests__/access-rule-section.spec.tsx | 11 +- .../__tests__/index.spec.tsx | 45 +- .../__tests__/index.spec.tsx | 5 +- .../__tests__/index.spec.tsx | 14 +- .../__tests__/item.spec.tsx | 18 +- .../__tests__/modal.spec.tsx | 86 +- .../__tests__/card.spec.tsx | 57 +- .../__tests__/configure.spec.tsx | 30 +- .../__tests__/index.spec.tsx | 659 ++-- .../install-from-marketplace.spec.tsx | 182 -- .../__tests__/item.spec.tsx | 37 +- .../__tests__/operator.spec.tsx | 42 +- .../__tests__/plugin-actions.spec.tsx | 123 - .../use-data-source-auth-update.spec.ts | 51 - .../use-marketplace-all-plugins.spec.ts | 175 -- .../__tests__/member-row.spec.tsx | 24 - .../__tests__/index.spec.tsx | 59 +- .../__tests__/focus.browser.spec.tsx | 22 +- .../__tests__/index.non-cloud.spec.tsx | 23 +- .../__tests__/index.spec.tsx | 24 +- .../__tests__/add-custom-model.spec.tsx | 3 +- .../manage-custom-model-credentials.spec.tsx | 5 +- ...itch-credential-in-load-balancing.spec.tsx | 15 +- .../authorized/__tests__/index.spec.tsx | 54 +- .../model-modal/__tests__/Form.spec.tsx | 384 ++- .../__tests__/index.spec.tsx | 247 +- .../__tests__/parameter-item.spec.tsx | 103 +- .../model-selector/__tests__/index.spec.tsx | 31 +- .../__tests__/popup-item.spec.tsx | 427 --- .../model-selector/__tests__/popup.spec.tsx | 1828 ++++------- .../model-selector/popup.tsx | 8 +- .../__tests__/focus.browser.spec.tsx | 82 + .../__tests__/index.spec.tsx | 681 ++-- .../lazy-custom-model-actions.spec.tsx | 93 - .../load-balancing-credential-save.spec.tsx | 106 +- .../__tests__/model-list-item.spec.tsx | 60 +- .../__tests__/model-list-loading.spec.tsx | 46 + .../__tests__/model-list-test-env.tsx | 69 + .../__tests__/model-list.spec.tsx | 461 +-- .../model-load-balancing-configs.spec.tsx | 611 ++-- .../model-load-balancing-modal.spec.tsx | 404 ++- .../__tests__/provider-card-actions.spec.tsx | 981 ++---- .../__tests__/quota-panel.spec.tsx | 19 +- .../__tests__/dropdown-content.spec.tsx | 28 +- .../__tests__/index.spec.tsx | 132 +- .../role-list/__tests__/index.spec.tsx | 27 +- .../role-list/__tests__/row-menu.spec.tsx | 15 +- .../integrations/__tests__/page.spec.tsx | 1459 ++++----- .../__tests__/plugin-category-page.spec.tsx | 286 -- .../__tests__/tool-provider-list.spec.tsx | 1517 ++++----- .../main-nav/__tests__/index.spec.tsx | 311 +- .../main-nav/__tests__/layout.spec.tsx | 3 +- .../__tests__/support-menu.spec.tsx | 18 +- .../plugins/__tests__/provider-card.spec.tsx | 135 - .../plugins/card/__tests__/index.spec.tsx | 130 +- .../marketplace/__tests__/query.spec.tsx | 104 +- .../detail-dialog/__tests__/index.spec.tsx | 144 +- .../__tests__/event-ad-banner-image.spec.ts | 19 +- .../home/__tests__/home-trending.spec.tsx | 411 +-- .../list/__tests__/card-wrapper.spec.tsx | 152 +- .../__tests__/list-with-collection.spec.tsx | 255 +- .../plugins/plugin-auth/authorized/index.tsx | 1 + .../__tests__/datasource-action-list.spec.tsx | 52 - .../__tests__/index.spec.tsx | 607 ++-- .../detail-header/__tests__/index.spec.tsx | 352 +++ .../__tests__/header-modals.spec.tsx | 165 - .../__tests__/use-detail-header-state.spec.ts | 244 -- .../__tests__/use-plugin-operations.spec.ts | 410 --- .../model-selector/__tests__/index.spec.tsx | 123 +- .../__tests__/focus-restoration.spec.tsx | 95 +- .../__tests__/index.spec.tsx | 415 +-- .../__tests__/reasoning-config-form.spec.tsx | 287 +- .../components/__tests__/tool-item.spec.tsx | 3 +- .../components/reasoning-config-form.tsx | 7 +- .../trigger/__tests__/event-list.spec.tsx | 97 - .../category-marketplace-panel.spec.tsx | 357 ++- .../__tests__/plugins-picker.spec.tsx | 62 +- .../__tests__/tool-item.spec.tsx | 52 - .../__tests__/tool-picker.spec.tsx | 84 +- .../auto-update-setting/tool-picker.tsx | 8 +- .../update-plugin/downgrade-warning.tsx | 5 +- .../components/__tests__/conversion.spec.tsx | 31 +- ...blish-as-knowledge-pipeline-modal.spec.tsx | 219 +- .../__tests__/rag-pipeline-children.spec.tsx | 143 - .../__tests__/rag-pipeline-main.spec.tsx | 469 +-- .../components/panel/__tests__/index.spec.tsx | 347 +- .../panel/test-run/__tests__/header.spec.tsx | 6 +- .../panel/test-run/__tests__/index.spec.tsx | 50 - .../preparation/__tests__/index.spec.tsx | 416 ++- .../actions/__tests__/index.spec.tsx | 23 - .../__tests__/actions.spec.tsx | 93 - .../__tests__/hooks.spec.ts | 43 - .../__tests__/index.spec.tsx | 122 +- .../__tests__/options.spec.tsx | 48 - .../__tests__/run-mode.spec.tsx | 18 +- .../publisher/__tests__/index.spec.tsx | 1010 +++--- .../publisher/__tests__/popup.spec.tsx | 271 -- .../csv-download/__tests__/index.spec.tsx | 64 +- .../res-download/__tests__/index.spec.tsx | 49 +- .../snippet-list/__tests__/index.spec.tsx | 4 - .../__tests__/snippet-main.spec.tsx | 811 ++--- .../__tests__/snippet-sidebar.spec.tsx | 231 -- .../__tests__/mount.spec.tsx | 278 +- .../tools/mcp/__tests__/create-card.spec.tsx | 156 - .../tools/mcp/__tests__/index.spec.tsx | 696 ++--- .../tools/mcp/__tests__/modal.spec.tsx | 867 ++--- .../mcp/__tests__/provider-card.spec.tsx | 234 +- .../mcp/detail/__tests__/content.spec.tsx | 718 ++--- .../__tests__/use-mcp-modal-form.spec.ts | 424 +-- .../__tests__/custom-create-card.spec.tsx | 199 -- .../tools/provider/__tests__/detail.spec.tsx | 1210 +++---- .../tools/provider/__tests__/empty.spec.tsx | 4 +- .../provider/__tests__/tool-item.spec.tsx | 73 - .../__tests__/config-credentials.spec.tsx | 120 - .../__tests__/workflow-children.spec.tsx | 528 ---- .../__tests__/workflow-main.spec.tsx | 1716 +++++----- .../__tests__/workflow-panel.spec.tsx | 457 ++- .../__tests__/workflow-publishing.spec.tsx | 593 ++++ .../__tests__/features-trigger.spec.tsx | 682 ---- .../__tests__/index.spec.tsx | 28 - .../__tests__/use-auto-onboarding.spec.ts | 34 +- .../use-workflow-run-callbacks.spec.ts | 453 --- .../__tests__/use-workflow-run-utils.spec.ts | 554 ---- .../hooks/__tests__/use-workflow-run.spec.ts | 647 ---- .../hooks/__tests__/use-workflow-run.spec.tsx | 620 ++++ .../hooks/use-workflow-run-utils.ts | 2 +- .../__tests__/candidate-node-main.spec.tsx | 438 +-- .../components/workflow/__tests__/fixtures.ts | 9 - .../__tests__/panel-contextmenu.spec.tsx | 391 +-- .../__tests__/selection-contextmenu.spec.tsx | 42 +- .../__tests__/service-mock-factory.ts | 35 +- .../__tests__/workflow-edge-events.spec.tsx | 1293 +++----- .../__tests__/all-start-blocks.spec.tsx | 8 - .../__tests__/featured-tools.spec.tsx | 16 - .../__tests__/featured-triggers.spec.tsx | 9 - .../__tests__/tool-browser.spec.tsx | 236 +- .../__tests__/tool-picker.spec.tsx | 473 ++- .../block-selector/__tests__/tools.spec.tsx | 8 - .../__tests__/action.spec.tsx | 3 +- .../__tests__/list.spec.tsx | 16 - .../tool/__tests__/action-item.spec.tsx | 15 - .../tool/__tests__/tool.spec.tsx | 15 - .../__tests__/list.spec.tsx | 19 +- ...laboration-manager.logs-and-events.spec.ts | 684 ++-- ...llaboration-manager.merge-behavior.test.ts | 407 +-- ...laboration-manager.runtime-loading.spec.ts | 247 +- ...n-manager.socket-and-subscriptions.spec.ts | 2003 +++--------- .../__tests__/collaboration-manager.test.ts | 821 +---- .../core/__tests__/crdt-provider.test.ts | 100 +- .../__tests__/test-collaboration-session.ts | 97 + .../core/__tests__/test-crdt-runtime.ts | 7 - .../core/__tests__/test-graph-fixtures.ts | 59 + .../core/__tests__/test-socket.ts | 51 + .../core/__tests__/websocket-manager.test.ts | 224 +- .../workflow/comment/thread.spec.tsx | 27 +- .../__tests__/chat-variable-button.spec.tsx | 6 - .../header/__tests__/env-button.spec.tsx | 6 - .../__tests__/global-variable-button.spec.tsx | 6 - .../__tests__/header-in-restoring.spec.tsx | 145 - .../header/__tests__/header-layouts.spec.tsx | 348 +-- .../header/__tests__/test-run-menu.spec.tsx | 3 +- .../__tests__/version-history-button.spec.tsx | 6 - .../header/__tests__/view-history.spec.tsx | 315 +- .../selection-interactions.browser.spec.tsx | 197 ++ .../hooks/__tests__/use-checklist.spec.ts | 817 ----- .../hooks/__tests__/use-checklist.spec.tsx | 683 ++++ .../use-node-keyboard-interactions.spec.tsx | 397 +-- .../__tests__/use-nodes-interactions.spec.ts | 1765 +++-------- .../use-selection-interactions.spec.ts | 226 -- .../__tests__/use-workflow-variables.spec.ts | 28 +- .../__tests__/details.spec.tsx | 70 +- .../_base/__tests__/node-sections.spec.tsx | 32 - .../_base/__tests__/node.helpers.spec.ts | 43 - .../nodes/_base/__tests__/node.spec.tsx | 520 ++- .../use-node-resize-observer.spec.tsx | 68 - .../__tests__/file-support.spec.tsx | 7 +- .../__tests__/node-control.spec.tsx | 6 +- .../__tests__/node-focus.browser.spec.tsx | 22 +- .../components/__tests__/node-handle.spec.tsx | 6 +- .../before-run-form/__tests__/index.spec.tsx | 29 +- .../components/branch-list/class-item.tsx | 62 +- .../components/branch-list/class-list.tsx | 6 +- .../code-editor/__tests__/index.spec.tsx | 4 - .../var-reference-picker.branches.spec.tsx | 291 -- .../__tests__/var-reference-picker.spec.tsx | 370 ++- .../var-reference-picker.trigger.spec.tsx | 92 - .../workflow-panel/__tests__/index.spec.tsx | 1413 ++++----- .../last-run/__tests__/index.spec.tsx | 5 +- .../last-run/__tests__/use-last-run.spec.ts | 88 - .../hooks/__tests__/use-one-step-run.spec.ts | 143 +- .../nodes/agent-v2/__tests__/hooks.spec.tsx | 246 +- .../__tests__/panel-bindings.spec.tsx | 437 +++ .../agent-v2/__tests__/panel-test-env.tsx | 89 + .../agent-v2/__tests__/panel.browser.spec.tsx | 95 + .../nodes/agent-v2/__tests__/panel.spec.tsx | 1932 +----------- .../agent-orchestrate-panel-content.spec.tsx | 2106 ++++--------- .../agent-output-routes-history.spec.tsx | 157 - .../__tests__/agent-output-routes.spec.tsx | 427 +-- .../__tests__/agent-roster-field.spec.tsx | 22 +- .../assigner/__tests__/integration.spec.tsx | 3 +- .../nodes/code/__tests__/panel.spec.tsx | 5 +- .../__tests__/panel.spec.tsx | 3 +- .../human-input/__tests__/panel.spec.tsx | 3 +- .../__tests__/form-content.browser.spec.tsx | 85 + .../__tests__/form-content.spec.tsx | 162 +- .../__tests__/email-configure-modal.spec.tsx | 222 +- .../__tests__/mail-body-input.spec.tsx | 65 +- .../__tests__/method-item.spec.tsx | 30 +- .../delivery-method/email-configure-modal.tsx | 7 +- .../delivery-method/mail-body-input.tsx | 3 + .../__tests__/use-interactions.spec.tsx | 39 +- .../knowledge-base/__tests__/panel.spec.tsx | 497 ++- .../__tests__/search-method-option.spec.tsx | 49 +- .../hooks/__tests__/use-config.spec.tsx | 529 ---- .../__tests__/integration.spec.tsx | 542 ++-- .../__tests__/panel.spec.tsx | 810 +++-- .../__tests__/use-config.spec.ts | 531 ---- .../condition-list/condition-item.tsx | 9 +- .../condition-variable-selector.tsx | 2 +- .../use-knowledge-dataset-selection.spec.ts | 284 -- .../use-knowledge-metadata-config.spec.ts | 293 -- .../use-knowledge-model-config.spec.ts | 238 -- .../__tests__/integration.spec.tsx | 3 +- .../__tests__/extract-input.spec.tsx | 61 +- .../nodes/llm/__tests__/panel.spec.tsx | 662 ++-- .../__tests__/panel-output-section.spec.tsx | 5 +- .../__tests__/panel.spec.tsx | 223 +- .../__tests__/integration.spec.tsx | 129 +- .../__tests__/node.spec.tsx | 64 +- .../components/__tests__/class-item.spec.tsx | 35 + .../__tests__/panel.spec.tsx | 3 +- .../nodes/tool/__tests__/panel.spec.tsx | 15 +- .../__tests__/index.spec.tsx | 177 +- .../trigger-schedule/__tests__/panel.spec.tsx | 3 +- .../__tests__/generic-table.spec.tsx | 3 +- .../__tests__/node-group-item.spec.tsx | 36 +- .../note-node/__tests__/index.spec.tsx | 3 +- .../workflow/panel/__tests__/index.spec.tsx | 278 -- .../panel-width-integration.spec.tsx | 190 +- .../panel/__tests__/workflow-preview.spec.tsx | 559 ++-- .../__tests__/variable-modal.spec.tsx | 3 +- .../__tests__/chat-wrapper.spec.tsx | 6 +- .../__tests__/components.spec.tsx | 3 +- .../panel/env-panel/__tests__/index.spec.tsx | 351 ++- .../__tests__/variable-modal.spec.tsx | 191 +- .../__tests__/index.spec.tsx | 40 - .../__tests__/index.spec.tsx | 12 +- .../__tests__/hooks.spec.tsx | 145 +- .../run/__tests__/status-container.spec.tsx | 2 - .../workflow/run/__tests__/status.spec.tsx | 3 +- .../workflow-canvas.browser.spec.tsx | 20 +- .../variable-inspect/__tests__/group.spec.tsx | 21 +- .../variable-inspect/__tests__/panel.spec.tsx | 224 +- .../__tests__/value-content-sections.spec.tsx | 3 +- .../__tests__/value-content.spec.tsx | 6 +- .../__tests__/index.spec.tsx | 54 +- .../device/__tests__/page-terminal.spec.tsx | 3 +- .../__tests__/mail-and-code-auth.spec.tsx | 5 - .../__tests__/post-login-redirect.spec.ts | 54 +- .../signup/check-code/__tests__/page.spec.tsx | 4 - .../agent-detail/__tests__/layout.spec.tsx | 7 +- .../access-config/__tests__/page.spec.tsx | 37 +- .../__tests__/configure-test-env.tsx | 358 +++ .../__tests__/page-build-session.spec.tsx | 326 ++ .../__tests__/page-draft-actions.spec.tsx | 170 + .../configure/__tests__/page.spec.tsx | 2782 ++--------------- .../use-agent-configure-sync.spec.tsx | 21 +- .../agent-prompt-editor-test-env.tsx | 117 + .../agent-prompt-editor.browser.spec.tsx | 30 + .../__tests__/agent-prompt-editor.spec.tsx | 1488 ++------- .../__tests__/publish-bar.spec.tsx | 48 +- .../files/__tests__/index.spec.tsx | 14 +- .../prompt-editor/__tests__/slash.spec.tsx | 40 + .../skills/__tests__/index.spec.tsx | 79 +- .../preview/__tests__/chat-config.spec.ts | 25 + .../__tests__/chat-features-panel.spec.tsx | 25 +- .../preview/__tests__/chat.spec.tsx | 1899 ++++------- .../preview/__tests__/versions-panel.spec.tsx | 11 +- .../monitoring/__tests__/chart.spec.tsx | 5 - .../monitoring/__tests__/page.spec.tsx | 5 - .../__tests__/agent-roster-list.spec.tsx | 6 +- .../home/__tests__/template-card.spec.tsx | 6 +- .../__tests__/home-content.spec.tsx | 744 ++--- .../__tests__/detail-page-editor.spec.tsx | 6 +- .../__tests__/detail-page-navigation.spec.tsx | 10 +- web/features/skills/__tests__/page.spec.tsx | 693 ++-- .../detail/__tests__/publish-bar.spec.tsx | 7 +- web/features/skills/page.tsx | 8 +- .../system-features/__tests__/server.spec.ts | 28 +- web/hooks/use-import-dsl.spec.tsx | 193 +- web/next/navigation.ts | 2 +- web/test/console/model-provider.ts | 103 +- web/test/console/operation-hooks.ts | 45 - web/test/download.ts | 21 + web/test/fixtures/app.ts | 13 + web/test/fixtures/chat.ts | 58 + web/test/fixtures/mcp.ts | 21 + web/test/fixtures/onboarding.ts | 92 + web/test/fixtures/plugin.ts | 40 +- web/test/intersection-observer.ts | 60 + web/test/workflow-editor.tsx | 27 + web/types/app.ts | 2 +- 516 files changed, 39267 insertions(+), 74639 deletions(-) create mode 100644 web/__tests__/access-control/resource-pages.spec.tsx delete mode 100644 web/app/components/app/access-config/__tests__/index.spec.tsx delete mode 100644 web/app/components/app/app-publisher/__tests__/publisher-panel.spec.tsx create mode 100644 web/app/components/app/configuration/__tests__/configuration-flow.spec.tsx delete mode 100644 web/app/components/app/configuration/__tests__/configuration-view.spec.tsx delete mode 100644 web/app/components/app/configuration/config/__tests__/index.spec.tsx delete mode 100644 web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/chat-item.spec.tsx delete mode 100644 web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx delete mode 100644 web/app/components/custom/custom-web-app-brand/components/__tests__/powered-by-brand.spec.tsx delete mode 100644 web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx delete mode 100644 web/app/components/datasets/access-config/__tests__/index.spec.tsx delete mode 100644 web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx delete mode 100644 web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx delete mode 100644 web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx delete mode 100644 web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx delete mode 100644 web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts delete mode 100644 web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx delete mode 100644 web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx delete mode 100644 web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx delete mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts delete mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts delete mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts delete mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts delete mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts delete mode 100644 web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx delete mode 100644 web/app/components/datasets/documents/components/__tests__/empty-element.spec.tsx delete mode 100644 web/app/components/datasets/documents/components/__tests__/list.spec.tsx delete mode 100644 web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts delete mode 100644 web/app/components/datasets/list/__tests__/datasets.spec.tsx delete mode 100644 web/app/components/datasets/list/__tests__/header.spec.tsx delete mode 100644 web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx delete mode 100644 web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts delete mode 100644 web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx delete mode 100644 web/app/components/explore/try-app/app-info/__tests__/use-get-requirements.spec.ts delete mode 100644 web/app/components/explore/try-app/app/__tests__/chat.spec.tsx delete mode 100644 web/app/components/explore/try-app/app/__tests__/index.spec.tsx delete mode 100644 web/app/components/explore/try-app/app/__tests__/text-generation.spec.tsx delete mode 100644 web/app/components/explore/try-app/preview/__tests__/index.spec.tsx delete mode 100644 web/app/components/goto-anything/actions/__tests__/agent.spec.tsx delete mode 100644 web/app/components/goto-anything/actions/__tests__/app.spec.ts delete mode 100644 web/app/components/goto-anything/actions/__tests__/knowledge.spec.ts delete mode 100644 web/app/components/goto-anything/actions/__tests__/node-actions.spec.ts delete mode 100644 web/app/components/goto-anything/actions/__tests__/plugin.spec.ts delete mode 100644 web/app/components/goto-anything/actions/__tests__/skill.spec.tsx delete mode 100644 web/app/components/goto-anything/components/__tests__/empty-state.spec.tsx delete mode 100644 web/app/components/goto-anything/components/__tests__/footer.spec.tsx delete mode 100644 web/app/components/header/account-setting/__tests__/index.spec.tsx delete mode 100644 web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx delete mode 100644 web/app/components/header/account-setting/data-source-page-new/__tests__/plugin-actions.spec.tsx delete mode 100644 web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-data-source-auth-update.spec.ts delete mode 100644 web/app/components/header/account-setting/data-source-page-new/hooks/__tests__/use-marketplace-all-plugins.spec.ts delete mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/focus.browser.spec.tsx delete mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/lazy-custom-model-actions.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-loading.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-test-env.tsx delete mode 100644 web/app/components/integrations/__tests__/plugin-category-page.spec.tsx delete mode 100644 web/app/components/plugins/__tests__/provider-card.spec.tsx delete mode 100644 web/app/components/plugins/plugin-detail-panel/__tests__/datasource-action-list.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx delete mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/header-modals.spec.tsx delete mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts delete mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-plugin-operations.spec.ts delete mode 100644 web/app/components/plugins/plugin-detail-panel/trigger/__tests__/event-list.spec.tsx delete mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/panel/test-run/__tests__/index.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/actions/__tests__/index.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts delete mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx delete mode 100644 web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx delete mode 100644 web/app/components/snippets/components/__tests__/snippet-sidebar.spec.tsx delete mode 100644 web/app/components/tools/mcp/__tests__/create-card.spec.tsx delete mode 100644 web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx delete mode 100644 web/app/components/tools/provider/__tests__/tool-item.spec.tsx delete mode 100644 web/app/components/tools/setting/build-in/__tests__/config-credentials.spec.tsx delete mode 100644 web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx create mode 100644 web/app/components/workflow-app/components/__tests__/workflow-publishing.spec.tsx delete mode 100644 web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx delete mode 100644 web/app/components/workflow-app/components/workflow-onboarding-modal/__tests__/index.spec.tsx delete mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-run-callbacks.spec.ts delete mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-run-utils.spec.ts delete mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts create mode 100644 web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.tsx create mode 100644 web/app/components/workflow/collaboration/core/__tests__/test-collaboration-session.ts delete mode 100644 web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts create mode 100644 web/app/components/workflow/collaboration/core/__tests__/test-graph-fixtures.ts create mode 100644 web/app/components/workflow/collaboration/core/__tests__/test-socket.ts delete mode 100644 web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx create mode 100644 web/app/components/workflow/hooks/__tests__/selection-interactions.browser.spec.tsx delete mode 100644 web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts create mode 100644 web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx delete mode 100644 web/app/components/workflow/hooks/__tests__/use-selection-interactions.spec.ts delete mode 100644 web/app/components/workflow/nodes/_base/__tests__/node-sections.spec.tsx delete mode 100644 web/app/components/workflow/nodes/_base/__tests__/node.helpers.spec.ts delete mode 100644 web/app/components/workflow/nodes/_base/__tests__/use-node-resize-observer.spec.tsx delete mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx delete mode 100644 web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.trigger.spec.tsx delete mode 100644 web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts create mode 100644 web/app/components/workflow/nodes/agent-v2/__tests__/panel-bindings.spec.tsx create mode 100644 web/app/components/workflow/nodes/agent-v2/__tests__/panel-test-env.tsx create mode 100644 web/app/components/workflow/nodes/agent-v2/__tests__/panel.browser.spec.tsx delete mode 100644 web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-output-routes-history.spec.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/__tests__/form-content.browser.spec.tsx delete mode 100644 web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx delete mode 100644 web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts delete mode 100644 web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-dataset-selection.spec.ts delete mode 100644 web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-metadata-config.spec.ts delete mode 100644 web/app/components/workflow/nodes/knowledge-retrieval/hooks/__tests__/use-knowledge-model-config.spec.ts delete mode 100644 web/app/components/workflow/panel/__tests__/index.spec.tsx delete mode 100644 web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/__tests__/configure-test-env.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/__tests__/page-build-session.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/__tests__/page-draft-actions.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor-test-env.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/__tests__/agent-prompt-editor.browser.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-config.spec.ts create mode 100644 web/test/download.ts create mode 100644 web/test/fixtures/chat.ts create mode 100644 web/test/fixtures/mcp.ts create mode 100644 web/test/fixtures/onboarding.ts create mode 100644 web/test/intersection-observer.ts create mode 100644 web/test/workflow-editor.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 0d8d0c1978d..c73b4e4ca08 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -85,13 +85,13 @@ "count": 1 }, "jsx-a11y/click-events-have-key-events": { - "count": 3 + "count": 1 }, "jsx-a11y/no-autofocus": { "count": 1 }, "jsx-a11y/no-static-element-interactions": { - "count": 3 + "count": 1 }, "react/only-export-components": { "count": 1 @@ -435,11 +435,6 @@ "count": 1 } }, - "web/app/components/base/chat/chat/answer/agent-content.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/chat/chat/answer/workflow-process.tsx": { "eslint-react/set-state-in-effect": { "count": 1 @@ -637,11 +632,6 @@ "count": 15 } }, - "web/app/components/base/image-uploader/__tests__/utils.spec.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/image-uploader/image-preview.tsx": { "jsx-a11y/no-static-element-interactions": { "count": 1 @@ -995,11 +985,6 @@ "count": 1 } }, - "web/app/components/datasets/create/step-two/__tests__/index.spec.tsx": { - "dify/require-disable-directive-description": { - "count": 1 - } - }, "web/app/components/datasets/create/step-two/components/index.ts": { "no-barrel-files/no-barrel-files": { "count": 5 @@ -1570,14 +1555,6 @@ "count": 1 } }, - "web/app/components/plugins/reference-setting-modal/auto-update-setting/tool-picker.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/plugins/types.ts": { "typescript/no-explicit-any": { "count": 8 @@ -1807,11 +1784,6 @@ "count": 4 } }, - "web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx": { - "eslint-react/static-components": { - "count": 2 - } - }, "web/app/components/workflow-app/components/workflow-children.tsx": { "typescript/no-explicit-any": { "count": 2 @@ -1933,11 +1905,6 @@ "count": 6 } }, - "web/app/components/workflow/hooks/__tests__/use-checklist.spec.ts": { - "eslint-react/error-boundaries": { - "count": 1 - } - }, "web/app/components/workflow/hooks/use-checklist.ts": { "typescript/no-empty-object-type": { "count": 1 @@ -2716,11 +2683,6 @@ "count": 1 } }, - "web/app/components/workflow/panel/__tests__/index.spec.tsx": { - "eslint-react/static-components": { - "count": 2 - } - }, "web/app/components/workflow/panel/chat-record/index.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 diff --git a/web/__tests__/access-control/resource-pages.spec.tsx b/web/__tests__/access-control/resource-pages.spec.tsx new file mode 100644 index 00000000000..dcfb2bcbe23 --- /dev/null +++ b/web/__tests__/access-control/resource-pages.spec.tsx @@ -0,0 +1,402 @@ +import type { + AccessPolicy, + ResourceUserAccessPolicies, +} from '@dify/contracts/api/console/workspaces/types.gen' +import { act, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AppAccessConfigPage from '@/app/components/app/access-config' +import ErrorBoundary from '@/app/components/base/error-boundary' +import DatasetAccessConfigPage from '@/app/components/datasets/access-config' +import DatasetDetailContext from '@/context/dataset-detail' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { createAppDetailFixture } from '@/test/fixtures/app' +import { createDatasetFixture } from '@/test/fixtures/dataset' +import { AppACLPermission, DatasetACLPermission } from '@/utils/permission' + +const service = vi.hoisted(() => ({ + rules: vi.fn(), + members: vi.fn(), + whitelist: vi.fn(), + config: vi.fn(), + candidates: vi.fn(), + updateConfig: vi.fn(), + updatePolicies: vi.fn(), + remove: vi.fn(), +})) + +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses, withConsoleClientResponses } = + await import('@/test/console/service-responses') + const queries = Object.fromEntries( + ['apps.byAppId', 'datasets.byDatasetId'].flatMap((resource) => { + const path = `workspaces.current.rbac.${resource}` + return [ + [`${path}.accessPolicy.get`, service.rules], + [`${path}.userAccessPolicies.get`, service.members], + [`${path}.whitelist.get`, service.whitelist], + [`${path}.whitelistConfig.get`, service.config], + ] + }), + ) + const mutations = Object.fromEntries( + ['apps.byAppId', 'datasets.byDatasetId'].flatMap((resource) => { + const path = `workspaces.current.rbac.${resource}` + return [ + [`${path}.whitelist.put`, service.updateConfig], + [`${path}.users.byTargetAccountId.accessPolicies.put`, service.updatePolicies], + [`${path}.accessPolicies.byPolicyId.memberBindings.delete`, service.remove], + ] + }), + ) + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + ...queries, + 'workspaces.current.members.get': service.candidates, + }), + consoleClient: withConsoleClientResponses(actual.consoleClient, mutations), + } +}) + +const member = (index: number): ResourceUserAccessPolicies => ({ + account: { + account_id: `account-${index}`, + account_name: `Member ${index}`, + email: `member${index}@example.com`, + }, + roles: [], + access_policies: [], +}) +const tableName = 'permission.accessRule.allowedMembers' +const switchName = 'permission.accessRule.automaticallyIncludeWorkspaceMembers' + +// Both resource pages own the same pagination/mutation contract. Exercise each real page +// and editor against request responses, including invalidation and server pagination. +describe.each(['app', 'dataset'] as const)('%s resource access page', (resource) => { + let members: ResourceUserAccessPolicies[] + let automaticInclusion: boolean + let policy: AccessPolicy + const resourceId = `${resource}-1` + const params = { [`${resource}_id`]: resourceId } + + const renderPage = ({ + allowed = true, + rbacEnabled = true, + mode = 'workflow' as 'workflow' | 'agent', + maintainerAccess = false, + } = {}) => { + const queryClient = createConsoleQueryClient() + const appDetail = createAppDetailFixture({ + id: 'app-1', + mode, + maintainer: 'account-1', + permission_keys: allowed && !maintainerAccess ? [AppACLPermission.AccessConfig] : [], + }) + queryClient.setQueryData( + consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }), + appDetail, + ) + const result = renderWithConsoleQuery( + + {resource === 'app' ? ( + + ) : ( + + )} + , + { + queryClient, + systemFeatures: { rbac_enabled: rbacEnabled }, + accountProfile: { id: maintainerAccess ? 'account-1' : 'user-1' }, + workspacePermissionKeys: maintainerAccess ? ['app.create_and_management'] : [], + }, + ) + return { ...result, appDetail } + } + + const expectPage = async (page: number, limit = 10) => { + await waitFor(() => + expect(service.members).toHaveBeenLastCalledWith({ + params, + query: expect.objectContaining({ page, limit, reverse: false }), + }), + ) + await screen.findByRole('checkbox', { name: `Member ${(page - 1) * limit + 1}` }) + } + + beforeEach(() => { + vi.resetAllMocks() + members = Array.from({ length: 21 }, (_, index) => member(index + 1)) + automaticInclusion = false + policy = { id: 'policy-1', name: 'Manage', resource_type: resource } + service.rules.mockImplementation(async () => ({ items: [{ policy, roles: [], accounts: [] }] })) + service.members.mockImplementation(async ({ query: { page, limit } }) => ({ + data: members.slice((page - 1) * limit, page * limit), + pagination: { + current_page: page, + per_page: limit, + total_count: members.length, + total_pages: Math.ceil(members.length / limit), + }, + })) + // The maintainer may be implicitly included and absent from the whitelist. + service.whitelist.mockImplementation(async () => ({ + account_ids: members.slice(1).map((item) => item.account.account_id), + })) + service.config.mockImplementation(async () => ({ + automatic_include_workspace_members: automaticInclusion, + })) + service.candidates.mockResolvedValue({ + accounts: Array.from({ length: 22 }, (_, index) => ({ + id: `account-${index + 1}`, + name: `Member ${index + 1}`, + email: `member${index + 1}@example.com`, + avatar: '', + avatar_url: '', + status: 'active', + role: 'normal', + roles: [], + })), + }) + service.updateConfig.mockImplementation(async ({ body }) => { + automaticInclusion = body.automatic_include_workspace_members + return {} + }) + service.updatePolicies.mockImplementation(async ({ params: inputParams, body }) => { + const id = inputParams.target_account_id + let setting = members.find((item) => item.account.account_id === id) + if (!setting) { + setting = member(Number(id.split('-')[1])) + members.push(setting) + } + members = members.map((item) => + item.account.account_id === id + ? { ...item, access_policies: body.access_policy_ids.includes(policy.id) ? [policy] : [] } + : item, + ) + return {} + }) + service.remove.mockImplementation(async ({ body }) => { + members = members.filter((item) => !body.account_ids.includes(item.account.account_id)) + return {} + }) + }) + + it('shows the server page and keeps the maintainer protected', async () => { + automaticInclusion = true + renderPage() + await expectPage(1) + expect( + screen.getByRole('heading', { name: 'navigation.settings.resourceAccess' }), + ).toBeVisible() + expect(screen.getByRole('switch', { name: switchName })).toBeChecked() + const row = screen.getByRole('row', { name: /Member 1\b/ }) + expect(within(row).getByText('permission.accessRule.maintainer')).toBeVisible() + expect(within(row).getByRole('button', { name: 'common.operation.remove' })).toBeDisabled() + expect(screen.queryByRole('checkbox', { name: 'Member 11' })).not.toBeInTheDocument() + }) + + it('requests another page and resets to the first page when page size changes', async () => { + const user = userEvent.setup() + renderPage() + await expectPage(1) + await user.click(screen.getByRole('button', { name: 'common.pagination.next' })) + await expectPage(2) + await user.click(screen.getByRole('radio', { name: '25' })) + await expectPage(1, 25) + expect(screen.getByRole('radio', { name: '25' })).toBeChecked() + expect(screen.getByRole('checkbox', { name: 'Member 21' })).toBeInTheDocument() + }) + + it('persists automatic inclusion and returns to the first page when it is disabled', async () => { + const user = userEvent.setup() + automaticInclusion = true + renderPage() + await expectPage(1) + await user.click(screen.getByRole('button', { name: 'common.pagination.next' })) + await expectPage(2) + await user.click(screen.getByRole('switch', { name: switchName })) + await waitFor(() => + expect(service.updateConfig).toHaveBeenCalledWith({ + params, + body: { automatic_include_workspace_members: false }, + }), + ) + await expectPage(1) + expect(screen.getByRole('switch', { name: switchName })).not.toBeChecked() + }) + + it('adds a candidate and displays the new last page using the server total', async () => { + const user = userEvent.setup() + members = members.slice(0, 10) + renderPage() + await expectPage(1) + await user.click(screen.getByRole('button', { name: 'common.operation.add' })) + const dialog = await screen.findByRole('dialog', { + name: 'permission.accessRule.addMembersTitle', + }) + expect( + await within(dialog).findByRole('menuitem', { name: /Member 1 member1@/ }), + ).toHaveAttribute('aria-disabled', 'true') + await user.click(within(dialog).getByRole('menuitem', { name: /Member 11 member11@/ })) + await waitFor(() => + expect(service.updatePolicies).toHaveBeenCalledWith({ + params: { ...params, target_account_id: 'account-11' }, + body: { access_policy_ids: ['default'] }, + }), + ) + await expectPage(2) + }) + + it.each(['single', 'batch'] as const)( + 'returns to the previous page after %s removal empties the last page', + async (removal) => { + const user = userEvent.setup() + members = members.slice(0, removal === 'single' ? 11 : 12) + renderPage() + await expectPage(1) + await user.click(screen.getByRole('button', { name: 'common.pagination.next' })) + await expectPage(2) + if (removal === 'single') { + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + } else { + await user.click(screen.getByRole('checkbox', { name: 'common.operation.selectAll' })) + await user.click(screen.getByRole('button', { name: 'common.operation.delete' })) + await user.click( + within(screen.getByRole('alertdialog')).getByRole('button', { + name: 'common.operation.sure', + }), + ) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + } + await waitFor(() => + expect(service.remove).toHaveBeenCalledWith({ + params: { ...params, policy_id: 'default' }, + body: { + account_ids: removal === 'single' ? ['account-11'] : ['account-11', 'account-12'], + }, + }), + ) + await expectPage(1) + expect(screen.queryByRole('checkbox', { name: 'Member 11' })).not.toBeInTheDocument() + }, + ) + + it('blocks membership changes during automatic inclusion while still saving a policy change', async () => { + const user = userEvent.setup() + automaticInclusion = true + renderPage() + await expectPage(1) + expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled() + const row = screen.getByRole('row', { name: /Member 2 member2@/ }) + expect(within(row).getByRole('button', { name: 'common.operation.remove' })).toBeDisabled() + expect(within(row).getByRole('checkbox')).toHaveAttribute('aria-disabled', 'true') + await user.click(within(row).getByLabelText(/permission\.accessRule\.exceptionPermissionFor/)) + await user.click(await screen.findByRole('option', { name: 'Manage' })) + await waitFor(() => + expect(service.updatePolicies).toHaveBeenCalledWith({ + params: { ...params, target_account_id: 'account-2' }, + body: { access_policy_ids: ['policy-1'] }, + }), + ) + await waitFor(() => + expect( + within(row).getByLabelText(/permission\.accessRule\.exceptionPermissionFor/), + ).toHaveTextContent('Manage'), + ) + expect(service.remove).not.toHaveBeenCalled() + }) + + it('keeps the inclusion switch checked while the separate candidate whitelist is loading', async () => { + automaticInclusion = true + service.whitelist.mockImplementation(() => new Promise(() => {})) + renderPage() + await expectPage(1) + expect(screen.getByRole('switch', { name: switchName })).toBeChecked() + }) + + it.each([{ allowed: false }, { rbacEnabled: false }])( + 'does not fetch access data when access is unavailable: %j', + async (options) => { + renderPage(options) + await act(async () => {}) + expect(screen.queryByRole('table', { name: tableName })).not.toBeInTheDocument() + expect(service.rules).not.toHaveBeenCalled() + expect(service.members).not.toHaveBeenCalled() + expect(service.whitelist).not.toHaveBeenCalled() + expect(service.config).not.toHaveBeenCalled() + }, + ) + + if (resource === 'app') { + it('does not fetch access data for Agent apps', async () => { + renderPage({ mode: 'agent' }) + await act(async () => {}) + expect(screen.queryByRole('table', { name: tableName })).not.toBeInTheDocument() + expect(service.rules).not.toHaveBeenCalled() + expect(service.members).not.toHaveBeenCalled() + }) + + it('allows the maintainer with workspace app-management permission', async () => { + renderPage({ maintainerAccess: true }) + await expectPage(1) + expect(screen.getByRole('table', { name: tableName })).toBeVisible() + }) + + it('preserves the page across metadata refresh and removes access after permission revocation', async () => { + const user = userEvent.setup() + const { queryClient, appDetail } = renderPage() + const key = consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }) + await expectPage(1) + await user.click(screen.getByRole('button', { name: 'common.pagination.next' })) + await expectPage(2) + act(() => queryClient.setQueryData(key, { ...appDetail, maintainer: 'account-11' })) + const row = screen.getByRole('row', { name: /Member 11 member11@/ }) + await waitFor(() => + expect(within(row).getByText('permission.accessRule.maintainer')).toBeVisible(), + ) + expect(within(row).getByRole('button', { name: 'common.operation.remove' })).toBeDisabled() + act(() => queryClient.setQueryData(key, { ...appDetail, permission_keys: [] })) + await waitFor(() => + expect(screen.queryByRole('table', { name: tableName })).not.toBeInTheDocument(), + ) + }) + + it('surfaces an initial app-detail failure before requesting access data', async () => { + const queryClient = createConsoleQueryClient() + const queryKey = consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: 'unavailable-app' } }, + }) + queryClient.setQueryDefaults(queryKey, { retryOnMount: false }) + await queryClient + .query({ queryKey, queryFn: () => Promise.reject(new Error('Detail unavailable')) }) + .catch(() => {}) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + renderWithConsoleQuery( + + + , + { queryClient }, + ) + expect( + await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }), + ).toBeVisible() + expect(service.rules).not.toHaveBeenCalled() + expect(service.members).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + } +}) diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx index 42a82d9db76..2fb808f1812 100644 --- a/web/__tests__/base/chat-flow.test.tsx +++ b/web/__tests__/base/chat-flow.test.tsx @@ -1,7 +1,7 @@ import type { RefObject } from 'react' import type { ChatConfig } from '@/app/components/base/chat/types' import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' -import { fireEvent, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' import ChatWithHistory from '@/app/components/base/chat/chat-with-history' import { useChatWithHistoryContext } from '@/app/components/base/chat/chat-with-history/context' @@ -122,30 +122,9 @@ describe('Base Chat Flow', () => { // Chat-with-history shell integration across layout, responsive shell, and theme setup. describe('Chat With History Shell', () => { - it('updates the document title and expands the collapsed desktop sidebar on hover', () => { - const { rerender, container } = render() - - const titles = screen.getAllByText('Test Chat') - expect(titles.length).toBeGreaterThan(0) + it('uses the site title for the document', () => { + render() expect(document.title).toBe('Test Chat - Dify') - - vi.mocked(useChatWithHistory).mockReturnValue({ - ...defaultHookReturn, - sidebarCollapseState: true, - }) - - rerender() - const hoverArea = container.querySelector('.absolute.top-0.z-20') - - expect(hoverArea).toBeInTheDocument() - - if (hoverArea) { - fireEvent.mouseEnter(hoverArea) - expect(hoverArea).toHaveClass('left-0') - - fireEvent.mouseLeave(hoverArea) - expect(hoverArea).toHaveClass('-left-62') - } }) it('renders a new theme when site configuration changes', () => { diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 31d46a6e178..6ef3b57cc2d 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -66,11 +66,6 @@ vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', })) -vi.mock('#i18n', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: () => 'en-US', -})) - const mockRouterPush = vi.fn() vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush }), diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx index 1ed01f4a213..6d934408b2a 100644 --- a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -88,13 +88,12 @@ describe('Cloud plan payment flow', () => { ] as const)( 'displays $plan pricing and redirects to its $interval checkout', async ({ plan, interval, label }) => { + const user = userEvent.setup() renderPlan({ plan, billingInterval: interval }) const price = ALL_PLANS[plan].price expect(screen.getByText(`$${price * (interval === 'year' ? 10 : 1)}`)).toBeInTheDocument() if (interval === 'year') expect(screen.getByText(`$${price * 12}`)).toBeInTheDocument() - await userEvent - .setup() - .click(screen.getByRole('button', { name: `billing.plansCommon.${label}` })) + await user.click(screen.getByRole('button', { name: `billing.plansCommon.${label}` })) await waitFor(() => expect(window.location.href).toBe('https://pay.example.com/checkout')) expect(mockGetSubscription).toHaveBeenCalledExactlyOnceWith({ query: { plan, interval } }) expect(mockGetInvoices).not.toHaveBeenCalled() @@ -109,10 +108,11 @@ describe('Cloud plan payment flow', () => { ] as const)( 'prevents purchasing $plan from $currentPlan', async ({ currentPlan, plan, label }) => { + const user = userEvent.setup() renderPlan({ currentPlan, plan }) const button = screen.getByRole('button', { name: `billing.plansCommon.${label}` }) expect(button).toBeDisabled() - await userEvent.setup().click(button) + await user.click(button) expect(mockGetSubscription).not.toHaveBeenCalled() expect(mockGetInvoices).not.toHaveBeenCalled() expect(window.open).not.toHaveBeenCalled() @@ -120,12 +120,11 @@ describe('Cloud plan payment flow', () => { ) it('opens the billing portal for the current paid subscription after resolving its URL', async () => { + const user = userEvent.setup() const request = createDeferred<{ url: string }>() mockGetInvoices.mockReturnValue(request.promise) renderPlan({ currentPlan: 'professional', plan: 'professional' }) - await userEvent - .setup() - .click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })) + await user.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })) expect(window.open).toHaveBeenCalledWith('about:blank', '_blank', undefined) expect(popup.opener).toBeNull() expect(popup.location.href).toBe('') @@ -136,11 +135,10 @@ describe('Cloud plan payment flow', () => { }) it('closes the pending portal and reports its URL request failure', async () => { + const user = userEvent.setup() mockGetInvoices.mockRejectedValueOnce(new Error('Billing unavailable')) renderPlan({ currentPlan: 'professional', plan: 'professional' }) - await userEvent - .setup() - .click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })) + await user.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })) expect(await screen.findByText('Billing unavailable')).toBeInTheDocument() expect(popup.close).toHaveBeenCalledOnce() expect(popup.location.href).toBe('') @@ -165,11 +163,10 @@ describe('Cloud plan payment flow', () => { ] as const)( 'blocks the $label action without billing permission', async ({ currentPlan, label }) => { + const user = userEvent.setup() canManageBilling = false renderPlan({ currentPlan }) - await userEvent - .setup() - .click(screen.getByRole('button', { name: `billing.plansCommon.${label}` })) + await user.click(screen.getByRole('button', { name: `billing.plansCommon.${label}` })) expect(await screen.findByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() expect(mockGetSubscription).not.toHaveBeenCalled() expect(mockGetInvoices).not.toHaveBeenCalled() diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx index 7cd9e0b5715..fdc90514f6d 100644 --- a/web/__tests__/billing/pricing-modal-flow.test.tsx +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -63,11 +63,6 @@ vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', })) -vi.mock('#i18n', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: () => 'en-US', -})) - vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() return { diff --git a/web/__tests__/interactive-component-mock-boundary.spec.ts b/web/__tests__/interactive-component-mock-boundary.spec.ts index 668bb4c47ca..2fdc02738cb 100644 --- a/web/__tests__/interactive-component-mock-boundary.spec.ts +++ b/web/__tests__/interactive-component-mock-boundary.spec.ts @@ -5,7 +5,7 @@ const testFilePattern = /\.(?:spec|test)\.[cm]?[jt]sx?$/ const interactivePrimitiveMockPattern = /vi\.mock\(\s*['"]@langgenius\/dify-ui\/(?:alert-dialog|avatar|button|dialog|dropdown-menu|pagination|popover|select|slider|switch|textarea|tooltip)['"]/ const interactiveWrapperMockPattern = - /vi\.mock\(\s*['"][^'"]*(?:block-selector|plugin-version-picker|time-picker)['"]/ + /vi\.mock\(\s*['"][^'"]*(?:block-selector|plugin-version-picker|time-picker|prompt-editor)(?:\/index)?['"]/ const collectTestFiles = (directory: string): string[] => { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx index d9eca10f2cc..f584d8fccd8 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/panel.spec.tsx @@ -78,12 +78,13 @@ describe('Tracing overview panel', () => { }) it('offers retry when tracing cannot load instead of remaining in a loading state', async () => { + const user = userEvent.setup() request.mockRejectedValue(new Error('Tracing unavailable')) renderPanel() const retry = await screen.findByRole('button', { name: /common.errorBoundary.tryAgain/ }) expect(screen.queryByRole('status')).not.toBeInTheDocument() mockServer(false) - await userEvent.setup().click(retry) + await user.click(retry) expect(await screen.findByText('app.tracing.title')).toBeInTheDocument() expect( screen.queryByRole('button', { name: /common.errorBoundary.tryAgain/ }), diff --git a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx index 5050334fd68..2e27afe5787 100644 --- a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx @@ -207,6 +207,7 @@ describe('Splash', () => { }) it('logs out and returns from the unavailable state through its keyboard action', async () => { + const user = userEvent.setup() navigationMocks.searchParams = new URLSearchParams({ code: '404', message: 'The Web App is unavailable.', @@ -219,7 +220,7 @@ describe('Splash', () => { ) screen.getByRole('button', { name: 'share.login.backToHome' }).focus() - await userEvent.setup().keyboard('{Enter}') + await user.keyboard('{Enter}') expect(webAppAuthMocks.webAppLogout).toHaveBeenCalledWith({ kind: 'default', code: 'share-app', diff --git a/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-code-auth.spec.tsx b/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-code-auth.spec.tsx index d8f61829120..47ccf9cb04c 100644 --- a/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-code-auth.spec.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-code-auth.spec.tsx @@ -15,11 +15,6 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => searchParams, })) -vi.mock('#i18n', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: () => 'en-US', -})) - vi.mock('@/app/components/signin/storage', () => ({ COUNT_DOWN_TIME_MS: 60_000, useSetCountdownLeftTime: () => setCountdownLeftTimeMock, diff --git a/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-password-auth.spec.tsx b/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-password-auth.spec.tsx index 15bab5796b0..ed651905dba 100644 --- a/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-password-auth.spec.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/__tests__/mail-and-password-auth.spec.tsx @@ -14,11 +14,6 @@ vi.mock('@/next/navigation', () => ({ useSearchParams: () => searchParams, })) -vi.mock('#i18n', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: () => 'en-US', -})) - vi.mock('@/context/web-app-context', () => ({ useWebAppStore: (selector: (state: { embeddedUserId: string }) => unknown) => selector({ embeddedUserId: 'embedded-user-99' }), diff --git a/web/app/__tests__/home-banner.browser.spec.tsx b/web/app/__tests__/home-banner.browser.spec.tsx index 52d26c6c093..8bb42cb7801 100644 --- a/web/app/__tests__/home-banner.browser.spec.tsx +++ b/web/app/__tests__/home-banner.browser.spec.tsx @@ -2,26 +2,10 @@ import type { BannerResponse } from '@dify/contracts/api/console/explore/types.g import { userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { Banner } from '@/features/home/banner/banner' +import { createConsoleQueryWrapper } from '@/test/console/query-data' const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useSuspenseQuery: () => ({ data: { id: 'account-123' } }), - } -}) - -vi.mock('@/features/account-profile/client', () => ({ - userProfileQueryOptions: () => ({}), -})) - -vi.mock('#i18n', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: () => 'en-US', -})) - vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) @@ -56,14 +40,15 @@ const banners: BannerResponse[] = [ ] async function renderBanner() { + const { wrapper: Wrapper } = createConsoleQueryWrapper({ accountProfile: { id: 'account-123' } }) return render( - <> +
- , +
, ) } diff --git a/web/app/components/access-rules-editor/index.tsx b/web/app/components/access-rules-editor/index.tsx index 24c7e2f323a..728e1addf3d 100644 --- a/web/app/components/access-rules-editor/index.tsx +++ b/web/app/components/access-rules-editor/index.tsx @@ -24,7 +24,7 @@ export type AccessPolicyMemberBindingRemoval = { accountIds: string[] } -export type AccessRulesEditorProps = { +type AccessRulesEditorProps = { rules: { policy?: Pick | null }[] userAccessSettings: ResourceUserAccessPolicies[] isLoadingRules: boolean diff --git a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx index 0c1b3dde80f..635a5da8fc3 100644 --- a/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/dataset-detail-section.spec.tsx @@ -3,7 +3,8 @@ import type { RelatedAppListResponse, } from '@dify/contracts/api/console/datasets/types.gen' import { screen } from '@testing-library/react' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { consoleQuery } from '@/service/console' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { DatasetACLPermission } from '@/utils/permission' import DatasetDetailSection from '../dataset-detail-section' @@ -18,12 +19,19 @@ const mockConsoleState = vi.hoisted(() => ({ }, })) -const render = (ui: Parameters[0]) => - renderWithConsoleQuery(ui, { - systemFeatures: { - rbac_enabled: mockIsRbacEnabled, - }, +const render = (ui: Parameters[0]) => { + const queryClient = createConsoleQueryClient() + const input = { params: { dataset_id: 'dataset-1' } } + queryClient.setQueryData(consoleQuery.datasets.byDatasetId.get.queryKey({ input }), mockDataset) + queryClient.setQueryData( + consoleQuery.datasets.byDatasetId.relatedApps.get.queryKey({ input }), + mockRelatedApps, + ) + return renderWithConsoleQuery(ui, { + queryClient, + systemFeatures: { rbac_enabled: mockIsRbacEnabled }, }) +} vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname, @@ -35,14 +43,6 @@ 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(), { - 'datasets.byDatasetId.get': () => ({ data: mockDataset, refetch: vi.fn() }), - 'datasets.byDatasetId.relatedApps.get': () => ({ data: mockRelatedApps }), - }) -}) - vi.mock('../dataset-info', () => ({ default: () =>
dataset info
, })) diff --git a/web/app/components/app/access-config/__tests__/index.spec.tsx b/web/app/components/app/access-config/__tests__/index.spec.tsx deleted file mode 100644 index 462fc89ed60..00000000000 --- a/web/app/components/app/access-config/__tests__/index.spec.tsx +++ /dev/null @@ -1,465 +0,0 @@ -import type { AccessRulesEditorProps } from '@/app/components/access-rules-editor' -import { act, screen, waitFor } from '@testing-library/react' -import ErrorBoundary from '@/app/components/base/error-boundary' -import { - useAppAccessRules, - useAppResourceWhitelist, - useAppResourceWhitelistConfig, - useAppUserAccessSettings, -} from '@/service/access-control/use-app-access-config' -import { consoleQuery } from '@/service/console' -import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' -import { createAppDetailFixture } from '@/test/fixtures/app' -import { AppACLPermission } from '@/utils/permission' -import AppAccessConfigPage from '../index' - -const mockConsoleState = vi.hoisted(() => ({ - userProfile: { id: 'user-1' }, - workspacePermissionKeys: [] as string[], -})) - -let mockIsRbacEnabled = true -let appDetail = createAppDetailFixture() - -const render = (ui: Parameters[0]) => { - const queryClient = createConsoleQueryClient() - queryClient.setQueryData( - consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: appDetail.id } } }), - appDetail, - ) - return renderWithConsoleQuery(ui, { - queryClient, - accountProfile: mockConsoleState.userProfile, - systemFeatures: { rbac_enabled: mockIsRbacEnabled }, - }) -} - -const mockAppAccessRules = vi.hoisted(() => ({ - items: [] as AccessRulesEditorProps['rules'], - isLoading: false, -})) - -const createUserAccessSetting = ( - accountId: string, -): AccessRulesEditorProps['userAccessSettings'][number] => ({ - account: { - account_id: accountId, - account_name: accountId, - }, - roles: [], - access_policies: [], -}) - -const mockAppUserAccessSettings = vi.hoisted(() => ({ - data: [] as AccessRulesEditorProps['userAccessSettings'], - pagination: { - current_page: 1, - per_page: 10, - total_count: 0, - total_pages: 0, - }, - isLoading: false, - isPlaceholderData: false, -})) - -const mockAppResourceWhitelist = vi.hoisted(() => ({ - data: { - account_ids: [] as string[], - } as { account_ids?: string[] } | undefined, -})) - -const mockAppResourceWhitelistConfig = vi.hoisted(() => ({ - data: { - automatic_include_workspace_members: false, - } as { automatic_include_workspace_members: boolean } | undefined, -})) - -const mockAccessRulesEditor = vi.hoisted(() => ({ - props: null as AccessRulesEditorProps | null, -})) - -const mockMutations = vi.hoisted(() => ({ - updateAutomaticIncludeWorkspaceMembers: vi.fn(), - updateUserAccessSettings: vi.fn(), - removeMemberBindings: vi.fn(), -})) - -vi.mock('@/context/permission-state', async () => { - const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') - return createPermissionStateModuleMock(() => mockConsoleState) -}) - -vi.mock('@/service/access-control/use-app-access-config', async (importOriginal) => ({ - ...(await importOriginal()), - useAppAccessRules: vi.fn(() => ({ - data: { items: mockAppAccessRules.items }, - isLoading: mockAppAccessRules.isLoading, - })), - useAppUserAccessSettings: vi.fn(() => ({ - data: { - data: mockAppUserAccessSettings.data, - pagination: mockAppUserAccessSettings.pagination, - }, - isLoading: mockAppUserAccessSettings.isLoading, - isPlaceholderData: mockAppUserAccessSettings.isPlaceholderData, - })), - useAppResourceWhitelist: vi.fn(() => ({ data: mockAppResourceWhitelist.data })), - useAppResourceWhitelistConfig: vi.fn(() => ({ data: mockAppResourceWhitelistConfig.data })), -})) - -vi.mock('@/app/components/access-rules-editor', () => ({ - default: (props: AccessRulesEditorProps) => { - mockAccessRulesEditor.props = props - return
- }, -})) - -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() - mockConsoleState.userProfile = { id: 'user-1' } - mockConsoleState.workspacePermissionKeys = [] - mockIsRbacEnabled = true - mockAppAccessRules.items = [] - mockAppAccessRules.isLoading = false - mockAppUserAccessSettings.data = [] - mockAppUserAccessSettings.pagination = { - current_page: 1, - per_page: 10, - total_count: 0, - total_pages: 0, - } - mockAppUserAccessSettings.isLoading = false - mockAppUserAccessSettings.isPlaceholderData = false - mockAppResourceWhitelist.data = { - account_ids: [], - } - mockAppResourceWhitelistConfig.data = { - automatic_include_workspace_members: false, - } - mockMutations.removeMemberBindings.mockResolvedValue({}) - mockMutations.updateUserAccessSettings.mockResolvedValue({}) - mockMutations.updateAutomaticIncludeWorkspaceMembers.mockResolvedValue({}) - mockAccessRulesEditor.props = null - appDetail = createAppDetailFixture({ - id: 'app-1', - maintainer: 'account-1', - permission_keys: [AppACLPermission.AccessConfig], - }) - }) - - it('should render the first paginated member page with config and whitelist data', () => { - mockAppResourceWhitelistConfig.data = { - automatic_include_workspace_members: true, - } - mockAppUserAccessSettings.pagination = { - current_page: 1, - per_page: 10, - total_count: 45, - total_pages: 3, - } - mockAppResourceWhitelist.data = { - account_ids: ['account-1', 'account-2'], - } - - render() - - expect( - screen.getByRole('heading', { name: 'navigation.settings.resourceAccess' }), - ).toBeInTheDocument() - expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument() - expect(useAppUserAccessSettings).toHaveBeenCalledWith('app-1', expect.any(String), 1, 10) - expect(useAppResourceWhitelist).toHaveBeenCalledWith('app-1') - expect(useAppResourceWhitelistConfig).toHaveBeenCalledWith('app-1') - expect(mockAccessRulesEditor.props?.automaticIncludeWorkspaceMembers).toBe(true) - expect(mockAccessRulesEditor.props?.existingAccountIds).toEqual(['account-1', 'account-2']) - expect(mockAccessRulesEditor.props?.currentPage).toBe(1) - expect(mockAccessRulesEditor.props?.pageSize).toBe(10) - expect(mockAccessRulesEditor.props?.totalCount).toBe(45) - expect(mockAccessRulesEditor.props?.totalPages).toBe(3) - expect(mockAccessRulesEditor.props?.maintainerId).toBe('account-1') - }) - - it('should keep the switch state while the add-member whitelist is loading', () => { - mockAppResourceWhitelistConfig.data = { - automatic_include_workspace_members: true, - } - mockAppResourceWhitelist.data = undefined - - render() - - expect(mockAccessRulesEditor.props?.automaticIncludeWorkspaceMembers).toBe(true) - expect(mockAccessRulesEditor.props?.existingAccountIds).toBeUndefined() - }) - - it('should request the selected member page', () => { - render() - - act(() => mockAccessRulesEditor.props?.onPageChange?.(2)) - - expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 2, 10) - }) - - it('should request the selected page size from the first page', () => { - render() - - act(() => mockAccessRulesEditor.props?.onPageChange?.(3)) - act(() => mockAccessRulesEditor.props?.onPageSizeChange?.(50)) - - expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 1, 50) - expect(mockAccessRulesEditor.props?.pageSize).toBe(50) - }) - - 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)) - - 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', async () => { - mockAppUserAccessSettings.pagination = { - current_page: 2, - per_page: 10, - total_count: 20, - total_pages: 2, - } - mockAppResourceWhitelist.data = { - // The whitelist can omit an implicitly included maintainer; pagination is authoritative. - account_ids: Array.from({ length: 19 }, (_, index) => `account-${index + 1}`), - } - render() - - await act(async () => { - mockAccessRulesEditor.props?.onAddAccessSubject?.('account-21', ['default']) - }) - - await waitFor(() => - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ - params: { app_id: 'app-1', target_account_id: 'account-21' }, - body: { access_policy_ids: ['default'] }, - }), - ) - 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', async () => { - mockAppUserAccessSettings.data = [createUserAccessSetting('account-11')] - mockAppUserAccessSettings.pagination = { - current_page: 2, - per_page: 10, - total_count: 11, - total_pages: 2, - } - render() - act(() => mockAccessRulesEditor.props?.onPageChange?.(2)) - - act(() => - mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-11', 'default'), - ) - mockAppUserAccessSettings.data = [] - mockAppUserAccessSettings.pagination = { - current_page: 2, - per_page: 10, - total_count: 10, - total_pages: 1, - } - mockAppUserAccessSettings.isPlaceholderData = true - await act(async () => {}) - - expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 1, 10) - expect(mockAccessRulesEditor.props?.isLoadingUserAccessSettings).toBe(true) - }) - - it('should wire switch, policy update, and removal mutations', async () => { - render() - - mockAccessRulesEditor.props?.onAutomaticIncludeWorkspaceMembersChange?.(true) - await waitFor(() => - expect(mockMutations.updateAutomaticIncludeWorkspaceMembers).toHaveBeenCalledWith({ - params: { app_id: 'app-1' }, - body: { automatic_include_workspace_members: true }, - }), - ) - - mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-1', ['policy-1']) - 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') - await waitFor(() => - expect(mockMutations.removeMemberBindings).toHaveBeenCalledWith({ - params: { app_id: 'app-1', policy_id: 'policy-3' }, - body: { account_ids: ['account-3'] }, - }), - ) - - const removals = [ - { accessPolicyId: 'policy-1', accountIds: ['account-1', 'account-2'] }, - { accessPolicyId: 'default', accountIds: ['account-4'] }, - ] - await act(async () => { - await mockAccessRulesEditor.props?.onBatchRemoveAccessPolicyMemberBindings?.(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 () => { - mockAppResourceWhitelistConfig.data = { - automatic_include_workspace_members: true, - } - render() - - mockAccessRulesEditor.props?.onAddAccessSubject?.('account-2', ['default']) - mockAccessRulesEditor.props?.onRemoveAccessPolicyMemberBinding?.('account-2', 'policy-1') - await act(async () => { - await mockAccessRulesEditor.props?.onBatchRemoveAccessPolicyMemberBindings?.([ - { accessPolicyId: 'policy-1', accountIds: ['account-2'] }, - ]) - }) - - expect(mockMutations.updateUserAccessSettings).not.toHaveBeenCalled() - expect(mockMutations.removeMemberBindings).not.toHaveBeenCalled() - - mockAccessRulesEditor.props?.onUserAccessPoliciesChange?.('account-2', ['policy-2']) - await waitFor(() => - expect(mockMutations.updateUserAccessSettings).toHaveBeenCalledWith({ - params: { app_id: 'app-1', target_account_id: 'account-2' }, - body: { access_policy_ids: ['policy-2'] }, - }), - ) - }) - - it('should not mount access config data hooks when access permission is missing', () => { - appDetail = createAppDetailFixture({ - id: 'app-1', - maintainer: 'account-1', - permission_keys: [AppACLPermission.ViewLayout], - }) - - render() - - expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() - expect(useAppAccessRules).not.toHaveBeenCalled() - expect(useAppUserAccessSettings).not.toHaveBeenCalled() - }) - - it('should not mount access config data hooks when RBAC is disabled', () => { - mockIsRbacEnabled = false - - render() - - expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() - expect(useAppAccessRules).not.toHaveBeenCalled() - expect(useAppUserAccessSettings).not.toHaveBeenCalled() - }) - - it('should not mount access config data hooks for Agent apps', () => { - appDetail = createAppDetailFixture({ - id: 'app-1', - mode: 'agent' as const, - maintainer: 'account-1', - permission_keys: [AppACLPermission.AccessConfig], - }) - - render() - - expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument() - expect(useAppAccessRules).not.toHaveBeenCalled() - expect(useAppUserAccessSettings).not.toHaveBeenCalled() - }) - - it('should allow the maintainer with app management workspace permission', () => { - mockConsoleState.userProfile = { id: 'account-1' } - mockConsoleState.workspacePermissionKeys = ['app.create_and_management'] - appDetail = createAppDetailFixture({ - id: 'app-1', - maintainer: 'account-1', - permission_keys: [], - }) - - render() - - expect(screen.getByTestId('access-rules-editor')).toBeInTheDocument() - }) - - it('keeps the current member page on metadata refresh and stops rendering after permission revocation', async () => { - appDetail = createAppDetailFixture({ permission_keys: [AppACLPermission.AccessConfig] }) - const { queryClient } = render() - const queryKey = consoleQuery.apps.byAppId.get.queryKey({ - input: { params: { app_id: 'app-1' } }, - }) - act(() => mockAccessRulesEditor.props?.onPageChange?.(3)) - expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 3, 10) - act(() => { - queryClient.setQueryData(queryKey, { ...appDetail, maintainer: 'new-maintainer' }) - }) - await waitFor(() => expect(mockAccessRulesEditor.props?.maintainerId).toBe('new-maintainer')) - expect(useAppUserAccessSettings).toHaveBeenLastCalledWith('app-1', expect.any(String), 3, 10) - act(() => { - queryClient.setQueryData(queryKey, { ...appDetail, permission_keys: [] }) - }) - await waitFor(() => expect(screen.queryByTestId('access-rules-editor')).not.toBeInTheDocument()) - }) - - it('surfaces an initial detail failure without mounting member queries', async () => { - const queryClient = createConsoleQueryClient() - const queryKey = consoleQuery.apps.byAppId.get.queryKey({ - input: { params: { app_id: 'unavailable-app' } }, - }) - queryClient.setQueryDefaults(queryKey, { retryOnMount: false }) - await queryClient - .query({ queryKey, queryFn: () => Promise.reject(new Error('Detail unavailable')) }) - .catch(() => {}) - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - renderWithConsoleQuery( - - - , - { queryClient }, - ) - expect( - await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }), - ).toBeInTheDocument() - expect(useAppAccessRules).not.toHaveBeenCalled() - } finally { - consoleError.mockRestore() - } - }) -}) diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx index 36a3c9f0d39..16bc2dc6f5c 100644 --- a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -29,11 +29,6 @@ const mocks = vi.hoisted(() => ({ useAppWorkflow: vi.fn(), })) -vi.mock('react-i18next', async () => { - const { createReactI18nextMock } = await import('@/test/i18n-mock') - return createReactI18nextMock() -}) - vi.mock('@/features/system-features/client', () => ({ systemFeaturesQueryOptions: () => ({ queryKey: ['system-features'] }), })) diff --git a/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx index 4102ec510fd..450c69817f8 100644 --- a/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx @@ -7,11 +7,6 @@ const mocks = vi.hoisted(() => ({ webAppCard: vi.fn(), })) -vi.mock('react-i18next', async () => { - const { createReactI18nextMock } = await import('@/test/i18n-mock') - return createReactI18nextMock() -}) - vi.mock('../deployed-environment-access-points/environment-service-api-card', () => ({ EnvironmentServiceApiCard: (props: Record) => { mocks.serviceApiCard(props) diff --git a/web/app/components/app/annotation/__tests__/index.spec.tsx b/web/app/components/app/annotation/__tests__/index.spec.tsx index f6bfc8891be..882137b1da1 100644 --- a/web/app/components/app/annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/annotation/__tests__/index.spec.tsx @@ -1,504 +1,360 @@ -import type { Annotation as AnnotationData } from '@dify/contracts/api/console/apps/types.gen' -import type { ComponentProps, ReactElement } from 'react' -import { act, screen, waitFor } from '@testing-library/react' +import type { + Annotation as AnnotationData, + AnnotationSettingResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { consoleClient } from '@/service/console' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { toast } from '@/app/notifications' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { AppToastHost } from '@/app/notifications/host' +import { + createCatalogModelFixture, + createModelCatalogFixture, + seedModelProviderQueries, +} from '@/test/console/model-provider' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { createAppDetailFixture } from '@/test/fixtures/app' import Annotation from '../index' -import { AnnotationEnableStatus, JobStatus } from '../type' -let annotationQuota = { size: 0, limit: 10 } - -vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path: string) => `https://docs.example.com${path}`, +const api = vi.hoisted(() => ({ + list: vi.fn(), + add: vi.fn(), + remove: vi.fn(), + batchRemove: vi.fn(), + edit: vi.fn(), + config: vi.fn(), + status: vi.fn(), + job: vi.fn(), + score: vi.fn(), })) -const mocks = vi.hoisted(() => ({ - addAnnotation: vi.fn(), - delAnnotation: vi.fn(), - delAnnotations: vi.fn(), - editAnnotation: vi.fn(), - fetchAnnotationConfig: vi.fn(), - fetchAnnotationList: vi.fn(), - queryAnnotationJobStatus: vi.fn(), - updateAnnotationScore: vi.fn(), - updateAnnotationStatus: vi.fn(), -})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses, withConsoleClientResponses } = + await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.byAppId.annotations.count.get': () => ({ count: 0 }), + 'apps.byAppId.annotations.byAnnotationId.delete': (input) => api.remove(input), + 'apps.byAppId.annotations.delete': (input) => api.batchRemove(input), + }), + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'apps.byAppId.annotations.get': (input) => api.list(input), + 'apps.byAppId.annotations.export.get': () => ({ data: [] }), + 'apps.byAppId.annotations.post': (input) => api.add(input), + 'apps.byAppId.annotations.delete': (input) => api.batchRemove(input), + 'apps.byAppId.annotations.byAnnotationId.delete': (input) => api.remove(input), + 'apps.byAppId.annotations.byAnnotationId.post': (input) => api.edit(input), + 'apps.byAppId.annotations.byAnnotationId.hitHistories.get': () => ({ + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }), + 'apps.byAppId.annotationSetting.get': (input) => api.config(input), + 'apps.byAppId.annotationReply.byAction.post': (input) => api.status(input), + 'apps.byAppId.annotationReply.byAction.status.byJobId.get': (input) => api.job(input), + 'apps.byAppId.annotationSettings.byAnnotationSettingId.post': (input) => api.score(input), + }), + } +}) -vi.mock('@/service/annotation', () => ({ - updateAnnotationStatus: mocks.updateAnnotationStatus, -})) +const item: AnnotationData = { + id: 'annotation-1', + question: 'Question 1', + answer: 'Answer 1', + created_at: 1700000000, + hit_count: 0, +} +const initialConfig: AnnotationSettingResponse = { + id: 'config-id', + enabled: false, + score_threshold: 0.5, + embedding_model: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', + }, +} +let annotations: AnnotationData[] +let config: AnnotationSettingResponse -vi.mock('@/service/console', async (importOriginal) => ({ - ...(await importOriginal()), - consoleClient: { - apps: { - byAppId: { - annotationSetting: { get: mocks.fetchAnnotationConfig }, - annotationReply: { - byAction: { status: { byJobId: { get: mocks.queryAnnotationJobStatus } } }, - }, - annotationSettings: { byAnnotationSettingId: { post: mocks.updateAnnotationScore } }, - annotations: { - get: mocks.fetchAnnotationList, - post: mocks.addAnnotation, - delete: mocks.delAnnotations, - byAnnotationId: { delete: mocks.delAnnotation, post: mocks.editAnnotation }, - }, - }, +function setup(quota = { size: 0, limit: 10 }) { + const queryClient = createConsoleQueryClient() + seedModelProviderQueries(queryClient, [ + createModelCatalogFixture({ + models: [ + createCatalogModelFixture({ + model: 'text-embedding-ada-002', + label: { en_US: 'Ada Embedding' }, + model_type: 'text-embedding', + }), + ], + }), + createModelCatalogFixture({ + provider: 'cohere', + models: [ + createCatalogModelFixture({ + model: 'embed-english', + label: { en_US: 'English Embedding' }, + model_type: 'text-embedding', + }), + ], + }), + ]) + renderWithConsoleQuery( + + + + , + { + queryClient, + systemFeatures: { deployment_edition: 'CLOUD' }, + features: { annotation_quota_limit: quota }, }, - }, -})) - -vi.mock('../filter', () => ({ - default: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) - -vi.mock('../empty-element', () => ({ - default: () =>
, -})) - -vi.mock('../header-opts', () => ({ - default: (props: ComponentProps) => ( -
- - -
- ), -})) - -let latestListProps: ComponentProps | undefined - -vi.mock('../list', () => ({ - List: (props: ComponentProps) => { - latestListProps = props - if (!props.list.length) return
- return ( -
- - - -
- ) - }, -})) - -vi.mock('../view-annotation-modal', () => ({ - default: (props: ComponentProps) => { - if (!props.isShow) return null - return ( -
-
{props.item.question}
- - - -
- ) - }, -})) - -vi.mock( - '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', - () => ({ - default: ( - props: ComponentProps< - typeof import('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal').default - >, - ) => - props.isShow ? ( -
- - -
- ) : null, - }), -) -vi.mock('@/app/components/billing/annotation-full/modal', () => ({ - default: ( - props: ComponentProps, - ) => - props.show ? ( -
- -
- ) : null, -})) - -const mockNotify = vi.fn() -vi.spyOn(toast, 'success').mockImplementation((message, options) => { - mockNotify({ type: 'success', message, ...options }) - return 'toast-success-id' -}) -vi.spyOn(toast, 'error').mockImplementation((message, options) => { - mockNotify({ type: 'error', message, ...options }) - return 'toast-error-id' -}) -vi.spyOn(toast, 'warning').mockImplementation((message, options) => { - mockNotify({ type: 'warning', message, ...options }) - return 'toast-warning-id' -}) -vi.spyOn(toast, 'info').mockImplementation((message, options) => { - mockNotify({ type: 'info', message, ...options }) - return 'toast-info-id' -}) -const addAnnotationMock = mocks.addAnnotation -const delAnnotationMock = mocks.delAnnotation -const delAnnotationsMock = mocks.delAnnotations -const editAnnotationMock = mocks.editAnnotation -const fetchAnnotationConfigMock = mocks.fetchAnnotationConfig -const fetchAnnotationListMock = mocks.fetchAnnotationList -const queryAnnotationJobStatusMock = mocks.queryAnnotationJobStatus -const updateAnnotationScoreMock = mocks.updateAnnotationScore -const updateAnnotationStatusMock = mocks.updateAnnotationStatus - -const appDetail = createAppDetailFixture({ - id: 'app-id', - mode: 'chat' as const, -}) - -const createAnnotation = (overrides: Partial = {}): AnnotationData => ({ - id: overrides.id ?? 'annotation-1', - question: overrides.question ?? 'Question 1', - answer: overrides.answer ?? 'Answer 1', - created_at: overrides.created_at ?? 1700000000, - hit_count: overrides.hit_count ?? 0, -}) - -const renderComponent = () => render() - -function render(ui: ReactElement) { - return renderWithConsoleQuery(ui, { - systemFeatures: { deployment_edition: 'CLOUD' }, - features: { annotation_quota_limit: annotationQuota }, - }) + ) + return userEvent.setup() } -describe('Annotation', () => { - beforeEach(() => { - vi.clearAllMocks() - for (const mock of Object.values(mocks)) mock.mockReset() - latestListProps = undefined - fetchAnnotationConfigMock.mockResolvedValue({ - id: 'config-id', - enabled: false, +beforeEach(() => { + for (const mock of Object.values(api)) mock.mockReset() + annotations = [] + config = { ...initialConfig } + api.list.mockImplementation(async () => ({ + data: [...annotations], + total: annotations.length, + has_more: false, + limit: 10, + page: 1, + })) + api.config.mockImplementation(async () => config) + api.add.mockImplementation(async ({ body }) => { + const added = { ...item, question: body.question, answer: body.answer } + annotations.push(added) + return added + }) + api.remove.mockImplementation(async () => { + annotations = [] + return { result: 'success' } + }) + api.batchRemove.mockImplementation(async () => { + annotations = [] + return { result: 'success' } + }) + api.edit.mockImplementation(async ({ body }) => { + const edited = { ...item, question: body.question, answer: body.answer } + annotations = [edited] + return edited + }) + api.status.mockImplementation(async ({ params, body }) => { + config = { + ...config, + enabled: params.action === 'enable', + id: params.action === 'enable' ? 'enabled-config-id' : config.id, embedding_model: { - embedding_model_name: 'model', - embedding_provider_name: 'provider', + embedding_model_name: body.embedding_model_name, + embedding_provider_name: body.embedding_provider_name, }, - score_threshold: 0.5, - }) - fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 }) - queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed }) - updateAnnotationStatusMock.mockResolvedValue({ job_id: 'job-1' }) - updateAnnotationScoreMock.mockResolvedValue(undefined) - editAnnotationMock.mockResolvedValue(undefined) - annotationQuota = { size: 0, limit: 10 } + } + return { job_id: 'job-1', job_status: 'completed' } }) - - it('should render empty element when no annotations are returned', async () => { - renderComponent() - - expect(screen.getByRole('heading', { name: 'appAnnotation.title' })).toBeInTheDocument() - expect(screen.getByText('appAnnotation.noData.description')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'common.operation.learnMore' })).toHaveAttribute( - 'href', - 'https://docs.example.com/use-dify/monitor/annotation-reply', - ) - expect(await screen.findByTestId('empty-element')).toBeInTheDocument() - expect(fetchAnnotationListMock).toHaveBeenCalledWith({ - params: { app_id: appDetail.id }, - query: expect.objectContaining({ page: 1, keyword: '' }), - }) + api.job.mockResolvedValue({ job_id: 'job-1', job_status: 'completed', error_msg: '' }) + api.score.mockImplementation(async ({ body }) => { + config = { ...config, score_threshold: body.score_threshold } + return config }) - - it('should handle annotation creation and refresh list data', async () => { - const user = userEvent.setup() - const annotation = createAnnotation() - fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 }) - addAnnotationMock.mockResolvedValue(undefined) - - renderComponent() - - await screen.findByTestId('list') - await user.click(screen.getByTestId('trigger-add')) - - await waitFor(() => { - expect(addAnnotationMock).toHaveBeenCalledWith({ - params: { app_id: appDetail.id }, - body: { question: 'new question', answer: 'new answer' }, - }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'common.api.actionSuccess', - type: 'success', - }), - ) - }) - expect(fetchAnnotationListMock).toHaveBeenCalledTimes(2) +}) + +afterEach(() => toast.dismiss()) + +it('creates an annotation from the empty page and displays the saved record', async () => { + const user = setup() + expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible() + await user.click( + await screen.findByRole('button', { name: 'appAnnotation.table.header.addAnnotation' }), + ) + const dialog = await screen.findByRole('dialog', { name: 'appAnnotation.addModal.title' }) + await user.type( + within(dialog).getByRole('textbox', { name: 'appAnnotation.addModal.queryName' }), + 'New question', + ) + await user.type( + within(dialog).getByRole('textbox', { name: 'appAnnotation.addModal.answerName' }), + 'New answer', + ) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.add' })) + await waitFor(() => expect(dialog).not.toBeInTheDocument()) + expect(await screen.findByRole('checkbox', { name: 'New question' })).toBeVisible() + expect(screen.getByRole('cell', { name: 'New answer' })).toBeVisible() + expect(api.add).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id' }, + body: { question: 'New question', answer: 'New answer' }, }) - - it('should refetch the list after a row is removed', async () => { - const user = userEvent.setup() - fetchAnnotationListMock.mockResolvedValue({ data: [createAnnotation()], total: 1 }) - - renderComponent() - await screen.findByTestId('list') - const fetchCount = fetchAnnotationListMock.mock.calls.length - - await user.click(screen.getByTestId('list-remove')) - - await waitFor(() => { - expect(fetchAnnotationListMock.mock.calls.length).toBeGreaterThan(fetchCount) - }) +}) + +it('removes a row after confirmation and refreshes the empty state', async () => { + annotations = [item] + const user = setup() + await user.click( + await screen.findByRole('button', { name: 'appDebug.feature.annotation.remove' }), + ) + const dialog = await screen.findByRole('alertdialog') + expect(api.remove).not.toHaveBeenCalled() + await user.click(within(dialog).getByRole('button', { name: 'common.operation.confirm' })) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible() + expect(api.remove).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id', annotation_id: item.id }, }) - - it('should support viewing items and running batch deletion success flow', async () => { - const user = userEvent.setup() - const annotation = createAnnotation() - fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 }) - delAnnotationsMock.mockResolvedValue(undefined) - delAnnotationMock.mockResolvedValue(undefined) - - renderComponent() - await screen.findByTestId('list') - - await act(async () => { - latestListProps!.onSelectedIdsChange([annotation.id]) - }) - await waitFor(() => { - expect(latestListProps!.selectedIds).toEqual([annotation.id]) - }) - - await act(async () => { - await latestListProps!.onBatchDelete() - }) - await waitFor(() => { - expect(delAnnotationsMock).toHaveBeenCalledWith({ - params: { app_id: appDetail.id }, - query: { annotation_id: [annotation.id] }, - }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'success', - }), - ) - expect(latestListProps!.selectedIds).toEqual([]) - }) - - await user.click(screen.getByTestId('list-view')) - expect(screen.getByTestId('view-modal')).toBeInTheDocument() - - await user.click(screen.getByTestId('view-modal-remove')) - await waitFor(() => { - expect(delAnnotationMock).toHaveBeenCalledWith({ - params: { app_id: appDetail.id, annotation_id: annotation.id }, - }) - }) +}) + +it('keeps a failed batch deletion open for retry and clears the selection after success', async () => { + annotations = [item] + api.batchRemove.mockRejectedValueOnce(new Error('Delete failed')) + const user = setup() + await user.click(await screen.findByRole('checkbox', { name: 'Question 1' })) + await user.click(screen.getByRole('button', { name: 'common.operation.delete' })) + const dialog = await screen.findByRole('alertdialog', { name: 'appAnnotation.list.delete.title' }) + expect(api.batchRemove).not.toHaveBeenCalled() + const confirm = within(dialog).getByRole('button', { name: 'common.operation.delete' }) + await user.click(confirm) + expect(await screen.findByText('Delete failed')).toBeVisible() + expect(dialog).toBeVisible() + await user.click(confirm) + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()) + expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible() + expect(screen.queryByText('appAnnotation.batchAction.selected')).not.toBeInTheDocument() + expect(api.batchRemove).toHaveBeenCalledTimes(2) + expect(api.batchRemove).toHaveBeenLastCalledWith({ + params: { app_id: 'app-id' }, + query: { annotation_id: [item.id] }, }) - - it('should show an error notification when batch deletion fails', async () => { - const annotation = createAnnotation() - fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 }) - const error = new Error('failed') - delAnnotationsMock.mockRejectedValue(error) - - renderComponent() - await screen.findByTestId('list') - - await act(async () => { - latestListProps!.onSelectedIdsChange([annotation.id]) - }) - await waitFor(() => { - expect(latestListProps!.selectedIds).toEqual([annotation.id]) - }) - - await act(async () => { - await expect(latestListProps!.onBatchDelete()).rejects.toThrow(error.message) - }) - - await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith({ - type: 'error', - message: error.message, - }) - expect(latestListProps!.selectedIds).toEqual([annotation.id]) - }) +}) + +it('saves edits from the detail drawer and shows them when reopened', async () => { + annotations = [item] + const user = setup() + await user.click(await screen.findByRole('button', { name: 'appDebug.feature.annotation.edit' })) + const dialog = await screen.findByRole('dialog', { + name: 'appAnnotation.viewModal.annotatedResponse', }) - - it('should show the annotation-full modal when enabling annotations exceeds the plan quota', async () => { - const user = userEvent.setup() - annotationQuota = { size: 10, limit: 10 } - - renderComponent() - - const toggle = await screen.findByRole('switch') - await user.click(toggle) - - expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument() - - await user.click(screen.getByTestId('hide-annotation-full-modal')) - expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument() + await user.click(within(dialog).getAllByRole('button', { name: 'common.operation.edit' })[0]!) + await user.type( + within(dialog).getByRole('textbox', { name: 'appAnnotation.editModal.yourQuery' }), + 'Edited question', + ) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => + expect(api.edit).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id', annotation_id: item.id }, + body: { question: 'Edited question', answer: 'Answer 1' }, + }), + ) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' })) + await waitFor(() => expect(dialog).not.toBeInTheDocument()) + expect(await screen.findByRole('checkbox', { name: 'Edited question' })).toBeVisible() + await user.click(screen.getByRole('button', { name: 'appDebug.feature.annotation.edit' })) + expect( + within( + await screen.findByRole('dialog', { name: 'appAnnotation.viewModal.annotatedResponse' }), + ).getByText('Edited question'), + ).toBeVisible() +}) + +it('removes an annotation through its detail drawer and closes it', async () => { + annotations = [item] + const user = setup() + await user.click(await screen.findByRole('button', { name: 'appDebug.feature.annotation.edit' })) + const dialog = await screen.findByRole('dialog', { + name: 'appAnnotation.viewModal.annotatedResponse', }) - - it('should disable annotations and refetch config after the async job completes', async () => { - const user = userEvent.setup() - fetchAnnotationConfigMock - .mockResolvedValueOnce({ - id: 'config-id', - enabled: true, - embedding_model: { - embedding_model_name: 'model', - embedding_provider_name: 'provider', - }, - score_threshold: 0.5, - }) - .mockResolvedValueOnce({ - id: 'config-id', - enabled: false, - embedding_model: { - embedding_model_name: 'model', - embedding_provider_name: 'provider', - }, - score_threshold: 0.5, - }) - - renderComponent() - - const toggle = await screen.findByRole('switch') - await waitFor(() => { - expect(toggle).toHaveAttribute('aria-checked', 'true') - }) - await user.click(toggle) - - await waitFor(() => { - expect(updateAnnotationStatusMock).toHaveBeenCalledWith( - appDetail.id, - AnnotationEnableStatus.disable, - expect.objectContaining({ - embedding_model_name: 'model', - embedding_provider_name: 'provider', - }), - 0.5, - ) - expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith({ - params: { app_id: appDetail.id, action: AnnotationEnableStatus.disable, job_id: 'job-1' }, - }) - expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') - }) + await user.click( + within(dialog).getByRole('button', { + name: 'appAnnotation.editModal.removeThisCache', + }), + ) + const confirm = await screen.findByRole('alertdialog') + await user.click(within(confirm).getByRole('button', { name: 'common.operation.confirm' })) + await waitFor(() => expect(dialog).not.toBeInTheDocument()) + expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible() + expect(api.remove).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id', annotation_id: item.id }, }) - - it('should save annotation config changes and update the score when the modal confirms', async () => { - const user = userEvent.setup() - fetchAnnotationConfigMock.mockResolvedValue({ - id: 'config-id', - enabled: false, - embedding_model: { - embedding_model_name: 'model', - embedding_provider_name: 'provider', - }, +}) + +it('refreshes annotations after the header clear-all action', async () => { + annotations = [item] + const user = setup() + await screen.findByRole('checkbox', { name: 'Question 1' }) + await user.click(await screen.findByRole('button', { name: 'common.operation.more' })) + await user.click( + await screen.findByRole('menuitem', { name: 'appAnnotation.table.header.clearAll' }), + ) + const dialog = await screen.findByRole('alertdialog') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.confirm' })) + expect(await screen.findByText('appAnnotation.noData.title')).toBeVisible() + expect(api.batchRemove).toHaveBeenCalledExactlyOnceWith({ params: { app_id: 'app-id' } }) +}) + +it('shows the quota dialog without enabling annotated replies when the plan is full', async () => { + const user = setup({ size: 10, limit: 10 }) + await user.click(await screen.findByRole('switch')) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText('billing.annotatedResponse.fullTipLine1')).toBeVisible() + expect(api.status).not.toHaveBeenCalled() + await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' })) + await waitFor(() => expect(dialog).not.toBeInTheDocument()) + expect(screen.getByRole('switch')).not.toBeChecked() +}) + +it('disables annotated replies and reloads settings after the job completes', async () => { + config = { ...initialConfig, enabled: true } + const user = setup() + await waitFor(() => expect(screen.getByRole('switch')).toBeChecked()) + await user.click(screen.getByRole('switch')) + await waitFor(() => expect(screen.getByRole('switch')).not.toBeChecked()) + expect(api.status).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id', action: 'disable' }, + body: { + embedding_provider_name: 'openai', + embedding_model_name: 'text-embedding-ada-002', score_threshold: 0.5, - }) - - renderComponent() - - const toggle = await screen.findByRole('switch') - await user.click(toggle) - - expect(screen.getByTestId('config-modal')).toBeInTheDocument() - await user.click(screen.getByTestId('config-save')) - - await waitFor(() => { - expect(updateAnnotationStatusMock).toHaveBeenCalledWith( - appDetail.id, - AnnotationEnableStatus.enable, - { - embedding_model_name: 'next-model', - embedding_provider_name: 'next-provider', - }, - 0.7, - ) - expect(updateAnnotationScoreMock).toHaveBeenCalledWith({ - params: { app_id: appDetail.id, annotation_setting_id: 'config-id' }, - body: { score_threshold: 0.7 }, - }) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'common.api.actionSuccess', - type: 'success', - }), - ) - }) + }, + }) + expect(api.job).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id', action: 'disable', job_id: 'job-1' }, + }) +}) + +it('enables replies with the selected model and saves the score to the returned settings id', async () => { + const user = setup() + await user.click(await screen.findByRole('switch')) + const dialog = await screen.findByRole('dialog', { name: 'appAnnotation.initSetup.title' }) + await user.click(within(dialog).getByRole('button', { name: /Ada Embedding/ })) + await user.click(await screen.findByRole('button', { name: /English Embedding/ })) + within(dialog).getByRole('slider').focus() + await user.keyboard('{ArrowRight}') + expect(within(dialog).getByRole('slider')).toHaveAttribute('aria-valuenow', '0.51') + await user.click( + within(dialog).getByRole('button', { name: 'appAnnotation.initSetup.confirmBtn' }), + ) + await waitFor(() => expect(dialog).not.toBeInTheDocument()) + expect(screen.getByRole('switch')).toBeChecked() + expect(api.status).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id', action: 'enable' }, + body: { + embedding_provider_name: 'cohere', + embedding_model_name: 'embed-english', + score_threshold: 0.51, + }, + }) + expect(api.job).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id', action: 'enable', job_id: 'job-1' }, }) - - it('should refresh the list from the header shortcut and allow saving or closing the view modal', async () => { - const user = userEvent.setup() - const annotation = createAnnotation() - fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 }) - - renderComponent() - - await screen.findByTestId('list') - await user.click(screen.getByTestId('list-view')) - - await user.click(screen.getByTestId('view-modal-save')) - - await waitFor(() => { - expect(editAnnotationMock).toHaveBeenCalledWith({ - params: { app_id: appDetail.id, annotation_id: annotation.id }, - body: { question: 'Edited question', answer: 'Edited answer' }, - }) - }) - - await user.click(screen.getByTestId('view-modal-close')) - expect(screen.queryByTestId('view-modal')).not.toBeInTheDocument() - - const fetchCount = fetchAnnotationListMock.mock.calls.length - await user.click(screen.getByTestId('trigger-added')) - await waitFor(() => - expect(fetchAnnotationListMock.mock.calls.length).toBeGreaterThan(fetchCount), - ) + expect(api.score).toHaveBeenCalledExactlyOnceWith({ + params: { app_id: 'app-id', annotation_setting_id: 'enabled-config-id' }, + body: { score_threshold: 0.51 }, }) }) diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx index a85114f3810..817e06d94ff 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx @@ -1,78 +1,43 @@ -import type { ComponentProps } from 'react' -import type { Mock } from 'vite-plus/test' import type { Locale } from '@/i18n' import { render, screen } from '@testing-library/react' -import * as React from 'react' -import { useLocale } from '#i18n' +import userEvent from '@testing-library/user-event' import { LanguagesSupported } from '@/i18n/language' +import { captureDownloads } from '@/test/download' import CSVDownload from '../csv-downloader' +let locale: Locale = 'en-US' vi.mock('#i18n', async (importOriginal) => ({ ...(await importOriginal()), - useLocale: vi.fn(() => 'en-US'), + useLocale: () => locale, })) -const downloaderProps: ComponentProps< - ReturnType['CSVDownloader'] ->[] = [] - -vi.mock('react-papaparse', () => ({ - useCSVDownloader: vi.fn(() => ({ - CSVDownloader: ({ - children, - ...props - }: ComponentProps< - ReturnType['CSVDownloader'] - >) => { - downloaderProps.push(props) - return
{children}
- }, - Type: { Link: 'link' }, - })), -})) - -const renderWithLocale = (locale: Locale) => { - ;(useLocale as Mock).mockReturnValue(locale) - return render() -} - describe('CSVDownload', () => { - const englishTemplate = [ - ['question', 'answer'], - ['question1', 'answer1'], - ['question2', 'answer2'], - ] - const chineseTemplate = [ - ['问题', '答案'], - ['问题 1', '答案 1'], - ['问题 2', '答案 2'], - ] - - beforeEach(() => { - downloaderProps.length = 0 + afterEach(() => { + vi.restoreAllMocks() }) - it('should render the structure preview and pass English template data by default', () => { - renderWithLocale('en-US' as Locale) + it.each([ + { + language: 'en-US' as Locale, + csv: 'question,answer\r\nquestion1,answer1\r\nquestion2,answer2', + }, + { + language: LanguagesSupported[1] as Locale, + csv: '问题,答案\r\n问题 1,答案 1\r\n问题 2,答案 2', + }, + ])('downloads the $language annotation template as CSV', async ({ language, csv }) => { + const user = userEvent.setup() + locale = language + const downloads = captureDownloads() + render() - expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument() - expect(screen.getByText('appAnnotation.batchModal.template')).toBeInTheDocument() + expect(screen.getByText('share.generation.csvStructureTitle')).toBeVisible() + await user.click(screen.getByText('appAnnotation.batchModal.template')) - expect(downloaderProps[0]).toMatchObject({ - filename: 'template-en-US', - type: 'link', - bom: true, - data: englishTemplate, - }) - }) - - it('should switch to the Chinese template when locale matches the secondary language', () => { - const locale = LanguagesSupported[1] as Locale - renderWithLocale(locale) - - expect(downloaderProps[0]).toMatchObject({ - filename: `template-${locale}`, - data: chineseTemplate, - }) + expect(downloads).toHaveLength(1) + const { filename, blob } = downloads[0]! + expect(filename).toBe(`template-${language}.csv`) + expect(blob.type).toBe('text/csv;charset=utf-8;') + expect(await blob.text()).toBe(`\uFEFF${csv}`) }) }) diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx index fe4bceb5322..eaf6602ce22 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Textarea } from '@langgenius/dify-ui/textarea' -import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -24,7 +23,7 @@ type Props = Readonly<{ export const EditTitle: FC<{ className?: string; title: string }> = ({ className, title }) => (
- +
{title}
= ({ type, readonly, content, onSave }) => { )}
{!readonly && ( -
{ setIsEdit(true) }} > - -
{t(($) => $['operation.edit'], { ns: 'common' })}
-
+ + {t(($) => $['operation.edit'], { ns: 'common' })} + )} {showNewContent && (
·
-
{ try { @@ -121,11 +122,11 @@ const EditItem: FC = ({ type, readonly, content, onSave }) => { } }} > -
- -
-
{t(($) => $['operation.delete'], { ns: 'common' })}
-
+ + + + {t(($) => $['operation.delete'], { ns: 'common' })} +
)}
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 ebc847fa510..e78fee5a90a 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -1,1163 +1,580 @@ +import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen' import type { ComponentProps } from 'react' -import { EnvironmentStatus, RuntimeState } from '@dify/contracts/enterprise-app-deploy/types.gen' -import { act, screen, waitFor } from '@testing-library/react' +import type { Socket } from 'socket.io-client' +import { EventEmitter } from 'node:events' +import { EnvironmentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { detectPlatform } from '@tanstack/react-hotkeys' +import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' +import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' import { createWorkflowStore } from '@/app/components/workflow/store' +import { toast } from '@/app/notifications' import { AccessMode } from '@/models/access-control' import { consoleQuery } from '@/service/console' import { appWorkflowQueryOptions, appWorkflowVersionsInfiniteQueryOptions, } from '@/service/workflow-queries' -import { - createConsoleQueryClient, - renderWithConsoleQuery as renderWithQuery, -} from '@/test/console/query-data' -import { createAppDetailFixture } from '@/test/fixtures/app' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { createDeferred } from '@/test/deferred' +import { createAppDetailFixture, createAppSiteFixture } from '@/test/fixtures/app' import { createWorkflowResponseFixture } from '@/test/fixtures/workflow-response' import { AppACLPermission } from '@/utils/permission' import { basePath } from '@/utils/var' import { AppPublisher } from '../index' -let mockAppDetail: Record | null = null - -const renderWithConsoleQuery = ( - ui: React.ReactElement, - options: Parameters[1] = {}, -) => { - const queryClient = options?.queryClient ?? createConsoleQueryClient() - const key = consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }) - if (mockAppDetail) queryClient.setQueryData(key, createAppDetailFixture(mockAppDetail)) - else queryClient.setQueryDefaults(key, { queryFn: () => new Promise(() => {}) }) - return renderWithQuery(ui, { ...options, queryClient }) -} - -const render = ( - ui: React.ReactElement, - queryClient?: ReturnType, -) => - renderWithConsoleQuery(ui, { - queryClient, - systemFeatures: { webapp_auth: { enabled: true } }, - }) - -const mockOnPublish = vi.fn() -const mockOnToggle = vi.fn() -const mockTrackEvent = vi.fn() -const mockRefetch = vi.fn() -const mockUseGetUserCanAccessApp = vi.fn() -const mockToastError = vi.fn() -const mockToastSuccess = vi.fn() -const mockWindowOpen = vi.fn() -const mockInvalidateAppWorkflow = vi.fn() -const mockUpdateWorkflow = vi.fn() -const mockFetchPublishedWorkflow = vi.fn() -let mockPublishedWorkflow: Record | null = null -let mockPublishedWorkflowQueryState = { - isError: false, - isLoading: false, - isSuccess: true, -} - -const sectionProps = vi.hoisted(() => ({ - summary: null as null | ComponentProps< - typeof import('../built-in-publisher/summary-section').PublisherSummarySection - >, - actions: null as null | ComponentProps< - typeof import('../built-in-publisher/actions-section').PublisherActionsSection - >, +const api = vi.hoisted(() => ({ + // WorkflowPublishApi.get returns null when unpublished; its generated response omits that case. + published: vi.fn<() => Promise>(), + environments: + vi.fn< + typeof import('@/service/console').consoleClient.enterprise.appDeploy.deploymentService.listAppEnvironments + >(), + deployments: + vi.fn< + typeof import('@/service/console').consoleClient.enterprise.appDeploy.deploymentService.listEnvironmentDeployments + >(), + marketplace: + vi.fn< + typeof import('@/service/console').consoleClient.apps.byAppId.publishToCreatorsPlatform.post + >(), + createTool: + vi.fn< + typeof import('@/service/console').consoleClient.workspaces.current.toolProvider.workflow.create.post + >(), + access: vi.fn(), + patch: vi.fn(), })) -const collaborationMocks = vi.hoisted(() => ({ - handler: undefined as - | ((update: { - type: 'app_publish_update' - userId: string - data: Record - timestamp: number - }) => void) - | undefined, -})) - -let mockWorkspacePermissionKeys: string[] = ['tool.manage'] - -vi.mock('@/hooks/use-format-time-from-now', () => ({ - useFormatTimeFromNow: () => ({ - formatTimeFromNow: () => 'moments ago', - }), -})) - -vi.mock('@/service/access-control/use-app-access-control', () => ({ - useGetUserCanAccessApp: (params: unknown) => { - mockUseGetUserCanAccessApp(params) - return { - data: { result: true }, - isLoading: false, - refetch: mockRefetch, - } - }, - useAppWhiteListSubjects: () => ({ - data: { groups: [], members: [] }, - isLoading: false, - }), -})) - -const mockPublishToCreatorsPlatform = vi.fn() -const mockCreateWorkflowToolProvider = vi.fn() - -vi.mock('@/service/tools', () => ({ - createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args), - saveWorkflowToolProvider: vi.fn(), -})) - -vi.mock('@/service/use-workflow', () => ({ - useAppWorkflow: () => ({ - data: mockPublishedWorkflow, - ...mockPublishedWorkflowQueryState, - }), - useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, - useUpdateWorkflow: () => ({ mutate: mockUpdateWorkflow }), -})) - -vi.mock('@/service/workflow-queries', async (importOriginal) => { - const original = await importOriginal() - - return { - ...original, - appWorkflowQueryOptions: (appId: string) => ({ - ...original.appWorkflowQueryOptions(appId), - queryFn: () => mockFetchPublishedWorkflow(appId), - }), - } -}) - -vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ - collaborationManager: { - onAppPublishUpdate: (handler: NonNullable<(typeof collaborationMocks)['handler']>) => { - collaborationMocks.handler = handler - return vi.fn() - }, - }, -})) - vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() - const { withConsoleResponses } = await import('@/test/console/service-responses') + const { withConsoleResponses, withConsoleClientResponses } = + await import('@/test/console/service-responses') return { ...actual, consoleQuery: withConsoleResponses(actual.consoleQuery, { - 'workspaces.current.toolProvider.workflow.get.get': async () => undefined, + 'apps.byAppId.workflows.publish.get': api.published, + 'enterprise.appDeploy.deploymentService.listAppEnvironments': api.environments, + 'enterprise.appDeploy.deploymentService.listEnvironmentDeployments': api.deployments, + 'enterprise.appDeploy.deploymentService.precheckWorkflowDeployment': async () => ({ + unsupported_nodes: [], + }), + 'enterprise.appDeploy.deploymentService.getWorkflowDeploymentOptions': async () => ({ + credential_slots: [], + environment_variable_groups: [], + }), + 'workspaces.current.tools.builtin.get': async () => [], + 'workspaces.current.tools.api.get': async () => [], + 'workspaces.current.tools.workflow.get': async () => [], + 'workspaces.current.tools.mcp.get': async () => [], + 'rag.pipelines.datasourcePlugins.get': async () => [], + }), + consoleClient: withConsoleClientResponses(actual.consoleClient, { + 'apps.byAppId.publishToCreatorsPlatform.post': api.marketplace, + 'workspaces.current.toolProvider.workflow.create.post': api.createTool, }), } }) - -vi.mock('@/context/workspace-state', async () => { - const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') - return createWorkspaceStateModuleMock(() => ({ - isCurrentWorkspaceManager: true, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) -vi.mock('@/context/permission-state', async () => { - const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') - return createPermissionStateModuleMock(() => ({ - isCurrentWorkspaceManager: true, - workspacePermissionKeys: mockWorkspacePermissionKeys, - })) -}) - -vi.mock('@/app/notifications', () => ({ - toast: { - error: (...args: unknown[]) => mockToastError(...args), - success: (...args: unknown[]) => mockToastSuccess(...args), - }, +vi.mock('@/service/share', async (importOriginal) => ({ + ...(await importOriginal()), + getUserCanAccess: api.access, +})) +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + patch: api.patch, })) -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: (...args: unknown[]) => mockTrackEvent(...args), -})) - -vi.mock('@/app/components/tools/workflow-tool', () => ({ - WorkflowToolDrawer: ({ - onCreate, - onHide, - }: { - onCreate?: (payload: Record) => void - onHide: () => void - }) => ( -
- workflow tool drawer - - -
- ), -})) - -vi.mock('../built-in-publisher/summary-section', () => ({ - PublisherSummarySection: ( - props: ComponentProps< - typeof import('../built-in-publisher/summary-section').PublisherSummarySection - >, - ) => { - sectionProps.summary = props - return ( -
- {props.environmentTabs} - - - -
+beforeEach(() => { + api.published + .mockReset() + .mockResolvedValue( + createWorkflowResponseFixture({ marked_name: 'Release 5', marked_comment: 'Initial notes' }), ) - }, -})) + api.environments.mockReset().mockResolvedValue({ data: [] }) + api.deployments.mockReset().mockResolvedValue({ environment_deployments: [] }) + api.marketplace + .mockReset() + .mockResolvedValue({ redirect_url: 'https://marketplace.example.com/publish?code=abc' }) + api.createTool.mockReset().mockResolvedValue({ result: 'success' }) + api.access.mockReset().mockResolvedValue({ result: true }) + api.patch.mockReset().mockResolvedValue(createWorkflowResponseFixture()) + vi.spyOn(window, 'open').mockImplementation(() => null) +}) +afterEach(() => vi.restoreAllMocks()) -vi.mock('../built-in-publisher/actions-section', () => ({ - PublisherActionsSection: ( - props: ComponentProps< - typeof import('../built-in-publisher/actions-section').PublisherActionsSection - >, - ) => { - sectionProps.actions = props - return ( -
- {props.showRunConfig && props.handleOpenRunConfig && ( - - )} - {props.showMarketplaceAction && ( - - )} - -
- ) - }, -})) - -describe('AppPublisher', () => { - beforeEach(() => { - vi.clearAllMocks() - mockOnPublish.mockReset().mockResolvedValue(undefined) - mockCreateWorkflowToolProvider.mockReset().mockResolvedValue({}) - mockUpdateWorkflow.mockReset() - collaborationMocks.handler = undefined - sectionProps.summary = null - sectionProps.actions = null - mockPublishedWorkflow = null - mockPublishedWorkflowQueryState = { - isError: false, - isLoading: false, - isSuccess: true, - } - mockFetchPublishedWorkflow.mockResolvedValue(null) - mockWorkspacePermissionKeys = ['tool.manage'] - mockAppDetail = { +function renderPublisher({ + props = {}, + app = {}, + queryClient = createConsoleQueryClient(), + workspacePermissionKeys = ['tool.manage'], + systemFeatures = {}, +}: { + props?: Partial> + app?: Parameters[0] + queryClient?: ReturnType + workspacePermissionKeys?: string[] + systemFeatures?: NonNullable[1]>['systemFeatures'] +} = {}) { + queryClient.setQueryData( + consoleQuery.apps.byAppId.get.queryKey({ input: { params: { app_id: 'app-1' } } }), + createAppDetailFixture({ id: 'app-1', name: 'Demo App', - mode: 'chat' as const, + mode: 'chat', maintainer: 'user-2', permission_keys: [], access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - site: { - app_base_url: 'https://example.com', - access_token: 'token-1', - }, - } - Object.defineProperty(window, 'open', { - configurable: true, - writable: true, - value: mockWindowOpen, - }) - }) - - it('should enable access permission query when the publish popover opens', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - - expect(screen.getByText('publisher-summary-publish'))!.toBeInTheDocument() - expect(mockOnToggle).toHaveBeenCalledWith(true) - - await waitFor(() => { - expect(mockUseGetUserCanAccessApp).toHaveBeenCalledWith({ - appId: 'app-1', - enabled: true, - }) - }) - expect(mockRefetch).not.toHaveBeenCalled() - }) - - it('should publish once per open session and reset the lock after reopening', async () => { - const user = userEvent.setup() - mockOnPublish.mockResolvedValue(undefined) - - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText('publisher-summary-publish')) - - await waitFor(() => { - expect(mockOnPublish).toHaveBeenCalledTimes(1) - expect(mockTrackEvent).toHaveBeenCalledWith( - 'app_published_time', - expect.objectContaining({ - action_mode: 'app', - app_id: 'app-1', - app_name: 'Demo App', - }), - ) - expect(sectionProps.summary?.published).toBe(true) - expect(screen.getByText('publisher-summary-publish')).toBeDisabled() - }) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(sectionProps.summary?.published).toBe(false) - expect(screen.getByText('publisher-summary-publish')).toBeEnabled() - }) - - it('should refresh deployment data after publishing when multi-environment deployment is available', async () => { - const user = userEvent.setup() - const queryClient = createConsoleQueryClient() - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - permission_keys: [AppACLPermission.Deploy], - } - mockOnPublish.mockResolvedValue(undefined) - const environmentsQuery = - consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ - enabled: true, - input: { - params: { - app_id: 'app-1', - }, - }, - }) - queryClient.setQueryDefaults(environmentsQuery.queryKey, { staleTime: Infinity }) - queryClient.setQueryData(environmentsQuery.queryKey, { data: [] }) - - renderWithConsoleQuery( - , - { - queryClient, - }, - ) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText('publisher-summary-publish')) - - const workflowVersionsQuery = appWorkflowVersionsInfiniteQueryOptions('app-1') - const environmentDeploymentsQuery = - consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions({ - input: { - params: { - app_id: 'app-1', - }, - }, - }) - await waitFor(() => { - expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1') - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: workflowVersionsQuery.queryKey, - }) - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: environmentDeploymentsQuery.queryKey, - }) - }) - }) - - it('should not refresh deployment data after publishing without multi-environment deployment', async () => { - const user = userEvent.setup() - const queryClient = createConsoleQueryClient() - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - permission_keys: [], - } - mockOnPublish.mockResolvedValue(undefined) - - renderWithConsoleQuery( - , - { - queryClient, - }, - ) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText('publisher-summary-publish')) - - await waitFor(() => { - expect(mockOnPublish).toHaveBeenCalledTimes(1) - }) - expect(invalidateQueries).not.toHaveBeenCalled() - }) - - it('should edit the current workflow version from the publish summary', async () => { - const user = userEvent.setup() - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - } - mockPublishedWorkflow = { - created_at: Math.floor(Date.now() / 1000), - id: 'workflow-version-5', - marked_name: 'Release 5', - marked_comment: 'Initial notes', - } - - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(sectionProps.summary).toEqual( - expect.objectContaining({ - isWorkflowApp: true, - versionInfo: mockPublishedWorkflow, - }), - ) - await user.click(screen.getByText('publisher-summary-edit-version')) - - expect(screen.queryByText('publisher-summary-edit-version')).not.toBeInTheDocument() - const [titleInput, notesInput] = screen.getAllByRole('textbox') - await user.clear(titleInput!) - await user.type(titleInput!, 'Release 6') - await user.clear(notesInput!) - await user.type(notesInput!, 'Updated notes') - await user.click( - screen.getByRole('button', { - name: /(?:^|\.)operation\.save(?=$|:)/, - }), - ) - - expect(mockUpdateWorkflow).toHaveBeenCalledWith( - { - appId: 'app-1', - appMode: 'workflow' as const, - url: '/apps/app-1/workflows/workflow-version-5', - title: 'Release 6', - releaseNotes: 'Updated notes', - }, - expect.objectContaining({ - onSuccess: expect.any(Function), - onError: expect.any(Function), - onSettled: expect.any(Function), - }), - ) - - const mutationCallbacks = mockUpdateWorkflow.mock.calls[0]![1] - act(() => mutationCallbacks.onSuccess()) - expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1') - expect(mockToastSuccess).toHaveBeenCalled() - }) - - it('should expose app deployment with deploy ACL regardless of the legacy workspace role', async () => { - const user = userEvent.setup() - const queryClient = createConsoleQueryClient() - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - permission_keys: [AppACLPermission.Deploy], - } - const environmentsQuery = - consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ - enabled: true, - input: { - params: { - app_id: 'app-1', - }, - }, - }) - queryClient.setQueryDefaults(environmentsQuery.queryKey, { staleTime: Infinity }) - queryClient.setQueryData(environmentsQuery.queryKey, { data: [] }) - - renderWithConsoleQuery(, { + site: createAppSiteFixture({ app_base_url: 'https://example.com', access_token: 'token-1' }), + ...app, + }), + ) + const store = createWorkflowStore({}) + renderWithConsoleQuery( + + + + , + { queryClient, - systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: false }, - }) + workspacePermissionKeys, + systemFeatures: { webapp_auth: { enabled: true }, ...systemFeatures }, + }, + ) + return { queryClient, store } +} +const trigger = () => screen.getByRole('button', { name: 'workflow.common.publish' }) +const publishUpdate = () => screen.getByRole('button', { name: 'workflow.common.publishUpdate' }) +const environments = [ + { + description: '', + display_name: 'Staging', + id: 'staging', + in_use: false, + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + }, +] - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) +it('checks web app access only when opened and disables the link when access is denied', async () => { + const user = userEvent.setup() + const access = createDeferred<{ result: boolean }>() + api.access.mockReturnValueOnce(access.promise) + renderPublisher() + expect(api.access).not.toHaveBeenCalled() + await user.click(trigger()) + expect(screen.getByRole('button', { name: 'workflow.common.openWebApp' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + await act(async () => access.resolve({ result: true })) + expect(await screen.findByRole('link', { name: 'workflow.common.openWebApp' })).toHaveAttribute( + 'href', + `https://example.com${basePath}/chat/token-1`, + ) + await user.click(trigger()) + api.access.mockResolvedValue({ result: false }) + await user.click(trigger()) + expect(await screen.findByRole('button', { name: 'workflow.common.openWebApp' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + expect(api.access).toHaveBeenCalledWith('app-1', true) +}) - expect(sectionProps.actions?.showDeployAction).toBe(true) - expect(sectionProps.actions?.appURL).toContain('/workflow/token-1') - expect(screen.getByRole('group', { name: /studio\.environments/ })).toBeInTheDocument() - expect( - screen.getByRole('button', { name: /nodes\.common\.memories\.builtIn/ }), - ).toHaveAttribute('aria-current', 'true') +it('publishes once per open session and permits another update after reopening', async () => { + const user = userEvent.setup() + const onPublish = vi.fn().mockResolvedValue(undefined) + renderPublisher({ props: { onPublish } }) + await user.click(trigger()) + await user.click(publishUpdate()) + expect(await screen.findByRole('button', { name: 'workflow.common.published' })).toBeDisabled() + expect(onPublish).toHaveBeenCalledOnce() + await user.click(trigger()) + expect( + screen.queryByRole('button', { name: 'workflow.common.published' }), + ).not.toBeInTheDocument() + await user.click(trigger()) + await user.click(publishUpdate()) + await waitFor(() => expect(onPublish).toHaveBeenCalledTimes(2)) +}) + +it('refreshes workflow and deployment caches after publishing with deployment access', async () => { + const user = userEvent.setup() + const { queryClient } = renderPublisher({ + app: { mode: 'workflow', permission_keys: [AppACLPermission.Deploy] }, + props: { onPublish: vi.fn().mockResolvedValue(undefined) }, }) + const versionsKey = appWorkflowVersionsInfiniteQueryOptions('app-1').queryKey + const deploymentsKey = + consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions({ + input: { params: { app_id: 'app-1' } }, + }).queryKey + queryClient.setQueryData(versionsKey, { pages: [], pageParams: [] }) + queryClient.setQueryData(deploymentsKey, { environment_deployments: [] }) + await user.click(trigger()) + expect(await screen.findByText('Release 5')).toBeInTheDocument() + const updated = createWorkflowResponseFixture({ marked_name: 'Release 6' }) + api.published.mockResolvedValue(updated) + await user.click(publishUpdate()) + expect(await screen.findByText('Release 6')).toBeInTheDocument() + expect(queryClient.getQueryState(versionsKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(deploymentsKey)?.isInvalidated).toBe(true) +}) - it('should keep the workflow publisher single-environment without app deploy ACL', async () => { - const user = userEvent.setup() - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - permission_keys: [], - } - - renderWithConsoleQuery(, { - systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: false }, - }) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - - expect(sectionProps.actions?.showDeployAction).toBe(false) - expect(screen.queryByRole('group', { name: /studio\.environments/ })).not.toBeInTheDocument() - expect(sectionProps.summary?.environmentTabs).toBeUndefined() +it('edits a published workflow version and shows the saved title and notes when reopened', async () => { + const user = userEvent.setup() + const saved = createWorkflowResponseFixture({ + marked_name: 'Release 6', + marked_comment: 'Updated notes', }) + renderPublisher({ app: { mode: 'workflow' } }) + await user.click(trigger()) + await user.click( + await screen.findByRole('button', { name: 'workflowHistory.versionHistory.editVersionInfo' }), + ) + const [title, notes] = screen.getAllByRole('textbox') + await user.clear(title!) + await user.type(title!, 'Release 6') + await user.clear(notes!) + await user.type(notes!, 'Updated notes') + api.patch.mockResolvedValue(saved) + api.published.mockResolvedValue(saved) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => + expect(api.patch).toHaveBeenCalledWith('/apps/app-1/workflows/workflow-1', { + body: { marked_name: 'Release 6', marked_comment: 'Updated notes' }, + }), + ) + await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument()) + await user.click(trigger()) + expect(await screen.findByText('Release 6')).toBeInTheDocument() + expect(screen.getByText('Updated notes')).toBeInTheDocument() +}) - it('should keep the single-environment publisher for unsupported app types', async () => { +it.each([ + { mode: 'workflow' as const, permission_keys: [AppACLPermission.Deploy], available: true }, + { mode: 'workflow' as const, permission_keys: [], available: false }, + { mode: 'chat' as const, permission_keys: [AppACLPermission.Deploy], available: false }, +])( + 'offers environment deployment for $mode with permissions $permission_keys', + async ({ mode, permission_keys, available }) => { const user = userEvent.setup() - renderWithConsoleQuery(, { - systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true }, + renderPublisher({ + app: { mode, permission_keys }, + workspacePermissionKeys: [], + systemFeatures: { enable_app_deploy: false }, }) + await user.click(trigger()) + if (mode === 'workflow') await screen.findByText('Release 5') + if (available) { + expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute( + 'href', + `${basePath}/app/app-1/deploy`, + ) + expect(screen.getByRole('group', { name: /studio.environments/ })).toBeInTheDocument() + } else { + expect(screen.queryByRole('link', { name: 'common.appMenus.deploy' })).not.toBeInTheDocument() + expect(screen.queryByRole('group', { name: /studio.environments/ })).not.toBeInTheDocument() + expect(api.environments).not.toHaveBeenCalled() + } + }, +) - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - - expect(screen.queryByRole('group', { name: /studio\.environments/ })).not.toBeInTheDocument() - expect(sectionProps.summary?.environmentTabs).toBeUndefined() +it('loads an unused environment without requesting a deployment and enables deploying a published version', async () => { + const user = userEvent.setup() + const unexpectedRequest = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Unexpected network request')) + const published = createDeferred() + api.published.mockReturnValueOnce(published.promise) + api.environments.mockResolvedValue({ data: environments }) + const { queryClient } = renderPublisher({ + app: { mode: 'workflow', permission_keys: [AppACLPermission.Deploy] }, }) + await user.click(trigger()) + await user.click( + await screen.findByRole('button', { name: /studio.moreEnvironments|operation.more/ }), + ) + await user.click(screen.getByRole('menuitem', { name: 'Staging' })) + expect(await screen.findByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument() + await act(async () => published.resolve(null)) + expect(await screen.findByText(/studio.accessPoint.noPublishedTitle/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /studio.accessPoint.goToPublish/ })).toBeEnabled() + api.published.mockResolvedValue(createWorkflowResponseFixture({ marked_name: 'Release 6' })) + await act(async () => { + await queryClient.invalidateQueries({ queryKey: appWorkflowQueryOptions('app-1').queryKey }) + }) + expect(await screen.findByText(/studio.publisher.notDeployedYet/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /studio.deployLatest/ })).toBeEnabled() + expect(screen.getByText(/Release 6/)).toBeInTheDocument() + expect(unexpectedRequest).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: /nodes.common.memories.builtIn/ })) + expect(publishUpdate()).toBeEnabled() +}) - it('should show the matching publisher state for an undeployed environment', async () => { - const user = userEvent.setup() - const queryClient = createConsoleQueryClient() - const publishedAt = Date.now() - const environmentDeploymentDetailRequests: Request[] = [] - const environmentDeploymentListRequests: Request[] = [] - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { - const request = input instanceof Request ? input : new Request(input, init) - const pathname = new URL(request.url).pathname - if ( - pathname.endsWith('/enterprise/app-deploy/apps/app-1/workflows/environment-deployments') - ) { - environmentDeploymentListRequests.push(request.clone()) - return new Response( - JSON.stringify({ - environment_deployments: [], - }), - { - headers: { 'Content-Type': 'application/json' }, - status: 200, - }, - ) - } +it('returns to the built-in environment when the publisher is closed and reopened', async () => { + const user = userEvent.setup() + api.environments.mockResolvedValue({ data: environments }) + renderPublisher({ app: { mode: 'workflow', permission_keys: [AppACLPermission.Deploy] } }) + await user.click(trigger()) + await user.click( + await screen.findByRole('button', { name: /studio.moreEnvironments|operation.more/ }), + ) + await user.click(screen.getByRole('menuitem', { name: 'Staging' })) + expect(await screen.findByText(/studio.publisher.notDeployedYet/)).toBeInTheDocument() + await user.click(trigger()) + expect(screen.queryByRole('button', { name: 'Staging' })).not.toBeInTheDocument() + await user.click(trigger()) + expect(screen.getByRole('button', { name: /nodes.common.memories.builtIn/ })).toHaveAttribute( + 'aria-current', + 'true', + ) + expect(publishUpdate()).toBeEnabled() +}) - if ( - pathname.endsWith( - '/enterprise/app-deploy/apps/app-1/workflows/environment-deployments/pre-release', - ) - ) { - environmentDeploymentDetailRequests.push(request.clone()) - throw new Error('The undeployed environment detail endpoint must not be queried.') - } - - throw new Error(`Unexpected request: ${request.method} ${request.url}`) - }) - mockPublishedWorkflowQueryState = { - isError: false, - isLoading: true, - isSuccess: false, - } - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - permission_keys: [AppACLPermission.Deploy], - } - const environmentsQuery = - consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ - enabled: true, - input: { - params: { - app_id: 'app-1', - }, - }, - }) - queryClient.setQueryDefaults(environmentsQuery.queryKey, { staleTime: Infinity }) - queryClient.setQueryData(environmentsQuery.queryKey, { - data: [ +it('collects hidden inputs before opening the web app from its config action', async () => { + const user = userEvent.setup() + renderPublisher({ + props: { + inputs: [ { - description: '', - display_name: 'Staging', - id: 'staging', - in_use: true, - status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, - }, - { - description: '', - display_name: 'Canary', - id: 'canary', - in_use: true, - status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, - }, - { - description: '', - display_name: 'Pre-release', - id: 'pre-release', - in_use: false, - status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + variable: 'secret', + label: 'Secret', + type: 'text-input', + required: true, + hide: true, + default: '', }, ], - }) - const { rerender } = renderWithConsoleQuery( - , - { - queryClient, - systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true }, - }, - ) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click( - screen.getByRole('button', { - name: /(?:studio\.moreEnvironments|operation\.more)/, - }), - ) - await user.click(screen.getByRole('menuitem', { name: 'Pre-release' })) - - await waitFor(() => { - expect(environmentDeploymentListRequests.length).toBeGreaterThan(0) - }) - expect(environmentDeploymentDetailRequests).toHaveLength(0) - expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument() - expect(screen.queryByText(/studio\.accessPoint\.noPublishedTitle/)).not.toBeInTheDocument() - expect(screen.queryByText(/studio\.publisher\.notDeployedYet/)).not.toBeInTheDocument() - - mockPublishedWorkflowQueryState = { - isError: false, - isLoading: false, - isSuccess: true, - } - rerender() - - expect(await screen.findByText(/studio\.accessPoint\.noPublishedTitle/)).toBeInTheDocument() - expect(screen.getByText(/studio\.publisher\.noPublishedDescription/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /studio\.accessPoint\.goToPublish/ })).toBeEnabled() - expect(screen.queryByText(/studio\.publisher\.notDeployedYet/)).not.toBeInTheDocument() - - mockPublishedWorkflow = { - created_at: 1_710_000_100, - id: 'workflow-version-5', - marked_comment: '', - marked_name: 'Release 5', - version: 'v5', - } - rerender() - - expect(await screen.findByText(/studio\.publisher\.notDeployedYet/)).toBeInTheDocument() - expect(screen.queryByText(/studio\.publisher\.noPublishedDescription/)).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: /studio\.deployLatest/ })).toBeEnabled() - expect(screen.getByText(/overview\.chip\.latest/).parentElement).toHaveTextContent( - 'overview.chip.latest: Release 5', - ) - expect(screen.getByRole('button', { name: /studio\.allVersions/ })).toBeInTheDocument() - expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: /nodes\.common\.memories\.builtIn/ })) - - expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument() + }, }) + await user.click(trigger()) + await user.click(await screen.findByRole('button', { name: 'common.operation.config' })) + await user.type(screen.getByLabelText('Secret'), 'top-secret') + await user.click(screen.getByRole('button', { name: 'appOverview.overview.appInfo.launch' })) + expect(window.open).toHaveBeenCalledWith( + `https://example.com${basePath}/chat/token-1?secret=top-secret`, + '_blank', + ) +}) - it('returns to the built-in environment when reopened', async () => { +it.each(['publish', 'create'] as const)( + 'keeps the tool form for retry when %s fails', + async (stage) => { const user = userEvent.setup() - const queryClient = createConsoleQueryClient() - const detailRequests: Request[] = [] - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - permission_keys: [AppACLPermission.Deploy], - } - const environmentsQuery = - consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ - enabled: true, - input: { - params: { - app_id: 'app-1', - }, - }, - }) - queryClient.setQueryDefaults(environmentsQuery.queryKey, { staleTime: Infinity }) - queryClient.setQueryData(environmentsQuery.queryKey, { - data: [ - { - description: '', - display_name: 'Staging', - id: 'staging', - in_use: true, - status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, - }, - ], - }) - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { - const request = input instanceof Request ? input : new Request(input, init) - if ( - new URL(request.url).pathname.endsWith( - '/enterprise/app-deploy/apps/app-1/workflows/environment-deployments/staging', - ) - ) { - detailRequests.push(request.clone()) - return new Response( - JSON.stringify({ - environment_deployment: { - access: { - enable_api: true, - enable_site: true, - }, - deployment: { - current_version: { - id: 'workflow-version-5', - marked_comment: '', - marked_name: 'Release 5', - version: 'v5', - }, - runtimeState: RuntimeState.RUNTIME_STATE_STARTING, - }, - environment: { - description: '', - display_name: 'Staging', - id: 'staging', - status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, - }, - }, - }), - { - headers: { 'Content-Type': 'application/json' }, - status: 200, - }, - ) - } - - throw new Error(`Unexpected request: ${request.method} ${request.url}`) - }) - - renderWithConsoleQuery(, { - queryClient, - systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true }, - }) - - const publishButton = screen.getByRole('button', { - name: /(?:^|\.)common\.publish(?=$|:)/, - }) - expect(publishButton).toHaveAttribute('aria-expanded', 'false') - - await user.click(publishButton) - expect(publishButton).toHaveAttribute('aria-expanded', 'true') - const stagingButton = screen.getByRole('button', { name: 'Staging' }) - expect(stagingButton).toBeInTheDocument() - - await user.click(stagingButton) - await waitFor(() => { - expect(detailRequests.length).toBeGreaterThan(0) - }) - expect(screen.getByRole('button', { name: 'Staging' })).toHaveAttribute('aria-current', 'true') - - await user.click(publishButton) - - expect(publishButton).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByRole('button', { name: 'Staging' })).not.toBeInTheDocument() - - await user.click(publishButton) - expect(publishButton).toHaveAttribute('aria-expanded', 'true') + const onPublish = vi.fn().mockResolvedValue(undefined) + const success = vi.spyOn(toast, 'success') + const error = vi.spyOn(toast, 'error') + if (stage === 'publish') onPublish.mockRejectedValueOnce(new Error('publish failed')) + else api.createTool.mockRejectedValueOnce(new Error('create failed')) + renderPublisher({ app: { mode: 'workflow' }, props: { onPublish } }) + await user.click(trigger()) + await screen.findByText('Release 5') + await user.click(screen.getByRole('button', { name: /navigation.common.workflowAsTool/ })) + const drawer = screen.getByRole('dialog', { name: 'navigation.common.workflowAsTool' }) expect( - screen.getByRole('button', { name: /nodes\.common\.memories\.builtIn/ }), - ).toHaveAttribute('aria-current', 'true') - expect(screen.getByRole('button', { name: 'Staging' })).not.toHaveAttribute( - 'aria-current', - 'true', - ) - }) - - it('should collect hidden inputs before opening the web app from its config action', async () => { - const user = userEvent.setup() - render( - , - ) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - - expect(sectionProps.actions?.showRunConfig).toBe(true) - await user.click(screen.getByText('publisher-run-config')) - - expect( - screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), - ).toBeInTheDocument() - - await user.type(screen.getByLabelText('Secret'), 'top-secret') - await user.click( - screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }), - ) - - await waitFor(() => { - expect(mockWindowOpen).toHaveBeenCalledWith( - `https://example.com${basePath}/chat/token-1?secret=${encodeURIComponent('top-secret')}`, - '_blank', - ) - }) - }) - - it('should keep workflow tool drawer mounted after closing the publish popover', async () => { - const user = userEvent.setup() - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - } - - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(sectionProps.actions).toEqual( - expect.objectContaining({ - toolPublished: true, - workflowToolOutdated: false, - }), - ) - await user.click(screen.getByText('publisher-workflow-tool')) - - expect(screen.queryByText('publisher-workflow-tool')).not.toBeInTheDocument() - expect(screen.getByRole('dialog', { name: 'Workflow tool drawer' })).toBeInTheDocument() - }) - - it('should show one success toast when automatically publishing a workflow tool', async () => { - const user = userEvent.setup() - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - } - mockOnPublish.mockImplementation(async (_params, options?: { showSuccessToast?: boolean }) => { - if (options?.showSuccessToast !== false) mockToastSuccess('common.api.actionSuccess') - }) - mockCreateWorkflowToolProvider.mockResolvedValue({}) - - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText('publisher-workflow-tool')) - await user.click(screen.getByRole('button', { name: 'create-workflow-tool' })) - - await waitFor(() => { - expect(mockCreateWorkflowToolProvider).toHaveBeenCalledOnce() - }) - expect(mockOnPublish).toHaveBeenCalledWith(undefined, { showSuccessToast: false }) - expect(mockToastSuccess).toHaveBeenCalledOnce() - }) - - it('should not show a success toast when workflow tool creation fails after publishing', async () => { - const user = userEvent.setup() - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - } - mockOnPublish.mockImplementation(async (_params, options?: { showSuccessToast?: boolean }) => { - if (options?.showSuccessToast !== false) mockToastSuccess('common.api.actionSuccess') - }) - mockCreateWorkflowToolProvider.mockRejectedValue(new Error('create failed')) - - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText('publisher-workflow-tool')) - await user.click(screen.getByRole('button', { name: 'create-workflow-tool' })) - - await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith('create failed') - }) - expect(mockOnPublish).toHaveBeenCalledWith(undefined, { showSuccessToast: false }) - expect(mockToastSuccess).not.toHaveBeenCalled() - }) - - it('should not create a workflow tool when automatic publishing fails', async () => { - const user = userEvent.setup() - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - } - mockOnPublish.mockRejectedValueOnce(new Error('publish failed')) - - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText('publisher-workflow-tool')) - await user.click(screen.getByRole('button', { name: 'create-workflow-tool' })) - - await waitFor(() => { - expect(mockOnPublish).toHaveBeenCalledOnce() - }) - expect(mockCreateWorkflowToolProvider).not.toHaveBeenCalled() - expect(mockToastError).toHaveBeenCalledWith('publish failed') - consoleWarnSpy.mockRestore() - }) - - it('should not open workflow tool drawer without tool.manage', async () => { - const user = userEvent.setup() - mockWorkspacePermissionKeys = [] - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - } - - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText('publisher-workflow-tool')) - - expect(screen.queryByRole('dialog', { name: 'Workflow tool drawer' })).not.toBeInTheDocument() - expect(sectionProps.actions?.workflowToolAvailable).toBe(false) - }) - - it('should ignore the trigger when the publish button is disabled', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) - - expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() - expect(mockOnToggle).not.toHaveBeenCalled() - }) - - it('should keep the popover open when restore and publish fail', async () => { - const user = userEvent.setup() - const onRestore = vi.fn().mockRejectedValue(new Error('restore failed')) - mockOnPublish.mockRejectedValueOnce(new Error('publish failed')) - - render( - , - ) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText('publisher-summary-publish')) - await waitFor(() => expect(mockOnPublish).toHaveBeenCalledTimes(1)) - expect(mockTrackEvent).not.toHaveBeenCalled() - await user.click(screen.getByText('publisher-summary-restore')) - - await waitFor(() => { - expect(onRestore).toHaveBeenCalledTimes(1) - }) - expect(screen.getByText('publisher-summary-publish'))!.toBeInTheDocument() - }) - - it('should show marketplace button and open redirect URL on success', async () => { - const user = userEvent.setup() - vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { - const request = new Request(input, init) - if (new URL(request.url).pathname.endsWith('/apps/app-1/publish-to-creators-platform')) { - mockPublishToCreatorsPlatform(request.method) - return Response.json({ redirect_url: 'https://marketplace.example.com/publish?code=abc' }) - } - throw new Error(`Unexpected request: ${request.method} ${request.url}`) - }) - const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) - - renderWithConsoleQuery( - , - { - systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true }, - }, - ) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(sectionProps.actions).toEqual( - expect.objectContaining({ - marketplaceActionDisabled: false, - showMarketplaceAction: true, - }), - ) - await user.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)) - - await waitFor(() => { - expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith('POST') - expect(windowOpenSpy).toHaveBeenCalledWith( - 'https://marketplace.example.com/publish?code=abc', - '_blank', - ) - }) - - windowOpenSpy.mockRestore() - }) - - it('should show toast error when publish to marketplace fails', async () => { - const user = userEvent.setup() - vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error')) - - renderWithConsoleQuery( - , - { - systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true }, - }, - ) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - await user.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)) - - await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith( - expect.stringMatching(/(?:^|\.)common\.publishToMarketplaceFailed(?=$|:)/), - ) - }) - }) - - it('should disable marketplace button when not yet published', async () => { - const user = userEvent.setup() - renderWithConsoleQuery(, { - systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true }, - }) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(sectionProps.actions).toEqual( - expect.objectContaining({ - marketplaceActionDisabled: true, - showMarketplaceAction: true, - }), - ) - const marketplaceButton = screen.getByRole('button', { name: /common\.publishToMarketplace/ }) - expect(marketplaceButton).toBeDisabled() - await user.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)) - expect(mockPublishToCreatorsPlatform).not.toHaveBeenCalled() - }) - - it('should hide marketplace button when enable_creators_platform is false', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(sectionProps.actions?.showMarketplaceAction).toBe(false) - expect( - screen.queryByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/), + screen.queryByRole('button', { name: 'workflow.common.publishUpdate' }), ).not.toBeInTheDocument() - }) - - it('waits for app details before exposing publishing actions', () => { - mockAppDetail = null - render() - expect(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })).toBeDisabled() - expect(sectionProps.summary).toBeNull() - }) - - it('should keep workflow publishing available for an existing published version', async () => { - const user = userEvent.setup() - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - } - mockPublishedWorkflow = { - created_at: 1_710_000_100, - hash: 'published-hash', - } - - mockOnPublish.mockResolvedValue(undefined) - render( - , + await user.type( + within(drawer).getByRole('textbox', { name: 'tools.createTool.nameForToolCall' }), + 'workflow_tool', ) - - await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - expect(sectionProps.summary).toEqual( - expect.objectContaining({ - published: false, - publishedAt: 1_710_000_100_000, - }), + await user.click(within(drawer).getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => expect(error).toHaveBeenCalledWith(`${stage} failed`)) + expect(success).not.toHaveBeenCalled() + if (stage === 'publish') expect(api.createTool).not.toHaveBeenCalled() + expect( + within(drawer).getByRole('textbox', { name: 'tools.createTool.nameForToolCall' }), + ).toHaveValue('workflow_tool') + await user.click(within(drawer).getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: 'navigation.common.workflowAsTool' }), + ).not.toBeInTheDocument(), ) - await user.click(screen.getByText('publisher-summary-publish')) - await waitFor(() => expect(mockOnPublish).toHaveBeenCalledOnce()) - }) - - it('should refresh the shared workflow query and store after a collaborator publishes', async () => { - const setPublishedAt = vi.fn() - const queryClient = createConsoleQueryClient() - const workflowStore = createWorkflowStore({}) - workflowStore.setState({ setPublishedAt }) - mockAppDetail = { - ...mockAppDetail, - mode: 'workflow' as const, - } - mockFetchPublishedWorkflow.mockResolvedValue({ - created_at: 1_710_000_300, - hash: 'published-hash', + expect(api.createTool).toHaveBeenCalledWith({ + body: expect.objectContaining({ workflow_app_id: 'app-1', name: 'workflow_tool' }), }) - queryClient.setQueryData( - appWorkflowQueryOptions('app-1').queryKey, + expect(success).toHaveBeenCalledOnce() + }, +) + +it('disables configuring a workflow tool without tool.manage', async () => { + const user = userEvent.setup() + renderPublisher({ app: { mode: 'workflow' }, workspacePermissionKeys: [] }) + await user.click(trigger()) + await screen.findByText('Release 5') + expect(screen.getByRole('button', { name: /navigation.common.workflowAsTool/ })).toBeDisabled() +}) + +it('keeps failed publish and restore actions available for retry', async () => { + const user = userEvent.setup() + const onPublish = vi + .fn() + .mockRejectedValueOnce(new Error('publish failed')) + .mockResolvedValue(undefined) + const onRestore = vi + .fn() + .mockRejectedValueOnce(new Error('restore failed')) + .mockResolvedValue(undefined) + renderPublisher({ props: { onPublish, onRestore } }) + await user.click(trigger()) + await user.click(publishUpdate()) + await waitFor(() => expect(publishUpdate()).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + expect(publishUpdate()).toBeEnabled() + await user.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + await waitFor(() => + expect( + screen.queryByRole('button', { name: 'workflow.common.publishUpdate' }), + ).not.toBeInTheDocument(), + ) + expect(onRestore).toHaveBeenCalledTimes(2) +}) + +it('opens the marketplace redirect after a successful submission', async () => { + const user = userEvent.setup() + renderPublisher({ systemFeatures: { enable_creators_platform: true } }) + await user.click(trigger()) + await user.click(screen.getByRole('button', { name: 'workflow.common.publishToMarketplace' })) + await waitFor(() => + expect(window.open).toHaveBeenCalledWith( + 'https://marketplace.example.com/publish?code=abc', + '_blank', + ), + ) + expect(api.marketplace).toHaveBeenCalledExactlyOnceWith({ params: { app_id: 'app-1' } }) +}) + +it('shows a marketplace error and lets the user retry', async () => { + const user = userEvent.setup() + const error = vi.spyOn(toast, 'error') + api.marketplace.mockRejectedValueOnce(new Error('network error')) + renderPublisher({ systemFeatures: { enable_creators_platform: true } }) + await user.click(trigger()) + await user.click(screen.getByRole('button', { name: 'workflow.common.publishToMarketplace' })) + await waitFor(() => + expect(error).toHaveBeenCalledWith('workflow.common.publishToMarketplaceFailed'), + ) + expect(window.open).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'workflow.common.publishToMarketplace' })) + await waitFor(() => expect(window.open).toHaveBeenCalledOnce()) +}) + +it('disables marketplace submission until a version is published', async () => { + const user = userEvent.setup() + renderPublisher({ + props: { publishedAt: undefined }, + systemFeatures: { enable_creators_platform: true }, + }) + await user.click(trigger()) + expect( + screen.getByRole('button', { name: 'workflow.common.publishToMarketplace' }), + ).toBeDisabled() + expect(api.marketplace).not.toHaveBeenCalled() +}) + +it('hides marketplace submission when the feature is disabled', async () => { + const user = userEvent.setup() + renderPublisher() + await user.click(trigger()) + expect( + screen.queryByRole('button', { name: 'workflow.common.publishToMarketplace' }), + ).not.toBeInTheDocument() +}) + +it('refreshes the visible version and workflow store after receiving a collaborator publication', async () => { + const user = userEvent.setup() + // The socket is the network boundary; the collaboration manager and query subscriptions remain real. + const incoming = new EventEmitter() + const socket = Object.assign(incoming, { + id: 'test-socket', + connected: false, + }) as unknown as Socket + vi.spyOn(webSocketClient, 'connect').mockReturnValue(socket) + vi.spyOn(webSocketClient, 'disconnect').mockImplementation(() => {}) + const connection = await collaborationManager.connect('app-1') + try { + const { store } = renderPublisher({ app: { mode: 'workflow' } }) + await user.click(trigger()) + await screen.findByText('Release 5') + api.published.mockResolvedValue( createWorkflowResponseFixture({ - created_at: 1_710_000_100, - hash: 'stale-published-hash', + created_at: 1_710_000_300, + marked_name: 'Collaborator release', }), ) - - render( - - - , - queryClient, - ) - act(() => { - collaborationMocks.handler?.({ + incoming.emit('collaboration_update', { type: 'app_publish_update', userId: 'collaborator-1', data: { action: 'published' }, timestamp: 1_710_000_300_000, }) }) + expect(await screen.findByText('Collaborator release')).toBeInTheDocument() + expect(store.getState().publishedAt).toBe(1_710_000_300_000) + } finally { + collaborationManager.disconnect(connection) + } +}) - await waitFor(() => { - expect(mockFetchPublishedWorkflow).toHaveBeenCalledWith('app-1') - expect(setPublishedAt).toHaveBeenCalledWith(1_710_000_300) - }) - }) +it('publishes with the shortcut from the action area after opening and reopening, but not while closed', async () => { + const user = userEvent.setup() + const onPublish = vi.fn().mockResolvedValue(undefined) + const modifier = detectPlatform() === 'mac' ? 'Meta' : 'Control' + const shortcut = `{${modifier}>}{Shift>}P{/Shift}{/${modifier}}` + renderPublisher({ props: { onPublish } }) + for (let opened = 1; opened <= 2; opened++) { + await user.click(trigger()) + const link = await screen.findByRole('link', { name: 'workflow.common.openWebApp' }) + act(() => link.focus()) + await user.keyboard(shortcut) + await waitFor(() => expect(onPublish).toHaveBeenCalledTimes(opened)) + expect(screen.getByRole('button', { name: 'workflow.common.published' })).toBeDisabled() + await user.click(trigger()) + await waitFor(() => + expect( + screen.queryByRole('link', { name: 'workflow.common.openWebApp' }), + ).not.toBeInTheDocument(), + ) + await user.keyboard(shortcut) + expect(onPublish).toHaveBeenCalledTimes(opened) + } +}) + +it('preserves deployment configuration after an outside press and restores dismissal after leaving it', async () => { + const user = userEvent.setup() + api.environments.mockResolvedValue({ data: environments }) + renderPublisher({ app: { mode: 'workflow', permission_keys: [AppACLPermission.Deploy] } }) + await user.click(trigger()) + await user.click( + await screen.findByRole('button', { name: /studio.moreEnvironments|operation.more/ }), + ) + await user.click(screen.getByRole('menuitem', { name: 'Staging' })) + await user.click(await screen.findByRole('button', { name: 'deployments.studio.deployLatest' })) + expect( + await screen.findByRole('heading', { name: 'deployments.studio.deployConfiguration' }), + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Outside control' })) + expect( + screen.getByRole('heading', { name: 'deployments.studio.deployConfiguration' }), + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + expect(await screen.findByText(/studio.publisher.notDeployedYet/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Outside control' })) + await waitFor(() => + expect(screen.queryByText(/studio.publisher.notDeployedYet/)).not.toBeInTheDocument(), + ) + await user.click(trigger()) + await user.click(screen.getByRole('button', { name: 'Staging' })) + await user.click(await screen.findByRole('button', { name: 'deployments.studio.deployLatest' })) + await screen.findByRole('heading', { name: 'deployments.studio.deployConfiguration' }) + await user.keyboard('{Escape}') + await waitFor(() => + expect( + screen.queryByRole('heading', { name: 'deployments.studio.deployConfiguration' }), + ).not.toBeInTheDocument(), + ) + expect(trigger()).toHaveAttribute('aria-expanded', 'false') }) diff --git a/web/app/components/app/app-publisher/__tests__/publisher-panel.spec.tsx b/web/app/components/app/app-publisher/__tests__/publisher-panel.spec.tsx deleted file mode 100644 index 157daebc275..00000000000 --- a/web/app/components/app/app-publisher/__tests__/publisher-panel.spec.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { detectPlatform } from '@tanstack/react-hotkeys' -import { act, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { useState } from 'react' -import { PublisherPanel } from '../publisher-content/publisher-panel' - -vi.mock('../environment-deployment-flow', () => ({ - PublisherEnvironmentFlow: ({ - onConfigurationOpenChange, - }: { - onConfigurationOpenChange?: (open: boolean) => void - }) => ( -
- Environment publisher - -
- ), -})) - -function PublisherPanelHarness({ - initialOpen = true, - onPublish = vi.fn(), - showBuiltInPublisher = false, -}: { - initialOpen?: boolean - onPublish?: () => Promise - showBuiltInPublisher?: boolean -}) { - const [open, setOpen] = useState(initialOpen) - - return ( - <> - - '', - handlePublish: onPublish, - handleRestore: vi.fn(), - isChatApp: false, - isPublishing: false, - published: false, - publishedAt: 1_710_000_000_000, - upgradeHighlightStyle: {}, - }, - }} - environmentPublisher={{ - appId: 'app-1', - canViewAccessPoint: false, - environmentId: 'staging', - environmentName: 'Staging', - environmentTabs: null, - isEnvironmentInUse: true, - isDeploymentError: false, - isDeploymentLoading: false, - onGoToPublish: vi.fn(), - }} - environmentPublisherKey="staging" - open={open} - showBuiltInPublisher={showBuiltInPublisher} - workflowLaunch={{ - hiddenVariables: [], - open: false, - targetUrl: '', - onOpenChange: vi.fn(), - }} - onOpenChange={setOpen} - /> - - ) -} - -describe('PublisherPanel', () => { - it('publishes from the actions section on first open and reopen, but not while closed', async () => { - const user = userEvent.setup() - const onPublish = vi.fn().mockResolvedValue(undefined) - const modifier = detectPlatform() === 'mac' ? 'Meta' : 'Control' - const shortcut = `{${modifier}>}{Shift>}P{/Shift}{/${modifier}}` - render() - const trigger = screen.getByRole('button', { name: /common\.publish\b/ }) - - for (let opened = 1; opened <= 2; opened++) { - await user.click(trigger) - const openWebApp = screen.getByRole('link', { name: /common\.openWebApp\b/ }) - act(() => openWebApp.focus()) - expect(openWebApp).toHaveFocus() - expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled() - await user.keyboard(shortcut) - expect(onPublish).toHaveBeenCalledTimes(opened) - - await user.click(trigger) - await waitFor(() => - expect( - screen.queryByRole('link', { name: /common\.openWebApp\b/ }), - ).not.toBeInTheDocument(), - ) - expect(trigger).toHaveFocus() - await user.keyboard(shortcut) - expect(onPublish).toHaveBeenCalledTimes(opened) - } - }) - - it('keeps the publisher open after an outside press when dismissal is prevented', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByRole('button', { name: 'Configure deployment' })) - - await user.click(screen.getByRole('button', { name: 'Outside control' })) - - expect(screen.getByText('Environment publisher')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /common\.publish/ })).toHaveAttribute( - 'aria-expanded', - 'true', - ) - }) - - it('keeps the default outside-press dismissal outside deployment configuration', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByRole('button', { name: 'Outside control' })) - - expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: /common\.publish/ })).toHaveAttribute( - 'aria-expanded', - 'false', - ) - }) - - it('still closes from the trigger and Escape when outside dismissal is prevented', async () => { - const user = userEvent.setup() - render() - const publishButton = screen.getByRole('button', { name: /common\.publish/ }) - - await user.click(screen.getByRole('button', { name: 'Configure deployment' })) - await user.click(publishButton) - expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument() - - await user.click(publishButton) - expect(screen.getByText('Environment publisher')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Configure deployment' })) - - await user.keyboard('{Escape}') - - expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument() - }) -}) diff --git a/web/app/components/app/configuration/__tests__/configuration-flow.spec.tsx b/web/app/components/app/configuration/__tests__/configuration-flow.spec.tsx new file mode 100644 index 00000000000..bb79311a23f --- /dev/null +++ b/web/app/components/app/configuration/__tests__/configuration-flow.spec.tsx @@ -0,0 +1,744 @@ +import type { Dependency } from '@/app/components/plugins/types' +import type { consoleClient } from '@/service/console' +import { zAppProviderAgentToolResponse } from '@dify/contracts/api/console/apps/zod.gen' +import { zProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/zod.gen' +import { act, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Cookies from 'js-cookie' +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' +import { PermissionType } from '@/app/components/plugins/types' +import { useStore as usePluginDependenciesStore } from '@/app/components/workflow/plugin-dependency/store' +import { toast } from '@/app/notifications' +import { AppToastHost } from '@/app/notifications/host' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' +import { consoleQuery } from '@/service/console' +import { createHttpError } from '@/service/http-error' +import { + createCatalogModelFixture, + createModelCatalogFixture, + createProviderSummaryFixture, + seedModelProviderQueries, +} from '@/test/console/model-provider' +import { + createConsoleQueryClient, + renderWithConsoleQuery, + seedAppDetail, +} from '@/test/console/query-data' +import { seedToolProviders } from '@/test/console/tool-providers' +import { createDeferred } from '@/test/deferred' +import { createAppDetailFixture, createAppModelConfigFixture } from '@/test/fixtures/app' +import { createFileUploadConfigFixture } from '@/test/fixtures/file-upload-config' +import { createMarketplacePluginFixture } from '@/test/fixtures/plugin' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' +import { AppACLPermission } from '@/utils/permission' +import Configuration from '../index' +import { toast as configurationToast } from '../toast' + +const flags = vi.hoisted(() => ({ agentV2: false })) +vi.mock('@/env', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + env: { + ...actual.env, + get NEXT_PUBLIC_ENABLE_AGENT_V2() { + return flags.agentV2 + }, + }, + } +}) + +const api = vi.hoisted(() => ({ + app: vi.fn(), + publish: vi.fn(), + tools: vi.fn(), + datasets: vi.fn(), + rules: + vi.fn< + typeof consoleClient.workspaces.current.modelProviders.byProvider.models.parameterRules.get + >(), + stream: vi.fn(), + marketplace: vi.fn(), + install: vi.fn(), + models: vi.fn(), + modelSummary: vi.fn(), +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses, withConsoleClientResponses } = + await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'apps.byAppId.get': (input) => api.app(input), + 'apps.byAppId.modelConfig.post': (input) => api.publish(input), + 'datasets.get': (input) => api.datasets(input), + 'codeBasedExtension.get': async () => ({ data: [] }), + 'workspaces.current.plugin.list.installations.ids.post': async () => ({ plugins: [] }), + 'workspaces.current.tools.builtin.get': async () => [], + 'workspaces.current.toolProviders.get': (input) => api.tools(input), + 'workspaces.current.models.modelTypes.byModelType.get': (input) => api.models(input), + 'workspaces.current.modelProviders.summary.get': (input) => api.modelSummary(input), + 'workspaces.current.defaultModel.get': async () => ({ data: null }), + 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': (input) => + api.rules(input), + }), + 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': (input) => api.install(input), + 'apps.byAppId.get': (input) => api.app(input), + 'apps.byAppId.modelConfig.post': (input) => api.publish(input), + 'workspaces.current.toolProviders.get': (input) => api.tools(input), + 'datasets.get': (input) => api.datasets(input), + 'workspaces.current.modelProviders.byProvider.models.parameterRules.get': (input) => + api.rules(input), + }), + } +}) +vi.mock('@/service/base', async (importOriginal) => ({ + ...(await importOriginal()), + ssePost: api.stream, + postMarketplace: api.marketplace, +})) +vi.mock('@/next/navigation', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ appId: 'app-1' }), + usePathname: () => '/app/app-1/configuration', +})) + +const createApp = (overrides: Parameters[0] = {}) => + createAppDetailFixture({ + id: 'app-1', + name: 'Configuration test', + mode: 'chat', + permission_keys: [ + AppACLPermission.Edit, + AppACLPermission.ReleaseAndVersion, + AppACLPermission.TestAndRun, + ], + model_config: createAppModelConfigFixture({ + updated_at: 1710000000, + prompt_type: 'simple', + pre_prompt: 'Explain concepts clearly.', + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4', + mode: 'chat', + completion_params: { temperature: 0.7 }, + }, + }), + ...overrides, + }) +function setup({ app = createApp() }: { app?: ReturnType } = {}) { + const queryClient = createConsoleQueryClient() + seedAppDetail(queryClient, app) + const catalog = createModelCatalogFixture({ + provider: 'langgenius/openai/openai', + models: [ + createCatalogModelFixture({ + model_properties: { mode: 'chat' }, + features: ['vision'], + }), + createCatalogModelFixture({ + model: 'gpt-instruct', + label: { en_US: 'GPT Instruct' }, + model_properties: { mode: 'completion' }, + }), + createCatalogModelFixture({ + model: 'gpt-4.1', + label: { en_US: 'GPT-4.1' }, + model_properties: { mode: 'chat' }, + features: ['vision'], + }), + ], + }) + seedModelProviderQueries(queryClient, [catalog]) + api.models.mockImplementation(async ({ params }) => ({ + data: params.model_type === 'llm' ? [zProviderWithModelsResponse.parse(catalog)] : [], + })) + api.modelSummary.mockResolvedValue({ + data: [createProviderSummaryFixture({ provider: catalog.provider, label: catalog.label })], + plugins: {}, + }) + seedToolProviders(queryClient, { + builtInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + }) + queryClient.setQueryData( + consoleQuery.files.upload.get.queryKey(), + createFileUploadConfigFixture(), + ) + const { wrapper: UrlState } = createNuqsTestWrapper() + renderWithConsoleQuery( + + + {}} />}> + + + + + , + { + queryClient, + currentWorkspace: { id: 'workspace-1' }, + workspacePermissionKeys: ['app.create_and_management', 'plugin.install'], + systemFeatures: { + enable_marketplace: true, + rbac_enabled: true, + plugin_installation_permission: { + plugin_installation_scope: 'all', + restrict_to_marketplace_only: false, + }, + }, + }, + ) + return { user: userEvent.setup() } +} +const prompt = () => screen.getByRole('textbox', { name: 'appDebug.chatSubTitle' }) +const waitForPage = () => screen.findByRole('heading', { name: 'appDebug.orchestrate' }) +async function openPublisher(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'workflow.common.publish' })) +} +async function publish(user: ReturnType) { + const previousCalls = api.publish.mock.calls.length + await openPublisher(user) + await user.click(screen.getByRole('button', { name: 'workflow.common.publishUpdate' })) + await waitFor(() => expect(api.publish).toHaveBeenCalledTimes(previousCalls + 1)) + await screen.findByRole('button', { name: 'workflow.common.published' }) +} +async function enableComparison(user: ReturnType) { + await user.click( + screen.getByRole('button', { name: 'modelProvider.modelProvider.modelSettings' }), + ) + await user.click(screen.getByRole('button', { name: 'appDebug.debugAsMultipleModel' })) + await screen.findByRole('button', { name: /modelProvider.modelProvider.addModel/ }) +} +beforeEach(() => { + flags.agentV2 = false + usePluginDependenciesStore.setState({ dependencies: [] }) + for (const mock of Object.values(api)) mock.mockReset() + Cookies.remove(DETAIL_SIDEBAR_COOKIE_NAME) + api.app.mockResolvedValue(createApp()) + api.publish.mockResolvedValue({ result: 'success' }) + api.tools.mockResolvedValue([]) + api.datasets.mockResolvedValue({ data: [], has_more: false, limit: 20, page: 1, total: 0 }) + api.rules.mockResolvedValue({ + data: [ + { + name: 'temperature', + label: { en_US: 'Temperature' }, + type: 'float', + default: 0.7, + min: 0, + max: 2, + required: false, + options: [], + }, + ], + }) + api.stream.mockResolvedValue(undefined) + api.install.mockReset().mockResolvedValue({ all_installed: true, task_id: '' }) + api.marketplace.mockReset() +}) +afterEach(() => + act(() => { + toast.dismiss() + configurationToast.dismiss() + vi.unstubAllGlobals() + }), +) + +it('loads the actual configuration page before editing and publishing a prompt', async () => { + const request = createDeferred>>() + api.app.mockReturnValueOnce(request.promise) + const { user } = setup() + expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeVisible() + await act(async () => request.resolve(createApp())) + await waitForPage() + expect(prompt()).toHaveTextContent('Explain concepts clearly.') + await user.type(prompt(), ' Answer in one sentence.') + await publish(user) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + params: { app_id: 'app-1' }, + body: { + pre_prompt: 'Explain concepts clearly. Answer in one sentence.', + model: { name: 'gpt-4', completion_params: { temperature: 0.7 } }, + }, + }) +}) + +it('restores the last successful publication after cancelling an earlier restore', async () => { + const { user } = setup() + await waitForPage() + await user.type(prompt(), ' Published revision.') + await publish(user) + await user.keyboard('{Escape}') + await user.type(prompt(), ' Unpublished revision.') + await openPublisher(user) + await user.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + const confirmation = await screen.findByRole('alertdialog', { + name: 'appDebug.resetConfig.title', + }) + await user.click(within(confirmation).getByRole('button', { name: 'common.operation.cancel' })) + expect(prompt()).toHaveTextContent('Unpublished revision.') + await openPublisher(user) + await user.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + await user.click( + within( + await screen.findByRole('alertdialog', { name: 'appDebug.resetConfig.title' }), + ).getByRole('button', { name: 'common.operation.confirm' }), + ) + expect(prompt()).toHaveTextContent('Explain concepts clearly. Published revision.') + expect(prompt()).not.toHaveTextContent('Unpublished revision.') + expect(api.publish).toHaveBeenCalledTimes(1) +}) + +it.each([ + { permission: AppACLPermission.ViewLayout, editable: false, runnable: false, publishable: false }, + { permission: AppACLPermission.Edit, editable: true, runnable: false, publishable: false }, + { permission: AppACLPermission.TestAndRun, editable: false, runnable: true, publishable: false }, + { + permission: AppACLPermission.ReleaseAndVersion, + editable: false, + runnable: false, + publishable: true, + }, +])( + 'enforces independent edit, run, and release permissions for $permission', + async ({ permission, editable, runnable, publishable }) => { + const app = createApp({ permission_keys: [permission] }) + api.app.mockResolvedValue(app) + const { user } = setup({ app }) + await waitForPage() + expect(prompt()).toHaveAttribute('contenteditable', String(editable)) + const send = screen.getByRole('button', { name: 'common.operation.send' }) + if (runnable) { + await user.type(screen.getByPlaceholderText(/^common.chat.inputPlaceholder/), 'Explain RAG') + await user.click(screen.getByRole('button', { name: 'common.operation.send' })) + expect(api.stream).toHaveBeenCalledWith( + 'apps/app-1/chat-messages', + expect.objectContaining({ body: expect.objectContaining({ query: 'Explain RAG' }) }), + expect.anything(), + ) + } else { + expect(send).toBeDisabled() + expect(api.stream).not.toHaveBeenCalled() + } + if (publishable) { + await publish(user) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + body: { pre_prompt: 'Explain concepts clearly.' }, + }) + } else { + expect(screen.getByRole('button', { name: 'workflow.common.publish' })).toBeDisabled() + expect(api.publish).not.toHaveBeenCalled() + } + }, +) + +it('selects another model and publishes only parameters supported by that model', async () => { + const { user } = setup() + await waitForPage() + api.rules.mockResolvedValue({ data: [] }) + await user.click(screen.getByRole('button', { name: /^GPT-4 CHAT$/ })) + const chooser = await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' }) + await user.click(within(chooser).getByRole('button', { name: /^GPT-4.1/ })) + await screen.findByText(/modelProvider.modelProvider.parametersInvalidRemoved/) + await publish(user) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + body: { model: { name: 'gpt-4.1' } }, + }) + expect(api.publish.mock.lastCall![0].body.model.completion_params).toEqual({}) +}) + +it('keeps comparison mode when restoring the published prompt', async () => { + const { user } = setup() + await waitForPage() + await enableComparison(user) + const addModel = await screen.findByRole('button', { + name: /modelProvider.modelProvider.addModel/, + }) + expect(Cookies.get(DETAIL_SIDEBAR_COOKIE_NAME)).toBe('collapse') + await user.type(prompt(), ' Draft.') + await openPublisher(user) + await user.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + await user.click( + within( + await screen.findByRole('alertdialog', { name: 'appDebug.resetConfig.title' }), + ).getByRole('button', { name: 'common.operation.confirm' }), + ) + expect(prompt()).toHaveTextContent('Explain concepts clearly.') + expect(prompt()).not.toHaveTextContent('Draft.') + expect(addModel).toBeVisible() +}) + +it('publishes the selected comparison model and restores its parameters as the published snapshot', async () => { + const { user } = setup() + await waitForPage() + await enableComparison(user) + await user.click(screen.getByRole('button', { name: /modelProvider.modelProvider.selectModel/ })) + const settings = await screen.findByRole('dialog', { + name: 'modelProvider.modelProvider.modelSettings', + }) + await user.click( + within(settings).getByRole('button', { name: 'plugin.detailPanel.configureModel' }), + ) + await user.click( + within( + await screen.findByRole('dialog', { name: 'plugin.detailPanel.configureModel' }), + ).getByRole('button', { name: /^GPT-4.1/ }), + ) + const modelSettings = await screen.findByRole('dialog', { + name: 'modelProvider.modelProvider.modelSettings', + }) + await user.click(within(modelSettings).getByRole('switch', { name: 'Temperature' })) + await user.clear(within(modelSettings).getByRole('spinbutton', { name: 'Temperature' })) + await user.type(within(modelSettings).getByRole('spinbutton', { name: 'Temperature' }), '0.2') + await user.click(within(modelSettings).getByRole('button', { name: 'common.operation.close' })) + await openPublisher(user) + await user.click(screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })) + await user.click(screen.getByRole('menuitem', { name: /GPT-4.1/ })) + await waitFor(() => expect(api.publish).toHaveBeenCalledTimes(1)) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + body: { model: { name: 'gpt-4.1', completion_params: { temperature: 0.2 } } }, + }) + await user.keyboard('{Escape}') + await user.keyboard('{Escape}') + await user.click(screen.getAllByRole('button', { name: 'common.operation.more' })[0]!) + await user.click(screen.getByRole('menuitem', { name: 'appDebug.debugAsSingleModel' })) + expect(screen.getByRole('button', { name: /^GPT-4 CHAT$/ })).toBeVisible() + await user.type(prompt(), ' Unsaved draft.') + await openPublisher(user) + await user.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + await user.click( + within( + await screen.findByRole('alertdialog', { name: 'appDebug.resetConfig.title' }), + ).getByRole('button', { name: 'common.operation.confirm' }), + ) + expect(screen.getByRole('button', { name: /^GPT-4.1/ })).toBeVisible() + expect(prompt()).not.toHaveTextContent('Unsaved draft.') + await publish(user) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + body: { model: { name: 'gpt-4.1', completion_params: { temperature: 0.2 } } }, + }) +}) + +it('loads and publishes persisted advanced messages with their roles intact', async () => { + const app = createApp({ + model_config: createAppModelConfigFixture({ + ...createApp().model_config, + prompt_type: 'advanced', + chat_prompt_config: { + prompt: [ + { role: 'system', text: 'Answer with sources.' }, + { role: 'user', text: 'Explain RAG.' }, + ], + }, + }), + }) + api.app.mockResolvedValue(app) + const { user } = setup({ app }) + await waitForPage() + expect(screen.getByText('Answer with sources.')).toBeVisible() + expect(screen.getByText('Explain RAG.')).toBeVisible() + await publish(user) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + body: { + prompt_type: 'advanced', + chat_prompt_config: { + prompt: [ + { role: 'system', text: 'Answer with sources.' }, + { role: 'user', text: 'Explain RAG.' }, + ], + }, + }, + }) +}) + +it('shows the page error fallback for a missing model configuration', async () => { + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + api.app.mockResolvedValue(createApp({ model_config: undefined })) + setup() + expect( + await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }), + ).toBeVisible() + expect(screen.queryByRole('heading', { name: 'appDebug.orchestrate' })).not.toBeInTheDocument() + } finally { + report.mockRestore() + } +}) + +it('keeps the redirect loading fallback when configuration dependencies return unauthorized', async () => { + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const request = + createDeferred< + Awaited> + >() + api.tools.mockReturnValue(request.promise) + setup() + await act(async () => request.reject(createHttpError(new Response(null, { status: 401 })))) + expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeVisible() + expect( + screen.queryByRole('button', { name: 'common.errorBoundary.tryAgain' }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'appDebug.orchestrate' })).not.toBeInTheDocument() + } finally { + report.mockRestore() + } +}) + +it('preserves feature drafts across reopening and restores the last published features', async () => { + const { user } = setup() + await waitForPage() + await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' })) + const panel = await screen.findByRole('dialog', { name: 'workflow.common.features' }) + await user.click( + within(panel).getByRole('switch', { + name: 'appDebug.feature.suggestedQuestionsAfterAnswer.title', + }), + ) + await user.click(within(panel).getByRole('button', { name: 'common.operation.close' })) + await waitFor(() => + expect( + screen.queryByRole('dialog', { name: 'workflow.common.features' }), + ).not.toBeInTheDocument(), + ) + await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.manage' })) + const reopened = await screen.findByRole('dialog', { name: 'workflow.common.features' }) + expect( + within(reopened).getByRole('switch', { + name: 'appDebug.feature.suggestedQuestionsAfterAnswer.title', + }), + ).toBeChecked() + await user.click(within(reopened).getByRole('button', { name: 'common.operation.close' })) + await publish(user) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + body: { suggested_questions_after_answer: { enabled: true } }, + }) + await user.keyboard('{Escape}') + await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.manage' })) + const draftPanel = await screen.findByRole('dialog', { name: 'workflow.common.features' }) + await user.click( + within(draftPanel).getByRole('switch', { + name: 'appDebug.feature.suggestedQuestionsAfterAnswer.title', + }), + ) + await user.click(within(draftPanel).getByRole('button', { name: 'common.operation.close' })) + expect(screen.getByRole('button', { name: 'appDebug.feature.bar.empty' })).toBeVisible() + await openPublisher(user) + await user.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + await user.click( + within( + await screen.findByRole('alertdialog', { name: 'appDebug.resetConfig.title' }), + ).getByRole('button', { name: 'common.operation.confirm' }), + ) + await user.click(screen.getByRole('button', { name: 'appDebug.feature.bar.manage' })) + expect( + within(await screen.findByRole('dialog', { name: 'workflow.common.features' })).getByRole( + 'switch', + { name: 'appDebug.feature.suggestedQuestionsAfterAnswer.title' }, + ), + ).toBeChecked() + expect(api.publish).toHaveBeenCalledTimes(1) +}) + +it('opens and closes the mobile debug drawer while preserving the configuration draft', async () => { + vi.stubGlobal('innerWidth', 390) + const { user } = setup() + await waitForPage() + await user.type(prompt(), ' Mobile draft.') + const openDebug = () => + user.click(screen.getByRole('button', { name: 'appDebug.operation.debugConfig' })) + await openDebug() + const drawer = await screen.findByRole('dialog') + const composer = await within(drawer).findByRole('textbox') + await user.type(composer, 'Try this draft') + expect(composer).toHaveValue('Try this draft') + await user.click(within(drawer).getByRole('button', { name: 'common.operation.close' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(prompt()).toHaveTextContent('Mobile draft.') + await openDebug() + expect(await within(await screen.findByRole('dialog')).findByRole('textbox')).toBeInTheDocument() +}) + +it.each([false, true])( + 'shows the legacy Agent migration entry only with Agent v2 enabled=%s', + async (enabled) => { + flags.agentV2 = enabled + const app = createApp({ mode: 'agent-chat' }) + api.app.mockResolvedValue(app) + const { user } = setup({ app }) + await waitForPage() + const badge = screen.queryByRole('button', { name: 'appDebug.legacyAgentBadge.label' }) + if (!enabled) { + expect(badge).not.toBeInTheDocument() + return + } + await user.click(screen.getByRole('button', { name: 'appDebug.legacyAgentBadge.label' })) + expect(await screen.findByText('appDebug.legacyAgentBadge.description')).toBeVisible() + expect(screen.getByRole('link', { name: /appDebug.legacyAgentBadge.action/ })).toHaveAttribute( + 'href', + '/agents', + ) + }, +) + +it.each([true, false])( + 'updates removed Agent tools after dependency installation succeeds=%s', + async (success) => { + const plugin = createMarketplacePluginFixture({ + org: 'vendor', + name: 'search', + plugin_id: 'vendor/search', + latest_package_identifier: 'vendor/search:1.0.0', + label: { en_US: 'Search plugin' }, + }) + const unselectedPlugin = createMarketplacePluginFixture({ + org: 'vendor', + name: 'reference', + plugin_id: 'vendor/reference', + latest_package_identifier: 'vendor/reference:1.0.0', + label: { en_US: 'Reference plugin' }, + }) + const providerId = 'vendor/search/search' + const tool = zAppProviderAgentToolResponse.parse({ + provider_id: providerId, + provider_name: providerId, + provider_type: 'builtin', + tool_name: 'search', + tool_label: 'Search the web', + tool_parameters: {}, + enabled: true, + }) + const unselectedTool = { + ...tool, + provider_id: 'vendor/reference/reference', + provider_name: 'vendor/reference/reference', + tool_name: 'lookup', + tool_label: 'Read reference', + } + const tools = [tool, unselectedTool] + const base = createApp() + const app = createApp({ + mode: 'agent-chat', + deleted_tools: tools.map((tool) => ({ + provider_id: tool.provider_id, + tool_name: tool.tool_name, + type: 'builtin', + })), + model_config: { ...base.model_config!, agent_mode: { enabled: true, tools } }, + }) + api.app.mockResolvedValue(app) + api.marketplace.mockResolvedValue({ + data: { list: [plugin, unselectedPlugin].map((plugin) => ({ plugin })) }, + }) + if (!success) api.install.mockRejectedValue(new Error('Installation rejected')) + const { user } = setup({ app }) + await waitForPage() + expect(screen.getAllByRole('button', { name: 'tools.toolRemoved' })).toHaveLength(2) + const dependencies: Dependency[] = [plugin, unselectedPlugin].map((plugin) => ({ + type: 'marketplace', + value: { marketplace_plugin_unique_identifier: plugin.latest_package_identifier }, + })) + act(() => usePluginDependenciesStore.getState().setDependencies(dependencies)) + const dialog = await screen.findByRole('dialog', { name: 'plugin.installModal.installPlugin' }) + const install = within(dialog).getByRole('button', { name: 'plugin.installModal.install' }) + await waitFor(() => expect(install).toBeEnabled()) + await user.click(within(dialog).getByRole('checkbox', { name: 'reference' })) + await user.click(install) + await screen.findByRole('dialog', { name: 'plugin.installModal.installedSuccessfully' }) + expect(api.install).toHaveBeenCalledExactlyOnceWith({ + body: { plugin_unique_identifiers: [plugin.latest_package_identifier] }, + }) + await user.click(screen.getAllByRole('button', { name: 'common.operation.close' }).at(-1)!) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.getAllByRole('button', { name: 'tools.toolRemoved' })).toHaveLength( + success ? 1 : 2, + ) + if (success) expect(screen.getByRole('button', { name: 'search' })).toBeInTheDocument() + else expect(screen.queryByRole('button', { name: 'search' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'lookup' })).not.toBeInTheDocument() + }, +) + +it('adds a prompt variable, edits its label, and publishes the actual form schema', async () => { + const { user } = setup() + await waitForPage() + await user.click(prompt()) + await user.paste('Explain {{topic}} clearly.') + await user.tab() + const confirmation = await screen.findByRole('group', { name: 'appDebug.autoAddVar' }) + await user.click(within(confirmation).getByRole('button', { name: 'common.operation.add' })) + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + const dialog = await screen.findByRole('dialog') + const label = within(dialog).getByRole('textbox', { name: 'appDebug.variableConfig.labelName' }) + await user.clear(label) + await user.type(label, 'Discussion topic') + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await publish(user) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + body: { + pre_prompt: expect.stringContaining('{{topic}}'), + user_input_form: [{ 'text-input': { variable: 'topic', label: 'Discussion topic' } }], + }, + }) +}) + +it('edits advanced completion history prefixes through the page and publishes them', async () => { + const app = createApp({ + model_config: createAppModelConfigFixture({ + ...createApp().model_config, + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-instruct', + mode: 'completion', + completion_params: {}, + }, + prompt_type: 'advanced', + completion_prompt_config: { + prompt: { text: 'Answer accurately. {{#histories#}} {{#query#}}' }, + conversation_histories_role: { user_prefix: 'Human', assistant_prefix: 'Assistant' }, + }, + }), + }) + api.app.mockResolvedValue(app) + const { user } = setup({ app }) + await waitForPage() + expect( + screen.getByRole('heading', { name: 'appDebug.feature.conversationHistory.title' }), + ).toBeVisible() + await user.click(screen.getByRole('button', { name: 'common.operation.edit' })) + const dialog = await screen.findByRole('dialog', { + name: 'appDebug.feature.conversationHistory.editModal.title', + }) + const userPrefix = within(dialog).getByDisplayValue('Human') + await user.clear(userPrefix) + await user.type(userPrefix, 'Customer') + await user.clear(within(dialog).getByDisplayValue('Assistant')) + await user.type( + within(dialog).getByPlaceholderText('common.chat.conversationNamePlaceholder'), + 'Expert', + ) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await publish(user) + expect(api.publish.mock.lastCall![0]).toMatchObject({ + body: { + prompt_type: 'advanced', + completion_prompt_config: { + conversation_histories_role: { user_prefix: 'Customer', assistant_prefix: 'Expert' }, + }, + }, + }) +}) diff --git a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx deleted file mode 100644 index 093215b60eb..00000000000 --- a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import type { ComponentProps } from 'react' -import type { ConfigurationViewModel } from '../hooks/configuration-view-model' -import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper' -import type { InstallBundleCompleteCallback } from '@/app/components/plugins/install-plugin/install-bundle' -import type { Plugin } from '@/app/components/plugins/types' -import type { AgentTool } from '@/types/app' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import * as React from 'react' -import { PluginCategoryEnum } from '@/app/components/plugins/types' -import { CollectionType } from '@/app/components/tools/types' -import { createDebugConfigurationFixture } from '@/test/fixtures/debug-configuration' -import ConfigurationView from '../configuration-view' - -const mockIsAgentV2Enabled = vi.hoisted(() => vi.fn(() => false)) - -vi.mock('@/features/agent-v2/feature-flag', () => ({ - isAgentV2Enabled: () => mockIsAgentV2Enabled(), -})) - -vi.mock('@/app/components/app/app-publisher/features-wrapper', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/app/configuration/config', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/app/configuration/debug', () => ({ - default: () =>
, -})) - -vi.mock( - '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', - () => ({ - default: () =>
, - }), -) - -vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/app/configuration/config-prompt/conversation-history/edit-modal', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/base/features/new-feature-panel', () => ({ - default: () =>
, -})) - -let pluginDependencyOnInstallComplete: InstallBundleCompleteCallback | undefined -vi.mock('@/app/components/workflow/plugin-dependency', () => ({ - default: ({ onInstallComplete }: { onInstallComplete?: InstallBundleCompleteCallback }) => { - pluginDependencyOnInstallComplete = onInstallComplete - return
- }, -})) - -const createPlugin = (name: string): Plugin => ({ - type: 'plugin', - org: 'vendor', - name, - plugin_id: 'vendor', - version: '1.0.0', - latest_version: '1.0.0', - latest_package_identifier: `vendor/${name}:1.0.0`, - icon: 'icon.svg', - verified: true, - label: { 'en-US': name }, - brief: { 'en-US': name }, - description: { 'en-US': name }, - introduction: '', - repository: `https://example.com/vendor/${name}`, - category: PluginCategoryEnum.tool, - install_count: 0, - endpoint: { settings: [] }, - tags: [], - badges: [], - verification: { authorized_category: 'community' }, - from: 'marketplace', -}) - -const createDeletedAgentTool = (providerId: string): AgentTool => ({ - provider_id: providerId, - provider_type: CollectionType.builtIn, - provider_name: providerId, - tool_name: 'search', - tool_label: 'Search', - tool_parameters: {}, - enabled: true, - isDeleted: true, -}) - -const createContextValue = () => - createDebugConfigurationFixture({ appId: 'app-1', setModelConfig: vi.fn() }) - -const createViewModel = ( - overrides: Partial = {}, -): ConfigurationViewModel => ({ - appPublisherProps: { - appId: 'app-1', - publishDisabled: false, - publishedAt: 0, - debugWithMultipleModel: false, - multipleModelConfigs: [], - onPublish: vi.fn(), - publishedConfig: { - modelConfig: createContextValue().modelConfig, - completionParams: {}, - promptMode: createContextValue().promptMode, - chatPromptConfig: createContextValue().chatPromptConfig, - completionPromptConfig: createContextValue().completionPromptConfig, - datasetConfigs: createContextValue().datasetConfigs, - externalDataToolsConfig: createContextValue().externalDataToolsConfig, - }, - resetAppConfig: vi.fn(), - } as ComponentProps, - contextValue: createContextValue(), - featuresData: { - moreLikeThis: { enabled: false }, - opening: { enabled: false, opening_statement: '', suggested_questions: [] }, - moderation: { enabled: false }, - speech2text: { enabled: false }, - text2speech: { enabled: false, voice: '', language: '' }, - file: { - enabled: false, - image: { enabled: false, detail: 'high', number_limits: 3, transfer_methods: ['local_file'] }, - } as never, - suggested: { enabled: false }, - citation: { enabled: false }, - annotationReply: { enabled: false }, - }, - isAgent: false, - isAdvancedMode: false, - isMobile: false, - isShowDebugPanel: false, - isShowHistoryModal: false, - isShowSelectDataSet: false, - modelConfig: createContextValue().modelConfig, - multipleModelConfigs: [], - onAutoAddPromptVariable: vi.fn(), - onAgentSettingChange: vi.fn(), - onCloseFeaturePanel: vi.fn(), - onCloseHistoryModal: vi.fn(), - onCloseSelectDataSet: vi.fn(), - onCompletionParamsChange: vi.fn(), - onConfirmUseGPT4: vi.fn(), - onEnableMultipleModelDebug: vi.fn(), - onFeaturesChange: vi.fn(), - onHideDebugPanel: vi.fn(), - onModelChange: vi.fn(), - onMultipleModelConfigsChange: vi.fn(), - onOpenAccountSettings: vi.fn(), - onOpenDebugPanel: vi.fn(), - onSaveHistory: vi.fn(), - onSelectDataSets: vi.fn(), - promptVariables: [], - selectedIds: [], - showAppConfigureFeaturesModal: false, - showLoading: false, - showUseGPT4Confirm: false, - setShowUseGPT4Confirm: vi.fn(), - ...overrides, -}) - -describe('ConfigurationView', () => { - beforeEach(() => { - vi.clearAllMocks() - mockIsAgentV2Enabled.mockReturnValue(false) - pluginDependencyOnInstallComplete = undefined - }) - - it('should render a loading state before configuration data is ready', () => { - render() - - expect(screen.getByRole('progressbar', { name: 'common.loading' })).toBeInTheDocument() - expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument() - }) - - it('provides the page heading inside the parent-owned main landmark', () => { - render() - expect( - screen.getByRole('heading', { level: 1, name: 'appDebug.orchestrate' }), - ).toBeInTheDocument() - expect(screen.queryByRole('main')).not.toBeInTheDocument() - }) - - it('should open the mobile debug panel from the header button', async () => { - const user = userEvent.setup() - const onOpenDebugPanel = vi.fn() - render() - - await user.click(screen.getByRole('button', { name: /appDebug.operation.debugConfig/i })) - - expect(onOpenDebugPanel).toHaveBeenCalledTimes(1) - }) - - it('should close the GPT-4 confirmation dialog when cancel is clicked', async () => { - const user = userEvent.setup() - const setShowUseGPT4Confirm = vi.fn() - render( - , - ) - - await user.click(screen.getByRole('button', { name: /operation.cancel/i })) - - expect(setShowUseGPT4Confirm).toHaveBeenCalledWith(false) - }) - - it('should show the legacy Agent badge for legacy Agent apps when Agent v2 is enabled', async () => { - const user = userEvent.setup() - mockIsAgentV2Enabled.mockReturnValue(true) - const contextValue = createContextValue() - contextValue.mode = 'agent-chat' - - render() - - const badge = screen.getByRole('button', { name: 'appDebug.legacyAgentBadge.label' }) - - await user.click(badge) - - expect(await screen.findByText('appDebug.legacyAgentBadge.description')).toBeInTheDocument() - expect( - screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }), - ).toHaveAttribute('href', '/agents') - expect( - screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }), - ).toHaveAttribute('target', '_blank') - expect( - screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ }), - ).toHaveAttribute('rel', 'noopener noreferrer') - }) - - it('should not show the legacy Agent badge when Agent v2 is disabled', () => { - const contextValue = createContextValue() - contextValue.mode = 'agent-chat' - - render() - - expect(screen.queryByText('appDebug.legacyAgentBadge.label')).not.toBeInTheDocument() - }) - - it('should reinstate selected plugin tools when at least one installation succeeds', () => { - const contextValue = createContextValue() - contextValue.isAgent = true - const tools = [ - createDeletedAgentTool('vendor/successful-plugin'), - createDeletedAgentTool('vendor/failed-plugin'), - createDeletedAgentTool('vendor/unselected-plugin'), - ] - contextValue.modelConfig.agentConfig.tools = tools - - render() - if (!pluginDependencyOnInstallComplete) - throw new Error('Plugin completion callback not registered') - - pluginDependencyOnInstallComplete( - [createPlugin('successful-plugin'), createPlugin('failed-plugin')], - [ - { success: true, isFromMarketPlace: true }, - { success: false, isFromMarketPlace: true }, - ], - [ - { hasInstalled: false, toInstallVersion: '1.0.0' }, - { hasInstalled: false, toInstallVersion: '1.0.0' }, - ], - ) - - expect(contextValue.setModelConfig).toHaveBeenCalledOnce() - const nextModelConfig = vi.mocked(contextValue.setModelConfig).mock.calls[0]![0] - expect(nextModelConfig.agentConfig.tools).toEqual([ - expect.objectContaining({ provider_id: 'vendor/successful-plugin', isDeleted: false }), - expect.objectContaining({ provider_id: 'vendor/failed-plugin', isDeleted: false }), - expect.objectContaining({ provider_id: 'vendor/unselected-plugin', isDeleted: true }), - ]) - }) - - it('should keep deleted tools unchanged when every installation fails', () => { - const contextValue = createContextValue() - contextValue.isAgent = true - contextValue.modelConfig.agentConfig.tools = [createDeletedAgentTool('vendor/failed-plugin')] - - render() - if (!pluginDependencyOnInstallComplete) - throw new Error('Plugin completion callback not registered') - - pluginDependencyOnInstallComplete( - [createPlugin('failed-plugin')], - [{ success: false, isFromMarketPlace: true }], - [{ hasInstalled: false, toInstallVersion: '1.0.0' }], - ) - - expect(contextValue.setModelConfig).not.toHaveBeenCalled() - }) - - it('should not attach the agent tool completion handler to non-agent configurations', () => { - render() - - expect(pluginDependencyOnInstallComplete).toBeUndefined() - }) -}) diff --git a/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx index 296cf238303..068c491cae9 100644 --- a/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import ConfirmAddVar from '../index' @@ -15,8 +15,9 @@ describe('ConfirmAddVar', () => { onHide={vi.fn()} />, ) - expect(screen.getByText('foo')).toBeInTheDocument() - expect(screen.getByText('bar')).toBeInTheDocument() + const confirmation = screen.getByRole('group', { name: 'appDebug.autoAddVar' }) + expect(within(confirmation).getByText('foo')).toBeInTheDocument() + expect(within(confirmation).getByText('bar')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: `common.operation.${action}` })) expect(action === 'add' ? onConfirm : onCancel).toHaveBeenCalledOnce() expect(action === 'add' ? onCancel : onConfirm).not.toHaveBeenCalled() diff --git a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx b/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx index d50cced9b14..b3b44fd555e 100644 --- a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx +++ b/web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import * as React from 'react' -import { useRef } from 'react' +import { useId, useRef } from 'react' import { useTranslation } from 'react-i18next' import VarHighlight from '../../base/var-highlight' @@ -37,6 +37,7 @@ const ConfirmAddVar: FC = ({ // onHide, }) => { const { t } = useTranslation(['appDebug', 'common']) + const titleId = useId() const mainContentRef = useRef(null) // new prompt editor blur trigger click... // useClickAway(() => { @@ -51,6 +52,8 @@ const ConfirmAddVar: FC = ({ >
= ({ {VarIcon}
-
+
{t(($) => $.autoAddVar, { ns: 'appDebug' })}
diff --git a/web/app/components/app/configuration/config/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/__tests__/index.spec.tsx deleted file mode 100644 index baa442e74c3..00000000000 --- a/web/app/components/app/configuration/config/__tests__/index.spec.tsx +++ /dev/null @@ -1,259 +0,0 @@ -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 { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import * as React from 'react' -import { renderWithDebugConfiguration } from '@/test/debug-configuration' -import { AgentStrategy, ModelModeType } from '@/types/app' -import Config from '../index' - -let configuration: Parameters[1] = {} -const render = (ui: Parameters[0]) => - renderWithDebugConfiguration(ui, configuration) - -const mockFormattingDispatcher = vi.fn() -vi.mock('../../debug/hooks', () => ({ - useFormattingChangedDispatcher: () => mockFormattingDispatcher, -})) - -vi.mock('@/app/components/app/configuration/config-prompt', () => ({ - default: ( - props: ComponentProps< - typeof import('@/app/components/app/configuration/config-prompt').default - >, - ) => ( - <> -