diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index dd311701b5..b1f32f96c2 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -35,6 +35,14 @@ jobs: cache: pnpm cache-dependency-path: ./web/pnpm-lock.yaml + - name: Restore Jest cache + uses: actions/cache@v4 + with: + path: web/.cache/jest + key: ${{ runner.os }}-jest-${{ hashFiles('web/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-jest- + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -45,7 +53,7 @@ jobs: run: | pnpm exec jest \ --ci \ - --runInBand \ + --maxWorkers=100% \ --coverage \ --passWithNoTests @@ -70,6 +78,13 @@ jobs: node <<'NODE' >> "$GITHUB_STEP_SUMMARY" const fs = require('fs'); const path = require('path'); + let libCoverage = null; + + try { + libCoverage = require('istanbul-lib-coverage'); + } catch (error) { + libCoverage = null; + } const summaryPath = path.join('coverage', 'coverage-summary.json'); const finalPath = path.join('coverage', 'coverage-final.json'); @@ -91,6 +106,54 @@ jobs: ? JSON.parse(fs.readFileSync(finalPath, 'utf8')) : null; + const getLineCoverageFromStatements = (statementMap, statementHits) => { + const lineHits = {}; + + if (!statementMap || !statementHits) { + return lineHits; + } + + Object.entries(statementMap).forEach(([key, statement]) => { + const line = statement?.start?.line; + if (!line) { + return; + } + const hits = statementHits[key] ?? 0; + const previous = lineHits[line]; + lineHits[line] = previous === undefined ? hits : Math.max(previous, hits); + }); + + return lineHits; + }; + + const getFileCoverage = (entry) => ( + libCoverage ? libCoverage.createFileCoverage(entry) : null + ); + + const getLineHits = (entry, fileCoverage) => { + const lineHits = entry.l ?? {}; + if (Object.keys(lineHits).length > 0) { + return lineHits; + } + if (fileCoverage) { + return fileCoverage.getLineCoverage(); + } + return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {}); + }; + + const getUncoveredLines = (entry, fileCoverage, lineHits) => { + if (lineHits && Object.keys(lineHits).length > 0) { + return Object.entries(lineHits) + .filter(([, count]) => count === 0) + .map(([line]) => Number(line)) + .sort((a, b) => a - b); + } + if (fileCoverage) { + return fileCoverage.getUncoveredLines(); + } + return []; + }; + const totals = { lines: { covered: 0, total: 0 }, statements: { covered: 0, total: 0 }, @@ -106,7 +169,7 @@ jobs: totals[key].covered = totalEntry[key].covered ?? 0; totals[key].total = totalEntry[key].total ?? 0; } - }); + }); Object.entries(summary) .filter(([file]) => file !== 'total') @@ -122,7 +185,8 @@ jobs: }); } else if (coverage) { Object.entries(coverage).forEach(([file, entry]) => { - const lineHits = entry.l ?? {}; + const fileCoverage = getFileCoverage(entry); + const lineHits = getLineHits(entry, fileCoverage); const statementHits = entry.s ?? {}; const branchHits = entry.b ?? {}; const functionHits = entry.f ?? {}; @@ -228,7 +292,8 @@ jobs: }; const tableRows = Object.entries(coverage) .map(([file, entry]) => { - const lineHits = entry.l ?? {}; + const fileCoverage = getFileCoverage(entry); + const lineHits = getLineHits(entry, fileCoverage); const statementHits = entry.s ?? {}; const branchHits = entry.b ?? {}; const functionHits = entry.f ?? {}; @@ -254,10 +319,7 @@ jobs: tableTotals.functions.total += functionTotal; tableTotals.functions.covered += functionCovered; - const uncoveredLines = Object.entries(lineHits) - .filter(([, count]) => count === 0) - .map(([line]) => Number(line)) - .sort((a, b) => a - b); + const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits); const filePath = entry.path ?? file; const relativePath = path.isAbsolute(filePath) @@ -294,46 +356,20 @@ jobs: }; const rowsForOutput = [allFilesRow, ...tableRows]; - const columnWidths = Object.fromEntries( - columns.map(({ key, header }) => [key, header.length]), - ); - - rowsForOutput.forEach((row) => { - columns.forEach(({ key }) => { - const value = String(row[key] ?? ''); - columnWidths[key] = Math.max(columnWidths[key], value.length); - }); - }); - - const formatRow = (row) => columns - .map(({ key, align }) => { - const value = String(row[key] ?? ''); - const width = columnWidths[key]; - return align === 'right' ? value.padStart(width) : value.padEnd(width); - }) - .join(' | '); - - const headerRow = columns - .map(({ header, key, align }) => { - const width = columnWidths[key]; - return align === 'right' ? header.padStart(width) : header.padEnd(width); - }) - .join(' | '); - - const dividerRow = columns - .map(({ key }) => '-'.repeat(columnWidths[key])) - .join('|'); + const formatRow = (row) => `| ${columns + .map(({ key }) => String(row[key] ?? '')) + .join(' | ')} |`; + const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`; + const dividerRow = `| ${columns + .map(({ align }) => (align === 'right' ? '---:' : ':---')) + .join(' | ')} |`; console.log(''); console.log('
Jest coverage table'); console.log(''); - console.log('```'); - console.log(dividerRow); console.log(headerRow); console.log(dividerRow); rowsForOutput.forEach((row) => console.log(formatRow(row))); - console.log(dividerRow); - console.log('```'); console.log('
'); } NODE diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py index af31fbc334..50bbf3ede9 100644 --- a/api/controllers/web/audio.py +++ b/api/controllers/web/audio.py @@ -31,7 +31,6 @@ from services.errors.audio import ( ) from ..common.schema import register_schema_models -from ..web.wraps import WebApiResource class TextToAudioPayload(BaseModel): diff --git a/api/core/rag/splitter/fixed_text_splitter.py b/api/core/rag/splitter/fixed_text_splitter.py index e95c009292..b65cb14d8e 100644 --- a/api/core/rag/splitter/fixed_text_splitter.py +++ b/api/core/rag/splitter/fixed_text_splitter.py @@ -95,7 +95,8 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter) splits = re.split(r" +", text) else: splits = text.split(separator) - splits = [item + separator if i < len(splits) else item for i, item in enumerate(splits)] + if self._keep_separator: + splits = [s + separator for s in splits[:-1]] + splits[-1:] else: splits = list(text) if separator == "\n": @@ -104,7 +105,7 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter) splits = [s for s in splits if (s not in {"", "\n"})] _good_splits = [] _good_splits_lengths = [] # cache the lengths of the splits - _separator = separator if self._keep_separator else "" + _separator = "" if self._keep_separator else separator s_lens = self._length_function(splits) if separator != "": for s, s_len in zip(splits, s_lens): diff --git a/api/libs/helper.py b/api/libs/helper.py index 888a99fb5c..74e1808e49 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -120,9 +120,9 @@ def uuid_value(value: Any) -> str: raise ValueError(error) -def normalize_uuid(value: str | UUID | None) -> str | None: +def normalize_uuid(value: str | UUID) -> str: if not value: - return None + return "" try: return uuid_value(value) diff --git a/web/__mocks__/ky.ts b/web/__mocks__/ky.ts new file mode 100644 index 0000000000..6c7691f2cf --- /dev/null +++ b/web/__mocks__/ky.ts @@ -0,0 +1,71 @@ +/** + * Mock for ky HTTP client + * This mock is used to avoid ESM issues in Jest tests + */ + +type KyResponse = { + ok: boolean + status: number + statusText: string + headers: Headers + json: jest.Mock + text: jest.Mock + blob: jest.Mock + arrayBuffer: jest.Mock + clone: jest.Mock +} + +type KyInstance = jest.Mock & { + get: jest.Mock + post: jest.Mock + put: jest.Mock + patch: jest.Mock + delete: jest.Mock + head: jest.Mock + create: jest.Mock + extend: jest.Mock + stop: symbol +} + +const createResponse = (data: unknown = {}, status = 200): KyResponse => { + const response: KyResponse = { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: new Headers(), + json: jest.fn().mockResolvedValue(data), + text: jest.fn().mockResolvedValue(JSON.stringify(data)), + blob: jest.fn().mockResolvedValue(new Blob()), + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)), + clone: jest.fn(), + } + // Ensure clone returns a new response-like object, not the same instance + response.clone.mockImplementation(() => createResponse(data, status)) + return response +} + +const createKyInstance = (): KyInstance => { + const instance = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) as KyInstance + + // HTTP methods + instance.get = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.post = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.put = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.patch = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.delete = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + instance.head = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) + + // Create new instance with custom options + instance.create = jest.fn().mockImplementation(() => createKyInstance()) + instance.extend = jest.fn().mockImplementation(() => createKyInstance()) + + // Stop method for AbortController + instance.stop = Symbol('stop') + + return instance +} + +const ky = createKyInstance() + +export default ky +export { ky } diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 1f836de6e6..d5e3c61932 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -16,7 +16,7 @@ import { import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import s from './style.module.css' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useStore } from '@/app/components/app/store' import AppSideBar from '@/app/components/app-sidebar' import type { NavIcon } from '@/app/components/app-sidebar/navLink' 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 2bfdece433..dda5dff2b9 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 @@ -3,7 +3,7 @@ import { RiCalendarLine } from '@remixicon/react' import type { Dayjs } from 'dayjs' import type { FC } from 'react' import React, { useCallback } from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { formatToLocalTime } from '@/utils/format' import { useI18N } from '@/context/i18n' import Picker from '@/app/components/base/date-and-time-picker/date-picker' 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 f99ea52492..0a80bf670d 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 @@ -6,7 +6,7 @@ import { SimpleSelect } from '@/app/components/base/select' import type { Item } from '@/app/components/base/select' import dayjs from 'dayjs' import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useTranslation } from 'react-i18next' const today = dayjs() 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 246a1eb6a3..17c919bf22 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 @@ -4,7 +4,7 @@ import React, { useCallback, useRef, useState } from 'react' import type { PopupProps } from './config-popup' import ConfigPopup from './config-popup' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { PortalToFollowElem, PortalToFollowElemContent, 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 628eb13071..767ccb8c59 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 @@ -12,7 +12,7 @@ import Indicator from '@/app/components/header/indicator' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import Divider from '@/app/components/base/divider' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' const I18N_PREFIX = 'app.tracing' 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 eecd356e08..e170159e35 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/field.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import React from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Input from '@/app/components/base/input' type Props = { 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 2c17931b83..319ff3f423 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -12,7 +12,7 @@ import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangS import { TracingProvider } from './type' import TracingIcon from './tracing-icon' import ConfigButton from './config-button' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing' import Indicator from '@/app/components/header/indicator' import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' 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 ac1704d60d..0779689c76 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 @@ -6,7 +6,7 @@ import { } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { TracingProvider } from './type' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' 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' 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 ec9117dd38..aeca1cd3ab 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,7 +1,7 @@ 'use client' import type { FC } from 'react' import React from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { TracingIcon as Icon } from '@/app/components/base/icons/src/public/tracing' type Props = { diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 3effb79f20..3581587b54 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -23,7 +23,7 @@ import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use import useDocumentTitle from '@/hooks/use-document-title' import ExtraInfo from '@/app/components/datasets/extra-info' import { useEventEmitterContextContext } from '@/context/event-emitter' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' export type IAppDetailLayoutProps = { children: React.ReactNode diff --git a/web/app/(shareLayout)/webapp-reset-password/layout.tsx b/web/app/(shareLayout)/webapp-reset-password/layout.tsx index e0ac6b9ad6..13073b0e6a 100644 --- a/web/app/(shareLayout)/webapp-reset-password/layout.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/layout.tsx @@ -1,7 +1,7 @@ 'use client' import Header from '@/app/signin/_header' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useGlobalPublicStore } from '@/context/global-public-context' export default function SignInLayout({ children }: any) { 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 5e3f6fff1d..843f10e039 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useRouter, useSearchParams } from 'next/navigation' -import cn from 'classnames' +import { cn } from '@/utils/classnames' import { RiCheckboxCircleFill } from '@remixicon/react' import { useCountDown } from 'ahooks' import Button from '@/app/components/base/button' diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index 7649982072..c75f925d40 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -1,6 +1,6 @@ 'use client' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' import type { PropsWithChildren } from 'react' diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index 219722eef3..a14bfcd737 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -7,7 +7,7 @@ import Loading from '@/app/components/base/loading' import MailAndCodeAuth from './components/mail-and-code-auth' import MailAndPasswordAuth from './components/mail-and-password-auth' import SSOAuth from './components/sso-auth' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { LicenseStatus } from '@/types/feature' import { IS_CE_EDITION } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx index 2ab676d6b6..b70ab210d0 100644 --- a/web/app/account/oauth/authorize/layout.tsx +++ b/web/app/account/oauth/authorize/layout.tsx @@ -1,7 +1,7 @@ 'use client' import Header from '@/app/signin/_header' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' import { AppContextProvider } from '@/context/app-context' diff --git a/web/app/activate/activateForm.tsx b/web/app/activate/activateForm.tsx index d9d07cbfa1..11fc4866f3 100644 --- a/web/app/activate/activateForm.tsx +++ b/web/app/activate/activateForm.tsx @@ -1,13 +1,13 @@ 'use client' +import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import useSWR from 'swr' import { useRouter, useSearchParams } from 'next/navigation' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Button from '@/app/components/base/button' -import { invitationCheck } from '@/service/common' import Loading from '@/app/components/base/loading' import useDocumentTitle from '@/hooks/use-document-title' +import { useInvitationCheck } from '@/service/use-common' const ActivateForm = () => { useDocumentTitle('') @@ -26,19 +26,21 @@ const ActivateForm = () => { token, }, } - const { data: checkRes } = useSWR(checkParams, invitationCheck, { - revalidateOnFocus: false, - onSuccess(data) { - if (data.is_valid) { - const params = new URLSearchParams(searchParams) - const { email, workspace_id } = data.data - params.set('email', encodeURIComponent(email)) - params.set('workspace_id', encodeURIComponent(workspace_id)) - params.set('invite_token', encodeURIComponent(token as string)) - router.replace(`/signin?${params.toString()}`) - } - }, - }) + const { data: checkRes } = useInvitationCheck({ + ...checkParams.params, + token: token || undefined, + }, true) + + useEffect(() => { + if (checkRes?.is_valid) { + const params = new URLSearchParams(searchParams) + const { email, workspace_id } = checkRes.data + params.set('email', encodeURIComponent(email)) + params.set('workspace_id', encodeURIComponent(workspace_id)) + params.set('invite_token', encodeURIComponent(token as string)) + router.replace(`/signin?${params.toString()}`) + } + }, [checkRes, router, searchParams, token]) return (
{ diff --git a/web/app/components/app-sidebar/app-info.tsx b/web/app/components/app-sidebar/app-info.tsx index f143c2fcef..1b4377c10a 100644 --- a/web/app/components/app-sidebar/app-info.tsx +++ b/web/app/components/app-sidebar/app-info.tsx @@ -29,7 +29,7 @@ import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overvie import type { Operation } from './app-operations' import AppOperations from './app-operations' import dynamic from 'next/dynamic' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { AppModeEnum } from '@/types/app' const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), { diff --git a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx index 3c5d38dd82..04634906af 100644 --- a/web/app/components/app-sidebar/app-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/app-sidebar-dropdown.tsx @@ -16,7 +16,7 @@ import AppInfo from './app-info' import NavLink from './navLink' import { useStore as useAppStore } from '@/app/components/app/store' import type { NavIcon } from './navLink' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { AppModeEnum } from '@/types/app' type Props = { diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index ff110f70bd..dc46af2d02 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useState } from 'react' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem' import ActionButton from '../../base/action-button' import { RiMoreFill } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Menu from './menu' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' diff --git a/web/app/components/app-sidebar/dataset-info/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/index.spec.tsx new file mode 100644 index 0000000000..3674be6658 --- /dev/null +++ b/web/app/components/app-sidebar/dataset-info/index.spec.tsx @@ -0,0 +1,379 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DatasetInfo from './index' +import Dropdown from './dropdown' +import Menu from './menu' +import MenuItem from './menu-item' +import type { DataSet } from '@/models/datasets' +import { + ChunkingMode, + DataSourceType, + DatasetPermission, +} from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' +import { RiEditLine } from '@remixicon/react' + +let mockDataset: DataSet +let mockIsDatasetOperator = false +const mockReplace = jest.fn() +const mockInvalidDatasetList = jest.fn() +const mockInvalidDatasetDetail = jest.fn() +const mockExportPipeline = jest.fn() +const mockCheckIsUsedInApp = jest.fn() +const mockDeleteDataset = jest.fn() + +const createDataset = (overrides: Partial = {}): DataSet => ({ + id: 'dataset-1', + name: 'Dataset Name', + indexing_status: 'completed', + icon_info: { + icon: '📙', + icon_background: '#FFF4ED', + icon_type: 'emoji', + icon_url: '', + }, + description: 'Dataset description', + permission: DatasetPermission.onlyMe, + data_source_type: DataSourceType.FILE, + indexing_technique: 'high_quality' as DataSet['indexing_technique'], + created_by: 'user-1', + updated_by: 'user-1', + updated_at: 1690000000, + app_count: 0, + doc_form: ChunkingMode.text, + document_count: 1, + total_document_count: 1, + word_count: 1000, + provider: 'internal', + embedding_model: 'text-embedding-3', + embedding_model_provider: 'openai', + embedding_available: true, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + }, + tags: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + built_in_field_enabled: false, + runtime_mode: 'rag_pipeline', + enable_api: false, + is_multimodal: false, + ...overrides, +}) + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), +})) + +jest.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }), +})) + +jest.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) => + selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }), +})) + +jest.mock('@/service/knowledge/use-dataset', () => ({ + datasetDetailQueryKeyPrefix: ['dataset', 'detail'], + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +jest.mock('@/service/use-base', () => ({ + useInvalid: () => mockInvalidDatasetDetail, +})) + +jest.mock('@/service/use-pipeline', () => ({ + useExportPipelineDSL: () => ({ + mutateAsync: mockExportPipeline, + }), +})) + +jest.mock('@/service/datasets', () => ({ + checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args), + deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args), +})) + +jest.mock('@/hooks/use-knowledge', () => ({ + useKnowledge: () => ({ + formatIndexingTechniqueAndMethod: () => 'indexing-technique', + }), +})) + +jest.mock('@/app/components/datasets/rename-modal', () => ({ + __esModule: true, + default: ({ + show, + onClose, + onSuccess, + }: { + show: boolean + onClose: () => void + onSuccess?: () => void + }) => { + if (!show) + return null + return ( +
+ + +
+ ) + }, +})) + +const openMenu = async (user: ReturnType) => { + const trigger = screen.getByRole('button') + await user.click(trigger) +} + +describe('DatasetInfo', () => { + beforeEach(() => { + jest.clearAllMocks() + mockDataset = createDataset() + mockIsDatasetOperator = false + }) + + // Rendering of dataset summary details based on expand and dataset state. + describe('Rendering', () => { + it('should show dataset details when expanded', () => { + // Arrange + mockDataset = createDataset({ is_published: true }) + render() + + // Assert + expect(screen.getByText('Dataset Name')).toBeInTheDocument() + expect(screen.getByText('Dataset description')).toBeInTheDocument() + expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument() + expect(screen.getByText('indexing-technique')).toBeInTheDocument() + }) + + it('should show external tag when provider is external', () => { + // Arrange + mockDataset = createDataset({ provider: 'external', is_published: false }) + render() + + // Assert + expect(screen.getByText('dataset.externalTag')).toBeInTheDocument() + expect(screen.queryByText('dataset.chunkingMode.general')).not.toBeInTheDocument() + }) + + it('should hide detailed fields when collapsed', () => { + // Arrange + render() + + // Assert + expect(screen.queryByText('Dataset Name')).not.toBeInTheDocument() + expect(screen.queryByText('Dataset description')).not.toBeInTheDocument() + }) + }) +}) + +describe('MenuItem', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // Event handling for menu item interactions. + describe('Interactions', () => { + it('should call handler when clicked', async () => { + const user = userEvent.setup() + const handleClick = jest.fn() + // Arrange + render() + + // Act + await user.click(screen.getByText('Edit')) + + // Assert + expect(handleClick).toHaveBeenCalledTimes(1) + }) + }) +}) + +describe('Menu', () => { + beforeEach(() => { + jest.clearAllMocks() + mockDataset = createDataset() + }) + + // Rendering of menu options based on runtime mode and delete visibility. + describe('Rendering', () => { + it('should show edit, export, and delete options when rag pipeline and deletable', () => { + // Arrange + mockDataset = createDataset({ runtime_mode: 'rag_pipeline' }) + render( + , + ) + + // Assert + expect(screen.getByText('common.operation.edit')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument() + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + }) + + it('should hide export and delete options when not rag pipeline and not deletable', () => { + // Arrange + mockDataset = createDataset({ runtime_mode: 'general' }) + render( + , + ) + + // Assert + expect(screen.getByText('common.operation.edit')).toBeInTheDocument() + expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument() + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) + }) +}) + +describe('Dropdown', () => { + beforeEach(() => { + jest.clearAllMocks() + mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' }) + mockIsDatasetOperator = false + mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' }) + mockCheckIsUsedInApp.mockResolvedValue({ is_using: false }) + mockDeleteDataset.mockResolvedValue({}) + if (!('createObjectURL' in URL)) { + Object.defineProperty(URL, 'createObjectURL', { + value: jest.fn(), + writable: true, + }) + } + if (!('revokeObjectURL' in URL)) { + Object.defineProperty(URL, 'revokeObjectURL', { + value: jest.fn(), + writable: true, + }) + } + }) + + // Rendering behavior based on workspace role. + describe('Rendering', () => { + it('should hide delete option when user is dataset operator', async () => { + const user = userEvent.setup() + // Arrange + mockIsDatasetOperator = true + render() + + // Act + await openMenu(user) + + // Assert + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() + }) + }) + + // User interactions that trigger modals and exports. + describe('Interactions', () => { + it('should open rename modal when edit is clicked', async () => { + const user = userEvent.setup() + // Arrange + render() + + // Act + await openMenu(user) + await user.click(screen.getByText('common.operation.edit')) + + // Assert + expect(screen.getByTestId('rename-modal')).toBeInTheDocument() + }) + + it('should export pipeline when export is clicked', async () => { + const user = userEvent.setup() + const anchorClickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click') + const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL') + // Arrange + render() + + // Act + await openMenu(user) + await user.click(screen.getByText('datasetPipeline.operations.exportPipeline')) + + // Assert + await waitFor(() => { + expect(mockExportPipeline).toHaveBeenCalledWith({ + pipelineId: 'pipeline-1', + include: false, + }) + }) + expect(createObjectURLSpy).toHaveBeenCalledTimes(1) + expect(anchorClickSpy).toHaveBeenCalledTimes(1) + }) + + it('should show delete confirmation when delete is clicked', async () => { + const user = userEvent.setup() + // Arrange + render() + + // Act + await openMenu(user) + await user.click(screen.getByText('common.operation.delete')) + + // Assert + await waitFor(() => { + expect(screen.getByText('dataset.deleteDatasetConfirmContent')).toBeInTheDocument() + }) + }) + + it('should delete dataset and redirect when confirm is clicked', async () => { + const user = userEvent.setup() + // Arrange + render() + + // Act + await openMenu(user) + await user.click(screen.getByText('common.operation.delete')) + await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' })) + + // Assert + await waitFor(() => { + expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1') + }) + expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1) + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) +}) diff --git a/web/app/components/app-sidebar/dataset-info/index.tsx b/web/app/components/app-sidebar/dataset-info/index.tsx index 44b0baa72b..bace656d54 100644 --- a/web/app/components/app-sidebar/dataset-info/index.tsx +++ b/web/app/components/app-sidebar/dataset-info/index.tsx @@ -8,7 +8,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import type { DataSet } from '@/models/datasets' import { DOC_FORM_TEXT } from '@/models/datasets' import { useKnowledge } from '@/hooks/use-knowledge' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Dropdown from './dropdown' type DatasetInfoProps = { diff --git a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx index ac07333712..cf380d00d2 100644 --- a/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx @@ -11,7 +11,7 @@ import AppIcon from '../base/app-icon' import Divider from '../base/divider' import NavLink from './navLink' import type { NavIcon } from './navLink' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import Effect from '../base/effect' import Dropdown from './dataset-info/dropdown' diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index 86de2e2034..fe52c4cfa2 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -9,7 +9,7 @@ import AppSidebarDropdown from './app-sidebar-dropdown' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { useStore as useAppStore } from '@/app/components/app/store' import { useEventEmitterContextContext } from '@/context/event-emitter' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Divider from '../base/divider' import { useHover, useKeyPress } from 'ahooks' import ToggleButton from './toggle-button' diff --git a/web/app/components/app-sidebar/navLink.tsx b/web/app/components/app-sidebar/navLink.tsx index ad90b91250..f6d8e57682 100644 --- a/web/app/components/app-sidebar/navLink.tsx +++ b/web/app/components/app-sidebar/navLink.tsx @@ -2,7 +2,7 @@ import React from 'react' import { useSelectedLayoutSegment } from 'next/navigation' import Link from 'next/link' -import classNames from '@/utils/classnames' +import { cn } from '@/utils/classnames' import type { RemixiconComponentType } from '@remixicon/react' export type NavIcon = React.ComponentType< @@ -42,7 +42,7 @@ const NavLink = ({ const NavIcon = isActive ? iconMap.selected : iconMap.normal const renderIcon = () => ( -
+
) @@ -53,21 +53,17 @@ const NavLink = ({ key={name} type='button' disabled - className={classNames( - 'system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover', - 'pl-3 pr-1', - )} + className={cn('system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover', + 'pl-3 pr-1')} title={mode === 'collapse' ? name : ''} aria-disabled > {renderIcon()} {name} @@ -79,22 +75,18 @@ const NavLink = ({ {renderIcon()} {name} diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx index 8de6f887f6..4f69adfc34 100644 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ b/web/app/components/app-sidebar/toggle-button.tsx @@ -1,7 +1,7 @@ import React from 'react' import Button from '../base/button' import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Tooltip from '../base/tooltip' import { useTranslation } from 'react-i18next' import { getKeyboardKeyNameBySystem } from '../workflow/utils' diff --git a/web/app/components/app/annotation/batch-action.tsx b/web/app/components/app/annotation/batch-action.tsx index 6e80d0c4c8..6ff392d17e 100644 --- a/web/app/components/app/annotation/batch-action.tsx +++ b/web/app/components/app/annotation/batch-action.tsx @@ -3,7 +3,7 @@ import { RiDeleteBinLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import Divider from '@/app/components/base/divider' -import classNames from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Confirm from '@/app/components/base/confirm' const i18nPrefix = 'appAnnotation.batchAction' @@ -38,7 +38,7 @@ const BatchAction: FC = ({ setIsNotDeleting() } return ( -
+
diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx index ccad46b860..c9766135df 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/csv-uploader.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { RiDeleteBinLine } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files' import { ToastContext } from '@/app/components/base/toast' import Button from '@/app/components/base/button' diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx index 1f32e55928..95a5586292 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.spec.tsx @@ -245,7 +245,7 @@ describe('EditItem', () => { expect(mockSave).toHaveBeenCalledWith('Test save content') }) - it('should show delete option when content changes', async () => { + it('should show delete option and restore original content when delete is clicked', async () => { // Arrange const mockSave = jest.fn().mockResolvedValue(undefined) const props = { @@ -267,7 +267,13 @@ describe('EditItem', () => { await user.click(screen.getByRole('button', { name: 'common.operation.save' })) // Assert - expect(mockSave).toHaveBeenCalledWith('Modified content') + expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content') + expect(await screen.findByText('common.operation.delete')).toBeInTheDocument() + + await user.click(screen.getByText('common.operation.delete')) + + expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content') + expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() }) it('should handle keyboard interactions in edit mode', async () => { @@ -393,5 +399,68 @@ describe('EditItem', () => { expect(screen.queryByRole('textbox')).not.toBeInTheDocument() expect(screen.getByText('Test content')).toBeInTheDocument() }) + + it('should handle save failure gracefully in edit mode', async () => { + // Arrange + const mockSave = jest.fn().mockRejectedValueOnce(new Error('Save failed')) + const props = { + ...defaultProps, + onSave: mockSave, + } + const user = userEvent.setup() + + // Act + render() + + // Enter edit mode and save (should fail) + await user.click(screen.getByText('common.operation.edit')) + const textarea = screen.getByRole('textbox') + await user.type(textarea, 'New content') + + // Save should fail but not throw + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert - Should remain in edit mode when save fails + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + expect(mockSave).toHaveBeenCalledWith('New content') + }) + + it('should handle delete action failure gracefully', async () => { + // Arrange + const mockSave = jest.fn() + .mockResolvedValueOnce(undefined) // First save succeeds + .mockRejectedValueOnce(new Error('Delete failed')) // Delete fails + const props = { + ...defaultProps, + onSave: mockSave, + } + const user = userEvent.setup() + + // Act + render() + + // Edit content to show delete button + await user.click(screen.getByText('common.operation.edit')) + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Modified content') + + // Save to create new content + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + await screen.findByText('common.operation.delete') + + // Click delete (should fail but not throw) + await user.click(screen.getByText('common.operation.delete')) + + // Assert - Delete action should handle error gracefully + expect(mockSave).toHaveBeenCalledTimes(2) + expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content') + expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content') + + // When delete fails, the delete button should still be visible (state not changed) + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + expect(screen.getByText('Modified content')).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx index e808d0b48a..6ba830967d 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx @@ -6,7 +6,7 @@ import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react' import { Robot, User } from '@/app/components/base/icons/src/public/avatar' import Textarea from '@/app/components/base/textarea' import Button from '@/app/components/base/button' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' export enum EditItemType { Query = 'query', @@ -52,8 +52,14 @@ const EditItem: FC = ({ }, [content]) const handleSave = async () => { - await onSave(newContent) - setIsEdit(false) + try { + await onSave(newContent) + setIsEdit(false) + } + catch { + // Keep edit mode open when save fails + // Error notification is handled by the parent component + } } const handleCancel = () => { @@ -96,9 +102,16 @@ const EditItem: FC = ({
·
{ - setNewContent(content) - onSave(content) + onClick={async () => { + try { + await onSave(content) + // Only update UI state after successful delete + setNewContent(content) + } + catch { + // Delete action failed - error is already handled by parent + // UI state remains unchanged, user can retry + } }} >
diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx index b48f8a2a4a..bdc991116c 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/index.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast' import EditAnnotationModal from './index' @@ -408,7 +408,7 @@ describe('EditAnnotationModal', () => { // Error Handling (CRITICAL for coverage) describe('Error Handling', () => { - it('should handle addAnnotation API failure gracefully', async () => { + it('should show error toast and skip callbacks when addAnnotation fails', async () => { // Arrange const mockOnAdded = jest.fn() const props = { @@ -420,29 +420,75 @@ describe('EditAnnotationModal', () => { // Mock API failure mockAddAnnotation.mockRejectedValueOnce(new Error('API Error')) - // Act & Assert - Should handle API error without crashing - expect(async () => { - render() + // Act + render() - // Find and click edit link for query - const editLinks = screen.getAllByText(/common\.operation\.edit/i) - await user.click(editLinks[0]) + // Find and click edit link for query + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) - // Find textarea and enter new content - const textarea = screen.getByRole('textbox') - await user.clear(textarea) - await user.type(textarea, 'New query content') + // Find textarea and enter new content + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'New query content') - // Click save button - const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) - await user.click(saveButton) + // Click save button + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) - // Should not call onAdded on error - expect(mockOnAdded).not.toHaveBeenCalled() - }).not.toThrow() + // Assert + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + message: 'API Error', + type: 'error', + }) + }) + expect(mockOnAdded).not.toHaveBeenCalled() + + // Verify edit mode remains open (textarea should still be visible) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() }) - it('should handle editAnnotation API failure gracefully', async () => { + it('should show fallback error message when addAnnotation error has no message', async () => { + // Arrange + const mockOnAdded = jest.fn() + const props = { + ...defaultProps, + onAdded: mockOnAdded, + } + const user = userEvent.setup() + + mockAddAnnotation.mockRejectedValueOnce({}) + + // Act + render() + + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'New query content') + + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + message: 'common.api.actionFailed', + type: 'error', + }) + }) + expect(mockOnAdded).not.toHaveBeenCalled() + + // Verify edit mode remains open (textarea should still be visible) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + }) + + it('should show error toast and skip callbacks when editAnnotation fails', async () => { // Arrange const mockOnEdited = jest.fn() const props = { @@ -456,24 +502,72 @@ describe('EditAnnotationModal', () => { // Mock API failure mockEditAnnotation.mockRejectedValueOnce(new Error('API Error')) - // Act & Assert - Should handle API error without crashing - expect(async () => { - render() + // Act + render() - // Edit query content - const editLinks = screen.getAllByText(/common\.operation\.edit/i) - await user.click(editLinks[0]) + // Edit query content + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) - const textarea = screen.getByRole('textbox') - await user.clear(textarea) - await user.type(textarea, 'Modified query') + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Modified query') - const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) - await user.click(saveButton) + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) - // Should not call onEdited on error - expect(mockOnEdited).not.toHaveBeenCalled() - }).not.toThrow() + // Assert + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + message: 'API Error', + type: 'error', + }) + }) + expect(mockOnEdited).not.toHaveBeenCalled() + + // Verify edit mode remains open (textarea should still be visible) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + }) + + it('should show fallback error message when editAnnotation error is not an Error instance', async () => { + // Arrange + const mockOnEdited = jest.fn() + const props = { + ...defaultProps, + annotationId: 'test-annotation-id', + messageId: 'test-message-id', + onEdited: mockOnEdited, + } + const user = userEvent.setup() + + mockEditAnnotation.mockRejectedValueOnce('oops') + + // Act + render() + + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) + + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Modified query') + + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + message: 'common.api.actionFailed', + type: 'error', + }) + }) + expect(mockOnEdited).not.toHaveBeenCalled() + + // Verify edit mode remains open (textarea should still be visible) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() }) }) @@ -526,25 +620,33 @@ describe('EditAnnotationModal', () => { }) }) - // Toast Notifications (Simplified) + // Toast Notifications (Success) describe('Toast Notifications', () => { - it('should trigger success notification when save operation completes', async () => { + it('should show success notification when save operation completes', async () => { // Arrange - const mockOnAdded = jest.fn() - const props = { - ...defaultProps, - onAdded: mockOnAdded, - } + const props = { ...defaultProps } + const user = userEvent.setup() // Act render() - // Simulate successful save by calling handleSave indirectly - const mockSave = jest.fn() - expect(mockSave).not.toHaveBeenCalled() + const editLinks = screen.getAllByText(/common\.operation\.edit/i) + await user.click(editLinks[0]) - // Assert - Toast spy is available and will be called during real save operations - expect(toastNotifySpy).toBeDefined() + const textarea = screen.getByRole('textbox') + await user.clear(textarea) + await user.type(textarea, 'Updated query') + + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) + + // Assert + await waitFor(() => { + expect(toastNotifySpy).toHaveBeenCalledWith({ + message: 'common.api.actionSuccess', + type: 'success', + }) + }) }) }) diff --git a/web/app/components/app/annotation/edit-annotation-modal/index.tsx b/web/app/components/app/annotation/edit-annotation-modal/index.tsx index 2961ce393c..6172a215e4 100644 --- a/web/app/components/app/annotation/edit-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/edit-annotation-modal/index.tsx @@ -53,27 +53,39 @@ const EditAnnotationModal: FC = ({ postQuery = editedContent else postAnswer = editedContent - if (!isAdd) { - await editAnnotation(appId, annotationId, { - message_id: messageId, - question: postQuery, - answer: postAnswer, - }) - onEdited(postQuery, postAnswer) - } - else { - const res: any = await addAnnotation(appId, { - question: postQuery, - answer: postAnswer, - message_id: messageId, - }) - onAdded(res.id, res.account?.name, postQuery, postAnswer) - } + try { + if (!isAdd) { + await editAnnotation(appId, annotationId, { + message_id: messageId, + question: postQuery, + answer: postAnswer, + }) + onEdited(postQuery, postAnswer) + } + else { + const res = await addAnnotation(appId, { + question: postQuery, + answer: postAnswer, + message_id: messageId, + }) + onAdded(res.id, res.account?.name ?? '', postQuery, postAnswer) + } - Toast.notify({ - message: t('common.api.actionSuccess') as string, - type: 'success', - }) + Toast.notify({ + message: t('common.api.actionSuccess') as string, + type: 'success', + }) + } + catch (error) { + const fallbackMessage = t('common.api.actionFailed') as string + const message = error instanceof Error && error.message ? error.message : fallbackMessage + Toast.notify({ + message, + type: 'error', + }) + // Re-throw to preserve edit mode behavior for UI components + throw error + } } const [showModal, setShowModal] = useState(false) diff --git a/web/app/components/app/annotation/header-opts/index.spec.tsx b/web/app/components/app/annotation/header-opts/index.spec.tsx index 8c640c2790..3d8a1fd4ef 100644 --- a/web/app/components/app/annotation/header-opts/index.spec.tsx +++ b/web/app/components/app/annotation/header-opts/index.spec.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { ComponentProps } from 'react' @@ -7,6 +8,120 @@ import { LanguagesSupported } from '@/i18n-config/language' import type { AnnotationItemBasic } from '../type' import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation' +jest.mock('@headlessui/react', () => { + type PopoverContextValue = { open: boolean; setOpen: (open: boolean) => void } + type MenuContextValue = { open: boolean; setOpen: (open: boolean) => void } + const PopoverContext = React.createContext(null) + const MenuContext = React.createContext(null) + + const Popover = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => { + const [open, setOpen] = React.useState(false) + const value = React.useMemo(() => ({ open, setOpen }), [open]) + return ( + + {typeof children === 'function' ? children({ open }) : children} + + ) + } + + const PopoverButton = React.forwardRef(({ onClick, children, ...props }: { onClick?: () => void; children?: React.ReactNode }, ref: React.Ref) => { + const context = React.useContext(PopoverContext) + const handleClick = () => { + context?.setOpen(!context.open) + onClick?.() + } + return ( + + ) + }) + + const PopoverPanel = React.forwardRef(({ children, ...props }: { children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode) }, ref: React.Ref) => { + const context = React.useContext(PopoverContext) + if (!context?.open) return null + const content = typeof children === 'function' ? children({ close: () => context.setOpen(false) }) : children + return ( +
+ {content} +
+ ) + }) + + const Menu = ({ children }: { children: React.ReactNode }) => { + const [open, setOpen] = React.useState(false) + const value = React.useMemo(() => ({ open, setOpen }), [open]) + return ( + + {children} + + ) + } + + const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void; children?: React.ReactNode }) => { + const context = React.useContext(MenuContext) + const handleClick = () => { + context?.setOpen(!context.open) + onClick?.() + } + return ( + + ) + } + + const MenuItems = ({ children, ...props }: { children: React.ReactNode }) => { + const context = React.useContext(MenuContext) + if (!context?.open) return null + return ( +
+ {children} +
+ ) + } + + return { + Dialog: ({ open, children, className }: { open?: boolean; children: React.ReactNode; className?: string }) => { + if (open === false) return null + return ( +
+ {children} +
+ ) + }, + DialogBackdrop: ({ children, className, onClick }: { children?: React.ReactNode; className?: string; onClick?: () => void }) => ( +
+ {children} +
+ ), + DialogPanel: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogTitle: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + Popover, + PopoverButton, + PopoverPanel, + Menu, + MenuButton, + MenuItems, + Transition: ({ show = true, children }: { show?: boolean; children: React.ReactNode }) => (show ? <>{children} : null), + TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + let lastCSVDownloaderProps: Record | undefined const mockCSVDownloader = jest.fn(({ children, ...props }) => { lastCSVDownloaderProps = props @@ -121,6 +236,7 @@ const mockedClearAllAnnotations = jest.mocked(clearAllAnnotations) describe('HeaderOptions', () => { beforeEach(() => { jest.clearAllMocks() + jest.useRealTimers() mockCSVDownloader.mockClear() lastCSVDownloaderProps = undefined mockedFetchAnnotations.mockResolvedValue({ data: [] }) diff --git a/web/app/components/app/annotation/header-opts/index.tsx b/web/app/components/app/annotation/header-opts/index.tsx index 024f75867c..5f8ef658e7 100644 --- a/web/app/components/app/annotation/header-opts/index.tsx +++ b/web/app/components/app/annotation/header-opts/index.tsx @@ -17,7 +17,7 @@ import Button from '../../../base/button' import AddAnnotationModal from '../add-annotation-modal' import type { AnnotationItemBasic } from '../type' import BatchAddModal from '../batch-add-annotation-modal' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import CustomPopover from '@/app/components/base/popover' import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' diff --git a/web/app/components/app/annotation/index.tsx b/web/app/components/app/annotation/index.tsx index 32d0c799fc..2d639c91e4 100644 --- a/web/app/components/app/annotation/index.tsx +++ b/web/app/components/app/annotation/index.tsx @@ -25,7 +25,7 @@ import { sleep } from '@/utils' import { useProviderContext } from '@/context/provider-context' import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' import { type App, AppModeEnum } from '@/types/app' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { delAnnotations } from '@/service/annotation' type Props = { diff --git a/web/app/components/app/annotation/list.tsx b/web/app/components/app/annotation/list.tsx index 4135b4362e..62a0c50e60 100644 --- a/web/app/components/app/annotation/list.tsx +++ b/web/app/components/app/annotation/list.tsx @@ -7,7 +7,7 @@ import type { AnnotationItem } from './type' import RemoveAnnotationConfirmModal from './remove-annotation-confirm-modal' import ActionButton from '@/app/components/base/action-button' import useTimestamp from '@/hooks/use-timestamp' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Checkbox from '@/app/components/base/checkbox' import BatchAction from './batch-action' diff --git a/web/app/components/app/annotation/type.ts b/web/app/components/app/annotation/type.ts index 5df6f51ace..e2f2264f07 100644 --- a/web/app/components/app/annotation/type.ts +++ b/web/app/components/app/annotation/type.ts @@ -12,6 +12,12 @@ export type AnnotationItem = { hit_count: number } +export type AnnotationCreateResponse = AnnotationItem & { + account?: { + name?: string + } +} + export type HitHistoryItem = { id: string question: string diff --git a/web/app/components/app/annotation/view-annotation-modal/index.tsx b/web/app/components/app/annotation/view-annotation-modal/index.tsx index 8426ab0005..d21b177098 100644 --- a/web/app/components/app/annotation/view-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/view-annotation-modal/index.tsx @@ -14,7 +14,7 @@ import TabSlider from '@/app/components/base/tab-slider-plain' import { fetchHitHistoryList } from '@/service/annotation' import { APP_PAGE_LIMIT } from '@/config' import useTimestamp from '@/hooks/use-timestamp' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' type Props = { appId: string diff --git a/web/app/components/app/app-access-control/access-control-dialog.tsx b/web/app/components/app/app-access-control/access-control-dialog.tsx index ee3fa9650b..99cf6d7074 100644 --- a/web/app/components/app/app-access-control/access-control-dialog.tsx +++ b/web/app/components/app/app-access-control/access-control-dialog.tsx @@ -2,7 +2,7 @@ import { Fragment, useCallback } from 'react' import type { ReactNode } from 'react' import { Dialog, Transition } from '@headlessui/react' import { RiCloseLine } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' type DialogProps = { className?: string diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index bb8dabbae6..17263fdd46 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx @@ -11,7 +11,7 @@ import Input from '../../base/input' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem' import Loading from '../../base/loading' import useAccessControlStore from '../../../../context/access-control-store' -import classNames from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useSearchForWhiteListCandidates } from '@/service/access-control' import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control' import { SubjectType } from '@/models/access-control' @@ -106,7 +106,7 @@ function SelectedGroupsBreadCrumb() { setSelectedGroupsForBreadcrumb([]) }, [setSelectedGroupsForBreadcrumb]) return
- 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')} + 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')} {selectedGroupsForBreadcrumb.map((group, index) => { return
/ @@ -198,7 +198,7 @@ type BaseItemProps = { children: React.ReactNode } function BaseItem({ children, className }: BaseItemProps) { - return
+ return
{children}
} diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx index 2535de6654..154bacc361 100644 --- a/web/app/components/app/app-publisher/suggested-action.tsx +++ b/web/app/components/app/app-publisher/suggested-action.tsx @@ -1,6 +1,6 @@ import type { HTMLProps, PropsWithChildren } from 'react' import { RiArrowRightUpLine } from '@remixicon/react' -import classNames from '@/utils/classnames' +import { cn } from '@/utils/classnames' export type SuggestedActionProps = PropsWithChildren & { icon?: React.ReactNode @@ -19,11 +19,9 @@ const SuggestedAction = ({ icon, link, disabled, children, className, onClick, . href={disabled ? undefined : link} target='_blank' rel='noreferrer' - className={classNames( - 'flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1', + className={cn('flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1', disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer text-text-secondary hover:bg-state-accent-hover hover:text-text-accent', - className, - )} + className)} onClick={handleClick} {...props} > diff --git a/web/app/components/app/configuration/base/feature-panel/index.tsx b/web/app/components/app/configuration/base/feature-panel/index.tsx index ec5ab96d76..c9ebfefbe5 100644 --- a/web/app/components/app/configuration/base/feature-panel/index.tsx +++ b/web/app/components/app/configuration/base/feature-panel/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC, ReactNode } from 'react' import React from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' export type IFeaturePanelProps = { className?: string diff --git a/web/app/components/app/configuration/base/operation-btn/index.tsx b/web/app/components/app/configuration/base/operation-btn/index.tsx index aba35cded2..db19d2976e 100644 --- a/web/app/components/app/configuration/base/operation-btn/index.tsx +++ b/web/app/components/app/configuration/base/operation-btn/index.tsx @@ -6,7 +6,7 @@ import { RiAddLine, RiEditLine, } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { noop } from 'lodash-es' export type IOperationBtnProps = { diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx index 5bf2f177ff..6492864ce2 100644 --- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx @@ -14,7 +14,7 @@ import s from './style.module.css' import MessageTypeSelector from './message-type-selector' import ConfirmAddVar from './confirm-add-var' import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import type { PromptRole, PromptVariable } from '@/models/debug' import { Copy, diff --git a/web/app/components/app/configuration/config-prompt/message-type-selector.tsx b/web/app/components/app/configuration/config-prompt/message-type-selector.tsx index 17b3ecb2f1..71f3e6ee5f 100644 --- a/web/app/components/app/configuration/config-prompt/message-type-selector.tsx +++ b/web/app/components/app/configuration/config-prompt/message-type-selector.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react' import React from 'react' import { useBoolean, useClickAway } from 'ahooks' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { PromptRole } from '@/models/debug' import { ChevronSelectorVertical } from '@/app/components/base/icons/src/vender/line/arrows' type Props = { diff --git a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx b/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx index 9e10db93ae..90a19c883a 100644 --- a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx +++ b/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react' import type { FC } from 'react' import { useDebounceFn } from 'ahooks' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' type Props = { className?: string diff --git a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx index 68bf6dd7c2..e4c21b0cbc 100644 --- a/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx +++ b/web/app/components/app/configuration/config-prompt/simple-prompt-input.tsx @@ -7,7 +7,7 @@ import { produce } from 'immer' import { useContext } from 'use-context-selector' import ConfirmAddVar from './confirm-add-var' import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import type { PromptVariable } from '@/models/debug' import Tooltip from '@/app/components/base/tooltip' import { AppModeEnum } from '@/types/app' diff --git a/web/app/components/app/configuration/config-var/config-modal/field.tsx b/web/app/components/app/configuration/config-var/config-modal/field.tsx index b24e0be6ce..76d228358a 100644 --- a/web/app/components/app/configuration/config-var/config-modal/field.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/field.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import React from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useTranslation } from 'react-i18next' type Props = { diff --git a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx index 2b52991d4a..53d59eb24b 100644 --- a/web/app/components/app/configuration/config-var/config-modal/type-select.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/type-select.tsx @@ -2,7 +2,6 @@ import type { FC } from 'react' import React, { useState } from 'react' import { ChevronDownIcon } from '@heroicons/react/20/solid' -import classNames from '@/utils/classnames' import { PortalToFollowElem, PortalToFollowElemContent, @@ -10,7 +9,7 @@ import { } from '@/app/components/base/portal-to-follow-elem' import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon' import type { InputVarType } from '@/app/components/workflow/types' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Badge from '@/app/components/base/badge' import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' @@ -47,7 +46,7 @@ const TypeSelector: FC = ({ > !readonly && setOpen(v => !v)} className='w-full'>
@@ -69,7 +68,7 @@ const TypeSelector: FC = ({
{items.map((item: Item) => (
{ + const actual = jest.requireActual('use-context-selector') + return { + ...actual, + useContext: (context: unknown) => mockUseContext(context), + } +}) + +const mockUseFeatures = jest.fn() +const mockUseFeaturesStore = jest.fn() +jest.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector), + useFeaturesStore: () => mockUseFeaturesStore(), +})) + +const defaultFile: FileUpload = { + enabled: false, + allowed_file_types: [], + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + number_limits: 3, + image: { + enabled: false, + detail: Resolution.low, + number_limits: 3, + transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }, +} + +let featureStoreState: FeatureStoreState +let setFeaturesMock: jest.Mock + +const setupFeatureStore = (fileOverrides: Partial = {}) => { + const mergedFile: FileUpload = { + ...defaultFile, + ...fileOverrides, + image: { + ...defaultFile.image, + ...fileOverrides.image, + }, + } + featureStoreState = { + features: { + file: mergedFile, + }, + setFeatures: jest.fn(), + showFeaturesModal: false, + setShowFeaturesModal: jest.fn(), + } + setFeaturesMock = featureStoreState.setFeatures as jest.Mock + mockUseFeaturesStore.mockReturnValue({ + getState: () => featureStoreState, + }) + mockUseFeatures.mockImplementation(selector => selector(featureStoreState)) +} + +const getLatestFileConfig = () => { + expect(setFeaturesMock).toHaveBeenCalled() + const latestFeatures = setFeaturesMock.mock.calls[setFeaturesMock.mock.calls.length - 1][0] as { file: FileUpload } + return latestFeatures.file +} + +beforeEach(() => { + jest.clearAllMocks() + mockUseContext.mockReturnValue({ + isShowVisionConfig: true, + isAllowVideoUpload: false, + }) + setupFeatureStore() +}) + +// ConfigVision handles toggling file upload types + visibility rules. +describe('ConfigVision', () => { + it('should not render when vision configuration is hidden', () => { + mockUseContext.mockReturnValue({ + isShowVisionConfig: false, + isAllowVideoUpload: false, + }) + + render() + + expect(screen.queryByText('appDebug.vision.name')).not.toBeInTheDocument() + }) + + it('should show the toggle and parameter controls when visible', () => { + render() + + expect(screen.getByText('appDebug.vision.name')).toBeInTheDocument() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) + + it('should enable both image and video uploads when toggled on with video support', async () => { + const user = userEvent.setup() + mockUseContext.mockReturnValue({ + isShowVisionConfig: true, + isAllowVideoUpload: true, + }) + setupFeatureStore({ + allowed_file_types: [], + }) + + render() + await user.click(screen.getByRole('switch')) + + const updatedFile = getLatestFileConfig() + expect(updatedFile.allowed_file_types).toEqual([SupportUploadFileTypes.image, SupportUploadFileTypes.video]) + expect(updatedFile.image?.enabled).toBe(true) + expect(updatedFile.enabled).toBe(true) + }) + + it('should disable image and video uploads when toggled off and no other types remain', async () => { + const user = userEvent.setup() + mockUseContext.mockReturnValue({ + isShowVisionConfig: true, + isAllowVideoUpload: true, + }) + setupFeatureStore({ + allowed_file_types: [SupportUploadFileTypes.image, SupportUploadFileTypes.video], + enabled: true, + image: { + enabled: true, + }, + }) + + render() + await user.click(screen.getByRole('switch')) + + const updatedFile = getLatestFileConfig() + expect(updatedFile.allowed_file_types).toEqual([]) + expect(updatedFile.enabled).toBe(false) + expect(updatedFile.image?.enabled).toBe(false) + }) + + it('should keep file uploads enabled when other file types remain after disabling vision', async () => { + const user = userEvent.setup() + mockUseContext.mockReturnValue({ + isShowVisionConfig: true, + isAllowVideoUpload: false, + }) + setupFeatureStore({ + allowed_file_types: [SupportUploadFileTypes.image, SupportUploadFileTypes.document], + enabled: true, + image: { enabled: true }, + }) + + render() + await user.click(screen.getByRole('switch')) + + const updatedFile = getLatestFileConfig() + expect(updatedFile.allowed_file_types).toEqual([SupportUploadFileTypes.document]) + expect(updatedFile.enabled).toBe(true) + expect(updatedFile.image?.enabled).toBe(false) + }) +}) + +// ParamConfig exposes ParamConfigContent via an inline trigger. +describe('ParamConfig', () => { + it('should toggle parameter panel when clicking the settings button', async () => { + setupFeatureStore() + 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() + }) +}) + +// ParamConfigContent manages resolution, upload source, and count limits. +describe('ParamConfigContent', () => { + it('should set resolution to high when the corresponding option is selected', async () => { + const user = userEvent.setup() + setupFeatureStore({ + image: { detail: Resolution.low }, + }) + + render() + + await user.click(screen.getByText('appDebug.vision.visionSettings.high')) + + const updatedFile = getLatestFileConfig() + expect(updatedFile.image?.detail).toBe(Resolution.high) + }) + + it('should switch upload method to local only', async () => { + const user = userEvent.setup() + setupFeatureStore({ + allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], + }) + + render() + + await user.click(screen.getByText('appDebug.vision.visionSettings.localUpload')) + + const updatedFile = getLatestFileConfig() + expect(updatedFile.allowed_file_upload_methods).toEqual([TransferMethod.local_file]) + expect(updatedFile.image?.transfer_methods).toEqual([TransferMethod.local_file]) + }) + + it('should update upload limit value when input changes', async () => { + setupFeatureStore({ + number_limits: 2, + }) + + render() + const input = screen.getByRole('spinbutton') as HTMLInputElement + fireEvent.change(input, { target: { value: '4' } }) + + const updatedFile = getLatestFileConfig() + expect(updatedFile.number_limits).toBe(4) + expect(updatedFile.image?.number_limits).toBe(4) + }) +}) diff --git a/web/app/components/app/configuration/config-vision/param-config.tsx b/web/app/components/app/configuration/config-vision/param-config.tsx index 5e4aac6a25..e6af188052 100644 --- a/web/app/components/app/configuration/config-vision/param-config.tsx +++ b/web/app/components/app/configuration/config-vision/param-config.tsx @@ -10,7 +10,7 @@ import { PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' const ParamsConfig: FC = () => { const { t } = useTranslation() diff --git a/web/app/components/app/configuration/config/agent-setting-button.spec.tsx b/web/app/components/app/configuration/config/agent-setting-button.spec.tsx new file mode 100644 index 0000000000..db70865e51 --- /dev/null +++ b/web/app/components/app/configuration/config/agent-setting-button.spec.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AgentSettingButton from './agent-setting-button' +import type { AgentConfig } from '@/models/debug' +import { AgentStrategy } from '@/types/app' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +let latestAgentSettingProps: any +jest.mock('./agent/agent-setting', () => ({ + __esModule: true, + default: (props: any) => { + latestAgentSettingProps = props + return ( +
+ + +
+ ) + }, +})) + +const createAgentConfig = (overrides: Partial = {}): AgentConfig => ({ + enabled: true, + strategy: AgentStrategy.react, + max_iteration: 3, + tools: [], + ...overrides, +}) + +const setup = (overrides: Partial> = {}) => { + const props: React.ComponentProps = { + isFunctionCall: false, + isChatModel: true, + onAgentSettingChange: jest.fn(), + agentConfig: createAgentConfig(), + ...overrides, + } + + const user = userEvent.setup() + render() + return { props, user } +} + +beforeEach(() => { + jest.clearAllMocks() + latestAgentSettingProps = undefined +}) + +describe('AgentSettingButton', () => { + it('should render button label from translation key', () => { + setup() + + expect(screen.getByRole('button', { name: 'appDebug.agent.setting.name' })).toBeInTheDocument() + }) + + it('should open AgentSetting with the provided configuration when clicked', async () => { + const { user, props } = setup({ isFunctionCall: true, isChatModel: false }) + + await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' })) + + expect(screen.getByTestId('agent-setting')).toBeInTheDocument() + expect(latestAgentSettingProps.isFunctionCall).toBe(true) + expect(latestAgentSettingProps.isChatModel).toBe(false) + expect(latestAgentSettingProps.payload).toEqual(props.agentConfig) + }) + + it('should call onAgentSettingChange and close when AgentSetting saves', async () => { + const { user, props } = setup() + + await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' })) + await user.click(screen.getByText('save-agent')) + + expect(props.onAgentSettingChange).toHaveBeenCalledTimes(1) + expect(props.onAgentSettingChange).toHaveBeenCalledWith({ + ...props.agentConfig, + max_iteration: 9, + }) + expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument() + }) + + it('should close AgentSetting without saving when cancel is triggered', async () => { + const { user, props } = setup() + + await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' })) + await user.click(screen.getByText('cancel-agent')) + + expect(props.onAgentSettingChange).not.toHaveBeenCalled() + expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx index 6512e11545..6193392026 100644 --- a/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx +++ b/web/app/components/app/configuration/config/agent/agent-setting/item-panel.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import React from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Tooltip from '@/app/components/base/tooltip' type Props = { className?: string diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 4793b5fe49..8dfa2f194b 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -25,7 +25,7 @@ import { MAX_TOOLS_NUM } from '@/config' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import Tooltip from '@/app/components/base/tooltip' import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import ToolPicker from '@/app/components/workflow/block-selector/tool-picker' import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' import { canFindTool } from '@/utils' diff --git a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx index c5947495db..0627666b4c 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool.tsx @@ -22,7 +22,7 @@ import { CollectionType } from '@/app/components/tools/types' import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools' import I18n from '@/context/i18n' import { getLanguage } from '@/i18n-config/language' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import type { ToolWithProvider } from '@/app/components/workflow/types' import { AuthCategory, diff --git a/web/app/components/app/configuration/config/agent/prompt-editor.tsx b/web/app/components/app/configuration/config/agent/prompt-editor.tsx index 71a9304d0c..78d7eef029 100644 --- a/web/app/components/app/configuration/config/agent/prompt-editor.tsx +++ b/web/app/components/app/configuration/config/agent/prompt-editor.tsx @@ -4,7 +4,7 @@ import React from 'react' import copy from 'copy-to-clipboard' import { useContext } from 'use-context-selector' import { useTranslation } from 'react-i18next' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { Copy, CopyCheck, diff --git a/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx b/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx index f935a203fe..cda24ea045 100644 --- a/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx +++ b/web/app/components/app/configuration/config/assistant-type-picker/index.spec.tsx @@ -5,31 +5,6 @@ import AssistantTypePicker from './index' import type { AgentConfig } from '@/models/debug' import { AgentStrategy } from '@/types/app' -// Type definition for AgentSetting props -type AgentSettingProps = { - isChatModel: boolean - payload: AgentConfig - isFunctionCall: boolean - onCancel: () => void - onSave: (payload: AgentConfig) => void -} - -// Track mock calls for props validation -let mockAgentSettingProps: AgentSettingProps | null = null - -// Mock AgentSetting component (complex modal with external hooks) -jest.mock('../agent/agent-setting', () => { - return function MockAgentSetting(props: AgentSettingProps) { - mockAgentSettingProps = props - return ( -
- - -
- ) - } -}) - // Test utilities const defaultAgentConfig: AgentConfig = { enabled: true, @@ -62,7 +37,6 @@ const getOptionByDescription = (descriptionRegex: RegExp) => { describe('AssistantTypePicker', () => { beforeEach(() => { jest.clearAllMocks() - mockAgentSettingProps = null }) // Rendering tests (REQUIRED) @@ -139,8 +113,8 @@ describe('AssistantTypePicker', () => { renderComponent() // Act - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Assert - Both options should be visible await waitFor(() => { @@ -225,8 +199,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'chat' }) // Act - Open dropdown - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Wait for dropdown and select agent await waitFor(() => { @@ -235,7 +209,7 @@ describe('AssistantTypePicker', () => { }) const agentOptions = screen.getAllByText(/agentAssistant.name/i) - await user.click(agentOptions[0].closest('div')!) + await user.click(agentOptions[0]) // Assert - Dropdown should remain open (agent settings should be visible) await waitFor(() => { @@ -250,8 +224,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'chat', onChange }) // Act - Open dropdown - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Wait for dropdown and click same option await waitFor(() => { @@ -260,7 +234,7 @@ describe('AssistantTypePicker', () => { }) const chatOptions = screen.getAllByText(/chatAssistant.name/i) - await user.click(chatOptions[1].closest('div')!) + await user.click(chatOptions[1]) // Assert expect(onChange).not.toHaveBeenCalled() @@ -276,8 +250,8 @@ describe('AssistantTypePicker', () => { renderComponent({ disabled: true, onChange }) // Act - Open dropdown (dropdown can still open when disabled) - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Wait for dropdown to open await waitFor(() => { @@ -298,8 +272,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: true }) // Act - Open dropdown - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) // Assert - Agent settings option should not be visible await waitFor(() => { @@ -313,8 +287,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false }) // Act - Open dropdown - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) // Assert - Agent settings option should be visible await waitFor(() => { @@ -331,20 +305,20 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false }) // Act - Open dropdown - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) // Click agent settings await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) // Assert await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) }) @@ -354,8 +328,8 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'chat', disabled: false }) // Act - Open dropdown - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Wait for dropdown to open await waitFor(() => { @@ -363,7 +337,7 @@ describe('AssistantTypePicker', () => { }) // Assert - Agent settings modal should not appear (value is 'chat') - expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument() }) it('should call onAgentSettingChange when saving agent settings', async () => { @@ -373,26 +347,26 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false, onAgentSettingChange }) // Act - Open dropdown and agent settings - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) // Wait for modal and click save await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) - const saveButton = screen.getByText('Save') + const saveButton = screen.getByText(/common.operation.save/i) await user.click(saveButton) // Assert - expect(onAgentSettingChange).toHaveBeenCalledWith({ max_iteration: 5 }) + expect(onAgentSettingChange).toHaveBeenCalledWith(defaultAgentConfig) }) it('should close modal when saving agent settings', async () => { @@ -401,26 +375,26 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false }) // Act - Open dropdown, agent settings, and save - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/appDebug.agent.setting.name/i)).toBeInTheDocument() }) - const saveButton = screen.getByText('Save') + const saveButton = screen.getByText(/common.operation.save/i) await user.click(saveButton) // Assert await waitFor(() => { - expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument() }) }) @@ -431,26 +405,26 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false, onAgentSettingChange }) // Act - Open dropdown, agent settings, and cancel - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) - const cancelButton = screen.getByText('Cancel') + const cancelButton = screen.getByText(/common.operation.cancel/i) await user.click(cancelButton) // Assert await waitFor(() => { - expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument() + expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument() }) expect(onAgentSettingChange).not.toHaveBeenCalled() }) @@ -461,19 +435,19 @@ describe('AssistantTypePicker', () => { renderComponent({ value: 'agent', disabled: false }) // Act - Open dropdown and agent settings - const trigger = screen.getByText(/agentAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) await waitFor(() => { expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() }) - const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div') - await user.click(agentSettingsTrigger!) + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) // Assert - Modal should be open and dropdown should close await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) // The dropdown should be closed (agent settings description should not be visible) @@ -492,10 +466,10 @@ describe('AssistantTypePicker', () => { renderComponent() // Act - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) - await user.click(trigger!) - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) + await user.click(trigger) + await user.click(trigger) // Assert - Should not crash expect(trigger).toBeInTheDocument() @@ -538,8 +512,8 @@ describe('AssistantTypePicker', () => { }) }).not.toThrow() - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) }) it('should handle empty agentConfig', async () => { @@ -630,8 +604,8 @@ describe('AssistantTypePicker', () => { renderComponent() // Act - Open dropdown - const trigger = screen.getByText(/chatAssistant.name/i).closest('div') - await user.click(trigger!) + const trigger = screen.getByText(/chatAssistant.name/i) + await user.click(trigger) // Assert - Descriptions should be visible await waitFor(() => { @@ -657,18 +631,14 @@ describe('AssistantTypePicker', () => { }) }) - // Props Validation for AgentSetting - describe('AgentSetting Props', () => { - it('should pass isFunctionCall and isChatModel props to AgentSetting', async () => { + // Agent Setting Integration + describe('AgentSetting Integration', () => { + it('should show function call mode when isFunctionCall is true', async () => { // Arrange const user = userEvent.setup() - renderComponent({ - value: 'agent', - isFunctionCall: true, - isChatModel: false, - }) + renderComponent({ value: 'agent', isFunctionCall: true, isChatModel: false }) - // Act - Open dropdown and trigger AgentSetting + // Act - Open dropdown and settings modal const trigger = screen.getByText(/agentAssistant.name/i) await user.click(trigger) @@ -679,17 +649,37 @@ describe('AssistantTypePicker', () => { const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) await user.click(agentSettingsTrigger) - // Assert - Verify AgentSetting receives correct props + // Assert await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() }) - - expect(mockAgentSettingProps).not.toBeNull() - expect(mockAgentSettingProps!.isFunctionCall).toBe(true) - expect(mockAgentSettingProps!.isChatModel).toBe(false) + expect(screen.getByText(/appDebug.agent.agentModeType.functionCall/i)).toBeInTheDocument() }) - it('should pass agentConfig payload to AgentSetting', async () => { + it('should show built-in prompt when isFunctionCall is false', async () => { + // Arrange + const user = userEvent.setup() + renderComponent({ value: 'agent', isFunctionCall: false, isChatModel: true }) + + // Act - Open dropdown and settings modal + const trigger = screen.getByText(/agentAssistant.name/i) + await user.click(trigger) + + await waitFor(() => { + expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument() + }) + + const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) + await user.click(agentSettingsTrigger) + + // Assert + await waitFor(() => { + expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument() + }) + expect(screen.getByText(/tools.builtInPromptTitle/i)).toBeInTheDocument() + }) + + it('should initialize max iteration from agentConfig payload', async () => { // Arrange const user = userEvent.setup() const customConfig: AgentConfig = { @@ -699,12 +689,9 @@ describe('AssistantTypePicker', () => { tools: [], } - renderComponent({ - value: 'agent', - agentConfig: customConfig, - }) + renderComponent({ value: 'agent', agentConfig: customConfig }) - // Act - Open AgentSetting + // Act - Open dropdown and settings modal const trigger = screen.getByText(/agentAssistant.name/i) await user.click(trigger) @@ -715,13 +702,10 @@ describe('AssistantTypePicker', () => { const agentSettingsTrigger = screen.getByText(/agent.setting.name/i) await user.click(agentSettingsTrigger) - // Assert - Verify payload was passed - await waitFor(() => { - expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument() - }) - - expect(mockAgentSettingProps).not.toBeNull() - expect(mockAgentSettingProps!.payload).toEqual(customConfig) + // Assert + await screen.findByText(/common.operation.save/i) + const maxIterationInput = await screen.findByRole('spinbutton') + expect(maxIterationInput).toHaveValue(10) }) }) diff --git a/web/app/components/app/configuration/config/assistant-type-picker/index.tsx b/web/app/components/app/configuration/config/assistant-type-picker/index.tsx index 3597a6e292..50f16f957a 100644 --- a/web/app/components/app/configuration/config/assistant-type-picker/index.tsx +++ b/web/app/components/app/configuration/config/assistant-type-picker/index.tsx @@ -4,7 +4,7 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { RiArrowDownSLine } from '@remixicon/react' import AgentSetting from '../agent/agent-setting' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { PortalToFollowElem, PortalToFollowElemContent, diff --git a/web/app/components/app/configuration/config/automatic/idea-output.tsx b/web/app/components/app/configuration/config/automatic/idea-output.tsx index df4f76c92b..895f74baa3 100644 --- a/web/app/components/app/configuration/config/automatic/idea-output.tsx +++ b/web/app/components/app/configuration/config/automatic/idea-output.tsx @@ -3,7 +3,7 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid import { useBoolean } from 'ahooks' import type { FC } from 'react' import React from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Textarea from '@/app/components/base/textarea' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx index b14ee93313..409f335232 100644 --- a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx +++ b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import React from 'react' import PromptEditor from '@/app/components/base/prompt-editor' import type { GeneratorType } from './types' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types' import { BlockEnum } from '@/app/components/workflow/types' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/app/configuration/config/automatic/prompt-toast.tsx b/web/app/components/app/configuration/config/automatic/prompt-toast.tsx index 2826cc97c8..c9169f0ad7 100644 --- a/web/app/components/app/configuration/config/automatic/prompt-toast.tsx +++ b/web/app/components/app/configuration/config/automatic/prompt-toast.tsx @@ -1,7 +1,7 @@ import { RiArrowDownSLine, RiSparklingFill } from '@remixicon/react' import { useBoolean } from 'ahooks' import React from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { Markdown } from '@/app/components/base/markdown' import { useTranslation } from 'react-i18next' import s from './style.module.css' diff --git a/web/app/components/app/configuration/config/automatic/version-selector.tsx b/web/app/components/app/configuration/config/automatic/version-selector.tsx index c3d3e1d91c..715c1f3c80 100644 --- a/web/app/components/app/configuration/config/automatic/version-selector.tsx +++ b/web/app/components/app/configuration/config/automatic/version-selector.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' import { useBoolean } from 'ahooks' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/app/configuration/config/config-audio.spec.tsx b/web/app/components/app/configuration/config/config-audio.spec.tsx new file mode 100644 index 0000000000..94eeb87c99 --- /dev/null +++ b/web/app/components/app/configuration/config/config-audio.spec.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ConfigAudio from './config-audio' +import type { FeatureStoreState } from '@/app/components/base/features/store' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' + +const mockUseContext = jest.fn() +jest.mock('use-context-selector', () => { + const actual = jest.requireActual('use-context-selector') + return { + ...actual, + useContext: (context: unknown) => mockUseContext(context), + } +}) + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const mockUseFeatures = jest.fn() +const mockUseFeaturesStore = jest.fn() +jest.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector), + useFeaturesStore: () => mockUseFeaturesStore(), +})) + +type SetupOptions = { + isVisible?: boolean + allowedTypes?: SupportUploadFileTypes[] +} + +let mockFeatureStoreState: FeatureStoreState +let mockSetFeatures: jest.Mock +const mockStore = { + getState: jest.fn(() => mockFeatureStoreState), +} + +const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => { + mockSetFeatures = jest.fn() + mockFeatureStoreState = { + features: { + file: { + allowed_file_types: allowedTypes, + enabled: allowedTypes.length > 0, + }, + }, + setFeatures: mockSetFeatures, + showFeaturesModal: false, + setShowFeaturesModal: jest.fn(), + } + mockStore.getState.mockImplementation(() => mockFeatureStoreState) + mockUseFeaturesStore.mockReturnValue(mockStore) + mockUseFeatures.mockImplementation(selector => selector(mockFeatureStoreState)) +} + +const renderConfigAudio = (options: SetupOptions = {}) => { + const { + isVisible = true, + allowedTypes = [], + } = options + setupFeatureStore(allowedTypes) + mockUseContext.mockReturnValue({ + isShowAudioConfig: isVisible, + }) + const user = userEvent.setup() + render() + return { + user, + setFeatures: mockSetFeatures, + } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('ConfigAudio', () => { + it('should not render when the audio configuration is hidden', () => { + renderConfigAudio({ isVisible: false }) + + expect(screen.queryByText('appDebug.feature.audioUpload.title')).not.toBeInTheDocument() + }) + + it('should display the audio toggle state based on feature store data', () => { + renderConfigAudio({ allowedTypes: [SupportUploadFileTypes.audio] }) + + expect(screen.getByText('appDebug.feature.audioUpload.title')).toBeInTheDocument() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true') + }) + + it('should enable audio uploads when toggled on', async () => { + const { user, setFeatures } = renderConfigAudio() + const toggle = screen.getByRole('switch') + + expect(toggle).toHaveAttribute('aria-checked', 'false') + await user.click(toggle) + + expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({ + file: expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.audio], + enabled: true, + }), + })) + }) + + it('should disable audio uploads and turn off file feature when last type is removed', async () => { + const { user, setFeatures } = renderConfigAudio({ allowedTypes: [SupportUploadFileTypes.audio] }) + const toggle = screen.getByRole('switch') + + expect(toggle).toHaveAttribute('aria-checked', 'true') + await user.click(toggle) + + expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({ + file: expect.objectContaining({ + allowed_file_types: [], + enabled: false, + }), + })) + }) +}) diff --git a/web/app/components/app/configuration/config/config-document.spec.tsx b/web/app/components/app/configuration/config/config-document.spec.tsx new file mode 100644 index 0000000000..aeb504fdbd --- /dev/null +++ b/web/app/components/app/configuration/config/config-document.spec.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ConfigDocument from './config-document' +import type { FeatureStoreState } from '@/app/components/base/features/store' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' + +const mockUseContext = jest.fn() +jest.mock('use-context-selector', () => { + const actual = jest.requireActual('use-context-selector') + return { + ...actual, + useContext: (context: unknown) => mockUseContext(context), + } +}) + +const mockUseFeatures = jest.fn() +const mockUseFeaturesStore = jest.fn() +jest.mock('@/app/components/base/features/hooks', () => ({ + useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector), + useFeaturesStore: () => mockUseFeaturesStore(), +})) + +type SetupOptions = { + isVisible?: boolean + allowedTypes?: SupportUploadFileTypes[] +} + +let mockFeatureStoreState: FeatureStoreState +let mockSetFeatures: jest.Mock +const mockStore = { + getState: jest.fn(() => mockFeatureStoreState), +} + +const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => { + mockSetFeatures = jest.fn() + mockFeatureStoreState = { + features: { + file: { + allowed_file_types: allowedTypes, + enabled: allowedTypes.length > 0, + }, + }, + setFeatures: mockSetFeatures, + showFeaturesModal: false, + setShowFeaturesModal: jest.fn(), + } + mockStore.getState.mockImplementation(() => mockFeatureStoreState) + mockUseFeaturesStore.mockReturnValue(mockStore) + mockUseFeatures.mockImplementation(selector => selector(mockFeatureStoreState)) +} + +const renderConfigDocument = (options: SetupOptions = {}) => { + const { + isVisible = true, + allowedTypes = [], + } = options + setupFeatureStore(allowedTypes) + mockUseContext.mockReturnValue({ + isShowDocumentConfig: isVisible, + }) + const user = userEvent.setup() + render() + return { + user, + setFeatures: mockSetFeatures, + } +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('ConfigDocument', () => { + it('should not render when the document configuration is hidden', () => { + renderConfigDocument({ isVisible: false }) + + expect(screen.queryByText('appDebug.feature.documentUpload.title')).not.toBeInTheDocument() + }) + + it('should show document toggle badge when configuration is visible', () => { + renderConfigDocument({ allowedTypes: [SupportUploadFileTypes.document] }) + + expect(screen.getByText('appDebug.feature.documentUpload.title')).toBeInTheDocument() + expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true') + }) + + it('should add document type to allowed list when toggled on', async () => { + const { user, setFeatures } = renderConfigDocument({ allowedTypes: [SupportUploadFileTypes.audio] }) + const toggle = screen.getByRole('switch') + + expect(toggle).toHaveAttribute('aria-checked', 'false') + await user.click(toggle) + + expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({ + file: expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.audio, SupportUploadFileTypes.document], + enabled: true, + }), + })) + }) + + it('should remove document type but keep file feature enabled when other types remain', async () => { + const { user, setFeatures } = renderConfigDocument({ + allowedTypes: [SupportUploadFileTypes.document, SupportUploadFileTypes.audio], + }) + const toggle = screen.getByRole('switch') + + expect(toggle).toHaveAttribute('aria-checked', 'true') + await user.click(toggle) + + expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({ + file: expect.objectContaining({ + allowed_file_types: [SupportUploadFileTypes.audio], + enabled: true, + }), + })) + }) +}) diff --git a/web/app/components/app/configuration/config/index.spec.tsx b/web/app/components/app/configuration/config/index.spec.tsx new file mode 100644 index 0000000000..814c52c3d7 --- /dev/null +++ b/web/app/components/app/configuration/config/index.spec.tsx @@ -0,0 +1,254 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import Config from './index' +import type { ModelConfig, PromptVariable } from '@/models/debug' +import * as useContextSelector from 'use-context-selector' +import type { ToolItem } from '@/types/app' +import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app' + +jest.mock('use-context-selector', () => { + const actual = jest.requireActual('use-context-selector') + return { + ...actual, + useContext: jest.fn(), + } +}) + +const mockFormattingDispatcher = jest.fn() +jest.mock('../debug/hooks', () => ({ + __esModule: true, + useFormattingChangedDispatcher: () => mockFormattingDispatcher, +})) + +let latestConfigPromptProps: any +jest.mock('@/app/components/app/configuration/config-prompt', () => ({ + __esModule: true, + default: (props: any) => { + latestConfigPromptProps = props + return
+ }, +})) + +let latestConfigVarProps: any +jest.mock('@/app/components/app/configuration/config-var', () => ({ + __esModule: true, + default: (props: any) => { + latestConfigVarProps = props + return
+ }, +})) + +jest.mock('../dataset-config', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('./agent/agent-tools', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('../config-vision', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('./config-document', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('./config-audio', () => ({ + __esModule: true, + default: () =>
, +})) + +let latestHistoryPanelProps: any +jest.mock('../config-prompt/conversation-history/history-panel', () => ({ + __esModule: true, + default: (props: any) => { + latestHistoryPanelProps = props + return
+ }, +})) + +type MockContext = { + mode: AppModeEnum + isAdvancedMode: boolean + modelModeType: ModelModeType + isAgent: boolean + hasSetBlockStatus: { + context: boolean + history: boolean + query: boolean + } + showHistoryModal: jest.Mock + modelConfig: ModelConfig + setModelConfig: jest.Mock + setPrevPromptConfig: jest.Mock +} + +const createPromptVariable = (overrides: Partial = {}): PromptVariable => ({ + key: 'variable', + name: 'Variable', + type: 'string', + ...overrides, +}) + +const createModelConfig = (overrides: Partial = {}): ModelConfig => ({ + provider: 'openai', + model_id: 'gpt-4', + mode: ModelModeType.chat, + configs: { + prompt_template: 'Hello {{variable}}', + prompt_variables: [createPromptVariable({ key: 'existing' })], + }, + chat_prompt_config: null, + completion_prompt_config: null, + opening_statement: null, + more_like_this: null, + suggested_questions: null, + suggested_questions_after_answer: null, + speech_to_text: null, + text_to_speech: null, + file_upload: null, + retriever_resource: null, + sensitive_word_avoidance: null, + annotation_reply: null, + external_data_tools: null, + system_parameters: { + audio_file_size_limit: 1, + file_size_limit: 1, + image_file_size_limit: 1, + video_file_size_limit: 1, + workflow_file_upload_limit: 1, + }, + dataSets: [], + agentConfig: { + enabled: false, + strategy: AgentStrategy.react, + max_iteration: 1, + tools: [] as ToolItem[], + }, + ...overrides, +}) + +const createContextValue = (overrides: Partial = {}): MockContext => ({ + mode: AppModeEnum.CHAT, + isAdvancedMode: false, + modelModeType: ModelModeType.chat, + isAgent: false, + hasSetBlockStatus: { + context: false, + history: true, + query: false, + }, + showHistoryModal: jest.fn(), + modelConfig: createModelConfig(), + setModelConfig: jest.fn(), + setPrevPromptConfig: jest.fn(), + ...overrides, +}) + +const mockUseContext = useContextSelector.useContext as jest.Mock + +const renderConfig = (contextOverrides: Partial = {}) => { + const contextValue = createContextValue(contextOverrides) + mockUseContext.mockReturnValue(contextValue) + return { + contextValue, + ...render(), + } +} + +beforeEach(() => { + jest.clearAllMocks() + latestConfigPromptProps = undefined + latestConfigVarProps = undefined + latestHistoryPanelProps = undefined +}) + +// Rendering scenarios ensure the layout toggles agent/history specific sections correctly. +describe('Config - Rendering', () => { + it('should render baseline sections without agent specific panels', () => { + renderConfig() + + expect(screen.getByTestId('config-prompt')).toBeInTheDocument() + expect(screen.getByTestId('config-var')).toBeInTheDocument() + expect(screen.getByTestId('dataset-config')).toBeInTheDocument() + expect(screen.getByTestId('config-vision')).toBeInTheDocument() + expect(screen.getByTestId('config-document')).toBeInTheDocument() + expect(screen.getByTestId('config-audio')).toBeInTheDocument() + expect(screen.queryByTestId('agent-tools')).not.toBeInTheDocument() + expect(screen.queryByTestId('history-panel')).not.toBeInTheDocument() + }) + + it('should show AgentTools when app runs in agent mode', () => { + renderConfig({ isAgent: true }) + + expect(screen.getByTestId('agent-tools')).toBeInTheDocument() + }) + + it('should display HistoryPanel only when advanced chat completion values apply', () => { + const showHistoryModal = jest.fn() + renderConfig({ + isAdvancedMode: true, + mode: AppModeEnum.ADVANCED_CHAT, + modelModeType: ModelModeType.completion, + hasSetBlockStatus: { + context: false, + history: false, + query: false, + }, + showHistoryModal, + }) + + expect(screen.getByTestId('history-panel')).toBeInTheDocument() + expect(latestHistoryPanelProps.showWarning).toBe(true) + expect(latestHistoryPanelProps.onShowEditModal).toBe(showHistoryModal) + }) +}) + +// Prompt handling scenarios validate integration between Config and prompt children. +describe('Config - Prompt Handling', () => { + it('should update prompt template and dispatch formatting event when text changes', () => { + const { contextValue } = renderConfig() + const previousVariables = contextValue.modelConfig.configs.prompt_variables + const additions = [createPromptVariable({ key: 'new', name: 'New' })] + + latestConfigPromptProps.onChange('Updated template', additions) + + expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs) + expect(contextValue.setModelConfig).toHaveBeenCalledWith(expect.objectContaining({ + configs: expect.objectContaining({ + prompt_template: 'Updated template', + prompt_variables: [...previousVariables, ...additions], + }), + })) + expect(mockFormattingDispatcher).toHaveBeenCalledTimes(1) + }) + + it('should skip formatting dispatcher when template remains identical', () => { + const { contextValue } = renderConfig() + const unchangedTemplate = contextValue.modelConfig.configs.prompt_template + + latestConfigPromptProps.onChange(unchangedTemplate, [createPromptVariable({ key: 'added' })]) + + expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs) + expect(mockFormattingDispatcher).not.toHaveBeenCalled() + }) + + it('should replace prompt variables when ConfigVar reports updates', () => { + const { contextValue } = renderConfig() + const replacementVariables = [createPromptVariable({ key: 'replacement' })] + + latestConfigVarProps.onPromptVariablesChange(replacementVariables) + + expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs) + expect(contextValue.setModelConfig).toHaveBeenCalledWith(expect.objectContaining({ + configs: expect.objectContaining({ + prompt_variables: replacementVariables, + }), + })) + }) +}) diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.tsx index 85d46122a3..7fd7011a56 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/index.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/index.tsx @@ -13,7 +13,7 @@ import Drawer from '@/app/components/base/drawer' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import Badge from '@/app/components/base/badge' import { useKnowledge } from '@/hooks/use-knowledge' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import AppIcon from '@/app/components/base/app-icon' type ItemProps = { diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.tsx index ebba9c51cb..80cc50acdf 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/index.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/index.tsx @@ -4,7 +4,7 @@ import React from 'react' import { useTranslation } from 'react-i18next' import type { Props } from './var-picker' import VarPicker from './var-picker' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { BracketsX } from '@/app/components/base/icons/src/vender/line/development' import Tooltip from '@/app/components/base/tooltip' diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx index c443ea0b1f..f5ea2eaa27 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronDownIcon } from '@heroicons/react/24/outline' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { PortalToFollowElem, PortalToFollowElemContent, diff --git a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx index 8e06d6c901..c7a43fbfbd 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/config-content.tsx @@ -20,7 +20,7 @@ import type { DataSet, } from '@/models/datasets' import { RerankingModeEnum } from '@/models/datasets' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/hooks' import Switch from '@/app/components/base/switch' import Toast from '@/app/components/base/toast' diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx index b666a6cb5b..c432ca68e2 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.spec.tsx @@ -1,5 +1,6 @@ import * as React from 'react' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import ParamsConfig from './index' import ConfigContext from '@/context/debug-configuration' import type { DatasetConfigs } from '@/models/debug' @@ -11,6 +12,37 @@ import { useModelListAndDefaultModelAndCurrentProviderAndModel, } from '@/app/components/header/account-setting/model-provider-page/hooks' +jest.mock('@headlessui/react', () => ({ + Dialog: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogPanel: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + DialogTitle: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + Transition: ({ show, children }: { show: boolean; children: React.ReactNode }) => (show ? <>{children} : null), + TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}, + Switch: ({ checked, onChange, children, ...props }: { checked: boolean; onChange?: (value: boolean) => void; children?: React.ReactNode }) => ( + + ), +})) + jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(), useCurrentProviderAndModel: jest.fn(), @@ -74,9 +106,6 @@ const renderParamsConfig = ({ initialModalOpen?: boolean disabled?: boolean } = {}) => { - const setDatasetConfigsSpy = jest.fn() - const setModalOpenSpy = jest.fn() - const Wrapper = ({ children }: { children: React.ReactNode }) => { const [datasetConfigsState, setDatasetConfigsState] = React.useState(datasetConfigs) const [modalOpen, setModalOpen] = React.useState(initialModalOpen) @@ -84,12 +113,10 @@ const renderParamsConfig = ({ const contextValue = { datasetConfigs: datasetConfigsState, setDatasetConfigs: (next: DatasetConfigs) => { - setDatasetConfigsSpy(next) setDatasetConfigsState(next) }, rerankSettingModalOpen: modalOpen, setRerankSettingModalOpen: (open: boolean) => { - setModalOpenSpy(open) setModalOpen(open) }, } as unknown as React.ComponentProps['value'] @@ -101,18 +128,13 @@ const renderParamsConfig = ({ ) } - render( + return render( , { wrapper: Wrapper }, ) - - return { - setDatasetConfigsSpy, - setModalOpenSpy, - } } describe('dataset-config/params-config', () => { @@ -151,77 +173,92 @@ describe('dataset-config/params-config', () => { describe('User Interactions', () => { it('should open modal and persist changes when save is clicked', async () => { // Arrange - const { setDatasetConfigsSpy } = renderParamsConfig() + renderParamsConfig() + const user = userEvent.setup() // Act - fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) + await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const dialogScope = within(dialog) - // Change top_k via the first number input increment control. const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' }) - fireEvent.click(incrementButtons[0]) + await user.click(incrementButtons[0]) - const saveButton = await dialogScope.findByRole('button', { name: 'common.operation.save' }) - fireEvent.click(saveButton) + await waitFor(() => { + const [topKInput] = dialogScope.getAllByRole('spinbutton') + expect(topKInput).toHaveValue(5) + }) + + await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' })) - // Assert - expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 5 })) await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + + await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) + const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + const reopenedScope = within(reopenedDialog) + const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton') + + // Assert + expect(reopenedTopKInput).toHaveValue(5) }) it('should discard changes when cancel is clicked', async () => { // Arrange - const { setDatasetConfigsSpy } = renderParamsConfig() + renderParamsConfig() + const user = userEvent.setup() // Act - fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) + await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const dialogScope = within(dialog) const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' }) - fireEvent.click(incrementButtons[0]) + await user.click(incrementButtons[0]) + + await waitFor(() => { + const [topKInput] = dialogScope.getAllByRole('spinbutton') + expect(topKInput).toHaveValue(5) + }) const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' }) - fireEvent.click(cancelButton) + await user.click(cancelButton) await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) - // Re-open and save without changes. - fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) + // Re-open and verify the original value remains. + await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' })) const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const reopenedScope = within(reopenedDialog) - const reopenedSave = await reopenedScope.findByRole('button', { name: 'common.operation.save' }) - fireEvent.click(reopenedSave) + const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton') - // Assert - should save original top_k rather than the canceled change. - expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 })) + // Assert + expect(reopenedTopKInput).toHaveValue(4) }) it('should prevent saving when rerank model is required but invalid', async () => { // Arrange - const { setDatasetConfigsSpy } = renderParamsConfig({ + renderParamsConfig({ datasetConfigs: createDatasetConfigs({ reranking_enable: true, reranking_mode: RerankingModeEnum.RerankingModel, }), initialModalOpen: true, }) + const user = userEvent.setup() // Act const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) const dialogScope = within(dialog) - fireEvent.click(dialogScope.getByRole('button', { name: 'common.operation.save' })) + await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' })) // Assert expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'error', message: 'appDebug.datasetConfig.rerankModelRequired', }) - expect(setDatasetConfigsSpy).not.toHaveBeenCalled() expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) diff --git a/web/app/components/app/configuration/dataset-config/params-config/index.tsx b/web/app/components/app/configuration/dataset-config/params-config/index.tsx index df2b4293c4..24da958217 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/index.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { RiEqualizer2Line } from '@remixicon/react' import ConfigContent from './config-content' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import ConfigContext from '@/context/debug-configuration' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' diff --git a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx index ebfa3b1e12..459623104d 100644 --- a/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx +++ b/web/app/components/app/configuration/dataset-config/params-config/weighted-score.tsx @@ -2,7 +2,7 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' import './weighted-score.css' import Slider from '@/app/components/base/slider' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { noop } from 'lodash-es' const formatNumber = (value: number) => { diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index 6857c38e1e..f02fdcb5d7 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -10,7 +10,7 @@ import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' import Badge from '@/app/components/base/badge' import { useKnowledge } from '@/hooks/use-knowledge' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import AppIcon from '@/app/components/base/app-icon' import { useInfiniteDatasets } from '@/service/knowledge/use-dataset' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx index 08db7186ec..e2c5307b03 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.spec.tsx @@ -7,8 +7,9 @@ import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } fr import { IndexingType } from '@/app/components/datasets/create/step-two' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { updateDatasetSetting } from '@/service/datasets' -import { fetchMembers } from '@/service/common' +import { useMembers } from '@/service/use-common' import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const mockNotify = jest.fn() const mockOnCancel = jest.fn() @@ -41,8 +42,10 @@ jest.mock('@/service/datasets', () => ({ updateDatasetSetting: jest.fn(), })) -jest.mock('@/service/common', () => ({ - fetchMembers: jest.fn(), +jest.mock('@/service/use-common', () => ({ + __esModule: true, + ...jest.requireActual('@/service/use-common'), + useMembers: jest.fn(), })) jest.mock('@/context/app-context', () => ({ @@ -103,7 +106,7 @@ jest.mock('@/app/components/datasets/settings/utils', () => ({ })) const mockUpdateDatasetSetting = updateDatasetSetting as jest.MockedFunction -const mockFetchMembers = fetchMembers as jest.MockedFunction +const mockUseMembers = useMembers as jest.MockedFunction const createRetrievalConfig = (overrides: Partial = {}): RetrievalConfig => ({ search_method: RETRIEVE_METHOD.semantic, @@ -192,10 +195,43 @@ const renderWithProviders = (dataset: DataSet) => { ) } +const createMemberList = (): DataSet['partial_member_list'] => ([ + 'member-2', +]) + +const renderSettingsModal = async (dataset: DataSet) => { + renderWithProviders(dataset) + await waitFor(() => expect(mockUseMembers).toHaveBeenCalled()) +} + describe('SettingsModal', () => { beforeEach(() => { jest.clearAllMocks() mockIsWorkspaceDatasetOperator = false + mockUseMembers.mockReturnValue({ + data: { + accounts: [ + { + id: 'user-1', + name: 'User One', + email: 'user@example.com', + avatar: 'avatar.png', + avatar_url: 'avatar.png', + status: 'active', + role: 'owner', + }, + { + id: 'member-2', + name: 'Member Two', + email: 'member@example.com', + avatar: 'avatar.png', + avatar_url: 'avatar.png', + status: 'active', + role: 'editor', + }, + ], + }, + } as ReturnType) mockUseModelList.mockImplementation((type: ModelTypeEnum) => { if (type === ModelTypeEnum.rerank) { return { @@ -213,261 +249,289 @@ describe('SettingsModal', () => { mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null }) mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null }) mockCheckShowMultiModalTip.mockReturnValue(false) - mockFetchMembers.mockResolvedValue({ - accounts: [ - { - id: 'user-1', - name: 'User One', - email: 'user@example.com', - avatar: 'avatar.png', - avatar_url: 'avatar.png', - status: 'active', - role: 'owner', - }, - { - id: 'member-2', - name: 'Member Two', - email: 'member@example.com', - avatar: 'avatar.png', - avatar_url: 'avatar.png', - status: 'active', - role: 'editor', - }, - ], - }) mockUpdateDatasetSetting.mockResolvedValue(createDataset()) }) - it('renders dataset details', async () => { - renderWithProviders(createDataset()) + // Rendering and basic field bindings. + describe('Rendering', () => { + it('should render dataset details when dataset is provided', async () => { + // Arrange + const dataset = createDataset() - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + // Act + await renderSettingsModal(dataset) - expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset') - expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description') - }) - - it('calls onCancel when cancel is clicked', async () => { - renderWithProviders(createDataset()) - - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) - - await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - - expect(mockOnCancel).toHaveBeenCalledTimes(1) - }) - - it('shows external knowledge info for external datasets', async () => { - const dataset = createDataset({ - provider: 'external', - external_knowledge_info: { - external_knowledge_id: 'ext-id-123', - external_knowledge_api_id: 'ext-api-id-123', - external_knowledge_api_name: 'External Knowledge API', - external_knowledge_api_endpoint: 'https://api.external.com', - }, + // Assert + expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset') + expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description') }) - renderWithProviders(dataset) + it('should show external knowledge info when dataset is external', async () => { + // Arrange + const dataset = createDataset({ + provider: 'external', + external_knowledge_info: { + external_knowledge_id: 'ext-id-123', + external_knowledge_api_id: 'ext-api-id-123', + external_knowledge_api_name: 'External Knowledge API', + external_knowledge_api_endpoint: 'https://api.external.com', + }, + }) - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + // Act + await renderSettingsModal(dataset) - expect(screen.getByText('External Knowledge API')).toBeInTheDocument() - expect(screen.getByText('https://api.external.com')).toBeInTheDocument() - expect(screen.getByText('ext-id-123')).toBeInTheDocument() - }) - - it('updates name when user types', async () => { - renderWithProviders(createDataset()) - - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) - - const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') - await userEvent.clear(nameInput) - await userEvent.type(nameInput, 'New Dataset Name') - - expect(nameInput).toHaveValue('New Dataset Name') - }) - - it('updates description when user types', async () => { - renderWithProviders(createDataset()) - - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) - - const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder') - await userEvent.clear(descriptionInput) - await userEvent.type(descriptionInput, 'New description') - - expect(descriptionInput).toHaveValue('New description') - }) - - it('shows and dismisses retrieval change tip when index method changes', async () => { - const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL }) - - renderWithProviders(dataset) - - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) - - await userEvent.click(screen.getByText('datasetCreation.stepTwo.qualified')) - - expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument() - - await userEvent.click(screen.getByLabelText('close-retrieval-change-tip')) - - await waitFor(() => { - expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument() + // Assert + expect(screen.getByText('External Knowledge API')).toBeInTheDocument() + expect(screen.getByText('https://api.external.com')).toBeInTheDocument() + expect(screen.getByText('ext-id-123')).toBeInTheDocument() }) }) - it('requires dataset name before saving', async () => { - renderWithProviders(createDataset()) + // User interactions that update visible state. + describe('Interactions', () => { + it('should call onCancel when cancel button is clicked', async () => { + // Arrange + const user = userEvent.setup() - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + // Act + await renderSettingsModal(createDataset()) + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') - await userEvent.clear(nameInput) - await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'datasetSettings.form.nameError', - })) - expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() - }) - - it('requires rerank model when reranking is enabled', async () => { - mockUseModelList.mockReturnValue({ data: [] }) - const dataset = createDataset({}, createRetrievalConfig({ - reranking_enable: true, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - })) - - renderWithProviders(dataset) - - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) - await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'appDebug.datasetConfig.rerankModelRequired', - })) - expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() - }) - - it('saves internal dataset changes', async () => { - const rerankRetrieval = createRetrievalConfig({ - reranking_enable: true, - reranking_model: { - reranking_provider_name: 'rerank-provider', - reranking_model_name: 'rerank-model', - }, - }) - const dataset = createDataset({ - retrieval_model: rerankRetrieval, - retrieval_model_dict: rerankRetrieval, + // Assert + expect(mockOnCancel).toHaveBeenCalledTimes(1) }) - renderWithProviders(dataset) + it('should update name input when user types', async () => { + // Arrange + const user = userEvent.setup() + await renderSettingsModal(createDataset()) - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') - const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') - await userEvent.clear(nameInput) - await userEvent.type(nameInput, 'Updated Internal Dataset') - await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + // Act + await user.clear(nameInput) + await user.type(nameInput, 'New Dataset Name') - await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled()) + // Assert + expect(nameInput).toHaveValue('New Dataset Name') + }) - expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ - name: 'Updated Internal Dataset', - permission: DatasetPermission.allTeamMembers, - }), - })) - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'success', - message: 'common.actionMsg.modifiedSuccessfully', - })) - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - name: 'Updated Internal Dataset', - retrieval_model_dict: expect.objectContaining({ + it('should update description input when user types', async () => { + // Arrange + const user = userEvent.setup() + await renderSettingsModal(createDataset()) + + const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder') + + // Act + await user.clear(descriptionInput) + await user.type(descriptionInput, 'New description') + + // Assert + expect(descriptionInput).toHaveValue('New description') + }) + + it('should show and dismiss retrieval change tip when indexing method changes', async () => { + // Arrange + const user = userEvent.setup() + const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL }) + + // Act + await renderSettingsModal(dataset) + await user.click(screen.getByText('datasetCreation.stepTwo.qualified')) + + // Assert + expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument() + + // Act + await user.click(screen.getByLabelText('close-retrieval-change-tip')) + + // Assert + await waitFor(() => { + expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument() + }) + }) + + it('should open account setting modal when embedding model tip is clicked', async () => { + // Arrange + const user = userEvent.setup() + + // Act + await renderSettingsModal(createDataset()) + await user.click(screen.getByText('datasetSettings.form.embeddingModelTipLink')) + + // Assert + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) + }) + }) + + // Validation guardrails before saving. + describe('Validation', () => { + it('should block save when dataset name is empty', async () => { + // Arrange + const user = userEvent.setup() + await renderSettingsModal(createDataset()) + + const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') + + // Act + await user.clear(nameInput) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: 'datasetSettings.form.nameError', + })) + expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() + }) + + it('should block save when reranking is enabled without model', async () => { + // Arrange + const user = userEvent.setup() + mockUseModelList.mockReturnValue({ data: [] }) + const dataset = createDataset({}, createRetrievalConfig({ reranking_enable: true, - }), - })) + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + })) + + // Act + await renderSettingsModal(dataset) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'error', + message: 'appDebug.datasetConfig.rerankModelRequired', + })) + expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() + }) }) - it('saves external dataset with partial members and updated retrieval params', async () => { - const dataset = createDataset({ - provider: 'external', - permission: DatasetPermission.partialMembers, - partial_member_list: ['member-2'], - external_retrieval_model: { - top_k: 5, - score_threshold: 0.3, - score_threshold_enabled: true, - }, - }, { - score_threshold_enabled: true, - score_threshold: 0.8, + // Save flows and side effects. + describe('Save', () => { + it('should save internal dataset changes when form is valid', async () => { + // Arrange + const user = userEvent.setup() + const rerankRetrieval = createRetrievalConfig({ + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'rerank-provider', + reranking_model_name: 'rerank-model', + }, + }) + const dataset = createDataset({ + retrieval_model: rerankRetrieval, + retrieval_model_dict: rerankRetrieval, + }) + + // Act + await renderSettingsModal(dataset) + + const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder') + await user.clear(nameInput) + await user.type(nameInput, 'Updated Internal Dataset') + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + // Assert + await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled()) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + name: 'Updated Internal Dataset', + permission: DatasetPermission.allTeamMembers, + }), + })) + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ + type: 'success', + message: 'common.actionMsg.modifiedSuccessfully', + })) + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Updated Internal Dataset', + retrieval_model_dict: expect.objectContaining({ + reranking_enable: true, + }), + })) }) - renderWithProviders(dataset) - - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) - - await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) - - await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled()) - - expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({ - body: expect.objectContaining({ + it('should save external dataset changes when partial members configured', async () => { + // Arrange + const user = userEvent.setup() + const dataset = createDataset({ + provider: 'external', permission: DatasetPermission.partialMembers, - external_retrieval_model: expect.objectContaining({ + partial_member_list: createMemberList(), + external_retrieval_model: { top_k: 5, - }), - partial_member_list: [ - { - user_id: 'member-2', - role: 'editor', - }, - ], - }), - })) - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - retrieval_model_dict: expect.objectContaining({ + score_threshold: 0.3, + score_threshold_enabled: true, + }, + }, { score_threshold_enabled: true, score_threshold: 0.8, - }), - })) - }) + }) - it('disables save button while saving', async () => { - mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) + // Act + await renderSettingsModal(dataset) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - renderWithProviders(createDataset()) + // Assert + await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled()) - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + permission: DatasetPermission.partialMembers, + external_retrieval_model: expect.objectContaining({ + top_k: 5, + }), + partial_member_list: [ + { + user_id: 'member-2', + role: 'editor', + }, + ], + }), + })) + expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ + retrieval_model_dict: expect.objectContaining({ + score_threshold_enabled: true, + score_threshold: 0.8, + }), + })) + }) - const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) - await userEvent.click(saveButton) + it('should disable save button while saving', async () => { + // Arrange + const user = userEvent.setup() + mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) - expect(saveButton).toBeDisabled() - }) + // Act + await renderSettingsModal(createDataset()) - it('shows error toast when save fails', async () => { - mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error')) + const saveButton = screen.getByRole('button', { name: 'common.operation.save' }) + await user.click(saveButton) - renderWithProviders(createDataset()) + // Assert + expect(saveButton).toBeDisabled() + }) - await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled()) + it('should show error toast when save fails', async () => { + // Arrange + const user = userEvent.setup() + mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error')) - await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + // Act + await renderSettingsModal(createDataset()) + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) - await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + // Assert + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) }) }) }) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 37d9ddd372..c191ff5d46 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -1,10 +1,9 @@ import type { FC } from 'react' -import { useMemo, useRef, useState } from 'react' -import { useMount } from 'ahooks' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { isEqual } from 'lodash-es' import { RiCloseLine } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import IndexMethod from '@/app/components/datasets/settings/index-method' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' @@ -21,10 +20,10 @@ import PermissionSelector from '@/app/components/datasets/settings/permission-se import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { fetchMembers } from '@/service/common' import type { Member } from '@/models/common' import { IndexingType } from '@/app/components/datasets/create/step-two' import { useDocLink } from '@/context/i18n' +import { useMembers } from '@/service/use-common' import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils' import { RetrievalChangeTip, RetrievalSection } from './retrieval-section' @@ -63,6 +62,7 @@ const SettingsModal: FC = ({ const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold_enabled ?? false) const [selectedMemberIDs, setSelectedMemberIDs] = useState(currentDataset.partial_member_list || []) const [memberList, setMemberList] = useState([]) + const { data: membersData } = useMembers() const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique) const [retrievalConfig, setRetrievalConfig] = useState(localeCurrentDataset?.retrieval_model_dict as RetrievalConfig) @@ -160,17 +160,12 @@ const SettingsModal: FC = ({ } } - const getMembers = async () => { - const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} }) - if (!accounts) + useEffect(() => { + if (!membersData?.accounts) setMemberList([]) else - setMemberList(accounts) - } - - useMount(() => { - getMembers() - }) + setMemberList(membersData.accounts) + }, [membersData]) const showMultiModalTip = useMemo(() => { return checkShowMultiModalTip({ diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx index 5ea799d092..99d042f681 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/retrieval-section.tsx @@ -1,6 +1,6 @@ import { RiCloseLine } from '@remixicon/react' import type { FC } from 'react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Divider from '@/app/components/base/divider' import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' diff --git a/web/app/components/app/configuration/debug/chat-user-input.tsx b/web/app/components/app/configuration/debug/chat-user-input.tsx index 16666d514e..c25bed548c 100644 --- a/web/app/components/app/configuration/debug/chat-user-input.tsx +++ b/web/app/components/app/configuration/debug/chat-user-input.tsx @@ -7,7 +7,7 @@ import Select from '@/app/components/base/select' import Textarea from '@/app/components/base/textarea' import { DEFAULT_VALUE_MAX_LEN } from '@/config' import type { Inputs } from '@/models/debug' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' type Props = { diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx index f76145f901..676456c3ea 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { createRef } from 'react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { type ReactNode, type RefObject, createRef } from 'react' import DebugWithSingleModel from './index' import type { DebugWithSingleModelRefType } from './index' import type { ChatItem } from '@/app/components/base/chat/types' @@ -8,7 +8,8 @@ import type { ProviderContextState } from '@/context/provider-context' import type { DatasetConfigs, ModelConfig } from '@/models/debug' import { PromptMode } from '@/models/debug' import { type Collection, CollectionType } from '@/app/components/tools/types' -import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app' +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app' // ============================================================================ // Test Data Factories (Following testing.md guidelines) @@ -67,21 +68,6 @@ function createMockModelConfig(overrides: Partial = {}): ModelConfi } } -/** - * Factory function for creating mock ChatItem list - * Note: Currently unused but kept for potential future test cases - */ -// eslint-disable-next-line unused-imports/no-unused-vars -function createMockChatList(items: Partial[] = []): ChatItem[] { - return items.map((item, index) => ({ - id: `msg-${index}`, - content: 'Test message', - isAnswer: false, - message_files: [], - ...item, - })) -} - /** * Factory function for creating mock Collection list */ @@ -156,9 +142,9 @@ const mockFetchSuggestedQuestions = jest.fn() const mockStopChatMessageResponding = jest.fn() jest.mock('@/service/debug', () => ({ - fetchConversationMessages: (...args: any[]) => mockFetchConversationMessages(...args), - fetchSuggestedQuestions: (...args: any[]) => mockFetchSuggestedQuestions(...args), - stopChatMessageResponding: (...args: any[]) => mockStopChatMessageResponding(...args), + fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args), + fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args), + stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args), })) jest.mock('next/navigation', () => ({ @@ -255,11 +241,11 @@ const mockDebugConfigContext = { score_threshold: 0.7, datasets: { datasets: [] }, } as DatasetConfigs, - datasetConfigsRef: { current: null } as any, + datasetConfigsRef: createRef(), setDatasetConfigs: jest.fn(), hasSetContextVar: false, isShowVisionConfig: false, - visionConfig: { enabled: false, number_limits: 2, detail: 'low' as any, transfer_methods: [] }, + visionConfig: { enabled: false, number_limits: 2, detail: Resolution.low, transfer_methods: [] }, setVisionConfig: jest.fn(), isAllowVideoUpload: false, isShowDocumentConfig: false, @@ -295,7 +281,19 @@ jest.mock('@/context/app-context', () => ({ useAppContext: jest.fn(() => mockAppContext), })) -const mockFeatures = { +type FeatureState = { + moreLikeThis: { enabled: boolean } + opening: { enabled: boolean; opening_statement: string; suggested_questions: string[] } + moderation: { enabled: boolean } + speech2text: { enabled: boolean } + text2speech: { enabled: boolean } + file: { enabled: boolean } + suggested: { enabled: boolean } + citation: { enabled: boolean } + annotationReply: { enabled: boolean } +} + +const defaultFeatures: FeatureState = { moreLikeThis: { enabled: false }, opening: { enabled: false, opening_statement: '', suggested_questions: [] }, moderation: { enabled: false }, @@ -306,13 +304,11 @@ const mockFeatures = { citation: { enabled: false }, annotationReply: { enabled: false }, } +type FeatureSelector = (state: { features: FeatureState }) => unknown +let mockFeaturesState: FeatureState = { ...defaultFeatures } jest.mock('@/app/components/base/features/hooks', () => ({ - useFeatures: jest.fn((selector) => { - if (typeof selector === 'function') - return selector({ features: mockFeatures }) - return mockFeatures - }), + useFeatures: jest.fn(), })) const mockConfigFromDebugContext = { @@ -345,7 +341,7 @@ jest.mock('../hooks', () => ({ const mockSetShowAppConfigureFeaturesModal = jest.fn() jest.mock('@/app/components/app/store', () => ({ - useStore: jest.fn((selector) => { + useStore: jest.fn((selector?: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => { if (typeof selector === 'function') return selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal }) return mockSetShowAppConfigureFeaturesModal @@ -384,12 +380,31 @@ jest.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ }, })) -// Mock external APIs that might be used -globalThis.ResizeObserver = jest.fn().mockImplementation(() => ({ - observe: jest.fn(), - unobserve: jest.fn(), - disconnect: jest.fn(), -})) +type MockChatProps = { + chatList?: ChatItem[] + isResponding?: boolean + onSend?: (message: string, files?: FileEntity[]) => void + onRegenerate?: (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => void + onStopResponding?: () => void + suggestedQuestions?: string[] + questionIcon?: ReactNode + answerIcon?: ReactNode + onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void + onAnnotationEdited?: (question: string, answer: string, index: number) => void + onAnnotationRemoved?: (index: number) => void + switchSibling?: (siblingMessageId: string) => void + onFeatureBarClick?: (state: boolean) => void +} + +const mockFile: FileEntity = { + id: 'file-1', + name: 'test.png', + size: 123, + type: 'image/png', + progress: 100, + transferMethod: TransferMethod.local_file, + supportFileType: 'image', +} // Mock Chat component (complex with many dependencies) // This is a pragmatic mock that tests the integration at DebugWithSingleModel level @@ -408,11 +423,13 @@ jest.mock('@/app/components/base/chat/chat', () => { onAnnotationRemoved, switchSibling, onFeatureBarClick, - }: any) { + }: MockChatProps) { + const items = chatList || [] + const suggested = suggestedQuestions ?? [] return (
- {chatList?.map((item: any) => ( + {items.map((item: ChatItem) => (
{item.content}
@@ -434,14 +451,21 @@ jest.mock('@/app/components/base/chat/chat', () => { > Send + {isResponding && ( )} - {suggestedQuestions?.length > 0 && ( + {suggested.length > 0 && (
- {suggestedQuestions.map((q: string, i: number) => ( + {suggested.map((q: string, i: number) => ( @@ -451,7 +475,13 @@ jest.mock('@/app/components/base/chat/chat', () => { {onRegenerate && ( @@ -506,12 +536,30 @@ jest.mock('@/app/components/base/chat/chat', () => { // ============================================================================ describe('DebugWithSingleModel', () => { - let ref: React.RefObject + let ref: RefObject beforeEach(() => { jest.clearAllMocks() ref = createRef() + const { useDebugConfigurationContext } = require('@/context/debug-configuration') + const { useProviderContext } = require('@/context/provider-context') + const { useAppContext } = require('@/context/app-context') + const { useConfigFromDebugContext, useFormattingChangedSubscription } = require('../hooks') + const { useFeatures } = require('@/app/components/base/features/hooks') as { useFeatures: jest.Mock } + + useDebugConfigurationContext.mockReturnValue(mockDebugConfigContext) + useProviderContext.mockReturnValue(mockProviderContext) + useAppContext.mockReturnValue(mockAppContext) + useConfigFromDebugContext.mockReturnValue(mockConfigFromDebugContext) + useFormattingChangedSubscription.mockReturnValue(undefined) + mockFeaturesState = { ...defaultFeatures } + useFeatures.mockImplementation((selector?: FeatureSelector) => { + if (typeof selector === 'function') + return selector({ features: mockFeaturesState }) + return mockFeaturesState + }) + // Reset mock implementations mockFetchConversationMessages.mockResolvedValue({ data: [] }) mockFetchSuggestedQuestions.mockResolvedValue({ data: [] }) @@ -521,7 +569,7 @@ describe('DebugWithSingleModel', () => { // Rendering Tests describe('Rendering', () => { it('should render without crashing', () => { - render(} />) + render(} />) // Verify Chat component is rendered expect(screen.getByTestId('chat-component')).toBeInTheDocument() @@ -532,7 +580,7 @@ describe('DebugWithSingleModel', () => { it('should render with custom checkCanSend prop', () => { const checkCanSend = jest.fn(() => true) - render(} checkCanSend={checkCanSend} />) + render(} checkCanSend={checkCanSend} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -543,122 +591,88 @@ describe('DebugWithSingleModel', () => { it('should respect checkCanSend returning true', async () => { const checkCanSend = jest.fn(() => true) - render(} checkCanSend={checkCanSend} />) + render(} checkCanSend={checkCanSend} />) const sendButton = screen.getByTestId('send-button') fireEvent.click(sendButton) + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } await waitFor(() => { expect(checkCanSend).toHaveBeenCalled() + expect(ssePost).toHaveBeenCalled() }) + + expect(ssePost.mock.calls[0][0]).toBe('apps/test-app-id/chat-messages') }) it('should prevent send when checkCanSend returns false', async () => { const checkCanSend = jest.fn(() => false) - render(} checkCanSend={checkCanSend} />) + render(} checkCanSend={checkCanSend} />) const sendButton = screen.getByTestId('send-button') fireEvent.click(sendButton) + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } await waitFor(() => { expect(checkCanSend).toHaveBeenCalled() expect(checkCanSend).toHaveReturnedWith(false) }) + expect(ssePost).not.toHaveBeenCalled() }) }) - // Context Integration Tests - describe('Context Integration', () => { - it('should use debug configuration context', () => { - const { useDebugConfigurationContext } = require('@/context/debug-configuration') + // User Interactions + describe('User Interactions', () => { + it('should open feature configuration when feature bar is clicked', () => { + render(} />) - render(} />) + fireEvent.click(screen.getByTestId('feature-bar-button')) - expect(useDebugConfigurationContext).toHaveBeenCalled() - }) - - it('should use provider context for model list', () => { - const { useProviderContext } = require('@/context/provider-context') - - render(} />) - - expect(useProviderContext).toHaveBeenCalled() - }) - - it('should use app context for user profile', () => { - const { useAppContext } = require('@/context/app-context') - - render(} />) - - expect(useAppContext).toHaveBeenCalled() - }) - - it('should use features from features hook', () => { - const { useFeatures } = require('@/app/components/base/features/hooks') - - render(} />) - - expect(useFeatures).toHaveBeenCalled() - }) - - it('should use config from debug context hook', () => { - const { useConfigFromDebugContext } = require('../hooks') - - render(} />) - - expect(useConfigFromDebugContext).toHaveBeenCalled() - }) - - it('should subscribe to formatting changes', () => { - const { useFormattingChangedSubscription } = require('../hooks') - - render(} />) - - expect(useFormattingChangedSubscription).toHaveBeenCalled() + expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true) }) }) // Model Configuration Tests describe('Model Configuration', () => { - it('should merge features into config correctly when all features enabled', () => { - const { useFeatures } = require('@/app/components/base/features/hooks') + it('should include opening features in request when enabled', async () => { + mockFeaturesState = { + ...defaultFeatures, + opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] }, + } - useFeatures.mockReturnValue((selector: any) => { - const features = { - moreLikeThis: { enabled: true }, - opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] }, - moderation: { enabled: true }, - speech2text: { enabled: true }, - text2speech: { enabled: true }, - file: { enabled: true }, - suggested: { enabled: true }, - citation: { enabled: true }, - annotationReply: { enabled: true }, - } - return typeof selector === 'function' ? selector({ features }) : features + render(} />) + + fireEvent.click(screen.getByTestId('send-button')) + + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } + await waitFor(() => { + expect(ssePost).toHaveBeenCalled() }) - render(} />) - - expect(screen.getByTestId('chat-component')).toBeInTheDocument() + const body = ssePost.mock.calls[0][1].body + expect(body.model_config.opening_statement).toBe('Hello!') + expect(body.model_config.suggested_questions).toEqual(['Q1']) }) - it('should handle opening feature disabled correctly', () => { - const { useFeatures } = require('@/app/components/base/features/hooks') + it('should omit opening statement when feature is disabled', async () => { + mockFeaturesState = { + ...defaultFeatures, + opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] }, + } - useFeatures.mockReturnValue((selector: any) => { - const features = { - ...mockFeatures, - opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] }, - } - return typeof selector === 'function' ? selector({ features }) : features + render(} />) + + fireEvent.click(screen.getByTestId('send-button')) + + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } + await waitFor(() => { + expect(ssePost).toHaveBeenCalled() }) - render(} />) - - // When opening is disabled, opening_statement should be empty - expect(screen.queryByText('Should not appear')).not.toBeInTheDocument() + const body = ssePost.mock.calls[0][1].body + expect(body.model_config.opening_statement).toBe('') + expect(body.model_config.suggested_questions).toEqual([]) }) it('should handle model without vision support', () => { @@ -689,7 +703,7 @@ describe('DebugWithSingleModel', () => { ], })) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -710,7 +724,7 @@ describe('DebugWithSingleModel', () => { ], })) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -735,7 +749,7 @@ describe('DebugWithSingleModel', () => { }), }) - render(} />) + render(} />) // Component should render successfully with filtered variables expect(screen.getByTestId('chat-component')).toBeInTheDocument() @@ -754,7 +768,7 @@ describe('DebugWithSingleModel', () => { }), }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -763,7 +777,7 @@ describe('DebugWithSingleModel', () => { // Tool Icons Tests describe('Tool Icons', () => { it('should map tool icons from collection list', () => { - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -783,7 +797,7 @@ describe('DebugWithSingleModel', () => { }), }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -812,7 +826,7 @@ describe('DebugWithSingleModel', () => { collectionList: [], }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -828,7 +842,7 @@ describe('DebugWithSingleModel', () => { inputs: {}, }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -846,7 +860,7 @@ describe('DebugWithSingleModel', () => { }, }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -859,7 +873,7 @@ describe('DebugWithSingleModel', () => { completionParams: {}, }) - render(} />) + render(} />) expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) @@ -868,7 +882,7 @@ describe('DebugWithSingleModel', () => { // Imperative Handle Tests describe('Imperative Handle', () => { it('should expose handleRestart method via ref', () => { - render(} />) + render(} />) expect(ref.current).not.toBeNull() expect(ref.current?.handleRestart).toBeDefined() @@ -876,65 +890,26 @@ describe('DebugWithSingleModel', () => { }) it('should call handleRestart when invoked via ref', () => { - render(} />) + render(} />) - expect(() => { + act(() => { ref.current?.handleRestart() - }).not.toThrow() - }) - }) - - // Memory and Performance Tests - describe('Memory and Performance', () => { - it('should properly memoize component', () => { - const { rerender } = render(} />) - - // Re-render with same props - rerender(} />) - - expect(screen.getByTestId('chat-component')).toBeInTheDocument() - }) - - it('should have displayName set for debugging', () => { - expect(DebugWithSingleModel).toBeDefined() - // memo wraps the component - expect(typeof DebugWithSingleModel).toBe('object') - }) - }) - - // Async Operations Tests - describe('Async Operations', () => { - it('should handle API calls during message send', async () => { - mockFetchConversationMessages.mockResolvedValue({ data: [] }) - - render(} />) - - const textarea = screen.getByRole('textbox', { hidden: true }) - fireEvent.change(textarea, { target: { value: 'Test message' } }) - - // Component should render without errors during async operations - await waitFor(() => { - expect(screen.getByTestId('chat-component')).toBeInTheDocument() - }) - }) - - it('should handle API errors gracefully', async () => { - mockFetchConversationMessages.mockRejectedValue(new Error('API Error')) - - render(} />) - - // Component should still render even if API calls fail - await waitFor(() => { - expect(screen.getByTestId('chat-component')).toBeInTheDocument() }) }) }) // File Upload Tests describe('File Upload', () => { - it('should not include files when vision is not supported', () => { + it('should not include files when vision is not supported', async () => { + const { useDebugConfigurationContext } = require('@/context/debug-configuration') const { useProviderContext } = require('@/context/provider-context') - const { useFeatures } = require('@/app/components/base/features/hooks') + + useDebugConfigurationContext.mockReturnValue({ + ...mockDebugConfigContext, + modelConfig: createMockModelConfig({ + model_id: 'gpt-3.5-turbo', + }), + }) useProviderContext.mockReturnValue(createMockProviderContext({ textGenerationModelList: [ @@ -961,23 +936,34 @@ describe('DebugWithSingleModel', () => { ], })) - useFeatures.mockReturnValue((selector: any) => { - const features = { - ...mockFeatures, - file: { enabled: true }, // File upload enabled - } - return typeof selector === 'function' ? selector({ features }) : features + mockFeaturesState = { + ...defaultFeatures, + file: { enabled: true }, + } + + render(} />) + + fireEvent.click(screen.getByTestId('send-with-files')) + + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } + await waitFor(() => { + expect(ssePost).toHaveBeenCalled() }) - render(} />) - - // Should render but not allow file uploads - expect(screen.getByTestId('chat-component')).toBeInTheDocument() + const body = ssePost.mock.calls[0][1].body + expect(body.files).toEqual([]) }) - it('should support files when vision is enabled', () => { + it('should support files when vision is enabled', async () => { + const { useDebugConfigurationContext } = require('@/context/debug-configuration') const { useProviderContext } = require('@/context/provider-context') - const { useFeatures } = require('@/app/components/base/features/hooks') + + useDebugConfigurationContext.mockReturnValue({ + ...mockDebugConfigContext, + modelConfig: createMockModelConfig({ + model_id: 'gpt-4-vision', + }), + }) useProviderContext.mockReturnValue(createMockProviderContext({ textGenerationModelList: [ @@ -1004,17 +990,22 @@ describe('DebugWithSingleModel', () => { ], })) - useFeatures.mockReturnValue((selector: any) => { - const features = { - ...mockFeatures, - file: { enabled: true }, - } - return typeof selector === 'function' ? selector({ features }) : features + mockFeaturesState = { + ...defaultFeatures, + file: { enabled: true }, + } + + render(} />) + + fireEvent.click(screen.getByTestId('send-with-files')) + + const { ssePost } = require('@/service/base') as { ssePost: jest.Mock } + await waitFor(() => { + expect(ssePost).toHaveBeenCalled() }) - render(} />) - - expect(screen.getByTestId('chat-component')).toBeInTheDocument() + const body = ssePost.mock.calls[0][1].body + expect(body.files).toHaveLength(1) }) }) }) diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index 2537062e13..4da12319f2 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import useSWR from 'swr' import { basePath } from '@/utils/var' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -72,7 +71,7 @@ import type { Features as FeaturesData, FileUpload } from '@/app/components/base import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import NewFeaturePanel from '@/app/components/base/features/new-feature-panel' -import { fetchFileUploadConfig } from '@/service/common' +import { useFileUploadConfig } from '@/service/use-common' import { correctModelProvider, correctToolProvider, @@ -101,7 +100,7 @@ const Configuration: FC = () => { showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal, }))) - const { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig) + const { data: fileUploadConfigResponse } = useFileUploadConfig() const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail]) const [formattingChanged, setFormattingChanged] = useState(false) diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index 005f7f938f..9874664443 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -21,7 +21,7 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature import type { VisionFile, VisionSettings } from '@/types/app' import { DEFAULT_VALUE_MAX_LEN } from '@/config' import { useStore as useAppStore } from '@/app/components/app/store' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' export type IPromptValuePanelProps = { diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 990e679c79..6f177643ae 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -1,6 +1,5 @@ import type { FC } from 'react' import { useState } from 'react' -import useSWR from 'swr' import { useContext } from 'use-context-selector' import { useTranslation } from 'react-i18next' import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation' @@ -9,7 +8,6 @@ import Button from '@/app/components/base/button' import EmojiPicker from '@/app/components/base/emoji-picker' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' -import { fetchCodeBasedExtensionList } from '@/service/common' import { SimpleSelect } from '@/app/components/base/select' import I18n from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' @@ -21,6 +19,7 @@ import { useToastContext } from '@/app/components/base/toast' import AppIcon from '@/app/components/base/app-icon' import { noop } from 'lodash-es' import { useDocLink } from '@/context/i18n' +import { useCodeBasedExtensions } from '@/service/use-common' const systemTypes = ['api'] type ExternalDataToolModalProps = { @@ -46,10 +45,7 @@ const ExternalDataToolModal: FC = ({ const { locale } = useContext(I18n) const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' }) const [showEmojiPicker, setShowEmojiPicker] = useState(false) - const { data: codeBasedExtensionList } = useSWR( - '/code-based-extension?module=external_data_tool', - fetchCodeBasedExtensionList, - ) + const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool') const providers: Provider[] = [ { diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index a3bf91cb5d..df35a74ec7 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { PlusIcon } from '@heroicons/react/20/solid' import { AppTypeIcon, AppTypeLabel } from '../../type-selector' import Button from '@/app/components/base/button' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import type { App } from '@/models/explore' import AppIcon from '@/app/components/base/app-icon' diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 51b6874d52..4655d7a676 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -11,7 +11,7 @@ import AppCard from '../app-card' import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar' import Toast from '@/app/components/base/toast' import Divider from '@/app/components/base/divider' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import ExploreContext from '@/context/explore-context' import type { App } from '@/models/explore' import { fetchAppDetail, fetchAppList } from '@/service/explore' diff --git a/web/app/components/app/create-app-dialog/app-list/sidebar.tsx b/web/app/components/app/create-app-dialog/app-list/sidebar.tsx index 85c55c5385..89062cdcf9 100644 --- a/web/app/components/app/create-app-dialog/app-list/sidebar.tsx +++ b/web/app/components/app/create-app-dialog/app-list/sidebar.tsx @@ -1,7 +1,7 @@ 'use client' import { RiStickyNoteAddLine, RiThumbUpLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import classNames from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Divider from '@/app/components/base/divider' export enum AppCategories { @@ -40,13 +40,13 @@ type CategoryItemProps = { } function CategoryItem({ category, active, onClick }: CategoryItemProps) { return
  • { onClick?.(category) }}> {category === AppCategories.RECOMMENDED &&
    } + className={cn('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} />
  • } diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index a449ec8ef2..d74715187f 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -13,7 +13,7 @@ import AppIconPicker from '../../base/app-icon-picker' import type { AppIconSelection } from '../../base/app-icon-picker' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { basePath } from '@/utils/var' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 3564738dfd..0d30a2abac 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -25,7 +25,7 @@ import { useProviderContext } from '@/context/provider-context' import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { getRedirection } from '@/utils/app-redirection' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { noop } from 'lodash-es' import { trackEvent } from '@/app/components/base/amplitude' diff --git a/web/app/components/app/create-from-dsl-modal/uploader.tsx b/web/app/components/app/create-from-dsl-modal/uploader.tsx index b6644da5a4..2745ca84c6 100644 --- a/web/app/components/app/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/app/create-from-dsl-modal/uploader.tsx @@ -8,7 +8,7 @@ import { import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import { formatFileSize } from '@/utils/format' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files' import { ToastContext } from '@/app/components/base/toast' import ActionButton from '@/app/components/base/action-button' diff --git a/web/app/components/app/duplicate-modal/index.spec.tsx b/web/app/components/app/duplicate-modal/index.spec.tsx new file mode 100644 index 0000000000..2d73addeab --- /dev/null +++ b/web/app/components/app/duplicate-modal/index.spec.tsx @@ -0,0 +1,167 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DuplicateAppModal from './index' +import Toast from '@/app/components/base/toast' +import type { ProviderContextState } from '@/context/provider-context' +import { baseProviderContextValue } from '@/context/provider-context' +import { Plan } from '@/app/components/billing/type' + +const appsFullRenderSpy = jest.fn() +jest.mock('@/app/components/billing/apps-full-in-dialog', () => ({ + __esModule: true, + default: ({ loc }: { loc: string }) => { + appsFullRenderSpy(loc) + return
    AppsFull
    + }, +})) + +const useProviderContextMock = jest.fn() +jest.mock('@/context/provider-context', () => { + const actual = jest.requireActual('@/context/provider-context') + return { + ...actual, + useProviderContext: () => useProviderContextMock(), + } +}) + +const renderComponent = (overrides: Partial> = {}) => { + const onConfirm = jest.fn().mockResolvedValue(undefined) + const onHide = jest.fn() + const props: React.ComponentProps = { + appName: 'My App', + icon_type: 'emoji', + icon: '🚀', + icon_background: '#FFEAD5', + icon_url: null, + show: true, + onConfirm, + onHide, + ...overrides, + } + const utils = render() + return { + ...utils, + onConfirm, + onHide, + } +} + +const setupProviderContext = (overrides: Partial = {}) => { + useProviderContextMock.mockReturnValue({ + ...baseProviderContextValue, + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: { + ...baseProviderContextValue.plan.usage, + buildApps: 0, + }, + total: { + ...baseProviderContextValue.plan.total, + buildApps: 10, + }, + }, + enableBilling: false, + ...overrides, + } as ProviderContextState) +} + +describe('DuplicateAppModal', () => { + beforeEach(() => { + jest.clearAllMocks() + setupProviderContext() + }) + + // Rendering output based on modal visibility. + describe('Rendering', () => { + it('should render modal content when show is true', () => { + // Arrange + renderComponent() + + // Assert + expect(screen.getByText('app.duplicateTitle')).toBeInTheDocument() + expect(screen.getByDisplayValue('My App')).toBeInTheDocument() + }) + + it('should not render modal content when show is false', () => { + // Arrange + renderComponent({ show: false }) + + // Assert + expect(screen.queryByText('app.duplicateTitle')).not.toBeInTheDocument() + }) + }) + + // Prop-driven states such as full plan handling. + describe('Props', () => { + it('should disable duplicate button and show apps full content when plan is full', () => { + // Arrange + setupProviderContext({ + enableBilling: true, + plan: { + ...baseProviderContextValue.plan, + type: Plan.sandbox, + usage: { ...baseProviderContextValue.plan.usage, buildApps: 10 }, + total: { ...baseProviderContextValue.plan.total, buildApps: 10 }, + }, + }) + renderComponent() + + // Assert + expect(screen.getByTestId('apps-full')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'app.duplicate' })).toBeDisabled() + }) + }) + + // User interactions for cancel and confirm flows. + describe('Interactions', () => { + it('should call onHide when cancel is clicked', async () => { + const user = userEvent.setup() + // Arrange + const { onHide } = renderComponent() + + // Act + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + // Assert + expect(onHide).toHaveBeenCalledTimes(1) + }) + + it('should show error toast when name is empty', async () => { + const user = userEvent.setup() + const toastSpy = jest.spyOn(Toast, 'notify') + // Arrange + const { onConfirm, onHide } = renderComponent() + + // Act + await user.clear(screen.getByDisplayValue('My App')) + await user.click(screen.getByRole('button', { name: 'app.duplicate' })) + + // Assert + expect(toastSpy).toHaveBeenCalledWith({ type: 'error', message: 'explore.appCustomize.nameRequired' }) + expect(onConfirm).not.toHaveBeenCalled() + expect(onHide).not.toHaveBeenCalled() + }) + + it('should submit app info and hide modal when duplicate is clicked', async () => { + const user = userEvent.setup() + // Arrange + const { onConfirm, onHide } = renderComponent() + + // Act + await user.clear(screen.getByDisplayValue('My App')) + await user.type(screen.getByRole('textbox'), 'New App') + await user.click(screen.getByRole('button', { name: 'app.duplicate' })) + + // Assert + expect(onConfirm).toHaveBeenCalledWith({ + name: 'New App', + icon_type: 'emoji', + icon: '🚀', + icon_background: '#FFEAD5', + }) + expect(onHide).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index f98fb831ed..f25eb5373d 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { RiCloseLine } from '@remixicon/react' import AppIconPicker from '../../base/app-icon-picker' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Modal from '@/app/components/base/modal' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' diff --git a/web/app/components/app/log-annotation/index.tsx b/web/app/components/app/log-annotation/index.tsx index c0b0854b29..e7c2be3eed 100644 --- a/web/app/components/app/log-annotation/index.tsx +++ b/web/app/components/app/log-annotation/index.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useRouter } from 'next/navigation' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Log from '@/app/components/app/log' import WorkflowLog from '@/app/components/app/workflow-log' import Annotation from '@/app/components/app/annotation' diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 0ff375d815..e479cbe881 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -39,7 +39,7 @@ import Tooltip from '@/app/components/base/tooltip' import CopyIcon from '@/app/components/base/copy-icon' import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils' import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { noop } from 'lodash-es' import PromptLogModal from '../../base/prompt-log-modal' import { WorkflowContextProvider } from '@/app/components/workflow/context' diff --git a/web/app/components/app/log/model-info.tsx b/web/app/components/app/log/model-info.tsx index 626ef093e9..b3c4f11be5 100644 --- a/web/app/components/app/log/model-info.tsx +++ b/web/app/components/app/log/model-info.tsx @@ -13,7 +13,7 @@ import { PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' const PARAM_MAP = { temperature: 'Temperature', diff --git a/web/app/components/app/log/var-panel.tsx b/web/app/components/app/log/var-panel.tsx index dd8c231a56..8915b3438a 100644 --- a/web/app/components/app/log/var-panel.tsx +++ b/web/app/components/app/log/var-panel.tsx @@ -9,7 +9,7 @@ import { } from '@remixicon/react' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import ImagePreview from '@/app/components/base/image-uploader/image-preview' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' type Props = { varList: { label: string; value: string }[] diff --git a/web/app/components/app/overview/apikey-info-panel/index.tsx b/web/app/components/app/overview/apikey-info-panel/index.tsx index b50b0077cb..47fe7af972 100644 --- a/web/app/components/app/overview/apikey-info-panel/index.tsx +++ b/web/app/components/app/overview/apikey-info-panel/index.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { RiCloseLine } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Button from '@/app/components/base/button' import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general' import { IS_CE_EDITION } from '@/config' diff --git a/web/app/components/app/overview/embedded/index.tsx b/web/app/components/app/overview/embedded/index.tsx index 6eba993e1d..d4be58b1b2 100644 --- a/web/app/components/app/overview/embedded/index.tsx +++ b/web/app/components/app/overview/embedded/index.tsx @@ -14,7 +14,7 @@ import type { SiteInfo } from '@/models/share' import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' import ActionButton from '@/app/components/base/action-button' import { basePath } from '@/utils/var' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' type Props = { siteInfo?: SiteInfo diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index 3b71b8f75c..d079631cf7 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -25,7 +25,7 @@ import { useModalContext } from '@/context/modal-context' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import AppIconPicker from '@/app/components/base/app-icon-picker' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { useDocLink } from '@/context/i18n' export type ISettingsModalProps = { diff --git a/web/app/components/app/switch-app-modal/index.spec.tsx b/web/app/components/app/switch-app-modal/index.spec.tsx new file mode 100644 index 0000000000..b6fe838666 --- /dev/null +++ b/web/app/components/app/switch-app-modal/index.spec.tsx @@ -0,0 +1,295 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import SwitchAppModal from './index' +import { ToastContext } from '@/app/components/base/toast' +import type { App } from '@/types/app' +import { AppModeEnum } from '@/types/app' +import { Plan } from '@/app/components/billing/type' +import { NEED_REFRESH_APP_LIST_KEY } from '@/config' + +const mockPush = jest.fn() +const mockReplace = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + }), +})) + +const mockSetAppDetail = jest.fn() +jest.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: any) => unknown) => selector({ setAppDetail: mockSetAppDetail }), +})) + +const mockSwitchApp = jest.fn() +const mockDeleteApp = jest.fn() +jest.mock('@/service/apps', () => ({ + switchApp: (...args: unknown[]) => mockSwitchApp(...args), + deleteApp: (...args: unknown[]) => mockDeleteApp(...args), +})) + +let mockIsEditor = true +jest.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceEditor: mockIsEditor, + userProfile: { + email: 'user@example.com', + }, + langGeniusVersionInfo: { + current_version: '1.0.0', + }, + }), +})) + +let mockEnableBilling = false +let mockPlan = { + type: Plan.sandbox, + usage: { + buildApps: 0, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, + }, + total: { + buildApps: 10, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, + }, +} +jest.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: mockPlan, + enableBilling: mockEnableBilling, + }), +})) + +jest.mock('@/app/components/billing/apps-full-in-dialog', () => ({ + __esModule: true, + default: ({ loc }: { loc: string }) =>
    AppsFull {loc}
    , +})) + +const createMockApp = (overrides: Partial = {}): App => ({ + id: 'app-123', + name: 'Demo App', + description: 'Demo description', + author_name: 'Demo author', + icon_type: 'emoji', + icon: '🚀', + icon_background: '#FFEAD5', + icon_url: null, + use_icon_as_answer_icon: false, + mode: AppModeEnum.COMPLETION, + enable_site: true, + enable_api: true, + api_rpm: 60, + api_rph: 3600, + is_demo: false, + model_config: {} as App['model_config'], + app_model_config: {} as App['app_model_config'], + created_at: Date.now(), + updated_at: Date.now(), + site: { + access_token: 'token', + app_base_url: 'https://example.com', + } as App['site'], + api_base_url: 'https://api.example.com', + tags: [], + access_mode: 'public_access' as App['access_mode'], + ...overrides, +}) + +const renderComponent = (overrides: Partial> = {}) => { + const notify = jest.fn() + const onClose = jest.fn() + const onSuccess = jest.fn() + const appDetail = createMockApp() + + const utils = render( + + + , + ) + + return { + ...utils, + notify, + onClose, + onSuccess, + appDetail, + } +} + +describe('SwitchAppModal', () => { + beforeEach(() => { + jest.clearAllMocks() + mockIsEditor = true + mockEnableBilling = false + mockPlan = { + type: Plan.sandbox, + usage: { + buildApps: 0, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, + }, + total: { + buildApps: 10, + teamMembers: 0, + annotatedResponse: 0, + documentsUploadQuota: 0, + apiRateLimit: 0, + triggerEvents: 0, + vectorSpace: 0, + }, + } + }) + + // Rendering behavior for modal visibility and default values. + describe('Rendering', () => { + it('should render modal content when show is true', () => { + // Arrange + renderComponent() + + // Assert + expect(screen.getByText('app.switch')).toBeInTheDocument() + expect(screen.getByDisplayValue('Demo App(copy)')).toBeInTheDocument() + }) + + it('should not render modal content when show is false', () => { + // Arrange + renderComponent({ show: false }) + + // Assert + expect(screen.queryByText('app.switch')).not.toBeInTheDocument() + }) + }) + + // Prop-driven UI states such as disabling actions. + describe('Props', () => { + it('should disable the start button when name is empty', async () => { + const user = userEvent.setup() + // Arrange + renderComponent() + + // Act + const nameInput = screen.getByDisplayValue('Demo App(copy)') + await user.clear(nameInput) + + // Assert + expect(screen.getByRole('button', { name: 'app.switchStart' })).toBeDisabled() + }) + + it('should render the apps full warning when plan limits are reached', () => { + // Arrange + mockEnableBilling = true + mockPlan = { + ...mockPlan, + usage: { ...mockPlan.usage, buildApps: 10 }, + total: { ...mockPlan.total, buildApps: 10 }, + } + renderComponent() + + // Assert + expect(screen.getByTestId('apps-full')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'app.switchStart' })).toBeDisabled() + }) + }) + + // User interactions that trigger navigation and API calls. + describe('Interactions', () => { + it('should call onClose when cancel is clicked', async () => { + const user = userEvent.setup() + // Arrange + const { onClose } = renderComponent() + + // Act + await user.click(screen.getByRole('button', { name: 'app.newApp.Cancel' })) + + // Assert + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should switch app and navigate with push when keeping original', async () => { + const user = userEvent.setup() + // Arrange + const { appDetail, notify, onClose, onSuccess } = renderComponent() + mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-001' }) + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem') + + // Act + await user.click(screen.getByRole('button', { name: 'app.switchStart' })) + + // Assert + await waitFor(() => { + expect(mockSwitchApp).toHaveBeenCalledWith({ + appID: appDetail.id, + name: 'Demo App(copy)', + icon_type: 'emoji', + icon: '🚀', + icon_background: '#FFEAD5', + }) + }) + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + expect(notify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' }) + expect(setItemSpy).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1') + expect(mockPush).toHaveBeenCalledWith('/app/new-app-001/workflow') + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('should delete the original app and use replace when remove original is confirmed', async () => { + const user = userEvent.setup() + // Arrange + const { appDetail } = renderComponent({ inAppDetail: true }) + mockSwitchApp.mockResolvedValueOnce({ new_app_id: 'new-app-002' }) + + // Act + await user.click(screen.getByText('app.removeOriginal')) + const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' }) + await user.click(confirmButton) + await user.click(screen.getByRole('button', { name: 'app.switchStart' })) + + // Assert + await waitFor(() => { + expect(mockDeleteApp).toHaveBeenCalledWith(appDetail.id) + }) + expect(mockReplace).toHaveBeenCalledWith('/app/new-app-002/workflow') + expect(mockPush).not.toHaveBeenCalled() + expect(mockSetAppDetail).toHaveBeenCalledTimes(1) + }) + + it('should notify error when switch app fails', async () => { + const user = userEvent.setup() + // Arrange + const { notify, onClose, onSuccess } = renderComponent() + mockSwitchApp.mockRejectedValueOnce(new Error('fail')) + + // Act + await user.click(screen.getByRole('button', { name: 'app.switchStart' })) + + // Assert + await waitFor(() => { + expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'app.newApp.appCreateFailed' }) + }) + expect(onClose).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index a7e1cea429..742212a44d 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -6,7 +6,7 @@ import { useContext } from 'use-context-selector' import { useTranslation } from 'react-i18next' import { RiCloseLine } from '@remixicon/react' import AppIconPicker from '../../base/app-icon-picker' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import Checkbox from '@/app/components/base/checkbox' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index 92d86351e0..d284ecd46e 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -30,7 +30,7 @@ import type { SiteInfo } from '@/models/share' import { useChatContext } from '@/app/components/base/chat/chat/context' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import NewAudioButton from '@/app/components/base/new-audio-button' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' const MAX_DEPTH = 3 diff --git a/web/app/components/app/text-generate/saved-items/index.tsx b/web/app/components/app/text-generate/saved-items/index.tsx index c22a4ca6c2..e6cf264cf2 100644 --- a/web/app/components/app/text-generate/saved-items/index.tsx +++ b/web/app/components/app/text-generate/saved-items/index.tsx @@ -8,7 +8,7 @@ import { import { useTranslation } from 'react-i18next' import copy from 'copy-to-clipboard' import NoData from './no-data' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import type { SavedMessage } from '@/models/debug' import { Markdown } from '@/app/components/base/markdown' import Toast from '@/app/components/base/toast' diff --git a/web/app/components/app/type-selector/index.tsx b/web/app/components/app/type-selector/index.tsx index 7be2351119..f213a89a94 100644 --- a/web/app/components/app/type-selector/index.tsx +++ b/web/app/components/app/type-selector/index.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next' import React, { useState } from 'react' import { RiArrowDownSLine, RiCloseCircleFill, RiExchange2Fill, RiFilter3Line } from '@remixicon/react' import Checkbox from '../../base/checkbox' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { PortalToFollowElem, PortalToFollowElemContent, diff --git a/web/app/components/app/workflow-log/filter.spec.tsx b/web/app/components/app/workflow-log/filter.spec.tsx index d7bec41224..04216e5cc8 100644 --- a/web/app/components/app/workflow-log/filter.spec.tsx +++ b/web/app/components/app/workflow-log/filter.spec.tsx @@ -7,6 +7,7 @@ * - Keyword search */ +import { useState } from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Filter, { TIME_PERIOD_MAPPING } from './filter' @@ -293,12 +294,21 @@ describe('Filter', () => { const user = userEvent.setup() const setQueryParams = jest.fn() - render( - , - ) + const Wrapper = () => { + const [queryParams, updateQueryParams] = useState(createDefaultQueryParams()) + const handleSetQueryParams = (next: QueryParam) => { + updateQueryParams(next) + setQueryParams(next) + } + return ( + + ) + } + + render() const input = screen.getByPlaceholderText('common.operation.search') await user.type(input, 'workflow') diff --git a/web/app/components/app/workflow-log/filter.tsx b/web/app/components/app/workflow-log/filter.tsx index 0c8d72c1be..a4db4c9642 100644 --- a/web/app/components/app/workflow-log/filter.tsx +++ b/web/app/components/app/workflow-log/filter.tsx @@ -65,7 +65,7 @@ const Filter: FC = ({ queryParams, setQueryParams }: IFilterProps) wrapperClassName='w-[200px]' showLeftIcon showClearIcon - value={queryParams.keyword} + value={queryParams.keyword ?? ''} placeholder={t('common.operation.search')!} onChange={(e) => { setQueryParams({ ...queryParams, keyword: e.target.value }) diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index 0e9b5dd67f..cef8a98f44 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -12,7 +12,7 @@ import Drawer from '@/app/components/base/drawer' import Indicator from '@/app/components/header/indicator' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import type { WorkflowRunTriggeredFrom } from '@/models/log' type ILogs = { diff --git a/web/app/components/apps/app-card.spec.tsx b/web/app/components/apps/app-card.spec.tsx index 40aa66075d..f7ff525ed2 100644 --- a/web/app/components/apps/app-card.spec.tsx +++ b/web/app/components/apps/app-card.spec.tsx @@ -42,11 +42,12 @@ jest.mock('@/context/provider-context', () => ({ }), })) -// Mock global public store +// Mock global public store - allow dynamic configuration +let mockWebappAuthEnabled = false jest.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: (selector: (s: any) => any) => selector({ systemFeatures: { - webapp_auth: { enabled: false }, + webapp_auth: { enabled: mockWebappAuthEnabled }, branding: { enabled: false }, }, }), @@ -79,8 +80,9 @@ jest.mock('@/service/access-control', () => ({ })) // Mock hooks +const mockOpenAsyncWindow = jest.fn() jest.mock('@/hooks/use-async-window-open', () => ({ - useAsyncWindowOpen: () => jest.fn(), + useAsyncWindowOpen: () => mockOpenAsyncWindow, })) // Mock utils @@ -178,21 +180,10 @@ jest.mock('next/dynamic', () => { } }) -/** - * Mock components that require special handling in test environment. - * - * Per frontend testing skills (mocking.md), we should NOT mock simple base components. - * However, the following require mocking due to: - * - Portal-based rendering that doesn't work well in happy-dom - * - Deep dependency chains importing ES modules (like ky) incompatible with Jest - * - Complex state management that requires controlled test behavior - */ - -// Popover uses portals for positioning which requires mocking in happy-dom environment +// Popover uses @headlessui/react portals - mock for controlled interaction testing jest.mock('@/app/components/base/popover', () => { const MockPopover = ({ htmlContent, btnElement, btnClassName }: any) => { const [isOpen, setIsOpen] = React.useState(false) - // Call btnClassName to cover lines 430-433 const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : '' return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', { @@ -210,13 +201,13 @@ jest.mock('@/app/components/base/popover', () => { return { __esModule: true, default: MockPopover } }) -// Tooltip uses portals for positioning - minimal mock preserving popup content as title attribute +// Tooltip uses portals - minimal mock preserving popup content as title attribute jest.mock('@/app/components/base/tooltip', () => ({ __esModule: true, default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children), })) -// TagSelector imports service/tag which depends on ky ES module - mock to avoid Jest ES module issues +// TagSelector has API dependency (service/tag) - mock for isolated testing jest.mock('@/app/components/base/tag-management/selector', () => ({ __esModule: true, default: ({ tags }: any) => { @@ -227,7 +218,7 @@ jest.mock('@/app/components/base/tag-management/selector', () => ({ }, })) -// AppTypeIcon has complex icon mapping logic - mock for focused component testing +// AppTypeIcon has complex icon mapping - mock for focused component testing jest.mock('@/app/components/app/type-selector', () => ({ AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }), })) @@ -278,6 +269,8 @@ describe('AppCard', () => { beforeEach(() => { jest.clearAllMocks() + mockOpenAsyncWindow.mockReset() + mockWebappAuthEnabled = false }) describe('Rendering', () => { @@ -536,6 +529,46 @@ describe('AppCard', () => { expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument() }) }) + + it('should close edit modal when onHide is called', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.editApp')) + }) + + await waitFor(() => { + expect(screen.getByTestId('edit-app-modal')).toBeInTheDocument() + }) + + // Click close button to trigger onHide + fireEvent.click(screen.getByTestId('close-edit-modal')) + + await waitFor(() => { + expect(screen.queryByTestId('edit-app-modal')).not.toBeInTheDocument() + }) + }) + + it('should close duplicate modal when onHide is called', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.duplicate')) + }) + + await waitFor(() => { + expect(screen.getByTestId('duplicate-modal')).toBeInTheDocument() + }) + + // Click close button to trigger onHide + fireEvent.click(screen.getByTestId('close-duplicate-modal')) + + await waitFor(() => { + expect(screen.queryByTestId('duplicate-modal')).not.toBeInTheDocument() + }) + }) }) describe('Styling', () => { @@ -852,6 +885,31 @@ describe('AppCard', () => { expect(workflowService.fetchWorkflowDraft).toHaveBeenCalled() }) }) + + it('should close DSL export modal when onClose is called', async () => { + (workflowService.fetchWorkflowDraft as jest.Mock).mockResolvedValueOnce({ + environment_variables: [{ value_type: 'secret', name: 'API_KEY' }], + }) + + const workflowApp = { ...mockApp, mode: AppModeEnum.WORKFLOW } + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.export')) + }) + + await waitFor(() => { + expect(screen.getByTestId('dsl-export-modal')).toBeInTheDocument() + }) + + // Click close button to trigger onClose + fireEvent.click(screen.getByTestId('close-dsl-export')) + + await waitFor(() => { + expect(screen.queryByTestId('dsl-export-modal')).not.toBeInTheDocument() + }) + }) }) describe('Edge Cases', () => { @@ -1054,6 +1112,276 @@ describe('AppCard', () => { const tagSelector = screen.getByLabelText('tag-selector') expect(tagSelector).toBeInTheDocument() + + // Click on tag selector wrapper to trigger stopPropagation + const tagSelectorWrapper = tagSelector.closest('div') + if (tagSelectorWrapper) + fireEvent.click(tagSelectorWrapper) + }) + + it('should handle popover mouse leave', async () => { + render() + + // Open popover + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByTestId('popover-content')).toBeInTheDocument() + }) + + // Trigger mouse leave on the outer popover-content + fireEvent.mouseLeave(screen.getByTestId('popover-content')) + + await waitFor(() => { + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + }) + }) + + it('should handle operations menu mouse leave', async () => { + render() + + // Open popover + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.editApp')).toBeInTheDocument() + }) + + // Find the Operations wrapper div (contains the menu items) + const editButton = screen.getByText('app.editApp') + const operationsWrapper = editButton.closest('div.relative') + + // Trigger mouse leave on the Operations wrapper to call onMouseLeave + if (operationsWrapper) + fireEvent.mouseLeave(operationsWrapper) + }) + + it('should click open in explore button', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + // Verify openAsyncWindow was called with callback and options + await waitFor(() => { + expect(mockOpenAsyncWindow).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ onError: expect.any(Function) }), + ) + }) + }) + + it('should handle open in explore via async window', async () => { + // Configure mockOpenAsyncWindow to actually call the callback + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise) => { + await callback() + }) + + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + const { fetchInstalledAppList } = require('@/service/explore') + await waitFor(() => { + expect(fetchInstalledAppList).toHaveBeenCalledWith(mockApp.id) + }) + }) + + it('should handle open in explore API failure', async () => { + const { fetchInstalledAppList } = require('@/service/explore') + fetchInstalledAppList.mockRejectedValueOnce(new Error('API Error')) + + // Configure mockOpenAsyncWindow to call the callback and trigger error + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options: any) => { + try { + await callback() + } + catch (err) { + options?.onError?.(err) + } + }) + + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + await waitFor(() => { + expect(fetchInstalledAppList).toHaveBeenCalled() + }) + }) + }) + + describe('Access Control', () => { + it('should render operations menu correctly', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.editApp')).toBeInTheDocument() + expect(screen.getByText('app.duplicate')).toBeInTheDocument() + expect(screen.getByText('app.export')).toBeInTheDocument() + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() + }) + }) + }) + + describe('Open in Explore - No App Found', () => { + it('should handle case when installed_apps is empty array', async () => { + const { fetchInstalledAppList } = require('@/service/explore') + fetchInstalledAppList.mockResolvedValueOnce({ installed_apps: [] }) + + // Configure mockOpenAsyncWindow to call the callback and trigger error + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise, options: any) => { + try { + await callback() + } + catch (err) { + options?.onError?.(err) + } + }) + + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + await waitFor(() => { + expect(fetchInstalledAppList).toHaveBeenCalled() + }) + }) + + it('should handle case when API throws in callback', async () => { + const { fetchInstalledAppList } = require('@/service/explore') + fetchInstalledAppList.mockRejectedValueOnce(new Error('Network error')) + + // Configure mockOpenAsyncWindow to call the callback without catching + mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise) => { + return await callback() + }) + + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const openInExploreBtn = screen.getByText('app.openInExplore') + fireEvent.click(openInExploreBtn) + }) + + await waitFor(() => { + expect(fetchInstalledAppList).toHaveBeenCalled() + }) + }) + }) + + describe('Draft Trigger Apps', () => { + it('should not show open in explore option for apps with has_draft_trigger', async () => { + const draftTriggerApp = createMockApp({ has_draft_trigger: true }) + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.editApp')).toBeInTheDocument() + // openInExplore should not be shown for draft trigger apps + expect(screen.queryByText('app.openInExplore')).not.toBeInTheDocument() + }) + }) + }) + + describe('Non-editor User', () => { + it('should handle non-editor workspace users', () => { + // This tests the isCurrentWorkspaceEditor=true branch (default mock) + render() + expect(screen.getByTitle('Test App')).toBeInTheDocument() + }) + }) + + describe('WebApp Auth Enabled', () => { + beforeEach(() => { + mockWebappAuthEnabled = true + }) + + it('should show access control option when webapp_auth is enabled', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.accessControl')).toBeInTheDocument() + }) + }) + + it('should click access control button', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + const accessControlBtn = screen.getByText('app.accessControl') + fireEvent.click(accessControlBtn) + }) + + await waitFor(() => { + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + }) + }) + + it('should close access control modal and call onRefresh', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.accessControl')) + }) + + await waitFor(() => { + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + }) + + // Confirm access control + fireEvent.click(screen.getByTestId('confirm-access-control')) + + await waitFor(() => { + expect(mockOnRefresh).toHaveBeenCalled() + }) + }) + + it('should show open in explore when userCanAccessApp is true', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + expect(screen.getByText('app.openInExplore')).toBeInTheDocument() + }) + }) + + it('should close access control modal when onClose is called', async () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + await waitFor(() => { + fireEvent.click(screen.getByText('app.accessControl')) + }) + + await waitFor(() => { + expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() + }) + + // Click close button to trigger onClose + fireEvent.click(screen.getByTestId('close-access-control')) + + await waitFor(() => { + expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() + }) }) }) }) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index b8da0264e4..8140422c0f 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -5,7 +5,7 @@ import { useContext } from 'use-context-selector' import { useRouter } from 'next/navigation' import { useTranslation } from 'react-i18next' import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import { type App, AppModeEnum } from '@/types/app' import Toast, { ToastContext } from '@/app/components/base/toast' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' diff --git a/web/app/components/apps/list.spec.tsx b/web/app/components/apps/list.spec.tsx index fe664a4a50..3bc8a27375 100644 --- a/web/app/components/apps/list.spec.tsx +++ b/web/app/components/apps/list.spec.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import { AppModeEnum } from '@/types/app' // Mock next/navigation @@ -28,20 +28,29 @@ jest.mock('@/context/global-public-context', () => ({ }), })) -// Mock custom hooks +// Mock custom hooks - allow dynamic query state const mockSetQuery = jest.fn() +const mockQueryState = { + tagIDs: [] as string[], + keywords: '', + isCreatedByMe: false, +} jest.mock('./hooks/use-apps-query-state', () => ({ __esModule: true, default: () => ({ - query: { tagIDs: [], keywords: '', isCreatedByMe: false }, + query: mockQueryState, setQuery: mockSetQuery, }), })) +// Store callback for testing DSL file drop +let mockOnDSLFileDropped: ((file: File) => void) | null = null +let mockDragging = false jest.mock('./hooks/use-dsl-drag-drop', () => ({ - useDSLDragDrop: () => ({ - dragging: false, - }), + useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => { + mockOnDSLFileDropped = onDSLFileDropped + return { dragging: mockDragging } + }, })) const mockSetActiveTab = jest.fn() @@ -49,55 +58,90 @@ jest.mock('@/hooks/use-tab-searchparams', () => ({ useTabSearchParams: () => ['all', mockSetActiveTab], })) -// Mock service hooks +// Mock service hooks - use object for mutable state (jest.mock is hoisted) const mockRefetch = jest.fn() +const mockFetchNextPage = jest.fn() + +const mockServiceState = { + error: null as Error | null, + hasNextPage: false, + isLoading: false, + isFetchingNextPage: false, +} + +const defaultAppData = { + pages: [{ + data: [ + { + id: 'app-1', + name: 'Test App 1', + description: 'Description 1', + mode: AppModeEnum.CHAT, + icon: '🤖', + icon_type: 'emoji', + icon_background: '#FFEAD5', + tags: [], + author_name: 'Author 1', + created_at: 1704067200, + updated_at: 1704153600, + }, + { + id: 'app-2', + name: 'Test App 2', + description: 'Description 2', + mode: AppModeEnum.WORKFLOW, + icon: '⚙️', + icon_type: 'emoji', + icon_background: '#E4FBCC', + tags: [], + author_name: 'Author 2', + created_at: 1704067200, + updated_at: 1704153600, + }, + ], + total: 2, + }], +} + jest.mock('@/service/use-apps', () => ({ useInfiniteAppList: () => ({ - data: { - pages: [{ - data: [ - { - id: 'app-1', - name: 'Test App 1', - description: 'Description 1', - mode: AppModeEnum.CHAT, - icon: '🤖', - icon_type: 'emoji', - icon_background: '#FFEAD5', - tags: [], - author_name: 'Author 1', - created_at: 1704067200, - updated_at: 1704153600, - }, - { - id: 'app-2', - name: 'Test App 2', - description: 'Description 2', - mode: AppModeEnum.WORKFLOW, - icon: '⚙️', - icon_type: 'emoji', - icon_background: '#E4FBCC', - tags: [], - author_name: 'Author 2', - created_at: 1704067200, - updated_at: 1704153600, - }, - ], - total: 2, - }], - }, - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: jest.fn(), - hasNextPage: false, - error: null, + data: defaultAppData, + isLoading: mockServiceState.isLoading, + isFetchingNextPage: mockServiceState.isFetchingNextPage, + fetchNextPage: mockFetchNextPage, + hasNextPage: mockServiceState.hasNextPage, + error: mockServiceState.error, refetch: mockRefetch, }), })) // Mock tag store jest.mock('@/app/components/base/tag-management/store', () => ({ - useStore: () => false, + useStore: (selector: (state: { tagList: any[]; setTagList: any; showTagManagementModal: boolean; setShowTagManagementModal: any }) => any) => { + const state = { + tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app' }], + setTagList: jest.fn(), + showTagManagementModal: false, + setShowTagManagementModal: jest.fn(), + } + return selector(state) + }, +})) + +// Mock tag service to avoid API calls in TagFilter +jest.mock('@/service/tag', () => ({ + fetchTagList: jest.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]), +})) + +// Store TagFilter onChange callback for testing +let mockTagFilterOnChange: ((value: string[]) => void) | null = null +jest.mock('@/app/components/base/tag-management/filter', () => ({ + __esModule: true, + default: ({ onChange }: { onChange: (value: string[]) => void }) => { + const React = require('react') + mockTagFilterOnChange = onChange + return React.createElement('div', { 'data-testid': 'tag-filter' }, 'common.tag.placeholder') + }, })) // Mock config @@ -110,9 +154,17 @@ jest.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) -// Mock debounce hook +// Mock ahooks - useMount only executes once on mount, not on fn change jest.mock('ahooks', () => ({ useDebounceFn: (fn: () => void) => ({ run: fn }), + useMount: (fn: () => void) => { + const React = require('react') + const fnRef = React.useRef(fn) + fnRef.current = fn + React.useEffect(() => { + fnRef.current() + }, []) + }, })) // Mock dynamic imports @@ -127,10 +179,11 @@ jest.mock('next/dynamic', () => { } } if (fnString.includes('create-from-dsl-modal')) { - return function MockCreateFromDSLModal({ show, onClose }: any) { + return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) { if (!show) return null return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), + React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'), ) } } @@ -174,127 +227,83 @@ jest.mock('./footer', () => ({ }, })) -/** - * Mock base components that have deep dependency chains or require controlled test behavior. - * - * Per frontend testing skills (mocking.md), we generally should NOT mock base components. - * However, the following require mocking due to: - * - Deep dependency chains importing ES modules (like ky) incompatible with Jest - * - Need for controlled interaction behavior in tests (onChange, onClear handlers) - * - Complex internal state that would make tests flaky - * - * These mocks preserve the component's props interface to test List's integration correctly. - */ -jest.mock('@/app/components/base/tab-slider-new', () => ({ - __esModule: true, - default: ({ value, onChange, options }: any) => { - const React = require('react') - return React.createElement('div', { 'data-testid': 'tab-slider', 'role': 'tablist' }, - options.map((opt: any) => - React.createElement('button', { - 'key': opt.value, - 'data-testid': `tab-${opt.value}`, - 'role': 'tab', - 'aria-selected': value === opt.value, - 'onClick': () => onChange(opt.value), - }, opt.text), - ), - ) - }, -})) - -jest.mock('@/app/components/base/input', () => ({ - __esModule: true, - default: ({ value, onChange, onClear }: any) => { - const React = require('react') - return React.createElement('div', { 'data-testid': 'search-input' }, - React.createElement('input', { - 'data-testid': 'search-input-field', - 'role': 'searchbox', - 'value': value || '', - onChange, - }), - React.createElement('button', { - 'data-testid': 'clear-search', - 'aria-label': 'Clear search', - 'onClick': onClear, - }, 'Clear'), - ) - }, -})) - -jest.mock('@/app/components/base/tag-management/filter', () => ({ - __esModule: true, - default: ({ value, onChange }: any) => { - const React = require('react') - return React.createElement('div', { 'data-testid': 'tag-filter', 'role': 'listbox' }, - React.createElement('button', { - 'data-testid': 'add-tag-filter', - 'onClick': () => onChange([...value, 'new-tag']), - }, 'Add Tag'), - ) - }, -})) - -jest.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({ - __esModule: true, - default: ({ label, isChecked, onChange }: any) => { - const React = require('react') - return React.createElement('label', { 'data-testid': 'created-by-me-checkbox' }, - React.createElement('input', { - 'type': 'checkbox', - 'role': 'checkbox', - 'checked': isChecked, - 'aria-checked': isChecked, - onChange, - 'data-testid': 'created-by-me-input', - }), - label, - ) - }, -})) - // Import after mocks import List from './list' +// Store IntersectionObserver callback +let intersectionCallback: IntersectionObserverCallback | null = null +const mockObserve = jest.fn() +const mockDisconnect = jest.fn() + +// Mock IntersectionObserver +beforeAll(() => { + globalThis.IntersectionObserver = class MockIntersectionObserver { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback + } + + observe = mockObserve + disconnect = mockDisconnect + unobserve = jest.fn() + root = null + rootMargin = '' + thresholds = [] + takeRecords = () => [] + } as unknown as typeof IntersectionObserver +}) + describe('List', () => { beforeEach(() => { jest.clearAllMocks() mockIsCurrentWorkspaceEditor.mockReturnValue(true) mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false) + mockDragging = false + mockOnDSLFileDropped = null + mockTagFilterOnChange = null + mockServiceState.error = null + mockServiceState.hasNextPage = false + mockServiceState.isLoading = false + mockServiceState.isFetchingNextPage = false + mockQueryState.tagIDs = [] + mockQueryState.keywords = '' + mockQueryState.isCreatedByMe = false + intersectionCallback = null localStorage.clear() }) describe('Rendering', () => { it('should render without crashing', () => { render() - expect(screen.getByTestId('tab-slider')).toBeInTheDocument() + // Tab slider renders app type tabs + expect(screen.getByText('app.types.all')).toBeInTheDocument() }) it('should render tab slider with all app types', () => { render() - expect(screen.getByTestId('tab-all')).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument() + expect(screen.getByText('app.types.all')).toBeInTheDocument() + expect(screen.getByText('app.types.workflow')).toBeInTheDocument() + expect(screen.getByText('app.types.advanced')).toBeInTheDocument() + expect(screen.getByText('app.types.chatbot')).toBeInTheDocument() + expect(screen.getByText('app.types.agent')).toBeInTheDocument() + expect(screen.getByText('app.types.completion')).toBeInTheDocument() }) it('should render search input', () => { render() - expect(screen.getByTestId('search-input')).toBeInTheDocument() + // Input component renders a searchbox + expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render tag filter', () => { render() - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() + // Tag filter renders with placeholder text + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) it('should render created by me checkbox', () => { render() - expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument() + expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() }) it('should render app cards when apps exist', () => { @@ -324,7 +333,7 @@ describe('List', () => { it('should call setActiveTab when tab is clicked', () => { render() - fireEvent.click(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)) + fireEvent.click(screen.getByText('app.types.workflow')) expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) }) @@ -332,7 +341,7 @@ describe('List', () => { it('should call setActiveTab for all tab', () => { render() - fireEvent.click(screen.getByTestId('tab-all')) + fireEvent.click(screen.getByText('app.types.all')) expect(mockSetActiveTab).toHaveBeenCalledWith('all') }) @@ -341,23 +350,38 @@ describe('List', () => { describe('Search Functionality', () => { it('should render search input field', () => { render() - expect(screen.getByTestId('search-input-field')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should handle search input change', () => { render() - const input = screen.getByTestId('search-input-field') + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'test search' } }) expect(mockSetQuery).toHaveBeenCalled() }) - it('should clear search when clear button is clicked', () => { + it('should handle search input interaction', () => { render() - fireEvent.click(screen.getByTestId('clear-search')) + const input = screen.getByRole('textbox') + expect(input).toBeInTheDocument() + }) + it('should handle search clear button click', () => { + // Set initial keywords to make clear button visible + mockQueryState.keywords = 'existing search' + + render() + + // Find and click clear button (Input component uses .group class for clear icon container) + const clearButton = document.querySelector('.group') + expect(clearButton).toBeInTheDocument() + if (clearButton) + fireEvent.click(clearButton) + + // handleKeywordsChange should be called with empty string expect(mockSetQuery).toHaveBeenCalled() }) }) @@ -365,16 +389,14 @@ describe('List', () => { describe('Tag Filter', () => { it('should render tag filter component', () => { render() - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) - it('should handle tag filter change', () => { + it('should render tag filter with placeholder', () => { render() - fireEvent.click(screen.getByTestId('add-tag-filter')) - - // Tag filter change triggers debounced setTagIDs - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() + // Tag filter is rendered + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) }) @@ -387,7 +409,9 @@ describe('List', () => { it('should handle checkbox change', () => { render() - const checkbox = screen.getByTestId('created-by-me-input') + // Checkbox component uses data-testid="checkbox-{id}" + // CheckboxWithLabel doesn't pass testId, so id is undefined + const checkbox = screen.getByTestId('checkbox-undefined') fireEvent.click(checkbox) expect(mockSetQuery).toHaveBeenCalled() @@ -436,10 +460,10 @@ describe('List', () => { describe('Edge Cases', () => { it('should handle multiple renders without issues', () => { const { rerender } = render() - expect(screen.getByTestId('tab-slider')).toBeInTheDocument() + expect(screen.getByText('app.types.all')).toBeInTheDocument() rerender() - expect(screen.getByTestId('tab-slider')).toBeInTheDocument() + expect(screen.getByText('app.types.all')).toBeInTheDocument() }) it('should render app cards correctly', () => { @@ -452,9 +476,9 @@ describe('List', () => { it('should render with all filter options visible', () => { render() - expect(screen.getByTestId('search-input')).toBeInTheDocument() - expect(screen.getByTestId('tag-filter')).toBeInTheDocument() - expect(screen.getByTestId('created-by-me-checkbox')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() + expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() }) }) @@ -469,27 +493,27 @@ describe('List', () => { it('should render all app type tabs', () => { render() - expect(screen.getByTestId('tab-all')).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.ADVANCED_CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.AGENT_CHAT}`)).toBeInTheDocument() - expect(screen.getByTestId(`tab-${AppModeEnum.COMPLETION}`)).toBeInTheDocument() + expect(screen.getByText('app.types.all')).toBeInTheDocument() + expect(screen.getByText('app.types.workflow')).toBeInTheDocument() + expect(screen.getByText('app.types.advanced')).toBeInTheDocument() + expect(screen.getByText('app.types.chatbot')).toBeInTheDocument() + expect(screen.getByText('app.types.agent')).toBeInTheDocument() + expect(screen.getByText('app.types.completion')).toBeInTheDocument() }) it('should call setActiveTab for each app type', () => { render() - const appModes = [ - AppModeEnum.WORKFLOW, - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.CHAT, - AppModeEnum.AGENT_CHAT, - AppModeEnum.COMPLETION, + const appTypeTexts = [ + { mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' }, + { mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' }, + { mode: AppModeEnum.CHAT, text: 'app.types.chatbot' }, + { mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' }, + { mode: AppModeEnum.COMPLETION, text: 'app.types.completion' }, ] - appModes.forEach((mode) => { - fireEvent.click(screen.getByTestId(`tab-${mode}`)) + appTypeTexts.forEach(({ mode, text }) => { + fireEvent.click(screen.getByText(text)) expect(mockSetActiveTab).toHaveBeenCalledWith(mode) }) }) @@ -499,7 +523,7 @@ describe('List', () => { it('should display search input with correct attributes', () => { render() - const input = screen.getByTestId('search-input-field') + const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() expect(input).toHaveAttribute('value', '') }) @@ -507,8 +531,7 @@ describe('List', () => { it('should have tag filter component', () => { render() - const tagFilter = screen.getByTestId('tag-filter') - expect(tagFilter).toBeInTheDocument() + expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument() }) it('should display created by me label', () => { @@ -547,18 +570,17 @@ describe('List', () => { // -------------------------------------------------------------------------- describe('Additional Coverage', () => { it('should render dragging state overlay when dragging', () => { - // Test dragging state is handled + mockDragging = true const { container } = render() - // Component should render successfully + // Component should render successfully with dragging state expect(container).toBeInTheDocument() }) it('should handle app mode filter in query params', () => { - // Test that different modes are handled in query render() - const workflowTab = screen.getByTestId(`tab-${AppModeEnum.WORKFLOW}`) + const workflowTab = screen.getByText('app.types.workflow') fireEvent.click(workflowTab) expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW) @@ -570,4 +592,168 @@ describe('List', () => { expect(screen.getByTestId('new-app-card')).toBeInTheDocument() }) }) + + describe('DSL File Drop', () => { + it('should handle DSL file drop and show modal', () => { + render() + + // Simulate DSL file drop via the callback + const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) + act(() => { + if (mockOnDSLFileDropped) + mockOnDSLFileDropped(mockFile) + }) + + // Modal should be shown + expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() + }) + + it('should close DSL modal when onClose is called', () => { + render() + + // Open modal via DSL file drop + const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) + act(() => { + if (mockOnDSLFileDropped) + mockOnDSLFileDropped(mockFile) + }) + + expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() + + // Close modal + fireEvent.click(screen.getByTestId('close-dsl-modal')) + + expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() + }) + + it('should close DSL modal and refetch when onSuccess is called', () => { + render() + + // Open modal via DSL file drop + const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' }) + act(() => { + if (mockOnDSLFileDropped) + mockOnDSLFileDropped(mockFile) + }) + + expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument() + + // Click success button + fireEvent.click(screen.getByTestId('success-dsl-modal')) + + // Modal should be closed and refetch should be called + expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument() + expect(mockRefetch).toHaveBeenCalled() + }) + }) + + describe('Tag Filter Change', () => { + it('should handle tag filter value change', () => { + jest.useFakeTimers() + render() + + // TagFilter component is rendered + expect(screen.getByTestId('tag-filter')).toBeInTheDocument() + + // Trigger tag filter change via captured callback + act(() => { + if (mockTagFilterOnChange) + mockTagFilterOnChange(['tag-1', 'tag-2']) + }) + + // Advance timers to trigger debounced setTagIDs + act(() => { + jest.advanceTimersByTime(500) + }) + + // setQuery should have been called with updated tagIDs + expect(mockSetQuery).toHaveBeenCalled() + + jest.useRealTimers() + }) + + it('should handle empty tag filter selection', () => { + jest.useFakeTimers() + render() + + // Trigger tag filter change with empty array + act(() => { + if (mockTagFilterOnChange) + mockTagFilterOnChange([]) + }) + + // Advance timers + act(() => { + jest.advanceTimersByTime(500) + }) + + expect(mockSetQuery).toHaveBeenCalled() + + jest.useRealTimers() + }) + }) + + describe('Infinite Scroll', () => { + it('should call fetchNextPage when intersection observer triggers', () => { + mockServiceState.hasNextPage = true + render() + + // Simulate intersection + if (intersectionCallback) { + act(() => { + intersectionCallback!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + } + + expect(mockFetchNextPage).toHaveBeenCalled() + }) + + it('should not call fetchNextPage when not intersecting', () => { + mockServiceState.hasNextPage = true + render() + + // Simulate non-intersection + if (intersectionCallback) { + act(() => { + intersectionCallback!( + [{ isIntersecting: false } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + } + + expect(mockFetchNextPage).not.toHaveBeenCalled() + }) + + it('should not call fetchNextPage when loading', () => { + mockServiceState.hasNextPage = true + mockServiceState.isLoading = true + render() + + if (intersectionCallback) { + act(() => { + intersectionCallback!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) + }) + } + + expect(mockFetchNextPage).not.toHaveBeenCalled() + }) + }) + + describe('Error State', () => { + it('should handle error state in useEffect', () => { + mockServiceState.error = new Error('Test error') + const { container } = render() + + // Component should still render + expect(container).toBeInTheDocument() + // Disconnect should be called when there's an error (cleanup) + }) + }) }) diff --git a/web/app/components/apps/new-app-card.tsx b/web/app/components/apps/new-app-card.tsx index 7a10bc8527..51e4bae8fe 100644 --- a/web/app/components/apps/new-app-card.tsx +++ b/web/app/components/apps/new-app-card.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next' import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' import { useProviderContext } from '@/context/provider-context' import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' -import cn from '@/utils/classnames' +import { cn } from '@/utils/classnames' import dynamic from 'next/dynamic' const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), { diff --git a/web/app/components/base/action-button/index.tsx b/web/app/components/base/action-button/index.tsx index f70bfb4448..eff6a43d22 100644 --- a/web/app/components/base/action-button/index.tsx +++ b/web/app/components/base/action-button/index.tsx @@ -1,7 +1,7 @@ import type { CSSProperties } from 'react' import React from 'react' import { type VariantProps, cva } from 'class-variance-authority' -import classNames from '@/utils/classnames' +import { cn } from '@/utils/classnames' enum ActionButtonState { Destructive = 'destructive', @@ -54,10 +54,8 @@ const ActionButton = ({ className, size, state = ActionButtonState.Default, styl return (