From 7d1ad7e03ad008c810dfa6851729d0d9b0046ca8 Mon Sep 17 00:00:00 2001 From: CrabSAMA <40541269+CrabSAMA@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:57:46 +0800 Subject: [PATCH 1/3] refactor: unified shortcut keys display using component (#31713) --- .../components/app-sidebar/toggle-button.tsx | 15 ++------------- web/app/components/app/app-publisher/index.tsx | 11 +++-------- .../components/app/create-app-modal/index.tsx | 8 +++----- .../app/create-from-dsl-modal/index.tsx | 8 +++----- .../detail/completed/common/action-buttons.tsx | 10 ++++------ .../explore/create-app-modal/index.tsx | 8 +++----- web/app/components/goto-anything/index.tsx | 12 +++--------- .../rag-pipeline-header/publisher/popup.tsx | 11 +++-------- .../components/rag-pipeline-header/run-mode.tsx | 11 ++--------- .../workflow-onboarding-modal/index.tsx | 5 ++--- web/app/components/workflow/header/run-mode.tsx | 11 ++--------- .../workflow/header/version-history-button.tsx | 14 +++----------- .../edit-card/advanced-actions.tsx | 17 +++-------------- web/app/components/workflow/shortcuts-name.tsx | 6 +++++- 14 files changed, 41 insertions(+), 106 deletions(-) diff --git a/web/app/components/app-sidebar/toggle-button.tsx b/web/app/components/app-sidebar/toggle-button.tsx index a6bdee4f78..cbfbeee452 100644 --- a/web/app/components/app-sidebar/toggle-button.tsx +++ b/web/app/components/app-sidebar/toggle-button.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' import Button from '../base/button' import Tooltip from '../base/tooltip' -import { getKeyboardKeyNameBySystem } from '../workflow/utils' +import ShortcutsName from '../workflow/shortcuts-name' type TooltipContentProps = { expand: boolean @@ -20,18 +20,7 @@ const TooltipContent = ({ return (
{expand ? t('sidebar.collapseSidebar', { ns: 'layout' }) : t('sidebar.expandSidebar', { ns: 'layout' })} -
- { - TOGGLE_SHORTCUT.map(key => ( - - {getKeyboardKeyNameBySystem(key)} - - )) - } -
+
) } diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 0a026a680b..0fc364cb7e 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -49,7 +49,8 @@ import Divider from '../../base/divider' import Loading from '../../base/loading' import Toast from '../../base/toast' import Tooltip from '../../base/tooltip' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils' +import ShortcutsName from '../../workflow/shortcuts-name' +import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' import PublishWithMultipleModel from './publish-with-multiple-model' import SuggestedAction from './suggested-action' @@ -345,13 +346,7 @@ const AppPublisher = ({ : (
{t('common.publishUpdate', { ns: 'workflow' })} -
- {PUBLISH_SHORTCUT.map(key => ( - - {getKeyboardKeyNameBySystem(key)} - - ))} -
+
) } diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index e2b50cf030..66c7bce80c 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { AppIconSelection } from '../../base/app-icon-picker' -import { RiArrowRightLine, RiArrowRightSLine, RiCommandLine, RiCornerDownLeftLine, RiExchange2Fill } from '@remixicon/react' +import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import Image from 'next/image' @@ -29,6 +29,7 @@ import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' import { basePath } from '@/utils/var' import AppIconPicker from '../../base/app-icon-picker' +import ShortcutsName from '../../workflow/shortcuts-name' type CreateAppProps = { onSuccess: () => void @@ -269,10 +270,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: 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 838e9cc03f..04d8b1e754 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { MouseEventHandler } from 'react' -import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' import { useRouter } from 'next/navigation' @@ -28,6 +28,7 @@ import { } from '@/service/apps' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import ShortcutsName from '../../workflow/shortcuts-name' import Uploader from './uploader' type CreateFromDSLModalProps = { @@ -298,10 +299,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS className="gap-1" > {t('newApp.Create', { ns: 'app' })} -
- - -
+ diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx index efb9848494..a0cbfea147 100644 --- a/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/action-buttons.tsx @@ -4,7 +4,8 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' +import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import { ChunkingMode } from '@/models/datasets' import { useDocumentContext } from '../../context' @@ -54,7 +55,7 @@ const ActionButtons: FC = ({ >
{t('operation.cancel', { ns: 'common' })} - ESC +
{(isParentChildParagraphMode && actionType === 'edit' && !isChildChunk && showRegenerationButton) @@ -76,10 +77,7 @@ const ActionButtons: FC = ({ >
{t('operation.save', { ns: 'common' })} -
- {getKeyboardKeyNameBySystem('ctrl')} - S -
+
diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index 9bffcc6c69..cfe59fb7f3 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -1,6 +1,6 @@ 'use client' import type { AppIconType } from '@/types/app' -import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react' +import { RiCloseLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' @@ -17,6 +17,7 @@ import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { useProviderContext } from '@/context/provider-context' import { AppModeEnum } from '@/types/app' import AppIconPicker from '../../base/app-icon-picker' +import ShortcutsName from '../../workflow/shortcuts-name' export type CreateAppModalProps = { show: boolean @@ -198,10 +199,7 @@ const CreateAppModal = ({ onClick={handleSubmit} > {!isEditModal ? t('operation.create', { ns: 'common' }) : t('operation.save', { ns: 'common' })} -
- - -
+ diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index d34176e4c7..733e1d3162 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -12,7 +12,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' -import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' +import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { useGetLanguage } from '@/context/i18n' import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace' @@ -356,14 +357,7 @@ const GotoAnything: FC = ({ )} -
- - {isMac() ? '⌘' : 'Ctrl'} - - - K - -
+ diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 0cdc9a0327..c66b293d8a 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -28,11 +28,12 @@ import { useToastContext } from '@/app/components/base/toast' import { useChecklistBeforePublish, } from '@/app/components/workflow/hooks' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore, useWorkflowStore, } from '@/app/components/workflow/store' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' +import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDocLink } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' @@ -261,13 +262,7 @@ const Popup = () => { : (
{t('common.publishUpdate', { ns: 'workflow' })} -
- {PUBLISH_SHORTCUT.map(key => ( - - {getKeyboardKeyNameBySystem(key)} - - ))} -
+
) } diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx index 00c531004f..81389e51b4 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx @@ -4,9 +4,9 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { useEventEmitterContextContext } from '@/context/event-emitter' import { cn } from '@/utils/classnames' @@ -78,14 +78,7 @@ const RunMode = ({ )} { !isDisabled && ( -
-
- {getKeyboardKeyNameBySystem('alt')} -
-
- R -
-
+ ) } diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx index c483abfb0b..16bae51246 100644 --- a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx +++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx @@ -7,6 +7,7 @@ import { } from 'react' import { useTranslation } from 'react-i18next' import Modal from '@/app/components/base/modal' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { BlockEnum } from '@/app/components/workflow/types' import StartNodeSelectionPanel from './start-node-selection-panel' @@ -75,9 +76,7 @@ const WorkflowOnboardingModal: FC = ({ {isShow && (
{t('onboarding.escTip.press', { ns: 'workflow' })} - - {t('onboarding.escTip.key', { ns: 'workflow' })} - + {t('onboarding.escTip.toDismiss', { ns: 'workflow' })}
)} diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index 1a101bc6d2..74bc5bc80a 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -7,9 +7,9 @@ import { trackEvent } from '@/app/components/base/amplitude' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import { useToastContext } from '@/app/components/base/toast' import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' import { useStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { useEventEmitterContextContext } from '@/context/event-emitter' import { cn } from '@/utils/classnames' @@ -143,14 +143,7 @@ const RunMode = ({ > {text ?? t('common.run', { ns: 'workflow' })} -
-
- {getKeyboardKeyNameBySystem('alt')} -
-
- R -
-
+ ) diff --git a/web/app/components/workflow/header/version-history-button.tsx b/web/app/components/workflow/header/version-history-button.tsx index 32e72dc184..b98dfeea76 100644 --- a/web/app/components/workflow/header/version-history-button.tsx +++ b/web/app/components/workflow/header/version-history-button.tsx @@ -8,7 +8,8 @@ import useTheme from '@/hooks/use-theme' import { cn } from '@/utils/classnames' import Button from '../../base/button' import Tooltip from '../../base/tooltip' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../utils' +import ShortcutsName from '../shortcuts-name' +import { getKeyboardKeyCodeBySystem } from '../utils' type VersionHistoryButtonProps = { onClick: () => Promise | unknown @@ -23,16 +24,7 @@ const PopupContent = React.memo(() => {
{t('common.versionHistory', { ns: 'workflow' })}
-
- {VERSION_HISTORY_SHORTCUT.map(key => ( - - {getKeyboardKeyNameBySystem(key)} - - ))} -
+ ) }) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx index 536277b9e2..8aad824008 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx @@ -3,7 +3,8 @@ import { useKeyPress } from 'ahooks' import * as React from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' -import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' +import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils' type AdvancedActionsProps = { isConfirmDisabled: boolean @@ -11,15 +12,6 @@ type AdvancedActionsProps = { onConfirm: () => void } -const Key = (props: { keyName: string }) => { - const { keyName } = props - return ( - - {keyName} - - ) -} - const AdvancedActions: FC = ({ isConfirmDisabled, onCancel, @@ -48,10 +40,7 @@ const AdvancedActions: FC = ({ onClick={onConfirm} > {t('operation.confirm', { ns: 'common' })} -
- - -
+ ) diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx index d0ce007f61..3d21cff316 100644 --- a/web/app/components/workflow/shortcuts-name.tsx +++ b/web/app/components/workflow/shortcuts-name.tsx @@ -6,11 +6,13 @@ type ShortcutsNameProps = { keys: string[] className?: string textColor?: 'default' | 'secondary' + bgColor?: 'gray' | 'white' } const ShortcutsName = ({ keys, className, textColor = 'default', + bgColor = 'gray', }: ShortcutsNameProps) => { return (
From 25ac69afc5ac9324079be5f0d02b2a2b03dcc784 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:58:10 +0800 Subject: [PATCH 2/3] docs: relocate frontend docs for agents and human (#31714) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .agents/skills/component-refactoring/SKILL.md | 2 +- .agents/skills/frontend-testing/SKILL.md | 4 +- .../frontend-testing/references/workflow.md | 2 +- AGENTS.md | 33 +----------- CONTRIBUTING.md | 2 +- web/AGENTS.md | 6 ++- web/README.md | 2 + web/docs/lint.md | 51 +++++++++++++++++++ web/{testing/testing.md => docs/test.md} | 4 +- web/eslint-suppressions.json | 5 -- web/scripts/analyze-component.js | 4 +- 11 files changed, 69 insertions(+), 46 deletions(-) create mode 100644 web/docs/lint.md rename web/{testing/testing.md => docs/test.md} (99%) diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md index 7006c382c8..140e0ef434 100644 --- a/.agents/skills/component-refactoring/SKILL.md +++ b/.agents/skills/component-refactoring/SKILL.md @@ -480,4 +480,4 @@ const useButtonState = () => { ### Related Skills - `frontend-testing` - For testing refactored components -- `web/testing/testing.md` - Testing specification +- `web/docs/test.md` - Testing specification diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index 0716c81ef7..280fcb6341 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -7,7 +7,7 @@ description: Generate Vitest + React Testing Library tests for Dify frontend com This skill enables Claude to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices. -> **⚠️ Authoritative Source**: This skill is derived from `web/testing/testing.md`. Use Vitest mock/timer APIs (`vi.*`). +> **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`). ## When to Apply This Skill @@ -309,7 +309,7 @@ For more detailed information, refer to: ### Primary Specification (MUST follow) -- **`web/testing/testing.md`** - The canonical testing specification. This skill is derived from this document. +- **`web/docs/test.md`** - The canonical testing specification. This skill is derived from this document. ### Reference Examples in Codebase diff --git a/.agents/skills/frontend-testing/references/workflow.md b/.agents/skills/frontend-testing/references/workflow.md index 009c3e013b..bc4ed8285a 100644 --- a/.agents/skills/frontend-testing/references/workflow.md +++ b/.agents/skills/frontend-testing/references/workflow.md @@ -4,7 +4,7 @@ This guide defines the workflow for generating tests, especially for complex com ## Scope Clarification -This guide addresses **multi-file workflow** (how to process multiple test files). For coverage requirements within a single test file, see `web/testing/testing.md` § Coverage Goals. +This guide addresses **multi-file workflow** (how to process multiple test files). For coverage requirements within a single test file, see `web/docs/test.md` § Coverage Goals. | Scope | Rule | |-------|------| diff --git a/AGENTS.md b/AGENTS.md index 7d96ac3a6d..51fa6e4527 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Dify is an open-source platform for developing LLM applications with an intuitiv The codebase is split into: - **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design -- **Frontend Web** (`/web`): Next.js 15 application using TypeScript and React 19 +- **Frontend Web** (`/web`): Next.js application using TypeScript and React - **Docker deployment** (`/docker`): Containerized deployment configurations ## Backend Workflow @@ -18,36 +18,7 @@ The codebase is split into: ## Frontend Workflow -```bash -cd web -pnpm lint:fix -pnpm type-check:tsgo -pnpm test -``` - -### Frontend Linting - -ESLint is used for frontend code quality. Available commands: - -```bash -# Lint all files (report only) -pnpm lint - -# Lint and auto-fix issues -pnpm lint:fix - -# Lint specific files or directories -pnpm lint:fix app/components/base/button/ -pnpm lint:fix app/components/base/button/index.tsx - -# Lint quietly (errors only, no warnings) -pnpm lint:quiet - -# Check code complexity -pnpm lint:complexity -``` - -**Important**: Always run `pnpm lint:fix` before committing. The pre-commit hook runs `lint-staged` which only lints staged files. +- Read `web/AGENTS.md` for details ## Testing & Quality Practices diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 20a7d6c6f6..d7f007af67 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ How we prioritize: For setting up the frontend service, please refer to our comprehensive [guide](https://github.com/langgenius/dify/blob/main/web/README.md) in the `web/README.md` file. This document provides detailed instructions to help you set up the frontend environment properly. -**Testing**: All React components must have comprehensive test coverage. See [web/testing/testing.md](https://github.com/langgenius/dify/blob/main/web/testing/testing.md) for the canonical frontend testing guidelines and follow every requirement described there. +**Testing**: All React components must have comprehensive test coverage. See [web/docs/test.md](https://github.com/langgenius/dify/blob/main/web/docs/test.md) for the canonical frontend testing guidelines and follow every requirement described there. #### Backend diff --git a/web/AGENTS.md b/web/AGENTS.md index 7362cd51db..5dd41b8a3c 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,5 +1,9 @@ +## Frontend Workflow + +- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions. + ## Automated Test Generation -- Use `web/testing/testing.md` as the canonical instruction set for generating frontend automated tests. +- Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests. - When proposing or saving tests, re-read that document and follow every requirement. - All frontend tests MUST also comply with the `frontend-testing` skill. Treat the skill as a mandatory constraint, not optional guidance. diff --git a/web/README.md b/web/README.md index 9c731a081a..64039709dc 100644 --- a/web/README.md +++ b/web/README.md @@ -107,6 +107,8 @@ Open [http://localhost:6006](http://localhost:6006) with your browser to see the If your IDE is VSCode, rename `web/.vscode/settings.example.json` to `web/.vscode/settings.json` for lint code setting. +Then follow the [Lint Documentation](./docs/lint.md) to lint the code. + ## Test We use [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) for Unit Testing. diff --git a/web/docs/lint.md b/web/docs/lint.md new file mode 100644 index 0000000000..051f9e6ecd --- /dev/null +++ b/web/docs/lint.md @@ -0,0 +1,51 @@ +# Lint Guide + +We use ESLint and Typescript to maintain code quality and consistency across the project. + +## ESLint + +### Common Flags + +**File/folder targeting**: Append paths to lint specific files or directories. + +```sh +pnpm eslint [options] file.js [file.js] [dir] +``` + +**`--cache`**: Caches lint results for faster subsequent runs. Keep this enabled by default; only disable when you encounter unexpected lint results. + +**`--concurrency`**: Enables multi-threaded linting. Use `--concurrency=auto` or experiment with specific numbers to find the optimal setting for your machine. Keep this enabled when linting multiple files. + +- [ESLint multi-thread linting blog post](https://eslint.org/blog/2025/08/multithread-linting/) + +**`--fix`**: Automatically fixes auto-fixable rule violations. Always review the diff before committing to ensure no unintended changes. + +**`--quiet`**: Suppresses warnings and only shows errors. Useful when you want to reduce noise from existing issues. + +**`--suppress-all`**: Temporarily suppresses error-level violations and records them, allowing CI to pass. Treat this as an escape hatch—fix these errors when time permits. + +**`--prune-suppressions`**: Removes outdated suppressions after you've fixed the underlying errors. + +- [ESLint bulk suppressions blog post](https://eslint.org/blog/2025/04/introducing-bulk-suppressions/) + +### Type-Aware Linting + +Some ESLint rules require type information, such as [no-leaked-conditional-rendering](https://www.eslint-react.xyz/docs/rules/no-leaked-conditional-rendering). However, [typed linting via typescript-eslint](https://typescript-eslint.io/getting-started/typed-linting) is too slow for practical use, so we use [TSSLint](https://github.com/johnsoncodehk/tsslint) instead. + +```sh +pnpm lint:tss +``` + +This command lints the entire project and is intended for final verification before committing or pushing changes. + +## Type Check + +You should be able to see suggestions from TypeScript in your editor for all open files. + +However, it can be useful to run the TypeScript 7 command-line (tsgo) to type check all files: + +```sh +pnpm type-check:tsgo +``` + +Prefer using `tsgo` for type checking as it is significantly faster than the standard TypeScript compiler. Only fall back to `pnpm type-check` (which uses `tsc`) if you encounter unexpected results. diff --git a/web/testing/testing.md b/web/docs/test.md similarity index 99% rename from web/testing/testing.md rename to web/docs/test.md index 47341e445e..cac0e0e351 100644 --- a/web/testing/testing.md +++ b/web/docs/test.md @@ -360,11 +360,11 @@ describe('ComponentName', () => { let mockPortalOpenState = false vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open, ...props }: any) => { + PortalToFollowElem: ({ children, open, ...props }) => { mockPortalOpenState = open || false // Update shared state return
{children}
}, - PortalToFollowElemContent: ({ children }: any) => { + PortalToFollowElemContent: ({ children }) => { // ✅ Matches actual: returns null when open is false if (!mockPortalOpenState) return null diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 6193a8ad4e..63f10d238c 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -4318,11 +4318,6 @@ "count": 10 } }, - "testing/testing.md": { - "ts/no-explicit-any": { - "count": 2 - } - }, "types/app.ts": { "ts/no-explicit-any": { "count": 1 diff --git a/web/scripts/analyze-component.js b/web/scripts/analyze-component.js index b09301503c..2fdff2f3d0 100755 --- a/web/scripts/analyze-component.js +++ b/web/scripts/analyze-component.js @@ -337,7 +337,7 @@ Test file under review: ${testPath} Checklist (ensure every item is addressed in your review): -- Confirm the tests satisfy all requirements listed above and in web/testing/TESTING.md. +- Confirm the tests satisfy all requirements listed above and in web/docs/test.md. - Verify Arrange → Act → Assert structure, mocks, and cleanup follow project conventions. - Ensure all detected component features (state, effects, routing, API, events, etc.) are exercised, including edge cases and error paths. - Check coverage of prop variations, null/undefined inputs, and high-priority workflows implied by usage score. @@ -382,7 +382,7 @@ Examples: # Review existing test pnpm analyze-component app/components/base/button/index.tsx --review -For complete testing guidelines, see: web/testing/testing.md +For complete testing guidelines, see: web/docs/test.md `) } From 8aeef36e2d16c9b9ba41088aee937d0348b5cbec Mon Sep 17 00:00:00 2001 From: yihong Date: Thu, 29 Jan 2026 18:17:40 +0800 Subject: [PATCH 3/3] feat: use xdist to make make test faster (#30824) Signed-off-by: yihong0618 --- .github/workflows/api-tests.yml | 1 + Makefile | 2 +- api/pyproject.toml | 1 + api/tests/unit_tests/conftest.py | 17 +++++++++++++ .../console/app/test_app_response_models.py | 7 ++++++ api/uv.lock | 24 +++++++++++++++++++ dev/pytest/pytest_unit_tests.sh | 10 ++++++-- 7 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 190e00d9fe..52e3272f99 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -72,6 +72,7 @@ jobs: OPENDAL_FS_ROOT: /tmp/dify-storage run: | uv run --project api pytest \ + -n auto \ --timeout "${PYTEST_TIMEOUT:-180}" \ api/tests/integration_tests/workflow \ api/tests/integration_tests/tools \ diff --git a/Makefile b/Makefile index 20cede9a5e..984e8676ee 100644 --- a/Makefile +++ b/Makefile @@ -80,7 +80,7 @@ test: echo "Target: $(TARGET_TESTS)"; \ uv run --project api --dev pytest $(TARGET_TESTS); \ else \ - uv run --project api --dev dev/pytest/pytest_unit_tests.sh; \ + PYTEST_XDIST_ARGS="-n auto" uv run --project api --dev dev/pytest/pytest_unit_tests.sh; \ fi @echo "✅ Tests complete" diff --git a/api/pyproject.toml b/api/pyproject.toml index 575c1434c5..af2dba6fac 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -175,6 +175,7 @@ dev = [ # "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved. "sseclient-py>=1.8.0", "pytest-timeout>=2.4.0", + "pytest-xdist>=3.8.0", ] ############################################################ diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index c5e1576186..e3c1a617f7 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask +from sqlalchemy import create_engine # Getting the absolute path of the current file's directory ABS_PATH = os.path.dirname(os.path.abspath(__file__)) @@ -36,6 +37,7 @@ import sys sys.path.insert(0, PROJECT_DIR) +from core.db.session_factory import configure_session_factory, session_factory from extensions import ext_redis @@ -102,3 +104,18 @@ def reset_secret_key(): yield finally: dify_config.SECRET_KEY = original + + +@pytest.fixture(scope="session") +def _unit_test_engine(): + engine = create_engine("sqlite:///:memory:") + yield engine + engine.dispose() + + +@pytest.fixture(autouse=True) +def _configure_session_factory(_unit_test_engine): + try: + session_factory.get_session_maker() + except RuntimeError: + configure_session_factory(_unit_test_engine, expire_on_commit=False) diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 40eb59a8f4..c557605916 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -31,6 +31,13 @@ def _load_app_module(): def schema_model(self, name, schema): self.models[name] = schema + return schema + + def model(self, name, model_dict=None, **kwargs): + """Register a model with the namespace (flask-restx compatibility).""" + if model_dict is not None: + self.models[name] = model_dict + return model_dict def _decorator(self, obj): return obj diff --git a/api/uv.lock b/api/uv.lock index 7808c16a8c..a3ad292168 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1479,6 +1479,7 @@ dev = [ { name = "pytest-env" }, { name = "pytest-mock" }, { name = "pytest-timeout" }, + { name = "pytest-xdist" }, { name = "ruff" }, { name = "scipy-stubs" }, { name = "sseclient-py" }, @@ -1678,6 +1679,7 @@ dev = [ { name = "pytest-env", specifier = "~=1.1.3" }, { name = "pytest-mock", specifier = "~=3.14.0" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = "~=0.14.0" }, { name = "scipy-stubs", specifier = ">=1.15.3.0" }, { name = "sseclient-py", specifier = ">=1.8.0" }, @@ -1896,6 +1898,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/d8/2a1c638d9e0aa7e269269a1a1bf423ddd94267f1a01bbe3ad03432b67dd4/eval_type_backport-0.3.0-py3-none-any.whl", hash = "sha256:975a10a0fe333c8b6260d7fdb637698c9a16c3a9e3b6eb943fee6a6f67a37fe8", size = 6061, upload-time = "2025-11-13T20:56:49.499Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "faker" version = "38.2.0" @@ -5141,6 +5152,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-calamine" version = "0.5.4" diff --git a/dev/pytest/pytest_unit_tests.sh b/dev/pytest/pytest_unit_tests.sh index 496cb40952..7c39a48bf4 100755 --- a/dev/pytest/pytest_unit_tests.sh +++ b/dev/pytest/pytest_unit_tests.sh @@ -5,6 +5,12 @@ SCRIPT_DIR="$(dirname "$(realpath "$0")")" cd "$SCRIPT_DIR/../.." PYTEST_TIMEOUT="${PYTEST_TIMEOUT:-20}" +PYTEST_XDIST_ARGS="${PYTEST_XDIST_ARGS:--n auto}" -# libs -pytest --timeout "${PYTEST_TIMEOUT}" api/tests/unit_tests +# Run most tests in parallel (excluding controllers which have import conflicts with xdist) +# Controller tests have module-level side effects (Flask route registration) that cause +# race conditions when imported concurrently by multiple pytest-xdist workers. +pytest --timeout "${PYTEST_TIMEOUT}" ${PYTEST_XDIST_ARGS} api/tests/unit_tests --ignore=api/tests/unit_tests/controllers + +# Run controller tests sequentially to avoid import race conditions +pytest --timeout "${PYTEST_TIMEOUT}" api/tests/unit_tests/controllers