From 83d41767856861a64e45baa2aa3838c8bd3fd947 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 3 Apr 2026 17:09:59 +0800 Subject: [PATCH] test: add unit tests for app store and annotation components, enhancing coverage for state management and UI interactions (#34510) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../components/app/__tests__/store.spec.ts | 69 + .../{ => __tests__}/batch-action.spec.tsx | 2 +- .../{ => __tests__}/empty-element.spec.tsx | 2 +- .../{ => __tests__}/filter.spec.tsx | 4 +- .../annotation/{ => __tests__}/index.spec.tsx | 221 +++- .../annotation/{ => __tests__}/list.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../edit-item/{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/csv-downloader.spec.tsx | 3 +- .../{ => __tests__}/csv-uploader.spec.tsx | 18 +- .../{ => __tests__}/index.spec.tsx | 8 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 12 +- .../edit-item/{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 5 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/access-control.spec.tsx | 51 +- .../__tests__/features-wrapper.spec.tsx | 140 +++ .../app-publisher/__tests__/index.spec.tsx | 455 +++++++ .../publish-with-multiple-model.spec.tsx | 110 ++ .../app-publisher/__tests__/sections.spec.tsx | 266 ++++ .../__tests__/suggested-action.spec.tsx | 49 + .../app/app-publisher/__tests__/utils.spec.ts | 70 ++ .../__tests__/version-info-modal.spec.tsx | 128 ++ .../components/app/app-publisher/index.tsx | 336 ++--- .../components/app/app-publisher/sections.tsx | 360 ++++++ web/app/components/app/app-publisher/utils.ts | 84 ++ .../__tests__/configuration-view.spec.tsx | 283 +++++ .../configuration/__tests__/index.spec.tsx | 32 + .../app/configuration/__tests__/utils.spec.ts | 226 ++++ .../{ => __tests__}/index.spec.tsx | 2 +- .../group-name/{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../cannot-query-dataset.spec.tsx | 2 +- .../formatting-changed.spec.tsx | 2 +- .../{ => __tests__}/has-not-set-api.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../__tests__/advanced-prompt-input.spec.tsx | 228 ++++ .../{ => __tests__}/index.spec.tsx | 9 +- .../message-type-selector.spec.tsx | 2 +- .../prompt-editor-height-resize-wrap.spec.tsx | 2 +- .../__tests__/simple-prompt-input.spec.tsx | 320 +++++ .../{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/edit-modal.spec.tsx | 2 +- .../{ => __tests__}/history-panel.spec.tsx | 2 +- .../config-var/{ => __tests__}/index.spec.tsx | 93 +- .../__tests__/form-fields.spec.tsx | 207 +++ .../__tests__/index-logic.spec.tsx | 150 +++ .../config-modal/__tests__/index.spec.tsx | 89 ++ .../__tests__/type-select.spec.tsx | 37 + .../config-modal/__tests__/utils.spec.ts | 267 ++++ .../config-var/config-modal/form-fields.tsx | 228 ++++ .../config-var/config-modal/index.tsx | 422 +------ .../config-var/config-modal/utils.ts | 247 ++++ .../{ => __tests__}/index.spec.tsx | 39 +- .../{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 7 +- .../agent-setting-button.spec.tsx | 5 +- .../{ => __tests__}/config-audio.spec.tsx | 3 +- .../{ => __tests__}/config-document.spec.tsx | 3 +- .../config/{ => __tests__}/index.spec.tsx | 17 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/item-panel.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 7 +- .../setting-built-in-tool.spec.tsx | 3 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/automatic-btn.spec.tsx | 2 +- .../__tests__/get-automatic-res.spec.tsx | 280 +++++ .../automatic/__tests__/idea-output.spec.tsx | 28 + .../instruction-editor-in-workflow.spec.tsx | 81 ++ .../__tests__/instruction-editor.spec.tsx | 91 ++ .../__tests__/prompt-res-in-workflow.spec.tsx | 89 ++ .../automatic/__tests__/prompt-res.spec.tsx | 40 + .../automatic/__tests__/result.spec.tsx | 126 ++ .../__tests__/version-selector.spec.tsx | 49 + .../__tests__/get-code-generator-res.spec.tsx | 254 ++++ .../app/configuration/configuration-view.tsx | 235 ++++ .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 97 +- .../card-item/{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/var-picker.spec.tsx | 4 +- .../{ => __tests__}/config-content.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/weighted-score.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 88 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../retrieval-section.spec.tsx | 2 +- .../{ => __tests__}/chat-user-input.spec.tsx | 2 +- .../debug/__tests__/hooks.spec.tsx | 154 +++ .../debug/{ => __tests__}/index.spec.tsx | 14 +- .../{ => __tests__}/chat-item.spec.tsx | 8 +- .../{ => __tests__}/context.spec.tsx | 8 +- .../{ => __tests__}/debug-item.spec.tsx | 12 +- .../{ => __tests__}/index.spec.tsx | 11 +- .../model-parameter-trigger.spec.tsx | 6 +- .../text-generation-item.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 7 +- .../use-advanced-prompt-config.spec.tsx | 288 +++++ .../__tests__/use-configuration-utils.spec.ts | 908 ++++++++++++++ .../__tests__/use-configuration.spec.tsx | 492 ++++++++ .../hooks/use-configuration-utils.ts | 688 ++++++++++ .../configuration/hooks/use-configuration.ts | 687 ++++++++++ .../components/app/configuration/index.tsx | 1117 +---------------- .../__tests__/index.spec.tsx | 233 ++++ .../{ => __tests__}/utils.spec.ts | 2 +- .../prompt-value-panel/index.spec.tsx | 108 -- .../external-data-tool-modal-utils.spec.ts | 230 ++++ .../external-data-tool-modal.spec.tsx | 223 ++++ .../tools/external-data-tool-modal-utils.ts | 124 ++ .../tools/external-data-tool-modal.tsx | 115 +- web/app/components/app/configuration/utils.ts | 110 ++ .../{ => __tests__}/index.spec.tsx | 4 +- .../app-card/{ => __tests__}/index.spec.tsx | 3 +- .../app-list/__tests__/index.spec.tsx | 370 ++++++ .../app-list/{ => __tests__}/sidebar.spec.tsx | 2 +- .../create-app-dialog/app-list/index.spec.tsx | 127 -- .../create-app-modal/__tests__/index.spec.tsx | 337 +++++ .../app/create-app-modal/index.spec.tsx | 167 --- .../__tests__/dsl-confirm-modal.spec.tsx | 45 + .../__tests__/index.spec.tsx | 474 +++++++ .../__tests__/uploader.spec.tsx | 210 ++++ .../duplicate-modal/__tests__/index.spec.tsx | 138 ++ .../app/duplicate-modal/index.spec.tsx | 166 --- .../{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/notification.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/empty-element.spec.tsx | 2 +- .../app/log/{ => __tests__}/filter.spec.tsx | 89 +- .../app/log/__tests__/index.spec.tsx | 146 +++ .../app/log/__tests__/list-utils.spec.ts | 240 ++++ .../app/log/__tests__/list.spec.tsx | 654 ++++++++++ .../log/{ => __tests__}/model-info.spec.tsx | 2 +- .../log/{ => __tests__}/var-panel.spec.tsx | 2 +- web/app/components/app/log/list-utils.ts | 343 +++++ web/app/components/app/log/list.spec.tsx | 228 ---- web/app/components/app/log/list.tsx | 268 +--- .../app/overview/__tests__/app-card.spec.tsx | 367 ++++++ .../__tests__/app-chart-utils.spec.ts | 132 ++ .../app/overview/__tests__/app-chart.spec.tsx | 101 ++ .../overview/__tests__/toggle-logic.test.ts | 309 ++--- .../{ => __tests__}/trigger-card.spec.tsx | 2 +- .../{ => __tests__}/cloud.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../app/overview/app-card-sections.tsx | 351 ++++++ .../components/app/overview/app-card-utils.ts | 119 ++ web/app/components/app/overview/app-card.tsx | 425 +++---- .../app/overview/app-chart-utils.ts | 269 ++++ web/app/components/app/overview/app-chart.tsx | 589 +++------ .../customize/{ => __tests__}/index.spec.tsx | 2 +- .../embedded/{ => __tests__}/index.spec.tsx | 4 +- .../settings/{ => __tests__}/index.spec.tsx | 81 +- .../{ => __tests__}/index.spec.tsx | 69 +- .../item/__tests__/action-groups.spec.tsx | 214 ++++ .../item/__tests__/index.spec.tsx | 322 +++++ .../item/__tests__/result-tab.spec.tsx | 65 + .../item/__tests__/utils.spec.ts | 71 ++ .../item/__tests__/workflow-body.spec.tsx | 93 ++ .../app/text-generate/item/action-groups.tsx | 187 +++ .../app/text-generate/item/index.tsx | 245 +--- .../app/text-generate/item/utils.ts | 86 ++ .../app/text-generate/item/workflow-body.tsx | 118 ++ .../{ => __tests__}/index.spec.tsx | 4 +- .../no-data/{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/detail.spec.tsx | 2 +- .../{ => __tests__}/filter.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/list.spec.tsx | 3 +- .../trigger-by-display.spec.tsx | 2 +- web/eslint-suppressions.json | 168 +-- 174 files changed, 16587 insertions(+), 4268 deletions(-) create mode 100644 web/app/components/app/__tests__/store.spec.ts rename web/app/components/app/annotation/{ => __tests__}/batch-action.spec.tsx (96%) rename web/app/components/app/annotation/{ => __tests__}/empty-element.spec.tsx (91%) rename web/app/components/app/annotation/{ => __tests__}/filter.spec.tsx (99%) rename web/app/components/app/annotation/{ => __tests__}/index.spec.tsx (53%) rename web/app/components/app/annotation/{ => __tests__}/list.spec.tsx (97%) rename web/app/components/app/annotation/add-annotation-modal/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/annotation/add-annotation-modal/edit-item/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/app/annotation/batch-add-annotation-modal/{ => __tests__}/csv-downloader.spec.tsx (95%) rename web/app/components/app/annotation/batch-add-annotation-modal/{ => __tests__}/csv-uploader.spec.tsx (88%) rename web/app/components/app/annotation/batch-add-annotation-modal/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/app/annotation/clear-all-annotations-confirm-modal/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/annotation/edit-annotation-modal/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/annotation/edit-annotation-modal/edit-item/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/annotation/header-opts/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/annotation/remove-annotation-confirm-modal/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/annotation/view-annotation-modal/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/app/app-access-control/{ => __tests__}/access-control.spec.tsx (84%) create mode 100644 web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/index.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/sections.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/utils.spec.ts create mode 100644 web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx create mode 100644 web/app/components/app/app-publisher/sections.tsx create mode 100644 web/app/components/app/app-publisher/utils.ts create mode 100644 web/app/components/app/configuration/__tests__/configuration-view.spec.tsx create mode 100644 web/app/components/app/configuration/__tests__/index.spec.tsx create mode 100644 web/app/components/app/configuration/__tests__/utils.spec.ts rename web/app/components/app/configuration/base/feature-panel/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/configuration/base/group-name/{ => __tests__}/index.spec.tsx (92%) rename web/app/components/app/configuration/base/operation-btn/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/configuration/base/var-highlight/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/app/configuration/base/warning-mask/{ => __tests__}/cannot-query-dataset.spec.tsx (94%) rename web/app/components/app/configuration/base/warning-mask/{ => __tests__}/formatting-changed.spec.tsx (95%) rename web/app/components/app/configuration/base/warning-mask/{ => __tests__}/has-not-set-api.spec.tsx (93%) rename web/app/components/app/configuration/base/warning-mask/{ => __tests__}/index.spec.tsx (95%) create mode 100644 web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx rename web/app/components/app/configuration/config-prompt/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/configuration/config-prompt/{ => __tests__}/message-type-selector.spec.tsx (95%) rename web/app/components/app/configuration/config-prompt/{ => __tests__}/prompt-editor-height-resize-wrap.spec.tsx (95%) create mode 100644 web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx rename web/app/components/app/configuration/config-prompt/confirm-add-var/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/app/configuration/config-prompt/conversation-history/{ => __tests__}/edit-modal.spec.tsx (97%) rename web/app/components/app/configuration/config-prompt/conversation-history/{ => __tests__}/history-panel.spec.tsx (95%) rename web/app/components/app/configuration/config-var/{ => __tests__}/index.spec.tsx (82%) create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts create mode 100644 web/app/components/app/configuration/config-var/config-modal/form-fields.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/utils.ts rename web/app/components/app/configuration/config-var/config-select/{ => __tests__}/index.spec.tsx (64%) rename web/app/components/app/configuration/config-var/config-string/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/app/configuration/config-var/select-type-item/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/app/configuration/config-vision/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/app/configuration/config/{ => __tests__}/agent-setting-button.spec.tsx (95%) rename web/app/components/app/configuration/config/{ => __tests__}/config-audio.spec.tsx (97%) rename web/app/components/app/configuration/config/{ => __tests__}/config-document.spec.tsx (97%) rename web/app/components/app/configuration/config/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/app/configuration/config/agent/agent-setting/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/configuration/config/agent/agent-setting/{ => __tests__}/item-panel.spec.tsx (94%) rename web/app/components/app/configuration/config/agent/agent-tools/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/configuration/config/agent/agent-tools/{ => __tests__}/setting-built-in-tool.spec.tsx (98%) rename web/app/components/app/configuration/config/assistant-type-picker/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/configuration/config/automatic/{ => __tests__}/automatic-btn.spec.tsx (97%) create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/get-automatic-res.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/idea-output.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/instruction-editor-in-workflow.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/prompt-res-in-workflow.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/prompt-res.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/result.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/version-selector.spec.tsx create mode 100644 web/app/components/app/configuration/config/code-generator/__tests__/get-code-generator-res.spec.tsx create mode 100644 web/app/components/app/configuration/configuration-view.tsx rename web/app/components/app/configuration/ctrl-btn-group/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/app/configuration/dataset-config/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/app/configuration/dataset-config/card-item/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/configuration/dataset-config/context-var/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/configuration/dataset-config/context-var/{ => __tests__}/var-picker.spec.tsx (99%) rename web/app/components/app/configuration/dataset-config/params-config/{ => __tests__}/config-content.spec.tsx (99%) rename web/app/components/app/configuration/dataset-config/params-config/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/configuration/dataset-config/params-config/{ => __tests__}/weighted-score.spec.tsx (98%) rename web/app/components/app/configuration/dataset-config/select-dataset/{ => __tests__}/index.spec.tsx (63%) rename web/app/components/app/configuration/dataset-config/settings-modal/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/configuration/dataset-config/settings-modal/{ => __tests__}/retrieval-section.spec.tsx (99%) rename web/app/components/app/configuration/debug/{ => __tests__}/chat-user-input.spec.tsx (99%) create mode 100644 web/app/components/app/configuration/debug/__tests__/hooks.spec.tsx rename web/app/components/app/configuration/debug/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/configuration/debug/debug-with-multiple-model/{ => __tests__}/chat-item.spec.tsx (99%) rename web/app/components/app/configuration/debug/debug-with-multiple-model/{ => __tests__}/context.spec.tsx (96%) rename web/app/components/app/configuration/debug/debug-with-multiple-model/{ => __tests__}/debug-item.spec.tsx (98%) rename web/app/components/app/configuration/debug/debug-with-multiple-model/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/configuration/debug/debug-with-multiple-model/{ => __tests__}/model-parameter-trigger.spec.tsx (99%) rename web/app/components/app/configuration/debug/debug-with-multiple-model/{ => __tests__}/text-generation-item.spec.tsx (99%) rename web/app/components/app/configuration/debug/debug-with-single-model/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/app/configuration/hooks/__tests__/use-advanced-prompt-config.spec.tsx create mode 100644 web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts create mode 100644 web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx create mode 100644 web/app/components/app/configuration/hooks/use-configuration-utils.ts create mode 100644 web/app/components/app/configuration/hooks/use-configuration.ts create mode 100644 web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx rename web/app/components/app/configuration/prompt-value-panel/{ => __tests__}/utils.spec.ts (95%) delete mode 100644 web/app/components/app/configuration/prompt-value-panel/index.spec.tsx create mode 100644 web/app/components/app/configuration/tools/__tests__/external-data-tool-modal-utils.spec.ts create mode 100644 web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx create mode 100644 web/app/components/app/configuration/tools/external-data-tool-modal-utils.ts create mode 100644 web/app/components/app/configuration/utils.ts rename web/app/components/app/create-app-dialog/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/create-app-dialog/app-card/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx rename web/app/components/app/create-app-dialog/app-list/{ => __tests__}/sidebar.spec.tsx (95%) delete mode 100644 web/app/components/app/create-app-dialog/app-list/index.spec.tsx create mode 100644 web/app/components/app/create-app-modal/__tests__/index.spec.tsx delete mode 100644 web/app/components/app/create-app-modal/index.spec.tsx create mode 100644 web/app/components/app/create-from-dsl-modal/__tests__/dsl-confirm-modal.spec.tsx create mode 100644 web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx create mode 100644 web/app/components/app/create-from-dsl-modal/__tests__/uploader.spec.tsx create mode 100644 web/app/components/app/duplicate-modal/__tests__/index.spec.tsx delete mode 100644 web/app/components/app/duplicate-modal/index.spec.tsx rename web/app/components/app/in-site-message/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/in-site-message/{ => __tests__}/notification.spec.tsx (99%) rename web/app/components/app/log-annotation/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/log/{ => __tests__}/empty-element.spec.tsx (99%) rename web/app/components/app/log/{ => __tests__}/filter.spec.tsx (70%) create mode 100644 web/app/components/app/log/__tests__/index.spec.tsx create mode 100644 web/app/components/app/log/__tests__/list-utils.spec.ts create mode 100644 web/app/components/app/log/__tests__/list.spec.tsx rename web/app/components/app/log/{ => __tests__}/model-info.spec.tsx (99%) rename web/app/components/app/log/{ => __tests__}/var-panel.spec.tsx (99%) create mode 100644 web/app/components/app/log/list-utils.ts delete mode 100644 web/app/components/app/log/list.spec.tsx create mode 100644 web/app/components/app/overview/__tests__/app-card.spec.tsx create mode 100644 web/app/components/app/overview/__tests__/app-chart-utils.spec.ts create mode 100644 web/app/components/app/overview/__tests__/app-chart.spec.tsx rename web/app/components/app/overview/{ => __tests__}/trigger-card.spec.tsx (99%) rename web/app/components/app/overview/apikey-info-panel/{ => __tests__}/cloud.spec.tsx (98%) rename web/app/components/app/overview/apikey-info-panel/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/app/overview/app-card-sections.tsx create mode 100644 web/app/components/app/overview/app-card-utils.ts create mode 100644 web/app/components/app/overview/app-chart-utils.ts rename web/app/components/app/overview/customize/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/overview/embedded/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/app/overview/settings/{ => __tests__}/index.spec.tsx (79%) rename web/app/components/app/switch-app-modal/{ => __tests__}/index.spec.tsx (77%) create mode 100644 web/app/components/app/text-generate/item/__tests__/action-groups.spec.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/index.spec.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/result-tab.spec.tsx create mode 100644 web/app/components/app/text-generate/item/__tests__/utils.spec.ts create mode 100644 web/app/components/app/text-generate/item/__tests__/workflow-body.spec.tsx create mode 100644 web/app/components/app/text-generate/item/action-groups.tsx create mode 100644 web/app/components/app/text-generate/item/utils.ts create mode 100644 web/app/components/app/text-generate/item/workflow-body.tsx rename web/app/components/app/text-generate/saved-items/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/app/text-generate/saved-items/no-data/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/app/type-selector/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/app/workflow-log/{ => __tests__}/detail.spec.tsx (99%) rename web/app/components/app/workflow-log/{ => __tests__}/filter.spec.tsx (99%) rename web/app/components/app/workflow-log/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/app/workflow-log/{ => __tests__}/list.spec.tsx (99%) rename web/app/components/app/workflow-log/{ => __tests__}/trigger-by-display.spec.tsx (99%) diff --git a/web/app/components/app/__tests__/store.spec.ts b/web/app/components/app/__tests__/store.spec.ts new file mode 100644 index 00000000000..204d659fdd8 --- /dev/null +++ b/web/app/components/app/__tests__/store.spec.ts @@ -0,0 +1,69 @@ +import { useStore } from '../store' + +const resetStore = () => { + useStore.setState({ + appDetail: undefined, + appSidebarExpand: '', + currentLogItem: undefined, + currentLogModalActiveTab: 'DETAIL', + showPromptLogModal: false, + showAgentLogModal: false, + showMessageLogModal: false, + showAppConfigureFeaturesModal: false, + }) +} + +describe('app store', () => { + beforeEach(() => { + resetStore() + }) + + it('should expose the default state', () => { + expect(useStore.getState()).toEqual(expect.objectContaining({ + appDetail: undefined, + appSidebarExpand: '', + currentLogItem: undefined, + currentLogModalActiveTab: 'DETAIL', + showPromptLogModal: false, + showAgentLogModal: false, + showMessageLogModal: false, + showAppConfigureFeaturesModal: false, + })) + }) + + it('should update every mutable field through its actions', () => { + const appDetail = { id: 'app-1' } as ReturnType['appDetail'] + const currentLogItem = { id: 'message-1' } as ReturnType['currentLogItem'] + + useStore.getState().setAppDetail(appDetail) + useStore.getState().setAppSidebarExpand('logs') + useStore.getState().setCurrentLogItem(currentLogItem) + useStore.getState().setCurrentLogModalActiveTab('MESSAGE') + useStore.getState().setShowPromptLogModal(true) + useStore.getState().setShowAgentLogModal(true) + useStore.getState().setShowAppConfigureFeaturesModal(true) + + expect(useStore.getState()).toEqual(expect.objectContaining({ + appDetail, + appSidebarExpand: 'logs', + currentLogItem, + currentLogModalActiveTab: 'MESSAGE', + showPromptLogModal: true, + showAgentLogModal: true, + showAppConfigureFeaturesModal: true, + })) + }) + + it('should reset the active tab when the message log modal closes', () => { + useStore.getState().setCurrentLogModalActiveTab('TRACE') + useStore.getState().setShowMessageLogModal(true) + + expect(useStore.getState().showMessageLogModal).toBe(true) + expect(useStore.getState().currentLogModalActiveTab).toBe('TRACE') + + useStore.getState().setShowMessageLogModal(false) + + expect(useStore.getState().showMessageLogModal).toBe(false) + expect(useStore.getState().currentLogModalActiveTab).toBe('DETAIL') + }) +}) diff --git a/web/app/components/app/annotation/batch-action.spec.tsx b/web/app/components/app/annotation/__tests__/batch-action.spec.tsx similarity index 96% rename from web/app/components/app/annotation/batch-action.spec.tsx rename to web/app/components/app/annotation/__tests__/batch-action.spec.tsx index 8d56dde14aa..95dddd4b23a 100644 --- a/web/app/components/app/annotation/batch-action.spec.tsx +++ b/web/app/components/app/annotation/__tests__/batch-action.spec.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import BatchAction from './batch-action' +import BatchAction from '../batch-action' describe('BatchAction', () => { const baseProps = { diff --git a/web/app/components/app/annotation/empty-element.spec.tsx b/web/app/components/app/annotation/__tests__/empty-element.spec.tsx similarity index 91% rename from web/app/components/app/annotation/empty-element.spec.tsx rename to web/app/components/app/annotation/__tests__/empty-element.spec.tsx index 89ba7e9ff8b..7dcf902a9e3 100644 --- a/web/app/components/app/annotation/empty-element.spec.tsx +++ b/web/app/components/app/annotation/__tests__/empty-element.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import EmptyElement from './empty-element' +import EmptyElement from '../empty-element' describe('EmptyElement', () => { it('should render the empty state copy and supporting icon', () => { diff --git a/web/app/components/app/annotation/filter.spec.tsx b/web/app/components/app/annotation/__tests__/filter.spec.tsx similarity index 99% rename from web/app/components/app/annotation/filter.spec.tsx rename to web/app/components/app/annotation/__tests__/filter.spec.tsx index 7bb39bd4442..8b69494e3f2 100644 --- a/web/app/components/app/annotation/filter.spec.tsx +++ b/web/app/components/app/annotation/__tests__/filter.spec.tsx @@ -1,12 +1,12 @@ import type { UseQueryResult } from '@tanstack/react-query' import type { Mock } from 'vitest' -import type { QueryParam } from './filter' +import type { QueryParam } from '../filter' import type { AnnotationsCountResponse } from '@/models/log' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import * as useLogModule from '@/service/use-log' -import Filter from './filter' +import Filter from '../filter' vi.mock('@/service/use-log') diff --git a/web/app/components/app/annotation/index.spec.tsx b/web/app/components/app/annotation/__tests__/index.spec.tsx similarity index 53% rename from web/app/components/app/annotation/index.spec.tsx rename to web/app/components/app/annotation/__tests__/index.spec.tsx index 5f5e9f74c07..cd9b127c7f7 100644 --- a/web/app/components/app/annotation/index.spec.tsx +++ b/web/app/components/app/annotation/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ +/* eslint-disable ts/no-explicit-any */ import type { Mock } from 'vitest' -import type { AnnotationItem } from './type' +import type { AnnotationItem } from '../type' import type { App } from '@/types/app' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' @@ -9,13 +10,16 @@ import { addAnnotation, delAnnotation, delAnnotations, + editAnnotation, fetchAnnotationConfig, fetchAnnotationList, queryAnnotationJobStatus, + updateAnnotationScore, + updateAnnotationStatus, } from '@/service/annotation' import { AppModeEnum } from '@/types/app' -import Annotation from './index' -import { JobStatus } from './type' +import Annotation from '../index' +import { AnnotationEnableStatus, JobStatus } from '../type' vi.mock('ahooks', () => ({ useDebounce: (value: any) => value, @@ -37,29 +41,32 @@ vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) -vi.mock('./filter', () => ({ +vi.mock('../filter', () => ({ default: ({ children }: { children: React.ReactNode }) => (
{children}
), })) -vi.mock('./empty-element', () => ({ +vi.mock('../empty-element', () => ({ default: () =>
, })) -vi.mock('./header-opts', () => ({ +vi.mock('../header-opts', () => ({ default: (props: any) => (
+
), })) let latestListProps: any -vi.mock('./list', () => ({ +vi.mock('../list', () => ({ default: (props: any) => { latestListProps = props if (!props.list.length) @@ -74,7 +81,7 @@ vi.mock('./list', () => ({ }, })) -vi.mock('./view-annotation-modal', () => ({ +vi.mock('../view-annotation-modal', () => ({ default: (props: any) => { if (!props.isShow) return null @@ -82,14 +89,40 @@ vi.mock('./view-annotation-modal', () => ({
{props.item.question}
+
) }, })) -vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({ default: (props: any) => props.isShow ?
: null })) -vi.mock('@/app/components/billing/annotation-full/modal', () => ({ default: (props: any) => props.show ?
: null })) +vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({ + default: (props: any) => props.isShow + ? ( +
+ + +
+ ) + : null, +})) +vi.mock('@/app/components/billing/annotation-full/modal', () => ({ + default: (props: any) => props.show + ? ( +
+ +
+ ) + : null, +})) const mockNotify = vi.fn() vi.spyOn(toast, 'success').mockImplementation((message, options) => { @@ -111,9 +144,12 @@ vi.spyOn(toast, 'info').mockImplementation((message, options) => { const addAnnotationMock = addAnnotation as Mock const delAnnotationMock = delAnnotation as Mock const delAnnotationsMock = delAnnotations as Mock +const editAnnotationMock = editAnnotation as Mock const fetchAnnotationConfigMock = fetchAnnotationConfig as Mock const fetchAnnotationListMock = fetchAnnotationList as Mock const queryAnnotationJobStatusMock = queryAnnotationJobStatus as Mock +const updateAnnotationScoreMock = updateAnnotationScore as Mock +const updateAnnotationStatusMock = updateAnnotationStatus as Mock const useProviderContextMock = useProviderContext as Mock const appDetail = { @@ -146,6 +182,9 @@ describe('Annotation', () => { }) fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 }) queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed }) + updateAnnotationStatusMock.mockResolvedValue({ job_id: 'job-1' }) + updateAnnotationScoreMock.mockResolvedValue(undefined) + editAnnotationMock.mockResolvedValue(undefined) useProviderContextMock.mockReturnValue({ plan: { usage: { annotatedResponse: 0 }, @@ -251,4 +290,166 @@ describe('Annotation', () => { expect(latestListProps.selectedIds).toEqual([annotation.id]) }) }) + + it('should show the annotation-full modal when enabling annotations exceeds the plan quota', async () => { + useProviderContextMock.mockReturnValue({ + plan: { + usage: { annotatedResponse: 10 }, + total: { annotatedResponse: 10 }, + }, + enableBilling: true, + }) + + renderComponent() + + const toggle = await screen.findByRole('switch') + fireEvent.click(toggle) + + expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('hide-annotation-full-modal')) + expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument() + }) + + it('should disable annotations and refetch config after the async job completes', async () => { + 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') + }) + fireEvent.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(appDetail.id, AnnotationEnableStatus.disable, 'job-1') + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + message: 'common.api.actionSuccess', + type: 'success', + })) + }) + }) + + it('should save annotation config changes and update the score when the modal confirms', async () => { + fetchAnnotationConfigMock.mockResolvedValue({ + 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') + fireEvent.click(toggle) + + expect(screen.getByTestId('config-modal')).toBeInTheDocument() + fireEvent.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(appDetail.id, 'config-id', 0.7) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + message: 'common.api.actionSuccess', + type: 'success', + })) + }) + }) + + it('should refresh the list from the header shortcut and allow saving or closing the view modal', async () => { + const annotation = createAnnotation() + fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 }) + + renderComponent() + + await screen.findByTestId('list') + fireEvent.click(screen.getByTestId('list-view')) + + fireEvent.click(screen.getByTestId('view-modal-save')) + + await waitFor(() => { + expect(editAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id, { + question: 'Edited question', + answer: 'Edited answer', + }) + }) + + fireEvent.click(screen.getByTestId('view-modal-close')) + expect(screen.queryByTestId('view-modal')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('trigger-added')) + + expect(fetchAnnotationListMock).toHaveBeenCalled() + }) + + it('should clear selections on cancel and hide the config modal when requested', async () => { + const annotation = createAnnotation() + fetchAnnotationConfigMock.mockResolvedValue({ + id: 'config-id', + enabled: true, + embedding_model: { + embedding_model_name: 'model', + embedding_provider_name: 'provider', + }, + score_threshold: 0.5, + }) + fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 }) + + renderComponent() + + await screen.findByTestId('list') + + await act(async () => { + latestListProps.onSelectedIdsChange([annotation.id]) + }) + await act(async () => { + latestListProps.onCancel() + }) + + expect(latestListProps.selectedIds).toEqual([]) + + const configButton = document.querySelector('.action-btn') as HTMLButtonElement + fireEvent.click(configButton) + expect(await screen.findByTestId('config-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('config-hide')) + expect(screen.queryByTestId('config-modal')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/app/annotation/list.spec.tsx b/web/app/components/app/annotation/__tests__/list.spec.tsx similarity index 97% rename from web/app/components/app/annotation/list.spec.tsx rename to web/app/components/app/annotation/__tests__/list.spec.tsx index c126092ecf9..aa47a5304b3 100644 --- a/web/app/components/app/annotation/list.spec.tsx +++ b/web/app/components/app/annotation/__tests__/list.spec.tsx @@ -1,7 +1,7 @@ -import type { AnnotationItem } from './type' +import type { AnnotationItem } from '../type' import { fireEvent, render, screen, within } from '@testing-library/react' import * as React from 'react' -import List from './list' +import List from '../list' const mockFormatTime = vi.fn(() => 'formatted-time') diff --git a/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/app/annotation/add-annotation-modal/index.spec.tsx rename to web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx index 14f94d910bc..d7e46f9f925 100644 --- a/web/app/components/app/annotation/add-annotation-modal/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { Mock } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { useProviderContext } from '@/context/provider-context' -import AddAnnotationModal from './index' +import AddAnnotationModal from '../index' vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), diff --git a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/add-annotation-modal/edit-item/__tests__/index.spec.tsx similarity index 96% rename from web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx rename to web/app/components/app/annotation/add-annotation-modal/edit-item/__tests__/index.spec.tsx index ce660f7880c..6dd1d42246e 100644 --- a/web/app/components/app/annotation/add-annotation-modal/edit-item/index.spec.tsx +++ b/web/app/components/app/annotation/add-annotation-modal/edit-item/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import EditItem, { EditItemType } from './index' +import EditItem, { EditItemType } from '../index' describe('AddAnnotationModal/EditItem', () => { it('should render query inputs with user avatar and placeholder strings', () => { diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx similarity index 95% rename from web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx rename to web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx index 2ab0934fe27..69574564eb9 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-downloader.spec.tsx @@ -1,10 +1,11 @@ +/* eslint-disable ts/no-explicit-any */ import type { Mock } from 'vitest' import type { Locale } from '@/i18n-config' import { render, screen } from '@testing-library/react' import * as React from 'react' import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' -import CSVDownload from './csv-downloader' +import CSVDownload from '../csv-downloader' const downloaderProps: any[] = [] diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx similarity index 88% rename from web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.spec.tsx rename to web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx index 847db746195..d26ab051ef8 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/csv-uploader.spec.tsx @@ -1,7 +1,7 @@ -import type { Props } from './csv-uploader' +import type { Props } from '../csv-uploader' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import CSVUploader from './csv-uploader' +import CSVUploader from '../csv-uploader' const toastMocks = vi.hoisted(() => ({ notify: vi.fn(), @@ -75,6 +75,20 @@ describe('CSVUploader', () => { expect(dropZone.className).not.toContain('border-components-dropzone-border-accent') }) + it('should handle drag over and clear dragging state when leaving through the overlay', () => { + renderComponent() + const { dropZone, dropContainer } = getDropElements() + + fireEvent.dragEnter(dropContainer) + const dragLayer = dropContainer.querySelector('.absolute') as HTMLDivElement + + fireEvent.dragOver(dropContainer) + fireEvent.dragLeave(dragLayer) + + expect(dropZone.className).not.toContain('border-components-dropzone-border-accent') + expect(dropZone.className).not.toContain('bg-components-dropzone-bg-accent') + }) + it('should ignore drop events without dataTransfer', () => { renderComponent() const { dropContainer } = getDropElements() diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx similarity index 96% rename from web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx rename to web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx index 8929cc292f9..51047060459 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.spec.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ import type { Mock } from 'vitest' -import type { IBatchModalProps } from './index' +import type { IBatchModalProps } from '../index' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { useProviderContext } from '@/context/provider-context' import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation' -import BatchModal, { ProcessStatus } from './index' +import BatchModal, { ProcessStatus } from '../index' vi.mock('@/service/annotation', () => ({ annotationBatchImport: vi.fn(), @@ -15,13 +15,13 @@ vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) -vi.mock('./csv-downloader', () => ({ +vi.mock('../csv-downloader', () => ({ default: () =>
, })) let lastUploadedFile: File | undefined -vi.mock('./csv-uploader', () => ({ +vi.mock('../csv-uploader', () => ({ default: ({ file, updateFile }: { file?: File, updateFile: (file?: File) => void }) => (
+ +
+ ) + }, +})) + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: { features: typeof mockFeatures }) => unknown) => selector({ features: mockFeatures }), + useFeaturesStore: () => ({ + getState: () => ({ + features: mockFeatures, + setFeatures: mockSetFeatures, + }), + }), +})) + +describe('FeaturesWrappedAppPublisher', () => { + const publishedConfig = { + modelConfig: { + more_like_this: { enabled: true }, + opening_statement: 'Hello there', + suggested_questions: ['Q1'], + sensitive_word_avoidance: { enabled: true }, + speech_to_text: { enabled: true }, + text_to_speech: { enabled: true }, + suggested_questions_after_answer: { enabled: true }, + retriever_resource: { enabled: true }, + annotation_reply: { enabled: true }, + file_upload: { + enabled: true, + image: { + enabled: true, + detail: 'low', + number_limits: 5, + transfer_methods: ['remote_url'], + }, + allowed_file_types: ['image'], + allowed_file_extensions: ['.jpg'], + allowed_file_upload_methods: ['remote_url'], + number_limits: 5, + }, + resetAppConfig: vi.fn(), + }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockAppPublisherProps.current = null + }) + + it('should pass current features through to onPublish', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('publish-through-wrapper')) + + await waitFor(() => { + expect(mockOnPublish).toHaveBeenCalledWith({ id: 'model-1' }, mockFeatures) + }) + }) + + it('should restore published features after confirmation', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('restore-through-wrapper')) + fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' })) + + await waitFor(() => { + expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1) + expect(mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({ + moreLikeThis: { enabled: true }, + opening: { + enabled: true, + opening_statement: 'Hello there', + suggested_questions: ['Q1'], + }, + moderation: { enabled: true }, + speech2text: { enabled: true }, + text2speech: { enabled: true }, + suggested: { enabled: true }, + citation: { enabled: true }, + annotationReply: { enabled: true }, + })) + }) + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx new file mode 100644 index 00000000000..e97efaa5259 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -0,0 +1,455 @@ +/* eslint-disable ts/no-explicit-any */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { basePath } from '@/utils/var' +import AppPublisher from '../index' + +const mockOnPublish = vi.fn() +const mockOnToggle = vi.fn() +const mockSetAppDetail = vi.fn() +const mockTrackEvent = vi.fn() +const mockRefetch = vi.fn() +const mockOpenAsyncWindow = vi.fn() +const mockFetchInstalledAppList = vi.fn() +const mockFetchAppDetailDirect = vi.fn() +const mockToastError = vi.fn() + +const sectionProps = vi.hoisted(() => ({ + summary: null as null | Record, + access: null as null | Record, + actions: null as null | Record, +})) +const ahooksMocks = vi.hoisted(() => ({ + keyPressHandlers: [] as Array<(event: { preventDefault: () => void }) => void>, +})) + +let mockAppDetail: Record | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('ahooks', async () => { + return { + useKeyPress: (_keys: unknown, handler: (event: { preventDefault: () => void }) => void) => { + ahooksMocks.keyPressHandlers.push(handler) + }, + } +}) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { appDetail: Record | null, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({ + appDetail: mockAppDetail, + setAppDetail: mockSetAppDetail, + }), +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({ + systemFeatures: { + webapp_auth: { + enabled: true, + }, + }, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: () => 'moments ago', + }), +})) + +vi.mock('@/hooks/use-async-window-open', () => ({ + useAsyncWindowOpen: () => mockOpenAsyncWindow, +})) + +vi.mock('@/service/access-control', () => ({ + useGetUserCanAccessApp: () => ({ + data: { result: true }, + isLoading: false, + refetch: mockRefetch, + }), + useAppWhiteListSubjects: () => ({ + data: { groups: [], members: [] }, + isLoading: false, + }), +})) + +vi.mock('@/service/explore', () => ({ + fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), +})) + +vi.mock('@/service/apps', () => ({ + fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (isShow + ? ( +
+ embedded modal + +
+ ) + : null), +})) + +vi.mock('../../app-access-control', () => ({ + default: ({ onConfirm, onClose }: { onConfirm: () => Promise, onClose: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const ReactModule = await vi.importActual('react') + const OpenContext = ReactModule.createContext(false) + + return { + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + +
{children}
+
+ ), + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( +
{children}
+ ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => { + const open = ReactModule.useContext(OpenContext) + return open ?
{children}
: null + }, + } +}) + +vi.mock('../sections', () => ({ + PublisherSummarySection: (props: Record) => { + sectionProps.summary = props + return ( +
+ + +
+ ) + }, + PublisherAccessSection: (props: Record) => { + sectionProps.access = props + return + }, + PublisherActionsSection: (props: Record) => { + sectionProps.actions = props + return ( +
+ + +
+ ) + }, +})) + +describe('AppPublisher', () => { + beforeEach(() => { + vi.clearAllMocks() + ahooksMocks.keyPressHandlers.length = 0 + sectionProps.summary = null + sectionProps.access = null + sectionProps.actions = null + mockAppDetail = { + id: 'app-1', + name: 'Demo App', + mode: AppModeEnum.CHAT, + access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } + mockFetchInstalledAppList.mockResolvedValue({ + installed_apps: [{ id: 'installed-1' }], + }) + mockFetchAppDetailDirect.mockResolvedValue({ + id: 'app-1', + access_mode: AccessMode.PUBLIC, + }) + mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise) => { + await resolver() + }) + }) + + it('should open the publish popover and refetch access permission data', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + + expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument() + expect(mockOnToggle).toHaveBeenCalledWith(true) + + await waitFor(() => { + expect(mockRefetch).toHaveBeenCalledTimes(1) + }) + }) + + it('should publish and track the publish event', async () => { + mockOnPublish.mockResolvedValue(undefined) + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.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', + })) + }) + }) + + it('should open the embedded modal from the actions section', () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-embed')) + + expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() + }) + + it('should close embedded and access control panels through child callbacks', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-embed')) + fireEvent.click(screen.getByText('close-embedded-modal')) + expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-access-control')) + expect(screen.getByTestId('access-control')).toBeInTheDocument() + fireEvent.click(screen.getByText('close-access-control')) + expect(screen.queryByTestId('access-control')).not.toBeInTheDocument() + }) + + it('should refresh app detail after access control confirmation', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-access-control')) + + expect(screen.getByTestId('access-control')).toBeInTheDocument() + + fireEvent.click(screen.getByText('confirm-access-control')) + + await waitFor(() => { + expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) + expect(mockSetAppDetail).toHaveBeenCalledWith({ + id: 'app-1', + access_mode: AccessMode.PUBLIC, + }) + }) + }) + + it('should open the installed explore page through the async window helper', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-open-in-explore')) + + await waitFor(() => { + expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1) + expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1') + expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`) + }) + }) + + it('should ignore the trigger when the publish button is disabled', () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish').parentElement?.parentElement as HTMLElement) + + expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() + expect(mockOnToggle).not.toHaveBeenCalled() + }) + + it('should publish from the keyboard shortcut and restore the popover state', async () => { + const preventDefault = vi.fn() + const onRestore = vi.fn().mockResolvedValue(undefined) + mockOnPublish.mockResolvedValue(undefined) + + render( + , + ) + + ahooksMocks.keyPressHandlers[0]({ preventDefault }) + + await waitFor(() => { + expect(preventDefault).toHaveBeenCalled() + expect(mockOnPublish).toHaveBeenCalledTimes(1) + }) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-summary-restore')) + + await waitFor(() => { + expect(onRestore).toHaveBeenCalledTimes(1) + }) + expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() + }) + + it('should keep the popover open when restore fails and reset published state after publish failures', async () => { + const preventDefault = vi.fn() + const onRestore = vi.fn().mockRejectedValue(new Error('restore failed')) + mockOnPublish.mockRejectedValueOnce(new Error('publish failed')) + + render( + , + ) + + ahooksMocks.keyPressHandlers[0]({ preventDefault }) + + await waitFor(() => { + expect(preventDefault).toHaveBeenCalled() + expect(mockOnPublish).toHaveBeenCalledTimes(1) + }) + expect(mockTrackEvent).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-summary-restore')) + + await waitFor(() => { + expect(onRestore).toHaveBeenCalledTimes(1) + }) + expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument() + }) + + it('should report missing explore installations', async () => { + mockFetchInstalledAppList.mockResolvedValueOnce({ + installed_apps: [], + }) + mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise, options: { onError: (error: Error) => void }) => { + try { + await resolver() + } + catch (error) { + options.onError(error as Error) + } + }) + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-open-in-explore')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('No app found in Explore') + }) + }) + + it('should report explore errors when the app cannot be opened', async () => { + mockAppDetail = { + ...mockAppDetail, + id: undefined, + } + mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise, options: { onError: (error: Error) => void }) => { + try { + await resolver() + } + catch (error) { + options.onError(error as Error) + } + }) + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-open-in-explore')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('App not found') + }) + }) + + it('should keep access control open when app detail is unavailable during confirmation', async () => { + mockAppDetail = null + + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('publisher-access-control')) + fireEvent.click(screen.getByText('confirm-access-control')) + + await waitFor(() => { + expect(mockFetchAppDetailDirect).not.toHaveBeenCalled() + }) + expect(screen.getByTestId('access-control')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx new file mode 100644 index 00000000000..f476d8b188e --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import PublishWithMultipleModel from '../publish-with-multiple-model' + +const mockUseProviderContext = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockUseProviderContext(), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({ + default: ({ modelName }: { modelName: string }) => {modelName}, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const ReactModule = await vi.importActual('react') + const OpenContext = ReactModule.createContext(false) + + return { + PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( + +
{children}
+
+ ), + PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => ( +
+ {children} +
+ ), + PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { + const open = ReactModule.useContext(OpenContext) + return open ?
{children}
: null + }, + } +}) + +describe('PublishWithMultipleModel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProviderContext.mockReturnValue({ + textGenerationModelList: [ + { + provider: 'openai', + models: [ + { + model: 'gpt-4o', + label: { + en_US: 'GPT-4o', + }, + }, + ], + }, + ], + }) + }) + + it('should disable the trigger when no valid model configuration is available', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'operation.applyConfig' })).toBeDisabled() + expect(screen.queryByText('publishAs')).not.toBeInTheDocument() + }) + + it('should open matching model options and call onSelect', () => { + const handleSelect = vi.fn() + const modelConfig = { + id: 'config-1', + provider: 'openai', + model: 'gpt-4o', + parameters: { temperature: 0.7 }, + } + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.applyConfig' })) + + expect(screen.getByText('publishAs')).toBeInTheDocument() + + fireEvent.click(screen.getByText('GPT-4o')) + + expect(handleSelect).toHaveBeenCalledWith(expect.objectContaining(modelConfig)) + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx new file mode 100644 index 00000000000..57e7a55b131 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -0,0 +1,266 @@ +/* eslint-disable ts/no-explicit-any */ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { AccessModeDisplay, PublisherAccessSection, PublisherActionsSection, PublisherSummarySection } from '../sections' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('../publish-with-multiple-model', () => ({ + default: ({ onSelect }: { onSelect: (item: Record) => void }) => ( + + ), +})) + +vi.mock('../suggested-action', () => ({ + default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => ( + + ), +})) + +vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({ + default: (props: Record) => ( +
+ workflow-tool-configure + {String(props.disabledReason || '')} +
+ ), +})) + +describe('app-publisher sections', () => { + it('should render restore controls for published chat apps', () => { + const handleRestore = vi.fn() + + render( + '3 minutes ago'} + handlePublish={vi.fn()} + handleRestore={handleRestore} + isChatApp + multipleModelConfigs={[]} + publishDisabled={false} + published={false} + publishedAt={Date.now()} + publishShortcut={['ctrl', '⇧', 'P']} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + />, + ) + + fireEvent.click(screen.getByText('common.restore')) + expect(handleRestore).toHaveBeenCalled() + }) + + it('should expose the access control warning when subjects are missing', () => { + render( + , + ) + + expect(screen.getByText('publishApp.notSet')).toBeInTheDocument() + expect(screen.getByText('publishApp.notSetDesc')).toBeInTheDocument() + }) + + it('should render the publish update action when the draft has not been published yet', () => { + render( + '1 minute ago'} + handlePublish={vi.fn()} + handleRestore={vi.fn()} + isChatApp={false} + multipleModelConfigs={[]} + publishDisabled={false} + published={false} + publishedAt={undefined} + publishShortcut={['ctrl', '⇧', 'P']} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + />, + ) + + expect(screen.getByText('common.publishUpdate')).toBeInTheDocument() + }) + + it('should render multiple-model publishing', () => { + const handlePublish = vi.fn() + + render( + '1 minute ago'} + handlePublish={handlePublish} + handleRestore={vi.fn()} + isChatApp={false} + multipleModelConfigs={[{ id: '1' } as any]} + publishDisabled={false} + published={false} + publishedAt={undefined} + publishShortcut={['ctrl', '⇧', 'P']} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + />, + ) + + fireEvent.click(screen.getByText('publish-multiple-model')) + + expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' }) + }) + + it('should render the upgrade hint when the start node limit is exceeded', () => { + render( + '1 minute ago'} + handlePublish={vi.fn()} + handleRestore={vi.fn()} + isChatApp={false} + multipleModelConfigs={[]} + publishDisabled={false} + published={false} + publishedAt={undefined} + publishShortcut={['ctrl', '⇧', 'P']} + startNodeLimitExceeded + upgradeHighlightStyle={{}} + />, + ) + + expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument() + }) + + it('should render loading access state and access mode labels when enabled', () => { + const { rerender } = render( + , + ) + + expect(document.querySelector('.spin-animation')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('accessControlDialog.accessItems.anyone')).toBeInTheDocument() + expect(render().container).toBeEmptyDOMElement() + }) + + it('should render workflow actions, batch run links, and workflow tool configuration', () => { + const handleOpenInExplore = vi.fn() + const handleEmbed = vi.fn() + + const { rerender } = render( + , + ) + + expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch') + fireEvent.click(screen.getByText('common.openInExplore')) + expect(handleOpenInExplore).toHaveBeenCalled() + expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument() + expect(screen.getByText('workflow-disabled')).toBeInTheDocument() + + rerender( + , + ) + + fireEvent.click(screen.getByText('common.embedIntoSite')) + expect(handleEmbed).toHaveBeenCalled() + expect(screen.getByText('common.accessAPIReference')).toBeDisabled() + + rerender( + , + ) + + expect(screen.queryByText('common.runApp')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx new file mode 100644 index 00000000000..ea199dfb781 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx @@ -0,0 +1,49 @@ +import type { MouseEvent as ReactMouseEvent } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import SuggestedAction from '../suggested-action' + +describe('SuggestedAction', () => { + it('should render an enabled external link', () => { + render( + + Open docs + , + ) + + const link = screen.getByRole('link', { name: 'Open docs' }) + expect(link).toHaveAttribute('href', 'https://example.com/docs') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should block clicks when disabled', () => { + const handleClick = vi.fn() + + render( + + Disabled action + , + ) + + const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement + fireEvent.click(link) + + expect(link).not.toHaveAttribute('href') + expect(handleClick).not.toHaveBeenCalled() + }) + + it('should forward click events when enabled', () => { + const handleClick = vi.fn((event: ReactMouseEvent) => { + event.preventDefault() + }) + + render( + + Enabled action + , + ) + + fireEvent.click(screen.getByRole('link', { name: 'Enabled action' })) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/utils.spec.ts b/web/app/components/app/app-publisher/__tests__/utils.spec.ts new file mode 100644 index 00000000000..9f191ce514d --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/utils.spec.ts @@ -0,0 +1,70 @@ +import type { TFunction } from 'i18next' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { basePath } from '@/utils/var' +import { + getDisabledFunctionTooltip, + getPublisherAppMode, + getPublisherAppUrl, + isPublisherAccessConfigured, +} from '../utils' + +describe('app-publisher utils', () => { + describe('getPublisherAppMode', () => { + it('should normalize chat-like apps to chat mode', () => { + expect(getPublisherAppMode(AppModeEnum.AGENT_CHAT)).toBe(AppModeEnum.CHAT) + }) + + it('should keep completion mode unchanged', () => { + expect(getPublisherAppMode(AppModeEnum.COMPLETION)).toBe(AppModeEnum.COMPLETION) + }) + }) + + describe('getPublisherAppUrl', () => { + it('should build the published app url from site info', () => { + expect(getPublisherAppUrl({ + appBaseUrl: 'https://example.com', + accessToken: 'token-1', + mode: AppModeEnum.CHAT, + })).toBe(`https://example.com${basePath}/chat/token-1`) + }) + }) + + describe('isPublisherAccessConfigured', () => { + it('should require members or groups for specific access mode', () => { + expect(isPublisherAccessConfigured( + { access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + { groups: [], members: [] }, + )).toBe(false) + }) + + it('should treat public access as configured', () => { + expect(isPublisherAccessConfigured( + { access_mode: AccessMode.PUBLIC }, + { groups: [], members: [] }, + )).toBe(true) + }) + }) + + describe('getDisabledFunctionTooltip', () => { + const t = ((key: string) => key) as unknown as TFunction + + it('should prioritize the unpublished hint', () => { + expect(getDisabledFunctionTooltip({ + t, + publishedAt: undefined, + missingStartNode: false, + noAccessPermission: false, + })).toBe('notPublishedYet') + }) + + it('should return the access error when the app is published but blocked', () => { + expect(getDisabledFunctionTooltip({ + t, + publishedAt: Date.now(), + missingStartNode: false, + noAccessPermission: true, + })).toBe('noAccessPermission') + }) + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx new file mode 100644 index 00000000000..fa8b1c92fc9 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx @@ -0,0 +1,128 @@ +/* eslint-disable ts/no-explicit-any */ +import { fireEvent, render, screen } from '@testing-library/react' +import { toast } from '@/app/components/base/ui/toast' +import VersionInfoModal from '../version-info-modal' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +describe('VersionInfoModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should prefill the fields from the current version info', () => { + render( + , + ) + + expect(screen.getByDisplayValue('Release 1')).toBeInTheDocument() + expect(screen.getByDisplayValue('Initial release')).toBeInTheDocument() + }) + + it('should reject overlong titles', () => { + const handlePublish = vi.fn() + + render( + , + ) + + const [titleInput] = screen.getAllByRole('textbox') + fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } }) + fireEvent.click(screen.getByRole('button', { name: 'common.publish' })) + + expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit') + expect(handlePublish).not.toHaveBeenCalled() + }) + + it('should publish valid values and close the modal', () => { + const handlePublish = vi.fn() + const handleClose = vi.fn() + + render( + , + ) + + const [titleInput, notesInput] = screen.getAllByRole('textbox') + fireEvent.change(titleInput, { target: { value: 'Release 2' } }) + fireEvent.change(notesInput, { target: { value: 'Updated notes' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.publish' })) + + expect(handlePublish).toHaveBeenCalledWith({ + title: 'Release 2', + releaseNotes: 'Updated notes', + id: 'version-2', + }) + expect(handleClose).toHaveBeenCalledTimes(1) + }) + + it('should validate release note length and clear previous errors before publishing', () => { + const handlePublish = vi.fn() + const handleClose = vi.fn() + + render( + , + ) + + const [titleInput, notesInput] = screen.getAllByRole('textbox') + + fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } }) + fireEvent.click(screen.getByRole('button', { name: 'common.publish' })) + expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit') + + fireEvent.change(titleInput, { target: { value: 'Release 3' } }) + fireEvent.change(notesInput, { target: { value: 'b'.repeat(101) } }) + fireEvent.click(screen.getByRole('button', { name: 'common.publish' })) + expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.releaseNotesLengthLimit') + + fireEvent.change(notesInput, { target: { value: 'Stable release notes' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.publish' })) + + expect(handlePublish).toHaveBeenCalledWith({ + title: 'Release 3', + releaseNotes: 'Stable release notes', + id: 'version-3', + }) + expect(handleClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 0b5f6298290..8792a1c5072 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,6 +1,5 @@ import type { ModelAndParameter } from '../configuration/debug/types' import type { InputVar, Variable } from '@/app/components/workflow/types' -import type { I18nKeysByPrefix } from '@/types/i18n' import type { PublishWorkflowParams } from '@/types/workflow' import { useKeyPress } from 'ahooks' import { @@ -15,15 +14,11 @@ import EmbeddedModal from '@/app/components/app/overview/embedded' import { useStore as useAppStore } from '@/app/components/app/store' import { trackEvent } from '@/app/components/base/amplitude' import Button from '@/app/components/base/button' -import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import UpgradeBtn from '@/app/components/billing/upgrade-btn' -import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' -import { appDefaultIconBackground } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' @@ -33,54 +28,19 @@ import { fetchAppDetailDirect } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import Divider from '../../base/divider' -import Loading from '../../base/loading' -import Tooltip from '../../base/tooltip' import { toast } from '../../base/ui/toast' -import ShortcutsName from '../../workflow/shortcuts-name' import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' -import PublishWithMultipleModel from './publish-with-multiple-model' -import SuggestedAction from './suggested-action' - -type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'> - -const ACCESS_MODE_MAP: Record = { - [AccessMode.ORGANIZATION]: { - label: 'organization', - icon: 'i-ri-building-line', - }, - [AccessMode.SPECIFIC_GROUPS_MEMBERS]: { - label: 'specific', - icon: 'i-ri-lock-line', - }, - [AccessMode.PUBLIC]: { - label: 'anyone', - icon: 'i-ri-global-line', - }, - [AccessMode.EXTERNAL_MEMBERS]: { - label: 'external', - icon: 'i-ri-verified-badge-line', - }, -} - -const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => { - const { t } = useTranslation() - - if (!mode || !ACCESS_MODE_MAP[mode]) - return null - - const { icon, label } = ACCESS_MODE_MAP[mode] - - return ( - <> - -
- {t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })} -
- - ) -} +import { + PublisherAccessSection, + PublisherActionsSection, + PublisherSummarySection, +} from './sections' +import { + getDisabledFunctionTooltip, + getPublisherAppUrl, + isPublisherAccessConfigured, +} from './utils' export type AppPublisherProps = { disabled?: boolean @@ -143,32 +103,28 @@ const AppPublisher = ({ const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} - const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode - const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}` + const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false }) const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS) const openAsyncWindow = useAsyncWindowOpen() - const isAppAccessSet = useMemo(() => { - if (appDetail && appAccessSubjects) { - return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0) - } - return true - }, [appAccessSubjects, appDetail]) + const isAppAccessSet = useMemo(() => isPublisherAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail]) - const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp]) + const noAccessPermission = useMemo(() => Boolean( + systemFeatures.webapp_auth.enabled + && appDetail + && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS + && !userCanAccessApp?.result, + ), [systemFeatures, appDetail, userCanAccessApp]) const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission]) - - const disabledFunctionTooltip = useMemo(() => { - if (!publishedAt) - return t('notPublishedYet', { ns: 'app' }) - if (missingStartNode) - return t('noUserInputNode', { ns: 'app' }) - if (noAccessPermission) - return t('noAccessPermission', { ns: 'app' }) - }, [missingStartNode, noAccessPermission, publishedAt, t]) + const disabledFunctionTooltip = useMemo(() => getDisabledFunctionTooltip({ + t, + publishedAt, + missingStartNode, + noAccessPermission, + }), [missingStartNode, noAccessPermission, publishedAt, t]) useEffect(() => { if (systemFeatures.webapp_auth.enabled && open && appDetail) @@ -244,9 +200,9 @@ const AppPublisher = ({ }, { exactMatch: true, useCapture: true }) const hasPublishedVersion = !!publishedAt - const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable - const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined - const showStartNodeLimitHint = Boolean(startNodeLimitExceeded) + const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable + ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) + : undefined const upgradeHighlightStyle = useMemo(() => ({ background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', WebkitBackgroundClip: 'text', @@ -277,199 +233,51 @@ const AppPublisher = ({
-
-
- {publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })} -
- {publishedAt - ? ( -
-
- {t('common.publishedAt', { ns: 'workflow' })} - {' '} - {formatTimeFromNow(publishedAt)} -
- {isChatApp && ( - - )} -
- ) - : ( -
- {t('common.autoSaved', { ns: 'workflow' })} - {' '} - · - {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} -
- )} - {debugWithMultipleModel - ? ( - handlePublish(item)} - // textGenerationModelList={textGenerationModelList} - /> - ) - : ( - <> - - {showStartNodeLimitHint && ( -
-

- {t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })} - {t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })} -

-

- {t('publishLimit.startNodeDesc', { ns: 'workflow' })} -

- -
- )} - - )} -
- {(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects)) - ?
- : ( - <> - - {systemFeatures.webapp_auth.enabled && ( -
-
-

{t('publishApp.title', { ns: 'app' })}

-
-
{ - setShowAppAccessControl(true) - }} - > -
- -
- {!isAppAccessSet &&

{t('publishApp.notSet', { ns: 'app' })}

} -
- -
-
- {!isAppAccessSet &&

{t('publishApp.notSetDesc', { ns: 'app' })}

} -
- )} - { - // Hide run/batch run app buttons when there is a trigger node. - !hasTriggerNode && ( -
- - } - > - {t('common.runApp', { ns: 'workflow' })} - - - {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION - ? ( - - } - > - {t('common.batchRunApp', { ns: 'workflow' })} - - - ) - : ( - { - setEmbeddingModalOpen(true) - handleTrigger() - }} - disabled={!publishedAt} - icon={} - > - {t('common.embedIntoSite', { ns: 'workflow' })} - - )} - - { - if (publishedAt) - handleOpenInExplore() - }} - disabled={disabledFunctionButton} - icon={} - > - {t('common.openInExplore', { ns: 'workflow' })} - - - - } - > - {t('common.accessAPIReference', { ns: 'workflow' })} - - - {appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && ( - - )} -
- ) - } - - )} + + setShowAppAccessControl(true)} + /> + { + setEmbeddingModalOpen(true) + handleTrigger() + }} + handleOpenInExplore={handleOpenInExplore} + handlePublish={handlePublish} + hasHumanInputNode={hasHumanInputNode} + hasTriggerNode={hasTriggerNode} + inputs={inputs} + missingStartNode={missingStartNode} + onRefreshData={onRefreshData} + outputs={outputs} + published={published} + publishedAt={publishedAt} + toolPublished={toolPublished} + workflowToolAvailable={workflowToolAvailable} + workflowToolMessage={workflowToolMessage} + />
& { + formatTimeFromNow: (value: number) => string + handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise + handleRestore: () => Promise + isChatApp: boolean + published: boolean + publishShortcut: string[] + upgradeHighlightStyle: CSSProperties + } + +type AccessSectionProps = { + enabled: boolean + isAppAccessSet: boolean + isLoading: boolean + accessMode?: keyof typeof ACCESS_MODE_MAP + onClick: () => void +} + +type ActionsSectionProps = Pick & { + appDetail: { + id?: string + icon?: string + icon_type?: string | null + icon_background?: string | null + description?: string + mode?: AppModeEnum + name?: string + } | null | undefined + appURL: string + disabledFunctionButton: boolean + disabledFunctionTooltip?: string + handleEmbed: () => void + handleOpenInExplore: () => void + handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise + published: boolean + workflowToolMessage?: string + } + +export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => { + const { t } = useTranslation() + + if (!mode || !ACCESS_MODE_MAP[mode]) + return null + + const { icon, label } = ACCESS_MODE_MAP[mode] + + return ( + <> + +
+ {t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })} +
+ + ) +} + +export const PublisherSummarySection = ({ + debugWithMultipleModel = false, + draftUpdatedAt, + formatTimeFromNow, + handlePublish, + handleRestore, + isChatApp, + multipleModelConfigs = [], + publishDisabled = false, + published, + publishedAt, + publishShortcut, + startNodeLimitExceeded = false, + upgradeHighlightStyle, +}: SummarySectionProps) => { + const { t } = useTranslation() + + return ( +
+
+ {publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })} +
+ {publishedAt + ? ( +
+
+ {t('common.publishedAt', { ns: 'workflow' })} + {' '} + {formatTimeFromNow(publishedAt)} +
+ {isChatApp && ( + + )} +
+ ) + : ( +
+ {t('common.autoSaved', { ns: 'workflow' })} + {' '} + · + {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} +
+ )} + {debugWithMultipleModel + ? ( + handlePublish(item)} + /> + ) + : ( + <> + + {startNodeLimitExceeded && ( +
+

+ {t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })} + {t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })} +

+

+ {t('publishLimit.startNodeDesc', { ns: 'workflow' })} +

+ +
+ )} + + )} +
+ ) +} + +export const PublisherAccessSection = ({ + enabled, + isAppAccessSet, + isLoading, + accessMode, + onClick, +}: AccessSectionProps) => { + const { t } = useTranslation() + + if (isLoading) + return
+ + return ( + <> + + {enabled && ( +
+
+

{t('publishApp.title', { ns: 'app' })}

+
+
+
+ +
+ {!isAppAccessSet &&

{t('publishApp.notSet', { ns: 'app' })}

} +
+ +
+
+ {!isAppAccessSet &&

{t('publishApp.notSetDesc', { ns: 'app' })}

} +
+ )} + + ) +} + +const ActionTooltip = ({ + disabled, + tooltip, + children, +}: { + disabled: boolean + tooltip?: ReactNode + children: ReactNode +}) => { + if (!disabled || !tooltip) + return <>{children} + + return ( + + {children}
} /> + + {tooltip} + + + ) +} + +export const PublisherActionsSection = ({ + appDetail, + appURL, + disabledFunctionButton, + disabledFunctionTooltip, + handleEmbed, + handleOpenInExplore, + handlePublish, + hasHumanInputNode = false, + hasTriggerNode = false, + inputs, + missingStartNode = false, + onRefreshData, + outputs, + published, + publishedAt, + toolPublished, + workflowToolAvailable = true, + workflowToolMessage, +}: ActionsSectionProps) => { + const { t } = useTranslation() + + if (hasTriggerNode) + return null + + const workflowToolDisabled = !publishedAt || !workflowToolAvailable + + return ( +
+ + } + > + {t('common.runApp', { ns: 'workflow' })} + + + {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION + ? ( + + } + > + {t('common.batchRunApp', { ns: 'workflow' })} + + + ) + : ( + } + > + {t('common.embedIntoSite', { ns: 'workflow' })} + + )} + + { + if (publishedAt) + handleOpenInExplore() + }} + disabled={disabledFunctionButton} + icon={} + > + {t('common.openInExplore', { ns: 'workflow' })} + + + + } + > + {t('common.accessAPIReference', { ns: 'workflow' })} + + + {appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && ( + + )} +
+ ) +} diff --git a/web/app/components/app/app-publisher/utils.ts b/web/app/components/app/app-publisher/utils.ts new file mode 100644 index 00000000000..5ce8a4425d1 --- /dev/null +++ b/web/app/components/app/app-publisher/utils.ts @@ -0,0 +1,84 @@ +import type { TFunction } from 'i18next' +import type { I18nKeysByPrefix } from '@/types/i18n' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { basePath } from '@/utils/var' + +type AccessSubjectsLike = { + groups?: unknown[] + members?: unknown[] +} | null | undefined + +type AppDetailLike = { + access_mode?: AccessMode + mode?: AppModeEnum +} + +type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'> + +export const ACCESS_MODE_MAP: Record = { + [AccessMode.ORGANIZATION]: { + label: 'organization', + icon: 'i-ri-building-line', + }, + [AccessMode.SPECIFIC_GROUPS_MEMBERS]: { + label: 'specific', + icon: 'i-ri-lock-line', + }, + [AccessMode.PUBLIC]: { + label: 'anyone', + icon: 'i-ri-global-line', + }, + [AccessMode.EXTERNAL_MEMBERS]: { + label: 'external', + icon: 'i-ri-verified-badge-line', + }, +} + +export const getPublisherAppMode = (mode?: AppModeEnum) => { + if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) + return AppModeEnum.CHAT + + return mode +} + +export const getPublisherAppUrl = ({ + appBaseUrl, + accessToken, + mode, +}: { + appBaseUrl: string + accessToken: string + mode?: AppModeEnum +}) => `${appBaseUrl}${basePath}/${getPublisherAppMode(mode)}/${accessToken}` + +export const isPublisherAccessConfigured = (appDetail: AppDetailLike | null | undefined, appAccessSubjects: AccessSubjectsLike) => { + if (!appDetail || !appAccessSubjects) + return true + + if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS) + return true + + return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length) +} + +export const getDisabledFunctionTooltip = ({ + t, + publishedAt, + missingStartNode, + noAccessPermission, +}: { + t: TFunction + publishedAt?: number + missingStartNode: boolean + noAccessPermission: boolean +}) => { + if (!publishedAt) + return t('notPublishedYet', { ns: 'app' }) + if (missingStartNode) + return t('noUserInputNode', { ns: 'app' }) + if (noAccessPermission) + return t('noAccessPermission', { ns: 'app' }) + + return undefined +} diff --git a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx new file mode 100644 index 00000000000..459df5d0638 --- /dev/null +++ b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx @@ -0,0 +1,283 @@ +import type { ComponentProps } from 'react' +import type { ConfigurationViewModel } from '../hooks/use-configuration' +import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper' +import type ConfigContext from '@/context/debug-configuration' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { AppModeEnum, ModelModeType } from '@/types/app' +import ConfigurationView from '../configuration-view' + +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/app/configuration/config/agent-setting-button', () => ({ + 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: () =>
, +})) + +vi.mock('@/app/components/workflow/plugin-dependency', () => ({ + default: () =>
, +})) + +const createContextValue = (): ComponentProps['value'] => ({ + appId: 'app-1', + isAPIKeySet: true, + isTrailFinished: false, + mode: AppModeEnum.CHAT, + modelModeType: ModelModeType.chat, + promptMode: 'simple' as never, + setPromptMode: vi.fn(), + isAdvancedMode: false, + isAgent: false, + isFunctionCall: false, + isOpenAI: false, + collectionList: [], + canReturnToSimpleMode: false, + setCanReturnToSimpleMode: vi.fn(), + chatPromptConfig: { prompt: [] } as never, + completionPromptConfig: { + prompt: { text: '' }, + conversation_histories_role: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + } as never, + currentAdvancedPrompt: [], + setCurrentAdvancedPrompt: vi.fn(), + showHistoryModal: vi.fn(), + conversationHistoriesRole: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + setConversationHistoriesRole: vi.fn(), + hasSetBlockStatus: { + context: false, + history: true, + query: true, + }, + conversationId: '', + setConversationId: vi.fn(), + introduction: '', + setIntroduction: vi.fn(), + suggestedQuestions: [], + setSuggestedQuestions: vi.fn(), + controlClearChatMessage: 0, + setControlClearChatMessage: vi.fn(), + prevPromptConfig: { + prompt_template: '', + prompt_variables: [], + }, + setPrevPromptConfig: vi.fn(), + moreLikeThisConfig: { enabled: false }, + setMoreLikeThisConfig: vi.fn(), + suggestedQuestionsAfterAnswerConfig: { enabled: false }, + setSuggestedQuestionsAfterAnswerConfig: vi.fn(), + speechToTextConfig: { enabled: false }, + setSpeechToTextConfig: vi.fn(), + textToSpeechConfig: { enabled: false, voice: '', language: '' }, + setTextToSpeechConfig: vi.fn(), + citationConfig: { enabled: false }, + setCitationConfig: vi.fn(), + annotationConfig: { + id: '', + enabled: false, + score_threshold: 0.5, + embedding_model: { + embedding_model_name: '', + embedding_provider_name: '', + }, + }, + setAnnotationConfig: vi.fn(), + moderationConfig: { enabled: false }, + setModerationConfig: vi.fn(), + externalDataToolsConfig: [], + setExternalDataToolsConfig: vi.fn(), + formattingChanged: false, + setFormattingChanged: vi.fn(), + inputs: {}, + setInputs: vi.fn(), + query: '', + setQuery: vi.fn(), + completionParams: {}, + setCompletionParams: vi.fn(), + modelConfig: { + provider: 'openai', + model_id: 'gpt-4o', + mode: ModelModeType.chat, + configs: { + prompt_template: '', + prompt_variables: [], + }, + chat_prompt_config: null, + completion_prompt_config: null, + opening_statement: '', + more_like_this: null, + suggested_questions: [], + suggested_questions_after_answer: null, + speech_to_text: null, + text_to_speech: null, + file_upload: null, + retriever_resource: null, + sensitive_word_avoidance: null, + annotation_reply: null, + external_data_tools: [], + system_parameters: { + audio_file_size_limit: 1, + file_size_limit: 1, + image_file_size_limit: 1, + video_file_size_limit: 1, + workflow_file_upload_limit: 1, + }, + dataSets: [], + agentConfig: { + enabled: false, + strategy: 'react', + max_iteration: 1, + tools: [], + }, + } as never, + setModelConfig: vi.fn(), + dataSets: [], + setDataSets: vi.fn(), + showSelectDataSet: vi.fn(), + datasetConfigs: { + retrieval_model: 'multiple', + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + datasets: { datasets: [] }, + } as never, + datasetConfigsRef: { current: {} as never }, + setDatasetConfigs: vi.fn(), + hasSetContextVar: false, + isShowVisionConfig: false, + visionConfig: { + enabled: false, + number_limits: 1, + detail: 'low', + transfer_methods: ['local_file'], + } as never, + setVisionConfig: vi.fn(), + isAllowVideoUpload: false, + isShowDocumentConfig: false, + isShowAudioConfig: false, + rerankSettingModalOpen: false, + setRerankSettingModalOpen: vi.fn(), +}) + +const createViewModel = (overrides: Partial = {}): ConfigurationViewModel => ({ + appPublisherProps: { + publishDisabled: false, + publishedAt: 0, + debugWithMultipleModel: false, + multipleModelConfigs: [], + onPublish: vi.fn(), + publishedConfig: { + modelConfig: createContextValue().modelConfig, + completionParams: {}, + }, + 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() + }) + + it('should render a loading state before configuration data is ready', () => { + render() + + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument() + }) + + it('should open the mobile debug panel from the header button', () => { + const onOpenDebugPanel = vi.fn() + render() + + fireEvent.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', () => { + const setShowUseGPT4Confirm = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /operation.cancel/i })) + + expect(setShowUseGPT4Confirm).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/app/configuration/__tests__/index.spec.tsx b/web/app/components/app/configuration/__tests__/index.spec.tsx new file mode 100644 index 00000000000..b855725b1d4 --- /dev/null +++ b/web/app/components/app/configuration/__tests__/index.spec.tsx @@ -0,0 +1,32 @@ +import { render } from '@testing-library/react' +import * as React from 'react' +import { useConfiguration } from '../hooks/use-configuration' +import Configuration from '../index' + +const mockView = vi.fn((_: unknown) =>
) + +vi.mock('../configuration-view', () => ({ + default: (props: unknown) => mockView(props), +})) + +vi.mock('../hooks/use-configuration', () => ({ + useConfiguration: vi.fn(), +})) + +describe('Configuration entry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should pass the hook view model into ConfigurationView', () => { + const viewModel = { + showLoading: true, + } + vi.mocked(useConfiguration).mockReturnValue(viewModel as never) + + render() + + expect(useConfiguration).toHaveBeenCalledTimes(1) + expect(mockView).toHaveBeenCalledWith(viewModel) + }) +}) diff --git a/web/app/components/app/configuration/__tests__/utils.spec.ts b/web/app/components/app/configuration/__tests__/utils.spec.ts new file mode 100644 index 00000000000..65a61921770 --- /dev/null +++ b/web/app/components/app/configuration/__tests__/utils.spec.ts @@ -0,0 +1,226 @@ +import type { ModelConfig } from '@/models/debug' +import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app' +import { buildConfigurationFeaturesData, getConfigurationPublishingState, withCollectionIconBasePath } from '../utils' + +const createModelConfig = (overrides: Partial = {}): ModelConfig => ({ + provider: 'openai', + model_id: 'gpt-4o', + mode: ModelModeType.chat, + configs: { + prompt_template: 'Hello', + prompt_variables: [], + }, + chat_prompt_config: { + prompt: [], + } as ModelConfig['chat_prompt_config'], + completion_prompt_config: { + prompt: { text: '' }, + conversation_histories_role: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + } as ModelConfig['completion_prompt_config'], + opening_statement: '', + more_like_this: { enabled: false }, + suggested_questions: [], + suggested_questions_after_answer: { enabled: false }, + speech_to_text: { enabled: false }, + text_to_speech: { enabled: false, voice: '', language: '' }, + file_upload: null, + retriever_resource: { enabled: false }, + sensitive_word_avoidance: { enabled: false }, + annotation_reply: null, + external_data_tools: [], + system_parameters: { + audio_file_size_limit: 1, + file_size_limit: 1, + image_file_size_limit: 1, + video_file_size_limit: 1, + workflow_file_upload_limit: 1, + }, + dataSets: [], + agentConfig: { + enabled: false, + strategy: 'react', + max_iteration: 1, + tools: [], + } as ModelConfig['agentConfig'], + ...overrides, +}) + +describe('configuration utils', () => { + describe('withCollectionIconBasePath', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should prefix relative collection icons with the base path', () => { + const result = withCollectionIconBasePath([ + { id: 'tool-1', icon: '/icons/tool.svg' }, + { id: 'tool-2', icon: '/console/icons/prefixed.svg' }, + ] as never, '/console') + + expect(result[0].icon).toBe('/console/icons/tool.svg') + expect(result[1].icon).toBe('/console/icons/prefixed.svg') + }) + }) + + describe('buildConfigurationFeaturesData', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should derive feature toggles and upload fallbacks from model config', () => { + const result = buildConfigurationFeaturesData(createModelConfig({ + opening_statement: 'Welcome', + suggested_questions: ['How are you?'], + file_upload: { + enabled: true, + image: { + enabled: true, + detail: Resolution.low, + number_limits: 2, + transfer_methods: [TransferMethod.local_file], + }, + allowed_file_types: ['image'], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: [TransferMethod.local_file], + number_limits: 2, + }, + }), undefined) + + expect(result.opening).toEqual({ + enabled: true, + opening_statement: 'Welcome', + suggested_questions: ['How are you?'], + }) + expect(result.file).toBeDefined() + expect(result.file!.enabled).toBe(true) + expect(result.file!.image!.detail).toBe(Resolution.low) + expect(result.file!.allowed_file_extensions).toEqual(['.png']) + }) + }) + + describe('getConfigurationPublishingState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should block publish when advanced completion mode is missing required blocks', () => { + const result = getConfigurationPublishingState({ + chatPromptConfig: { + prompt: [], + } as never, + completionPromptConfig: { + prompt: { text: 'Answer' }, + conversation_histories_role: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + } as never, + hasSetBlockStatus: { + context: false, + history: false, + query: false, + }, + hasSetContextVar: false, + hasSelectedDataSets: false, + isAdvancedMode: true, + mode: AppModeEnum.CHAT, + modelModeType: ModelModeType.completion, + promptTemplate: 'ignored', + }) + + expect(result.promptEmpty).toBe(false) + expect(result.cannotPublish).toBe(true) + }) + + it('should require a context variable only for completion apps with selected datasets', () => { + const result = getConfigurationPublishingState({ + chatPromptConfig: { + prompt: [], + } as never, + completionPromptConfig: { + prompt: { text: 'Completion prompt' }, + conversation_histories_role: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + } as never, + hasSetBlockStatus: { + context: false, + history: true, + query: true, + }, + hasSetContextVar: false, + hasSelectedDataSets: true, + isAdvancedMode: false, + mode: AppModeEnum.COMPLETION, + modelModeType: ModelModeType.completion, + promptTemplate: 'Prompt', + }) + + expect(result.promptEmpty).toBe(false) + expect(result.cannotPublish).toBe(false) + expect(result.contextVarEmpty).toBe(true) + }) + + it('should treat advanced completion chat prompts as empty when every segment is blank', () => { + const result = getConfigurationPublishingState({ + chatPromptConfig: { + prompt: [{ text: '' }, { text: '' }], + } as never, + completionPromptConfig: { + prompt: { text: 'ignored' }, + conversation_histories_role: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + } as never, + hasSetBlockStatus: { + context: true, + history: true, + query: true, + }, + hasSetContextVar: true, + hasSelectedDataSets: false, + isAdvancedMode: true, + mode: AppModeEnum.COMPLETION, + modelModeType: ModelModeType.chat, + promptTemplate: 'ignored', + }) + + expect(result.promptEmpty).toBe(true) + expect(result.cannotPublish).toBe(true) + }) + + it('should treat advanced completion text prompts as empty when the completion prompt is missing', () => { + const result = getConfigurationPublishingState({ + chatPromptConfig: { + prompt: [{ text: 'ignored' }], + } as never, + completionPromptConfig: { + prompt: { text: '' }, + conversation_histories_role: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + } as never, + hasSetBlockStatus: { + context: true, + history: true, + query: true, + }, + hasSetContextVar: true, + hasSelectedDataSets: false, + isAdvancedMode: true, + mode: AppModeEnum.COMPLETION, + modelModeType: ModelModeType.completion, + promptTemplate: 'ignored', + }) + + expect(result.promptEmpty).toBe(true) + expect(result.cannotPublish).toBe(true) + }) + }) +}) diff --git a/web/app/components/app/configuration/base/feature-panel/index.spec.tsx b/web/app/components/app/configuration/base/feature-panel/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/app/configuration/base/feature-panel/index.spec.tsx rename to web/app/components/app/configuration/base/feature-panel/__tests__/index.spec.tsx index 7e1b6613992..0daf638b490 100644 --- a/web/app/components/app/configuration/base/feature-panel/index.spec.tsx +++ b/web/app/components/app/configuration/base/feature-panel/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import FeaturePanel from './index' +import FeaturePanel from '../index' describe('FeaturePanel', () => { // Rendering behavior for standard layout. diff --git a/web/app/components/app/configuration/base/group-name/index.spec.tsx b/web/app/components/app/configuration/base/group-name/__tests__/index.spec.tsx similarity index 92% rename from web/app/components/app/configuration/base/group-name/index.spec.tsx rename to web/app/components/app/configuration/base/group-name/__tests__/index.spec.tsx index be698c3233f..ce1ee7a18ad 100644 --- a/web/app/components/app/configuration/base/group-name/index.spec.tsx +++ b/web/app/components/app/configuration/base/group-name/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import GroupName from './index' +import GroupName from '../index' describe('GroupName', () => { beforeEach(() => { diff --git a/web/app/components/app/configuration/base/operation-btn/index.spec.tsx b/web/app/components/app/configuration/base/operation-btn/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/app/configuration/base/operation-btn/index.spec.tsx rename to web/app/components/app/configuration/base/operation-btn/__tests__/index.spec.tsx index 8e254d261b5..325aebe6c0d 100644 --- a/web/app/components/app/configuration/base/operation-btn/index.spec.tsx +++ b/web/app/components/app/configuration/base/operation-btn/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import OperationBtn from './index' +import OperationBtn from '../index' vi.mock('@remixicon/react', () => ({ RiAddLine: (props: { className?: string }) => ( diff --git a/web/app/components/app/configuration/base/var-highlight/index.spec.tsx b/web/app/components/app/configuration/base/var-highlight/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/app/configuration/base/var-highlight/index.spec.tsx rename to web/app/components/app/configuration/base/var-highlight/__tests__/index.spec.tsx index 77fe1f2b28f..1add8601c40 100644 --- a/web/app/components/app/configuration/base/var-highlight/index.spec.tsx +++ b/web/app/components/app/configuration/base/var-highlight/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import VarHighlight, { varHighlightHTML } from './index' +import VarHighlight, { varHighlightHTML } from '../index' describe('VarHighlight', () => { beforeEach(() => { diff --git a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx b/web/app/components/app/configuration/base/warning-mask/__tests__/cannot-query-dataset.spec.tsx similarity index 94% rename from web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx rename to web/app/components/app/configuration/base/warning-mask/__tests__/cannot-query-dataset.spec.tsx index 730b251e67a..161bd5073d0 100644 --- a/web/app/components/app/configuration/base/warning-mask/cannot-query-dataset.spec.tsx +++ b/web/app/components/app/configuration/base/warning-mask/__tests__/cannot-query-dataset.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import CannotQueryDataset from './cannot-query-dataset' +import CannotQueryDataset from '../cannot-query-dataset' describe('CannotQueryDataset WarningMask', () => { it('should render dataset warning copy and action button', () => { diff --git a/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx b/web/app/components/app/configuration/base/warning-mask/__tests__/formatting-changed.spec.tsx similarity index 95% rename from web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx rename to web/app/components/app/configuration/base/warning-mask/__tests__/formatting-changed.spec.tsx index 9b5a5d93e1a..81655f2d993 100644 --- a/web/app/components/app/configuration/base/warning-mask/formatting-changed.spec.tsx +++ b/web/app/components/app/configuration/base/warning-mask/__tests__/formatting-changed.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import FormattingChanged from './formatting-changed' +import FormattingChanged from '../formatting-changed' describe('FormattingChanged WarningMask', () => { it('should display translation text and both actions', () => { diff --git a/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx b/web/app/components/app/configuration/base/warning-mask/__tests__/has-not-set-api.spec.tsx similarity index 93% rename from web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx rename to web/app/components/app/configuration/base/warning-mask/__tests__/has-not-set-api.spec.tsx index abcf5795d0b..5995f3472fe 100644 --- a/web/app/components/app/configuration/base/warning-mask/has-not-set-api.spec.tsx +++ b/web/app/components/app/configuration/base/warning-mask/__tests__/has-not-set-api.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import HasNotSetAPI from './has-not-set-api' +import HasNotSetAPI from '../has-not-set-api' describe('HasNotSetAPI', () => { it('should render the empty state copy', () => { diff --git a/web/app/components/app/configuration/base/warning-mask/index.spec.tsx b/web/app/components/app/configuration/base/warning-mask/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/app/configuration/base/warning-mask/index.spec.tsx rename to web/app/components/app/configuration/base/warning-mask/__tests__/index.spec.tsx index cb8ef0b6782..d287a790e6c 100644 --- a/web/app/components/app/configuration/base/warning-mask/index.spec.tsx +++ b/web/app/components/app/configuration/base/warning-mask/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import * as React from 'react' -import WarningMask from './index' +import WarningMask from '../index' describe('WarningMask', () => { // Rendering of title, description, and footer content diff --git a/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx new file mode 100644 index 00000000000..413721ee2e9 --- /dev/null +++ b/web/app/components/app/configuration/config-prompt/__tests__/advanced-prompt-input.spec.tsx @@ -0,0 +1,228 @@ +/* eslint-disable ts/no-explicit-any */ +import type { ReactNode } from 'react' +import type { PromptRole } from '@/models/debug' +import { fireEvent, render, screen } from '@testing-library/react' +import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' +import ConfigContext from '@/context/debug-configuration' +import { AppModeEnum } from '@/types/app' +import AdvancedPromptInput from '../advanced-prompt-input' + +const mockEmit = vi.fn() +const mockSetShowExternalDataToolModal = vi.fn() +const mockSetModelConfig = vi.fn() +const mockOnTypeChange = vi.fn() +const mockOnChange = vi.fn() +const mockOnDelete = vi.fn() +const mockOnHideContextMissingTip = vi.fn() +const mockCopy = vi.fn() +const mockToastError = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('copy-to-clipboard', () => ({ + default: (...args: unknown[]) => mockCopy(...args), +})) + +vi.mock('@remixicon/react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + RiDeleteBinLine: ({ onClick }: { onClick: () => void }) => ( + + ), + RiErrorWarningFill: () => warning-icon, + } +}) + +vi.mock('@/app/components/base/icons/src/vender/line/files', () => ({ + Copy: ({ onClick }: { onClick: () => void }) => ( + + ), + CopyCheck: () => copy-checked, +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + emit: (...args: unknown[]) => mockEmit(...args), + }, + }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalDataToolModal: mockSetShowExternalDataToolModal, + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('../message-type-selector', () => ({ + default: ({ onChange, value }: { onChange: (value: PromptRole) => void, value: PromptRole }) => ( + + ), +})) + +vi.mock('@/app/components/base/prompt-editor', () => ({ + default: (props: { + onBlur: () => void + onChange: (value: string) => void + externalToolBlock: { onAddExternalTool: () => void } + }) => ( +
+ + + +
+ ), +})) + +vi.mock('../prompt-editor-height-resize-wrap', () => ({ + default: ({ children, footer }: { children: ReactNode, footer: ReactNode }) => ( +
+ {children} + {footer} +
+ ), +})) + +const createContextValue = () => ({ + mode: AppModeEnum.CHAT, + hasSetBlockStatus: { + context: false, + history: false, + query: false, + }, + modelConfig: { + configs: { + prompt_variables: [ + { key: 'existing_var', name: 'Existing', type: 'string', required: true }, + ], + }, + }, + setModelConfig: mockSetModelConfig, + conversationHistoriesRole: { + user_prefix: 'user', + assistant_prefix: 'assistant', + }, + showHistoryModal: vi.fn(), + dataSets: [], + showSelectDataSet: vi.fn(), + externalDataToolsConfig: [], +}) as any + +describe('AdvancedPromptInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should delegate prompt text and role changes to the parent callbacks', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('change-advanced')) + fireEvent.click(screen.getByText('selector:user')) + fireEvent.click(screen.getByText('copy-prompt')) + fireEvent.click(screen.getByText('delete-prompt')) + + expect(mockOnChange).toHaveBeenCalledWith('Updated {{new_var}}') + expect(mockOnTypeChange).toHaveBeenCalledWith('assistant') + expect(mockCopy).toHaveBeenCalledWith('Hello') + expect(mockOnDelete).toHaveBeenCalled() + }) + + it('should add newly discovered variables after blur confirmation', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('blur-advanced')) + fireEvent.click(screen.getByText('operation.add')) + + expect(mockSetModelConfig).toHaveBeenCalledWith(expect.objectContaining({ + configs: expect.objectContaining({ + prompt_variables: expect.arrayContaining([ + expect.objectContaining({ + key: 'new_var', + name: 'new_var', + }), + ]), + }), + })) + }) + + it('should open the external data tool modal and validate duplicates', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('open-advanced-tool-modal')) + + const modalConfig = mockSetShowExternalDataToolModal.mock.calls[0][0] + expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'existing_var' })).toBe(false) + expect(mockToastError).toHaveBeenCalledWith('varKeyError.keyAlreadyExists') + + modalConfig.onSaveCallback({ + label: 'Search', + variable: 'search_api', + }) + + expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'ADD_EXTERNAL_DATA_TOOL', + })) + expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ + payload: 'search_api', + type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND, + })) + }) +}) diff --git a/web/app/components/app/configuration/config-prompt/index.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/app/configuration/config-prompt/index.spec.tsx rename to web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx index c784a09ab6d..d42eedf16b3 100644 --- a/web/app/components/app/configuration/config-prompt/index.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ -import type { IPromptProps } from './index' +/* eslint-disable ts/no-explicit-any */ +import type { IPromptProps } from '../index' import type { PromptItem, PromptVariable } from '@/models/debug' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' @@ -6,7 +7,7 @@ import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config' import ConfigContext from '@/context/debug-configuration' import { PromptRole } from '@/models/debug' import { AppModeEnum, ModelModeType } from '@/types/app' -import Prompt from './index' +import Prompt from '../index' type DebugConfiguration = { isAdvancedMode: boolean @@ -30,7 +31,7 @@ const defaultPromptVariables: PromptVariable[] = [ let mockSimplePromptInputProps: IPromptProps | null = null -vi.mock('./simple-prompt-input', () => ({ +vi.mock('../simple-prompt-input', () => ({ default: (props: IPromptProps) => { mockSimplePromptInputProps = props return ( @@ -65,7 +66,7 @@ type AdvancedMessageInputProps = { noResize?: boolean } -vi.mock('./advanced-prompt-input', () => ({ +vi.mock('../advanced-prompt-input', () => ({ default: (props: AdvancedMessageInputProps) => { return (
{ beforeEach(() => { diff --git a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/prompt-editor-height-resize-wrap.spec.tsx similarity index 95% rename from web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.spec.tsx rename to web/app/components/app/configuration/config-prompt/__tests__/prompt-editor-height-resize-wrap.spec.tsx index abd95e76604..7e168cc7f79 100644 --- a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/__tests__/prompt-editor-height-resize-wrap.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap' +import PromptEditorHeightResizeWrap from '../prompt-editor-height-resize-wrap' describe('PromptEditorHeightResizeWrap', () => { beforeEach(() => { diff --git a/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx b/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx new file mode 100644 index 00000000000..a0bc0727603 --- /dev/null +++ b/web/app/components/app/configuration/config-prompt/__tests__/simple-prompt-input.spec.tsx @@ -0,0 +1,320 @@ +/* eslint-disable ts/no-explicit-any */ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' +import ConfigContext from '@/context/debug-configuration' +import { AppModeEnum } from '@/types/app' +import Prompt from '../simple-prompt-input' + +const mockEmit = vi.fn() +const mockSetFeatures = vi.fn() +const mockSetShowExternalDataToolModal = vi.fn() +const mockSetModelConfig = vi.fn() +const mockSetPrevPromptConfig = vi.fn() +const mockSetIntroduction = vi.fn() +const mockOnChange = vi.fn() +const mockToastError = vi.fn() + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + __esModule: true, + default: () => 'desktop', + MediaType: { + mobile: 'mobile', + }, +})) + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeaturesStore: () => ({ + getState: () => ({ + features: { + opening: { + enabled: false, + opening_statement: '', + }, + }, + setFeatures: mockSetFeatures, + }), + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + emit: (...args: unknown[]) => mockEmit(...args), + }, + }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalDataToolModal: mockSetShowExternalDataToolModal, + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@/app/components/app/configuration/config/automatic/automatic-btn', () => ({ + default: ({ onClick }: { onClick: () => void }) => , +})) + +vi.mock('@/app/components/app/configuration/config/automatic/get-automatic-res', () => ({ + default: ({ onFinished }: { onFinished: (value: Record) => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/prompt-editor', () => ({ + default: (props: { + onBlur: () => void + onChange: (value: string) => void + contextBlock: { datasets: Array<{ id: string, name: string, type: string }> } + variableBlock: { variables: Array<{ name: string, value: string }> } + queryBlock: { selectable: boolean } + externalToolBlock: { + onAddExternalTool: () => void + externalTools: Array<{ name: string, variableName: string }> + } + }) => ( +
+
{`datasets:${props.contextBlock.datasets.map(item => item.name).join(',')}`}
+
{`variables:${props.variableBlock.variables.map(item => item.value).join(',')}`}
+
{`external-tools:${props.externalToolBlock.externalTools.map(item => item.variableName).join(',')}`}
+
{`query-selectable:${String(props.queryBlock.selectable)}`}
+ + + +
+ ), +})) + +vi.mock('../prompt-editor-height-resize-wrap', () => ({ + default: ({ children, footer }: { children: ReactNode, footer: ReactNode }) => ( +
+ {children} + {footer} +
+ ), +})) + +const createContextValue = (overrides: Record = {}) => ({ + appId: 'app-1', + modelConfig: { + configs: { + prompt_template: 'Hello {{new_var}}', + prompt_variables: [ + { key: 'existing_var', name: 'Existing', type: 'string', required: true }, + ], + }, + }, + dataSets: [], + setModelConfig: mockSetModelConfig, + setPrevPromptConfig: mockSetPrevPromptConfig, + setIntroduction: mockSetIntroduction, + hasSetBlockStatus: { + context: false, + history: false, + query: false, + }, + showSelectDataSet: vi.fn(), + externalDataToolsConfig: [], + ...overrides, +}) as any + +describe('SimplePromptInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should prompt to add new variables discovered from the prompt template', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('blur-prompt')) + + expect(screen.getByText('autoAddVar')).toBeInTheDocument() + + fireEvent.click(screen.getByText('operation.add')) + + expect(mockOnChange).toHaveBeenCalledWith('Hello {{new_var}}', [ + expect.objectContaining({ + key: 'new_var', + name: 'new_var', + }), + ]) + }) + + it('should open the external data tool modal and emit insert events after save', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('open-tool-modal')) + + expect(mockSetShowExternalDataToolModal).toHaveBeenCalledTimes(1) + const modalConfig = mockSetShowExternalDataToolModal.mock.calls[0][0] + + expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'existing_var' })).toBe(false) + expect(mockToastError).toHaveBeenCalledWith('varKeyError.keyAlreadyExists') + expect(modalConfig.onValidateBeforeSaveCallback({ variable: 'fresh_var' })).toBe(true) + + modalConfig.onSaveCallback(undefined) + expect(mockEmit).not.toHaveBeenCalled() + + modalConfig.onSaveCallback({ + label: 'Search', + variable: 'search_api', + }) + + expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ + type: 'ADD_EXTERNAL_DATA_TOOL', + })) + expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ + payload: 'search_api', + type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND, + })) + }) + + it('should apply automatic generation results to prompt and opening statement', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('automatic-btn')) + fireEvent.click(screen.getByText('finish-automatic')) + + expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ + payload: 'auto prompt', + type: 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER', + })) + expect(mockSetModelConfig).toHaveBeenCalledWith(expect.objectContaining({ + configs: expect.objectContaining({ + prompt_template: 'auto prompt', + prompt_variables: [ + expect.objectContaining({ key: 'city', name: 'city' }), + ], + }), + })) + expect(mockSetPrevPromptConfig).toHaveBeenCalled() + expect(mockSetIntroduction).toHaveBeenCalledWith('hello there') + expect(mockSetFeatures).toHaveBeenCalled() + }) + + it('should expose dataset and external tool metadata to the editor', () => { + render( + + + , + ) + + expect(screen.getByText('datasets:Knowledge Base')).toBeInTheDocument() + expect(screen.getByText('variables:existing_var')).toBeInTheDocument() + expect(screen.getByText('external-tools:search_api')).toBeInTheDocument() + expect(screen.getByText('query-selectable:false')).toBeInTheDocument() + }) + + it('should skip external tool variables and incomplete prompt variables when deciding whether to auto add', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('change-prompt')) + expect(mockOnChange).toHaveBeenCalledWith('Hello {{new_var}}', []) + + fireEvent.click(screen.getByText('blur-prompt')) + expect(mockOnChange).toHaveBeenLastCalledWith('Hello {{search_api}} {{existing_var}}', []) + }) + + it('should keep invalid prompt variables in the confirmation flow', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('blur-prompt')) + expect(screen.getByText('autoAddVar')).toBeInTheDocument() + + fireEvent.click(screen.getByText('operation.cancel')) + expect(mockOnChange).toHaveBeenCalledWith('Hello {{existing_var}}', []) + }) +}) diff --git a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.spec.tsx b/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx similarity index 93% rename from web/app/components/app/configuration/config-prompt/confirm-add-var/index.spec.tsx rename to web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx index c5a1500c59f..1d6aa915527 100644 --- a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/confirm-add-var/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import ConfirmAddVar from './index' +import ConfirmAddVar from '../index' -vi.mock('../../base/var-highlight', () => ({ +vi.mock('../../../base/var-highlight', () => ({ default: ({ name }: { name: string }) => {name}, })) diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.spec.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx similarity index 97% rename from web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.spec.tsx rename to web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx index 2f417fdded6..236f9403c92 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/edit-modal.spec.tsx @@ -1,7 +1,7 @@ import type { ConversationHistoriesRole } from '@/models/debug' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import EditModal from './edit-modal' +import EditModal from '../edit-modal' vi.mock('@/app/components/base/modal', () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.spec.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/history-panel.spec.tsx similarity index 95% rename from web/app/components/app/configuration/config-prompt/conversation-history/history-panel.spec.tsx rename to web/app/components/app/configuration/config-prompt/conversation-history/__tests__/history-panel.spec.tsx index 827986f5215..9a34dc2da10 100644 --- a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.spec.tsx +++ b/web/app/components/app/configuration/config-prompt/conversation-history/__tests__/history-panel.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import HistoryPanel from './history-panel' +import HistoryPanel from '../history-panel' vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({ default: ({ onClick }: { onClick: () => void }) => ( diff --git a/web/app/components/app/configuration/config-var/index.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx similarity index 82% rename from web/app/components/app/configuration/config-var/index.spec.tsx rename to web/app/components/app/configuration/config-var/__tests__/index.spec.tsx index a48d3233f5b..fb190d844a7 100644 --- a/web/app/components/app/configuration/config-var/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { IConfigVarProps } from './index' +import type { IConfigVarProps } from '../index' import type { ExternalDataTool } from '@/models/common' import type { PromptVariable } from '@/models/debug' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' @@ -9,7 +9,7 @@ import { toast } from '@/app/components/base/ui/toast' import DebugConfigurationContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' -import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index' +import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from '../index' const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error') @@ -393,5 +393,94 @@ describe('ConfigVar', () => { }), ]) }) + + it('should update an api variable with the modal save callback', () => { + const onPromptVariablesChange = vi.fn() + const apiVar = createPromptVariable({ + key: 'api_var', + name: 'API Var', + type: 'api', + }) + + renderConfigVar({ + promptVariables: [apiVar], + onPromptVariablesChange, + }) + + const item = screen.getByTitle('api_var · API Var') + const itemContainer = item.closest('div.group') + expect(itemContainer).not.toBeNull() + + const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') + fireEvent.click(actionButtons[0]) + + const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0] + + act(() => { + modalState.onSaveCallback?.({ + variable: 'next_api_var', + label: 'Next API Var', + enabled: true, + type: 'api', + config: { endpoint: '/search' }, + icon: 'tool-icon', + icon_background: '#fff', + }) + }) + + expect(onPromptVariablesChange).toHaveBeenCalledWith([ + expect.objectContaining({ + key: 'next_api_var', + name: 'Next API Var', + type: 'api', + icon: 'tool-icon', + }), + ]) + }) + + it('should ignore empty external tool saves and reject duplicate variable names during validation', () => { + const onPromptVariablesChange = vi.fn() + const firstVar = createPromptVariable({ + key: 'api_var', + name: 'API Var', + type: 'api', + }) + const secondVar = createPromptVariable({ + key: 'existing_var', + name: 'Existing Var', + type: 'string', + }) + + renderConfigVar({ + promptVariables: [firstVar, secondVar], + onPromptVariablesChange, + }) + + const item = screen.getByTitle('api_var · API Var') + const itemContainer = item.closest('div.group') + expect(itemContainer).not.toBeNull() + + const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') + fireEvent.click(actionButtons[0]) + + const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0] + + act(() => { + modalState.onSaveCallback?.(undefined) + }) + + expect(onPromptVariablesChange).not.toHaveBeenCalled() + + const isValid = modalState.onValidateBeforeSaveCallback?.({ + variable: 'existing_var', + label: 'Duplicated', + enabled: true, + type: 'api', + config: {}, + }) + + expect(isValid).toBe(false) + expect(toastErrorSpy).toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx new file mode 100644 index 00000000000..0740f0cde37 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -0,0 +1,207 @@ +/* eslint-disable ts/no-explicit-any */ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import ConfigModalFormFields from '../form-fields' + +vi.mock('@/app/components/base/file-uploader', () => ({ + FileUploaderInAttachmentWrapper: ({ onChange }: { onChange: (files: Array>) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({ + default: ({ onChange, isMultiple }: { onChange: (payload: Record) => void, isMultiple: boolean }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ onCheck, checked }: { onCheck: () => void, checked: boolean }) => ( + + ), +})) + +vi.mock('@/app/components/base/select', () => ({ + default: ({ onSelect }: { onSelect: (item: { value: string }) => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/ui/select', () => ({ + Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => ( +
+ + {children} +
+ ), + SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + SelectValue: () => select-value, + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +vi.mock('../field', () => ({ + default: ({ children, title }: { children: ReactNode, title: string }) => ( +
+ {title} + {children} +
+ ), +})) + +vi.mock('../type-select', () => ({ + default: ({ onSelect }: { onSelect: (item: { value: InputVarType }) => void }) => ( + + ), +})) + +vi.mock('../../config-select', () => ({ + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( + + ), +})) + +vi.mock('../../config-string', () => ({ + default: ({ onChange }: { onChange: (value: number) => void }) => ( + + ), +})) + +const t = (key: string) => key + +const createPayloadChangeHandler = () => vi.fn<(value: unknown) => void>() + +const createBaseProps = () => { + const payloadChangeHandlers: Record> = { + default: createPayloadChangeHandler(), + hide: createPayloadChangeHandler(), + label: createPayloadChangeHandler(), + max_length: createPayloadChangeHandler(), + options: createPayloadChangeHandler(), + required: createPayloadChangeHandler(), + } + + return { + checkboxDefaultSelectValue: 'false', + isStringInput: false, + jsonSchemaStr: '', + maxLength: 32, + modelId: 'gpt-4o', + onFilePayloadChange: vi.fn(), + onJSONSchemaChange: vi.fn(), + onPayloadChange: (key: string) => { + if (!payloadChangeHandlers[key]) + payloadChangeHandlers[key] = createPayloadChangeHandler() + return payloadChangeHandlers[key] + }, + onTypeChange: vi.fn(), + onVarKeyBlur: vi.fn(), + onVarNameChange: vi.fn(), + options: undefined as string[] | undefined, + selectOptions: [], + tempPayload: { + type: InputVarType.textInput, + label: 'Question', + variable: 'question', + required: false, + hide: false, + } as any, + t, + payloadChangeHandlers, + } +} + +describe('ConfigModalFormFields', () => { + it('should update paragraph, number, checkbox, and select defaults', () => { + const paragraphProps = createBaseProps() + paragraphProps.tempPayload = { ...paragraphProps.tempPayload, type: InputVarType.paragraph, default: 'hello' } + render() + fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated paragraph' } }) + expect(paragraphProps.payloadChangeHandlers.default).toHaveBeenCalledWith('updated paragraph') + + const numberProps = createBaseProps() + numberProps.tempPayload = { ...numberProps.tempPayload, type: InputVarType.number, default: '1' } + render() + fireEvent.change(screen.getByDisplayValue('1'), { target: { value: '2' } }) + expect(numberProps.payloadChangeHandlers.default).toHaveBeenCalledWith('2') + + const checkboxProps = createBaseProps() + checkboxProps.tempPayload = { ...checkboxProps.tempPayload, type: InputVarType.checkbox, default: false } + checkboxProps.checkboxDefaultSelectValue = 'true' + render() + fireEvent.click(screen.getByText('ui-select:true')) + expect(checkboxProps.payloadChangeHandlers.default).toHaveBeenCalledWith(false) + + const selectProps = createBaseProps() + selectProps.tempPayload = { ...selectProps.tempPayload, type: InputVarType.select, default: 'alpha' } + selectProps.options = ['alpha', 'beta'] + render() + fireEvent.click(screen.getByText('config-select')) + fireEvent.click(screen.getByText('ui-select:alpha')) + expect(selectProps.payloadChangeHandlers.options).toHaveBeenCalledWith(['alpha', 'beta']) + expect(selectProps.payloadChangeHandlers.default).toHaveBeenCalledWith('beta') + }) + + it('should wire file, json schema, and visibility controls', () => { + const singleFileProps = createBaseProps() + singleFileProps.tempPayload = { + ...singleFileProps.tempPayload, + type: InputVarType.singleFile, + allowed_file_types: ['document'], + allowed_file_extensions: [], + allowed_file_upload_methods: ['remote_url'], + } + render() + fireEvent.click(screen.getByText('single-file-setting')) + fireEvent.click(screen.getByText('upload-file')) + fireEvent.click(screen.getAllByText('unchecked')[0]) + fireEvent.click(screen.getAllByText('unchecked')[1]) + + expect(singleFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 1 }) + expect(singleFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith(expect.objectContaining({ + fileId: 'file-1', + })) + expect(singleFileProps.payloadChangeHandlers.required).toHaveBeenCalledWith(true) + expect(singleFileProps.payloadChangeHandlers.hide).toHaveBeenCalledWith(true) + + const multiFileProps = createBaseProps() + multiFileProps.tempPayload = { + ...multiFileProps.tempPayload, + type: InputVarType.multiFiles, + allowed_file_types: ['document'], + allowed_file_extensions: [], + allowed_file_upload_methods: ['remote_url'], + } + render() + fireEvent.click(screen.getByText('multi-file-setting')) + fireEvent.click(screen.getAllByText('upload-file')[1]) + expect(multiFileProps.onFilePayloadChange).toHaveBeenCalledWith({ number_limits: 3 }) + expect(multiFileProps.payloadChangeHandlers.default).toHaveBeenCalledWith([ + expect.objectContaining({ fileId: 'file-1' }), + expect.objectContaining({ fileId: 'file-2' }), + ]) + + const jsonProps = createBaseProps() + jsonProps.tempPayload = { ...jsonProps.tempPayload, type: InputVarType.jsonObject } + render() + fireEvent.click(screen.getByText('json-editor')) + expect(jsonProps.onJSONSchemaChange).toHaveBeenCalledWith('{\n "type": "object"\n}') + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx new file mode 100644 index 00000000000..4888d284d24 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -0,0 +1,150 @@ +/* eslint-disable ts/no-explicit-any */ +import type { InputVar } from '@/app/components/workflow/types' +import type { App, AppSSO } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import * as React from 'react' +import { useStore } from '@/app/components/app/store' +import { toast } from '@/app/components/base/ui/toast' +import { InputVarType } from '@/app/components/workflow/types' +import DebugConfigurationContext from '@/context/debug-configuration' +import { AppModeEnum } from '@/types/app' +import ConfigModal from '../index' + +const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error') +let latestFormProps: Record | null = null + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock('../form-fields', () => ({ + default: (props: Record) => { + latestFormProps = props + return ( +
+
{String(props.tempPayload.type)}
+
{String(props.tempPayload.label ?? '')}
+
{String(props.tempPayload.json_schema ?? '')}
+
{String(props.tempPayload.default ?? '')}
+ + + + + + + + +
+ ) + }, +})) + +const createPayload = (overrides: Partial = {}): InputVar => ({ + type: InputVarType.textInput, + label: '', + variable: 'question', + required: false, + hide: false, + options: [], + default: 'hello', + max_length: 32, + ...overrides, +}) + +const renderConfigModal = (payload: InputVar = createPayload()) => render( + + + , +) + +describe('ConfigModal logic', () => { + beforeEach(() => { + vi.clearAllMocks() + latestFormProps = null + useStore.setState({ + appDetail: { + mode: AppModeEnum.CHAT, + } as App & Partial, + }) + }) + + it('should surface validation errors from invalid variable name callbacks', async () => { + renderConfigModal() + + fireEvent.click(screen.getByTestId('invalid-key-blur')) + fireEvent.click(screen.getByTestId('invalid-name-change')) + + await waitFor(() => { + expect(toastErrorSpy).toHaveBeenCalledTimes(2) + }) + }) + + it('should keep the existing label when blur runs on a payload that already has one', async () => { + renderConfigModal(createPayload({ label: 'Existing label' })) + + fireEvent.click(screen.getByTestId('valid-key-blur')) + + await waitFor(() => { + expect(screen.getByTestId('payload-label')).toHaveTextContent('Existing label') + }) + }) + + it('should derive payload fields from mocked form-field callbacks', async () => { + renderConfigModal() + + fireEvent.click(screen.getByTestId('valid-key-blur')) + await waitFor(() => { + expect(screen.getByTestId('payload-label')).toHaveTextContent('auto_label') + }) + + fireEvent.click(screen.getByTestId('valid-json-change')) + await waitFor(() => { + expect(screen.getByTestId('payload-schema')).toHaveTextContent(/"foo": "bar"/) + }) + + fireEvent.click(screen.getByTestId('invalid-json-change')) + expect(screen.getByTestId('payload-schema')).toHaveTextContent(/"foo": "bar"/) + + fireEvent.click(screen.getByTestId('empty-json-change')) + await waitFor(() => { + expect(screen.getByTestId('payload-schema')).toHaveTextContent('') + }) + + fireEvent.click(screen.getByTestId('type-change')) + await waitFor(() => { + expect(screen.getByTestId('payload-type')).toHaveTextContent(InputVarType.singleFile) + }) + + fireEvent.click(screen.getByTestId('file-payload-change')) + await waitFor(() => { + expect(screen.getByTestId('payload-default')).toHaveTextContent('file-default') + }) + + expect(latestFormProps?.modelId).toBe('model-1') + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx new file mode 100644 index 00000000000..31256f0c08f --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -0,0 +1,89 @@ +import type { InputVar } from '@/app/components/workflow/types' +import type { App, AppSSO } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { useStore } from '@/app/components/app/store' +import { toast } from '@/app/components/base/ui/toast' +import { InputVarType } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' +import ConfigModal from '../index' + +const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error') + +const createPayload = (overrides: Partial = {}): InputVar => ({ + type: InputVarType.textInput, + label: '', + variable: 'question', + required: false, + hide: false, + options: [], + default: 'hello', + max_length: 32, + ...overrides, +}) + +describe('ConfigModal', () => { + beforeEach(() => { + vi.clearAllMocks() + useStore.setState({ + appDetail: { + mode: AppModeEnum.CHAT, + } as App & Partial, + }) + }) + + it('should copy the variable name into the label when the label is empty', () => { + render( + , + ) + + const textboxes = screen.getAllByRole('textbox') + fireEvent.blur(textboxes[0], { target: { value: 'question' } }) + + expect(textboxes[1]).toHaveValue('question') + }) + + it('should submit the edited payload when the form is valid', () => { + const onConfirm = vi.fn() + render( + , + ) + + fireEvent.change(screen.getByDisplayValue('hello'), { target: { value: 'updated default' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ + default: 'updated default', + label: 'Question', + variable: 'question', + }), undefined) + }) + + it('should block save when the label is missing', () => { + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(toastErrorSpy).toHaveBeenCalledWith('appDebug.variableConfig.errorMsg.labelNameRequired') + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx new file mode 100644 index 00000000000..2512aa93e8d --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx @@ -0,0 +1,37 @@ +/* eslint-disable ts/no-explicit-any */ +import { fireEvent, render, screen } from '@testing-library/react' +import TypeSelector from '../type-select' + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ children }: { children: React.ReactNode }) =>
{children}
, + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( + + ), + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({ + default: ({ type }: { type: string }) => {type}, +})) + +describe('TypeSelector', () => { + it('should toggle open state and select a new variable type', () => { + const onSelect = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getByText('Number')) + + expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts new file mode 100644 index 00000000000..1c00e1c5b28 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/utils.spec.ts @@ -0,0 +1,267 @@ +import type { InputVar } from '@/app/components/workflow/types' +import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants' +import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { + buildSelectOptions, + createPayloadForType, + getCheckboxDefaultSelectValue, + getJsonSchemaEditorValue, + isJsonSchemaEmpty, + isStringInputType, + normalizeSelectDefaultValue, + parseCheckboxSelectValue, + updatePayloadField, + validateConfigModalPayload, +} from '../utils' + +const t = (key: string) => key + +const createInputVar = (overrides: Partial = {}): InputVar => ({ + type: InputVarType.textInput, + label: 'Question', + variable: 'question', + required: false, + options: [], + hide: false, + ...overrides, +}) + +describe('config-modal utils', () => { + describe('payload helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should clear the default value when options no longer include it', () => { + const payload = createInputVar({ + type: InputVarType.select, + default: 'beta', + options: ['alpha', 'beta'], + }) + + const nextPayload = updatePayloadField(payload, 'options', ['alpha']) + + expect(nextPayload.default).toBeUndefined() + expect(nextPayload.options).toEqual(['alpha']) + }) + + it('should seed upload defaults when switching to multi-file input', () => { + const payload = createInputVar({ + type: InputVarType.textInput, + default: 'hello', + }) + + const nextPayload = createPayloadForType(payload, InputVarType.multiFiles) + + expect(nextPayload.type).toBe(InputVarType.multiFiles) + expect(nextPayload.max_length).toBe(DEFAULT_FILE_UPLOAD_SETTING.max_length) + expect(nextPayload.allowed_file_types).toEqual(DEFAULT_FILE_UPLOAD_SETTING.allowed_file_types) + expect(nextPayload.default).toBe('hello') + }) + + it('should clear the default value when switching to a select input type', () => { + const payload = createInputVar({ + type: InputVarType.textInput, + default: 'hello', + }) + + const nextPayload = createPayloadForType(payload, InputVarType.select) + + expect(nextPayload.type).toBe(InputVarType.select) + expect(nextPayload.default).toBeUndefined() + }) + + it('should normalize empty select defaults to undefined', () => { + const nextPayload = normalizeSelectDefaultValue(createInputVar({ + type: InputVarType.select, + default: '', + })) + + expect(nextPayload.default).toBeUndefined() + }) + + it('should parse checkbox default values and normalize json schema editor content', () => { + expect(parseCheckboxSelectValue('true')).toBe(true) + expect(parseCheckboxSelectValue('false')).toBe(false) + expect(getJsonSchemaEditorValue(InputVarType.jsonObject, { type: 'object' } as never)).toBe(JSON.stringify({ type: 'object' }, null, 2)) + expect(getJsonSchemaEditorValue(InputVarType.textInput, '{"type":"object"}')).toBe('') + expect(getJsonSchemaEditorValue(InputVarType.jsonObject, '{"type":"object"}')).toBe('{"type":"object"}') + }) + + it('should fall back to an empty editor value when json schema serialization fails', () => { + const circular: Record = {} + circular.self = circular + + expect(getJsonSchemaEditorValue(InputVarType.jsonObject, circular as never)).toBe('') + }) + }) + + describe('derived values', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should expose upload and json options only when supported', () => { + const options = buildSelectOptions({ + isBasicApp: false, + supportFile: true, + t, + }) + + expect(options.map(option => option.value)).toEqual(expect.arrayContaining([ + InputVarType.singleFile, + InputVarType.multiFiles, + InputVarType.jsonObject, + ])) + }) + + it('should derive checkbox defaults from boolean and string values', () => { + expect(getCheckboxDefaultSelectValue(true)).toBe('true') + expect(getCheckboxDefaultSelectValue('TRUE')).toBe('true') + expect(getCheckboxDefaultSelectValue(undefined)).toBe('false') + }) + + it('should detect blank json schema values', () => { + expect(isJsonSchemaEmpty(undefined)).toBe(true) + expect(isJsonSchemaEmpty(' ')).toBe(true) + expect(isJsonSchemaEmpty('{}')).toBe(false) + expect(isJsonSchemaEmpty({ type: 'object' } as never)).toBe(false) + expect(isStringInputType(InputVarType.textInput)).toBe(true) + expect(isStringInputType(InputVarType.paragraph)).toBe(true) + expect(isStringInputType(InputVarType.number)).toBe(false) + }) + }) + + describe('validation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should reject duplicate select options', () => { + const checkVariableName = vi.fn(() => true) + + const result = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.select, + options: ['alpha', 'alpha'], + }), + checkVariableName, + payload: createInputVar({ + variable: 'question', + }), + t, + }) + + expect(result.errorMessage).toBe('variableConfig.errorMsg.optionRepeat') + expect(checkVariableName).toHaveBeenCalledWith('question') + }) + + it('should require custom extensions when custom file types are enabled', () => { + const result = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.singleFile, + allowed_file_types: [SupportUploadFileTypes.custom], + allowed_file_extensions: [], + }), + checkVariableName: () => true, + payload: createInputVar(), + t, + }) + + expect(result.errorMessage).toBe('errorMsg.fieldRequired') + }) + + it('should require at least one select option and supported file types', () => { + const selectResult = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.select, + options: [], + }), + checkVariableName: () => true, + payload: createInputVar(), + t, + }) + + const fileResult = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.singleFile, + allowed_file_types: [], + }), + checkVariableName: () => true, + payload: createInputVar(), + t, + }) + + expect(selectResult.errorMessage).toBe('variableConfig.errorMsg.atLeastOneOption') + expect(fileResult.errorMessage).toBe('errorMsg.fieldRequired') + }) + + it('should reject invalid json schema definitions', () => { + const invalidResult = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.jsonObject, + json_schema: '{', + }), + payload: createInputVar(), + checkVariableName: () => true, + t, + }) + + const nonObjectResult = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.jsonObject, + json_schema: JSON.stringify({ type: 'string' }), + }), + payload: createInputVar(), + checkVariableName: () => true, + t, + }) + + expect(invalidResult.errorMessage).toBe('variableConfig.errorMsg.jsonSchemaInvalid') + expect(nonObjectResult.errorMessage).toBe('variableConfig.errorMsg.jsonSchemaMustBeObject') + }) + + it('should normalize blank json schema and return rename metadata', () => { + const result = validateConfigModalPayload({ + tempPayload: createInputVar({ + type: InputVarType.jsonObject, + variable: 'question_new', + json_schema: ' ', + }), + payload: createInputVar({ + variable: 'question_old', + }), + checkVariableName: () => true, + t, + }) + + expect(result.errorMessage).toBeUndefined() + expect(result.payloadToSave).toEqual(expect.objectContaining({ + json_schema: undefined, + variable: 'question_new', + })) + expect(result.moreInfo).toEqual({ + type: ChangeType.changeVarName, + payload: { + beforeKey: 'question_old', + afterKey: 'question_new', + }, + }) + }) + + it('should stop validation when the variable name checker rejects the payload', () => { + const result = validateConfigModalPayload({ + tempPayload: createInputVar({ + variable: 'invalid_name', + }), + payload: createInputVar({ + variable: 'question', + }), + checkVariableName: () => false, + t, + }) + + expect(result).toEqual({}) + }) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx new file mode 100644 index 00000000000..c2a02f47100 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -0,0 +1,228 @@ +'use client' +import type { ChangeEvent, FC } from 'react' +import type { Item as SelectOptionItem } from './type-select' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { InputVar, UploadFileSetting } from '@/app/components/workflow/types' +import * as React from 'react' +import Checkbox from '@/app/components/base/checkbox' +import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' +import Input from '@/app/components/base/input' +import Textarea from '@/app/components/base/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/app/components/base/ui/select' +import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' +import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting' +import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { TransferMethod } from '@/types/app' +import ConfigSelect from '../config-select' +import ConfigString from '../config-string' +import { jsonConfigPlaceHolder } from './config' +import Field from './field' +import TypeSelector from './type-select' +import { CHECKBOX_DEFAULT_FALSE_VALUE, CHECKBOX_DEFAULT_TRUE_VALUE, TEXT_MAX_LENGTH } from './utils' + +type Translate = (key: string, options?: Record) => string +const EMPTY_SELECT_VALUE = '__empty__' + +type ConfigModalFormFieldsProps = { + checkboxDefaultSelectValue: string + isStringInput: boolean + jsonSchemaStr: string + maxLength?: number + modelId: string + onFilePayloadChange: (payload: UploadFileSetting) => void + onJSONSchemaChange: (value: string) => void + onPayloadChange: (key: string) => (value: unknown) => void + onTypeChange: (item: SelectOptionItem) => void + onVarKeyBlur: (event: ChangeEvent) => void + onVarNameChange: (event: ChangeEvent) => void + options?: string[] + selectOptions: SelectOptionItem[] + tempPayload: InputVar + t: Translate +} + +const ConfigModalFormFields: FC = ({ + checkboxDefaultSelectValue, + isStringInput, + jsonSchemaStr, + maxLength, + modelId, + onFilePayloadChange, + onJSONSchemaChange, + onPayloadChange, + onTypeChange, + onVarKeyBlur, + onVarNameChange, + options, + selectOptions, + tempPayload, + t, +}) => { + const { type, label, variable } = tempPayload + + return ( +
+ + + + + + + + + onPayloadChange('label')(e.target.value)} + placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} + /> + + + {isStringInput && ( + + + + )} + + {type === InputVarType.textInput && ( + + onPayloadChange('default')(e.target.value || undefined)} + placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })} + /> + + )} + + {type === InputVarType.paragraph && ( + +