From 4d4265f531c1ada165aeca7aa6753ede196086f2 Mon Sep 17 00:00:00 2001 From: corevibe555 <45244658+corevibe555@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:20:00 +0300 Subject: [PATCH 001/359] refactor(api): deduplicate Pydantic models across fields and controllers (#34718) --- api/controllers/console/app/message.py | 5 ++--- api/fields/conversation_fields.py | 10 +++++----- api/fields/message_fields.py | 7 ++----- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 2afe276742..5a19544eab 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -8,6 +8,7 @@ from pydantic import BaseModel, Field, field_validator from sqlalchemy import exists, func, select from werkzeug.exceptions import InternalServerError, NotFound +from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.app.error import ( @@ -59,10 +60,8 @@ class ChatMessagesQuery(BaseModel): return uuid_value(value) -class MessageFeedbackPayload(BaseModel): +class MessageFeedbackPayload(_MessageFeedbackPayloadBase): message_id: str = Field(..., description="Message ID") - rating: Literal["like", "dislike"] | None = Field(default=None, description="Feedback rating") - content: str | None = Field(default=None, description="Feedback content") @field_validator("message_id") @classmethod diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index 7878d58679..1afcbdb5b9 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -80,7 +80,7 @@ class Feedback(ResponseModel): from_account: SimpleAccount | None = None -class Annotation(ResponseModel): +class ConversationAnnotation(ResponseModel): id: str question: str | None = None content: str @@ -95,7 +95,7 @@ class Annotation(ResponseModel): return value -class AnnotationHitHistory(ResponseModel): +class ConversationAnnotationHitHistory(ResponseModel): annotation_id: str annotation_create_account: SimpleAccount | None = None created_at: int | None = None @@ -151,8 +151,8 @@ class MessageDetail(ResponseModel): from_account_id: str | None = None feedbacks: list[Feedback] workflow_run_id: str | None = None - annotation: Annotation | None = None - annotation_hit_history: AnnotationHitHistory | None = None + annotation: ConversationAnnotation | None = None + annotation_hit_history: ConversationAnnotationHitHistory | None = None created_at: int | None = None agent_thoughts: list[AgentThought] message_files: list[MessageFile] @@ -223,7 +223,7 @@ class Conversation(ResponseModel): read_at: int | None = None created_at: int | None = None updated_at: int | None = None - annotation: Annotation | None = None + annotation: ConversationAnnotation | None = None model_config_: SimpleModelConfig | None = Field(default=None, alias="model_config") user_feedback_stats: FeedbackStat | None = None admin_feedback_stats: FeedbackStat | None = None diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py index a063a643b4..1a871204a0 100644 --- a/api/fields/message_fields.py +++ b/api/fields/message_fields.py @@ -4,18 +4,15 @@ from datetime import datetime from uuid import uuid4 from graphon.file import File -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import Field, field_validator from core.entities.execution_extra_content import ExecutionExtraContentDomainModel +from fields.base import ResponseModel from fields.conversation_fields import AgentThought, JSONValue, MessageFile type JSONValueType = JSONValue -class ResponseModel(BaseModel): - model_config = ConfigDict(from_attributes=True, extra="ignore") - - class SimpleFeedback(ResponseModel): rating: str | None = None From aad0b3c157b9cfa36991ad31b5cc6c8718493737 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 8 Apr 2026 15:26:39 +0800 Subject: [PATCH 002/359] build: include vinext in docker build (#34535) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: yyh --- docker/.env.example | 3 +++ docker/docker-compose-template.yaml | 1 + docker/docker-compose.yaml | 2 ++ web/Dockerfile | 10 ++++++---- .../__tests__/use-workflow-interactions.spec.tsx | 8 ++++++-- .../hooks/use-workflow-organize.helpers.ts | 2 +- .../workflow/hooks/use-workflow-organize.ts | 2 +- web/app/components/workflow/utils/elk-layout.ts | 15 +++++++++++---- web/app/components/workflow/utils/index.ts | 1 - web/docker/entrypoint.sh | 6 +++++- web/eslint-suppressions.json | 2 +- 11 files changed, 37 insertions(+), 15 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index f20d57c71a..e32c9108e8 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1173,6 +1173,9 @@ MAX_ITERATIONS_NUM=99 # The timeout for the text generation in millisecond TEXT_GENERATION_TIMEOUT_MS=60000 +# Enable the experimental vinext runtime shipped in the image. +EXPERIMENTAL_ENABLE_VINEXT=false + # Allow rendering unsafe URLs which have "data:" scheme. ALLOW_UNSAFE_DATA_SCHEME=false diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 5234202a62..0e766da878 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -161,6 +161,7 @@ services: NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} SENTRY_DSN: ${WEB_SENTRY_DSN:-} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} + EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d03835e2b0..1a61573446 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -509,6 +509,7 @@ x-shared-env: &shared-api-worker-env MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10} MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} + EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50} PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} @@ -870,6 +871,7 @@ services: NEXT_PUBLIC_COOKIE_DOMAIN: ${NEXT_PUBLIC_COOKIE_DOMAIN:-} SENTRY_DSN: ${WEB_SENTRY_DSN:-} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-0} + EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} diff --git a/web/Dockerfile b/web/Dockerfile index 030651bf27..4971f86f97 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -42,7 +42,7 @@ COPY . . WORKDIR /app/web ENV NODE_OPTIONS="--max-old-space-size=4096" -RUN pnpm build +RUN pnpm build && pnpm build:vinext # production stage @@ -56,6 +56,7 @@ ENV APP_API_URL=http://127.0.0.1:5001 ENV MARKETPLACE_API_URL=https://marketplace.dify.ai ENV MARKETPLACE_URL=https://marketplace.dify.ai ENV PORT=3000 +ENV EXPERIMENTAL_ENABLE_VINEXT=false ENV NEXT_TELEMETRY_DISABLED=1 # set timezone @@ -73,9 +74,10 @@ RUN addgroup -S -g ${dify_uid} dify && \ WORKDIR /app -COPY --from=builder --chown=dify:dify /app/web/public ./web/public -COPY --from=builder --chown=dify:dify /app/web/.next/standalone ./ -COPY --from=builder --chown=dify:dify /app/web/.next/static ./web/.next/static +COPY --from=builder --chown=dify:dify /app/web/public ./targets/next/web/public +COPY --from=builder --chown=dify:dify /app/web/.next/standalone ./targets/next/ +COPY --from=builder --chown=dify:dify /app/web/.next/static ./targets/next/web/.next/static +COPY --from=builder --chown=dify:dify /app/web/dist/standalone ./targets/vinext COPY --chown=dify:dify --chmod=755 web/docker/entrypoint.sh ./entrypoint.sh diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx index 95dc3dff00..8038fc528d 100644 --- a/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-workflow-interactions.spec.tsx @@ -111,12 +111,16 @@ vi.mock('../use-workflow-history', () => ({ vi.mock('../../utils', async importOriginal => ({ ...(await importOriginal()), - getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args), - getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args), initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges), initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes), })) +vi.mock('../../utils/elk-layout', async importOriginal => ({ + ...(await importOriginal()), + getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args), + getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args), +})) + describe('use-workflow-interactions exports', () => { it('re-exports the split workflow interaction hooks', () => { expect(workflowInteractionExports.useWorkflowInteractions).toBeTypeOf('function') diff --git a/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts b/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts index c95e003e7f..850aa403ef 100644 --- a/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts +++ b/web/app/components/workflow/hooks/use-workflow-organize.helpers.ts @@ -1,5 +1,5 @@ import type { Node } from '../types' -import type { LayoutResult } from '../utils' +import type { LayoutResult } from '../utils/elk-layout' import { produce } from 'immer' import { CUSTOM_NODE, diff --git a/web/app/components/workflow/hooks/use-workflow-organize.ts b/web/app/components/workflow/hooks/use-workflow-organize.ts index da158a4214..1acaa07fbc 100644 --- a/web/app/components/workflow/hooks/use-workflow-organize.ts +++ b/web/app/components/workflow/hooks/use-workflow-organize.ts @@ -4,7 +4,7 @@ import { useWorkflowStore } from '../store' import { getLayoutByELK, getLayoutForChildNodes, -} from '../utils' +} from '../utils/elk-layout' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesReadOnly } from './use-workflow' import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history' diff --git a/web/app/components/workflow/utils/elk-layout.ts b/web/app/components/workflow/utils/elk-layout.ts index 781416f3c4..950074a965 100644 --- a/web/app/components/workflow/utils/elk-layout.ts +++ b/web/app/components/workflow/utils/elk-layout.ts @@ -6,7 +6,6 @@ import type { Edge, Node, } from '@/app/components/workflow/types' -import ELK from 'elkjs/lib/elk.bundled.js' import { cloneDeep } from 'es-toolkit/object' import { CUSTOM_NODE, @@ -19,7 +18,15 @@ import { BlockEnum, } from '@/app/components/workflow/types' -const elk = new ELK() +let elk: import('elkjs/lib/elk-api').ELK | undefined + +async function getELK() { + if (!elk) { + const { default: ELK } = await import('elkjs/lib/elk.bundled.js') + elk = new ELK() + } + return elk +} const DEFAULT_NODE_WIDTH = 244 const DEFAULT_NODE_HEIGHT = 100 @@ -473,7 +480,7 @@ export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): edges: elkEdges, } - const layoutedGraph = await elk.layout(graph) + const layoutedGraph = await (await getELK()).layout(graph) const layout = collectLayout(layoutedGraph, () => true) return normaliseBounds(layout) } @@ -571,7 +578,7 @@ export const getLayoutForChildNodes = async ( edges: elkEdges, } - const layoutedGraph = await elk.layout(graph) + const layoutedGraph = await (await getELK()).layout(graph) const layout = collectLayout(layoutedGraph, () => true) return normaliseChildLayout(layout, nodes) } diff --git a/web/app/components/workflow/utils/index.ts b/web/app/components/workflow/utils/index.ts index 715ce081a3..641deec695 100644 --- a/web/app/components/workflow/utils/index.ts +++ b/web/app/components/workflow/utils/index.ts @@ -1,7 +1,6 @@ export * from './common' export * from './data-source' export * from './edge' -export * from './elk-layout' export * from './gen-node-meta-data' export * from './node' export * from './tool' diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index 034ed96491..50681910d2 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -43,4 +43,8 @@ export NEXT_PUBLIC_MAX_PARALLEL_LIMIT=${MAX_PARALLEL_LIMIT} export NEXT_PUBLIC_MAX_ITERATIONS_NUM=${MAX_ITERATIONS_NUM} export NEXT_PUBLIC_MAX_TREE_DEPTH=${MAX_TREE_DEPTH} -exec node /app/web/server.js +if [ "${EXPERIMENTAL_ENABLE_VINEXT:-}" = "true" ]; then + exec node /app/targets/vinext/server.js +fi + +exec node /app/targets/next/web/server.js diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 37417e7aa4..d334d7a153 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -11278,7 +11278,7 @@ }, "app/components/workflow/utils/index.ts": { "no-barrel-files/no-barrel-files": { - "count": 10 + "count": 9 } }, "app/components/workflow/utils/node-navigation.ts": { From 546062d2cd4a34b112d58c5ccca587b2bd51a806 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 8 Apr 2026 15:49:53 +0800 Subject: [PATCH 003/359] chore: remove raw vite deps (#34726) --- e2e/package.json | 1 + package.json | 1 + pnpm-lock.yaml | 364 ++------------------------------ sdks/nodejs-client/package.json | 1 + 4 files changed, 17 insertions(+), 350 deletions(-) diff --git a/e2e/package.json b/e2e/package.json index 0ee2afff7f..925418f223 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -19,6 +19,7 @@ "@types/node": "catalog:", "tsx": "catalog:", "typescript": "catalog:", + "vite": "catalog:", "vite-plus": "catalog:" } } diff --git a/package.json b/package.json index ce3180214b..736a354ef7 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "prepare": "vp config" }, "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98e6e21bc2..8e8c0970a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -568,9 +568,12 @@ importers: .: devDependencies: + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 + version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) e2e: devDependencies: @@ -589,9 +592,12 @@ importers: typescript: specifier: 'catalog:' version: 6.0.2 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 + version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) packages/iconify-collections: devDependencies: @@ -615,19 +621,22 @@ importers: version: 8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.3(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)) + version: 4.1.3(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)) eslint: specifier: 'catalog:' version: 10.2.0(jiti@2.6.1) typescript: specifier: 'catalog:' version: 6.0.2 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 + version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' vite-plus: specifier: 'catalog:' - version: 0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) + version: 0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) vitest: specifier: npm:@voidzero-dev/vite-plus-test@0.1.16 - version: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' + version: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' web: dependencies: @@ -2514,9 +2523,6 @@ packages: '@oxc-project/types@0.121.0': resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} - '@oxc-project/types@0.122.0': - resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} - '@oxc-project/types@0.123.0': resolution: {integrity: sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==} @@ -3284,104 +3290,6 @@ packages: resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==} engines: {node: '>=14.0.0'} - '@rolldown/binding-android-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': - resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-rc.12': - resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} - '@rolldown/pluginutils@1.0.0-rc.13': resolution: {integrity: sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==} @@ -7728,11 +7636,6 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rolldown@1.0.0-rc.12: - resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -8470,49 +8373,6 @@ packages: peerDependencies: vite: '*' - vite@8.0.3: - resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.0 - esbuild: 0.27.2 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: 2.8.3 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vitefu@1.1.3: resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: @@ -10269,8 +10129,6 @@ snapshots: '@oxc-project/types@0.121.0': {} - '@oxc-project/types@0.122.0': {} - '@oxc-project/types@0.123.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': @@ -10835,58 +10693,6 @@ snapshots: '@rgrove/parse-xml@4.2.0': {} - '@rolldown/binding-android-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': - dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': - optional: true - - '@rolldown/pluginutils@1.0.0-rc.12': {} - '@rolldown/pluginutils@1.0.0-rc.13': {} '@rolldown/pluginutils@1.0.0-rc.7': {} @@ -12109,20 +11915,6 @@ snapshots: tinyrainbow: 3.1.0 vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' - '@vitest/coverage-v8@4.1.3(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.3 - ast-v8-to-istanbul: 1.0.0 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - std-env: 4.0.0 - tinyrainbow: 3.1.0 - vitest: '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)' - '@vitest/eslint-plugin@1.6.14(@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2))(@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(eslint@10.2.0(jiti@2.6.1))(typescript@6.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.58.1 @@ -12241,46 +12033,6 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-test@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3)': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - es-module-lexer: 1.7.0 - obug: 2.1.1 - pixelmatch: 7.1.0 - pngjs: 7.0.0 - sirv: 3.0.2 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - ws: 8.20.0 - optionalDependencies: - '@types/node': 25.5.2 - happy-dom: 20.8.9 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@tsdown/css' - - '@tsdown/exe' - - '@vitejs/devtools' - - bufferutil - - esbuild - - jiti - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - utf-8-validate - - yaml - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.16': optional: true @@ -16048,30 +15800,6 @@ snapshots: robust-predicates@3.0.3: {} - rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): - dependencies: - '@oxc-project/types': 0.122.0 - '@rolldown/pluginutils': 1.0.0-rc.12 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 - '@rolldown/binding-darwin-x64': 1.0.0-rc.12 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -16893,51 +16621,6 @@ snapshots: - vite - yaml - vite-plus@0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3): - dependencies: - '@oxc-project/types': 0.123.0 - '@voidzero-dev/vite-plus-core': 0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) - '@voidzero-dev/vite-plus-test': 0.1.16(@types/node@25.5.2)(happy-dom@20.8.9)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(yaml@2.8.3) - oxfmt: 0.43.0 - oxlint: 1.58.0(oxlint-tsgolint@0.20.0) - oxlint-tsgolint: 0.20.0 - optionalDependencies: - '@voidzero-dev/vite-plus-darwin-arm64': 0.1.16 - '@voidzero-dev/vite-plus-darwin-x64': 0.1.16 - '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.16 - '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.16 - '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.16 - '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.16 - '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.16 - '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.16 - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@tsdown/css' - - '@tsdown/exe' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - publint - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - typescript - - unplugin-unused - - utf-8-validate - - vite - - yaml - vite-tsconfig-paths@5.1.4(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(typescript@6.0.2): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -16959,25 +16642,6 @@ snapshots: - supports-color - typescript - vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.9 - rolldown: 1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.5.2 - fsevents: 2.3.3 - jiti: 2.6.1 - sass: 1.98.0 - terser: 5.46.1 - tsx: 4.21.0 - yaml: 2.8.3 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - vitefu@1.1.3(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)): optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' diff --git a/sdks/nodejs-client/package.json b/sdks/nodejs-client/package.json index da9f7353ac..e058edb0ca 100644 --- a/sdks/nodejs-client/package.json +++ b/sdks/nodejs-client/package.json @@ -62,6 +62,7 @@ "@vitest/coverage-v8": "catalog:", "eslint": "catalog:", "typescript": "catalog:", + "vite": "catalog:", "vite-plus": "catalog:", "vitest": "catalog:" } From 0e0bb3582f9de9c1a822aed5145fcc3def0895cf Mon Sep 17 00:00:00 2001 From: s-kawamura-upgrade Date: Wed, 8 Apr 2026 17:38:24 +0900 Subject: [PATCH 004/359] feat(web): add ALLOW_INLINE_STYLES env var to opt-in inline CSS in Markdown rendering (#34719) Co-authored-by: Claude Opus 4.6 (1M context) --- docker/.env.example | 5 +++++ docker/docker-compose-template.yaml | 1 + docker/docker-compose.yaml | 2 ++ web/.env.example | 3 +++ web/app/components/base/markdown/streamdown-wrapper.tsx | 7 ++++++- web/config/index.ts | 1 + web/docker/entrypoint.sh | 1 + web/env.ts | 6 ++++++ 8 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docker/.env.example b/docker/.env.example index e32c9108e8..c046f6d378 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1176,6 +1176,11 @@ TEXT_GENERATION_TIMEOUT_MS=60000 # Enable the experimental vinext runtime shipped in the image. EXPERIMENTAL_ENABLE_VINEXT=false +# Allow inline style attributes in Markdown rendering. +# Enable this if your workflows use Jinja2 templates with styled HTML. +# Only recommended for self-hosted deployments with trusted content. +ALLOW_INLINE_STYLES=false + # Allow rendering unsafe URLs which have "data:" scheme. ALLOW_UNSAFE_DATA_SCHEME=false diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 0e766da878..4f4b3851f6 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -165,6 +165,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 1a61573446..3f6a13e78e 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -510,6 +510,7 @@ x-shared-env: &shared-api-worker-env MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} EXPERIMENTAL_ENABLE_VINEXT: ${EXPERIMENTAL_ENABLE_VINEXT:-false} + ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50} PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} @@ -875,6 +876,7 @@ services: TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} CSP_WHITELIST: ${CSP_WHITELIST:-} ALLOW_EMBED: ${ALLOW_EMBED:-false} + ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai} diff --git a/web/.env.example b/web/.env.example index 62d4fa6c56..93cbc22fc8 100644 --- a/web/.env.example +++ b/web/.env.example @@ -48,6 +48,9 @@ NEXT_PUBLIC_CSP_WHITELIST= # Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking NEXT_PUBLIC_ALLOW_EMBED= +# Allow inline style attributes in Markdown rendering (self-hosted opt-in). +NEXT_PUBLIC_ALLOW_INLINE_STYLES=false + # Allow rendering unsafe URLs which have "data:" scheme. NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false diff --git a/web/app/components/base/markdown/streamdown-wrapper.tsx b/web/app/components/base/markdown/streamdown-wrapper.tsx index 46db301adb..e20898135b 100644 --- a/web/app/components/base/markdown/streamdown-wrapper.tsx +++ b/web/app/components/base/markdown/streamdown-wrapper.tsx @@ -16,7 +16,7 @@ import { ThinkBlock, VideoBlock, } from '@/app/components/base/markdown-blocks' -import { ENABLE_SINGLE_DOLLAR_LATEX } from '@/config' +import { ALLOW_INLINE_STYLES, ENABLE_SINGLE_DOLLAR_LATEX } from '@/config' import dynamic from '@/next/dynamic' import { customUrlTransform } from './markdown-utils' import 'katex/dist/katex.min.css' @@ -118,6 +118,11 @@ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList { // component validates names with `isSafeName()`, so remove it. const clobber = (defaultSanitizeSchema.clobber ?? []).filter(k => k !== 'name') + if (ALLOW_INLINE_STYLES) { + const globalAttrs = mergedAttributes['*'] ?? [] + mergedAttributes['*'] = [...globalAttrs, 'style'] + } + const customSchema: SanitizeSchema = { ...defaultSanitizeSchema, tagNames: [...tagNamesSet], diff --git a/web/config/index.ts b/web/config/index.ts index 999aa754e6..63967763f6 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -304,6 +304,7 @@ export const LOOP_NODE_MAX_COUNT = env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT export const MAX_ITERATIONS_NUM = env.NEXT_PUBLIC_MAX_ITERATIONS_NUM export const MAX_TREE_DEPTH = env.NEXT_PUBLIC_MAX_TREE_DEPTH +export const ALLOW_INLINE_STYLES = env.NEXT_PUBLIC_ALLOW_INLINE_STYLES export const ALLOW_UNSAFE_DATA_SCHEME = env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME export const ENABLE_WEBSITE_JINAREADER = env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER export const ENABLE_WEBSITE_FIRECRAWL = env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index 50681910d2..3e42d48d0c 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -30,6 +30,7 @@ export NEXT_PUBLIC_AMPLITUDE_API_KEY=${AMPLITUDE_API_KEY} export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS} export NEXT_PUBLIC_CSP_WHITELIST=${CSP_WHITELIST} export NEXT_PUBLIC_ALLOW_EMBED=${ALLOW_EMBED} +export NEXT_PUBLIC_ALLOW_INLINE_STYLES=${ALLOW_INLINE_STYLES:-false} export NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=${ALLOW_UNSAFE_DATA_SCHEME:-false} export NEXT_PUBLIC_TOP_K_MAX_VALUE=${TOP_K_MAX_VALUE} export NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH} diff --git a/web/env.ts b/web/env.ts index 55709918a8..3623b68462 100644 --- a/web/env.ts +++ b/web/env.ts @@ -19,6 +19,11 @@ const clientSchema = { * Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking */ NEXT_PUBLIC_ALLOW_EMBED: coercedBoolean.default(false), + /** + * Allow inline style attributes in Markdown rendering. + * Self-hosted opt-in for workflows using styled Jinja2 templates. + */ + NEXT_PUBLIC_ALLOW_INLINE_STYLES: coercedBoolean.default(false), /** * Allow rendering unsafe URLs which have "data:" scheme. */ @@ -153,6 +158,7 @@ export const env = createEnv({ client: clientSchema, experimental__runtimeEnv: { NEXT_PUBLIC_ALLOW_EMBED: isServer ? process.env.NEXT_PUBLIC_ALLOW_EMBED : getRuntimeEnvFromBody('allowEmbed'), + NEXT_PUBLIC_ALLOW_INLINE_STYLES: isServer ? process.env.NEXT_PUBLIC_ALLOW_INLINE_STYLES : getRuntimeEnvFromBody('allowInlineStyles'), NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME: isServer ? process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME : getRuntimeEnvFromBody('allowUnsafeDataScheme'), NEXT_PUBLIC_AMPLITUDE_API_KEY: isServer ? process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY : getRuntimeEnvFromBody('amplitudeApiKey'), NEXT_PUBLIC_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_API_PREFIX : getRuntimeEnvFromBody('apiPrefix'), From 9948a51b141d67283c776e27f9c8568593314f6f Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Wed, 8 Apr 2026 16:50:57 +0800 Subject: [PATCH 005/359] test: add unit tests for access control components to enhance coverage and reliability (#34722) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../__tests__/hit-history-no-data.spec.tsx | 10 + .../__tests__/access-control-dialog.spec.tsx | 32 ++ .../__tests__/access-control-item.spec.tsx | 45 +++ .../add-member-or-group-pop.spec.tsx | 130 +++++++ .../__tests__/index.spec.tsx | 121 +++++++ .../specific-groups-or-members.spec.tsx | 97 +++++ .../__tests__/input-type-icon.spec.tsx | 26 ++ .../config-var/__tests__/modal-foot.spec.tsx | 19 + .../__tests__/select-var-type.spec.tsx | 16 + .../config-var/__tests__/var-item.spec.tsx | 46 +++ .../config-modal/__tests__/config.spec.ts | 23 ++ .../config-modal/__tests__/field.spec.tsx | 25 ++ .../__tests__/param-config-content.spec.tsx | 74 ++++ .../__tests__/param-config.spec.tsx | 58 +++ .../automatic/__tests__/prompt-toast.spec.tsx | 22 ++ .../__tests__/res-placeholder.spec.tsx | 10 + .../automatic/__tests__/use-gen-data.spec.ts | 39 ++ .../__tests__/context-provider.spec.tsx | 38 ++ .../__tests__/app-card-sections.spec.tsx | 103 ++++++ .../overview/__tests__/app-card-utils.spec.ts | 107 ++++++ .../plugins/__tests__/constants.spec.ts | 40 +++ .../plugins/__tests__/provider-card.spec.tsx | 104 ++++++ .../base/__tests__/use-get-icon.spec.ts | 22 ++ .../item/__tests__/github-item.spec.tsx | 136 +++++++ .../item/__tests__/loaded-item.spec.tsx | 160 +++++++++ .../item/__tests__/marketplace-item.spec.tsx | 69 ++++ .../item/__tests__/package-item.spec.tsx | 124 +++++++ .../steps/__tests__/installed.spec.tsx | 114 ++++++ .../marketplace/__tests__/constants.spec.ts | 37 ++ .../__tests__/search-params.spec.ts | 18 + .../marketplace/empty/__tests__/line.spec.tsx | 30 ++ .../list/__tests__/card-wrapper.spec.tsx | 115 ++++++ .../__tests__/list-with-collection.spec.tsx | 102 ++++++ .../list/__tests__/list-wrapper.spec.tsx | 92 +++++ .../__tests__/search-box-wrapper.spec.tsx | 43 +++ .../search-box/__tests__/tags-filter.spec.tsx | 126 +++++++ .../trigger/__tests__/marketplace.spec.tsx | 67 ++++ .../trigger/__tests__/tool-selector.spec.tsx | 61 ++++ .../__tests__/app-inputs-form.spec.tsx | 106 ++++++ .../__tests__/app-inputs-panel.spec.tsx | 87 +++++ .../__tests__/app-picker.spec.tsx | 179 +++++++++ .../use-app-inputs-form-schema.spec.ts | 141 ++++++++ .../detail-header/__tests__/index.spec.tsx | 251 +++++++++++++ .../components/__tests__/index.spec.ts | 9 + .../hooks/__tests__/index.spec.ts | 9 + .../components/__tests__/modal-steps.spec.tsx | 112 ++++++ .../use-common-modal-state.helpers.spec.ts | 196 ++++++++++ .../__tests__/use-common-modal-state.spec.ts | 253 +++++++++++++ .../hooks/use-common-modal-state.helpers.ts | 180 ++++++++++ .../create/hooks/use-common-modal-state.ts | 128 +++---- .../components/__tests__/index.spec.ts | 22 ++ .../reasoning-config-form.helpers.spec.ts | 181 ++++++++++ .../__tests__/reasoning-config-form.spec.tsx | 340 ++++++++++++++++++ .../__tests__/schema-modal.spec.tsx | 61 ++++ .../tool-authorization-section.spec.tsx | 64 ++++ .../components/__tests__/tool-item.spec.tsx | 130 +++++++ .../__tests__/tool-settings-panel.spec.tsx | 100 ++++++ .../__tests__/tool-trigger.spec.tsx | 38 ++ .../reasoning-config-form.helpers.ts | 233 ++++++++++++ .../components/reasoning-config-form.tsx | 172 +++------ .../hooks/__tests__/index.spec.ts | 9 + .../__tests__/context-provider.spec.tsx | 76 ++++ .../plugin-page/__tests__/debug-info.spec.tsx | 89 +++++ .../install-plugin-dropdown.spec.tsx | 156 ++++++++ .../__tests__/plugins-panel.spec.tsx | 200 +++++++++++ .../__tests__/constant.spec.ts | 32 ++ .../__tests__/tag-filter.spec.tsx | 76 ++++ .../__tests__/config.spec.ts | 15 + .../__tests__/no-data-placeholder.spec.tsx | 19 + .../__tests__/no-plugin-selected.spec.tsx | 18 + .../__tests__/plugins-picker.spec.tsx | 82 +++++ .../__tests__/plugins-selected.spec.tsx | 29 ++ .../__tests__/strategy-picker.spec.tsx | 100 ++++++ .../__tests__/tool-item.spec.tsx | 65 ++++ .../__tests__/tool-picker.spec.tsx | 248 +++++++++++++ .../__tests__/from-market-place.spec.tsx | 226 ++++++++++++ .../__tests__/plugin-version-picker.spec.tsx | 107 ++++++ .../__tests__/rag-pipeline-children.spec.tsx | 141 ++++++++ .../components/__tests__/screenshot.spec.tsx | 29 ++ .../__tests__/q-a-item.spec.tsx | 23 ++ .../editor/__tests__/utils.spec.ts | 97 +++++ .../form/__tests__/hidden-fields.spec.tsx | 73 ++++ .../form/__tests__/initial-fields.spec.tsx | 85 +++++ .../form/__tests__/show-all-settings.spec.tsx | 62 ++++ .../field-list/__tests__/field-item.spec.tsx | 83 +++++ .../__tests__/field-list-container.spec.tsx | 60 ++++ .../__tests__/datasource.spec.tsx | 24 ++ .../__tests__/global-inputs.spec.tsx | 23 ++ .../preview/__tests__/data-source.spec.tsx | 73 ++++ .../preview/__tests__/form.spec.tsx | 64 ++++ .../__tests__/process-documents.spec.tsx | 39 ++ .../panel/test-run/__tests__/header.spec.tsx | 60 ++++ .../__tests__/footer-tips.spec.tsx | 14 + .../__tests__/step-indicator.spec.tsx | 41 +++ .../__tests__/option-card.spec.tsx | 49 +++ .../__tests__/actions.spec.tsx | 67 ++++ .../__tests__/hooks.spec.ts | 32 ++ .../__tests__/options.spec.tsx | 140 ++++++++ .../result-preview/__tests__/utils.spec.ts | 84 +++++ .../result/tabs/__tests__/tab.spec.tsx | 64 ++++ .../__tests__/input-field-button.spec.tsx | 35 ++ .../utils/__tests__/nodes.spec.ts | 92 +++++ .../__tests__/examples.spec.ts | 18 + .../tools/labels/__tests__/constant.spec.ts | 33 ++ .../workflow-tool/__tests__/helpers.spec.ts | 102 ++++++ .../workflow-tool/__tests__/index.spec.tsx | 200 +++++++++++ .../components/tools/workflow-tool/helpers.ts | 95 +++++ .../components/tools/workflow-tool/index.tsx | 98 ++--- web/eslint-suppressions.json | 6 - 109 files changed, 8876 insertions(+), 270 deletions(-) create mode 100644 web/app/components/app/annotation/view-annotation-modal/__tests__/hit-history-no-data.spec.tsx create mode 100644 web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx create mode 100644 web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx create mode 100644 web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx create mode 100644 web/app/components/app/app-access-control/__tests__/index.spec.tsx create mode 100644 web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/config.spec.ts create mode 100644 web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx create mode 100644 web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx create mode 100644 web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx create mode 100644 web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts create mode 100644 web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx create mode 100644 web/app/components/app/overview/__tests__/app-card-sections.spec.tsx create mode 100644 web/app/components/app/overview/__tests__/app-card-utils.spec.ts create mode 100644 web/app/components/plugins/__tests__/constants.spec.ts create mode 100644 web/app/components/plugins/__tests__/provider-card.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx create mode 100644 web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx create mode 100644 web/app/components/plugins/marketplace/__tests__/constants.spec.ts create mode 100644 web/app/components/plugins/marketplace/__tests__/search-params.spec.ts create mode 100644 web/app/components/plugins/marketplace/empty/__tests__/line.spec.tsx create mode 100644 web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx create mode 100644 web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx create mode 100644 web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx create mode 100644 web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx create mode 100644 web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx create mode 100644 web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx create mode 100644 web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/index.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/index.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/index.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-trigger.spec.tsx create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts create mode 100644 web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts create mode 100644 web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx create mode 100644 web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts create mode 100644 web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx create mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts create mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx create mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx create mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx create mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx create mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx create mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx create mode 100644 web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx create mode 100644 web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx create mode 100644 web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts create mode 100644 web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx create mode 100644 web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx create mode 100644 web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts create mode 100644 web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts create mode 100644 web/app/components/tools/labels/__tests__/constant.spec.ts create mode 100644 web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts create mode 100644 web/app/components/tools/workflow-tool/__tests__/index.spec.tsx create mode 100644 web/app/components/tools/workflow-tool/helpers.ts diff --git a/web/app/components/app/annotation/view-annotation-modal/__tests__/hit-history-no-data.spec.tsx b/web/app/components/app/annotation/view-annotation-modal/__tests__/hit-history-no-data.spec.tsx new file mode 100644 index 0000000000..33a38106d0 --- /dev/null +++ b/web/app/components/app/annotation/view-annotation-modal/__tests__/hit-history-no-data.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react' +import HitHistoryNoData from '../hit-history-no-data' + +describe('HitHistoryNoData', () => { + it('should render the empty history message', () => { + render() + + expect(screen.getByText('appAnnotation.viewModal.noHitHistory')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx new file mode 100644 index 0000000000..5c7d2f2dc0 --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/access-control-dialog.spec.tsx @@ -0,0 +1,32 @@ +/* eslint-disable ts/no-explicit-any */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import AccessControlDialog from '../access-control-dialog' + +describe('AccessControlDialog', () => { + it('should render dialog content when visible', () => { + render( + +
Dialog Content
+
, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Dialog Content')).toBeInTheDocument() + }) + + it('should trigger onClose when clicking the close control', async () => { + const onClose = vi.fn() + render( + +
Dialog Content
+
, + ) + + const closeButton = document.body.querySelector('div.absolute.right-5.top-5') as HTMLElement + fireEvent.click(closeButton) + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx new file mode 100644 index 0000000000..b1a862a13c --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/access-control-item.spec.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import useAccessControlStore from '@/context/access-control-store' +import { AccessMode } from '@/models/access-control' +import AccessControlItem from '../access-control-item' + +describe('AccessControlItem', () => { + beforeEach(() => { + vi.clearAllMocks() + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.PUBLIC, + selectedGroupsForBreadcrumb: [], + }) + }) + + it('should update current menu when selecting a different access type', () => { + render( + + Organization Only + , + ) + + const option = screen.getByText('Organization Only').parentElement as HTMLElement + fireEvent.click(option) + + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.ORGANIZATION) + }) + + it('should keep the selected state for the active access type', () => { + useAccessControlStore.setState({ + currentMenu: AccessMode.ORGANIZATION, + }) + + render( + + Organization Only + , + ) + + const option = screen.getByText('Organization Only').parentElement as HTMLElement + expect(option).toHaveClass('border-components-option-card-option-selected-border') + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx new file mode 100644 index 0000000000..725b121d30 --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -0,0 +1,130 @@ +import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import useAccessControlStore from '@/context/access-control-store' +import { SubjectType } from '@/models/access-control' +import AddMemberOrGroupDialog from '../add-member-or-group-pop' + +const mockUseSearchForWhiteListCandidates = vi.fn() +const intersectionObserverMocks = vi.hoisted(() => ({ + callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (value: { userProfile: { email: string } }) => T) => selector({ + userProfile: { + email: 'member@example.com', + }, + }), +})) + +vi.mock('@/service/access-control', () => ({ + useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), +})) + +const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, +} as AccessControlGroup) + +const createMember = (overrides: Partial = {}): AccessControlAccount => ({ + id: 'member-1', + name: 'Member One', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + ...overrides, +} as AccessControlAccount) + +describe('AddMemberOrGroupDialog', () => { + const baseGroup = createGroup() + const baseMember = createMember() + const groupSubject: Subject = { + subjectId: baseGroup.id, + subjectType: SubjectType.GROUP, + groupData: baseGroup, + } as Subject + const memberSubject: Subject = { + subjectId: baseMember.id, + subjectType: SubjectType.ACCOUNT, + accountData: baseMember, + } as Subject + + beforeAll(() => { + class MockIntersectionObserver { + constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) { + intersectionObserverMocks.callback = callback + } + + observe = vi.fn(() => undefined) + disconnect = vi.fn(() => undefined) + unobserve = vi.fn(() => undefined) + } + + // @ts-expect-error test DOM typings do not guarantee IntersectionObserver here + globalThis.IntersectionObserver = MockIntersectionObserver + }) + + beforeEach(() => { + vi.clearAllMocks() + useAccessControlStore.setState({ + appId: 'app-1', + specificGroups: [], + specificMembers: [], + currentMenu: SubjectType.GROUP as never, + selectedGroupsForBreadcrumb: [], + }) + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { + pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }], + }, + }) + }) + + it('should open the search popover and display candidates', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + + expect(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder')).toBeInTheDocument() + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByText(baseMember.name)).toBeInTheDocument() + }) + + it('should allow expanding groups and selecting members', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) + + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + + const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement + fireEvent.click(memberCheckbox) + + expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) + }) + + it('should show the empty state when no candidates are returned', async () => { + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [] }, + }) + + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + + expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f2fa09f98a --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -0,0 +1,121 @@ +/* eslint-disable ts/no-explicit-any */ +import type { App } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { toast } from '@/app/components/base/ui/toast' +import useAccessControlStore from '@/context/access-control-store' +import { AccessMode } from '@/models/access-control' +import AccessControl from '../index' + +const mockMutateAsync = vi.fn() +const mockUseUpdateAccessMode = vi.fn(() => ({ + isPending: false, + mutateAsync: mockMutateAsync, +})) +const mockUseAppWhiteListSubjects = vi.fn() +const mockUseSearchForWhiteListCandidates = vi.fn() +let mockWebappAuth = { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, +} + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({ + systemFeatures: { + webapp_auth: mockWebappAuth, + }, + }), +})) + +vi.mock('@/service/access-control', () => ({ + useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), + useSearchForWhiteListCandidates: (...args: unknown[]) => mockUseSearchForWhiteListCandidates(...args), + useUpdateAccessMode: () => mockUseUpdateAccessMode(), +})) + +describe('AccessControl', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWebappAuth = { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, + } + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [], + }) + mockMutateAsync.mockResolvedValue(undefined) + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: false, + data: { + groups: [], + members: [], + }, + }) + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [] }, + }) + }) + + it('should initialize menu from the app and update access mode on confirm', async () => { + const onClose = vi.fn() + const onConfirm = vi.fn() + const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') + const app = { + id: 'app-id-1', + access_mode: AccessMode.PUBLIC, + } as App + + render( + , + ) + + await waitFor(() => { + expect(useAccessControlStore.getState().appId).toBe(app.id) + expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC) + }) + + fireEvent.click(screen.getByText('common.operation.confirm')) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalledWith({ + appId: app.id, + accessMode: AccessMode.PUBLIC, + }) + expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + }) + + it('should show the external-members option when SSO tip is visible', () => { + mockWebappAuth = { + enabled: false, + allow_sso: false, + allow_email_password_login: false, + allow_email_code_login: false, + } + + render( + , + ) + + expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() + expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx new file mode 100644 index 0000000000..7b198c4e66 --- /dev/null +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -0,0 +1,97 @@ +import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import useAccessControlStore from '@/context/access-control-store' +import { AccessMode } from '@/models/access-control' +import SpecificGroupsOrMembers from '../specific-groups-or-members' + +const mockUseAppWhiteListSubjects = vi.fn() + +vi.mock('@/service/access-control', () => ({ + useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), +})) + +vi.mock('../add-member-or-group-pop', () => ({ + default: () =>
, +})) + +const createGroup = (overrides: Partial = {}): AccessControlGroup => ({ + id: 'group-1', + name: 'Group One', + groupSize: 5, + ...overrides, +} as AccessControlGroup) + +const createMember = (overrides: Partial = {}): AccessControlAccount => ({ + id: 'member-1', + name: 'Member One', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + ...overrides, +} as AccessControlAccount) + +describe('SpecificGroupsOrMembers', () => { + const baseGroup = createGroup() + const baseMember = createMember() + + beforeEach(() => { + vi.clearAllMocks() + useAccessControlStore.setState({ + appId: '', + specificGroups: [], + specificMembers: [], + currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, + selectedGroupsForBreadcrumb: [], + }) + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: false, + data: { + groups: [baseGroup], + members: [baseMember], + }, + }) + }) + + it('should render the collapsed row when not in specific mode', () => { + useAccessControlStore.setState({ + currentMenu: AccessMode.ORGANIZATION, + }) + + render() + + expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() + expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() + }) + + it('should show loading while whitelist subjects are pending', async () => { + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: true, + data: undefined, + }) + + const { container } = render() + + await waitFor(() => { + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + }) + + it('should render fetched groups and members and support removal', async () => { + useAccessControlStore.setState({ appId: 'app-1' }) + + render() + + await waitFor(() => { + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByText(baseMember.name)).toBeInTheDocument() + }) + + const groupRemove = screen.getByText(baseGroup.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + fireEvent.click(groupRemove) + expect(useAccessControlStore.getState().specificGroups).toEqual([]) + + const memberRemove = screen.getByText(baseMember.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + fireEvent.click(memberRemove) + expect(useAccessControlStore.getState().specificMembers).toEqual([]) + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx new file mode 100644 index 0000000000..0b492a06ed --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/input-type-icon.spec.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import { InputVarType } from '@/app/components/workflow/types' +import InputTypeIcon from '../input-type-icon' + +const mockInputVarTypeIcon = vi.fn(({ type, className }: { type: InputVarType, className?: string }) => ( +
+)) + +vi.mock('@/app/components/workflow/nodes/_base/components/input-var-type-icon', () => ({ + default: (props: { type: InputVarType, className?: string }) => mockInputVarTypeIcon(props), +})) + +describe('InputTypeIcon', () => { + it('should map string variables to the workflow text-input icon', () => { + render() + + expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.textInput) + expect(screen.getByTestId('input-var-type-icon')).toHaveClass('marker') + }) + + it('should map select variables to the workflow select icon', () => { + render() + + expect(screen.getByTestId('input-var-type-icon')).toHaveAttribute('data-type', InputVarType.select) + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx new file mode 100644 index 0000000000..e84189ddff --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/modal-foot.spec.tsx @@ -0,0 +1,19 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import ModalFoot from '../modal-foot' + +describe('ModalFoot', () => { + it('should trigger cancel and confirm callbacks', () => { + const onCancel = vi.fn() + const onConfirm = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx new file mode 100644 index 0000000000..611aaa1c8a --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/select-var-type.spec.tsx @@ -0,0 +1,16 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import SelectVarType from '../select-var-type' + +describe('SelectVarType', () => { + it('should open the menu and return the selected variable type', () => { + const onChange = vi.fn() + + render() + + fireEvent.click(screen.getByText('common.operation.add')) + fireEvent.click(screen.getByText('appDebug.variableConfig.checkbox')) + + expect(onChange).toHaveBeenCalledWith('checkbox') + expect(screen.queryByText('appDebug.variableConfig.checkbox')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx new file mode 100644 index 0000000000..aae00bb2b7 --- /dev/null +++ b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import VarItem from '../var-item' + +describe('VarItem', () => { + it('should render variable metadata and allow editing', () => { + const onEdit = vi.fn() + const onRemove = vi.fn() + const { container } = render( + , + ) + + expect(screen.getByTitle('api_key · API Key')).toBeInTheDocument() + expect(screen.getByText('required')).toBeInTheDocument() + + const editButton = container.querySelector('.mr-1.flex.h-6.w-6') as HTMLElement + fireEvent.click(editButton) + + expect(onEdit).toHaveBeenCalledTimes(1) + }) + + it('should call remove when clicking the delete action', () => { + const onRemove = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('var-item-delete-btn')) + + expect(onRemove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/config.spec.ts b/web/app/components/app/configuration/config-var/config-modal/__tests__/config.spec.ts new file mode 100644 index 0000000000..efa2f793ae --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/config.spec.ts @@ -0,0 +1,23 @@ +import { jsonConfigPlaceHolder } from '../config' + +describe('config modal placeholder config', () => { + it('should contain a valid object schema example', () => { + const parsed = JSON.parse(jsonConfigPlaceHolder) as { + type: string + properties: { + foo: { type: string } + bar: { + type: string + properties: { + sub: { type: string } + } + } + } + } + + expect(parsed.type).toBe('object') + expect(parsed.properties.foo.type).toBe('string') + expect(parsed.properties.bar.type).toBe('object') + expect(parsed.properties.bar.properties.sub.type).toBe('number') + }) +}) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx new file mode 100644 index 0000000000..454e5dd444 --- /dev/null +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/field.spec.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@testing-library/react' +import Field from '../field' + +describe('ConfigModal Field', () => { + it('should render the title and children', () => { + render( + + + , + ) + + expect(screen.getByText('Field title')).toBeInTheDocument() + expect(screen.getByLabelText('field-input')).toBeInTheDocument() + }) + + it('should render the optional hint when requested', () => { + render( + + + , + ) + + expect(screen.getByText(/\(appDebug\.variableConfig\.optional\)/)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx b/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx new file mode 100644 index 0000000000..2cb919b6db --- /dev/null +++ b/web/app/components/app/configuration/config-vision/__tests__/param-config-content.spec.tsx @@ -0,0 +1,74 @@ +import type { FeatureStoreState } from '@/app/components/base/features/store' +import type { FileUpload } from '@/app/components/base/features/types' +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Resolution, TransferMethod } from '@/types/app' +import ParamConfigContent from '../param-config-content' + +const mockUseFeatures = vi.fn() +const mockUseFeaturesStore = vi.fn() +const mockSetFeatures = vi.fn() + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector), + useFeaturesStore: () => mockUseFeaturesStore(), +})) + +const setupFeatureStore = (fileOverrides: Partial = {}) => { + const file: FileUpload = { + enabled: true, + allowed_file_types: [], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + image: { + enabled: true, + detail: Resolution.low, + number_limits: 3, + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }, + ...fileOverrides, + } + const featureStoreState = { + features: { file }, + setFeatures: mockSetFeatures, + showFeaturesModal: false, + setShowFeaturesModal: vi.fn(), + } as unknown as FeatureStoreState + + mockUseFeatures.mockImplementation(selector => selector(featureStoreState)) + mockUseFeaturesStore.mockReturnValue({ + getState: () => featureStoreState, + }) +} + +const getUpdatedFile = () => { + expect(mockSetFeatures).toHaveBeenCalled() + return mockSetFeatures.mock.calls.at(-1)?.[0].file as FileUpload +} + +describe('ParamConfigContent', () => { + beforeEach(() => { + vi.clearAllMocks() + setupFeatureStore() + }) + + it('should update the image resolution', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('appDebug.vision.visionSettings.high')) + + expect(getUpdatedFile().image?.detail).toBe(Resolution.high) + }) + + it('should update upload methods and upload limit', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('appDebug.vision.visionSettings.localUpload')) + expect(getUpdatedFile().allowed_file_upload_methods).toEqual([TransferMethod.local_file]) + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '5' } }) + expect(getUpdatedFile().number_limits).toBe(5) + }) +}) diff --git a/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx b/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx new file mode 100644 index 0000000000..617f14629e --- /dev/null +++ b/web/app/components/app/configuration/config-vision/__tests__/param-config.spec.tsx @@ -0,0 +1,58 @@ +import type { FeatureStoreState } from '@/app/components/base/features/store' +import type { FileUpload } from '@/app/components/base/features/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Resolution, TransferMethod } from '@/types/app' +import ParamConfig from '../param-config' + +const mockUseFeatures = vi.fn() +const mockUseFeaturesStore = vi.fn() + +vi.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeatures(selector), + useFeaturesStore: () => mockUseFeaturesStore(), +})) + +const setupFeatureStore = (fileOverrides: Partial = {}) => { + const file: FileUpload = { + enabled: true, + allowed_file_types: [], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + image: { + enabled: true, + detail: Resolution.low, + number_limits: 3, + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }, + ...fileOverrides, + } + const featureStoreState = { + features: { file }, + setFeatures: vi.fn(), + showFeaturesModal: false, + setShowFeaturesModal: vi.fn(), + } as unknown as FeatureStoreState + mockUseFeatures.mockImplementation(selector => selector(featureStoreState)) + mockUseFeaturesStore.mockReturnValue({ + getState: () => featureStoreState, + }) +} + +describe('ParamConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + setupFeatureStore() + }) + + it('should toggle the settings panel when clicking the trigger', async () => { + const user = userEvent.setup() + render() + + expect(screen.queryByText('appDebug.vision.visionSettings.title')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'appDebug.voice.settings' })) + + expect(await screen.findByText('appDebug.vision.visionSettings.title')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx new file mode 100644 index 0000000000..bc380d35d0 --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/prompt-toast.spec.tsx @@ -0,0 +1,22 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import PromptToast from '../prompt-toast' + +describe('PromptToast', () => { + it('should render the note title and markdown message', () => { + render() + + expect(screen.getByText('appDebug.generate.optimizationNote')).toBeInTheDocument() + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) + + it('should collapse and expand the markdown content', () => { + const { container } = render() + + const toggle = container.querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(toggle) + expect(screen.queryByTestId('markdown-body')).not.toBeInTheDocument() + + fireEvent.click(toggle) + expect(screen.getByTestId('markdown-body')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx new file mode 100644 index 0000000000..cbdbda8480 --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/res-placeholder.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react' +import ResPlaceholder from '../res-placeholder' + +describe('ResPlaceholder', () => { + it('should render the placeholder copy', () => { + render() + + expect(screen.getByText('appDebug.generate.newNoDataLine1')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts b/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts new file mode 100644 index 0000000000..374a75cd7b --- /dev/null +++ b/web/app/components/app/configuration/config/automatic/__tests__/use-gen-data.spec.ts @@ -0,0 +1,39 @@ +import type { GenRes } from '@/service/debug' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import useGenData from '../use-gen-data' + +describe('useGenData', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('should start with an empty version list', () => { + const { result } = renderHook(() => useGenData({ storageKey: 'prompt' })) + + expect(result.current.versions).toEqual([]) + expect(result.current.currentVersionIndex).toBe(0) + expect(result.current.current).toBeUndefined() + }) + + it('should append versions and keep the latest one selected', () => { + const versionOne = { modified: 'first version' } as GenRes + const versionTwo = { modified: 'second version' } as GenRes + const { result } = renderHook(() => useGenData({ storageKey: 'prompt' })) + + act(() => { + result.current.addVersion(versionOne) + }) + + expect(result.current.versions).toEqual([versionOne]) + expect(result.current.current).toEqual(versionOne) + + act(() => { + result.current.addVersion(versionTwo) + }) + + expect(result.current.versions).toEqual([versionOne, versionTwo]) + expect(result.current.currentVersionIndex).toBe(1) + expect(result.current.current).toEqual(versionTwo) + }) +}) diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx new file mode 100644 index 0000000000..5608f4c5a2 --- /dev/null +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/__tests__/context-provider.spec.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react' +import { useDebugWithMultipleModelContext } from '../context' +import { DebugWithMultipleModelContextProvider } from '../context-provider' + +const ContextConsumer = () => { + const value = useDebugWithMultipleModelContext() + return ( +
+
{value.multipleModelConfigs.length}
+ + +
{String(value.checkCanSend?.())}
+
+ ) +} + +describe('DebugWithMultipleModelContextProvider', () => { + it('should expose the provided context value to descendants', () => { + const onMultipleModelConfigsChange = vi.fn() + const onDebugWithMultipleModelChange = vi.fn() + const checkCanSend = vi.fn(() => true) + const multipleModelConfigs = [{ model: 'gpt-4o' }] as unknown as [] + + render( + + + , + ) + + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('true')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx new file mode 100644 index 0000000000..9a818e0fd7 --- /dev/null +++ b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx @@ -0,0 +1,103 @@ +import type { AppDetailResponse } from '@/models/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { AppCardAccessControlSection, AppCardOperations, createAppCardOperations } from '../app-card-sections' + +describe('app-card-sections', () => { + const t = (key: string) => key + + it('should build operations with the expected disabled state', () => { + const onLaunch = vi.fn() + const operations = createAppCardOperations({ + operationKeys: ['launch', 'settings'], + t: t as never, + runningStatus: false, + triggerModeDisabled: false, + onLaunch, + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + expect(operations[0]).toMatchObject({ + key: 'launch', + disabled: true, + label: 'overview.appInfo.launch', + }) + expect(operations[1]).toMatchObject({ + key: 'settings', + disabled: false, + label: 'overview.appInfo.settings.entry', + }) + }) + + it('should render the access-control section and call onClick', () => { + const onClick = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('publishApp.notSet')) + + expect(screen.getByText('accessControlDialog.accessItems.specific')).toBeInTheDocument() + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should render operation buttons and execute enabled actions', () => { + const onLaunch = vi.fn() + const operations = createAppCardOperations({ + operationKeys: ['launch', 'embedded'], + t: t as never, + runningStatus: true, + triggerModeDisabled: false, + onLaunch, + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) + + expect(onLaunch).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i })).toBeInTheDocument() + }) + + it('should keep customize available for web app cards that are not completion or workflow apps', () => { + const operations = createAppCardOperations({ + operationKeys: ['customize'], + t: t as never, + runningStatus: true, + triggerModeDisabled: false, + onLaunch: vi.fn(), + onEmbedded: vi.fn(), + onCustomize: vi.fn(), + onSettings: vi.fn(), + onDevelop: vi.fn(), + }) + + render( + , + ) + + expect(screen.getByText('overview.appInfo.customize.entry')).toBeInTheDocument() + expect(AppModeEnum.CHAT).toBe('chat') + }) +}) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts new file mode 100644 index 0000000000..fbfcdaf955 --- /dev/null +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -0,0 +1,107 @@ +import type { AppDetailResponse } from '@/models/app' +import { BlockEnum } from '@/app/components/workflow/types' +import { AccessMode } from '@/models/access-control' +import { AppModeEnum } from '@/types/app' +import { basePath } from '@/utils/var' +import { getAppCardDisplayState, getAppCardOperationKeys, hasWorkflowStartNode, isAppAccessConfigured } from '../app-card-utils' + +describe('app-card-utils', () => { + const baseAppInfo = { + id: 'app-1', + mode: AppModeEnum.CHAT, + enable_site: true, + enable_api: false, + access_mode: AccessMode.PUBLIC, + api_base_url: 'https://api.example.com', + site: { + app_base_url: 'https://example.com', + access_token: 'token-1', + }, + } as AppDetailResponse + + it('should detect whether the workflow includes a start node', () => { + expect(hasWorkflowStartNode({ + graph: { + nodes: [{ data: { type: BlockEnum.Start } }], + }, + })).toBe(true) + + expect(hasWorkflowStartNode({ + graph: { + nodes: [{ data: { type: BlockEnum.Answer } }], + }, + })).toBe(false) + }) + + it('should build the display state for a published web app', () => { + const state = getAppCardDisplayState({ + appInfo: baseAppInfo, + cardType: 'webapp', + currentWorkflow: null, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceManager: true, + }) + + expect(state.isApp).toBe(true) + expect(state.appMode).toBe(AppModeEnum.CHAT) + expect(state.runningStatus).toBe(true) + expect(state.accessibleUrl).toBe(`https://example.com${basePath}/chat/token-1`) + }) + + it('should disable workflow cards without a graph or start node', () => { + const unpublishedState = getAppCardDisplayState({ + appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW }, + cardType: 'webapp', + currentWorkflow: null, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceManager: true, + }) + expect(unpublishedState.appUnpublished).toBe(true) + expect(unpublishedState.toggleDisabled).toBe(true) + + const missingStartState = getAppCardDisplayState({ + appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW }, + cardType: 'webapp', + currentWorkflow: { + graph: { + nodes: [{ data: { type: BlockEnum.Answer } }], + }, + }, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceManager: true, + }) + expect(missingStartState.missingStartNode).toBe(true) + expect(missingStartState.runningStatus).toBe(false) + }) + + it('should require specific access subjects only for the specific access mode', () => { + expect(isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.PUBLIC }, + { groups: [], members: [] }, + )).toBe(true) + + expect(isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + { groups: [], members: [] }, + )).toBe(false) + + expect(isAppAccessConfigured( + { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, + { groups: [{ id: 'group-1' }], members: [] }, + )).toBe(true) + }) + + it('should derive operation keys for api and webapp cards', () => { + expect(getAppCardOperationKeys({ + cardType: 'api', + appMode: AppModeEnum.COMPLETION, + isCurrentWorkspaceEditor: true, + })).toEqual(['develop']) + + expect(getAppCardOperationKeys({ + cardType: 'webapp', + appMode: AppModeEnum.CHAT, + isCurrentWorkspaceEditor: false, + })).toEqual(['launch', 'embedded', 'customize']) + }) +}) diff --git a/web/app/components/plugins/__tests__/constants.spec.ts b/web/app/components/plugins/__tests__/constants.spec.ts new file mode 100644 index 0000000000..d3ec02c76c --- /dev/null +++ b/web/app/components/plugins/__tests__/constants.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { categoryKeys, tagKeys } from '../constants' +import { PluginCategoryEnum } from '../types' + +describe('plugin constants', () => { + it('exposes the expected plugin tag keys', () => { + expect(tagKeys).toEqual([ + 'agent', + 'rag', + 'search', + 'image', + 'videos', + 'weather', + 'finance', + 'design', + 'travel', + 'social', + 'news', + 'medical', + 'productivity', + 'education', + 'business', + 'entertainment', + 'utilities', + 'other', + ]) + }) + + it('exposes the expected category keys in display order', () => { + expect(categoryKeys).toEqual([ + PluginCategoryEnum.model, + PluginCategoryEnum.tool, + PluginCategoryEnum.datasource, + PluginCategoryEnum.agent, + PluginCategoryEnum.extension, + 'bundle', + PluginCategoryEnum.trigger, + ]) + }) +}) diff --git a/web/app/components/plugins/__tests__/provider-card.spec.tsx b/web/app/components/plugins/__tests__/provider-card.spec.tsx new file mode 100644 index 0000000000..71efd86bb0 --- /dev/null +++ b/web/app/components/plugins/__tests__/provider-card.spec.tsx @@ -0,0 +1,104 @@ +import type { Plugin } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { ThemeProvider } from 'next-themes' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ProviderCard from '../provider-card' +import { PluginCategoryEnum } from '../types' + +vi.mock('@/context/i18n', () => ({ + useLocale: () => 'en-US', +})) + +vi.mock('@/hooks/use-i18n', () => ({ + useRenderI18nObject: () => (value: Record) => value['en-US'] || value.en_US, +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/marketplace/utils', () => ({ + getPluginLinkInMarketplace: (plugin: Plugin, params: Record) => + `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, +})) + +vi.mock('../card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
{src}
, +})) + +vi.mock('../card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('../card/base/download-count', () => ({ + default: ({ downloadCount }: { downloadCount: number }) =>
{downloadCount}
, +})) + +vi.mock('../card/base/title', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +const payload = { + type: 'plugin', + org: 'dify', + name: 'provider-one', + plugin_id: 'provider-one', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'pkg-1', + icon: 'icon.png', + verified: true, + label: { 'en-US': 'Provider One' }, + brief: { 'en-US': 'Provider description' }, + description: { 'en-US': 'Full description' }, + introduction: 'Intro', + repository: 'https://github.com/dify/provider-one', + category: PluginCategoryEnum.tool, + install_count: 123, + endpoint: { settings: [] }, + tags: [{ name: 'search' }, { name: 'rag' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', +} as Plugin + +describe('ProviderCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderProviderCard = () => render( + + + , + ) + + it('renders provider information, tags, and detail link', () => { + renderProviderCard() + + expect(screen.getByTestId('title')).toHaveTextContent('Provider One') + expect(screen.getByText('dify')).toBeInTheDocument() + expect(screen.getByTestId('download-count')).toHaveTextContent('123') + expect(screen.getByTestId('description')).toHaveTextContent('Provider description') + expect(screen.getByText('search')).toBeInTheDocument() + expect(screen.getByText('rag')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /plugin.detailPanel.operation.detail/i })).toHaveAttribute( + 'href', + '/marketplace/dify/provider-one?language=en-US&theme=system', + ) + }) + + it('opens and closes the install modal', () => { + renderProviderCard() + + fireEvent.click(screen.getByRole('button', { name: /plugin.detailPanel.operation.install/i })) + expect(screen.getByTestId('install-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('close-install-modal')) + expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts b/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts new file mode 100644 index 0000000000..c5364ec47f --- /dev/null +++ b/web/app/components/plugins/install-plugin/base/__tests__/use-get-icon.spec.ts @@ -0,0 +1,22 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import useGetIcon from '../use-get-icon' + +vi.mock('@/config', () => ({ + API_PREFIX: 'https://api.example.com', +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { currentWorkspace: { id: string } }) => string | { id: string }) => + selector({ currentWorkspace: { id: 'workspace-123' } }), +})) + +describe('useGetIcon', () => { + it('builds icon url with current workspace id', () => { + const { result } = renderHook(() => useGetIcon()) + + expect(result.current.getIconUrl('plugin-icon.png')).toBe( + 'https://api.example.com/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=plugin-icon.png', + ) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx new file mode 100644 index 0000000000..12cd89765a --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/github-item.spec.tsx @@ -0,0 +1,136 @@ +import type { GitHubItemAndMarketPlaceDependency, Plugin } from '../../../../types' +import type { VersionProps } from '@/app/components/plugins/types' +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import GithubItem from '../github-item' + +const mockUseUploadGitHub = vi.fn() +const mockPluginManifestToCardPluginProps = vi.fn() +const mockLoadedItem = vi.fn() + +vi.mock('@/service/use-plugins', () => ({ + useUploadGitHub: (params: { repo: string, version: string, package: string }) => mockUseUploadGitHub(params), +})) + +vi.mock('../../../utils', () => ({ + pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest), +})) + +vi.mock('../../../base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('../loaded-item', () => ({ + default: (props: Record) => { + mockLoadedItem(props) + return
loaded-item
+ }, +})) + +const dependency: GitHubItemAndMarketPlaceDependency = { + type: 'github', + value: { + repo: 'dify/plugin', + release: 'v1.0.0', + package: 'plugin.zip', + }, +} + +const versionInfo: VersionProps = { + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', +} + +describe('GithubItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading state before payload is ready', () => { + mockUseUploadGitHub.mockReturnValue({ data: null, error: null }) + + render( + , + ) + + expect(screen.getByTestId('loading')).toBeInTheDocument() + expect(mockUseUploadGitHub).toHaveBeenCalledWith({ + repo: 'dify/plugin', + version: 'v1.0.0', + package: 'plugin.zip', + }) + }) + + it('converts fetched manifest and renders LoadedItem', async () => { + const onFetchedPayload = vi.fn() + const payload = { + plugin_id: 'plugin-1', + name: 'Plugin One', + org: 'dify', + icon: 'icon.png', + version: '1.0.0', + } as Plugin + + mockUseUploadGitHub.mockReturnValue({ + data: { + manifest: { name: 'manifest' }, + unique_identifier: 'plugin-1', + }, + error: null, + }) + mockPluginManifestToCardPluginProps.mockReturnValue(payload) + + render( + , + ) + + await waitFor(() => { + expect(onFetchedPayload).toHaveBeenCalledWith(payload) + expect(screen.getByTestId('loaded-item')).toBeInTheDocument() + }) + + expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ + checked: true, + versionInfo, + payload: expect.objectContaining({ + ...payload, + from: 'github', + }), + })) + }) + + it('reports fetch error from upload hook', async () => { + const onFetchError = vi.fn() + mockUseUploadGitHub.mockReturnValue({ data: null, error: new Error('boom') }) + + render( + , + ) + + await waitFor(() => { + expect(onFetchError).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx new file mode 100644 index 0000000000..d19331a4e4 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/loaded-item.spec.tsx @@ -0,0 +1,160 @@ +import type { Plugin } from '../../../../types' +import type { VersionProps } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LoadedItem from '../loaded-item' + +const mockCheckbox = vi.fn() +const mockCard = vi.fn() +const mockVersion = vi.fn() +const mockUsePluginInstallLimit = vi.fn() + +vi.mock('@/config', () => ({ + API_PREFIX: 'https://api.example.com', + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: (props: { checked: boolean, disabled: boolean, onCheck: () => void }) => { + mockCheckbox(props) + return ( + + ) + }, +})) + +vi.mock('../../../../card', () => ({ + default: (props: { titleLeft?: React.ReactNode }) => { + mockCard(props) + return ( +
+ {props.titleLeft} +
+ ) + }, +})) + +vi.mock('../../../base/use-get-icon', () => ({ + default: () => ({ + getIconUrl: (icon: string) => `https://api.example.com/${icon}`, + }), +})) + +vi.mock('../../../base/version', () => ({ + default: (props: Record) => { + mockVersion(props) + return
version
+ }, +})) + +vi.mock('../../../hooks/use-install-plugin-limit', () => ({ + default: (payload: Plugin) => mockUsePluginInstallLimit(payload), +})) + +const payload = { + plugin_id: 'plugin-1', + org: 'dify', + name: 'Loaded Plugin', + icon: 'icon.png', + version: '1.0.0', +} as Plugin + +const versionInfo: VersionProps = { + hasInstalled: false, + installedVersion: '', + toInstallVersion: '0.9.0', +} + +describe('LoadedItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUsePluginInstallLimit.mockReturnValue({ canInstall: true }) + }) + + it('uses local icon url and forwards version title for non-marketplace plugins', () => { + render( + , + ) + + expect(screen.getByTestId('card')).toBeInTheDocument() + expect(mockUsePluginInstallLimit).toHaveBeenCalledWith(payload) + expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ + limitedInstall: false, + payload: expect.objectContaining({ + ...payload, + icon: 'https://api.example.com/icon.png', + }), + titleLeft: expect.anything(), + })) + expect(mockVersion).toHaveBeenCalledWith(expect.objectContaining({ + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', + })) + }) + + it('uses marketplace icon url and disables checkbox when install limit is reached', () => { + mockUsePluginInstallLimit.mockReturnValue({ canInstall: false }) + + render( + , + ) + + expect(screen.getByTestId('checkbox')).toBeDisabled() + expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ + limitedInstall: true, + payload: expect.objectContaining({ + icon: 'https://marketplace.example.com/plugins/dify/Loaded Plugin/icon', + }), + })) + }) + + it('calls onCheckedChange with payload when checkbox is toggled', () => { + const onCheckedChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('checkbox')) + + expect(onCheckedChange).toHaveBeenCalledWith(payload) + }) + + it('omits version badge when payload has no version', () => { + render( + , + ) + + expect(mockCard).toHaveBeenCalledWith(expect.objectContaining({ + titleLeft: null, + })) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx new file mode 100644 index 0000000000..b6c1763ac5 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/marketplace-item.spec.tsx @@ -0,0 +1,69 @@ +import type { Plugin } from '../../../../types' +import type { VersionProps } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import MarketPlaceItem from '../marketplace-item' + +const mockLoadedItem = vi.fn() + +vi.mock('../../../base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('../loaded-item', () => ({ + default: (props: Record) => { + mockLoadedItem(props) + return
loaded-item
+ }, +})) + +const payload = { + plugin_id: 'plugin-1', + org: 'dify', + name: 'Marketplace Plugin', + icon: 'icon.png', +} as Plugin + +const versionInfo: VersionProps = { + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', +} + +describe('MarketPlaceItem', () => { + it('renders loading when payload is absent', () => { + render( + , + ) + + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('renders LoadedItem with marketplace payload and version', () => { + render( + , + ) + + expect(screen.getByTestId('loaded-item')).toBeInTheDocument() + expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ + checked: true, + isFromMarketPlace: true, + versionInfo, + payload: expect.objectContaining({ + ...payload, + version: '2.0.0', + }), + })) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx new file mode 100644 index 0000000000..e92faeb77f --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/item/__tests__/package-item.spec.tsx @@ -0,0 +1,124 @@ +import type { PackageDependency } from '../../../../types' +import type { VersionProps } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '../../../../types' +import PackageItem from '../package-item' + +const mockPluginManifestToCardPluginProps = vi.fn() +const mockLoadedItem = vi.fn() + +vi.mock('../../../utils', () => ({ + pluginManifestToCardPluginProps: (manifest: unknown) => mockPluginManifestToCardPluginProps(manifest), +})) + +vi.mock('../../../base/loading-error', () => ({ + default: () =>
loading-error
, +})) + +vi.mock('../loaded-item', () => ({ + default: (props: Record) => { + mockLoadedItem(props) + return
loaded-item
+ }, +})) + +const versionInfo: VersionProps = { + hasInstalled: false, + installedVersion: '', + toInstallVersion: '1.0.0', +} + +const payload = { + type: 'package', + value: { + manifest: { + plugin_unique_identifier: 'plugin-1', + version: '1.0.0', + author: 'dify', + icon: 'icon.png', + name: 'Package Plugin', + category: PluginCategoryEnum.tool, + label: { en_US: 'Package Plugin', zh_Hans: 'Package Plugin' }, + description: { en_US: 'Description', zh_Hans: 'Description' }, + created_at: '2024-01-01', + resource: {}, + plugins: [], + verified: true, + endpoint: { settings: [], endpoints: [] }, + model: null, + tags: [], + agent_strategy: null, + meta: { version: '1.0.0' }, + trigger: { + events: [], + identity: { + author: 'dify', + name: 'trigger', + description: { en_US: 'Trigger', zh_Hans: 'Trigger' }, + icon: 'icon.png', + label: { en_US: 'Trigger', zh_Hans: 'Trigger' }, + tags: [], + }, + subscription_constructor: { + credentials_schema: [], + oauth_schema: { + client_schema: [], + credentials_schema: [], + }, + parameters: [], + }, + subscription_schema: [], + }, + }, + }, +} as unknown as PackageDependency + +describe('PackageItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading error when manifest is missing', () => { + render( + , + ) + + expect(screen.getByTestId('loading-error')).toBeInTheDocument() + }) + + it('renders LoadedItem with converted plugin payload', () => { + mockPluginManifestToCardPluginProps.mockReturnValue({ + plugin_id: 'plugin-1', + name: 'Package Plugin', + org: 'dify', + icon: 'icon.png', + }) + + render( + , + ) + + expect(screen.getByTestId('loaded-item')).toBeInTheDocument() + expect(mockLoadedItem).toHaveBeenCalledWith(expect.objectContaining({ + checked: true, + isFromMarketPlace: true, + versionInfo, + payload: expect.objectContaining({ + plugin_id: 'plugin-1', + from: 'package', + }), + })) + }) +}) diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx new file mode 100644 index 0000000000..9ae67b7d16 --- /dev/null +++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/installed.spec.tsx @@ -0,0 +1,114 @@ +import type { InstallStatus, Plugin } from '../../../../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Installed from '../installed' + +const mockCard = vi.fn() + +vi.mock('@/config', () => ({ + API_PREFIX: 'https://api.example.com', + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: (props: { titleLeft?: React.ReactNode }) => { + mockCard(props) + return ( +
+ {props.titleLeft} +
+ ) + }, +})) + +vi.mock('../../../base/use-get-icon', () => ({ + default: () => ({ + getIconUrl: (icon: string) => `https://api.example.com/${icon}`, + }), +})) + +const plugins = [ + { + plugin_id: 'plugin-1', + org: 'dify', + name: 'Plugin One', + icon: 'icon-1.png', + version: '1.0.0', + }, + { + plugin_id: 'plugin-2', + org: 'dify', + name: 'Plugin Two', + icon: 'icon-2.png', + version: '2.0.0', + }, +] as Plugin[] + +const installStatus: InstallStatus[] = [ + { success: true, isFromMarketPlace: true }, + { success: false, isFromMarketPlace: false }, +] + +describe('Installed', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders plugin cards with install status and marketplace icon handling', () => { + render( + , + ) + + expect(screen.getAllByTestId('card')).toHaveLength(2) + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() + expect(screen.getByText('1.0.0')).toBeInTheDocument() + expect(screen.getByText('2.0.0')).toBeInTheDocument() + expect(mockCard).toHaveBeenNthCalledWith(1, expect.objectContaining({ + installed: true, + installFailed: false, + payload: expect.objectContaining({ + icon: 'https://marketplace.example.com/plugins/dify/Plugin One/icon', + }), + })) + expect(mockCard).toHaveBeenNthCalledWith(2, expect.objectContaining({ + installed: false, + installFailed: true, + payload: expect.objectContaining({ + icon: 'https://api.example.com/icon-2.png', + }), + })) + }) + + it('calls onCancel when close button is clicked', () => { + const onCancel = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('hides action button when isHideButton is true', () => { + render( + , + ) + + expect(screen.queryByRole('button', { name: 'common.operation.close' })).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/constants.spec.ts b/web/app/components/plugins/marketplace/__tests__/constants.spec.ts new file mode 100644 index 0000000000..cb3c822993 --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/constants.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { PluginCategoryEnum } from '../../types' +import { + DEFAULT_SORT, + PLUGIN_CATEGORY_WITH_COLLECTIONS, + PLUGIN_TYPE_SEARCH_MAP, + SCROLL_BOTTOM_THRESHOLD, +} from '../constants' + +describe('marketplace constants', () => { + it('defines the expected default sort', () => { + expect(DEFAULT_SORT).toEqual({ + sortBy: 'install_count', + sortOrder: 'DESC', + }) + }) + + it('defines the expected plugin search type map', () => { + expect(PLUGIN_TYPE_SEARCH_MAP).toEqual({ + all: 'all', + model: PluginCategoryEnum.model, + tool: PluginCategoryEnum.tool, + agent: PluginCategoryEnum.agent, + extension: PluginCategoryEnum.extension, + datasource: PluginCategoryEnum.datasource, + trigger: PluginCategoryEnum.trigger, + bundle: 'bundle', + }) + expect(SCROLL_BOTTOM_THRESHOLD).toBe(100) + }) + + it('tracks only collection-backed categories', () => { + expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.all)).toBe(true) + expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(true) + expect(PLUGIN_CATEGORY_WITH_COLLECTIONS.has(PLUGIN_TYPE_SEARCH_MAP.model)).toBe(false) + }) +}) diff --git a/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts new file mode 100644 index 0000000000..c13a4528fb --- /dev/null +++ b/web/app/components/plugins/marketplace/__tests__/search-params.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { PLUGIN_TYPE_SEARCH_MAP } from '../constants' +import { marketplaceSearchParamsParsers } from '../search-params' + +describe('marketplace search params', () => { + it('applies the expected default values', () => { + expect(marketplaceSearchParamsParsers.category.parseServerSide(undefined)).toBe(PLUGIN_TYPE_SEARCH_MAP.all) + expect(marketplaceSearchParamsParsers.q.parseServerSide(undefined)).toBe('') + expect(marketplaceSearchParamsParsers.tags.parseServerSide(undefined)).toEqual([]) + }) + + it('parses supported query values with the configured parsers', () => { + expect(marketplaceSearchParamsParsers.category.parseServerSide(PLUGIN_TYPE_SEARCH_MAP.tool)).toBe(PLUGIN_TYPE_SEARCH_MAP.tool) + expect(marketplaceSearchParamsParsers.category.parseServerSide('unsupported')).toBe(PLUGIN_TYPE_SEARCH_MAP.all) + expect(marketplaceSearchParamsParsers.q.parseServerSide('keyword')).toBe('keyword') + expect(marketplaceSearchParamsParsers.tags.parseServerSide('rag,search')).toEqual(['rag', 'search']) + }) +}) diff --git a/web/app/components/plugins/marketplace/empty/__tests__/line.spec.tsx b/web/app/components/plugins/marketplace/empty/__tests__/line.spec.tsx new file mode 100644 index 0000000000..56e7046dae --- /dev/null +++ b/web/app/components/plugins/marketplace/empty/__tests__/line.spec.tsx @@ -0,0 +1,30 @@ +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Line from '../line' + +const mockUseTheme = vi.fn() + +vi.mock('@/hooks/use-theme', () => ({ + default: () => mockUseTheme(), +})) + +describe('Line', () => { + it('renders dark mode svg variant', () => { + mockUseTheme.mockReturnValue({ theme: 'dark' }) + const { container } = render() + const svg = container.querySelector('svg') + + expect(svg).toHaveAttribute('height', '240') + expect(svg).toHaveAttribute('viewBox', '0 0 2 240') + expect(svg).toHaveClass('divider') + }) + + it('renders light mode svg variant', () => { + mockUseTheme.mockReturnValue({ theme: 'light' }) + const { container } = render() + const svg = container.querySelector('svg') + + expect(svg).toHaveAttribute('height', '241') + expect(svg).toHaveAttribute('viewBox', '0 0 2 241') + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx new file mode 100644 index 0000000000..f1e263b6f6 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx @@ -0,0 +1,115 @@ +import type { ComponentProps } from 'react' +import type { Plugin } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { ThemeProvider } from 'next-themes' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum } from '@/app/components/plugins/types' +import CardWrapper from '../card-wrapper' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), + useLocale: () => 'en-US', +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + getTagLabel: (name: string) => `tag:${name}`, + }), +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ payload, footer }: { payload: Plugin, footer?: React.ReactNode }) => ( +
+ {payload.name} + {footer} +
+ ), +})) + +vi.mock('@/app/components/plugins/card/card-more-info', () => ({ + default: ({ downloadCount, tags }: { downloadCount: number, tags: string[] }) => ( +
+ {downloadCount} + : + {tags.join('|')} +
+ ), +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('../../utils', () => ({ + getPluginDetailLinkInMarketplace: (plugin: Plugin) => `/detail/${plugin.org}/${plugin.name}`, + getPluginLinkInMarketplace: (plugin: Plugin, params: Record) => `/marketplace/${plugin.org}/${plugin.name}?language=${params.language}&theme=${params.theme}`, +})) + +const plugin = { + type: 'plugin', + org: 'dify', + name: 'plugin-a', + plugin_id: 'plugin-a', + version: '1.0.0', + latest_version: '1.0.0', + latest_package_identifier: 'pkg', + icon: 'icon.png', + verified: true, + label: { 'en-US': 'Plugin A' }, + brief: { 'en-US': 'Brief' }, + description: { 'en-US': 'Description' }, + introduction: 'Intro', + repository: 'https://github.com/dify/plugin-a', + category: PluginCategoryEnum.tool, + install_count: 42, + endpoint: { settings: [] }, + tags: [{ name: 'search' }, { name: 'agent' }], + badges: [], + verification: { authorized_category: 'community' }, + from: 'marketplace', +} as Plugin + +describe('CardWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderCardWrapper = (props: Partial> = {}) => render( + + + , + ) + + it('renders plugin detail link when install button is hidden', () => { + renderCardWrapper() + + expect(screen.getByRole('link')).toHaveAttribute('href', '/detail/dify/plugin-a') + expect(screen.getByTestId('card-more-info')).toHaveTextContent('42:tag:search|tag:agent') + }) + + it('renders install and marketplace detail actions when install button is shown', () => { + renderCardWrapper({ showInstallButton: true }) + + expect(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'plugin.detailPanel.operation.detail' })).toHaveAttribute( + 'href', + '/marketplace/dify/plugin-a?language=en-US&theme=system', + ) + }) + + it('opens and closes install modal from install action', () => { + renderCardWrapper({ showInstallButton: true }) + + fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.install' })) + expect(screen.getByTestId('install-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('close-install-modal')) + expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx new file mode 100644 index 0000000000..cbaf7868a0 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx @@ -0,0 +1,102 @@ +import type { MarketplaceCollection } from '../../types' +import type { Plugin } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ListWithCollection from '../list-with-collection' + +const mockMoreClick = vi.fn() + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), + useLocale: () => 'en-US', +})) + +vi.mock('../../atoms', () => ({ + useMarketplaceMoreClick: () => mockMoreClick, +})) + +vi.mock('@/i18n-config/language', () => ({ + getLanguage: (locale: string) => locale, +})) + +vi.mock('../card-wrapper', () => ({ + default: ({ plugin }: { plugin: Plugin }) =>
{plugin.name}
, +})) + +const collections: MarketplaceCollection[] = [ + { + name: 'featured', + label: { 'en-US': 'Featured' }, + description: { 'en-US': 'Featured plugins' }, + rule: 'featured', + created_at: '', + updated_at: '', + searchable: true, + search_params: { query: 'featured' }, + }, + { + name: 'empty', + label: { 'en-US': 'Empty' }, + description: { 'en-US': 'No plugins' }, + rule: 'empty', + created_at: '', + updated_at: '', + searchable: false, + search_params: {}, + }, +] + +const pluginsMap: Record = { + featured: [ + { plugin_id: 'p1', name: 'Plugin One' }, + { plugin_id: 'p2', name: 'Plugin Two' }, + ] as Plugin[], + empty: [], +} + +describe('ListWithCollection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders only collections that contain plugins', () => { + render( + , + ) + + expect(screen.getByText('Featured')).toBeInTheDocument() + expect(screen.queryByText('Empty')).not.toBeInTheDocument() + expect(screen.getAllByTestId('card-wrapper')).toHaveLength(2) + }) + + it('calls more handler for searchable collection', () => { + render( + , + ) + + fireEvent.click(screen.getByText('plugin.marketplace.viewMore')) + + expect(mockMoreClick).toHaveBeenCalledWith({ query: 'featured' }) + }) + + it('uses custom card renderer when provided', () => { + render( +
{plugin.name}
} + />, + ) + + expect(screen.getAllByTestId('custom-card')).toHaveLength(2) + expect(screen.queryByTestId('card-wrapper')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx new file mode 100644 index 0000000000..fecfea3007 --- /dev/null +++ b/web/app/components/plugins/marketplace/list/__tests__/list-wrapper.spec.tsx @@ -0,0 +1,92 @@ +import type { MarketplaceCollection } from '../../types' +import type { Plugin } from '@/app/components/plugins/types' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ListWrapper from '../list-wrapper' + +const mockMarketplaceData = vi.hoisted(() => ({ + plugins: undefined as Plugin[] | undefined, + pluginsTotal: 0, + marketplaceCollections: [] as MarketplaceCollection[], + marketplaceCollectionPluginsMap: {} as Record, + isLoading: false, + isFetchingNextPage: false, + page: 1, +})) + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, num?: number }) => + key === 'marketplace.pluginsResult' && options?.ns === 'plugin' + ? `${options.num} plugins found` + : options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('../../state', () => ({ + useMarketplaceData: () => mockMarketplaceData, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: ({ className }: { className?: string }) =>
loading
, +})) + +vi.mock('../../sort-dropdown', () => ({ + default: () =>
sort
, +})) + +vi.mock('../index', () => ({ + default: ({ plugins }: { plugins?: Plugin[] }) =>
{plugins?.length ?? 'collections'}
, +})) + +describe('ListWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMarketplaceData.plugins = undefined + mockMarketplaceData.pluginsTotal = 0 + mockMarketplaceData.marketplaceCollections = [] + mockMarketplaceData.marketplaceCollectionPluginsMap = {} + mockMarketplaceData.isLoading = false + mockMarketplaceData.isFetchingNextPage = false + mockMarketplaceData.page = 1 + }) + + it('shows result header and sort dropdown when plugins are loaded', () => { + mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] + mockMarketplaceData.pluginsTotal = 1 + + render() + + expect(screen.getByText('1 plugins found')).toBeInTheDocument() + expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument() + }) + + it('shows centered loading only on initial loading page', () => { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 1 + + render() + + expect(screen.getByTestId('loading')).toBeInTheDocument() + expect(screen.queryByTestId('list')).not.toBeInTheDocument() + }) + + it('renders list when loading additional pages', () => { + mockMarketplaceData.isLoading = true + mockMarketplaceData.page = 2 + mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] + + render() + + expect(screen.getByTestId('list')).toBeInTheDocument() + }) + + it('shows bottom loading indicator while fetching next page', () => { + mockMarketplaceData.plugins = [{ plugin_id: 'p1', name: 'Plugin One' } as Plugin] + mockMarketplaceData.isFetchingNextPage = true + + render() + + expect(screen.getAllByTestId('loading')).toHaveLength(1) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx new file mode 100644 index 0000000000..4a3b880c27 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/__tests__/search-box-wrapper.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import SearchBoxWrapper from '../search-box-wrapper' + +const mockHandleSearchPluginTextChange = vi.fn() +const mockHandleFilterPluginTagsChange = vi.fn() +const mockSearchBox = vi.fn() + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('../../atoms', () => ({ + useSearchPluginText: () => ['plugin search', mockHandleSearchPluginTextChange], + useFilterPluginTags: () => [['agent', 'rag'], mockHandleFilterPluginTagsChange], +})) + +vi.mock('../index', () => ({ + default: (props: Record) => { + mockSearchBox(props) + return
search-box
+ }, +})) + +describe('SearchBoxWrapper', () => { + it('passes marketplace search state into SearchBox', () => { + render() + + expect(screen.getByTestId('search-box')).toBeInTheDocument() + expect(mockSearchBox).toHaveBeenCalledWith(expect.objectContaining({ + wrapperClassName: 'z-11 mx-auto w-[640px] shrink-0', + inputClassName: 'w-full', + search: 'plugin search', + onSearchChange: mockHandleSearchPluginTextChange, + tags: ['agent', 'rag'], + onTagsChange: mockHandleFilterPluginTagsChange, + placeholder: 'plugin.searchPlugins', + usedInMarketplace: true, + })) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx new file mode 100644 index 0000000000..bb5d8e734c --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TagsFilter from '../tags-filter' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + ], + tagsMap: { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, + }, + }), +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ checked }: { checked: boolean }) => {String(checked)}, +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ + value, + onChange, + placeholder, + }: { + value: string + onChange: (event: { target: { value: string } }) => void + placeholder: string + }) => ( + onChange({ target: { value: event.target.value } })} + /> + ), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await import('react') + return { + PortalToFollowElem: ({ children }: { children: React.ReactNode }) =>
{children}
, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => , + PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + } +}) + +vi.mock('../trigger/marketplace', () => ({ + default: ({ selectedTagsLength }: { selectedTagsLength: number }) => ( +
+ marketplace: + {selectedTagsLength} +
+ ), +})) + +vi.mock('../trigger/tool-selector', () => ({ + default: ({ selectedTagsLength }: { selectedTagsLength: number }) => ( +
+ tool: + {selectedTagsLength} +
+ ), +})) + +describe('TagsFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders marketplace trigger when used in marketplace', () => { + render() + + expect(screen.getByTestId('marketplace-trigger')).toHaveTextContent('marketplace:1') + expect(screen.queryByTestId('tool-trigger')).not.toBeInTheDocument() + }) + + it('renders tool selector trigger when used outside marketplace', () => { + render() + + expect(screen.getByTestId('tool-trigger')).toHaveTextContent('tool:1') + expect(screen.queryByTestId('marketplace-trigger')).not.toBeInTheDocument() + }) + + it('filters tag options by search text', () => { + render() + + expect(screen.getByText('Agent')).toBeInTheDocument() + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.getByText('Search')).toBeInTheDocument() + + fireEvent.change(screen.getByLabelText('tags-search'), { target: { value: 'ra' } }) + + expect(screen.queryByText('Agent')).not.toBeInTheDocument() + expect(screen.getByText('RAG')).toBeInTheDocument() + expect(screen.queryByText('Search')).not.toBeInTheDocument() + }) + + it('adds and removes selected tags when options are clicked', () => { + const onTagsChange = vi.fn() + const { rerender } = render() + + fireEvent.click(screen.getByText('Agent')) + expect(onTagsChange).toHaveBeenCalledWith([]) + + rerender() + fireEvent.click(screen.getByText('RAG')) + expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag']) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx new file mode 100644 index 0000000000..4d1a11ac00 --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/marketplace.spec.tsx @@ -0,0 +1,67 @@ +import type { Tag } from '../../../../hooks' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import MarketplaceTrigger from '../marketplace' + +vi.mock('#i18n', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + }), +})) + +const tagsMap: Record = { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, +} + +describe('MarketplaceTrigger', () => { + it('shows all-tags text when no tags are selected', () => { + const { container } = render( + , + ) + + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() + expect(container.querySelectorAll('svg').length).toBeGreaterThan(0) + expect(container.querySelectorAll('svg').length).toBe(2) + }) + + it('shows selected tag labels and overflow count', () => { + render( + , + ) + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('clears selected tags when clear icon is clicked', () => { + const onTagsChange = vi.fn() + + const { container } = render( + , + ) + + fireEvent.click(container.querySelectorAll('svg')[1]!) + + expect(onTagsChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx new file mode 100644 index 0000000000..7e9069d61f --- /dev/null +++ b/web/app/components/plugins/marketplace/search-box/trigger/__tests__/tool-selector.spec.tsx @@ -0,0 +1,61 @@ +import type { Tag } from '../../../../hooks' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ToolSelectorTrigger from '../tool-selector' + +const tagsMap: Record = { + agent: { name: 'agent', label: 'Agent' }, + rag: { name: 'rag', label: 'RAG' }, + search: { name: 'search', label: 'Search' }, +} + +describe('ToolSelectorTrigger', () => { + it('renders only icon when no tags are selected', () => { + const { container } = render( + , + ) + + expect(container.querySelectorAll('svg')).toHaveLength(1) + expect(screen.queryByText('Agent')).not.toBeInTheDocument() + }) + + it('renders selected tag labels and overflow count', () => { + const { container } = render( + , + ) + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + expect(container.querySelectorAll('svg')).toHaveLength(2) + }) + + it('clears selected tags when clear icon is clicked', () => { + const onTagsChange = vi.fn() + + const { container } = render( + , + ) + + fireEvent.click(container.querySelectorAll('svg')[1]!) + + expect(onTagsChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx new file mode 100644 index 0000000000..f3dcfeab5d --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-form.spec.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InputVarType } from '@/app/components/workflow/types' +import AppInputsForm from '../app-inputs-form' + +vi.mock('@/app/components/base/file-uploader', () => ({ + FileUploaderInAttachmentWrapper: ({ + onChange, + }: { + onChange: (files: Array>) => void + }) => ( + + ), +})) + +vi.mock('@/app/components/base/select', () => ({ + PortalSelect: ({ + items, + onSelect, + }: { + items: Array<{ value: string, name: string }> + onSelect: (item: { value: string }) => void + }) => ( +
+ {items.map(item => ( + + ))} +
+ ), +})) + +describe('AppInputsForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should update text input values', () => { + const onFormChange = vi.fn() + const inputsRef = { current: { question: '' } } + + render( + , + ) + + fireEvent.change(screen.getByPlaceholderText('Question'), { + target: { value: 'hello' }, + }) + + expect(onFormChange).toHaveBeenCalledWith({ question: 'hello' }) + }) + + it('should update select values', () => { + const onFormChange = vi.fn() + const inputsRef = { current: { tone: '' } } + + render( + , + ) + + fireEvent.click(screen.getByTestId('select-formal')) + + expect(onFormChange).toHaveBeenCalledWith({ tone: 'formal' }) + }) + + it('should update uploaded single file values', () => { + const onFormChange = vi.fn() + const inputsRef = { current: { attachment: null } } + + render( + , + ) + + fireEvent.click(screen.getByTestId('file-uploader')) + + expect(onFormChange).toHaveBeenCalledWith({ + attachment: { id: 'file-1', name: 'demo.png' }, + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx new file mode 100644 index 0000000000..3e1c2a5a2a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-inputs-panel.spec.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppInputsPanel from '../app-inputs-panel' + +let mockHookResult = { + inputFormSchema: [] as Array>, + isLoading: false, +} + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
Loading
, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-form', () => ({ + default: ({ + onFormChange, + }: { + onFormChange: (value: Record) => void + }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema', () => ({ + useAppInputsFormSchema: () => mockHookResult, +})) + +describe('AppInputsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHookResult = { + inputFormSchema: [], + isLoading: false, + } + }) + + it('should render a loading state', () => { + mockHookResult = { + inputFormSchema: [], + isLoading: true, + } + + render( + , + ) + + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('should render an empty state when no inputs are available', () => { + render( + , + ) + + expect(screen.getByText('app.appSelector.noParams')).toBeInTheDocument() + }) + + it('should render the inputs form and propagate changes', () => { + const onFormChange = vi.fn() + mockHookResult = { + inputFormSchema: [{ variable: 'topic' }], + isLoading: false, + } + + render( + , + ) + + fireEvent.click(screen.getByTestId('app-inputs-form')) + + expect(onFormChange).toHaveBeenCalledWith({ topic: 'updated' }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx new file mode 100644 index 0000000000..a319d2f8c4 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx @@ -0,0 +1,179 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { AppModeEnum } from '@/types/app' +import AppPicker from '../app-picker' + +class MockIntersectionObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +class MockMutationObserver { + observe = vi.fn() + disconnect = vi.fn() + takeRecords = vi.fn().mockReturnValue([]) +} + +beforeAll(() => { + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) + vi.stubGlobal('MutationObserver', MockMutationObserver) +}) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/input', () => ({ + default: ({ + value, + onChange, + onClear, + }: { + value: string + onChange: (e: { target: { value: string } }) => void + onClear?: () => void + }) => ( +
+ onChange({ target: { value: e.target.value } })} + /> + +
+ ), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ + children, + open, + }: { + children: ReactNode + open: boolean + }) => ( +
+ {children} +
+ ), + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: ReactNode + onClick?: () => void + }) => ( + + ), + PortalToFollowElemContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +const apps = [ + { + id: 'app-1', + name: 'Chat App', + mode: AppModeEnum.CHAT, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#fff', + }, + { + id: 'app-2', + name: 'Workflow App', + mode: AppModeEnum.WORKFLOW, + icon_type: 'emoji', + icon: '⚙️', + icon_background: '#fff', + }, +] + +describe('AppPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should open when the trigger is clicked', () => { + const onShowChange = vi.fn() + + render( + Trigger} + isShow={false} + onShowChange={onShowChange} + onSelect={vi.fn()} + apps={apps as never} + isLoading={false} + hasMore={false} + onLoadMore={vi.fn()} + searchText="" + onSearchChange={vi.fn()} + />, + ) + + fireEvent.click(screen.getByTestId('picker-trigger')) + + expect(onShowChange).toHaveBeenCalledWith(true) + }) + + it('should render apps, select one, and handle search changes', () => { + const onSelect = vi.fn() + const onSearchChange = vi.fn() + + render( + Trigger} + isShow + onShowChange={vi.fn()} + onSelect={onSelect} + apps={apps as never} + isLoading={false} + hasMore={false} + onLoadMore={vi.fn()} + searchText="chat" + onSearchChange={onSearchChange} + />, + ) + + fireEvent.change(screen.getByTestId('search-input'), { + target: { value: 'workflow' }, + }) + fireEvent.click(screen.getByText('Workflow App')) + fireEvent.click(screen.getByTestId('clear-input')) + + expect(onSearchChange).toHaveBeenCalledWith('workflow') + expect(onSearchChange).toHaveBeenCalledWith('') + expect(onSelect).toHaveBeenCalledWith(apps[1]) + expect(screen.getByText('chat')).toBeInTheDocument() + }) + + it('should render loading text when loading more apps', () => { + render( + Trigger} + isShow + onShowChange={vi.fn()} + onSelect={vi.fn()} + apps={apps as never} + isLoading + hasMore + onLoadMore={vi.fn()} + searchText="" + onSearchChange={vi.fn()} + />, + ) + + expect(screen.getByText('common.loading')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts new file mode 100644 index 0000000000..d6a5b03236 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/__tests__/use-app-inputs-form-schema.spec.ts @@ -0,0 +1,141 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockEnum, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { AppModeEnum, Resolution } from '@/types/app' +import { useAppInputsFormSchema } from '../use-app-inputs-form-schema' + +let mockAppDetailData: Record | null = null +let mockAppWorkflowData: Record | null = null + +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: () => ({ + data: { + file_size_limit: 15, + image_file_size_limit: 10, + }, + }), +})) + +vi.mock('@/service/use-apps', () => ({ + useAppDetail: () => ({ + data: mockAppDetailData, + isFetching: false, + }), +})) + +vi.mock('@/service/use-workflow', () => ({ + useAppWorkflow: () => ({ + data: mockAppWorkflowData, + isFetching: false, + }), +})) + +describe('useAppInputsFormSchema', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAppDetailData = null + mockAppWorkflowData = null + }) + + it('should build basic app schemas and append image upload support', () => { + mockAppDetailData = { + id: 'app-1', + mode: AppModeEnum.COMPLETION, + model_config: { + user_input_form: [ + { + 'text-input': { + label: 'Question', + variable: 'question', + }, + }, + ], + file_upload: { + enabled: true, + image: { + enabled: true, + detail: Resolution.high, + number_limits: 2, + transfer_methods: ['local_file'], + }, + allowed_file_types: [SupportUploadFileTypes.image], + allowed_file_extensions: ['.png'], + allowed_file_upload_methods: ['local_file'], + number_limits: 2, + }, + }, + } + + const { result } = renderHook(() => useAppInputsFormSchema({ + appDetail: { + id: 'app-1', + mode: AppModeEnum.COMPLETION, + } as never, + })) + + expect(result.current.isLoading).toBe(false) + expect(result.current.inputFormSchema).toEqual(expect.arrayContaining([ + expect.objectContaining({ + variable: 'question', + type: 'text-input', + }), + expect.objectContaining({ + variable: '#image#', + type: InputVarType.singleFile, + allowed_file_extensions: ['.png'], + }), + ])) + }) + + it('should build workflow schemas from start node variables', () => { + mockAppDetailData = { + id: 'app-2', + mode: AppModeEnum.WORKFLOW, + } + mockAppWorkflowData = { + graph: { + nodes: [ + { + data: { + type: BlockEnum.Start, + variables: [ + { + label: 'Attachments', + variable: 'attachments', + type: InputVarType.multiFiles, + }, + ], + }, + }, + ], + }, + features: {}, + } + + const { result } = renderHook(() => useAppInputsFormSchema({ + appDetail: { + id: 'app-2', + mode: AppModeEnum.WORKFLOW, + } as never, + })) + + expect(result.current.inputFormSchema).toEqual([ + expect.objectContaining({ + variable: 'attachments', + type: InputVarType.multiFiles, + fileUploadConfig: expect.any(Object), + }), + ]) + }) + + it('should return an empty schema when app detail is unavailable', () => { + const { result } = renderHook(() => useAppInputsFormSchema({ + appDetail: { + id: 'missing-app', + mode: AppModeEnum.CHAT, + } as never, + })) + + expect(result.current.inputFormSchema).toEqual([]) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx new file mode 100644 index 0000000000..27ef4e7eb3 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx @@ -0,0 +1,251 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' +import DetailHeader from '../index' + +const mockSetTargetVersion = vi.fn() +const mockSetVersionPickerOpen = vi.fn() +const mockHandleUpdate = vi.fn() +const mockHandleUpdatedFromMarketplace = vi.fn() +const mockHandleDelete = vi.fn() + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + userProfile: { timezone: 'UTC' }, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', + useLocale: () => 'en-US', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: 'light' }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllToolProviders: () => ({ + data: [{ + name: 'tool-plugin/provider-a', + type: 'builtin', + allow_delete: true, + }], + }), +})) + +vi.mock('@/utils/var', () => ({ + getMarketplaceUrl: (path: string) => `https://marketplace.example.com${path}`, +})) + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ onClick, children }: { onClick?: () => void, children: React.ReactNode }) => ( + + ), +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/badge', () => ({ + default: ({ text, children }: { text?: React.ReactNode, children?: React.ReactNode }) => ( +
{text ?? children}
+ ), +})) + +vi.mock('@/app/components/base/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/plugins/plugin-auth', () => ({ + AuthCategory: { + tool: 'tool', + }, + PluginAuth: ({ pluginPayload }: { pluginPayload: { provider: string } }) => ( +
{pluginPayload.provider}
+ ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/operation-dropdown', () => ({ + default: ({ detailUrl }: { detailUrl: string }) =>
{detailUrl}
, +})) + +vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({ + default: ({ onSelect, trigger }: { + onSelect: (value: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void + trigger: React.ReactNode + }) => ( +
+ {trigger} + +
+ ), +})) + +vi.mock('@/app/components/base/badges/verified', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/deprecation-notice', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
{src}
, +})) + +vi.mock('@/app/components/plugins/card/base/description', () => ({ + default: ({ text }: { text: string }) =>
{text}
, +})) + +vi.mock('@/app/components/plugins/card/base/org-info', () => ({ + default: ({ orgName }: { orgName: string }) =>
{orgName}
, +})) + +vi.mock('@/app/components/plugins/card/base/title', () => ({ + default: ({ title }: { title: string }) =>
{title}
, +})) + +vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ + default: () => ({ + referenceSetting: { + auto_upgrade: { + upgrade_time_of_day: 0, + }, + }, + }), +})) + +vi.mock('@/app/components/plugins/reference-setting-modal/auto-update-setting/utils', () => ({ + convertUTCDaySecondsToLocalSeconds: (value: number) => value, + timeOfDayToDayjs: () => ({ + format: () => '10:00 AM', + }), +})) + +vi.mock('../components', () => ({ + HeaderModals: () =>
, + PluginSourceBadge: ({ source }: { source: string }) =>
{source}
, +})) + +vi.mock('../hooks', () => ({ + useDetailHeaderState: () => ({ + modalStates: { + isShowUpdateModal: false, + showUpdateModal: vi.fn(), + hideUpdateModal: vi.fn(), + isShowPluginInfo: false, + showPluginInfo: vi.fn(), + hidePluginInfo: vi.fn(), + isShowDeleteConfirm: false, + showDeleteConfirm: vi.fn(), + hideDeleteConfirm: vi.fn(), + deleting: false, + showDeleting: vi.fn(), + hideDeleting: vi.fn(), + }, + versionPicker: { + isShow: false, + setIsShow: mockSetVersionPickerOpen, + targetVersion: { + version: '1.0.0', + unique_identifier: 'uid-1', + }, + setTargetVersion: mockSetTargetVersion, + isDowngrade: false, + setIsDowngrade: vi.fn(), + }, + hasNewVersion: true, + isAutoUpgradeEnabled: true, + isFromGitHub: false, + isFromMarketplace: true, + }), + usePluginOperations: () => ({ + handleUpdate: mockHandleUpdate, + handleUpdatedFromMarketplace: mockHandleUpdatedFromMarketplace, + handleDelete: mockHandleDelete, + }), +})) + +const createDetail = (overrides: Partial = {}): PluginDetail => ({ + id: 'plugin-1', + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: 'tool-plugin', + plugin_id: 'tool-plugin', + plugin_unique_identifier: 'tool-plugin@1.0.0', + declaration: { + author: 'acme', + category: PluginCategoryEnum.tool, + name: 'provider-a', + label: { en_US: 'Tool Plugin' }, + description: { en_US: 'Tool plugin description' }, + icon: 'icon.png', + icon_dark: 'icon-dark.png', + verified: true, + tool: { + identity: { + name: 'provider-a', + }, + }, + } as PluginDetail['declaration'], + installation_id: 'install-1', + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '2.0.0', + latest_unique_identifier: 'uid-2', + source: PluginSource.marketplace, + status: 'active', + deprecated_reason: 'Deprecated', + alternative_plugin_id: 'plugin-2', + meta: undefined, + ...overrides, +}) as PluginDetail + +describe('DetailHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the plugin summary, source badge, auth section, and modal container', () => { + render() + + expect(screen.getByTestId('title')).toHaveTextContent('Tool Plugin') + expect(screen.getByTestId('description')).toHaveTextContent('Tool plugin description') + expect(screen.getByTestId('source-badge')).toHaveTextContent('marketplace') + expect(screen.getByTestId('plugin-auth')).toHaveTextContent('tool-plugin/provider-a') + expect(screen.getByTestId('operation-dropdown')).toHaveTextContent('https://marketplace.example.com/plugins/acme/provider-a') + expect(screen.getByTestId('header-modals')).toBeInTheDocument() + }) + + it('wires version selection, latest update, and hide actions', () => { + const onHide = vi.fn() + render() + + fireEvent.click(screen.getByTestId('version-select')) + fireEvent.click(screen.getByText('plugin.detailPanel.operation.update')) + fireEvent.click(screen.getByTestId('close-button')) + + expect(mockSetTargetVersion).toHaveBeenCalledWith({ + version: '2.0.0', + unique_identifier: 'uid-2', + isDowngrade: true, + }) + expect(mockHandleUpdate).toHaveBeenCalledTimes(2) + expect(mockHandleUpdate).toHaveBeenNthCalledWith(1, true) + expect(onHide).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/index.spec.ts new file mode 100644 index 0000000000..a932907f44 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/index.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { HeaderModals, PluginSourceBadge } from '../index' + +describe('detail-header components index', () => { + it('re-exports header modal components', () => { + expect(HeaderModals).toBeDefined() + expect(PluginSourceBadge).toBeDefined() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/index.spec.ts new file mode 100644 index 0000000000..0edda1b86a --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/index.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { useDetailHeaderState, usePluginOperations } from '../index' + +describe('detail-header hooks index', () => { + it('re-exports hook entrypoints', () => { + expect(useDetailHeaderState).toBeTypeOf('function') + expect(usePluginOperations).toBeTypeOf('function') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx new file mode 100644 index 0000000000..b5e2be7105 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/components/__tests__/modal-steps.spec.tsx @@ -0,0 +1,112 @@ +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FormTypeEnum } from '@/app/components/base/form/types' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { ApiKeyStep } from '../../hooks/use-common-modal-state' +import { + ConfigurationStepContent, + MultiSteps, + VerifyStepContent, +} from '../modal-steps' + +const mockBaseForm = vi.fn() +vi.mock('@/app/components/base/form/components/base', () => ({ + BaseForm: ({ + formSchemas, + onChange, + }: { + formSchemas: Array<{ name: string }> + onChange?: () => void + }) => { + mockBaseForm(formSchemas) + return ( +
+ {formSchemas.map(schema => ( + + ))} +
+ ) + }, +})) + +vi.mock('../../../log-viewer', () => ({ + default: ({ logs }: { logs: Array<{ id: string, message: string }> }) => ( +
+ {logs.map(log => {log.message})} +
+ ), +})) + +const subscriptionBuilder: TriggerSubscriptionBuilder = { + id: 'builder-1', + name: 'builder', + provider: 'provider-a', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com/callback', + parameters: {}, + properties: {}, + workflows_in_use: 0, +} + +const formRef = { current: null } as React.RefObject + +describe('modal-steps', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the api key multi step indicator', () => { + render() + + expect(screen.getByText('pluginTrigger.modal.steps.verify')).toBeInTheDocument() + expect(screen.getByText('pluginTrigger.modal.steps.configuration')).toBeInTheDocument() + }) + + it('should render verify step content and forward change events', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('field-api_key')) + + expect(onChange).toHaveBeenCalled() + }) + + it('should render manual configuration content with logs', () => { + const onManualPropertiesChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('field-webhook_url')) + + expect(onManualPropertiesChange).toHaveBeenCalled() + expect(screen.getByTestId('log-viewer')).toHaveTextContent('log-entry') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts new file mode 100644 index 0000000000..61482e2912 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.helpers.spec.ts @@ -0,0 +1,196 @@ +import type { RefObject } from 'react' +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { + buildSubscriptionPayload, + DEFAULT_FORM_VALUES, + getConfirmButtonText, + getFirstFieldName, + getFormValues, + toSchemaWithTooltip, + useInitializeSubscriptionBuilder, + useSyncSubscriptionEndpoint, +} from '../use-common-modal-state.helpers' + +type BuilderResponse = { + subscription_builder: TriggerSubscriptionBuilder +} + +const { + mockToastError, + mockIsPrivateOrLocalAddress, +} = vi.hoisted(() => ({ + mockToastError: vi.fn(), + mockIsPrivateOrLocalAddress: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: mockToastError, + }, +})) + +vi.mock('@/utils/urlValidation', () => ({ + isPrivateOrLocalAddress: (value: string) => mockIsPrivateOrLocalAddress(value), +})) + +describe('use-common-modal-state helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsPrivateOrLocalAddress.mockReturnValue(false) + }) + + it('returns default form values when the form ref is empty', () => { + expect(getFormValues({ current: null })).toEqual(DEFAULT_FORM_VALUES) + }) + + it('returns form values from the form ref when available', () => { + expect(getFormValues({ + current: { + getFormValues: () => ({ values: { subscription_name: 'Sub' }, isCheckValidated: true }), + }, + } as unknown as React.RefObject)).toEqual({ + values: { subscription_name: 'Sub' }, + isCheckValidated: true, + }) + }) + + it('derives the first field name from values or schema fallback', () => { + expect(getFirstFieldName({ callback_url: 'https://example.com' }, [{ name: 'fallback' }])).toBe('callback_url') + expect(getFirstFieldName({}, [{ name: 'fallback' }])).toBe('fallback') + expect(getFirstFieldName({}, [])).toBe('') + }) + + it('copies schema help into tooltip fields', () => { + expect(toSchemaWithTooltip([{ name: 'field', help: 'Help text' }])).toEqual([ + { + name: 'field', + help: 'Help text', + tooltip: 'Help text', + }, + ]) + }) + + it('builds subscription payloads for automatic and manual creation', () => { + expect(buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.APIKEY, + subscriptionFormValues: { values: { subscription_name: 'My Sub' }, isCheckValidated: true }, + autoCommonParametersSchemaLength: 1, + autoCommonParametersFormValues: { values: { api_key: '123' }, isCheckValidated: true }, + manualPropertiesSchemaLength: 0, + manualPropertiesFormValues: undefined, + })).toEqual({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + name: 'My Sub', + parameters: { api_key: '123' }, + }) + + expect(buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.MANUAL, + subscriptionFormValues: { values: { subscription_name: 'Manual Sub' }, isCheckValidated: true }, + autoCommonParametersSchemaLength: 0, + autoCommonParametersFormValues: undefined, + manualPropertiesSchemaLength: 1, + manualPropertiesFormValues: { values: { custom: 'value' }, isCheckValidated: true }, + })).toEqual({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + name: 'Manual Sub', + }) + }) + + it('returns null when required validation is missing', () => { + expect(buildSubscriptionPayload({ + provider: 'provider-a', + subscriptionBuilderId: 'builder-a', + createType: SupportedCreationMethods.APIKEY, + subscriptionFormValues: { values: {}, isCheckValidated: false }, + autoCommonParametersSchemaLength: 1, + autoCommonParametersFormValues: { values: {}, isCheckValidated: true }, + manualPropertiesSchemaLength: 0, + manualPropertiesFormValues: undefined, + })).toBeNull() + }) + + it('builds confirm button text for verify and create states', () => { + const t = (key: string, options?: Record) => `${options?.ns}.${key}` + + expect(getConfirmButtonText({ + isVerifyStep: true, + isVerifyingCredentials: false, + isBuilding: false, + t, + })).toBe('pluginTrigger.modal.common.verify') + + expect(getConfirmButtonText({ + isVerifyStep: false, + isVerifyingCredentials: false, + isBuilding: true, + t, + })).toBe('pluginTrigger.modal.common.creating') + }) + + it('initializes the subscription builder once when provider is available', async () => { + const createBuilder = vi.fn(async () => ({ + subscription_builder: { id: 'builder-1' }, + })) as unknown as (params: { + provider: string + credential_type: string + }) => Promise + const setSubscriptionBuilder = vi.fn() + + renderHook(() => useInitializeSubscriptionBuilder({ + createBuilder, + credentialType: 'oauth', + provider: 'provider-a', + subscriptionBuilder: undefined, + setSubscriptionBuilder, + t: (key: string, options?: Record) => `${options?.ns}.${key}`, + })) + + await waitFor(() => { + expect(createBuilder).toHaveBeenCalledWith({ + provider: 'provider-a', + credential_type: 'oauth', + }) + expect(setSubscriptionBuilder).toHaveBeenCalledWith({ id: 'builder-1' }) + }) + }) + + it('syncs callback endpoint and warnings into the subscription form', async () => { + mockIsPrivateOrLocalAddress.mockReturnValue(true) + const setFieldValue = vi.fn() + const setFields = vi.fn() + const subscriptionFormRef = { + current: { + getForm: () => ({ + setFieldValue, + }), + setFields, + }, + } as unknown as RefObject + + renderHook(() => useSyncSubscriptionEndpoint({ + endpoint: 'http://127.0.0.1/callback', + isConfigurationStep: true, + subscriptionFormRef, + t: (key: string, options?: Record) => `${options?.ns}.${key}`, + })) + + await waitFor(() => { + expect(setFieldValue).toHaveBeenCalledWith('callback_url', 'http://127.0.0.1/callback') + expect(setFields).toHaveBeenCalledWith([{ + name: 'callback_url', + warnings: ['pluginTrigger.modal.form.callbackUrl.privateAddressWarning'], + }]) + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts new file mode 100644 index 0000000000..399d3ba60c --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/__tests__/use-common-modal-state.spec.ts @@ -0,0 +1,253 @@ +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types' +import { ApiKeyStep, useCommonModalState } from '../use-common-modal-state' + +type MockPluginDetail = { + plugin_id: string + provider: string + name: string + declaration: { + trigger: { + subscription_schema: Array<{ name: string, type: string, description?: string }> + subscription_constructor: { + credentials_schema: Array<{ name: string, type: string, help?: string }> + parameters: Array<{ name: string, type: string }> + } + } + } +} + +const createMockBuilder = (overrides: Partial = {}): TriggerSubscriptionBuilder => ({ + id: 'builder-1', + name: 'builder', + provider: 'provider-a', + credential_type: TriggerCredentialTypeEnum.ApiKey, + credentials: {}, + endpoint: 'https://example.com/callback', + parameters: {}, + properties: {}, + workflows_in_use: 0, + ...overrides, +}) + +const mockDetail: MockPluginDetail = { + plugin_id: 'plugin-id', + provider: 'provider-a', + name: 'Plugin A', + declaration: { + trigger: { + subscription_schema: [{ name: 'webhook_url', type: 'string', description: 'Webhook URL' }], + subscription_constructor: { + credentials_schema: [{ name: 'api_key', type: 'string', help: 'API key help' }], + parameters: [{ name: 'repo_name', type: 'string' }], + }, + }, + }, +} + +const mockUsePluginStore = vi.fn(() => mockDetail) +vi.mock('../../../../store', () => ({ + usePluginStore: () => mockUsePluginStore(), +})) + +const mockRefetch = vi.fn() +vi.mock('../../../use-subscription-list', () => ({ + useSubscriptionList: () => ({ refetch: mockRefetch }), +})) + +const mockVerifyCredentials = vi.fn() +const mockCreateBuilder = vi.fn() +const mockBuildSubscription = vi.fn() +const mockUpdateBuilder = vi.fn() +let mockIsVerifyingCredentials = false +let mockIsBuilding = false + +vi.mock('@/service/use-triggers', () => ({ + useVerifyAndUpdateTriggerSubscriptionBuilder: () => ({ + mutate: mockVerifyCredentials, + get isPending() { return mockIsVerifyingCredentials }, + }), + useCreateTriggerSubscriptionBuilder: () => ({ + mutateAsync: mockCreateBuilder, + }), + useBuildTriggerSubscription: () => ({ + mutate: mockBuildSubscription, + get isPending() { return mockIsBuilding }, + }), + useUpdateTriggerSubscriptionBuilder: () => ({ + mutate: mockUpdateBuilder, + }), + useTriggerSubscriptionBuilderLogs: () => ({ + data: { logs: [] }, + }), +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (message: string) => mockToastNotify({ type: 'success', message }), + error: (message: string) => mockToastNotify({ type: 'error', message }), + }, +})) + +const mockParsePluginErrorMessage = vi.fn().mockResolvedValue(null) +vi.mock('@/utils/error-parser', () => ({ + parsePluginErrorMessage: (...args: unknown[]) => mockParsePluginErrorMessage(...args), +})) + +vi.mock('@/utils/urlValidation', () => ({ + isPrivateOrLocalAddress: vi.fn().mockReturnValue(false), +})) + +const createFormRef = ({ + values = {}, + isCheckValidated = true, +}: { + values?: Record + isCheckValidated?: boolean +} = {}): FormRefObject => ({ + getFormValues: vi.fn().mockReturnValue({ values, isCheckValidated }), + setFields: vi.fn(), + getForm: vi.fn().mockReturnValue({ + setFieldValue: vi.fn(), + }), +} as unknown as FormRefObject) + +describe('useCommonModalState', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsVerifyingCredentials = false + mockIsBuilding = false + mockCreateBuilder.mockResolvedValue({ + subscription_builder: createMockBuilder(), + }) + }) + + it('should initialize api key builders and expose verify step state', async () => { + const { result } = renderHook(() => useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + onClose: vi.fn(), + })) + + await waitFor(() => { + expect(result.current.subscriptionBuilder?.id).toBe('builder-1') + }) + + expect(mockCreateBuilder).toHaveBeenCalledWith({ + provider: 'provider-a', + credential_type: TriggerCredentialTypeEnum.ApiKey, + }) + expect(result.current.currentStep).toBe(ApiKeyStep.Verify) + expect(result.current.apiKeyCredentialsSchema[0]).toMatchObject({ + name: 'api_key', + tooltip: 'API key help', + }) + }) + + it('should verify credentials and advance to configuration step', async () => { + mockVerifyCredentials.mockImplementation((_payload, options) => { + options?.onSuccess?.() + }) + + const builder = createMockBuilder() + const { result } = renderHook(() => useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + builder, + onClose: vi.fn(), + })) + + const credentialsFormRef = result.current.formRefs.apiKeyCredentialsFormRef as { current: FormRefObject | null } + credentialsFormRef.current = createFormRef({ + values: { api_key: 'secret' }, + }) + + act(() => { + result.current.handleVerify() + }) + + expect(mockVerifyCredentials).toHaveBeenCalledWith({ + provider: 'provider-a', + subscriptionBuilderId: builder.id, + credentials: { api_key: 'secret' }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + expect(result.current.currentStep).toBe(ApiKeyStep.Configuration) + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + })) + }) + + it('should build subscriptions with validated automatic parameters', () => { + const onClose = vi.fn() + const builder = createMockBuilder() + const { result } = renderHook(() => useCommonModalState({ + createType: SupportedCreationMethods.APIKEY, + builder, + onClose, + })) + + const subscriptionFormRef = result.current.formRefs.subscriptionFormRef as { current: FormRefObject | null } + const autoParamsFormRef = result.current.formRefs.autoCommonParametersFormRef as { current: FormRefObject | null } + + subscriptionFormRef.current = createFormRef({ + values: { subscription_name: 'Subscription A' }, + }) + autoParamsFormRef.current = createFormRef({ + values: { repo_name: 'repo-a' }, + }) + + act(() => { + result.current.handleCreate() + }) + + expect(mockBuildSubscription).toHaveBeenCalledWith({ + provider: 'provider-a', + subscriptionBuilderId: builder.id, + name: 'Subscription A', + parameters: { repo_name: 'repo-a' }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + }) + + it('should debounce manual property updates', async () => { + vi.useFakeTimers() + + const builder = createMockBuilder({ + credential_type: TriggerCredentialTypeEnum.Unauthorized, + }) + const { result } = renderHook(() => useCommonModalState({ + createType: SupportedCreationMethods.MANUAL, + builder, + onClose: vi.fn(), + })) + + const manualFormRef = result.current.formRefs.manualPropertiesFormRef as { current: FormRefObject | null } + manualFormRef.current = createFormRef({ + values: { webhook_url: 'https://hook.example.com' }, + isCheckValidated: true, + }) + + act(() => { + result.current.handleManualPropertiesChange() + vi.advanceTimersByTime(500) + }) + + expect(mockUpdateBuilder).toHaveBeenCalledWith({ + provider: 'provider-a', + subscriptionBuilderId: builder.id, + properties: { webhook_url: 'https://hook.example.com' }, + }, expect.objectContaining({ + onError: expect.any(Function), + })) + + vi.useRealTimers() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts new file mode 100644 index 0000000000..8df864c4fa --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.helpers.ts @@ -0,0 +1,180 @@ +'use client' +import type { Dispatch, SetStateAction } from 'react' +import type { FormRefObject } from '@/app/components/base/form/types' +import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' +import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' +import { useEffect, useRef } from 'react' +import { toast } from '@/app/components/base/ui/toast' +import { SupportedCreationMethods } from '@/app/components/plugins/types' +import { isPrivateOrLocalAddress } from '@/utils/urlValidation' + +type FormValuesResult = { + values: Record + isCheckValidated: boolean +} + +type InitializeBuilderParams = { + createBuilder: (params: { + provider: string + credential_type: string + }) => Promise<{ subscription_builder: TriggerSubscriptionBuilder }> + credentialType: string + provider?: string + subscriptionBuilder?: TriggerSubscriptionBuilder + setSubscriptionBuilder: Dispatch> + t: (key: string, options?: Record) => string +} + +type SyncEndpointParams = { + endpoint?: string + isConfigurationStep: boolean + subscriptionFormRef: React.RefObject + t: (key: string, options?: Record) => string +} + +type BuildPayloadParams = { + provider: string + subscriptionBuilderId: string + createType: SupportedCreationMethods + subscriptionFormValues?: FormValuesResult + autoCommonParametersSchemaLength: number + autoCommonParametersFormValues?: FormValuesResult + manualPropertiesSchemaLength: number + manualPropertiesFormValues?: FormValuesResult +} + +export const DEFAULT_FORM_VALUES: FormValuesResult = { values: {}, isCheckValidated: false } + +export const getFormValues = (formRef: React.RefObject) => { + return formRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES +} + +export const getFirstFieldName = ( + values: Record, + fallbackSchema: Array<{ name: string }>, +) => { + return Object.keys(values)[0] || fallbackSchema[0]?.name || '' +} + +export const toSchemaWithTooltip = (schemas: T[] = []) => { + return schemas.map(schema => ({ + ...schema, + tooltip: schema.help, + })) +} + +export const buildSubscriptionPayload = ({ + provider, + subscriptionBuilderId, + createType, + subscriptionFormValues, + autoCommonParametersSchemaLength, + autoCommonParametersFormValues, + manualPropertiesSchemaLength, + manualPropertiesFormValues, +}: BuildPayloadParams): BuildTriggerSubscriptionPayload | null => { + if (!subscriptionFormValues?.isCheckValidated) + return null + + const subscriptionNameValue = subscriptionFormValues.values.subscription_name as string + + const params: BuildTriggerSubscriptionPayload = { + provider, + subscriptionBuilderId, + name: subscriptionNameValue, + } + + if (createType !== SupportedCreationMethods.MANUAL) { + if (!autoCommonParametersSchemaLength) + return params + + if (!autoCommonParametersFormValues?.isCheckValidated) + return null + + params.parameters = autoCommonParametersFormValues.values + return params + } + + if (manualPropertiesSchemaLength && !manualPropertiesFormValues?.isCheckValidated) + return null + + return params +} + +export const getConfirmButtonText = ({ + isVerifyStep, + isVerifyingCredentials, + isBuilding, + t, +}: { + isVerifyStep: boolean + isVerifyingCredentials: boolean + isBuilding: boolean + t: (key: string, options?: Record) => string +}) => { + if (isVerifyStep) { + return isVerifyingCredentials + ? t('modal.common.verifying', { ns: 'pluginTrigger' }) + : t('modal.common.verify', { ns: 'pluginTrigger' }) + } + + return isBuilding + ? t('modal.common.creating', { ns: 'pluginTrigger' }) + : t('modal.common.create', { ns: 'pluginTrigger' }) +} + +export const useInitializeSubscriptionBuilder = ({ + createBuilder, + credentialType, + provider, + subscriptionBuilder, + setSubscriptionBuilder, + t, +}: InitializeBuilderParams) => { + const isInitializedRef = useRef(false) + + useEffect(() => { + const initializeBuilder = async () => { + isInitializedRef.current = true + try { + const response = await createBuilder({ + provider: provider || '', + credential_type: credentialType, + }) + setSubscriptionBuilder(response.subscription_builder) + } + catch (error) { + console.error('createBuilder error:', error) + toast.error(t('modal.errors.createFailed', { ns: 'pluginTrigger' })) + } + } + + if (!isInitializedRef.current && !subscriptionBuilder && provider) + initializeBuilder() + }, [subscriptionBuilder, provider, credentialType, createBuilder, setSubscriptionBuilder, t]) +} + +export const useSyncSubscriptionEndpoint = ({ + endpoint, + isConfigurationStep, + subscriptionFormRef, + t, +}: SyncEndpointParams) => { + useEffect(() => { + if (!endpoint || !subscriptionFormRef.current || !isConfigurationStep) + return + + const form = subscriptionFormRef.current.getForm() + if (form) + form.setFieldValue('callback_url', endpoint) + + const warnings = isPrivateOrLocalAddress(endpoint) + ? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })] + : [] + + subscriptionFormRef.current.setFields([{ + name: 'callback_url', + warnings, + }]) + }, [endpoint, isConfigurationStep, subscriptionFormRef, t]) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts index 339f782b45..e55f9525fe 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts @@ -3,7 +3,6 @@ import type { SimpleDetail } from '../../../store' import type { SchemaItem } from '../components/modal-steps' import type { FormRefObject } from '@/app/components/base/form/types' import type { TriggerLogEntity, TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types' -import type { BuildTriggerSubscriptionPayload } from '@/service/use-triggers' import { debounce } from 'es-toolkit/compat' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,9 +17,17 @@ import { useVerifyAndUpdateTriggerSubscriptionBuilder, } from '@/service/use-triggers' import { parsePluginErrorMessage } from '@/utils/error-parser' -import { isPrivateOrLocalAddress } from '@/utils/urlValidation' import { usePluginStore } from '../../../store' import { useSubscriptionList } from '../../use-subscription-list' +import { + buildSubscriptionPayload, + getConfirmButtonText, + getFirstFieldName, + getFormValues, + toSchemaWithTooltip, + useInitializeSubscriptionBuilder, + useSyncSubscriptionEndpoint, +} from './use-common-modal-state.helpers' // ============================================================================ // Types @@ -85,8 +92,6 @@ type UseCommonModalStateReturn = { handleApiKeyCredentialsChange: () => void } -const DEFAULT_FORM_VALUES = { values: {}, isCheckValidated: false } - // ============================================================================ // Hook Implementation // ============================================================================ @@ -105,7 +110,6 @@ export const useCommonModalState = ({ createType === SupportedCreationMethods.APIKEY ? ApiKeyStep.Verify : ApiKeyStep.Configuration, ) const [subscriptionBuilder, setSubscriptionBuilder] = useState(builder) - const isInitializedRef = useRef(false) // Form refs const manualPropertiesFormRef = useRef(null) @@ -123,12 +127,9 @@ export const useCommonModalState = ({ const manualPropertiesSchema = detail?.declaration?.trigger?.subscription_schema || [] const autoCommonParametersSchema = detail?.declaration.trigger?.subscription_constructor?.parameters || [] - const apiKeyCredentialsSchema = useMemo(() => { + const apiKeyCredentialsSchema = useMemo(() => { const rawSchema = detail?.declaration?.trigger?.subscription_constructor?.credentials_schema || [] - return rawSchema.map(schema => ({ - ...schema, - tooltip: schema.help, - })) + return toSchemaWithTooltip(rawSchema) as SchemaItem[] }, [detail?.declaration?.trigger?.subscription_constructor?.credentials_schema]) // Log data for manual mode @@ -162,25 +163,14 @@ export const useCommonModalState = ({ [updateBuilder, t], ) - // Initialize builder - useEffect(() => { - const initializeBuilder = async () => { - isInitializedRef.current = true - try { - const response = await createBuilder({ - provider: detail?.provider || '', - credential_type: CREDENTIAL_TYPE_MAP[createType], - }) - setSubscriptionBuilder(response.subscription_builder) - } - catch (error) { - console.error('createBuilder error:', error) - toast.error(t('modal.errors.createFailed', { ns: 'pluginTrigger' })) - } - } - if (!isInitializedRef.current && !subscriptionBuilder && detail?.provider) - initializeBuilder() - }, [subscriptionBuilder, detail?.provider, createType, createBuilder, t]) + useInitializeSubscriptionBuilder({ + createBuilder, + credentialType: CREDENTIAL_TYPE_MAP[createType], + provider: detail?.provider, + subscriptionBuilder, + setSubscriptionBuilder, + t, + }) // Cleanup debounced function useEffect(() => { @@ -189,24 +179,12 @@ export const useCommonModalState = ({ } }, [debouncedUpdate]) - // Update endpoint in form when endpoint changes - useEffect(() => { - if (!subscriptionBuilder?.endpoint || !subscriptionFormRef.current || currentStep !== ApiKeyStep.Configuration) - return - - const form = subscriptionFormRef.current.getForm() - if (form) - form.setFieldValue('callback_url', subscriptionBuilder.endpoint) - - const warnings = isPrivateOrLocalAddress(subscriptionBuilder.endpoint) - ? [t('modal.form.callbackUrl.privateAddressWarning', { ns: 'pluginTrigger' })] - : [] - - subscriptionFormRef.current?.setFields([{ - name: 'callback_url', - warnings, - }]) - }, [subscriptionBuilder?.endpoint, currentStep, t]) + useSyncSubscriptionEndpoint({ + endpoint: subscriptionBuilder?.endpoint, + isConfigurationStep: currentStep === ApiKeyStep.Configuration, + subscriptionFormRef, + t, + }) // Handle manual properties change const handleManualPropertiesChange = useCallback(() => { @@ -237,7 +215,7 @@ export const useCommonModalState = ({ return } - const apiKeyCredentialsFormValues = apiKeyCredentialsFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES + const apiKeyCredentialsFormValues = getFormValues(apiKeyCredentialsFormRef) const credentials = apiKeyCredentialsFormValues.values if (!Object.keys(credentials).length) { @@ -245,8 +223,10 @@ export const useCommonModalState = ({ return } + const credentialFieldName = getFirstFieldName(credentials, apiKeyCredentialsSchema) + apiKeyCredentialsFormRef.current?.setFields([{ - name: Object.keys(credentials)[0], + name: credentialFieldName, errors: [], }]) @@ -264,13 +244,13 @@ export const useCommonModalState = ({ onError: async (error: unknown) => { const errorMessage = await parsePluginErrorMessage(error) || t('modal.apiKey.verify.error', { ns: 'pluginTrigger' }) apiKeyCredentialsFormRef.current?.setFields([{ - name: Object.keys(credentials)[0], + name: credentialFieldName, errors: [errorMessage], }]) }, }, ) - }, [detail?.provider, subscriptionBuilder?.id, verifyCredentials, t]) + }, [apiKeyCredentialsSchema, detail?.provider, subscriptionBuilder?.id, verifyCredentials, t]) // Handle create const handleCreate = useCallback(() => { @@ -279,31 +259,19 @@ export const useCommonModalState = ({ return } - const subscriptionFormValues = subscriptionFormRef.current?.getFormValues({}) - if (!subscriptionFormValues?.isCheckValidated) - return - - const subscriptionNameValue = subscriptionFormValues?.values?.subscription_name as string - - const params: BuildTriggerSubscriptionPayload = { + const params = buildSubscriptionPayload({ provider: detail?.provider || '', subscriptionBuilderId: subscriptionBuilder.id, - name: subscriptionNameValue, - } + createType, + subscriptionFormValues: getFormValues(subscriptionFormRef), + autoCommonParametersSchemaLength: autoCommonParametersSchema.length, + autoCommonParametersFormValues: getFormValues(autoCommonParametersFormRef), + manualPropertiesSchemaLength: manualPropertiesSchema.length, + manualPropertiesFormValues: getFormValues(manualPropertiesFormRef), + }) - if (createType !== SupportedCreationMethods.MANUAL) { - if (autoCommonParametersSchema.length > 0) { - const autoCommonParametersFormValues = autoCommonParametersFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES - if (!autoCommonParametersFormValues?.isCheckValidated) - return - params.parameters = autoCommonParametersFormValues.values - } - } - else if (manualPropertiesSchema.length > 0) { - const manualFormValues = manualPropertiesFormRef.current?.getFormValues({}) || DEFAULT_FORM_VALUES - if (!manualFormValues?.isCheckValidated) - return - } + if (!params) + return buildSubscription( params, @@ -341,14 +309,12 @@ export const useCommonModalState = ({ // Confirm button text const confirmButtonText = useMemo(() => { - if (currentStep === ApiKeyStep.Verify) { - return isVerifyingCredentials - ? t('modal.common.verifying', { ns: 'pluginTrigger' }) - : t('modal.common.verify', { ns: 'pluginTrigger' }) - } - return isBuilding - ? t('modal.common.creating', { ns: 'pluginTrigger' }) - : t('modal.common.create', { ns: 'pluginTrigger' }) + return getConfirmButtonText({ + isVerifyStep: currentStep === ApiKeyStep.Verify, + isVerifyingCredentials, + isBuilding, + t, + }) }, [currentStep, isVerifyingCredentials, isBuilding, t]) return { diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/index.spec.ts new file mode 100644 index 0000000000..3ff43c4fb6 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/index.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { + SchemaModal, + ToolAuthorizationSection, + ToolBaseForm, + ToolCredentialsForm, + ToolItem, + ToolSettingsPanel, + ToolTrigger, +} from '../index' + +describe('tool-selector components index', () => { + it('re-exports the tool selector components', () => { + expect(SchemaModal).toBeDefined() + expect(ToolAuthorizationSection).toBeDefined() + expect(ToolBaseForm).toBeDefined() + expect(ToolCredentialsForm).toBeDefined() + expect(ToolItem).toBeDefined() + expect(ToolSettingsPanel).toBeDefined() + expect(ToolTrigger).toBeDefined() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts new file mode 100644 index 0000000000..24d7fd036d --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.helpers.spec.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import { VarType } from '@/app/components/workflow/types' +import { + createEmptyAppValue, + createFilterVar, + createPickerProps, + createReasoningFormContext, + getFieldFlags, + getFieldTitle, + getVarKindType, + getVisibleSelectOptions, + mergeReasoningValue, + resolveTargetVarType, + updateInputAutoState, + updateReasoningValue, + updateVariableSelectorValue, + updateVariableTypeValue, +} from '../reasoning-config-form.helpers' + +describe('reasoning-config-form helpers', () => { + it('maps schema types to variable-kind types and target variable types', () => { + expect(getVarKindType(FormTypeEnum.files)).toBe(VarKindType.variable) + expect(getVarKindType(FormTypeEnum.textNumber)).toBe(VarKindType.constant) + expect(getVarKindType(FormTypeEnum.textInput)).toBe(VarKindType.mixed) + expect(getVarKindType(FormTypeEnum.dynamicSelect)).toBeUndefined() + + expect(resolveTargetVarType(FormTypeEnum.textInput)).toBe(VarType.string) + expect(resolveTargetVarType(FormTypeEnum.textNumber)).toBe(VarType.number) + expect(resolveTargetVarType(FormTypeEnum.files)).toBe(VarType.arrayFile) + expect(resolveTargetVarType(FormTypeEnum.file)).toBe(VarType.file) + expect(resolveTargetVarType(FormTypeEnum.checkbox)).toBe(VarType.boolean) + expect(resolveTargetVarType(FormTypeEnum.object)).toBe(VarType.object) + expect(resolveTargetVarType(FormTypeEnum.array)).toBe(VarType.arrayObject) + }) + + it('creates variable filters for supported field types', () => { + const numberFilter = createFilterVar(FormTypeEnum.textNumber) + const stringFilter = createFilterVar(FormTypeEnum.textInput) + const fileFilter = createFilterVar(FormTypeEnum.files) + + expect(numberFilter?.({ type: VarType.number } as never)).toBe(true) + expect(numberFilter?.({ type: VarType.string } as never)).toBe(false) + expect(stringFilter?.({ type: VarType.secret } as never)).toBe(true) + expect(fileFilter?.({ type: VarType.arrayFile } as never)).toBe(true) + }) + + it('filters select options based on show_on conditions', () => { + const options = [ + { + value: 'one', + label: { en_US: 'One', zh_Hans: 'One' }, + show_on: [], + }, + { + value: 'two', + label: { en_US: 'Two', zh_Hans: 'Two' }, + show_on: [{ variable: 'mode', value: 'advanced' }], + }, + ] + + expect(getVisibleSelectOptions(options as never, { + mode: { value: { value: 'advanced' } }, + }, 'en_US')).toEqual([ + { value: 'one', name: 'One' }, + { value: 'two', name: 'Two' }, + ]) + + expect(getVisibleSelectOptions(options as never, { + mode: { value: { value: 'basic' } }, + }, 'en_US')).toEqual([ + { value: 'one', name: 'One' }, + ]) + }) + + it('updates reasoning values for auto, constant, variable, and merged states', () => { + const value = { + prompt: { + value: { + type: VarKindType.constant, + value: 'hello', + }, + auto: 0 as const, + }, + } + + expect(updateInputAutoState(value, 'prompt', true, FormTypeEnum.textInput)).toEqual({ + prompt: { + value: null, + auto: 1, + }, + }) + + expect(updateVariableTypeValue(value, 'prompt', VarKindType.variable, '')).toEqual({ + prompt: { + value: { + type: VarKindType.variable, + value: '', + }, + auto: 0, + }, + }) + + expect(updateReasoningValue(value, 'prompt', FormTypeEnum.textInput, 'updated')).toEqual({ + prompt: { + value: { + type: VarKindType.mixed, + value: 'updated', + }, + auto: 0, + }, + }) + + expect(mergeReasoningValue(value, 'prompt', { extra: true })).toEqual({ + prompt: { + value: { + type: VarKindType.constant, + value: 'hello', + extra: true, + }, + auto: 0, + }, + }) + + expect(updateVariableSelectorValue(value, 'prompt', ['node', 'field'])).toEqual({ + prompt: { + value: { + type: VarKindType.variable, + value: ['node', 'field'], + }, + auto: 0, + }, + }) + }) + + it('derives field flags and picker props from schema types', () => { + expect(getFieldFlags(FormTypeEnum.object, { type: VarKindType.constant })).toEqual(expect.objectContaining({ + isObject: true, + isShowJSONEditor: true, + showTypeSwitch: true, + isConstant: true, + })) + + expect(createPickerProps({ + type: FormTypeEnum.select, + value: {}, + language: 'en_US', + schema: { + options: [ + { + value: 'one', + label: { en_US: 'One', zh_Hans: 'One' }, + show_on: [], + }, + ], + } as never, + })).toEqual(expect.objectContaining({ + targetVarType: VarType.string, + selectItems: [{ value: 'one', name: 'One' }], + })) + }) + + it('provides label helpers and empty defaults', () => { + expect(getFieldTitle({ en_US: 'Prompt', zh_Hans: 'Prompt' }, 'en_US')).toBe('Prompt') + expect(createEmptyAppValue()).toEqual({ + app_id: '', + inputs: {}, + files: [], + }) + expect(createReasoningFormContext({ + availableNodes: [{ id: 'node-1' }] as never, + nodeId: 'node-current', + nodeOutputVars: [{ nodeId: 'node-1' }] as never, + })).toEqual({ + availableNodes: [{ id: 'node-1' }], + nodeId: 'node-current', + nodeOutputVars: [{ nodeId: 'node-1' }], + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx new file mode 100644 index 0000000000..f64d396d07 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx @@ -0,0 +1,340 @@ +import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { Type } from '@/app/components/workflow/nodes/llm/types' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import ReasoningConfigForm from '../reasoning-config-form' + +vi.mock('@/app/components/base/input', () => ({ + default: ({ value, onChange }: { value?: string, onChange: (e: { target: { value: string } }) => void }) => ( + onChange({ target: { value: e.target.value } })} /> + ), +})) + +vi.mock('@/app/components/base/select', () => ({ + SimpleSelect: ({ + items, + onSelect, + }: { + items: Array<{ value: string, name: string }> + onSelect: (item: { value: string }) => void + }) => ( +
+ {items.map(item => ( + + ))} +
+ ), +})) + +vi.mock('@/app/components/base/switch', () => ({ + default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useLanguage: () => 'en_US', +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ + default: ({ onSelect }: { onSelect: (value: Record) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ setModel }: { setModel: (value: Record) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/form-input-boolean', () => ({ + default: ({ onChange }: { onChange: (value: boolean) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch', () => ({ + default: ({ onChange }: { onChange: (value: VarKindType) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({ + default: ({ onChange }: { onChange: (value: string) => void }) => ( + + ), +})) + +vi.mock('../schema-modal', () => ({ + default: ({ isShow, rootName, onClose }: { isShow: boolean, rootName: string, onClose: () => void }) => ( + isShow + ? ( +
+ {rootName} + +
+ ) + : null + ), +})) + +const createSchema = (overrides: Partial = {}): ToolFormSchema => ({ + variable: 'field', + type: FormTypeEnum.textInput, + default: '', + required: false, + label: { en_US: 'Field', zh_Hans: '字段' }, + tooltip: { en_US: 'Tooltip', zh_Hans: '提示' }, + scope: 'all', + url: '', + input_schema: {}, + placeholder: { en_US: 'Placeholder', zh_Hans: '占位符' }, + options: [], + ...overrides, +} as ToolFormSchema) + +describe('ReasoningConfigForm', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should toggle automatic values for text fields', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('auto-switch')) + + expect(onChange).toHaveBeenCalledWith({ + field: { + auto: 1, + value: null, + }, + }) + }) + + it('should update mixed text and variable types', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('mixed-input')) + fireEvent.click(screen.getByTestId('type-switch')) + + expect(onChange).toHaveBeenNthCalledWith(1, expect.objectContaining({ + field: { + auto: 0, + value: { type: VarKindType.mixed, value: 'updated-text' }, + }, + })) + expect(onChange).toHaveBeenNthCalledWith(2, expect.objectContaining({ + count: { + auto: 0, + value: { type: VarKindType.variable, value: '' }, + }, + })) + }) + + it('should open schema modal for object fields and support app selection', () => { + const onChange = vi.fn() + + const { container } = render( + , + ) + + fireEvent.click(container.querySelector('div.ml-0\\.5.cursor-pointer')!) + expect(screen.getByTestId('schema-modal')).toHaveTextContent('Config') + fireEvent.click(screen.getByTestId('close-schema')) + + fireEvent.click(screen.getByTestId('app-selector')) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + app: { + auto: 0, + value: { + type: undefined, + value: { app_id: 'app-1', inputs: { topic: 'hello' } }, + }, + }, + })) + }) + + it('should merge model selector values into the current field value', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('model-selector')) + + expect(onChange).toHaveBeenCalledWith({ + model: { + auto: 0, + value: { + provider: 'openai', + model: 'gpt-4.1', + }, + }, + }) + }) + + it('should update file fields from the variable selector', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('var-picker')) + + expect(onChange).toHaveBeenCalledWith({ + files: { + auto: 0, + value: { + type: VarKindType.variable, + value: ['node', 'field'], + }, + }, + }) + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx new file mode 100644 index 0000000000..86158ab950 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/schema-modal.spec.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SchemaModal from '../schema-modal' + +vi.mock('@/app/components/base/modal', () => ({ + default: ({ + children, + isShow, + }: { + children: React.ReactNode + isShow: boolean + }) => isShow ?
{children}
: null, +})) + +vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor', () => ({ + default: ({ rootName }: { rootName: string }) =>
{rootName}
, +})) + +vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context', () => ({ + MittProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + VisualEditorContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +describe('SchemaModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('does not render content when hidden', () => { + render( + , + ) + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('renders the schema title and closes when the close control is clicked', () => { + const onClose = vi.fn() + render( + , + ) + + expect(screen.getByText('workflow.nodes.agent.parameterSchema')).toBeInTheDocument() + expect(screen.getByTestId('visual-editor')).toHaveTextContent('response') + + const closeButton = document.body.querySelector('div.absolute.right-5.top-5') + fireEvent.click(closeButton!) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx new file mode 100644 index 0000000000..03b684faac --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-authorization-section.spec.tsx @@ -0,0 +1,64 @@ +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { CollectionType } from '@/app/components/tools/types' +import ToolAuthorizationSection from '../tool-authorization-section' + +vi.mock('@/app/components/plugins/plugin-auth', () => ({ + AuthCategory: { + tool: 'tool', + }, + PluginAuthInAgent: ({ pluginPayload, credentialId }: { + pluginPayload: { provider: string, providerType: string } + credentialId?: string + }) => ( +
+ {pluginPayload.provider} + : + {pluginPayload.providerType} + : + {credentialId} +
+ ), +})) + +const createProvider = (overrides: Partial = {}): ToolWithProvider => ({ + name: 'provider-a', + type: CollectionType.builtIn, + allow_delete: true, + ...overrides, +}) as ToolWithProvider + +describe('ToolAuthorizationSection', () => { + it('returns null for providers that are not removable built-ins', () => { + const { container, rerender } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders the authorization panel for removable built-in providers', () => { + render( + , + ) + + expect(screen.getByTestId('plugin-auth-in-agent')).toHaveTextContent('provider-a:builtin:credential-1') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx new file mode 100644 index 0000000000..9a689dec8c --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-item.spec.tsx @@ -0,0 +1,130 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ToolItem from '../tool-item' + +let mcpAllowed = true + +vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({ + useMCPToolAvailability: () => ({ + allowed: mcpAllowed, + }), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip', () => ({ + default: () =>
mcp unavailable
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ + InstallPluginButton: ({ onSuccess }: { onSuccess: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({ + SwitchPluginVersion: ({ onChange }: { onChange: () => void }) => ( + + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + children, + popupContent, + }: { + children: React.ReactNode + popupContent: React.ReactNode + }) => ( +
+ {children} +
{popupContent}
+
+ ), +})) + +describe('ToolItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mcpAllowed = true + }) + + it('shows auth status actions for no-auth and auth-removed states', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('tools.notAuthorized')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('plugin.auth.authRemoved')).toBeInTheDocument() + }) + + it('surfaces install and version mismatch recovery actions', () => { + const onInstall = vi.fn() + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByText('install plugin')) + expect(onInstall).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + fireEvent.click(screen.getByText('switch version')) + expect(onInstall).toHaveBeenCalledTimes(2) + }) + + it('blocks unsupported MCP tools and still exposes error state', () => { + mcpAllowed = false + const { rerender } = render( + , + ) + + expect(screen.getByTestId('mcp-tooltip')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('tool failed')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx new file mode 100644 index 0000000000..56c98f695d --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-settings-panel.spec.tsx @@ -0,0 +1,100 @@ +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ToolSettingsPanel from '../tool-settings-panel' + +vi.mock('@/app/components/base/tab-slider-plain', () => ({ + default: ({ + options, + onChange, + }: { + options: Array<{ value: string, text: string }> + onChange: (value: string) => void + }) => ( +
+ {options.map(option => ( + + ))} +
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/tool/components/tool-form', () => ({ + default: ({ schema }: { schema: Array<{ name: string }> }) =>
{schema.map(item => item.name).join(',')}
, +})) + +vi.mock('../reasoning-config-form', () => ({ + default: ({ schemas }: { schemas: Array<{ name: string }> }) =>
{schemas.map(item => item.name).join(',')}
, +})) + +const baseProps = { + nodeId: 'node-1', + currType: 'settings' as const, + settingsFormSchemas: [{ name: 'api_key' }] as never[], + paramsFormSchemas: [{ name: 'temperature' }] as never[], + settingsValue: {}, + showTabSlider: true, + userSettingsOnly: false, + reasoningConfigOnly: false, + nodeOutputVars: [], + availableNodes: [], + onCurrTypeChange: vi.fn(), + onSettingsFormChange: vi.fn(), + onParamsFormChange: vi.fn(), + currentProvider: { + is_team_authorization: true, + } as ToolWithProvider, +} + +describe('ToolSettingsPanel', () => { + it('returns null when the provider is not team-authorized or has no forms', () => { + const { container, rerender } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + + rerender( + , + ) + + expect(container).toBeEmptyDOMElement() + }) + + it('renders the settings form and lets the tab slider switch to params', () => { + const onCurrTypeChange = vi.fn() + render( + , + ) + + expect(screen.getByTestId('tool-form')).toHaveTextContent('api_key') + fireEvent.click(screen.getByText('plugin.detailPanel.toolSelector.params')) + + expect(onCurrTypeChange).toHaveBeenCalledWith('params') + }) + + it('renders params tips and the reasoning config form for params-only views', () => { + render( + , + ) + + expect(screen.getAllByText('plugin.detailPanel.toolSelector.paramsTip1')).toHaveLength(2) + expect(screen.getByTestId('reasoning-config-form')).toHaveTextContent('temperature') + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-trigger.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-trigger.spec.tsx new file mode 100644 index 0000000000..903e1ef687 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/tool-trigger.spec.tsx @@ -0,0 +1,38 @@ +import type { ToolWithProvider } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ToolTrigger from '../tool-trigger' + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () =>
, +})) + +describe('ToolTrigger', () => { + it('renders the placeholder for the unconfigured state', () => { + render() + + expect(screen.getByText('plugin.detailPanel.toolSelector.placeholder')).toBeInTheDocument() + }) + + it('renders the selected provider icon and tool label', () => { + render( + , + ) + + expect(screen.getByTestId('block-icon')).toBeInTheDocument() + expect(screen.getByText('Search Tool')).toBeInTheDocument() + }) + + it('switches to the configure placeholder when requested', () => { + render() + + expect(screen.getByText('plugin.detailPanel.configureTool')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts new file mode 100644 index 0000000000..86e42aab6b --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts @@ -0,0 +1,233 @@ +import type { Node } from 'reactflow' +import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' +import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' +import { produce } from 'immer' +import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' +import { VarType } from '@/app/components/workflow/types' + +export type ReasoningConfigInputValue = { + type?: VarKindType + value?: unknown + [key: string]: unknown +} | null + +export type ReasoningConfigInput = { + value: ReasoningConfigInputValue + auto?: 0 | 1 +} + +export type ReasoningConfigValue = Record + +export const getVarKindType = (type: string) => { + if (type === FormTypeEnum.file || type === FormTypeEnum.files) + return VarKindType.variable + + if ([FormTypeEnum.select, FormTypeEnum.checkbox, FormTypeEnum.textNumber, FormTypeEnum.array, FormTypeEnum.object].includes(type as FormTypeEnum)) + return VarKindType.constant + + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return VarKindType.mixed + + return undefined +} + +export const resolveTargetVarType = (type: string) => { + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return VarType.string + if (type === FormTypeEnum.textNumber) + return VarType.number + if (type === FormTypeEnum.files) + return VarType.arrayFile + if (type === FormTypeEnum.file) + return VarType.file + if (type === FormTypeEnum.checkbox) + return VarType.boolean + if (type === FormTypeEnum.object) + return VarType.object + if (type === FormTypeEnum.array) + return VarType.arrayObject + + return VarType.string +} + +export const createFilterVar = (type: string) => { + if (type === FormTypeEnum.textNumber) + return (varPayload: Var) => varPayload.type === VarType.number + + if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) + return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) + + if (type === FormTypeEnum.file || type === FormTypeEnum.files) + return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type) + + if (type === FormTypeEnum.checkbox) + return (varPayload: Var) => varPayload.type === VarType.boolean + + if (type === FormTypeEnum.object) + return (varPayload: Var) => varPayload.type === VarType.object + + if (type === FormTypeEnum.array) + return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) + + return undefined +} + +export const getVisibleSelectOptions = ( + options: NonNullable, + value: ReasoningConfigValue, + language: string, +) => { + return options.filter((option) => { + if (option.show_on.length) + return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value) + + return true + }).map(option => ({ + value: option.value, + name: option.label[language] || option.label.en_US, + })) +} + +export const updateInputAutoState = ( + value: ReasoningConfigValue, + variable: string, + enabled: boolean, + type: string, +) => { + return { + ...value, + [variable]: { + value: enabled ? null : { type: getVarKindType(type), value: null }, + auto: enabled ? 1 as const : 0 as const, + }, + } +} + +export const updateVariableTypeValue = ( + value: ReasoningConfigValue, + variable: string, + newType: VarKindType, + defaultValue: unknown, +) => { + return produce(value, (draft) => { + draft[variable].value = { + type: newType, + value: newType === VarKindType.variable ? '' : defaultValue, + } + }) +} + +export const updateReasoningValue = ( + value: ReasoningConfigValue, + variable: string, + type: string, + newValue: unknown, +) => { + return produce(value, (draft) => { + draft[variable].value = { + type: getVarKindType(type), + value: newValue, + } + }) +} + +export const mergeReasoningValue = ( + value: ReasoningConfigValue, + variable: string, + newValue: Record, +) => { + return produce(value, (draft) => { + const currentValue = draft[variable].value as Record | undefined + draft[variable].value = { + ...currentValue, + ...newValue, + } + }) +} + +export const updateVariableSelectorValue = ( + value: ReasoningConfigValue, + variable: string, + newValue: ValueSelector | string, +) => { + return produce(value, (draft) => { + draft[variable].value = { + type: VarKindType.variable, + value: newValue, + } + }) +} + +export const getFieldFlags = (type: string, varInput?: ReasoningConfigInputValue) => { + const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput + const isNumber = type === FormTypeEnum.textNumber + const isObject = type === FormTypeEnum.object + const isArray = type === FormTypeEnum.array + const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files + const isBoolean = type === FormTypeEnum.checkbox + const isSelect = type === FormTypeEnum.select + const isAppSelector = type === FormTypeEnum.appSelector + const isModelSelector = type === FormTypeEnum.modelSelector + const isConstant = varInput?.type === VarKindType.constant || !varInput?.type + + return { + isString, + isNumber, + isObject, + isArray, + isShowJSONEditor: isObject || isArray, + isFile, + isBoolean, + isSelect, + isAppSelector, + isModelSelector, + showTypeSwitch: isNumber || isObject || isArray, + isConstant, + showVariableSelector: isFile || varInput?.type === VarKindType.variable, + } +} + +export const createPickerProps = ({ + type, + value, + language, + schema, +}: { + type: string + value: ReasoningConfigValue + language: string + schema: ToolFormSchema +}) => { + return { + filterVar: createFilterVar(type), + schema: schema as Partial, + targetVarType: resolveTargetVarType(type), + selectItems: schema.options ? getVisibleSelectOptions(schema.options, value, language) : [], + } +} + +export const getFieldTitle = (labels: { [key: string]: string }, language: string) => { + return labels[language] || labels.en_US +} + +export const createEmptyAppValue = () => ({ + app_id: '', + inputs: {}, + files: [], +}) + +export const createReasoningFormContext = ({ + availableNodes, + nodeId, + nodeOutputVars, +}: { + availableNodes: Node[] + nodeId: string + nodeOutputVars: NodeOutPutVar[] +}) => ({ + availableNodes, + nodeId, + nodeOutputVars, +}) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx index 38328aa8b3..1edc147370 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx @@ -1,19 +1,16 @@ import type { Node } from 'reactflow' -import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ReasoningConfigValue as ReasoningConfigValueShape } from './reasoning-config-form.helpers' import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema' import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types' -import type { ToolVarInputs } from '@/app/components/workflow/nodes/tool/types' import type { NodeOutPutVar, ValueSelector, - Var, } from '@/app/components/workflow/types' import { RiArrowRightUpLine, RiBracesLine, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { produce } from 'immer' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' @@ -31,21 +28,21 @@ import VarReferencePicker from '@/app/components/workflow/nodes/_base/components import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import MixedVariableTextInput from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' -import { VarType } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' +import { + createPickerProps, + getFieldFlags, + getFieldTitle, + mergeReasoningValue, + resolveTargetVarType, + updateInputAutoState, + updateReasoningValue, + updateVariableSelectorValue, + updateVariableTypeValue, +} from './reasoning-config-form.helpers' import SchemaModal from './schema-modal' -type ReasoningConfigInputValue = { - type?: VarKindType - value?: unknown -} | null - -type ReasoningConfigInput = { - value: ReasoningConfigInputValue - auto?: 0 | 1 -} - -export type ReasoningConfigValue = Record +export type ReasoningConfigValue = ReasoningConfigValueShape type Props = { value: ReasoningConfigValue @@ -66,79 +63,42 @@ const ReasoningConfigForm: React.FC = ({ }) => { const { t } = useTranslation() const language = useLanguage() - const getVarKindType = (type: string) => { - if (type === FormTypeEnum.file || type === FormTypeEnum.files) - return VarKindType.variable - if (type === FormTypeEnum.select || type === FormTypeEnum.checkbox || type === FormTypeEnum.textNumber || type === FormTypeEnum.array || type === FormTypeEnum.object) - return VarKindType.constant - if (type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput) - return VarKindType.mixed - } const handleAutomatic = (key: string, val: boolean, type: string) => { - onChange({ - ...value, - [key]: { - value: val ? null : { type: getVarKindType(type), value: null }, - auto: val ? 1 : 0, - }, - }) + onChange(updateInputAutoState(value, key, val, type)) } + const handleTypeChange = useCallback((variable: string, defaultValue: unknown) => { return (newType: VarKindType) => { - const res = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = { - type: newType, - value: newType === VarKindType.variable ? '' : defaultValue, - } - }) - onChange(res) + onChange(updateVariableTypeValue(value, variable, newType, defaultValue)) } }, [onChange, value]) + const handleValueChange = useCallback((variable: string, varType: string) => { return (newValue: unknown) => { - const res = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = { - type: getVarKindType(varType), - value: newValue, - } - }) - onChange(res) + onChange(updateReasoningValue(value, variable, varType, newValue)) } }, [onChange, value]) + const handleAppChange = useCallback((variable: string) => { return (app: { app_id: string inputs: Record files?: unknown[] }) => { - const newValue = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = app - }) - onChange(newValue) + onChange(updateReasoningValue(value, variable, FormTypeEnum.appSelector, app)) } }, [onChange, value]) + const handleModelChange = useCallback((variable: string) => { return (model: Record) => { - const newValue = produce(value, (draft: ToolVarInputs) => { - const currentValue = draft[variable].value as Record | undefined - draft[variable].value = { - ...currentValue, - ...model, - } - }) - onChange(newValue) + onChange(mergeReasoningValue(value, variable, model)) } }, [onChange, value]) + const handleVariableSelectorChange = useCallback((variable: string) => { return (newValue: ValueSelector | string) => { - const res = produce(value, (draft: ToolVarInputs) => { - draft[variable].value = { - type: VarKindType.variable, - value: newValue, - } - }) - onChange(res) + onChange(updateVariableSelectorValue(value, variable, newValue)) } }, [onChange, value]) @@ -165,6 +125,7 @@ const ReasoningConfigForm: React.FC = ({ options, } = schema const auto = value[variable]?.auto + const fieldTitle = getFieldTitle(label, language) const tooltipContent = (tooltip && ( = ({ /> )) const varInput = value[variable].value - const isString = type === FormTypeEnum.textInput || type === FormTypeEnum.secretInput - const isNumber = type === FormTypeEnum.textNumber - const isObject = type === FormTypeEnum.object - const isArray = type === FormTypeEnum.array - const isShowJSONEditor = isObject || isArray - const isFile = type === FormTypeEnum.file || type === FormTypeEnum.files - const isBoolean = type === FormTypeEnum.checkbox - const isSelect = type === FormTypeEnum.select - const isAppSelector = type === FormTypeEnum.appSelector - const isModelSelector = type === FormTypeEnum.modelSelector - const showTypeSwitch = isNumber || isObject || isArray - const isConstant = varInput?.type === VarKindType.constant || !varInput?.type - const showVariableSelector = isFile || varInput?.type === VarKindType.variable - const targetVarType = () => { - if (isString) - return VarType.string - else if (isNumber) - return VarType.number - else if (type === FormTypeEnum.files) - return VarType.arrayFile - else if (type === FormTypeEnum.file) - return VarType.file - else if (isBoolean) - return VarType.boolean - else if (isObject) - return VarType.object - else if (isArray) - return VarType.arrayObject - else - return VarType.string - } - const getFilterVar = () => { - if (isNumber) - return (varPayload: Var) => varPayload.type === VarType.number - else if (isString) - return (varPayload: Var) => [VarType.string, VarType.number, VarType.secret].includes(varPayload.type) - else if (isFile) - return (varPayload: Var) => [VarType.file, VarType.arrayFile].includes(varPayload.type) - else if (isBoolean) - return (varPayload: Var) => varPayload.type === VarType.boolean - else if (isObject) - return (varPayload: Var) => varPayload.type === VarType.object - else if (isArray) - return (varPayload: Var) => [VarType.array, VarType.arrayString, VarType.arrayNumber, VarType.arrayObject].includes(varPayload.type) - return undefined - } + const { + isString, + isNumber, + isShowJSONEditor, + isBoolean, + isSelect, + isAppSelector, + isModelSelector, + showTypeSwitch, + isConstant, + showVariableSelector, + } = getFieldFlags(type, varInput) + const pickerProps = createPickerProps({ + type, + value, + language, + schema, + }) return (
- {label[language] || label.en_US} + {fieldTitle} {required && ( * )} {tooltipContent} · - {targetVarType()} + {resolveTargetVarType(type)} {isShowJSONEditor && ( = ({ >
showSchema(input_schema as SchemaRoot, label[language] || label.en_US)} + onClick={() => showSchema(input_schema as SchemaRoot, fieldTitle)} >
@@ -295,12 +228,7 @@ const ReasoningConfigForm: React.FC = ({ { - if (option.show_on.length) - return option.show_on.every(showOnItem => value[showOnItem.variable]?.value?.value === showOnItem.value) - - return true - }).map(option => ({ value: option.value, name: option.label[language] || option.label.en_US }))} + items={pickerProps.selectItems} onSelect={item => handleValueChange(variable, type)(item.value as string)} placeholder={placeholder?.[language] || placeholder?.en_US} /> @@ -347,9 +275,9 @@ const ReasoningConfigForm: React.FC = ({ nodeId={nodeId} value={(varInput?.value as string | ValueSelector) || []} onChange={handleVariableSelectorChange(variable)} - filterVar={getFilterVar()} - schema={schema as Partial} - valueTypePlaceHolder={targetVarType()} + filterVar={pickerProps.filterVar} + schema={pickerProps.schema} + valueTypePlaceHolder={pickerProps.targetVarType} /> )}
diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts new file mode 100644 index 0000000000..33a05be1b8 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/hooks/__tests__/index.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { usePluginInstalledCheck, useToolSelectorState } from '../index' + +describe('tool-selector hooks index', () => { + it('re-exports the tool selector hooks', () => { + expect(usePluginInstalledCheck).toBeTypeOf('function') + expect(useToolSelectorState).toBeTypeOf('function') + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx new file mode 100644 index 0000000000..476ab8e145 --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx @@ -0,0 +1,76 @@ +import { fireEvent, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useGlobalPublicStore } from '@/context/global-public-context' +import { renderWithNuqs } from '@/test/nuqs-testing' +import { usePluginPageContext } from '../context' +import { PluginPageContextProvider } from '../context-provider' + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: vi.fn(), +})) + +vi.mock('../../hooks', () => ({ + PLUGIN_PAGE_TABS_MAP: { + plugins: 'plugins', + marketplace: 'discover', + }, + usePluginPageTabs: () => [ + { value: 'plugins', text: 'Plugins' }, + { value: 'discover', text: 'Discover' }, + ], +})) + +const mockGlobalPublicStore = (enableMarketplace: boolean) => { + vi.mocked(useGlobalPublicStore).mockImplementation((selector) => { + const state = { systemFeatures: { enable_marketplace: enableMarketplace } } + return selector(state as Parameters[0]) + }) +} + +const Consumer = () => { + const currentPluginID = usePluginPageContext(v => v.currentPluginID) + const setCurrentPluginID = usePluginPageContext(v => v.setCurrentPluginID) + const options = usePluginPageContext(v => v.options) + + return ( +
+ {currentPluginID ?? 'none'} + {options.length} + +
+ ) +} + +describe('PluginPageContextProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('filters out the marketplace tab when the feature is disabled', () => { + mockGlobalPublicStore(false) + + renderWithNuqs( + + + , + ) + + expect(screen.getByTestId('options-count')).toHaveTextContent('1') + }) + + it('keeps the query-state tab and updates the current plugin id', () => { + mockGlobalPublicStore(true) + + renderWithNuqs( + + + , + { searchParams: '?tab=discover' }, + ) + + fireEvent.click(screen.getByText('select plugin')) + + expect(screen.getByTestId('current-plugin')).toHaveTextContent('plugin-1') + expect(screen.getByTestId('options-count')).toHaveTextContent('2') + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx new file mode 100644 index 0000000000..ceec84a286 --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/debug-info.spec.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DebugInfo from '../debug-info' + +const mockDebugKey = vi.hoisted(() => ({ + data: null as null | { key: string, host: string, port: number }, + isLoading: false, +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +vi.mock('@/service/use-plugins', () => ({ + useDebugKey: () => mockDebugKey, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children }: { children: React.ReactNode }) => , +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + children, + disabled, + popupContent, + }: { + children: React.ReactNode + disabled?: boolean + popupContent: React.ReactNode + }) => ( +
+ {children} + {!disabled &&
{popupContent}
} +
+ ), +})) + +vi.mock('../../base/key-value-item', () => ({ + default: ({ + label, + value, + maskedValue, + }: { + label: string + value: string + maskedValue?: string + }) => ( +
+ {label} + : + {maskedValue || value} +
+ ), +})) + +describe('DebugInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDebugKey.data = null + mockDebugKey.isLoading = false + }) + + it('renders nothing while the debug key is loading', () => { + mockDebugKey.isLoading = true + const { container } = render() + + expect(container.innerHTML).toBe('') + }) + + it('renders debug metadata and masks the key when info is available', () => { + mockDebugKey.data = { + host: '127.0.0.1', + port: 5001, + key: '12345678abcdefghijklmnopqrst87654321', + } + + render() + + expect(screen.getByTestId('debug-button')).toBeInTheDocument() + expect(screen.getByText('plugin.debugInfo.title')).toBeInTheDocument() + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + 'https://docs.example.com/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin', + ) + expect(screen.getByTestId('kv-URL')).toHaveTextContent('URL:127.0.0.1:5001') + expect(screen.getByTestId('kv-Key')).toHaveTextContent('Key:12345678********87654321') + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx new file mode 100644 index 0000000000..d3b72ebe5b --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import InstallPluginDropdown from '../install-plugin-dropdown' + +let portalOpen = false +const { + mockSystemFeatures, +} = vi.hoisted(() => ({ + mockSystemFeatures: { + enable_marketplace: true, + plugin_installation_permission: { + restrict_to_marketplace_only: false, + }, + }, +})) + +vi.mock('@/config', () => ({ + SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS: '.difypkg,.zip', +})) + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) => + selector({ systemFeatures: mockSystemFeatures }), +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/files', () => ({ + FileZip: () => file, +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({ + Github: () => github, +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/mediaAndDevices', () => ({ + MagicBox: () => magic, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children }: { children: React.ReactNode }) => {children}, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await import('react') + return { + PortalToFollowElem: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => { + portalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => , + PortalToFollowElemContent: ({ + children, + }: { + children: React.ReactNode + }) => portalOpen ?
{children}
: null, + } +}) + +vi.mock('@/app/components/plugins/install-plugin/install-from-github', () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/plugins/install-plugin/install-from-local-package', () => ({ + default: ({ + file, + onClose, + }: { + file: File + onClose: () => void + }) => ( +
+ {file.name} + +
+ ), +})) + +describe('InstallPluginDropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + portalOpen = false + mockSystemFeatures.enable_marketplace = true + mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = false + }) + + it('shows all install methods when marketplace and custom installs are enabled', () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + + expect(screen.getByText('plugin.installFrom')).toBeInTheDocument() + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.getByText('plugin.source.github')).toBeInTheDocument() + expect(screen.getByText('plugin.source.local')).toBeInTheDocument() + }) + + it('shows only marketplace when installation is restricted', () => { + mockSystemFeatures.plugin_installation_permission.restrict_to_marketplace_only = true + + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + + expect(screen.getByText('plugin.source.marketplace')).toBeInTheDocument() + expect(screen.queryByText('plugin.source.github')).not.toBeInTheDocument() + expect(screen.queryByText('plugin.source.local')).not.toBeInTheDocument() + }) + + it('switches to marketplace when the marketplace action is selected', () => { + const onSwitchToMarketplaceTab = vi.fn() + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.marketplace')) + + expect(onSwitchToMarketplaceTab).toHaveBeenCalledTimes(1) + }) + + it('opens the github installer when github is selected', () => { + render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.click(screen.getByText('plugin.source.github')) + + expect(screen.getByTestId('github-modal')).toBeInTheDocument() + }) + + it('opens the local package installer when a file is selected', () => { + const { container } = render() + + fireEvent.click(screen.getByTestId('dropdown-trigger')) + fireEvent.change(container.querySelector('input[type="file"]')!, { + target: { + files: [new File(['content'], 'plugin.difypkg')], + }, + }) + + expect(screen.getByTestId('local-modal')).toBeInTheDocument() + expect(screen.getByText('plugin.difypkg')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx new file mode 100644 index 0000000000..bad857077a --- /dev/null +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -0,0 +1,200 @@ +import type { PluginDetail } from '../../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import PluginsPanel from '../plugins-panel' + +const mockState = vi.hoisted(() => ({ + filters: { + categories: [] as string[], + tags: [] as string[], + searchQuery: '', + }, + currentPluginID: undefined as string | undefined, +})) + +const mockSetFilters = vi.fn() +const mockSetCurrentPluginID = vi.fn() +const mockLoadNextPage = vi.fn() +const mockInvalidateInstalledPluginList = vi.fn() +const mockUseInstalledPluginList = vi.fn() +const mockPluginListWithLatestVersion = vi.fn<() => PluginDetail[]>(() => []) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (value: Record, locale: string) => value[locale] || '', +})) + +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => mockUseInstalledPluginList(), + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + +vi.mock('../../hooks', () => ({ + usePluginsWithLatestVersion: () => mockPluginListWithLatestVersion(), +})) + +vi.mock('../context', () => ({ + usePluginPageContext: (selector: (value: { + filters: typeof mockState.filters + setFilters: typeof mockSetFilters + currentPluginID: string | undefined + setCurrentPluginID: typeof mockSetCurrentPluginID + }) => unknown) => selector({ + filters: mockState.filters, + setFilters: mockSetFilters, + currentPluginID: mockState.currentPluginID, + setCurrentPluginID: mockSetCurrentPluginID, + }), +})) + +vi.mock('../filter-management', () => ({ + default: ({ onFilterChange }: { onFilterChange: (filters: typeof mockState.filters) => void }) => ( + + ), +})) + +vi.mock('../empty', () => ({ + default: () =>
empty
, +})) + +vi.mock('../list', () => ({ + default: ({ pluginList }: { pluginList: PluginDetail[] }) =>
{pluginList.map(plugin => plugin.plugin_id).join(',')}
, +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel', () => ({ + default: ({ detail, onHide, onUpdate }: { + detail?: PluginDetail + onHide: () => void + onUpdate: () => void + }) => ( +
+ {detail?.plugin_id ?? 'none'} + + +
+ ), +})) + +const createPlugin = (pluginId: string, label: string, tags: string[] = []): PluginDetail => ({ + id: pluginId, + created_at: '2024-01-01', + updated_at: '2024-01-02', + name: label, + plugin_id: pluginId, + plugin_unique_identifier: `${pluginId}-uid`, + declaration: { + category: 'tool', + name: pluginId, + label: { en_US: label }, + description: { en_US: `${label} description` }, + tags, + } as PluginDetail['declaration'], + installation_id: `${pluginId}-install`, + tenant_id: 'tenant-1', + endpoints_setups: 0, + endpoints_active: 0, + version: '1.0.0', + latest_version: '1.0.0', + latest_unique_identifier: `${pluginId}-uid`, + source: 'marketplace' as PluginDetail['source'], + status: 'active', + deprecated_reason: '', + alternative_plugin_id: '', +}) as PluginDetail + +describe('PluginsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mockState.filters = { categories: [], tags: [], searchQuery: '' } + mockState.currentPluginID = undefined + mockUseInstalledPluginList.mockReturnValue({ + data: { plugins: [] }, + isLoading: false, + isFetching: false, + isLastPage: true, + loadNextPage: mockLoadNextPage, + }) + mockPluginListWithLatestVersion.mockReturnValue([]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the loading state while the plugin list is pending', () => { + mockUseInstalledPluginList.mockReturnValue({ + data: { plugins: [] }, + isLoading: true, + isFetching: false, + isLastPage: true, + loadNextPage: mockLoadNextPage, + }) + + render() + + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('filters the list and exposes the load-more action', () => { + mockState.filters.searchQuery = 'alpha' + mockPluginListWithLatestVersion.mockReturnValue([ + createPlugin('alpha-tool', 'Alpha Tool', ['search']), + createPlugin('beta-tool', 'Beta Tool', ['rag']), + ]) + mockUseInstalledPluginList.mockReturnValue({ + data: { plugins: [] }, + isLoading: false, + isFetching: false, + isLastPage: false, + loadNextPage: mockLoadNextPage, + }) + + render() + + expect(screen.getByTestId('plugin-list')).toHaveTextContent('alpha-tool') + expect(screen.queryByText('beta-tool')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('workflow.common.loadMore')) + fireEvent.click(screen.getByTestId('filter-management')) + vi.runAllTimers() + + expect(mockLoadNextPage).toHaveBeenCalled() + expect(mockSetFilters).toHaveBeenCalledWith({ + categories: [], + tags: [], + searchQuery: 'beta', + }) + }) + + it('renders the empty state and keeps the current plugin detail in sync', () => { + mockState.currentPluginID = 'beta-tool' + mockState.filters.searchQuery = 'missing' + mockPluginListWithLatestVersion.mockReturnValue([ + createPlugin('beta-tool', 'Beta Tool'), + ]) + + render() + + expect(screen.getByTestId('empty-state')).toBeInTheDocument() + expect(screen.getByTestId('plugin-detail-panel')).toHaveTextContent('beta-tool') + + fireEvent.click(screen.getByText('hide detail')) + fireEvent.click(screen.getByText('refresh detail')) + + expect(mockSetCurrentPluginID).toHaveBeenCalledWith(undefined) + expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts b/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts new file mode 100644 index 0000000000..7286ff549f --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/constant.spec.ts @@ -0,0 +1,32 @@ +import type { Category, Tag } from '../constant' +import { describe, expect, it } from 'vitest' + +describe('filter-management constant types', () => { + it('accepts tag objects with binding counts', () => { + const tag: Tag = { + id: 'tag-1', + name: 'search', + type: 'plugin', + binding_count: 3, + } + + expect(tag).toEqual({ + id: 'tag-1', + name: 'search', + type: 'plugin', + binding_count: 3, + }) + }) + + it('accepts supported category names', () => { + const category: Category = { + name: 'tool', + binding_count: 8, + } + + expect(category).toEqual({ + name: 'tool', + binding_count: 8, + }) + }) +}) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx new file mode 100644 index 0000000000..ff3cd3d97c --- /dev/null +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TagFilter from '../tag-filter' + +let portalOpen = false + +vi.mock('../../../hooks', () => ({ + useTags: () => ({ + tags: [ + { name: 'agent', label: 'Agent' }, + { name: 'rag', label: 'RAG' }, + { name: 'search', label: 'Search' }, + ], + getTagLabel: (name: string) => ({ + agent: 'Agent', + rag: 'RAG', + search: 'Search', + }[name] ?? name), + }), +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ + PortalToFollowElem: ({ + children, + open, + }: { + children: React.ReactNode + open: boolean + }) => { + portalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => , + PortalToFollowElemContent: ({ + children, + }: { + children: React.ReactNode + }) => portalOpen ?
{children}
: null, +})) + +describe('TagFilter', () => { + beforeEach(() => { + vi.clearAllMocks() + portalOpen = false + }) + + it('renders selected tag labels and the overflow counter', () => { + render() + + expect(screen.getByText('Agent,RAG')).toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('filters options by search text and toggles tag selection', () => { + const onChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId('trigger')) + const portal = screen.getByTestId('portal-content') + + fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } }) + + expect(within(portal).queryByText('Agent')).not.toBeInTheDocument() + expect(within(portal).getByText('RAG')).toBeInTheDocument() + + fireEvent.click(within(portal).getByText('RAG')) + + expect(onChange).toHaveBeenCalledWith(['agent', 'rag']) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts new file mode 100644 index 0000000000..36450a4386 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/config.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { defaultValue } from '../config' +import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../types' + +describe('auto-update config', () => { + it('provides the expected default auto update value', () => { + expect(defaultValue).toEqual({ + strategy_setting: AUTO_UPDATE_STRATEGY.disabled, + upgrade_time_of_day: 0, + upgrade_mode: AUTO_UPDATE_MODE.update_all, + exclude_plugins: [], + include_plugins: [], + }) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx new file mode 100644 index 0000000000..d205682690 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-data-placeholder.spec.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import NoDataPlaceholder from '../no-data-placeholder' + +describe('NoDataPlaceholder', () => { + it('renders the no-found state by default', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noFound')).toBeInTheDocument() + }) + + it('renders the no-installed state when noPlugins is true', () => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + expect(screen.getByText('plugin.autoUpdate.noPluginPlaceholder.noInstalled')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx new file mode 100644 index 0000000000..ba172ad3d6 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/no-plugin-selected.spec.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import NoPluginSelected from '../no-plugin-selected' +import { AUTO_UPDATE_MODE } from '../types' + +describe('NoPluginSelected', () => { + it('renders partial mode placeholder', () => { + render() + + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.partial')).toBeInTheDocument() + }) + + it('renders exclude mode placeholder', () => { + render() + + expect(screen.getByText('plugin.autoUpdate.upgradeModePlaceholder.exclude')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx new file mode 100644 index 0000000000..4330f35bb4 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-picker.spec.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import PluginsPicker from '../plugins-picker' +import { AUTO_UPDATE_MODE } from '../types' + +const mockToolPicker = vi.fn() + +vi.mock('@/app/components/base/button', () => ({ + default: ({ + children, + }: { + children: React.ReactNode + }) => , +})) + +vi.mock('../no-plugin-selected', () => ({ + default: ({ updateMode }: { updateMode: AUTO_UPDATE_MODE }) =>
{updateMode}
, +})) + +vi.mock('../plugins-selected', () => ({ + default: ({ plugins }: { plugins: string[] }) =>
{plugins.join(',')}
, +})) + +vi.mock('../tool-picker', () => ({ + default: (props: Record) => { + mockToolPicker(props) + return
tool-picker
+ }, +})) + +describe('PluginsPicker', () => { + it('renders the empty state when no plugins are selected', () => { + render( + , + ) + + expect(screen.getByTestId('no-plugin-selected')).toHaveTextContent(AUTO_UPDATE_MODE.partial) + expect(screen.queryByTestId('plugins-selected')).not.toBeInTheDocument() + expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({ + value: [], + isShow: false, + onShowChange: expect.any(Function), + })) + }) + + it('renders selected plugins summary and clears them', () => { + const onChange = vi.fn() + render( + , + ) + + expect(screen.getByText('plugin.autoUpdate.excludeUpdate:{"num":2}')).toBeInTheDocument() + expect(screen.getByTestId('plugins-selected')).toHaveTextContent('dify/plugin-1,dify/plugin-2') + + fireEvent.click(screen.getByText('plugin.autoUpdate.operation.clearAll')) + + expect(onChange).toHaveBeenCalledWith([]) + }) + + it('passes the select button trigger into ToolPicker', () => { + render( + , + ) + + expect(screen.getByTestId('tool-picker')).toBeInTheDocument() + expect(mockToolPicker).toHaveBeenCalledWith(expect.objectContaining({ + trigger: expect.anything(), + })) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx new file mode 100644 index 0000000000..cc4693f89c --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/plugins-selected.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import PluginsSelected from '../plugins-selected' + +vi.mock('@/config', () => ({ + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
{src}
, +})) + +describe('PluginsSelected', () => { + it('renders all selected plugin icons when the count is below the limit', () => { + render() + + expect(screen.getAllByTestId('plugin-icon')).toHaveLength(2) + expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument() + expect(screen.queryByText('+1')).not.toBeInTheDocument() + }) + + it('renders the overflow badge when more than fourteen plugins are selected', () => { + const plugins = Array.from({ length: 16 }, (_, index) => `dify/plugin-${index}`) + render() + + expect(screen.getAllByTestId('plugin-icon')).toHaveLength(14) + expect(screen.getByText('+2')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx new file mode 100644 index 0000000000..aec57a2739 --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx @@ -0,0 +1,100 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import StrategyPicker from '../strategy-picker' +import { AUTO_UPDATE_STRATEGY } from '../types' + +let portalOpen = false + +vi.mock('@/app/components/base/button', () => ({ + default: ({ + children, + }: { + children: React.ReactNode + }) => {children}, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await import('react') + return { + PortalToFollowElem: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => { + portalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void + }) => ( + + ), + PortalToFollowElemContent: ({ + children, + }: { + children: React.ReactNode + }) => portalOpen ?
{children}
: null, + } +}) + +describe('StrategyPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + portalOpen = false + }) + + it('renders the selected strategy label in the trigger', () => { + render( + , + ) + + expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name') + }) + + it('opens the option list when the trigger is clicked', () => { + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger')) + + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1) + expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument() + }) + + it('calls onChange when a new strategy is selected', () => { + const onChange = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('trigger')) + fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name')) + + expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx new file mode 100644 index 0000000000..f15fe5933f --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-item.spec.tsx @@ -0,0 +1,65 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ToolItem from '../tool-item' + +vi.mock('@/config', () => ({ + MARKETPLACE_API_PREFIX: 'https://marketplace.example.com', +})) + +vi.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +vi.mock('@/i18n-config', () => ({ + renderI18nObject: (value: Record, language: string) => value[language], +})) + +vi.mock('@/app/components/plugins/card/base/card-icon', () => ({ + default: ({ src }: { src: string }) =>
{src}
, +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ + checked, + onCheck, + }: { + checked?: boolean + onCheck: () => void + }) => ( + + ), +})) + +const payload = { + plugin_id: 'dify/plugin-1', + declaration: { + label: { + en_US: 'Plugin One', + zh_Hans: 'Plugin One', + }, + author: 'Dify', + }, +} as PluginDetail + +describe('ToolItem', () => { + it('renders plugin metadata and marketplace icon', () => { + render() + + expect(screen.getByText('Plugin One')).toBeInTheDocument() + expect(screen.getByText('Dify')).toBeInTheDocument() + expect(screen.getByText('https://marketplace.example.com/plugins/dify/plugin-1/icon')).toBeInTheDocument() + expect(screen.getByText('true')).toBeInTheDocument() + }) + + it('calls onCheckChange when checkbox is clicked', () => { + const onCheckChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId('checkbox')) + + expect(onCheckChange).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx new file mode 100644 index 0000000000..9e63622d3f --- /dev/null +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx @@ -0,0 +1,248 @@ +import type { PluginDetail } from '@/app/components/plugins/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginSource } from '@/app/components/plugins/types' +import ToolPicker from '../tool-picker' + +let portalOpen = false + +const mockInstalledPluginList = vi.hoisted(() => ({ + data: { + plugins: [] as PluginDetail[], + }, + isLoading: false, +})) + +vi.mock('@/service/use-plugins', () => ({ + useInstalledPluginList: () => mockInstalledPluginList, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
loading
, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', async () => { + const React = await import('react') + return { + PortalToFollowElem: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => { + portalOpen = open + return
{children}
+ }, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick: () => void + }) => , + PortalToFollowElemContent: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => portalOpen ?
{children}
: null, + } +}) + +vi.mock('@/app/components/plugins/marketplace/search-box', () => ({ + default: ({ + search, + tags, + onSearchChange, + onTagsChange, + placeholder, + }: { + search: string + tags: string[] + onSearchChange: (value: string) => void + onTagsChange: (value: string[]) => void + placeholder: string + }) => ( +
+
{placeholder}
+
{search}
+
{tags.join(',')}
+ + +
+ ), +})) + +vi.mock('../no-data-placeholder', () => ({ + default: ({ + noPlugins, + }: { + noPlugins?: boolean + }) =>
{String(noPlugins)}
, +})) + +vi.mock('../tool-item', () => ({ + default: ({ + payload, + isChecked, + onCheckChange, + }: { + payload: PluginDetail + isChecked?: boolean + onCheckChange: () => void + }) => ( +
+ {payload.plugin_id} + {String(isChecked)} + +
+ ), +})) + +const createPlugin = ( + pluginId: string, + source: PluginDetail['source'], + category: string, + tags: string[], +): PluginDetail => ({ + plugin_id: pluginId, + source, + declaration: { + category, + tags, + }, +} as PluginDetail) + +describe('ToolPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + portalOpen = false + mockInstalledPluginList.data = { + plugins: [], + } + mockInstalledPluginList.isLoading = false + }) + + it('toggles popup visibility from the trigger', () => { + const onShowChange = vi.fn() + render( + trigger} + value={[]} + onChange={vi.fn()} + isShow={false} + onShowChange={onShowChange} + />, + ) + + fireEvent.click(screen.getByTestId('trigger')) + + expect(onShowChange).toHaveBeenCalledWith(true) + }) + + it('renders loading content while installed plugins are loading', () => { + mockInstalledPluginList.isLoading = true + + render( + trigger} + value={[]} + onChange={vi.fn()} + isShow + onShowChange={vi.fn()} + />, + ) + + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('renders no-data placeholder when there are no matching marketplace plugins', () => { + render( + trigger} + value={[]} + onChange={vi.fn()} + isShow + onShowChange={vi.fn()} + />, + ) + + expect(screen.getByTestId('no-data')).toHaveTextContent('true') + }) + + it('filters by plugin type, tags, and query', () => { + mockInstalledPluginList.data = { + plugins: [ + createPlugin('tool-search', PluginSource.marketplace, 'tool', ['search']), + createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']), + createPlugin('model-agent', PluginSource.marketplace, 'model', ['agent']), + createPlugin('github-tool', PluginSource.github, 'tool', ['rag']), + ], + } + + render( + trigger} + value={[]} + onChange={vi.fn()} + isShow + onShowChange={vi.fn()} + />, + ) + + expect(screen.getAllByTestId('tool-item')).toHaveLength(3) + expect(screen.queryByText('github-tool')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('plugin.category.models')) + expect(screen.getAllByTestId('tool-item')).toHaveLength(1) + expect(screen.getByText('model-agent')).toBeInTheDocument() + + fireEvent.click(screen.getByText('plugin.category.tools')) + expect(screen.getAllByTestId('tool-item')).toHaveLength(2) + + fireEvent.click(screen.getByTestId('set-tags')) + expect(screen.getAllByTestId('tool-item')).toHaveLength(1) + expect(screen.getByText('tool-rag')).toBeInTheDocument() + + fireEvent.click(screen.getByTestId('set-query')) + expect(screen.getAllByTestId('tool-item')).toHaveLength(1) + expect(screen.getByTestId('search-state')).toHaveTextContent('tool-rag') + }) + + it('adds and removes plugin ids from the selection', () => { + mockInstalledPluginList.data = { + plugins: [ + createPlugin('tool-rag', PluginSource.marketplace, 'tool', ['rag']), + createPlugin('tool-search', PluginSource.marketplace, 'tool', ['search']), + ], + } + const onChange = vi.fn() + const { rerender } = render( + trigger} + value={['tool-rag']} + onChange={onChange} + isShow + onShowChange={vi.fn()} + />, + ) + + fireEvent.click(screen.getByTestId('toggle-tool-search')) + expect(onChange).toHaveBeenCalledWith(['tool-rag', 'tool-search']) + + rerender( + trigger} + value={['tool-rag']} + onChange={onChange} + isShow + onShowChange={vi.fn()} + />, + ) + + fireEvent.click(screen.getByTestId('toggle-tool-rag')) + expect(onChange).toHaveBeenCalledWith([]) + }) +}) diff --git a/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx new file mode 100644 index 0000000000..b66ab20a45 --- /dev/null +++ b/web/app/components/plugins/update-plugin/__tests__/from-market-place.spec.tsx @@ -0,0 +1,226 @@ +import type { UpdateFromMarketPlacePayload } from '@/app/components/plugins/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PluginCategoryEnum, TaskStatus } from '@/app/components/plugins/types' +import UpdateFromMarketplace from '../from-market-place' + +const { + mockStop, + mockCheck, + mockHandleRefetch, + mockInvalidateReferenceSettings, + mockRemoveAutoUpgrade, + mockUpdateFromMarketPlace, + mockToastError, +} = vi.hoisted(() => ({ + mockStop: vi.fn(), + mockCheck: vi.fn(), + mockHandleRefetch: vi.fn(), + mockInvalidateReferenceSettings: vi.fn(), + mockRemoveAutoUpgrade: vi.fn(), + mockUpdateFromMarketPlace: vi.fn(), + mockToastError: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogCloseButton: () => , +})) + +vi.mock('@/app/components/base/badge/index', () => ({ + __esModule: true, + BadgeState: { + Warning: 'warning', + }, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => , +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: mockToastError, + }, +})) + +vi.mock('@/app/components/plugins/card', () => ({ + default: ({ titleLeft, payload }: { titleLeft: React.ReactNode, payload: { label: Record } }) => ( +
+
{payload.label.en_US}
+
{titleLeft}
+
+ ), +})) + +vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({ + default: () => ({ + check: mockCheck, + stop: mockStop, + }), +})) + +vi.mock('@/app/components/plugins/install-plugin/utils', () => ({ + pluginManifestToCardPluginProps: (payload: unknown) => payload, +})) + +vi.mock('@/service/plugins', () => ({ + updateFromMarketPlace: mockUpdateFromMarketPlace, +})) + +vi.mock('@/service/use-plugins', () => ({ + usePluginTaskList: () => ({ + handleRefetch: mockHandleRefetch, + }), + useRemoveAutoUpgrade: () => ({ + mutateAsync: mockRemoveAutoUpgrade, + }), + useInvalidateReferenceSettings: () => mockInvalidateReferenceSettings, +})) + +vi.mock('../install-plugin/base/use-get-icon', () => ({ + default: () => ({ + getIconUrl: async (icon: string) => `https://cdn.example.com/${icon}`, + }), +})) + +vi.mock('../downgrade-warning', () => ({ + default: ({ + onCancel, + onJustDowngrade, + onExcludeAndDowngrade, + }: { + onCancel: () => void + onJustDowngrade: () => void + onExcludeAndDowngrade: () => void + }) => ( +
+ + + +
+ ), +})) + +const createPayload = (overrides: Partial = {}): UpdateFromMarketPlacePayload => ({ + category: PluginCategoryEnum.tool, + originalPackageInfo: { + id: 'plugin@1.0.0', + payload: { + version: '1.0.0', + icon: 'plugin.png', + label: { en_US: 'Plugin Label' }, + } as UpdateFromMarketPlacePayload['originalPackageInfo']['payload'], + }, + targetPackageInfo: { + id: 'plugin@2.0.0', + version: '2.0.0', + }, + ...overrides, +}) + +describe('UpdateFromMarketplace', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheck.mockResolvedValue({ status: TaskStatus.success }) + mockUpdateFromMarketPlace.mockResolvedValue({ + all_installed: true, + task_id: 'task-1', + }) + }) + + it('renders the upgrade modal content and current version transition', async () => { + render( + , + ) + + expect(screen.getByText('plugin.upgrade.title')).toBeInTheDocument() + expect(screen.getByText('plugin.upgrade.description')).toBeInTheDocument() + expect(screen.getByText('1.0.0 -> 2.0.0')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByTestId('plugin-card')).toHaveTextContent('Plugin Label') + }) + }) + + it('submits the marketplace upgrade and calls onSave when installation is immediate', async () => { + const onSave = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('plugin.upgrade.upgrade')) + + await waitFor(() => { + expect(mockUpdateFromMarketPlace).toHaveBeenCalledWith({ + original_plugin_unique_identifier: 'plugin@1.0.0', + new_plugin_unique_identifier: 'plugin@2.0.0', + }) + expect(onSave).toHaveBeenCalled() + }) + }) + + it('surfaces failed upgrade messages from the response task payload', async () => { + mockUpdateFromMarketPlace.mockResolvedValue({ + task: { + status: TaskStatus.failed, + plugins: [{ + plugin_unique_identifier: 'plugin@2.0.0', + message: 'upgrade failed', + }], + }, + }) + + render( + , + ) + + fireEvent.click(screen.getByText('plugin.upgrade.upgrade')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('upgrade failed') + }) + }) + + it('removes auto-upgrade before downgrading when the warning modal is shown', async () => { + render( + , + ) + + fireEvent.click(screen.getByText('exclude and downgrade')) + + await waitFor(() => { + expect(mockRemoveAutoUpgrade).toHaveBeenCalledWith({ plugin_id: 'plugin-1' }) + expect(mockInvalidateReferenceSettings).toHaveBeenCalled() + expect(mockUpdateFromMarketPlace).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx new file mode 100644 index 0000000000..b65c6a6e42 --- /dev/null +++ b/web/app/components/plugins/update-plugin/__tests__/plugin-version-picker.spec.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PluginVersionPicker from '../plugin-version-picker' + +type VersionItem = { + version: string + unique_identifier: string + created_at: string +} + +const mockVersionList = vi.hoisted(() => ({ + data: { + versions: [] as VersionItem[], + }, +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatDate: (value: string, format: string) => `${value}:${format}`, + }), +})) + +vi.mock('@/service/use-plugins', () => ({ + useVersionListOfPlugin: () => ({ + data: mockVersionList, + }), +})) + +describe('PluginVersionPicker', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVersionList.data.versions = [ + { + version: '2.0.0', + unique_identifier: 'uid-current', + created_at: '2024-01-02', + }, + { + version: '1.0.0', + unique_identifier: 'uid-old', + created_at: '2023-12-01', + }, + ] + }) + + it('renders version options and highlights the current version', () => { + render( + trigger} + onSelect={vi.fn()} + />, + ) + + expect(screen.getByText('plugin.detailPanel.switchVersion')).toBeInTheDocument() + expect(screen.getByText('2.0.0')).toBeInTheDocument() + expect(screen.getByText('2024-01-02:appLog.dateTimeFormat')).toBeInTheDocument() + expect(screen.getByText('CURRENT')).toBeInTheDocument() + }) + + it('calls onSelect with downgrade metadata and closes the picker', () => { + const onSelect = vi.fn() + const onShowChange = vi.fn() + + render( + trigger} + onSelect={onSelect} + />, + ) + + fireEvent.click(screen.getByText('1.0.0')) + + expect(onSelect).toHaveBeenCalledWith({ + version: '1.0.0', + unique_identifier: 'uid-old', + isDowngrade: true, + }) + expect(onShowChange).toHaveBeenCalledWith(false) + }) + + it('does not call onSelect when the current version is clicked', () => { + const onSelect = vi.fn() + + render( + trigger} + onSelect={onSelect} + />, + ) + + fireEvent.click(screen.getByText('2.0.0')) + + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx new file mode 100644 index 0000000000..fcb208fc67 --- /dev/null +++ b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx @@ -0,0 +1,141 @@ +import type { EnvironmentVariable } from '@/app/components/workflow/types' +import { act, fireEvent, render, screen } from '@testing-library/react' +import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' +import RagPipelineChildren from '../rag-pipeline-children' + +let mockShowImportDSLModal = false +let mockSubscription: ((value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) | null = null + +const { + mockSetShowImportDSLModal, + mockHandlePaneContextmenuCancel, + mockExportCheck, + mockHandleExportDSL, + mockUseRagPipelineSearch, +} = vi.hoisted(() => ({ + mockSetShowImportDSLModal: vi.fn((value: boolean) => { + mockShowImportDSLModal = value + }), + mockHandlePaneContextmenuCancel: vi.fn(), + mockExportCheck: vi.fn(), + mockHandleExportDSL: vi.fn(), + mockUseRagPipelineSearch: vi.fn(), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { + useSubscription: (callback: (value: { type: string, payload?: { data?: EnvironmentVariable[] } }) => void) => { + mockSubscription = callback + }, + }, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + showImportDSLModal: boolean + setShowImportDSLModal: typeof mockSetShowImportDSLModal + }) => unknown) => selector({ + showImportDSLModal: mockShowImportDSLModal, + setShowImportDSLModal: mockSetShowImportDSLModal, + }), +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useDSL: () => ({ + exportCheck: mockExportCheck, + handleExportDSL: mockHandleExportDSL, + }), + usePanelInteractions: () => ({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + }), +})) + +vi.mock('../../hooks/use-rag-pipeline-search', () => ({ + useRagPipelineSearch: mockUseRagPipelineSearch, +})) + +vi.mock('../../../workflow/plugin-dependency', () => ({ + default: () =>
, +})) + +vi.mock('../panel', () => ({ + default: () =>
, +})) + +vi.mock('../publish-toast', () => ({ + default: () =>
, +})) + +vi.mock('../rag-pipeline-header', () => ({ + default: () =>
, +})) + +vi.mock('../update-dsl-modal', () => ({ + default: ({ onCancel }: { onCancel: () => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ + default: ({ + envList, + onConfirm, + onClose, + }: { + envList: EnvironmentVariable[] + onConfirm: () => void + onClose: () => void + }) => ( +
+
{envList.map(env => env.name).join(',')}
+ + +
+ ), +})) + +describe('RagPipelineChildren', () => { + beforeEach(() => { + vi.clearAllMocks() + mockShowImportDSLModal = false + mockSubscription = null + }) + + it('should render the main pipeline children and the import modal when enabled', () => { + mockShowImportDSLModal = true + + render() + + fireEvent.click(screen.getByText('close import')) + + expect(mockUseRagPipelineSearch).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument() + expect(screen.getByTestId('rag-header')).toBeInTheDocument() + expect(screen.getByTestId('rag-panel')).toBeInTheDocument() + expect(screen.getByTestId('publish-toast')).toBeInTheDocument() + expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument() + expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false) + }) + + it('should show the DSL export confirmation modal after receiving the export event', () => { + render() + + act(() => { + mockSubscription?.({ + type: DSL_EXPORT_CHECK, + payload: { + data: [{ name: 'API_KEY' } as EnvironmentVariable], + }, + }) + }) + + fireEvent.click(screen.getByText('confirm export')) + + expect(screen.getByTestId('dsl-export-modal')).toHaveTextContent('API_KEY') + expect(mockHandleExportDSL).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx new file mode 100644 index 0000000000..1854b2a683 --- /dev/null +++ b/web/app/components/rag-pipeline/components/__tests__/screenshot.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import PipelineScreenShot from '../screenshot' + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: 'dark', + }), +})) + +vi.mock('@/utils/var', () => ({ + basePath: '/console', +})) + +describe('PipelineScreenShot', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build themed screenshot sources', () => { + const { container } = render() + const sources = container.querySelectorAll('source') + + expect(sources).toHaveLength(3) + expect(sources[0]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline.png') + expect(sources[1]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@2x.png') + expect(sources[2]).toHaveAttribute('srcset', '/console/screenshots/dark/Pipeline@3x.png') + expect(screen.getByAltText('Pipeline Screenshot')).toHaveAttribute('src', '/console/screenshots/dark/Pipeline.png') + }) +}) diff --git a/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx new file mode 100644 index 0000000000..43dffb80f9 --- /dev/null +++ b/web/app/components/rag-pipeline/components/chunk-card-list/__tests__/q-a-item.spec.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import QAItem from '../q-a-item' +import { QAItemType } from '../types' + +describe('QAItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the question prefix', () => { + render() + + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('What is Dify?')).toBeInTheDocument() + }) + + it('should render the answer prefix', () => { + render() + + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('An LLM app platform.')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts new file mode 100644 index 0000000000..e4e53a4c5b --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/__tests__/utils.spec.ts @@ -0,0 +1,97 @@ +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { VAR_ITEM_TEMPLATE_IN_PIPELINE } from '@/config' +import { PipelineInputVarType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' +import { convertFormDataToINputField, convertToInputFieldFormData } from '../utils' + +describe('input-field editor utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should convert pipeline input vars into form data', () => { + const result = convertToInputFieldFormData({ + type: PipelineInputVarType.multiFiles, + label: 'Upload files', + variable: 'documents', + max_length: 5, + default_value: 'initial-value', + required: false, + tooltips: 'Tooltip text', + options: ['a', 'b'], + placeholder: 'Select files', + unit: 'MB', + allowed_file_upload_methods: [TransferMethod.local_file], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['pdf'], + }) + + expect(result).toEqual({ + type: PipelineInputVarType.multiFiles, + label: 'Upload files', + variable: 'documents', + maxLength: 5, + default: 'initial-value', + required: false, + tooltips: 'Tooltip text', + options: ['a', 'b'], + placeholder: 'Select files', + unit: 'MB', + allowedFileUploadMethods: [TransferMethod.local_file], + allowedTypesAndExtensions: { + allowedFileTypes: [SupportUploadFileTypes.document], + allowedFileExtensions: ['pdf'], + }, + }) + }) + + it('should fall back to the default input variable template', () => { + const result = convertToInputFieldFormData() + + expect(result).toEqual({ + type: VAR_ITEM_TEMPLATE_IN_PIPELINE.type, + label: VAR_ITEM_TEMPLATE_IN_PIPELINE.label, + variable: VAR_ITEM_TEMPLATE_IN_PIPELINE.variable, + maxLength: undefined, + required: VAR_ITEM_TEMPLATE_IN_PIPELINE.required, + options: VAR_ITEM_TEMPLATE_IN_PIPELINE.options, + allowedTypesAndExtensions: {}, + }) + }) + + it('should convert form data back into pipeline input variables', () => { + const result = convertFormDataToINputField({ + type: PipelineInputVarType.select, + label: 'Category', + variable: 'category', + maxLength: 10, + default: 'books', + required: true, + tooltips: 'Pick one', + options: ['books', 'music'], + placeholder: 'Choose', + unit: '', + allowedFileUploadMethods: [TransferMethod.local_file], + allowedTypesAndExtensions: { + allowedFileTypes: [SupportUploadFileTypes.document], + allowedFileExtensions: ['txt'], + }, + }) + + expect(result).toEqual({ + type: PipelineInputVarType.select, + label: 'Category', + variable: 'category', + max_length: 10, + default_value: 'books', + required: true, + tooltips: 'Pick one', + options: ['books', 'music'], + placeholder: 'Choose', + unit: '', + allowed_file_upload_methods: [TransferMethod.local_file], + allowed_file_types: [SupportUploadFileTypes.document], + allowed_file_extensions: ['txt'], + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx new file mode 100644 index 0000000000..0a5b748c7b --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/hidden-fields.spec.tsx @@ -0,0 +1,73 @@ +import type { InputFieldFormProps } from '../types' +import { render, screen } from '@testing-library/react' +import { useAppForm } from '@/app/components/base/form' +import HiddenFields from '../hidden-fields' +import { useHiddenConfigurations } from '../hooks' + +const { mockInputField } = vi.hoisted(() => ({ + mockInputField: vi.fn(({ config }: { config: { variable: string } }) => { + return function FieldComponent() { + return
{config.variable}
+ } + }), +})) + +vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({ + default: mockInputField, +})) + +vi.mock('../hooks', () => ({ + useHiddenConfigurations: vi.fn(), +})) + +describe('HiddenFields', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build fields from the hidden configuration list', () => { + vi.mocked(useHiddenConfigurations).mockReturnValue([ + { variable: 'default' }, + { variable: 'tooltips' }, + ] as ReturnType) + + const HiddenFieldsHarness = () => { + const initialData: InputFieldFormProps['initialData'] = { + variable: 'field_1', + options: ['option-a', 'option-b'], + } + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const HiddenFieldsComp = HiddenFields({ initialData }) + return + } + render() + + expect(useHiddenConfigurations).toHaveBeenCalledWith({ + options: ['option-a', 'option-b'], + }) + expect(mockInputField).toHaveBeenCalledTimes(2) + expect(screen.getAllByTestId('input-field')).toHaveLength(2) + expect(screen.getByText('default')).toBeInTheDocument() + expect(screen.getByText('tooltips')).toBeInTheDocument() + }) + + it('should render nothing when there are no hidden configurations', () => { + vi.mocked(useHiddenConfigurations).mockReturnValue([]) + + const HiddenFieldsHarness = () => { + const initialData: InputFieldFormProps['initialData'] = { options: [] } + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const HiddenFieldsComp = HiddenFields({ initialData }) + return + } + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx new file mode 100644 index 0000000000..e6bf21ed74 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/initial-fields.spec.tsx @@ -0,0 +1,85 @@ +import type { ComponentType } from 'react' +import { render, screen } from '@testing-library/react' +import { useConfigurations } from '../hooks' +import InitialFields from '../initial-fields' + +type MockForm = { + store: object + getFieldValue: (fieldName: string) => unknown + setFieldValue: (fieldName: string, value: unknown) => void +} + +const { + mockForm, + mockInputField, +} = vi.hoisted(() => ({ + mockForm: { + store: {}, + getFieldValue: vi.fn(), + setFieldValue: vi.fn(), + } as MockForm, + mockInputField: vi.fn(({ config }: { config: { variable: string } }) => { + return function FieldComponent() { + return
{config.variable}
+ } + }), +})) + +vi.mock('@/app/components/base/form', () => ({ + withForm: ({ render }: { + render: (props: { form: MockForm }) => React.ReactNode + }) => ({ form }: { form?: MockForm }) => render({ form: form ?? mockForm }), +})) + +vi.mock('@/app/components/base/form/form-scenarios/input-field/field', () => ({ + default: mockInputField, +})) + +vi.mock('../hooks', () => ({ + useConfigurations: vi.fn(), +})) + +describe('InitialFields', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build initial fields with the form accessors and supportFile flag', () => { + vi.mocked(useConfigurations).mockReturnValue([ + { variable: 'type' }, + { variable: 'label' }, + ] as ReturnType) + + const InitialFieldsComp = InitialFields({ + initialData: { variable: 'field_1' }, + supportFile: true, + }) as unknown as ComponentType + render() + + expect(useConfigurations).toHaveBeenCalledWith(expect.objectContaining({ + supportFile: true, + getFieldValue: expect.any(Function), + setFieldValue: expect.any(Function), + })) + expect(screen.getAllByTestId('input-field')).toHaveLength(2) + expect(screen.getByText('type')).toBeInTheDocument() + expect(screen.getByText('label')).toBeInTheDocument() + }) + + it('should delegate field accessors to the underlying form instance', () => { + vi.mocked(useConfigurations).mockReturnValue([] as ReturnType) + mockForm.getFieldValue = vi.fn(() => 'label-value') + mockForm.setFieldValue = vi.fn() + + const InitialFieldsComp = InitialFields({ supportFile: false }) as unknown as ComponentType + render() + + const call = vi.mocked(useConfigurations).mock.calls[0]?.[0] + const value = call?.getFieldValue('label') + call?.setFieldValue('label', 'next-value') + + expect(value).toBe('label-value') + expect(mockForm.getFieldValue).toHaveBeenCalledWith('label') + expect(mockForm.setFieldValue).toHaveBeenCalledWith('label', 'next-value') + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx new file mode 100644 index 0000000000..9dd943f969 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/__tests__/show-all-settings.spec.tsx @@ -0,0 +1,62 @@ +import type { InputFieldFormProps } from '../types' +import { fireEvent, render, screen } from '@testing-library/react' +import { useAppForm } from '@/app/components/base/form' +import { PipelineInputVarType } from '@/models/pipeline' +import { useHiddenFieldNames } from '../hooks' +import ShowAllSettings from '../show-all-settings' + +vi.mock('../hooks', () => ({ + useHiddenFieldNames: vi.fn(), +})) + +describe('ShowAllSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useHiddenFieldNames).mockReturnValue('default value, placeholder') + }) + + it('should render the summary and hidden field names', () => { + const ShowAllSettingsHarness = () => { + const initialData: InputFieldFormProps['initialData'] = { + type: PipelineInputVarType.textInput, + } + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const ShowAllSettingsComp = ShowAllSettings({ + initialData, + handleShowAllSettings: vi.fn(), + }) + return + } + render() + + expect(useHiddenFieldNames).toHaveBeenCalledWith(PipelineInputVarType.textInput) + expect(screen.getByText('appDebug.variableConfig.showAllSettings')).toBeInTheDocument() + expect(screen.getByText('default value, placeholder')).toBeInTheDocument() + }) + + it('should call the click handler when the row is pressed', () => { + const handleShowAllSettings = vi.fn() + const ShowAllSettingsHarness = () => { + const initialData: InputFieldFormProps['initialData'] = { + type: PipelineInputVarType.textInput, + } + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const ShowAllSettingsComp = ShowAllSettings({ + initialData, + handleShowAllSettings, + }) + return + } + render() + + fireEvent.click(screen.getByText('appDebug.variableConfig.showAllSettings')) + + expect(handleShowAllSettings).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx new file mode 100644 index 0000000000..4a738761d0 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-item.spec.tsx @@ -0,0 +1,83 @@ +import type { InputVar } from '@/models/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { PipelineInputVarType } from '@/models/pipeline' +import FieldItem from '../field-item' + +const createInputVar = (overrides: Partial = {}): InputVar => ({ + type: PipelineInputVarType.textInput, + label: 'Field Label', + variable: 'field_name', + max_length: 48, + default_value: '', + required: true, + tooltips: '', + options: [], + placeholder: '', + unit: '', + allowed_file_upload_methods: [], + allowed_file_types: [], + allowed_file_extensions: [], + ...overrides, +}) + +describe('FieldItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the variable, label, and required badge', () => { + render( + , + ) + + expect(screen.getByText('field_name')).toBeInTheDocument() + expect(screen.getByText('Field Label')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.start.required')).toBeInTheDocument() + }) + + it('should show edit and delete controls on hover and trigger both callbacks', () => { + const onClickEdit = vi.fn() + const onRemove = vi.fn() + const { container } = render( + , + ) + + fireEvent.mouseEnter(container.firstChild!) + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + fireEvent.click(buttons[1]) + + expect(onClickEdit).toHaveBeenCalledWith('custom_field') + expect(onRemove).toHaveBeenCalledWith(2) + }) + + it('should keep the row readonly when readonly is enabled', () => { + const onClickEdit = vi.fn() + const onRemove = vi.fn() + const { container } = render( + , + ) + + fireEvent.mouseEnter(container.firstChild!) + + expect(screen.queryAllByRole('button')).toHaveLength(0) + expect(onClickEdit).not.toHaveBeenCalled() + expect(onRemove).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx new file mode 100644 index 0000000000..5e49a4c9b4 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/field-list-container.spec.tsx @@ -0,0 +1,60 @@ +import type { InputVar } from '@/models/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { PipelineInputVarType } from '@/models/pipeline' +import FieldListContainer from '../field-list-container' + +const createInputVar = (variable: string): InputVar => ({ + type: PipelineInputVarType.textInput, + label: variable, + variable, + max_length: 48, + default_value: '', + required: true, + tooltips: '', + options: [], + placeholder: '', + unit: '', + allowed_file_upload_methods: [], + allowed_file_types: [], + allowed_file_extensions: [], +}) + +describe('FieldListContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the field items inside the sortable container', () => { + const onListSortChange = vi.fn() + const { container } = render( + , + ) + + expect(screen.getAllByText('field_1').length).toBeGreaterThan(0) + expect(screen.getAllByText('field_2').length).toBeGreaterThan(0) + expect(container.querySelector('.handle')).toBeInTheDocument() + expect(onListSortChange).not.toHaveBeenCalled() + }) + + it('should honor readonly mode for the rendered field rows', () => { + const { container } = render( + , + ) + + const firstRow = container.querySelector('.handle') + fireEvent.mouseEnter(firstRow!) + + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx new file mode 100644 index 0000000000..b0ab5d5312 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx @@ -0,0 +1,24 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { render, screen } from '@testing-library/react' +import Datasource from '../datasource' + +vi.mock('@/app/components/workflow/hooks', () => ({ + useToolIcon: () => 'tool-icon', +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: ({ toolIcon }: { toolIcon: string }) =>
{toolIcon}
, +})) + +describe('Datasource', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the datasource title and icon', () => { + render() + + expect(screen.getByTestId('block-icon')).toHaveTextContent('tool-icon') + expect(screen.getByText('Knowledge Base')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx new file mode 100644 index 0000000000..602a8a4708 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/global-inputs.spec.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react' +import GlobalInputs from '../global-inputs' + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + popupContent, + }: { + popupContent: React.ReactNode + }) =>
{popupContent}
, +})) + +describe('GlobalInputs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the title and tooltip copy', () => { + render() + + expect(screen.getByText('datasetPipeline.inputFieldPanel.globalInputs.title')).toBeInTheDocument() + expect(screen.getByTestId('tooltip')).toHaveTextContent('datasetPipeline.inputFieldPanel.globalInputs.tooltip') + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx new file mode 100644 index 0000000000..04701aeba4 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/data-source.spec.tsx @@ -0,0 +1,73 @@ +import type { Datasource } from '../../../test-run/types' +import { fireEvent, render, screen } from '@testing-library/react' +import DataSource from '../data-source' + +const { + mockOnSelect, + mockUseDraftPipelinePreProcessingParams, +} = vi.hoisted(() => ({ + mockOnSelect: vi.fn(), + mockUseDraftPipelinePreProcessingParams: vi.fn(() => ({ + data: { + variables: [{ variable: 'source' }], + }, + })), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useDraftPipelinePreProcessingParams: mockUseDraftPipelinePreProcessingParams, +})) + +vi.mock('../../../test-run/preparation/data-source-options', () => ({ + default: ({ + onSelect, + dataSourceNodeId, + }: { + onSelect: (data: Datasource) => void + dataSourceNodeId: string + }) => ( +
+ +
+ ), +})) + +vi.mock('../form', () => ({ + default: ({ variables }: { variables: Array<{ variable: string }> }) => ( +
{variables.map(item => item.variable).join(',')}
+ ), +})) + +describe('DataSource preview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the datasource selection step and forward selected values', () => { + render( + , + ) + + fireEvent.click(screen.getByText('select datasource')) + + expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepOneTitle')).toBeInTheDocument() + expect(screen.getByTestId('data-source-options')).toHaveAttribute('data-node-id', 'node-1') + expect(screen.getByTestId('preview-form')).toHaveTextContent('source') + expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-1', + node_id: 'node-1', + }, true) + expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'source-node' }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx new file mode 100644 index 0000000000..66299e112f --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx @@ -0,0 +1,64 @@ +import type { RAGPipelineVariables } from '@/models/pipeline' +import { render, screen } from '@testing-library/react' +import Form from '../form' + +type MockForm = { + id: string +} + +const { + mockForm, + mockBaseField, + mockUseInitialData, + mockUseConfigurations, +} = vi.hoisted(() => ({ + mockForm: { + id: 'form-1', + } as MockForm, + mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => { + return function FieldComponent() { + return
{config.variable}
+ } + }), + mockUseInitialData: vi.fn(() => ({ source: 'node-1' })), + mockUseConfigurations: vi.fn(() => [{ variable: 'source' }, { variable: 'chunkSize' }]), +})) + +vi.mock('@/app/components/base/form', () => ({ + useAppForm: () => mockForm, +})) + +vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ + default: mockBaseField, +})) + +vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ + useInitialData: mockUseInitialData, + useConfigurations: mockUseConfigurations, +})) + +describe('Preview form', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should build fields from the pipeline variable configuration', () => { + render(
) + + expect(mockUseInitialData).toHaveBeenCalled() + expect(mockUseConfigurations).toHaveBeenCalled() + expect(screen.getAllByTestId('base-field')).toHaveLength(2) + expect(screen.getByText('source')).toBeInTheDocument() + expect(screen.getByText('chunkSize')).toBeInTheDocument() + }) + + it('should prevent the native form submission', () => { + const { container } = render() + const form = container.querySelector('form')! + const submitEvent = new Event('submit', { bubbles: true, cancelable: true }) + + form.dispatchEvent(submitEvent) + + expect(submitEvent.defaultPrevented).toBe(true) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx new file mode 100644 index 0000000000..3e4944d775 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/process-documents.spec.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react' +import ProcessDocuments from '../process-documents' + +const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({ + data: { + variables: [{ variable: 'chunkSize' }], + }, +}))) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams, +})) + +vi.mock('../form', () => ({ + default: ({ variables }: { variables: Array<{ variable: string }> }) => ( +
{variables.map(item => item.variable).join(',')}
+ ), +})) + +describe('ProcessDocuments preview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the processing step and its variables', () => { + render() + + expect(screen.getByText('datasetPipeline.inputFieldPanel.preview.stepTwoTitle')).toBeInTheDocument() + expect(screen.getByTestId('preview-form')).toHaveTextContent('chunkSize') + expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-1', + node_id: 'node-2', + }, true) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx new file mode 100644 index 0000000000..8149bac144 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Header from '../header' + +const { + mockSetIsPreparingDataSource, + mockHandleCancelDebugAndPreviewPanel, + mockWorkflowStore, +} = vi.hoisted(() => ({ + mockSetIsPreparingDataSource: vi.fn(), + mockHandleCancelDebugAndPreviewPanel: vi.fn(), + mockWorkflowStore: { + getState: vi.fn(() => ({ + isPreparingDataSource: true, + setIsPreparingDataSource: vi.fn(), + })), + }, +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => mockWorkflowStore, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useWorkflowInteractions: () => ({ + handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, + }), +})) + +describe('TestRun header', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWorkflowStore.getState.mockReturnValue({ + isPreparingDataSource: true, + setIsPreparingDataSource: mockSetIsPreparingDataSource, + }) + }) + + it('should render the title and reset preparing state on close', () => { + render(
) + + fireEvent.click(screen.getByRole('button')) + + expect(screen.getByText('datasetPipeline.testRun.title')).toBeInTheDocument() + expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false) + expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1) + }) + + it('should only cancel the panel when the datasource preparation flag is false', () => { + mockWorkflowStore.getState.mockReturnValue({ + isPreparingDataSource: false, + setIsPreparingDataSource: mockSetIsPreparingDataSource, + }) + + render(
) + fireEvent.click(screen.getByRole('button')) + + expect(mockSetIsPreparingDataSource).not.toHaveBeenCalled() + expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx new file mode 100644 index 0000000000..b4eab3fe72 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/footer-tips.spec.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@testing-library/react' +import FooterTips from '../footer-tips' + +describe('FooterTips', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the localized footer copy', () => { + render() + + expect(screen.getByText('datasetPipeline.testRun.tooltip')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx new file mode 100644 index 0000000000..d5985f2969 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/step-indicator.spec.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import StepIndicator from '../step-indicator' + +describe('StepIndicator', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render all step labels and highlight the current step', () => { + const { container } = render( + , + ) + + expect(screen.getByText('Select source')).toBeInTheDocument() + expect(screen.getByText('Process docs')).toBeInTheDocument() + expect(screen.getByText('Run test')).toBeInTheDocument() + expect(container.querySelector('.bg-state-accent-solid')).toBeInTheDocument() + expect(screen.getByText('Process docs').parentElement).toHaveClass('text-state-accent-solid') + }) + + it('should keep inactive steps in the tertiary state', () => { + render( + , + ) + + expect(screen.getByText('Process docs').parentElement).toHaveClass('text-text-tertiary') + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx new file mode 100644 index 0000000000..83cb252943 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx @@ -0,0 +1,49 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { fireEvent, render, screen } from '@testing-library/react' +import OptionCard from '../option-card' + +vi.mock('@/app/components/workflow/hooks', () => ({ + useToolIcon: () => 'source-icon', +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: ({ toolIcon }: { toolIcon: string }) =>
{toolIcon}
, +})) + +describe('OptionCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render the datasource label and icon', () => { + render( + , + ) + + expect(screen.getByTestId('block-icon')).toHaveTextContent('source-icon') + expect(screen.getByText('Website Crawl')).toBeInTheDocument() + }) + + it('should call onClick with the card value and apply selected styles', () => { + const onClick = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('Online Drive')) + + expect(onClick).toHaveBeenCalledWith('online-drive') + expect(screen.getByText('Online Drive')).toHaveClass('text-text-primary') + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx new file mode 100644 index 0000000000..69f576eae7 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/actions.spec.tsx @@ -0,0 +1,67 @@ +import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions' +import { fireEvent, render, screen } from '@testing-library/react' +import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import Actions from '../actions' + +let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus } } | undefined + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { workflowRunningData: typeof mockWorkflowRunningData }) => unknown) => selector({ + workflowRunningData: mockWorkflowRunningData, + }), +})) + +const createFormParams = (overrides: Partial = {}): CustomActionsProps => ({ + form: { + handleSubmit: vi.fn(), + } as unknown as CustomActionsProps['form'], + isSubmitting: false, + canSubmit: true, + ...overrides, +}) + +describe('Document processing actions', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWorkflowRunningData = undefined + }) + + it('should render back/process actions and trigger both callbacks', () => { + const onBack = vi.fn() + const formParams = createFormParams() + + render() + + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.backToDataSource' })) + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })) + + expect(onBack).toHaveBeenCalledTimes(1) + expect(formParams.form.handleSubmit).toHaveBeenCalledTimes(1) + }) + + it('should disable processing when runDisabled or the workflow is already running', () => { + const { rerender } = render( + , + ) + + expect(screen.getByRole('button', { name: 'datasetPipeline.operations.process' })).toBeDisabled() + + mockWorkflowRunningData = { + result: { + status: WorkflowRunningStatus.Running, + }, + } + rerender( + , + ) + + expect(screen.getByRole('button', { name: /datasetPipeline\.operations\.process/i })).toBeDisabled() + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..822d553732 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/hooks.spec.ts @@ -0,0 +1,32 @@ +import { renderHook } from '@testing-library/react' +import { useInputVariables } from '../hooks' + +const mockUseDraftPipelineProcessingParams = vi.hoisted(() => vi.fn(() => ({ + data: { variables: [{ variable: 'chunkSize' }] }, + isFetching: true, +}))) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { pipelineId: string }) => string) => selector({ pipelineId: 'pipeline-1' }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useDraftPipelineProcessingParams: mockUseDraftPipelineProcessingParams, +})) + +describe('useInputVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should query processing params with the current pipeline id and datasource node id', () => { + const { result } = renderHook(() => useInputVariables('datasource-node')) + + expect(mockUseDraftPipelineProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-1', + node_id: 'datasource-node', + }) + expect(result.current.isFetchingParams).toBe(true) + expect(result.current.paramsConfig).toEqual({ variables: [{ variable: 'chunkSize' }] }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx new file mode 100644 index 0000000000..fcfa305bb3 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/options.spec.tsx @@ -0,0 +1,140 @@ +import type { ZodSchema } from 'zod' +import type { CustomActionsProps } from '@/app/components/base/form/components/form/actions' +import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Options from '../options' + +const { + mockFormValue, + mockHandleSubmit, + mockToastError, + mockBaseField, +} = vi.hoisted(() => ({ + mockFormValue: { chunkSize: 256 } as Record, + mockHandleSubmit: vi.fn(), + mockToastError: vi.fn(), + mockBaseField: vi.fn(({ config }: { config: { variable: string } }) => { + return function FieldComponent() { + return
{config.variable}
+ } + }), +})) + +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + error: mockToastError, + }, +})) + +vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ + default: mockBaseField, +})) + +vi.mock('@/app/components/base/form', () => ({ + useAppForm: ({ + onSubmit, + validators, + }: { + onSubmit: (params: { value: Record }) => void + validators?: { + onSubmit?: (params: { value: Record }) => string | undefined + } + }) => ({ + handleSubmit: () => { + const validationResult = validators?.onSubmit?.({ value: mockFormValue }) + if (!validationResult) + onSubmit({ value: mockFormValue }) + mockHandleSubmit() + }, + AppForm: ({ children }: { children: React.ReactNode }) =>
{children}
, + Actions: ({ CustomActions }: { CustomActions: (props: CustomActionsProps) => React.ReactNode }) => ( +
+ {CustomActions({ + form: { + handleSubmit: mockHandleSubmit, + } as unknown as CustomActionsProps['form'], + isSubmitting: false, + canSubmit: true, + })} +
+ ), + }), +})) + +const createSchema = (success: boolean): ZodSchema => ({ + safeParse: vi.fn(() => { + if (success) + return { success: true } + + return { + success: false, + error: { + issues: [{ + path: ['chunkSize'], + message: 'Invalid value', + }], + }, + } + }), +}) as unknown as ZodSchema + +describe('Document processing options', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render base fields and the custom actions slot', () => { + render( +
custom actions
} + onSubmit={vi.fn()} + />, + ) + + expect(screen.getByTestId('base-field')).toHaveTextContent('chunkSize') + expect(screen.getByTestId('form-actions')).toBeInTheDocument() + expect(screen.getByTestId('custom-actions')).toBeInTheDocument() + }) + + it('should validate and toast the first schema error before submitting', async () => { + const onSubmit = vi.fn() + const { container } = render( +
actions
} + onSubmit={onSubmit} + />, + ) + + fireEvent.submit(container.querySelector('form')!) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('Path: chunkSize Error: Invalid value') + }) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('should submit the parsed form value when validation succeeds', async () => { + const onSubmit = vi.fn() + const { container } = render( +
actions
} + onSubmit={onSubmit} + />, + ) + + fireEvent.submit(container.querySelector('form')!) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith(mockFormValue) + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts new file mode 100644 index 0000000000..376b529d40 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/result-preview/__tests__/utils.spec.ts @@ -0,0 +1,84 @@ +import { ChunkingMode } from '@/models/datasets' +import { formatPreviewChunks } from '../utils' + +vi.mock('@/config', () => ({ + RAG_PIPELINE_PREVIEW_CHUNK_NUM: 2, +})) + +describe('result preview utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return undefined for empty outputs', () => { + expect(formatPreviewChunks(undefined)).toBeUndefined() + expect(formatPreviewChunks(null)).toBeUndefined() + }) + + it('should format text chunks and limit them to the preview length', () => { + const result = formatPreviewChunks({ + chunk_structure: ChunkingMode.text, + preview: [ + { content: 'Chunk 1', summary: 'S1' }, + { content: 'Chunk 2', summary: 'S2' }, + { content: 'Chunk 3', summary: 'S3' }, + ], + }) + + expect(result).toEqual([ + { content: 'Chunk 1', summary: 'S1' }, + { content: 'Chunk 2', summary: 'S2' }, + ]) + }) + + it('should format paragraph and full-doc parent-child previews differently', () => { + const paragraph = formatPreviewChunks({ + chunk_structure: ChunkingMode.parentChild, + parent_mode: 'paragraph', + preview: [ + { content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] }, + { content: 'Parent 2', child_chunks: ['c4'] }, + { content: 'Parent 3', child_chunks: ['c5'] }, + ], + }) + const fullDoc = formatPreviewChunks({ + chunk_structure: ChunkingMode.parentChild, + parent_mode: 'full-doc', + preview: [ + { content: 'Parent 1', child_chunks: ['c1', 'c2', 'c3'] }, + ], + }) + + expect(paragraph).toEqual({ + parent_mode: 'paragraph', + parent_child_chunks: [ + { parent_content: 'Parent 1', parent_summary: undefined, child_contents: ['c1', 'c2', 'c3'], parent_mode: 'paragraph' }, + { parent_content: 'Parent 2', parent_summary: undefined, child_contents: ['c4'], parent_mode: 'paragraph' }, + ], + }) + expect(fullDoc).toEqual({ + parent_mode: 'full-doc', + parent_child_chunks: [ + { parent_content: 'Parent 1', child_contents: ['c1', 'c2'], parent_mode: 'full-doc' }, + ], + }) + }) + + it('should format qa previews and limit them to the preview size', () => { + const result = formatPreviewChunks({ + chunk_structure: ChunkingMode.qa, + qa_preview: [ + { question: 'Q1', answer: 'A1' }, + { question: 'Q2', answer: 'A2' }, + { question: 'Q3', answer: 'A3' }, + ], + }) + + expect(result).toEqual({ + qa_chunks: [ + { question: 'Q1', answer: 'A1' }, + { question: 'Q2', answer: 'A2' }, + ], + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx new file mode 100644 index 0000000000..0597bc3de8 --- /dev/null +++ b/web/app/components/rag-pipeline/components/panel/test-run/result/tabs/__tests__/tab.spec.tsx @@ -0,0 +1,64 @@ +import type { WorkflowRunningData } from '@/app/components/workflow/types' +import { fireEvent, render, screen } from '@testing-library/react' +import Tab from '../tab' + +const createWorkflowRunningData = (): WorkflowRunningData => ({ + task_id: 'task-1', + message_id: 'message-1', + conversation_id: 'conversation-1', + result: { + workflow_id: 'workflow-1', + inputs: '{}', + inputs_truncated: false, + process_data: '{}', + process_data_truncated: false, + outputs: '{}', + outputs_truncated: false, + status: 'succeeded', + elapsed_time: 10, + total_tokens: 20, + created_at: Date.now(), + finished_at: Date.now(), + steps: 1, + total_steps: 1, + }, + tracing: [], +}) + +describe('Tab', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render an active tab and pass its value on click', () => { + const onClick = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Preview' })) + + expect(screen.getByRole('button')).toHaveClass('border-util-colors-blue-brand-blue-brand-600') + expect(onClick).toHaveBeenCalledWith('preview') + }) + + it('should disable the tab when workflow run data is unavailable', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Trace' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Trace' })).toHaveClass('opacity-30') + }) +}) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx new file mode 100644 index 0000000000..493f3c3014 --- /dev/null +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/input-field-button.spec.tsx @@ -0,0 +1,35 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import InputFieldButton from '../input-field-button' + +const { + mockSetShowInputFieldPanel, + mockSetShowEnvPanel, +} = vi.hoisted(() => ({ + mockSetShowInputFieldPanel: vi.fn(), + mockSetShowEnvPanel: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useStore: (selector: (state: { + setShowInputFieldPanel: typeof mockSetShowInputFieldPanel + setShowEnvPanel: typeof mockSetShowEnvPanel + }) => unknown) => selector({ + setShowInputFieldPanel: mockSetShowInputFieldPanel, + setShowEnvPanel: mockSetShowEnvPanel, + }), +})) + +describe('InputFieldButton', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should open the input field panel and close the env panel', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.inputField' })) + + expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(true) + expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts b/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts new file mode 100644 index 0000000000..c90e702d8e --- /dev/null +++ b/web/app/components/rag-pipeline/utils/__tests__/nodes.spec.ts @@ -0,0 +1,92 @@ +import type { Viewport } from 'reactflow' +import type { Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { processNodesWithoutDataSource } from '../nodes' + +vi.mock('@/app/components/workflow/constants', () => ({ + CUSTOM_NODE: 'custom', + NODE_WIDTH_X_OFFSET: 400, + START_INITIAL_POSITION: { x: 100, y: 100 }, +})) + +vi.mock('@/app/components/workflow/nodes/data-source-empty/constants', () => ({ + CUSTOM_DATA_SOURCE_EMPTY_NODE: 'data-source-empty', +})) + +vi.mock('@/app/components/workflow/note-node/constants', () => ({ + CUSTOM_NOTE_NODE: 'note', +})) + +vi.mock('@/app/components/workflow/note-node/types', () => ({ + NoteTheme: { blue: 'blue' }, +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + generateNewNode: ({ id, type, data, position }: { id: string, type: string, data: object, position: { x: number, y: number } }) => ({ + newNode: { id, type, data, position }, + }), +})) + +describe('processNodesWithoutDataSource', () => { + it('should return the original nodes when a datasource node already exists', () => { + const nodes = [ + { + id: 'node-1', + type: 'custom', + data: { type: BlockEnum.DataSource }, + position: { x: 100, y: 100 }, + }, + ] as Node[] + const viewport: Viewport = { x: 0, y: 0, zoom: 1 } + + const result = processNodesWithoutDataSource(nodes, viewport) + + expect(result.nodes).toBe(nodes) + expect(result.viewport).toBe(viewport) + }) + + it('should prepend datasource empty and note nodes when the pipeline starts without a datasource', () => { + const nodes = [ + { + id: 'node-1', + type: 'custom', + data: { type: BlockEnum.KnowledgeBase }, + position: { x: 300, y: 200 }, + }, + ] as Node[] + + const result = processNodesWithoutDataSource(nodes, { x: 0, y: 0, zoom: 2 }) + + expect(result.nodes[0]).toEqual(expect.objectContaining({ + id: 'data-source-empty', + type: 'data-source-empty', + position: { x: -100, y: 200 }, + })) + expect(result.nodes[1]).toEqual(expect.objectContaining({ + id: 'note', + type: 'note', + position: { x: -100, y: 300 }, + })) + expect(result.viewport).toEqual({ + x: 400, + y: -200, + zoom: 2, + }) + }) + + it('should leave nodes unchanged when there is no custom node to anchor from', () => { + const nodes = [ + { + id: 'node-1', + type: 'note', + data: { type: BlockEnum.Answer }, + position: { x: 100, y: 100 }, + }, + ] as Node[] + + const result = processNodesWithoutDataSource(nodes) + + expect(result.nodes).toBe(nodes) + expect(result.viewport).toBeUndefined() + }) +}) diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts b/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts new file mode 100644 index 0000000000..6fe3576c26 --- /dev/null +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/examples.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import examples from '../examples' + +describe('edit-custom-collection examples', () => { + it('provides json, yaml, and blank templates in fixed order', () => { + expect(examples.map(example => example.key)).toEqual([ + 'json', + 'yaml', + 'blankTemplate', + ]) + }) + + it('contains representative OpenAPI content for each template', () => { + expect(examples[0].content).toContain('"openapi": "3.1.0"') + expect(examples[1].content).toContain('openapi: "3.0.0"') + expect(examples[2].content).toContain('"title": "Untitled"') + }) +}) diff --git a/web/app/components/tools/labels/__tests__/constant.spec.ts b/web/app/components/tools/labels/__tests__/constant.spec.ts new file mode 100644 index 0000000000..614476fb8c --- /dev/null +++ b/web/app/components/tools/labels/__tests__/constant.spec.ts @@ -0,0 +1,33 @@ +import type { Label } from '../constant' +import { describe, expect, it } from 'vitest' + +describe('tool label type contract', () => { + it('accepts string labels', () => { + const label: Label = { + name: 'agent', + label: 'Agent', + icon: 'robot', + } + + expect(label).toEqual({ + name: 'agent', + label: 'Agent', + icon: 'robot', + }) + }) + + it('accepts i18n labels', () => { + const label: Label = { + name: 'workflow', + label: { + en_US: 'Workflow', + zh_Hans: '工作流', + }, + } + + expect(label.label).toEqual({ + en_US: 'Workflow', + zh_Hans: '工作流', + }) + }) +}) diff --git a/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts b/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts new file mode 100644 index 0000000000..acf8aafdf8 --- /dev/null +++ b/web/app/components/tools/workflow-tool/__tests__/helpers.spec.ts @@ -0,0 +1,102 @@ +import type { TFunction } from 'i18next' +import { describe, expect, it } from 'vitest' +import { VarType } from '@/app/components/workflow/types' +import { + buildWorkflowToolRequestPayload, + getReservedWorkflowOutputParameters, + getWorkflowOutputParameters, + hasReservedWorkflowOutputConflict, + isWorkflowToolNameValid, + RESERVED_WORKFLOW_OUTPUTS, +} from '../helpers' + +describe('workflow-tool helpers', () => { + it('validates workflow tool names', () => { + expect(isWorkflowToolNameValid('')).toBe(true) + expect(isWorkflowToolNameValid('workflow_tool_1')).toBe(true) + expect(isWorkflowToolNameValid('workflow-tool')).toBe(false) + expect(isWorkflowToolNameValid('workflow tool')).toBe(false) + }) + + it('builds translated reserved workflow outputs', () => { + const t = ((key: string, options?: { ns?: string }) => `${options?.ns}:${key}`) as TFunction + + expect(getReservedWorkflowOutputParameters(t)).toEqual([ + { + ...RESERVED_WORKFLOW_OUTPUTS[0], + description: 'workflow:nodes.tool.outputVars.text', + }, + { + ...RESERVED_WORKFLOW_OUTPUTS[1], + description: 'workflow:nodes.tool.outputVars.files.title', + }, + { + ...RESERVED_WORKFLOW_OUTPUTS[2], + description: 'workflow:nodes.tool.outputVars.json', + }, + ]) + }) + + it('detects reserved output conflicts', () => { + expect(hasReservedWorkflowOutputConflict(RESERVED_WORKFLOW_OUTPUTS, 'text')).toBe(true) + expect(hasReservedWorkflowOutputConflict(RESERVED_WORKFLOW_OUTPUTS, 'custom')).toBe(false) + }) + + it('derives workflow output parameters from schema through helper wrapper', () => { + expect(getWorkflowOutputParameters([], { + type: 'object', + properties: { + text: { + type: VarType.string, + description: 'Result text', + }, + }, + })).toEqual([ + { + name: 'text', + description: 'Result text', + type: VarType.string, + }, + ]) + }) + + it('builds workflow tool request payload', () => { + expect(buildWorkflowToolRequestPayload({ + name: 'workflow_tool', + description: 'Workflow tool', + emoji: { + content: '🧠', + background: '#ffffff', + }, + label: 'Workflow Tool', + labels: ['agent', 'workflow'], + parameters: [ + { + name: 'question', + type: VarType.string, + required: true, + form: 'llm', + description: 'Question to ask', + }, + ], + privacyPolicy: 'https://example.com/privacy', + })).toEqual({ + name: 'workflow_tool', + description: 'Workflow tool', + icon: { + content: '🧠', + background: '#ffffff', + }, + label: 'Workflow Tool', + labels: ['agent', 'workflow'], + parameters: [ + { + name: 'question', + description: 'Question to ask', + form: 'llm', + }, + ], + privacy_policy: 'https://example.com/privacy', + }) + }) +}) diff --git a/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f3f229abea --- /dev/null +++ b/web/app/components/tools/workflow-tool/__tests__/index.spec.tsx @@ -0,0 +1,200 @@ +import type { WorkflowToolModalPayload } from '../index' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WorkflowToolAsModal from '../index' + +vi.mock('@/app/components/base/drawer-plus', () => ({ + default: ({ isShow, onHide, title, body }: { isShow: boolean, onHide: () => void, title: string, body: React.ReactNode }) => ( + isShow + ? ( +
+ {title} + + {body} +
+ ) + : null + ), +})) + +vi.mock('@/app/components/base/emoji-picker', () => ({ + default: ({ onSelect, onClose }: { onSelect: (icon: string, background: string) => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: ({ onClick, icon }: { onClick?: () => void, icon: string }) => ( + + ), +})) + +vi.mock('@/app/components/tools/labels/selector', () => ({ + default: ({ value, onChange }: { value: string[], onChange: (labels: string[]) => void }) => ( +
+ {value.join(',')} + +
+ ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ + children, + popupContent, + }: { + children?: React.ReactNode + popupContent?: React.ReactNode + }) => ( +
+ {children} + {popupContent} +
+ ), +})) + +vi.mock('../confirm-modal', () => ({ + default: ({ show, onClose, onConfirm }: { show: boolean, onClose: () => void, onConfirm: () => void }) => ( + show + ? ( +
+ + +
+ ) + : null + ), +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + success: (message: string) => mockToastNotify({ type: 'success', message }), + error: (message: string) => mockToastNotify({ type: 'error', message }), + }, +})) + +vi.mock('@/app/components/plugins/hooks', () => ({ + useTags: () => ({ + tags: [ + { name: 'label1', label: 'Label 1' }, + { name: 'label2', label: 'Label 2' }, + ], + }), +})) + +const createPayload = (overrides: Partial = {}): WorkflowToolModalPayload => ({ + icon: { content: '🔧', background: '#ffffff' }, + label: 'My Tool', + name: 'my_tool', + description: 'Tool description', + parameters: [ + { name: 'param1', description: 'Parameter 1', form: 'llm', required: true, type: 'string' }, + ], + outputParameters: [ + { name: 'output1', description: 'Output 1' }, + { name: 'text', description: 'Reserved output duplicate' }, + ], + labels: ['label1'], + privacy_policy: '', + workflow_app_id: 'workflow-app-1', + workflow_tool_id: 'workflow-tool-1', + ...overrides, +}) + +describe('WorkflowToolAsModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should create workflow tools with edited form values', async () => { + const user = userEvent.setup() + const onCreate = vi.fn() + + render( + , + ) + + await user.clear(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')) + await user.type(screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), 'Created Tool') + await user.click(screen.getByTestId('append-label')) + await user.click(screen.getByTestId('app-icon')) + await user.click(screen.getByTestId('select-emoji')) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ + workflow_app_id: 'workflow-app-1', + label: 'Created Tool', + icon: { content: '🚀', background: '#000000' }, + labels: ['label1', 'new-label'], + })) + }) + + it('should block invalid tool-call names before saving', async () => { + const user = userEvent.setup() + const onCreate = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(onCreate).not.toHaveBeenCalled() + expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + })) + }) + + it('should require confirmation before saving existing workflow tools', async () => { + const user = userEvent.setup() + const onSave = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + expect(screen.getByTestId('confirm-modal')).toBeInTheDocument() + + await user.click(screen.getByTestId('confirm-save')) + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + workflow_tool_id: 'workflow-tool-1', + name: 'my_tool', + })) + }) + }) + + it('should show duplicate reserved output warnings', () => { + render( + , + ) + + expect(screen.getAllByText('tools.createTool.toolOutput.reservedParameterDuplicateTip').length).toBeGreaterThan(0) + }) +}) diff --git a/web/app/components/tools/workflow-tool/helpers.ts b/web/app/components/tools/workflow-tool/helpers.ts new file mode 100644 index 0000000000..9af1107c80 --- /dev/null +++ b/web/app/components/tools/workflow-tool/helpers.ts @@ -0,0 +1,95 @@ +import type { TFunction } from 'i18next' +import type { + Emoji, + WorkflowToolProviderOutputParameter, + WorkflowToolProviderOutputSchema, + WorkflowToolProviderParameter, + WorkflowToolProviderRequest, +} from '../types' +import { VarType } from '@/app/components/workflow/types' +import { buildWorkflowOutputParameters } from './utils' + +export const RESERVED_WORKFLOW_OUTPUTS: WorkflowToolProviderOutputParameter[] = [ + { + name: 'text', + description: '', + type: VarType.string, + reserved: true, + }, + { + name: 'files', + description: '', + type: VarType.arrayFile, + reserved: true, + }, + { + name: 'json', + description: '', + type: VarType.arrayObject, + reserved: true, + }, +] + +export const isWorkflowToolNameValid = (name: string) => { + if (name === '') + return true + + return /^\w+$/.test(name) +} + +export const getReservedWorkflowOutputParameters = (t: TFunction) => { + return RESERVED_WORKFLOW_OUTPUTS.map(output => ({ + ...output, + description: output.name === 'text' + ? t('nodes.tool.outputVars.text', { ns: 'workflow' }) + : output.name === 'files' + ? t('nodes.tool.outputVars.files.title', { ns: 'workflow' }) + : t('nodes.tool.outputVars.json', { ns: 'workflow' }), + })) +} + +export const hasReservedWorkflowOutputConflict = ( + reservedOutputParameters: WorkflowToolProviderOutputParameter[], + name: string, +) => { + return reservedOutputParameters.some(parameter => parameter.name === name) +} + +export const getWorkflowOutputParameters = ( + rawOutputParameters: WorkflowToolProviderOutputParameter[], + outputSchema?: WorkflowToolProviderOutputSchema, +) => { + return buildWorkflowOutputParameters(rawOutputParameters, outputSchema) +} + +export const buildWorkflowToolRequestPayload = ({ + description, + emoji, + label, + labels, + name, + parameters, + privacyPolicy, +}: { + description: string + emoji: Emoji + label: string + labels: string[] + name: string + parameters: WorkflowToolProviderParameter[] + privacyPolicy: string +}): WorkflowToolProviderRequest & { label: string } => { + return { + name, + description, + icon: emoji, + label, + parameters: parameters.map(item => ({ + name: item.name, + description: item.description, + form: item.form, + })), + labels, + privacy_policy: privacyPolicy, + } +} diff --git a/web/app/components/tools/workflow-tool/index.tsx b/web/app/components/tools/workflow-tool/index.tsx index 23329f6a2c..219a0d8f53 100644 --- a/web/app/components/tools/workflow-tool/index.tsx +++ b/web/app/components/tools/workflow-tool/index.tsx @@ -17,9 +17,14 @@ import { toast } from '@/app/components/base/ui/toast' import LabelSelector from '@/app/components/tools/labels/selector' import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal' import MethodSelector from '@/app/components/tools/workflow-tool/method-selector' -import { VarType } from '@/app/components/workflow/types' import { cn } from '@/utils/classnames' -import { buildWorkflowOutputParameters } from './utils' +import { + buildWorkflowToolRequestPayload, + getReservedWorkflowOutputParameters, + getWorkflowOutputParameters, + hasReservedWorkflowOutputConflict, + isWorkflowToolNameValid, +} from './helpers' export type WorkflowToolModalPayload = { icon: Emoji @@ -67,27 +72,14 @@ const WorkflowToolAsModal: FC = ({ const [parameters, setParameters] = useState(payload.parameters) const rawOutputParameters = payload.outputParameters const outputSchema = payload.tool?.output_schema - const outputParameters = useMemo(() => buildWorkflowOutputParameters(rawOutputParameters, outputSchema), [rawOutputParameters, outputSchema]) - const reservedOutputParameters: WorkflowToolProviderOutputParameter[] = [ - { - name: 'text', - description: t('nodes.tool.outputVars.text', { ns: 'workflow' }), - type: VarType.string, - reserved: true, - }, - { - name: 'files', - description: t('nodes.tool.outputVars.files.title', { ns: 'workflow' }), - type: VarType.arrayFile, - reserved: true, - }, - { - name: 'json', - description: t('nodes.tool.outputVars.json', { ns: 'workflow' }), - type: VarType.arrayObject, - reserved: true, - }, - ] + const outputParameters = useMemo( + () => getWorkflowOutputParameters(rawOutputParameters, outputSchema), + [rawOutputParameters, outputSchema], + ) + const reservedOutputParameters = useMemo( + () => getReservedWorkflowOutputParameters(t), + [t], + ) const handleParameterChange = (key: string, value: string, index: number) => { const newData = produce(parameters, (draft: WorkflowToolProviderParameter[]) => { @@ -105,18 +97,6 @@ const WorkflowToolAsModal: FC = ({ const [privacyPolicy, setPrivacyPolicy] = useState(payload.privacy_policy) const [showModal, setShowModal] = useState(false) - const isNameValid = (name: string) => { - // when the user has not input anything, no need for a warning - if (name === '') - return true - - return /^\w+$/.test(name) - } - - const isOutputParameterReserved = (name: string) => { - return reservedOutputParameters.find(p => p.name === name) - } - const onConfirm = () => { let errorMessage = '' if (!label) @@ -125,7 +105,7 @@ const WorkflowToolAsModal: FC = ({ if (!name) errorMessage = t('errorMsg.fieldRequired', { ns: 'common', field: t('createTool.nameForToolCall', { ns: 'tools' }) }) - if (!isNameValid(name)) + if (!isWorkflowToolNameValid(name)) errorMessage = t('createTool.nameForToolCall', { ns: 'tools' }) + t('createTool.nameForToolCallTip', { ns: 'tools' }) if (errorMessage) { @@ -133,19 +113,15 @@ const WorkflowToolAsModal: FC = ({ return } - const requestParams = { + const requestParams = buildWorkflowToolRequestPayload({ name, description, - icon: emoji, + emoji, label, - parameters: parameters.map(item => ({ - name: item.name, - description: item.description, - form: item.form, - })), + parameters, labels, - privacy_policy: privacyPolicy, - } + privacyPolicy, + }) if (!isAdd) { onSave?.({ ...requestParams, @@ -175,7 +151,7 @@ const WorkflowToolAsModal: FC = ({
{/* name & icon */}
-
+
{t('createTool.name', { ns: 'tools' })} {' '} * @@ -192,7 +168,7 @@ const WorkflowToolAsModal: FC = ({
{/* name for tool call */}
-
+
{t('createTool.nameForToolCall', { ns: 'tools' })} {' '} * @@ -210,13 +186,13 @@ const WorkflowToolAsModal: FC = ({ value={name} onChange={e => setName(e.target.value)} /> - {!isNameValid(name) && ( + {!isWorkflowToolNameValid(name) && (
{t('createTool.nameForToolCallTip', { ns: 'tools' })}
)}
{/* description */}
-
{t('createTool.description', { ns: 'tools' })}
+
{t('createTool.description', { ns: 'tools' })}
-

Status: "{{ status }}"

-
'''code block'''
- -""" - inputs = {"task": {"Task ID": "TASK-123", "Issues": "Line 1\nLine 2\nLine 3"}, "status": "completed"} - - result = CodeExecutor.execute_workflow_code_template(language=CODE_LANGUAGE, code=template, inputs=inputs) - - # Verify the template rendered correctly with all special characters - output = result["result"] - assert 'value="TASK-123"' in output - assert "" in output - assert 'Status: "completed"' in output - assert "'''code block'''" in output - - -def test_jinja2_template_with_html_textarea_prefill(): - """ - Specific test for HTML textarea with Jinja2 variable pre-fill. - Verifies fix for issue #26818. - """ - template = "" - notes_content = "This is a multi-line note.\nWith special chars: 'single' and \"double\" quotes." - inputs = {"notes": notes_content} - - result = CodeExecutor.execute_workflow_code_template(language=CODE_LANGUAGE, code=template, inputs=inputs) - - expected_output = f"" - assert result["result"] == expected_output - - -def test_jinja2_assemble_runner_script_encodes_template(): - """Test that assemble_runner_script properly base64 encodes the template.""" - template = "Hello {{ name }}!" - inputs = {"name": "World"} - - script = Jinja2TemplateTransformer.assemble_runner_script(template, inputs) - - # The template should be base64 encoded in the script - template_b64 = base64.b64encode(template.encode("utf-8")).decode("utf-8") - assert template_b64 in script - # The raw template should NOT appear in the script (it's encoded) - assert "Hello {{ name }}!" not in script From 425457cb16845819d028775d45e3172cf6fa6003 Mon Sep 17 00:00:00 2001 From: James <63717587+jamesrayammons@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:53:53 +0200 Subject: [PATCH 314/359] test: remove legacy workflow draft variable api test (#35226) --- .../app/test_workflow_draft_variable.py | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py diff --git a/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py b/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py deleted file mode 100644 index 038f37af5f..0000000000 --- a/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py +++ /dev/null @@ -1,47 +0,0 @@ -import uuid -from unittest import mock - -from controllers.console.app import workflow_draft_variable as draft_variable_api -from controllers.console.app import wraps -from factories.variable_factory import build_segment -from models import App, AppMode -from models.workflow import WorkflowDraftVariable -from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService - - -def _get_mock_srv_class() -> type[WorkflowDraftVariableService]: - return mock.create_autospec(WorkflowDraftVariableService) - - -class TestWorkflowDraftNodeVariableListApi: - def test_get(self, test_client, auth_header, monkeypatch): - srv_class = _get_mock_srv_class() - mock_app_model: App = App() - mock_app_model.id = str(uuid.uuid4()) - test_node_id = "test_node_id" - mock_app_model.mode = AppMode.ADVANCED_CHAT - mock_load_app_model = mock.Mock(return_value=mock_app_model) - - monkeypatch.setattr(draft_variable_api, "WorkflowDraftVariableService", srv_class) - monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model) - - var1 = WorkflowDraftVariable.new_node_variable( - app_id="test_app_1", - node_id="test_node_1", - name="str_var", - value=build_segment("str_value"), - node_execution_id=str(uuid.uuid4()), - ) - srv_instance = mock.create_autospec(WorkflowDraftVariableService, instance=True) - srv_class.return_value = srv_instance - srv_instance.list_node_variables.return_value = WorkflowDraftVariableList(variables=[var1]) - - response = test_client.get( - f"/console/api/apps/{mock_app_model.id}/workflows/draft/nodes/{test_node_id}/variables", - headers=auth_header, - ) - assert response.status_code == 200 - response_dict = response.json - assert isinstance(response_dict, dict) - assert "items" in response_dict - assert len(response_dict["items"]) == 1 From dbceb3067e5c1ce177bf60ec8617b53e2d831ac3 Mon Sep 17 00:00:00 2001 From: NVIDIAN Date: Wed, 15 Apr 2026 02:57:27 -0700 Subject: [PATCH 315/359] refactor(api): migrate console tag responses from marshal_with to BaseModel (#35208) Co-authored-by: ai-hpc --- api/controllers/console/tag/tags.py | 56 +++++++++++++------ .../controllers/console/tag/test_tags.py | 26 ++++++++- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 39b84d3869..614bf03ea5 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -1,13 +1,14 @@ from typing import Literal from flask import request -from flask_restx import Namespace, Resource, fields, marshal_with -from pydantic import BaseModel, Field +from flask_restx import Resource +from pydantic import BaseModel, Field, field_validator from werkzeug.exceptions import Forbidden from controllers.common.schema import register_schema_models from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from fields.base import ResponseModel from libs.login import current_account_with_tenant, login_required from models.enums import TagType from services.tag_service import ( @@ -18,17 +19,6 @@ from services.tag_service import ( UpdateTagPayload, ) -dataset_tag_fields = { - "id": fields.String, - "name": fields.String, - "type": fields.String, - "binding_count": fields.String, -} - - -def build_dataset_tag_fields(api_or_ns: Namespace): - return api_or_ns.model("DataSetTag", dataset_tag_fields) - class TagBasePayload(BaseModel): name: str = Field(description="Tag name", min_length=1, max_length=50) @@ -52,12 +42,36 @@ class TagListQueryParam(BaseModel): keyword: str | None = Field(None, description="Search keyword") +class TagResponse(ResponseModel): + id: str + name: str + type: str | None = None + binding_count: str | None = None + + @field_validator("type", mode="before") + @classmethod + def normalize_type(cls, value: TagType | str | None) -> str | None: + if value is None: + return None + if isinstance(value, TagType): + return value.value + return value + + @field_validator("binding_count", mode="before") + @classmethod + def normalize_binding_count(cls, value: int | str | None) -> str | None: + if value is None: + return None + return str(value) + + register_schema_models( console_ns, TagBasePayload, TagBindingPayload, TagBindingRemovePayload, TagListQueryParam, + TagResponse, ) @@ -69,14 +83,18 @@ class TagListApi(Resource): @console_ns.doc( params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."} ) - @marshal_with(dataset_tag_fields) + @console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])}) def get(self): _, current_tenant_id = current_account_with_tenant() raw_args = request.args.to_dict() param = TagListQueryParam.model_validate(raw_args) tags = TagService.get_tags(param.type, current_tenant_id, param.keyword) - return tags, 200 + serialized_tags = [ + TagResponse.model_validate(tag, from_attributes=True).model_dump(mode="json") for tag in tags + ] + + return serialized_tags, 200 @console_ns.expect(console_ns.models[TagBasePayload.__name__]) @setup_required @@ -91,7 +109,9 @@ class TagListApi(Resource): payload = TagBasePayload.model_validate(console_ns.payload or {}) tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type)) - response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0} + response = TagResponse.model_validate( + {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0} + ).model_dump(mode="json") return response, 200 @@ -114,7 +134,9 @@ class TagUpdateDeleteApi(Resource): binding_count = TagService.get_tag_binding_count(tag_id) - response = {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count} + response = TagResponse.model_validate( + {"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count} + ).model_dump(mode="json") return response, 200 diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index e89b89c8b1..2be5a21f28 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -1,9 +1,11 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, PropertyMock, patch import pytest from flask import Flask from werkzeug.exceptions import Forbidden +import controllers.console.tag.tags as module from controllers.console import console_ns from controllers.console.tag.tags import ( TagBindingCreateApi, @@ -83,13 +85,20 @@ class TestTagListApi: ), patch( "controllers.console.tag.tags.TagService.get_tags", - return_value=[{"id": "1", "name": "tag"}], + return_value=[ + SimpleNamespace( + id="1", + name="tag", + type=TagType.KNOWLEDGE, + binding_count=1, + ) + ], ), ): result, status = method(api) assert status == 200 - assert isinstance(result, list) + assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}] def test_post_success(self, app, admin_user, tag, payload_patch): api = TagListApi() @@ -113,6 +122,7 @@ class TestTagListApi: assert status == 200 assert result["name"] == "test-tag" + assert result["binding_count"] == "0" def test_post_forbidden(self, app, readonly_user, payload_patch): api = TagListApi() @@ -158,7 +168,7 @@ class TestTagUpdateDeleteApi: result, status = method(api, "tag-1") assert status == 200 - assert result["binding_count"] == 3 + assert result["binding_count"] == "3" def test_patch_forbidden(self, app, readonly_user, payload_patch): api = TagUpdateDeleteApi() @@ -277,3 +287,13 @@ class TestTagBindingDeleteApi: ): with pytest.raises(Forbidden): method(api) + + +class TestTagResponseModel: + def test_tag_response_normalizes_enum_type(self): + payload = module.TagResponse.model_validate( + {"id": "tag-1", "name": "tag", "type": TagType.KNOWLEDGE, "binding_count": 1} + ).model_dump(mode="json") + + assert payload["type"] == "knowledge" + assert payload["binding_count"] == "1" From af7d5e60b47503a0c5c3751fa783d63654644a21 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:11:20 +0800 Subject: [PATCH 316/359] feat(ui): scaffold @langgenius/dify-ui and migrate design tokens (#35256) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- packages/dify-ui/AGENTS.md | 27 + packages/dify-ui/package.json | 24 + .../dify-ui/src/cn.ts | 0 packages/dify-ui/src/styles/styles.css | 3 + packages/dify-ui/src/styles/utilities.css | 272 ++ packages/dify-ui/src/tailwind-preset.ts | 87 + {web => packages/dify-ui/src}/themes/dark.css | 0 .../dify-ui/src}/themes/light.css | 0 .../src}/themes/tailwind-theme-var-define.ts | 0 packages/dify-ui/tsconfig.json | 18 + pnpm-lock.yaml | 85 +- pnpm-workspace.yaml | 85 +- web/AGENTS.md | 4 + web/README.md | 2 - .../plugins/plugin-auth-flow.test.tsx | 2 +- .../plugins/plugin-card-rendering.test.tsx | 2 +- .../tool-browsing-and-filtering.test.tsx | 2 +- .../tools/tool-provider-detail-flow.test.tsx | 2 +- .../(appDetailLayout)/[appId]/layout-main.tsx | 2 +- .../time-range-picker/date-picker.tsx | 4 +- .../time-range-picker/range-selector.tsx | 8 +- .../overview/tracing/config-button.tsx | 2 +- .../[appId]/overview/tracing/config-popup.tsx | 8 +- .../[appId]/overview/tracing/field.tsx | 2 +- .../[appId]/overview/tracing/panel.tsx | 12 +- .../overview/tracing/provider-panel.tsx | 6 +- .../[appId]/overview/tracing/tracing-icon.tsx | 4 +- .../[datasetId]/layout-main.tsx | 2 +- .../(humanInputLayout)/form/[token]/form.tsx | 14 +- .../webapp-reset-password/layout.tsx | 6 +- .../set-password/page.tsx | 2 +- .../(shareLayout)/webapp-signin/layout.tsx | 4 +- .../webapp-signin/normalForm.tsx | 24 +- web/app/account/(commonLayout)/avatar.tsx | 4 +- web/app/account/oauth/authorize/layout.tsx | 6 +- web/app/activate/activateForm.tsx | 6 +- web/app/activate/page.tsx | 2 +- .../text-squeeze-fix-verification.spec.tsx | 2 +- .../app-sidebar/app-info/app-info-trigger.tsx | 6 +- .../app-sidebar/app-info/app-operations.tsx | 2 +- .../app-sidebar/app-sidebar-dropdown.tsx | 12 +- .../app-sidebar/dataset-info/dropdown.tsx | 2 +- .../app-sidebar/dataset-info/index.tsx | 12 +- .../app-sidebar/dataset-sidebar-dropdown.tsx | 14 +- web/app/components/app-sidebar/index.tsx | 4 +- .../components/app-sidebar/nav-link/index.tsx | 8 +- .../components/app-sidebar/toggle-button.tsx | 2 +- .../app/annotation/batch-action.tsx | 4 +- .../csv-uploader.tsx | 2 +- .../edit-annotation-modal/edit-item/index.tsx | 2 +- .../app/annotation/header-opts/index.tsx | 4 +- web/app/components/app/annotation/index.tsx | 6 +- web/app/components/app/annotation/list.tsx | 18 +- .../view-annotation-modal/index.tsx | 22 +- .../access-control-dialog.tsx | 4 +- .../access-control-item.tsx | 4 +- .../add-member-or-group-pop.tsx | 2 +- .../app/app-publisher/suggested-action.tsx | 4 +- .../base/feature-panel/index.tsx | 4 +- .../base/operation-btn/index.tsx | 4 +- .../base/warning-mask/has-not-set-api.tsx | 10 +- .../config-prompt/advanced-prompt-input.tsx | 2 +- .../config-prompt/message-type-selector.tsx | 8 +- .../prompt-editor-height-resize-wrap.tsx | 2 +- .../config-prompt/simple-prompt-input.tsx | 8 +- .../config-var/config-modal/field.tsx | 6 +- .../config-var/config-modal/form-fields.tsx | 2 +- .../config-var/config-modal/type-select.tsx | 2 +- .../config-var/config-select/index.tsx | 8 +- .../app/configuration/config-var/index.tsx | 2 +- .../config-var/select-type-item/index.tsx | 4 +- .../app/configuration/config-var/var-item.tsx | 12 +- .../app/configuration/config-vision/index.tsx | 12 +- .../config-vision/param-config.tsx | 2 +- .../config/agent/agent-setting/item-panel.tsx | 4 +- .../config/agent/agent-tools/index.tsx | 2 +- .../agent-tools/setting-built-in-tool.tsx | 2 +- .../config/assistant-type-picker/index.tsx | 20 +- .../config/automatic/idea-output.tsx | 6 +- .../config/automatic/instruction-editor.tsx | 10 +- .../config/automatic/prompt-toast.tsx | 4 +- .../config/automatic/version-selector.tsx | 10 +- .../dataset-config/card-item/index.tsx | 4 +- .../dataset-config/context-var/index.tsx | 4 +- .../dataset-config/context-var/var-picker.tsx | 10 +- .../configuration/dataset-config/index.tsx | 4 +- .../params-config/config-content.tsx | 22 +- .../dataset-config/params-config/index.tsx | 2 +- .../dataset-config/select-dataset/index.tsx | 2 +- .../dataset-config/settings-modal/index.tsx | 2 +- .../settings-modal/retrieval-section.tsx | 10 +- .../configuration/debug/chat-user-input.tsx | 6 +- .../app/configuration/debug/index.tsx | 4 +- .../prompt-value-panel/index.tsx | 2 +- .../app/create-app-dialog/app-card/index.tsx | 4 +- .../app/create-app-dialog/app-list/index.tsx | 6 +- .../create-app-dialog/app-list/sidebar.tsx | 4 +- .../components/app/create-app-modal/index.tsx | 4 +- .../app/create-from-dsl-modal/index.tsx | 2 +- .../app/create-from-dsl-modal/uploader.tsx | 10 +- .../components/app/duplicate-modal/index.tsx | 2 +- .../components/app/in-site-message/index.tsx | 2 +- .../components/app/log-annotation/index.tsx | 2 +- web/app/components/app/log/list.tsx | 38 +- web/app/components/app/log/model-info.tsx | 8 +- web/app/components/app/log/var-panel.tsx | 14 +- .../app/overview/apikey-info-panel/index.tsx | 2 +- .../app/overview/embedded/index.tsx | 10 +- .../app/overview/settings/index.tsx | 2 +- .../components/app/switch-app-modal/index.tsx | 4 +- .../app/text-generate/item/action-groups.tsx | 6 +- .../app/text-generate/item/index.tsx | 10 +- .../app/text-generate/item/workflow-body.tsx | 2 +- .../app/text-generate/saved-items/index.tsx | 8 +- .../saved-items/no-data/index.tsx | 2 +- .../components/app/type-selector/index.tsx | 10 +- web/app/components/app/workflow-log/list.tsx | 28 +- web/app/components/apps/app-card.tsx | 22 +- web/app/components/apps/list.tsx | 18 +- web/app/components/apps/new-app-card.tsx | 10 +- .../components/base/action-button/index.tsx | 2 +- .../base/agent-log-modal/detail.tsx | 6 +- .../components/base/agent-log-modal/index.tsx | 4 +- .../base/agent-log-modal/iteration.tsx | 6 +- .../base/agent-log-modal/tool-call.tsx | 8 +- web/app/components/base/alert.tsx | 4 +- web/app/components/base/answer-icon/index.tsx | 2 +- .../base/app-icon-picker/ImageInput.tsx | 6 +- .../components/base/app-icon-picker/index.tsx | 2 +- .../base/app-icon/__tests__/index.spec.tsx | 6 +- web/app/components/base/app-icon/index.tsx | 34 +- web/app/components/base/app-unavailable.tsx | 4 +- .../base/audio-gallery/AudioPlayer.tsx | 10 +- .../base/auto-height-textarea/index.tsx | 4 +- web/app/components/base/badge.tsx | 6 +- web/app/components/base/badge/index.tsx | 2 +- web/app/components/base/block-input/index.tsx | 4 +- web/app/components/base/carousel/index.tsx | 2 +- .../chat/chat-with-history/chat-wrapper.tsx | 4 +- .../chat/chat-with-history/header/index.tsx | 2 +- .../chat-with-history/header/operation.tsx | 10 +- .../base/chat/chat-with-history/index.tsx | 2 +- .../chat-with-history/inputs-form/content.tsx | 2 +- .../chat-with-history/inputs-form/index.tsx | 2 +- .../chat/chat-with-history/sidebar/index.tsx | 4 +- .../chat/chat-with-history/sidebar/item.tsx | 4 +- .../chat-with-history/sidebar/operation.tsx | 8 +- .../base/chat/chat/answer/basic-content.tsx | 2 +- .../human-input-content/content-wrapper.tsx | 4 +- .../human-input-content/expiration-time.tsx | 4 +- .../base/chat/chat/answer/index.tsx | 10 +- .../base/chat/chat/answer/operation.tsx | 14 +- .../chat/chat/answer/suggested-questions.tsx | 4 +- .../base/chat/chat/answer/tool-detail.tsx | 16 +- .../chat/chat/answer/workflow-process.tsx | 16 +- .../base/chat/chat/chat-input-area/index.tsx | 8 +- .../chat/chat/chat-input-area/operation.tsx | 2 +- web/app/components/base/chat/chat/index.tsx | 2 +- .../base/chat/chat/loading-anim/index.tsx | 2 +- .../components/base/chat/chat/log/index.tsx | 2 +- .../components/base/chat/chat/question.tsx | 4 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 6 +- .../chat/embedded-chatbot/header/index.tsx | 4 +- .../base/chat/embedded-chatbot/index.tsx | 2 +- .../embedded-chatbot/inputs-form/content.tsx | 8 +- .../embedded-chatbot/inputs-form/index.tsx | 2 +- .../inputs-form/view-form-dropdown.tsx | 4 +- .../components/base/checkbox-list/index.tsx | 2 +- web/app/components/base/checkbox/index.tsx | 4 +- web/app/components/base/chip/index.tsx | 4 +- .../components/base/content-dialog/index.tsx | 4 +- .../components/base/corner-label/index.tsx | 2 +- .../date-and-time-picker/calendar/item.tsx | 6 +- .../common/option-list-item.tsx | 4 +- .../date-picker/footer.tsx | 2 +- .../date-picker/index.tsx | 8 +- .../time-picker/index.tsx | 10 +- .../time-picker/options.tsx | 6 +- .../year-and-month-picker/options.tsx | 4 +- web/app/components/base/dialog/index.tsx | 6 +- web/app/components/base/divider/index.tsx | 2 +- web/app/components/base/drawer-plus/index.tsx | 8 +- web/app/components/base/drawer/index.tsx | 2 +- web/app/components/base/dropdown/index.tsx | 2 +- web/app/components/base/effect/index.tsx | 2 +- .../components/base/emoji-picker/Inner.tsx | 10 +- .../components/base/emoji-picker/index.tsx | 2 +- .../base/encrypted-bottom/index.tsx | 4 +- .../components/base/error-boundary/index.tsx | 2 +- .../conversation-opener/modal.tsx | 2 +- .../new-feature-panel/dialog-wrapper.tsx | 6 +- .../new-feature-panel/feature-bar.tsx | 2 +- .../moderation/moderation-setting-modal.tsx | 2 +- .../text-to-speech/param-config-content.tsx | 18 +- web/app/components/base/file-thumb/index.tsx | 4 +- .../file-from-link-or-local/index.tsx | 2 +- .../base/file-uploader/file-image-render.tsx | 2 +- .../base/file-uploader/file-list-in-log.tsx | 8 +- .../base/file-uploader/file-type-icon.tsx | 2 +- .../file-uploader-in-attachment/file-item.tsx | 8 +- .../file-uploader-in-attachment/index.tsx | 2 +- .../file-uploader-in-chat-input/file-item.tsx | 2 +- .../file-uploader-in-chat-input/file-list.tsx | 2 +- .../file-uploader-in-chat-input/index.tsx | 2 +- .../base/form/components/base/base-field.tsx | 10 +- .../base/form/components/base/base-form.tsx | 2 +- .../base/form/components/field/checkbox.tsx | 4 +- .../form/components/field/custom-select.tsx | 2 +- .../base/form/components/field/file-types.tsx | 2 +- .../form/components/field/file-uploader.tsx | 2 +- .../field/input-type-select/index.tsx | 2 +- .../field/input-type-select/trigger.tsx | 2 +- .../field/mixed-variable-text-input/index.tsx | 2 +- .../form/components/field/number-input.tsx | 2 +- .../form/components/field/number-slider.tsx | 4 +- .../base/form/components/field/options.tsx | 2 +- .../base/form/components/field/select.tsx | 2 +- .../base/form/components/field/text-area.tsx | 2 +- .../base/form/components/field/text.tsx | 2 +- .../form/components/field/upload-method.tsx | 2 +- .../field/variable-or-constant-input.tsx | 2 +- .../components/field/variable-selector.tsx | 2 +- .../components/base/form/components/label.tsx | 6 +- .../fullscreen-modal/__tests__/index.spec.tsx | 4 +- .../base/fullscreen-modal/index.tsx | 8 +- web/app/components/base/grid-mask/index.tsx | 2 +- .../icons/src/image/llm/BaichuanTextCn.tsx | 2 +- .../base/icons/src/image/llm/Minimax.tsx | 2 +- .../base/icons/src/image/llm/MinimaxText.tsx | 2 +- .../base/icons/src/image/llm/Tongyi.tsx | 2 +- .../base/icons/src/image/llm/TongyiText.tsx | 2 +- .../base/icons/src/image/llm/TongyiTextCn.tsx | 2 +- .../base/icons/src/image/llm/Wxyy.tsx | 2 +- .../base/icons/src/image/llm/WxyyText.tsx | 2 +- .../base/icons/src/image/llm/WxyyTextCn.tsx | 2 +- .../components/base/image-gallery/index.tsx | 2 +- .../image-uploader/chat-image-uploader.tsx | 4 +- .../base/image-uploader/image-list.tsx | 7 +- .../base/inline-delete-confirm/index.tsx | 4 +- .../components/base/input-with-copy/index.tsx | 6 +- .../base/input/__tests__/index.spec.tsx | 6 +- web/app/components/base/input/index.tsx | 16 +- .../base/linked-apps-panel/index.tsx | 6 +- web/app/components/base/list-empty/index.tsx | 14 +- web/app/components/base/loading/index.tsx | 2 +- web/app/components/base/logo/dify-logo.tsx | 2 +- .../base/logo/logo-embedded-chat-header.tsx | 2 +- web/app/components/base/logo/logo-site.tsx | 2 +- .../base/markdown-blocks/button.tsx | 2 +- .../base/markdown-blocks/think-block.tsx | 4 +- .../components/with-icon-card-item.tsx | 4 +- .../components/with-icon-card-list.tsx | 2 +- web/app/components/base/markdown/index.tsx | 2 +- web/app/components/base/mermaid/index.tsx | 4 +- .../base/message-log-modal/index.tsx | 6 +- .../components/base/modal-like-wrap/index.tsx | 2 +- web/app/components/base/modal/index.css | 7 - web/app/components/base/modal/index.tsx | 2 +- web/app/components/base/modal/modal.tsx | 2 +- web/app/components/base/node-status/index.tsx | 2 +- .../base/notion-connector/index.tsx | 2 +- web/app/components/base/notion-icon/index.tsx | 2 +- .../page-selector/page-row.tsx | 2 +- .../search-input/index.tsx | 4 +- web/app/components/base/pagination/index.tsx | 6 +- .../components/base/pagination/pagination.tsx | 2 +- web/app/components/base/popover/index.tsx | 2 +- .../base/portal-to-follow-elem/index.tsx | 2 +- .../premium-badge/__tests__/index.spec.tsx | 2 +- .../components/base/premium-badge/index.css | 10 +- .../components/base/premium-badge/index.tsx | 6 +- .../base/progress-bar/progress-circle.tsx | 2 +- .../components/base/prompt-editor/index.tsx | 2 +- .../plugins/current-block/component.tsx | 6 +- .../plugins/draggable-plugin/index.tsx | 8 +- .../plugins/error-message-block/component.tsx | 6 +- .../plugins/hitl-input-block/component-ui.tsx | 6 +- .../plugins/hitl-input-block/input-field.tsx | 4 +- .../plugins/hitl-input-block/pre-populate.tsx | 4 +- .../plugins/hitl-input-block/tag-label.tsx | 4 +- .../plugins/hitl-input-block/type-switch.tsx | 4 +- .../plugins/last-run-block/component.tsx | 6 +- .../prompt-editor/plugins/placeholder.tsx | 4 +- .../plugins/request-url-block/component.tsx | 6 +- .../plugins/shortcuts-popup-plugin/index.tsx | 2 +- .../prompt-editor/prompt-editor-content.tsx | 2 +- web/app/components/base/radio-card/index.tsx | 6 +- .../base/radio-card/simple/index.tsx | 6 +- .../base/radio/component/group/index.tsx | 2 +- .../base/radio/component/radio/index.tsx | 2 +- web/app/components/base/radio/ui.tsx | 2 +- .../components/base/search-input/index.tsx | 6 +- .../__tests__/index.spec.tsx | 4 +- .../base/segmented-control/index.css | 36 +- .../base/segmented-control/index.tsx | 26 +- web/app/components/base/select/custom.tsx | 6 +- web/app/components/base/select/index.tsx | 16 +- web/app/components/base/select/pure.tsx | 8 +- .../base/simple-pie-chart/index.tsx | 2 +- web/app/components/base/skeleton/index.tsx | 2 +- web/app/components/base/sort/index.tsx | 4 +- web/app/components/base/svg/index.tsx | 2 +- .../base/switch/__tests__/index.spec.tsx | 6 +- web/app/components/base/switch/index.tsx | 10 +- web/app/components/base/switch/skeleton.tsx | 6 +- web/app/components/base/tab-header/index.tsx | 4 +- .../components/base/tab-slider-new/index.tsx | 4 +- .../base/tab-slider-plain/index.tsx | 4 +- web/app/components/base/tab-slider/index.tsx | 8 +- web/app/components/base/tag-input/index.tsx | 6 +- .../components/base/tag-management/filter.tsx | 12 +- .../base/tag-management/selector.tsx | 2 +- .../base/tag-management/tag-item-editor.tsx | 6 +- .../base/tag-management/tag-remove-modal.tsx | 2 +- web/app/components/base/tag/index.tsx | 2 +- web/app/components/base/textarea/index.tsx | 2 +- web/app/components/base/theme-switcher.tsx | 4 +- .../components/base/timezone-label/index.tsx | 4 +- web/app/components/base/tooltip/index.tsx | 4 +- .../components/base/ui/alert-dialog/index.tsx | 2 +- web/app/components/base/ui/avatar/index.tsx | 2 +- web/app/components/base/ui/button/index.tsx | 2 +- .../components/base/ui/context-menu/index.tsx | 2 +- web/app/components/base/ui/dialog/index.tsx | 2 +- .../base/ui/dropdown-menu/index.tsx | 2 +- .../ui/number-field/__tests__/index.spec.tsx | 4 +- .../base/ui/number-field/index.stories.tsx | 2 +- .../components/base/ui/number-field/index.tsx | 6 +- web/app/components/base/ui/popover/index.tsx | 2 +- .../ui/scroll-area/__tests__/index.spec.tsx | 4 +- .../base/ui/scroll-area/index.stories.tsx | 20 +- .../components/base/ui/scroll-area/index.tsx | 4 +- web/app/components/base/ui/select/index.tsx | 4 +- web/app/components/base/ui/slider/index.tsx | 2 +- web/app/components/base/ui/toast/index.tsx | 2 +- web/app/components/base/ui/tooltip/index.tsx | 2 +- web/app/components/base/voice-input/index.tsx | 6 +- .../billing/annotation-full/index.tsx | 4 +- .../billing/annotation-full/modal.tsx | 4 +- .../billing/apps-full-in-dialog/index.tsx | 2 +- .../billing/header-billing-btn/index.tsx | 2 +- web/app/components/billing/pricing/footer.tsx | 2 +- web/app/components/billing/pricing/header.tsx | 2 +- .../billing/pricing/plan-switcher/tab.tsx | 2 +- .../pricing/plans/cloud-plan-item/button.tsx | 4 +- .../cloud-plan-item/list/item/tooltip.tsx | 4 +- .../plans/self-hosted-plan-item/button.tsx | 6 +- .../plans/self-hosted-plan-item/index.tsx | 18 +- .../billing/priority-label/index.tsx | 4 +- .../components/billing/progress-bar/index.tsx | 10 +- .../trigger-events-limit-modal/index.tsx | 2 +- .../components/billing/usage-info/index.tsx | 8 +- .../billing/vector-space-full/index.tsx | 4 +- .../components/chat-preview-card.tsx | 2 +- .../components/workflow-preview-card.tsx | 2 +- .../custom/custom-web-app-brand/index.tsx | 2 +- .../datasets/common/credential-icon.tsx | 4 +- .../common/document-picker/document-list.tsx | 2 +- .../datasets/common/document-picker/index.tsx | 6 +- .../preview-document-picker.tsx | 8 +- .../status-with-action.tsx | 4 +- .../datasets/common/image-list/index.tsx | 2 +- .../datasets/common/image-previewer/index.tsx | 2 +- .../image-uploader-in-chunk/image-input.tsx | 6 +- .../image-uploader-in-chunk/index.tsx | 2 +- .../index.tsx | 2 +- .../common/retrieval-param-config/index.tsx | 14 +- .../__tests__/index.spec.tsx | 4 +- .../create-from-dsl-modal/tab/item.tsx | 4 +- .../create-from-dsl-modal/uploader.tsx | 8 +- .../create-from-pipeline/list/create-card.tsx | 6 +- .../details/chunk-structure-card.tsx | 4 +- .../indexing-progress-item.tsx | 8 +- .../empty-dataset-creation-modal/index.tsx | 2 +- .../datasets/create/file-preview/index.tsx | 2 +- .../components/upload-dropzone.tsx | 4 +- .../datasets/create/file-uploader/index.tsx | 4 +- .../create/notion-page-preview/index.tsx | 2 +- .../components/data-source-type-selector.tsx | 2 +- .../datasets/create/step-one/index.tsx | 2 +- .../datasets/create/step-three/index.tsx | 10 +- .../components/indexing-mode-section.tsx | 2 +- .../step-two/components/option-card.tsx | 8 +- .../step-two/components/preview-panel.tsx | 2 +- .../datasets/create/step-two/index.tsx | 4 +- .../create/step-two/language-select/index.tsx | 4 +- .../datasets/create/stepper/step.tsx | 4 +- .../create/stop-embedding-modal/index.tsx | 2 +- .../datasets/create/top-bar/index.tsx | 6 +- .../website/base/checkbox-with-label.tsx | 2 +- .../website/base/crawled-result-item.tsx | 2 +- .../create/website/base/crawled-result.tsx | 2 +- .../create/website/base/error-message.tsx | 4 +- .../datasets/create/website/base/field.tsx | 2 +- .../datasets/create/website/base/header.tsx | 2 +- .../create/website/base/options-wrap.tsx | 6 +- .../create/website/firecrawl/options.tsx | 2 +- .../datasets/create/website/index.tsx | 16 +- .../create/website/jina-reader/options.tsx | 4 +- .../datasets/create/website/no-data.tsx | 2 +- .../datasets/create/website/preview.tsx | 4 +- .../create/website/watercrawl/options.tsx | 4 +- .../document-list/components/sort-header.tsx | 4 +- .../document-list/components/utils.tsx | 2 +- .../documents/components/operations.tsx | 2 +- .../data-source-options/datasource-icon.tsx | 2 +- .../data-source-options/option-card.tsx | 6 +- .../base/credential-selector/trigger.tsx | 4 +- .../local-file/components/file-list-item.tsx | 2 +- .../local-file/components/upload-dropzone.tsx | 4 +- .../online-drive/connect/index.tsx | 2 +- .../file-list/header/breadcrumbs/bucket.tsx | 4 +- .../file-list/header/breadcrumbs/drive.tsx | 2 +- .../header/breadcrumbs/dropdown/index.tsx | 2 +- .../file-list/header/breadcrumbs/item.tsx | 4 +- .../file-list/list/empty-folder.tsx | 2 +- .../file-list/list/empty-search-result.tsx | 2 +- .../online-drive/file-list/list/file-icon.tsx | 2 +- .../online-drive/file-list/list/index.tsx | 2 +- .../online-drive/file-list/list/item.tsx | 6 +- .../base/checkbox-with-label.tsx | 2 +- .../base/crawled-result-item.tsx | 2 +- .../website-crawl/base/crawled-result.tsx | 4 +- .../website-crawl/base/crawling.tsx | 6 +- .../website-crawl/base/error-message.tsx | 2 +- .../website-crawl/base/options/index.tsx | 2 +- .../processing/embedding-process/index.tsx | 2 +- .../create-from-pipeline/processing/index.tsx | 6 +- .../create-from-pipeline/step-indicator.tsx | 2 +- .../detail/batch-modal/csv-uploader.tsx | 2 +- .../detail/completed/child-segment-detail.tsx | 8 +- .../detail/completed/child-segment-list.tsx | 10 +- .../detail/completed/common/add-another.tsx | 2 +- .../detail/completed/common/batch-action.tsx | 6 +- .../detail/completed/common/chunk-content.tsx | 6 +- .../detail/completed/common/drawer.tsx | 2 +- .../completed/common/full-screen-drawer.tsx | 4 +- .../detail/completed/common/keywords.tsx | 2 +- .../completed/common/segment-index-tag.tsx | 2 +- .../detail/completed/common/summary-label.tsx | 4 +- .../detail/completed/common/summary-text.tsx | 4 +- .../documents/detail/completed/common/tag.tsx | 2 +- .../components/segment-list-content.tsx | 2 +- .../detail/completed/new-child-segment.tsx | 12 +- .../completed/segment-card/chunk-content.tsx | 6 +- .../detail/completed/segment-card/index.tsx | 4 +- .../detail/completed/segment-detail.tsx | 8 +- .../documents/detail/document-title.tsx | 2 +- .../embedding/components/progress-bar.tsx | 2 +- .../datasets/documents/detail/index.tsx | 8 +- .../metadata/components/doc-type-selector.tsx | 2 +- .../detail/metadata/components/field-info.tsx | 2 +- .../datasets/documents/detail/new-segment.tsx | 12 +- .../documents/detail/segment-add/index.tsx | 22 +- .../datasets/documents/status-item/index.tsx | 2 +- .../external-api/external-api-modal/Form.tsx | 4 +- .../external-api/external-api-panel/index.tsx | 2 +- .../create/RetrievalSettings.tsx | 2 +- .../datasets/extra-info/api-access/card.tsx | 4 +- .../datasets/extra-info/api-access/index.tsx | 6 +- .../datasets/extra-info/service-api/index.tsx | 4 +- .../formatted-text/flavours/edit-slice.tsx | 2 +- .../formatted-text/flavours/shared.tsx | 8 +- .../datasets/formatted-text/formatted.tsx | 2 +- .../components/chunk-detail-modal.tsx | 12 +- .../hit-testing/components/empty-records.tsx | 4 +- .../datasets/hit-testing/components/mask.tsx | 2 +- .../components/query-input/index.tsx | 4 +- .../components/query-input/textarea.tsx | 12 +- .../hit-testing/components/records.tsx | 8 +- .../components/result-item-external.tsx | 6 +- .../components/result-item-meta.tsx | 2 +- .../hit-testing/components/result-item.tsx | 4 +- .../datasets/hit-testing/components/score.tsx | 4 +- .../components/datasets/hit-testing/index.tsx | 8 +- .../components/dataset-card-footer.tsx | 4 +- .../components/dataset-card-header.tsx | 20 +- .../dataset-card/components/description.tsx | 4 +- .../components/operations-dropdown.tsx | 4 +- .../list/dataset-card/components/tag-area.tsx | 4 +- .../datasets/metadata/add-metadata-button.tsx | 2 +- .../datasets/metadata/base/date-picker.tsx | 4 +- .../metadata/edit-metadata-batch/add-row.tsx | 2 +- .../metadata/edit-metadata-batch/edit-row.tsx | 4 +- .../edit-metadata-batch/input-combined.tsx | 6 +- .../input-has-set-multiple-value.tsx | 8 +- .../metadata/edit-metadata-batch/label.tsx | 4 +- .../dataset-metadata-drawer.tsx | 4 +- .../metadata/metadata-document/index.tsx | 2 +- .../metadata/metadata-document/info-group.tsx | 8 +- .../components/datasets/preview/container.tsx | 6 +- .../components/datasets/preview/header.tsx | 4 +- .../datasets/rename-modal/index.tsx | 2 +- .../datasets/settings/index-method/index.tsx | 2 +- .../datasets/settings/option-card.tsx | 6 +- .../settings/permission-selector/index.tsx | 18 +- .../permission-selector/member-item.tsx | 8 +- web/app/components/develop/code.tsx | 12 +- web/app/components/develop/doc.tsx | 4 +- web/app/components/develop/md.tsx | 4 +- web/app/components/develop/tag.tsx | 4 +- web/app/components/develop/toc-panel.tsx | 10 +- web/app/components/explore/app-card/index.tsx | 2 +- web/app/components/explore/app-list/index.tsx | 2 +- .../components/explore/banner/banner-item.tsx | 18 +- .../explore/banner/indicator-button.tsx | 4 +- web/app/components/explore/category.tsx | 4 +- .../explore/item-operation/index.tsx | 4 +- .../explore/sidebar/app-nav-item/index.tsx | 6 +- web/app/components/explore/sidebar/index.tsx | 18 +- .../explore/sidebar/no-apps/index.tsx | 6 +- .../explore/try-app/__tests__/index.spec.tsx | 2 +- .../explore/try-app/app-info/index.tsx | 4 +- .../components/explore/try-app/app/chat.tsx | 4 +- .../explore/try-app/app/text-generation.tsx | 8 +- web/app/components/explore/try-app/index.tsx | 2 +- .../try-app/preview/flow-app-preview.tsx | 2 +- .../actions/__tests__/knowledge.spec.ts | 2 +- .../components/goto-anything/actions/app.tsx | 2 +- .../goto-anything/actions/knowledge.tsx | 2 +- .../header/account-dropdown/index.tsx | 16 +- .../account-dropdown/menu-item-content.tsx | 2 +- .../workplace-selector/index.tsx | 16 +- .../Integrations-page/index.tsx | 6 +- .../api-based-extension-page/empty.tsx | 6 +- .../header/account-setting/collapse/index.tsx | 4 +- .../data-source-page-new/card.tsx | 2 +- .../install-from-marketplace.tsx | 8 +- .../data-source-page-new/item.tsx | 2 +- .../header/account-setting/index.tsx | 2 +- .../edit-workspace-modal/index.tsx | 2 +- .../members-page/invite-modal/index.tsx | 2 +- .../invite-modal/role-selector.tsx | 10 +- .../members-page/operation/index.tsx | 18 +- .../operation/transfer-ownership.tsx | 8 +- .../member-selector.tsx | 14 +- .../header/account-setting/menu-dialog.tsx | 6 +- .../model-provider-page/index.tsx | 18 +- .../install-from-marketplace.tsx | 8 +- .../add-credential-in-load-balancing.tsx | 4 +- .../model-auth/add-custom-model.tsx | 2 +- .../model-auth/authorized/credential-item.tsx | 6 +- .../model-auth/authorized/index.tsx | 2 +- .../model-auth/config-model.tsx | 2 +- .../manage-custom-model-credentials.tsx | 2 +- .../switch-credential-in-load-balancing.tsx | 2 +- .../model-provider-page/model-badge/index.tsx | 4 +- .../model-provider-page/model-icon/index.tsx | 2 +- .../model-provider-page/model-modal/Form.tsx | 22 +- .../model-provider-page/model-name/index.tsx | 4 +- .../agent-model-trigger.tsx | 4 +- .../model-parameter-modal/index.tsx | 16 +- .../model-parameter-modal/parameter-item.tsx | 14 +- .../model-parameter-modal/trigger.tsx | 10 +- .../model-selector/feature-icon.tsx | 2 +- .../model-selector/model-selector-trigger.tsx | 8 +- .../model-selector/popup-item.tsx | 14 +- .../model-selector/popup.tsx | 6 +- .../provider-added-card/index.tsx | 18 +- .../model-auth-dropdown/api-key-section.tsx | 2 +- .../credits-exhausted-alert.tsx | 14 +- .../usage-priority-section.tsx | 10 +- .../provider-added-card/model-list-item.tsx | 6 +- .../model-load-balancing-configs.tsx | 6 +- .../model-load-balancing-modal.tsx | 2 +- .../provider-added-card/priority-selector.tsx | 2 +- .../provider-card-actions.tsx | 2 +- .../provider-added-card/quota-panel.tsx | 10 +- .../provider-added-card/system-quota-card.tsx | 2 +- .../provider-icon/index.tsx | 4 +- web/app/components/header/app-back/index.tsx | 6 +- .../components/header/app-selector/index.tsx | 12 +- .../components/header/explore-nav/index.tsx | 2 +- web/app/components/header/header-wrapper.tsx | 4 +- web/app/components/header/indicator/index.tsx | 2 +- web/app/components/header/nav/index.tsx | 4 +- .../header/nav/nav-selector/index.tsx | 14 +- .../components/header/plugins-nav/index.tsx | 6 +- web/app/components/header/tools-nav/index.tsx | 2 +- .../plugins/base/badges/icon-with-tooltip.tsx | 2 +- .../plugins/base/deprecation-notice.tsx | 6 +- .../plugins/base/key-value-item.tsx | 6 +- .../card/base/__tests__/placeholder.spec.tsx | 2 +- .../plugins/card/base/card-icon.tsx | 2 +- .../plugins/card/base/description.tsx | 2 +- .../components/plugins/card/base/org-info.tsx | 8 +- .../plugins/card/base/placeholder.tsx | 4 +- web/app/components/plugins/card/index.tsx | 6 +- .../install-plugin/base/loading-error.tsx | 8 +- .../install-plugin/install-bundle/index.tsx | 6 +- .../install-from-github/index.tsx | 8 +- .../install-from-local-package/index.tsx | 6 +- .../install-from-marketplace/index.tsx | 6 +- .../plugins/marketplace/empty/index.tsx | 16 +- .../plugins/marketplace/list/index.tsx | 2 +- .../marketplace/list/list-with-collection.tsx | 4 +- .../marketplace/plugin-type-switch.tsx | 4 +- .../search-box/__tests__/index.spec.tsx | 2 +- .../plugins/marketplace/search-box/index.tsx | 10 +- .../search-box/trigger/marketplace.tsx | 6 +- .../search-box/trigger/tool-selector.tsx | 10 +- .../sticky-search-and-switch-wrapper.tsx | 2 +- .../__tests__/add-oauth-button.spec.tsx | 2 +- .../authorize/add-oauth-button.tsx | 2 +- .../plugins/plugin-auth/authorize/index.tsx | 4 +- .../authorized-in-data-source-node.tsx | 2 +- .../plugin-auth/authorized-in-node.tsx | 2 +- .../plugins/plugin-auth/authorized/index.tsx | 2 +- .../plugins/plugin-auth/authorized/item.tsx | 4 +- .../plugin-auth/plugin-auth-in-agent.tsx | 2 +- .../plugins/plugin-auth/plugin-auth.tsx | 2 +- .../__tests__/detail-header.spec.tsx | 2 +- .../__tests__/endpoint-list.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../__tests__/operation-dropdown.spec.tsx | 2 +- .../__tests__/strategy-detail.spec.tsx | 2 +- .../__tests__/strategy-item.spec.tsx | 2 +- .../__tests__/app-trigger.spec.tsx | 2 +- .../app-selector/app-inputs-panel.tsx | 4 +- .../app-selector/app-trigger.tsx | 6 +- .../detail-header/index.tsx | 2 +- .../plugin-detail-panel/endpoint-card.tsx | 2 +- .../plugin-detail-panel/endpoint-list.tsx | 10 +- .../plugin-detail-panel/endpoint-modal.tsx | 2 +- .../plugins/plugin-detail-panel/index.tsx | 4 +- .../model-selector/index.tsx | 4 +- .../model-selector/llm-params-panel.tsx | 4 +- .../multiple-tool-selector/index.tsx | 10 +- .../operation-dropdown.tsx | 2 +- .../plugin-detail-panel/strategy-detail.tsx | 18 +- .../plugin-detail-panel/strategy-item.tsx | 6 +- .../subscription-list/create/index.tsx | 2 +- .../subscription-list/list-view.tsx | 2 +- .../subscription-list/log-viewer.tsx | 10 +- .../subscription-list/selector-entry.tsx | 4 +- .../subscription-list/selector-view.tsx | 6 +- .../subscription-list/subscription-card.tsx | 6 +- .../__tests__/tool-credentials-form.spec.tsx | 2 +- .../components/reasoning-config-form.tsx | 12 +- .../components/tool-credentials-form.tsx | 2 +- .../tool-selector/components/tool-item.tsx | 2 +- .../tool-selector/components/tool-trigger.tsx | 6 +- .../tool-selector/index.tsx | 4 +- .../__tests__/event-detail-drawer.spec.tsx | 2 +- .../trigger/__tests__/event-list.spec.tsx | 2 +- .../trigger/event-detail-drawer.tsx | 18 +- .../trigger/event-list.tsx | 10 +- .../components/plugins/plugin-item/index.tsx | 18 +- .../__tests__/category-filter.spec.tsx | 2 +- .../filter-management/category-filter.tsx | 8 +- .../filter-management/tag-filter.tsx | 12 +- .../components/plugins/plugin-page/index.tsx | 2 +- .../plugin-page/install-plugin-dropdown.tsx | 2 +- .../components/task-status-indicator.tsx | 4 +- web/app/components/plugins/provider-card.tsx | 2 +- .../readme-panel/__tests__/entrance.spec.tsx | 2 +- .../plugins/readme-panel/entrance.tsx | 6 +- .../components/plugins/readme-panel/index.tsx | 6 +- .../auto-update-setting/index.tsx | 6 +- .../no-data-placeholder.tsx | 4 +- .../no-plugin-selected.tsx | 2 +- .../auto-update-setting/plugins-picker.tsx | 2 +- .../auto-update-setting/plugins-selected.tsx | 2 +- .../auto-update-setting/tool-picker.tsx | 2 +- .../plugins/reference-setting-modal/label.tsx | 4 +- .../update-plugin/from-market-place.tsx | 2 +- .../update-plugin/plugin-version-picker.tsx | 8 +- .../components/chunk-card-list/index.tsx | 2 +- .../panel/input-field/editor/index.tsx | 6 +- .../input-field/field-list/field-item.tsx | 8 +- .../field-list/field-list-container.tsx | 2 +- .../panel/input-field/field-list/index.tsx | 2 +- .../components/panel/input-field/index.tsx | 2 +- .../panel/input-field/preview/index.tsx | 2 +- .../data-source-options/option-card.tsx | 6 +- .../test-run/preparation/step-indicator.tsx | 2 +- .../panel/test-run/result/tabs/tab.tsx | 4 +- .../__tests__/run-mode.spec.tsx | 2 +- .../publisher/__tests__/popup.spec.tsx | 2 +- .../rag-pipeline-header/publisher/popup.tsx | 2 +- .../rag-pipeline-header/run-mode.tsx | 4 +- .../share/text-generation/index.tsx | 2 +- .../share/text-generation/info-modal.tsx | 6 +- .../share/text-generation/menu-dropdown.tsx | 10 +- .../run-batch/csv-reader/index.tsx | 4 +- .../share/text-generation/run-batch/index.tsx | 2 +- .../run-batch/res-download/index.tsx | 2 +- .../share/text-generation/run-once/index.tsx | 4 +- .../text-generation-result-panel.tsx | 14 +- .../text-generation-sidebar.tsx | 8 +- .../config-credentials.tsx | 2 +- .../edit-custom-collection-modal/index.tsx | 2 +- web/app/components/tools/labels/filter.tsx | 10 +- web/app/components/tools/labels/selector.tsx | 6 +- .../components/tools/mcp/detail/content.tsx | 2 +- .../tools/mcp/detail/list-loading.tsx | 2 +- .../tools/mcp/detail/operation-dropdown.tsx | 6 +- .../tools/mcp/detail/provider-detail.tsx | 4 +- .../components/tools/mcp/detail/tool-item.tsx | 12 +- .../components/tools/mcp/headers-input.tsx | 2 +- web/app/components/tools/mcp/index.tsx | 4 +- .../components/tools/mcp/mcp-server-modal.tsx | 2 +- .../components/tools/mcp/mcp-service-card.tsx | 2 +- web/app/components/tools/mcp/modal.tsx | 2 +- .../components/tools/mcp/provider-card.tsx | 2 +- .../mcp/sections/authentication-section.tsx | 4 +- web/app/components/tools/provider-list.tsx | 8 +- web/app/components/tools/provider/detail.tsx | 4 +- web/app/components/tools/provider/empty.tsx | 4 +- .../components/tools/provider/tool-item.tsx | 6 +- .../setting/build-in/config-credentials.tsx | 2 +- .../tools/workflow-tool/configure-button.tsx | 2 +- .../workflow-tool/confirm-modal/index.tsx | 2 +- .../components/tools/workflow-tool/index.tsx | 2 +- .../tools/workflow-tool/method-selector.tsx | 12 +- .../workflow-header/features-trigger.tsx | 2 +- .../start-node-selection-panel.tsx | 4 +- web/app/components/workflow/block-icon.tsx | 2 +- .../block-selector/all-start-blocks.tsx | 2 +- .../workflow/block-selector/all-tools.tsx | 2 +- .../workflow/block-selector/data-sources.tsx | 6 +- .../workflow/block-selector/index-bar.tsx | 4 +- .../market-place-plugin/action.tsx | 2 +- .../market-place-plugin/item.tsx | 12 +- .../market-place-plugin/list.tsx | 10 +- .../rag-tool-recommendations/list.tsx | 2 +- .../workflow/block-selector/tabs.tsx | 4 +- .../workflow/block-selector/tool-picker.tsx | 2 +- .../block-selector/tool/action-item.tsx | 8 +- .../workflow/block-selector/tool/tool.tsx | 6 +- .../workflow/block-selector/tools.tsx | 2 +- .../trigger-plugin/action-item.tsx | 8 +- .../block-selector/trigger-plugin/item.tsx | 6 +- .../block-selector/view-type-select.tsx | 4 +- web/app/components/workflow/custom-edge.tsx | 2 +- .../workflow/dsl-export-confirm-modal.tsx | 4 +- .../workflow/header/chat-variable-button.tsx | 2 +- .../workflow/header/checklist/index.tsx | 14 +- .../header/checklist/item-indicator.tsx | 2 +- .../workflow/header/checklist/node-group.tsx | 8 +- .../header/checklist/plugin-group.tsx | 2 +- .../components/workflow/header/env-button.tsx | 2 +- .../header/global-variable-button.tsx | 2 +- .../workflow/header/header-in-restoring.tsx | 2 +- .../workflow/header/run-and-history.tsx | 2 +- .../components/workflow/header/run-mode.tsx | 6 +- .../header/scroll-to-selected-node-button.tsx | 4 +- .../components/workflow/header/undo-redo.tsx | 6 +- .../header/version-history-button.tsx | 2 +- .../workflow/header/view-history.tsx | 14 +- .../workflow/header/view-workflow-history.tsx | 10 +- web/app/components/workflow/index.tsx | 2 +- .../nodes/_base/components/add-button.tsx | 2 +- .../components/agent-strategy-selector.tsx | 4 +- .../components/before-run-form/form-item.tsx | 10 +- .../_base/components/before-run-form/form.tsx | 2 +- .../components/before-run-form/index.tsx | 2 +- .../components/code-generator-button.tsx | 2 +- .../nodes/_base/components/collapse/index.tsx | 2 +- .../nodes/_base/components/editor/base.tsx | 4 +- .../code-editor/editor-support-vars.tsx | 2 +- .../components/editor/code-editor/index.tsx | 4 +- .../error-handle/error-handle-on-node.tsx | 4 +- .../error-handle/fail-branch-card.tsx | 6 +- .../workflow/nodes/_base/components/field.tsx | 6 +- .../nodes/_base/components/file-type-item.tsx | 8 +- .../_base/components/form-input-boolean.tsx | 10 +- .../components/form-input-item.sections.tsx | 6 +- .../_base/components/form-input-item.tsx | 2 +- .../components/form-input-type-switch.tsx | 4 +- .../workflow/nodes/_base/components/group.tsx | 4 +- .../components/input-support-select-var.tsx | 4 +- .../components/install-plugin-button.tsx | 2 +- .../nodes/_base/components/layout/box.tsx | 2 +- .../_base/components/layout/field-title.tsx | 6 +- .../nodes/_base/components/layout/group.tsx | 2 +- .../components/list-no-data-placeholder.tsx | 2 +- .../nodes/_base/components/memory-config.tsx | 6 +- .../mixed-variable-text-input/index.tsx | 2 +- .../_base/components/next-step/container.tsx | 6 +- .../nodes/_base/components/next-step/item.tsx | 2 +- .../nodes/_base/components/node-handle.tsx | 8 +- .../nodes/_base/components/node-resizer.tsx | 4 +- .../_base/components/node-status-icon.tsx | 2 +- .../nodes/_base/components/option-card.tsx | 6 +- .../nodes/_base/components/output-vars.tsx | 6 +- .../nodes/_base/components/prompt/editor.tsx | 12 +- .../readonly-input-with-select-var.tsx | 4 +- .../_base/components/retry/retry-on-node.tsx | 4 +- .../nodes/_base/components/selector.tsx | 8 +- .../nodes/_base/components/setting-item.tsx | 8 +- .../workflow/nodes/_base/components/split.tsx | 2 +- .../components/support-var-input/index.tsx | 2 +- .../components/switch-plugin-version.tsx | 2 +- .../object-child-tree-panel/picker/field.tsx | 6 +- .../object-child-tree-panel/picker/index.tsx | 6 +- .../object-child-tree-panel/show/field.tsx | 14 +- .../tree-indent-line.tsx | 4 +- .../_base/components/variable/var-list.tsx | 4 +- .../variable/var-reference-picker.trigger.tsx | 4 +- .../variable/var-reference-picker.tsx | 2 +- .../variable/var-reference-vars.tsx | 24 +- .../components/variable/var-type-picker.tsx | 4 +- .../variable-label/base/variable-icon.tsx | 2 +- .../variable-label/base/variable-label.tsx | 6 +- .../variable-label/base/variable-name.tsx | 4 +- .../variable-icon-with-color.tsx | 2 +- .../variable-label-in-editor.tsx | 2 +- .../variable-label/variable-label-in-node.tsx | 2 +- .../variable-label/variable-label-in-text.tsx | 2 +- .../_base/components/workflow-panel/index.tsx | 20 +- .../workflow-panel/trigger-subscription.tsx | 2 +- .../components/workflow/nodes/_base/node.tsx | 10 +- .../nodes/agent/components/tool-icon.tsx | 6 +- .../components/operation-selector.tsx | 10 +- .../nodes/data-source-empty/index.tsx | 2 +- .../nodes/http/components/api-input.tsx | 6 +- .../http/components/authorization/index.tsx | 2 +- .../components/authorization/radio-group.tsx | 6 +- .../nodes/http/components/edit-body/index.tsx | 4 +- .../key-value/key-value-edit/index.tsx | 6 +- .../key-value/key-value-edit/input-item.tsx | 4 +- .../key-value/key-value-edit/item.tsx | 4 +- .../components/workflow/nodes/http/panel.tsx | 6 +- .../components/button-style-dropdown.tsx | 2 +- .../delivery-method/email-configure-modal.tsx | 4 +- .../components/delivery-method/index.tsx | 2 +- .../delivery-method/mail-body-input.tsx | 2 +- .../delivery-method/method-item.tsx | 6 +- .../delivery-method/method-selector.tsx | 46 +- .../delivery-method/recipient/email-input.tsx | 4 +- .../delivery-method/recipient/email-item.tsx | 4 +- .../delivery-method/recipient/index.tsx | 12 +- .../delivery-method/recipient/member-list.tsx | 8 +- .../recipient/member-selector.tsx | 2 +- .../delivery-method/test-email-sender.tsx | 2 +- .../delivery-method/upgrade-modal.tsx | 2 +- .../human-input/components/form-content.tsx | 10 +- .../nodes/human-input/components/timeout.tsx | 8 +- .../components/variable-in-markdown.tsx | 2 +- .../workflow/nodes/human-input/node.tsx | 14 +- .../workflow/nodes/human-input/panel.tsx | 4 +- .../condition-list/condition-item.tsx | 10 +- .../condition-list/condition-operator.tsx | 2 +- .../components/condition-list/index.tsx | 10 +- .../components/condition-number-input.tsx | 2 +- .../if-else/components/condition-wrap.tsx | 4 +- .../workflow/nodes/iteration/add-block.tsx | 8 +- .../workflow/nodes/iteration/node.tsx | 2 +- .../components/chunk-structure/hooks.tsx | 2 +- .../chunk-structure/instruction/index.tsx | 10 +- .../components/index-method.tsx | 4 +- .../knowledge-base/components/option-card.tsx | 10 +- .../search-method-option.tsx | 8 +- .../workflow/nodes/knowledge-base/node.tsx | 8 +- .../condition-list/condition-date.tsx | 6 +- .../condition-list/condition-item.tsx | 8 +- .../condition-list/condition-operator.tsx | 2 +- .../condition-list/condition-value-method.tsx | 2 +- .../metadata/condition-list/index.tsx | 10 +- .../components/metadata/metadata-icon.tsx | 2 +- .../components/retrieval-config.tsx | 2 +- .../components/extract-input.tsx | 4 +- .../components/filter-condition.tsx | 2 +- .../list-operator/components/limit-config.tsx | 2 +- .../components/sub-variable-picker.tsx | 6 +- .../nodes/llm/components/config-prompt.tsx | 4 +- .../json-schema-config-modal/code-editor.tsx | 6 +- .../error-message.tsx | 4 +- .../json-importer.tsx | 2 +- .../json-schema-config.tsx | 2 +- .../json-schema-generator/index.tsx | 2 +- .../schema-editor.tsx | 4 +- .../edit-card/auto-width-input.tsx | 6 +- .../visual-editor/edit-card/index.tsx | 8 +- .../visual-editor/edit-card/type-selector.tsx | 4 +- .../visual-editor/index.tsx | 2 +- .../visual-editor/schema-node.tsx | 2 +- .../llm/components/prompt-generator-btn.tsx | 2 +- .../nodes/llm/components/structure-output.tsx | 4 +- .../workflow/nodes/loop/add-block.tsx | 10 +- .../condition-list/condition-item.tsx | 8 +- .../condition-list/condition-operator.tsx | 2 +- .../loop/components/condition-list/index.tsx | 10 +- .../components/condition-number-input.tsx | 2 +- .../nodes/loop/components/condition-wrap.tsx | 4 +- .../loop/components/loop-variables/empty.tsx | 2 +- .../components/loop-variables/form-item.tsx | 2 +- .../workflow/nodes/loop/insert-block.tsx | 4 +- .../components/workflow/nodes/loop/node.tsx | 2 +- .../extract-parameter/import-from-tool.tsx | 2 +- .../components/class-list.tsx | 8 +- .../nodes/start/components/var-item.tsx | 2 +- .../nodes/start/components/var-list.tsx | 6 +- .../nodes/tool/components/input-var-list.tsx | 2 +- .../mixed-variable-text-input/index.tsx | 2 +- .../mixed-variable-text-input/placeholder.tsx | 6 +- .../components/workflow/nodes/tool/node.tsx | 8 +- .../components/generic-table.tsx | 16 +- .../components/paragraph-input.tsx | 8 +- .../components/add-variable/index.tsx | 2 +- .../components/node-group-item.tsx | 8 +- .../components/node-variable-item.tsx | 4 +- .../nodes/variable-assigner/panel.tsx | 2 +- .../components/workflow/note-node/index.tsx | 4 +- .../plugins/link-editor-plugin/component.tsx | 2 +- .../note-editor/toolbar/color-picker.tsx | 6 +- .../note-node/note-editor/toolbar/command.tsx | 2 +- .../toolbar/font-size-selector.tsx | 6 +- .../note-editor/toolbar/operator.tsx | 2 +- .../workflow/operator/add-block.tsx | 2 +- .../components/workflow/operator/control.tsx | 2 +- .../workflow/operator/more-actions.tsx | 14 +- .../workflow/operator/zoom-in-out.tsx | 8 +- .../components/workflow/panel-contextmenu.tsx | 2 +- .../components/array-bool-list.tsx | 2 +- .../components/variable-item.tsx | 10 +- .../components/variable-modal.sections.tsx | 2 +- .../components/variable-modal.tsx | 2 +- .../components/variable-type-select.tsx | 12 +- .../panel/chat-variable-panel/index.tsx | 26 +- .../conversation-variable-modal.tsx | 18 +- .../panel/debug-and-preview/index.tsx | 8 +- .../panel/debug-and-preview/user-input.tsx | 4 +- .../workflow/panel/env-panel/env-item.tsx | 14 +- .../workflow/panel/env-panel/index.tsx | 8 +- .../panel/env-panel/variable-modal.tsx | 8 +- .../panel/global-variable-panel/index.tsx | 8 +- .../panel/global-variable-panel/item.tsx | 8 +- web/app/components/workflow/panel/index.tsx | 4 +- .../context-menu/menu-item.tsx | 6 +- .../version-history-panel/filter/index.tsx | 4 +- .../version-history-panel/loading/item.tsx | 6 +- .../version-history-item.tsx | 16 +- .../workflow/panel/workflow-preview.tsx | 2 +- .../workflow/run/agent-log/agent-log-item.tsx | 4 +- .../run/agent-log/agent-log-trigger.tsx | 10 +- web/app/components/workflow/run/index.tsx | 8 +- .../iteration-log/iteration-result-panel.tsx | 6 +- .../run/loop-log/loop-result-panel.tsx | 4 +- .../workflow/run/loop-result-panel.tsx | 10 +- web/app/components/workflow/run/node.tsx | 16 +- .../workflow/run/status-container.tsx | 6 +- web/app/components/workflow/run/status.tsx | 18 +- .../components/workflow/run/tracing-panel.tsx | 6 +- .../components/workflow/shortcuts-name.tsx | 4 +- .../components/workflow/simple-node/index.tsx | 6 +- .../variable-inspect/display-content.tsx | 10 +- .../workflow/variable-inspect/empty.tsx | 4 +- .../workflow/variable-inspect/group.tsx | 2 +- .../workflow/variable-inspect/index.tsx | 2 +- .../variable-inspect/large-data-alert.tsx | 6 +- .../workflow/variable-inspect/left.tsx | 2 +- .../workflow/variable-inspect/listening.tsx | 2 +- .../workflow/variable-inspect/panel.tsx | 10 +- .../workflow/variable-inspect/right.tsx | 12 +- .../workflow/variable-inspect/trigger.tsx | 10 +- .../value-content-sections.tsx | 4 +- .../variable-inspect/value-content.tsx | 2 +- .../components/error-handle-on-node.tsx | 4 +- .../components/node-handle.tsx | 6 +- .../components/nodes/base.tsx | 12 +- .../components/nodes/iteration/node.tsx | 2 +- .../components/nodes/loop/node.tsx | 2 +- .../components/note-node/index.tsx | 2 +- .../components/zoom-in-out.tsx | 6 +- .../workflow/workflow-preview/index.tsx | 2 +- web/app/education-apply/role-selector.tsx | 6 +- .../forgot-password/ChangePasswordForm.tsx | 6 +- web/app/forgot-password/page.tsx | 2 +- web/app/init/page.tsx | 2 +- web/app/install/installForm.tsx | 6 +- web/app/install/page.tsx | 2 +- web/app/reset-password/layout.tsx | 6 +- web/app/reset-password/set-password/page.tsx | 2 +- web/app/signin/components/social-auth.tsx | 2 +- web/app/signin/layout.tsx | 6 +- web/app/signin/normal-form.tsx | 2 +- web/app/signin/split.tsx | 2 +- web/app/signup/layout.tsx | 6 +- web/app/signup/set-password/page.tsx | 2 +- web/app/styles/globals.css | 849 +------ web/app/styles/markdown.css | 3 +- web/docs/test.md | 2 - web/eslint-suppressions.json | 2200 ----------------- web/package.json | 3 +- web/scripts/gen-icons.mjs | 2 +- web/tailwind-common-config.ts | 86 +- web/themes/markdown-light.css | 44 - .../{markdown-dark.css => markdown.css} | 1 + 990 files changed, 3065 insertions(+), 5786 deletions(-) create mode 100644 packages/dify-ui/AGENTS.md create mode 100644 packages/dify-ui/package.json rename web/utils/classnames.ts => packages/dify-ui/src/cn.ts (100%) create mode 100644 packages/dify-ui/src/styles/styles.css create mode 100644 packages/dify-ui/src/styles/utilities.css create mode 100644 packages/dify-ui/src/tailwind-preset.ts rename {web => packages/dify-ui/src}/themes/dark.css (100%) rename {web => packages/dify-ui/src}/themes/light.css (100%) rename {web => packages/dify-ui/src}/themes/tailwind-theme-var-define.ts (100%) create mode 100644 packages/dify-ui/tsconfig.json delete mode 100644 web/app/components/base/modal/index.css delete mode 100644 web/themes/markdown-light.css rename web/themes/{markdown-dark.css => markdown.css} (98%) diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md new file mode 100644 index 0000000000..6875f8b4e9 --- /dev/null +++ b/packages/dify-ui/AGENTS.md @@ -0,0 +1,27 @@ +# @langgenius/dify-ui + +This package provides shared design tokens (colors, shadows, typography), the `cn()` utility, and a Tailwind CSS preset consumed by `web/`. + +## Border Radius: Figma Token → Tailwind Class Mapping + +The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset. + +| Figma Token | Value | Tailwind Class | +|---|---|---| +| `--radius/2xs` | 2px | `rounded-xs` | +| `--radius/xs` | 4px | `rounded-sm` | +| `--radius/sm` | 6px | `rounded-md` | +| `--radius/md` | 8px | `rounded-lg` | +| `--radius/lg` | 10px | `rounded-[10px]` | +| `--radius/xl` | 12px | `rounded-xl` | +| `--radius/2xl` | 16px | `rounded-2xl` | +| `--radius/3xl` | 20px | `rounded-[20px]` | +| `--radius/6xl` | 28px | `rounded-[28px]` | +| `--radius/full` | 999px | `rounded-full` | + +### Rules + +- **Do not** add custom `borderRadius` values to `tailwind-preset.ts`. We use Tailwind v4 defaults and arbitrary values (`rounded-[Npx]`) for sizes without a standard equivalent. +- **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed. +- When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`). +- For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`. diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json new file mode 100644 index 0000000000..d8314a6be3 --- /dev/null +++ b/packages/dify-ui/package.json @@ -0,0 +1,24 @@ +{ + "name": "@langgenius/dify-ui", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + "./styles.css": "./src/styles/styles.css", + "./tailwind-preset": { + "import": "./src/tailwind-preset.ts", + "types": "./src/tailwind-preset.ts" + }, + "./cn": { + "import": "./src/cn.ts", + "types": "./src/cn.ts" + } + }, + "dependencies": { + "clsx": "catalog:", + "tailwind-merge": "catalog:" + }, + "devDependencies": { + "tailwindcss": "catalog:" + } +} diff --git a/web/utils/classnames.ts b/packages/dify-ui/src/cn.ts similarity index 100% rename from web/utils/classnames.ts rename to packages/dify-ui/src/cn.ts diff --git a/packages/dify-ui/src/styles/styles.css b/packages/dify-ui/src/styles/styles.css new file mode 100644 index 0000000000..a712e9c5db --- /dev/null +++ b/packages/dify-ui/src/styles/styles.css @@ -0,0 +1,3 @@ +@import '../themes/light.css' layer(base); +@import '../themes/dark.css' layer(base); +@import './utilities.css'; diff --git a/packages/dify-ui/src/styles/utilities.css b/packages/dify-ui/src/styles/utilities.css new file mode 100644 index 0000000000..69b15d4c10 --- /dev/null +++ b/packages/dify-ui/src/styles/utilities.css @@ -0,0 +1,272 @@ +@utility system-kbd { + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +@utility system-2xs-regular-uppercase { + font-size: 10px; + font-weight: 400; + text-transform: uppercase; + line-height: 12px; +} + +@utility system-2xs-regular { + font-size: 10px; + font-weight: 400; + line-height: 12px; +} + +@utility system-2xs-medium { + font-size: 10px; + font-weight: 500; + line-height: 12px; +} + +@utility system-2xs-medium-uppercase { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + line-height: 12px; +} + +@utility system-2xs-semibold-uppercase { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + line-height: 12px; +} + +@utility system-xs-regular { + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +@utility system-xs-regular-uppercase { + font-size: 12px; + font-weight: 400; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-xs-medium { + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +@utility system-xs-medium-uppercase { + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-xs-semibold { + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +@utility system-xs-semibold-uppercase { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-sm-regular { + font-size: 13px; + font-weight: 400; + line-height: 16px; +} + +@utility system-sm-medium { + font-size: 13px; + font-weight: 500; + line-height: 16px; +} + +@utility system-sm-medium-uppercase { + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-sm-semibold { + font-size: 13px; + font-weight: 600; + line-height: 16px; +} + +@utility system-sm-semibold-uppercase { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + line-height: 16px; +} + +@utility system-md-regular { + font-size: 14px; + font-weight: 400; + line-height: 20px; +} + +@utility system-md-medium { + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +@utility system-md-semibold { + font-size: 14px; + font-weight: 600; + line-height: 20px; +} + +@utility system-md-semibold-uppercase { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + line-height: 20px; +} + +@utility system-xl-medium { + font-size: 16px; + font-weight: 500; + line-height: 24px; +} + +@utility system-xl-semibold { + font-size: 16px; + font-weight: 600; + line-height: 24px; +} + +@utility code-xs-regular { + font-size: 12px; + font-weight: 400; + line-height: 1.5; +} + +@utility code-sm-regular { + font-size: 13px; + font-weight: 400; + line-height: 1.5; +} + +@utility code-sm-semibold { + font-size: 13px; + font-weight: 600; + line-height: 1.5; +} + +@utility body-xs-regular { + font-size: 12px; + font-weight: 400; + line-height: 16px; +} + +@utility body-xs-medium { + font-size: 12px; + font-weight: 500; + line-height: 16px; +} + +@utility body-sm-regular { + font-size: 13px; + font-weight: 400; + line-height: 16px; +} + +@utility body-sm-medium { + font-size: 13px; + font-weight: 500; + line-height: 16px; +} + +@utility body-md-regular { + font-size: 14px; + font-weight: 400; + line-height: 20px; +} + +@utility body-md-medium { + font-size: 14px; + font-weight: 500; + line-height: 20px; +} + +@utility body-lg-regular { + font-size: 15px; + font-weight: 400; + line-height: 20px; +} + +@utility body-2xl-regular { + font-size: 18px; + font-weight: 400; + line-height: 1.5; +} + +@utility title-xs-semi-bold { + font-size: 12px; + font-weight: 600; + line-height: 16px; +} + +@utility title-sm-semi-bold { + font-size: 13px; + font-weight: 600; + line-height: 16px; +} + +@utility title-md-semi-bold { + font-size: 14px; + font-weight: 600; + line-height: 20px; +} + +@utility title-lg-bold { + font-size: 15px; + font-weight: 700; + line-height: 1.2; +} + +@utility title-xl-semi-bold { + font-size: 16px; + font-weight: 600; + line-height: 1.2; +} + +@utility title-2xl-semi-bold { + font-size: 18px; + font-weight: 600; + line-height: 1.2; +} + +@utility title-3xl-semi-bold { + font-size: 20px; + font-weight: 600; + line-height: 1.2; +} + +@utility title-3xl-bold { + font-size: 20px; + font-weight: 700; + line-height: 1.2; +} + +@utility title-4xl-semi-bold { + font-size: 24px; + font-weight: 600; + line-height: 1.2; +} + +@utility title-5xl-bold { + font-size: 30px; + font-weight: 700; + line-height: 1.2; +} diff --git a/packages/dify-ui/src/tailwind-preset.ts b/packages/dify-ui/src/tailwind-preset.ts new file mode 100644 index 0000000000..2dbf4781b0 --- /dev/null +++ b/packages/dify-ui/src/tailwind-preset.ts @@ -0,0 +1,87 @@ +import tailwindThemeVarDefine from './themes/tailwind-theme-var-define' + +const difyUIPreset = { + theme: { + extend: { + colors: { + gray: { + 25: '#fcfcfd', + 50: '#f9fafb', + 100: '#f2f4f7', + 200: '#eaecf0', + 300: '#d0d5dd', + 400: '#98a2b3', + 500: '#667085', + 600: '#344054', + 700: '#475467', + 800: '#1d2939', + 900: '#101828', + }, + primary: { + 25: '#f5f8ff', + 50: '#eff4ff', + 100: '#d1e0ff', + 200: '#b2ccff', + 300: '#84adff', + 400: '#528bff', + 500: '#2970ff', + 600: '#155eef', + 700: '#004eeb', + 800: '#0040c1', + 900: '#00359e', + }, + blue: { + 500: '#E1EFFE', + }, + green: { + 50: '#F3FAF7', + 100: '#DEF7EC', + 800: '#03543F', + }, + yellow: { + 100: '#FDF6B2', + 800: '#723B13', + }, + purple: { + 50: '#F6F5FF', + 200: '#DCD7FE', + }, + indigo: { + 25: '#F5F8FF', + 50: '#EEF4FF', + 100: '#E0EAFF', + 300: '#A4BCFD', + 400: '#8098F9', + 600: '#444CE7', + 800: '#2D31A6', + }, + ...tailwindThemeVarDefine, + }, + boxShadow: { + 'xs': '0px 1px 2px 0px rgba(16, 24, 40, 0.05)', + 'sm': '0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.10)', + 'sm-no-bottom': '0px -1px 2px 0px rgba(16, 24, 40, 0.06), 0px -1px 3px 0px rgba(16, 24, 40, 0.10)', + 'md': '0px 2px 4px -2px rgba(16, 24, 40, 0.06), 0px 4px 8px -2px rgba(16, 24, 40, 0.10)', + 'lg': '0px 4px 6px -2px rgba(16, 24, 40, 0.03), 0px 12px 16px -4px rgba(16, 24, 40, 0.08)', + 'xl': '0px 8px 8px -4px rgba(16, 24, 40, 0.03), 0px 20px 24px -4px rgba(16, 24, 40, 0.08)', + '2xl': '0px 24px 48px -12px rgba(16, 24, 40, 0.18)', + '3xl': '0px 32px 64px -12px rgba(16, 24, 40, 0.14)', + 'status-indicator-green-shadow': '0px 2px 6px 0px var(--color-components-badge-status-light-success-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + 'status-indicator-warning-shadow': '0px 2px 6px 0px var(--color-components-badge-status-light-warning-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + 'status-indicator-red-shadow': '0px 2px 6px 0px var(--color-components-badge-status-light-error-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + 'status-indicator-blue-shadow': '0px 2px 6px 0px var(--color-components-badge-status-light-normal-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + 'status-indicator-gray-shadow': '0px 1px 2px 0px var(--color-components-badge-status-light-disabled-halo), 0px 0px 0px 1px var(--color-components-badge-status-light-border-outer)', + }, + opacity: { + 2: '0.02', + 8: '0.08', + }, + fontSize: { + '2xs': '0.625rem', + }, + }, + }, + plugins: [], +} + +export default difyUIPreset diff --git a/web/themes/dark.css b/packages/dify-ui/src/themes/dark.css similarity index 100% rename from web/themes/dark.css rename to packages/dify-ui/src/themes/dark.css diff --git a/web/themes/light.css b/packages/dify-ui/src/themes/light.css similarity index 100% rename from web/themes/light.css rename to packages/dify-ui/src/themes/light.css diff --git a/web/themes/tailwind-theme-var-define.ts b/packages/dify-ui/src/themes/tailwind-theme-var-define.ts similarity index 100% rename from web/themes/tailwind-theme-var-define.ts rename to packages/dify-ui/src/themes/tailwind-theme-var-define.ts diff --git a/packages/dify-ui/tsconfig.json b/packages/dify-ui/tsconfig.json new file mode 100644 index 0000000000..3e912baba0 --- /dev/null +++ b/packages/dify-ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4444981601..094faf78cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -599,6 +599,19 @@ importers: specifier: 'catalog:' version: 0.1.16(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.6.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(happy-dom@20.9.0)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3) + packages/dify-ui: + dependencies: + clsx: + specifier: 'catalog:' + version: 2.1.1 + tailwind-merge: + specifier: 'catalog:' + version: 3.5.0 + devDependencies: + tailwindcss: + specifier: 'catalog:' + version: 4.2.2 + packages/iconify-collections: devDependencies: iconify-import-svg: @@ -739,9 +752,6 @@ importers: client-only: specifier: 'catalog:' version: 0.0.1 - clsx: - specifier: 'catalog:' - version: 2.1.1 cmdk: specifier: 'catalog:' version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -922,9 +932,6 @@ importers: string-ts: specifier: 'catalog:' version: 2.3.1 - tailwind-merge: - specifier: 'catalog:' - version: 3.5.0 tldts: specifier: 'catalog:' version: 7.0.28 @@ -971,6 +978,9 @@ importers: '@iconify-json/ri': specifier: 'catalog:' version: 1.2.10 + '@langgenius/dify-ui': + specifier: workspace:* + version: link:../packages/dify-ui '@mdx-js/loader': specifier: 'catalog:' version: 3.1.1(webpack@5.105.4(uglify-js@3.19.3)) @@ -1126,7 +1136,7 @@ importers: version: 4.12.12 knip: specifier: 'catalog:' - version: 6.4.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + version: 6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) postcss: specifier: 'catalog:' version: 8.5.9 @@ -1553,14 +1563,17 @@ packages: peerDependencies: tailwindcss: '*' - '@emnapi/core@1.9.1': - resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} '@emnapi/runtime@1.9.1': resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} - '@emnapi/wasi-threads@1.2.0': - resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@emoji-mart/data@1.2.1': resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} @@ -8007,6 +8020,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} @@ -9153,9 +9170,9 @@ snapshots: '@iconify/utils': 3.1.0 tailwindcss: 4.2.2 - '@emnapi/core@1.9.1': + '@emnapi/core@1.9.2': dependencies: - '@emnapi/wasi-threads': 1.2.0 + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true @@ -9164,7 +9181,12 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.0': + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true @@ -9944,10 +9966,10 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 '@tybys/wasm-util': 0.10.1 optional: true @@ -10120,9 +10142,9 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.121.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -10191,9 +10213,9 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -11758,7 +11780,7 @@ snapshots: debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: @@ -14140,7 +14162,7 @@ snapshots: khroma@2.1.0: {} - knip@6.4.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + knip@6.4.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): dependencies: '@nodelib/fs.walk': 1.2.8 fast-glob: 3.3.3 @@ -14148,8 +14170,8 @@ snapshots: get-tsconfig: 4.13.7 jiti: 2.6.1 minimist: 1.2.8 - oxc-parser: 0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) - oxc-resolver: 11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + oxc-parser: 0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) picocolors: 1.1.1 picomatch: 4.0.4 smol-toml: 1.6.1 @@ -15056,7 +15078,7 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - oxc-parser@0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + oxc-parser@0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): dependencies: '@oxc-project/types': 0.121.0 optionalDependencies: @@ -15076,7 +15098,7 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu': 0.121.0 '@oxc-parser/binding-linux-x64-musl': 0.121.0 '@oxc-parser/binding-openharmony-arm64': 0.121.0 - '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 '@oxc-parser/binding-win32-x64-msvc': 0.121.0 @@ -15084,7 +15106,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxc-resolver@11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): + oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -15102,7 +15124,7 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1) + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 @@ -16241,6 +16263,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@2.1.0: {} tinyrainbow@2.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 98f1fcfa86..cd72c6bc0e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,57 +1,22 @@ -catalogMode: prefer -trustPolicy: no-downgrade -trustPolicyExclude: - - chokidar@4.0.3 - - reselect@5.1.1 - - semver@6.3.1 -blockExoticSubdeps: true -strictDepBuilds: true -allowBuilds: - "@parcel/watcher": false - canvas: false - esbuild: false - sharp: false packages: - web - e2e - sdks/nodejs-client - packages/* -overrides: - "@lexical/code": npm:lexical-code-no-prism@0.41.0 - "@monaco-editor/loader": 1.7.0 - brace-expansion@>=2.0.0 <2.0.3: 2.0.3 - canvas: ^3.2.2 - dompurify@>=3.1.3 <=3.3.1: 3.3.2 - esbuild@<0.27.2: 0.27.2 - flatted@<=3.4.1: 3.4.2 - glob@>=10.2.0 <10.5.0: 11.1.0 - is-core-module: npm:@nolyfill/is-core-module@^1.0.39 - lodash@>=4.0.0 <= 4.17.23: 4.18.0 - lodash-es@>=4.0.0 <= 4.17.23: 4.18.0 - picomatch@<2.3.2: 2.3.2 - picomatch@>=4.0.0 <4.0.4: 4.0.4 - rollup@>=4.0.0 <4.59.0: 4.59.0 - safe-buffer: ^5.2.1 - safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 - side-channel: npm:@nolyfill/side-channel@^1.0.44 - smol-toml@<1.6.1: 1.6.1 - solid-js: 1.9.11 - string-width: ~8.2.0 - svgo@>=3.0.0 <3.3.3: 3.3.3 - tar@<=7.5.10: 7.5.11 - undici@>=7.0.0 <7.24.0: 7.24.0 - vite: npm:@voidzero-dev/vite-plus-core@0.1.16 - vitest: npm:@voidzero-dev/vite-plus-test@0.1.16 - yaml@>=2.0.0 <2.8.3: 2.8.3 - yauzl@<3.2.1: 3.2.1 +allowBuilds: + "@parcel/watcher": false + canvas: false + esbuild: false + sharp: false +blockExoticSubdeps: true catalog: "@amplitude/analytics-browser": 2.39.0 "@amplitude/plugin-session-replay-browser": 1.27.7 "@antfu/eslint-config": 8.2.0 "@base-ui/react": 1.4.0 - "@date-fns/tz": 1.4.1 "@chromatic-com/storybook": 5.1.2 "@cucumber/cucumber": 12.8.0 + "@date-fns/tz": 1.4.1 "@egoist/tailwindcss-icons": 1.9.2 "@emoji-mart/data": 1.2.1 "@eslint-react/eslint-plugin": 3.0.0 @@ -107,6 +72,7 @@ catalog: "@testing-library/jest-dom": 6.9.1 "@testing-library/react": 16.3.2 "@testing-library/user-event": 14.6.1 + '@tsdown/css': 0.21.8 "@tsslint/cli": 3.0.3 "@tsslint/compat-eslint": 3.0.3 "@tsslint/config": 3.0.3 @@ -230,3 +196,38 @@ catalog: zod: 4.3.6 zundo: 2.3.0 zustand: 5.0.12 +catalogMode: prefer +overrides: + "@lexical/code": npm:lexical-code-no-prism@0.41.0 + "@monaco-editor/loader": 1.7.0 + brace-expansion@>=2.0.0 <2.0.3: 2.0.3 + canvas: ^3.2.2 + dompurify@>=3.1.3 <=3.3.1: 3.3.2 + esbuild@<0.27.2: 0.27.2 + flatted@<=3.4.1: 3.4.2 + glob@>=10.2.0 <10.5.0: 11.1.0 + is-core-module: npm:@nolyfill/is-core-module@^1.0.39 + lodash-es@>=4.0.0 <= 4.17.23: 4.18.0 + lodash@>=4.0.0 <= 4.17.23: 4.18.0 + picomatch@<2.3.2: 2.3.2 + picomatch@>=4.0.0 <4.0.4: 4.0.4 + rollup@>=4.0.0 <4.59.0: 4.59.0 + safe-buffer: ^5.2.1 + safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44 + side-channel: npm:@nolyfill/side-channel@^1.0.44 + smol-toml@<1.6.1: 1.6.1 + solid-js: 1.9.11 + string-width: ~8.2.0 + svgo@>=3.0.0 <3.3.3: 3.3.3 + tar@<=7.5.10: 7.5.11 + undici@>=7.0.0 <7.24.0: 7.24.0 + vite: npm:@voidzero-dev/vite-plus-core@0.1.16 + vitest: npm:@voidzero-dev/vite-plus-test@0.1.16 + yaml@>=2.0.0 <2.8.3: 2.8.3 + yauzl@<3.2.1: 3.2.1 +strictDepBuilds: true +trustPolicy: no-downgrade +trustPolicyExclude: + - chokidar@4.0.3 + - reselect@5.1.1 + - semver@6.3.1 diff --git a/web/AGENTS.md b/web/AGENTS.md index 97f74441a7..4a705bf4b8 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -12,6 +12,10 @@ - `frontend-query-mutation` is the source of truth for Dify frontend contracts, query and mutation call-site patterns, conditional queries, invalidation, and mutation error handling. +## Design Token Mapping + +- When translating Figma designs to code, read `../packages/dify-ui/AGENTS.md` for the Figma `--radius/*` token to Tailwind `rounded-*` class mapping. The two scales are offset by one step. + ## Automated Test Generation - Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests. diff --git a/web/README.md b/web/README.md index 2d69a94dbd..382c804264 100644 --- a/web/README.md +++ b/web/README.md @@ -136,7 +136,6 @@ pnpm -C web test If you are not familiar with writing tests, refer to: -- [classnames.spec.ts] - Utility function test example - [index.spec.tsx] - Component test example ### Analyze Component Complexity @@ -166,7 +165,6 @@ The Dify community can be found on [Discord community], where you can ask questi [Storybook]: https://storybook.js.org [Vite+]: https://viteplus.dev [Vitest]: https://vitest.dev -[classnames.spec.ts]: ./utils/classnames.spec.ts [index.spec.tsx]: ./app/components/base/button/index.spec.tsx [pnpm]: https://pnpm.io [vinext]: https://github.com/cloudflare/vinext diff --git a/web/__tests__/plugins/plugin-auth-flow.test.tsx b/web/__tests__/plugins/plugin-auth-flow.test.tsx index a2ec8703ca..c4d28e3f34 100644 --- a/web/__tests__/plugins/plugin-auth-flow.test.tsx +++ b/web/__tests__/plugins/plugin-auth-flow.test.tsx @@ -31,7 +31,7 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/utils/classnames', () => ({ +vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) diff --git a/web/__tests__/plugins/plugin-card-rendering.test.tsx b/web/__tests__/plugins/plugin-card-rendering.test.tsx index 5bd7f0c8bf..fb0163f344 100644 --- a/web/__tests__/plugins/plugin-card-rendering.test.tsx +++ b/web/__tests__/plugins/plugin-card-rendering.test.tsx @@ -32,7 +32,7 @@ vi.mock('@/types/app', async () => { return vi.importActual('@/types/app') }) -vi.mock('@/utils/classnames', () => ({ +vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '), })) diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx index dbefb1fdc3..aa8f59ca31 100644 --- a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -226,7 +226,7 @@ vi.mock('@/app/components/tools/mcp', () => ({ default: () =>
MCP List
, })) -vi.mock('@/utils/classnames', () => ({ +vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) diff --git a/web/__tests__/tools/tool-provider-detail-flow.test.tsx b/web/__tests__/tools/tool-provider-detail-flow.test.tsx index ce5ffe531e..c52871c946 100644 --- a/web/__tests__/tools/tool-provider-detail-flow.test.tsx +++ b/web/__tests__/tools/tool-provider-detail-flow.test.tsx @@ -112,7 +112,7 @@ vi.mock('@/service/use-tools', () => ({ useInvalidateAllWorkflowTools: () => vi.fn(), })) -vi.mock('@/utils/classnames', () => ({ +vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index d3f15bdf46..8a1a6fd131 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { NavIcon } from '@/app/components/app-sidebar/nav-link' import type { App } from '@/types/app' +import { cn } from '@langgenius/dify-ui/cn' import { RiDashboard2Fill, RiDashboard2Line, @@ -28,7 +29,6 @@ import dynamic from '@/next/dynamic' import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' import { AppModeEnum } from '@/types/app' -import { cn } from '@/utils/classnames' import s from './style.module.css' const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx index 368c3dcfc3..dbc429cbdc 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/date-picker.tsx @@ -2,6 +2,7 @@ import type { Dayjs } from 'dayjs' import type { FC } from 'react' import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types' +import { cn } from '@langgenius/dify-ui/cn' import { RiCalendarLine } from '@remixicon/react' import dayjs from 'dayjs' import { noop } from 'es-toolkit/function' @@ -9,7 +10,6 @@ import * as React from 'react' import { useCallback } from 'react' import Picker from '@/app/components/base/date-and-time-picker/date-picker' import { useLocale } from '@/context/i18n' -import { cn } from '@/utils/classnames' import { formatToLocalTime } from '@/utils/format' type Props = { @@ -30,7 +30,7 @@ const DatePicker: FC = ({ const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => { return ( -
+
{value ? formatToLocalTime(value, locale, 'MMM D') : ''}
) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx index a4bf025139..a89b77e9e3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/time-range-picker/range-selector.tsx @@ -3,13 +3,13 @@ import type { FC } from 'react' import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart' import type { Item } from '@/app/components/base/select' import type { I18nKeysByPrefix } from '@/types/i18n' +import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' import dayjs from 'dayjs' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { SimpleSelect } from '@/app/components/base/select' -import { cn } from '@/utils/classnames' const today = dayjs() @@ -44,7 +44,7 @@ const RangeSelector: FC = ({ const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => { return ( -
+
{isCustomRange ? t('filter.period.custom', { ns: 'appLog' }) : item?.name}
@@ -57,13 +57,13 @@ const RangeSelector: FC = ({ {selected && ( )} - {item.name} + {item.name} ) }, []) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx index 17ca5d78cf..29de1a1eae 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { PopupProps } from './config-popup' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useCallback, useRef, useState } from 'react' import { @@ -9,7 +10,6 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { cn } from '@/utils/classnames' import ConfigPopup from './config-popup' type Props = { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx index 138d238b47..15b81e1ad6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC, JSX } from 'react' import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' +import { cn } from '@langgenius/dify-ui/cn' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useState } from 'react' @@ -9,7 +10,6 @@ import Divider from '@/app/components/base/divider' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' -import { cn } from '@/utils/classnames' import ProviderConfigModal from './provider-config-modal' import ProviderPanel from './provider-panel' import TracingIcon from './tracing-icon' @@ -331,7 +331,7 @@ const ConfigPopup: FC = ({
-
+
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`, { ns: 'app' })}
{!readOnly && ( @@ -350,7 +350,7 @@ const ConfigPopup: FC = ({
-
+
{t(`${I18N_PREFIX}.tracingDescription`, { ns: 'app' })}
@@ -379,7 +379,7 @@ const ConfigPopup: FC = ({
{configuredProviderPanel()}
-
{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`, { ns: 'app' })}
+
{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`, { ns: 'app' })}
{moreProviderPanel()}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx index 7c47249830..2b56ecfeea 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import Input from '@/app/components/base/input' -import { cn } from '@/utils/classnames' type Props = { className?: string diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index 239427159c..9c42f85825 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type' import type { TracingStatus } from '@/models/app' +import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownDoubleLine, RiEqualizer2Line, @@ -18,7 +19,6 @@ import Indicator from '@/app/components/header/indicator' import { useAppContext } from '@/context/app-context' import { usePathname } from '@/next/navigation' import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' -import { cn } from '@/utils/classnames' import ConfigButton from './config-button' import TracingIcon from './tracing-icon' import { TracingProvider } from './type' @@ -247,11 +247,11 @@ const Panel: FC = () => { >
-
{t(`${I18N_PREFIX}.title`, { ns: 'app' })}
+
{t(`${I18N_PREFIX}.title`, { ns: 'app' })}
@@ -286,12 +286,12 @@ const Panel: FC = () => { >
-
+
-
+
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`, { ns: 'app' })}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx index 076a5cd7d8..ed7f4ab962 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import { RiEqualizer2Line, } from '@remixicon/react' @@ -8,7 +9,6 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { AliyunIconBig, ArizeIconBig, DatabricksIconBig, LangfuseIconBig, LangsmithIconBig, MlflowIconBig, OpikIconBig, PhoenixIconBig, TencentIconBig, WeaveIconBig } from '@/app/components/base/icons/src/public/tracing' import { Eye as View } from '@/app/components/base/icons/src/vender/solid/general' -import { cn } from '@/utils/classnames' import { TracingProvider } from './type' const I18N_PREFIX = 'tracing' @@ -82,7 +82,7 @@ const ProviderPanel: FC = ({
- {isChosen &&
{t(`${I18N_PREFIX}.inUse`, { ns: 'app' })}
} + {isChosen &&
{t(`${I18N_PREFIX}.inUse`, { ns: 'app' })}
}
{!readOnly && (
@@ -102,7 +102,7 @@ const ProviderPanel: FC = ({
)}
-
+
{t(`${I18N_PREFIX}.${type}.description`, { ns: 'app' })}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx index 9bf1ddc50d..cdc0f753a6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/tracing-icon.tsx @@ -1,8 +1,8 @@ 'use client' import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { TracingIcon as Icon } from '@/app/components/base/icons/src/public/tracing' -import { cn } from '@/utils/classnames' type Props = { className?: string @@ -10,7 +10,7 @@ type Props = { } const sizeClassMap = { - lg: 'w-9 h-9 p-2 radius-lg', + lg: 'w-9 h-9 p-2 rounded-[10px]', md: 'w-6 h-6 p-1 rounded-lg', } diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 092e47278f..ba3272c1a7 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -1,6 +1,7 @@ 'use client' import type { RemixiconComponentType } from '@remixicon/react' import type { FC } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import { RiEqualizer2Fill, RiEqualizer2Line, @@ -24,7 +25,6 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { usePathname } from '@/next/navigation' import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' -import { cn } from '@/utils/classnames' type IAppDetailLayoutProps = { children: React.ReactNode diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index 898dab8f4a..be30585101 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -3,6 +3,7 @@ import type { ButtonProps } from '@/app/components/base/ui/button' import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types' import type { SiteInfo } from '@/models/share' import type { HumanInputFormError } from '@/service/use-share' +import { cn } from '@langgenius/dify-ui/cn' import { RiCheckboxCircleFill, RiErrorWarningFill, @@ -22,7 +23,6 @@ import { Button } from '@/app/components/base/ui/button' import useDocumentTitle from '@/hooks/use-document-title' import { useParams } from '@/next/navigation' import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share' -import { cn } from '@/utils/classnames' export type FormData = { site: { site: SiteInfo } @@ -101,7 +101,7 @@ const FormContent = () => { return (
-
+
@@ -129,7 +129,7 @@ const FormContent = () => { return (
-
+
@@ -157,7 +157,7 @@ const FormContent = () => { return (
-
+
@@ -185,7 +185,7 @@ const FormContent = () => { return (
-
+
@@ -211,7 +211,7 @@ const FormContent = () => { return (
-
+
@@ -248,7 +248,7 @@ const FormContent = () => {
{site.title}
-
+
{contentList.map((content, index) => (
{!systemFeatures.branding.enabled && ( -
+
© {' '} {new Date().getFullYear()} diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx index 5b89084ea1..df102e609a 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -1,4 +1,5 @@ 'use client' +import { cn } from '@langgenius/dify-ui/cn' import { RiCheckboxCircleFill } from '@remixicon/react' import { useCountDown } from 'ahooks' import { useCallback, useState } from 'react' @@ -9,7 +10,6 @@ import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' import { useRouter, useSearchParams } from '@/next/navigation' import { changeWebAppPasswordWithToken } from '@/service/common' -import { cn } from '@/utils/classnames' const ChangePasswordForm = () => { const { t } = useTranslation() diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index 21cb0e1f57..5451b45194 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -1,10 +1,10 @@ 'use client' import type { PropsWithChildren } from 'react' +import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' -import { cn } from '@/utils/classnames' export default function SignInLayout({ children }: PropsWithChildren) { const { t } = useTranslation() @@ -21,7 +21,7 @@ export default function SignInLayout({ children }: PropsWithChildren) {
{systemFeatures.branding.enabled === false && ( -
+
© {' '} {new Date().getFullYear()} diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index ed97e64806..436c7e64bb 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -1,4 +1,5 @@ 'use client' +import { cn } from '@langgenius/dify-ui/cn' import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' @@ -8,7 +9,6 @@ import { IS_CE_EDITION } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' import Link from '@/next/link' import { LicenseStatus } from '@/types/feature' -import { cn } from '@/utils/classnames' import MailAndCodeAuth from './components/mail-and-code-auth' import MailAndPasswordAuth from './components/mail-and-password-auth' import SSOAuth from './components/sso-auth' @@ -58,10 +58,10 @@ const NormalForm = () => {
- +

{t('licenseLost', { ns: 'login' })}

-

{t('licenseLostTip', { ns: 'login' })}

+

{t('licenseLostTip', { ns: 'login' })}

@@ -74,10 +74,10 @@ const NormalForm = () => {
- +

{t('licenseExpired', { ns: 'login' })}

-

{t('licenseExpiredTip', { ns: 'login' })}

+

{t('licenseExpiredTip', { ns: 'login' })}

@@ -90,10 +90,10 @@ const NormalForm = () => {
- +

{t('licenseInactive', { ns: 'login' })}

-

{t('licenseInactiveTip', { ns: 'login' })}

+

{t('licenseInactiveTip', { ns: 'login' })}

@@ -105,7 +105,7 @@ const NormalForm = () => {

{systemFeatures.branding.enabled ? t('pageTitleForE', { ns: 'login' }) : t('pageTitle', { ns: 'login' })}

-

{t('welcome', { ns: 'login' })}

+

{t('welcome', { ns: 'login' })}

@@ -122,7 +122,7 @@ const NormalForm = () => {
- {t('or', { ns: 'login' })} + {t('or', { ns: 'login' })}
)} @@ -159,7 +159,7 @@ const NormalForm = () => {

{t('noLoginMethod', { ns: 'login' })}

-

{t('noLoginMethodTip', { ns: 'login' })}

+

{t('noLoginMethodTip', { ns: 'login' })}