diff --git a/.agents/skills/component-refactoring/SKILL.md b/.agents/skills/component-refactoring/SKILL.md index 98a94592ab..a7cae67e8f 100644 --- a/.agents/skills/component-refactoring/SKILL.md +++ b/.agents/skills/component-refactoring/SKILL.md @@ -63,7 +63,7 @@ pnpm analyze-component --json ```typescript // ❌ Before: Complex state logic in component -const Configuration: FC = () => { +function Configuration() { const [modelConfig, setModelConfig] = useState(...) const [datasetConfigs, setDatasetConfigs] = useState(...) const [completionParams, setCompletionParams] = useState({}) @@ -85,7 +85,7 @@ export const useModelConfig = (appId: string) => { } // Component becomes cleaner -const Configuration: FC = () => { +function Configuration() { const { modelConfig, setModelConfig } = useModelConfig(appId) return
...
} @@ -189,8 +189,6 @@ const Template = useMemo(() => { **Dify Convention**: - This skill is for component decomposition, not query/mutation design. -- When refactoring data fetching, follow `web/AGENTS.md`. -- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling. - Do not introduce deprecated `useInvalid` / `useReset`. - Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state. diff --git a/.agents/skills/component-refactoring/references/complexity-patterns.md b/.agents/skills/component-refactoring/references/complexity-patterns.md index 5a0a268f38..2873630d4b 100644 --- a/.agents/skills/component-refactoring/references/complexity-patterns.md +++ b/.agents/skills/component-refactoring/references/complexity-patterns.md @@ -60,8 +60,10 @@ const Template = useMemo(() => { **After** (complexity: ~3): ```typescript +import type { ComponentType } from 'react' + // Define lookup table outside component -const TEMPLATE_MAP: Record>> = { +const TEMPLATE_MAP: Record>> = { [AppModeEnum.CHAT]: { [LanguagesSupported[1]]: TemplateChatZh, [LanguagesSupported[7]]: TemplateChatJa, diff --git a/.agents/skills/component-refactoring/references/component-splitting.md b/.agents/skills/component-refactoring/references/component-splitting.md index 78a3389100..81c007e005 100644 --- a/.agents/skills/component-refactoring/references/component-splitting.md +++ b/.agents/skills/component-refactoring/references/component-splitting.md @@ -65,10 +65,10 @@ interface ConfigurationHeaderProps { onPublish: () => void } -const ConfigurationHeader: FC = ({ +function ConfigurationHeader({ isAdvancedMode, onPublish, -}) => { +}: ConfigurationHeaderProps) { const { t } = useTranslation() return ( @@ -136,7 +136,7 @@ const AppInfo = () => { } // ✅ After: Separate view components -const AppInfoExpanded: FC = ({ appDetail, onAction }) => { +function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) { return (
{/* Clean, focused expanded view */} @@ -144,7 +144,7 @@ const AppInfoExpanded: FC = ({ appDetail, onAction }) => { ) } -const AppInfoCollapsed: FC = ({ appDetail, onAction }) => { +function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) { return (
{/* Clean, focused collapsed view */} @@ -203,12 +203,12 @@ interface AppInfoModalsProps { onSuccess: () => void } -const AppInfoModals: FC = ({ +function AppInfoModals({ appDetail, activeModal, onClose, onSuccess, -}) => { +}: AppInfoModalsProps) { const handleEdit = async (data) => { /* logic */ } const handleDuplicate = async (data) => { /* logic */ } const handleDelete = async () => { /* logic */ } @@ -296,7 +296,7 @@ interface OperationItemProps { onAction: (id: string) => void } -const OperationItem: FC = ({ operation, onAction }) => { +function OperationItem({ operation, onAction }: OperationItemProps) { return (
{operation.icon} @@ -435,7 +435,7 @@ interface ChildProps { onSubmit: () => void } -const Child: FC = ({ value, onChange, onSubmit }) => { +function Child({ value, onChange, onSubmit }: ChildProps) { return (
onChange(e.target.value)} /> diff --git a/.agents/skills/component-refactoring/references/hook-extraction.md b/.agents/skills/component-refactoring/references/hook-extraction.md index 0d567eb2a6..6fad2c8885 100644 --- a/.agents/skills/component-refactoring/references/hook-extraction.md +++ b/.agents/skills/component-refactoring/references/hook-extraction.md @@ -112,13 +112,13 @@ export const useModelConfig = ({ ```typescript // Before: 50+ lines of state management -const Configuration: FC = () => { +function Configuration() { const [modelConfig, setModelConfig] = useState(...) // ... lots of related state and effects } // After: Clean component -const Configuration: FC = () => { +function Configuration() { const { modelConfig, setModelConfig, @@ -159,8 +159,6 @@ const Configuration: FC = () => { When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns. -- Follow `web/AGENTS.md` first. -- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling. - Do not introduce deprecated `useInvalid` / `useReset`. - Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks. diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md index de6b58f26d..dd7d204678 100644 --- a/.agents/skills/e2e-cucumber-playwright/SKILL.md +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -23,7 +23,7 @@ Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS - `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter 3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved. 4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved. -5. Re-check official docs with Context7 before introducing a new Playwright or Cucumber pattern. +5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern. ## Local Rules diff --git a/.agents/skills/frontend-code-review/references/performance.md b/.agents/skills/frontend-code-review/references/performance.md index d91ddafee9..ab6fdbfe09 100644 --- a/.agents/skills/frontend-code-review/references/performance.md +++ b/.agents/skills/frontend-code-review/references/performance.md @@ -9,18 +9,18 @@ Category: Performance When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks. -## Complex prop memoization +## Complex prop stability -IsUrgent: True +IsUrgent: False Category: Performance ### Description -Wrap complex prop values (objects, arrays, maps) in `useMemo` prior to passing them into child components to guarantee stable references and prevent unnecessary renders. +Only require stable object, array, or map props when there is a clear reason: the child is memoized, the value participates in effect/query dependencies, the value is part of a stable-reference API contract, or profiling/local behavior shows avoidable re-renders. Do not request `useMemo` for every inline object by default; `how-to-write-component` treats memoization as a targeted optimization. Update this file when adding, editing, or removing Performance rules so the catalog remains accurate. -Wrong: +Risky: ```tsx ``` -Right: +Better when stable identity matters: ```tsx const config = useMemo(() => ({ diff --git a/.agents/skills/frontend-query-mutation/SKILL.md b/.agents/skills/frontend-query-mutation/SKILL.md deleted file mode 100644 index 10c49d222e..0000000000 --- a/.agents/skills/frontend-query-mutation/SKILL.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: frontend-query-mutation -description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions()/mutationOptions() directly or extract a helper or use-* hook, configuring oRPC experimental_defaults/default options, handling conditional queries, cache updates/invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers. ---- - -# Frontend Query & Mutation - -## Intent - -- Keep contract as the single source of truth in `web/contract/*`. -- Prefer contract-shaped `queryOptions()` and `mutationOptions()`. -- Keep default cache behavior with `consoleQuery`/`marketplaceQuery` setup, and keep business orchestration in feature vertical hooks when direct contract calls are not enough. -- Treat `web/service/use-*` query or mutation wrappers as legacy migration targets, not the preferred destination. -- Keep abstractions minimal to preserve TypeScript inference. - -## Workflow - -1. Identify the change surface. - - Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape. - - Read `references/runtime-rules.md` for conditional queries, default options, cache updates/invalidation, error handling, and legacy migrations. - - Read both references when a task spans contract shape and runtime behavior. -2. Implement the smallest abstraction that fits the task. - - Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site. - - Extract a small shared query helper only when multiple call sites share the same extra options. - - Create or keep feature hooks only for real orchestration or shared domain behavior. - - When touching thin `web/service/use-*` wrappers, migrate them away when feasible. -3. Preserve Dify conventions. - - Keep contract inputs in `{ params, query?, body? }` shape. - - Bind default cache updates/invalidation in `createTanstackQueryUtils(...experimental_defaults...)`; use feature hooks only for workflows that cannot be expressed as default operation behavior. - - Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required. - -## Files Commonly Touched - -- `web/contract/console/*.ts` -- `web/contract/marketplace.ts` -- `web/contract/router.ts` -- `web/service/client.ts` -- legacy `web/service/use-*.ts` files when migrating wrappers away -- component and hook call sites using `consoleQuery` or `marketplaceQuery` - -## References - -- Use `references/contract-patterns.md` for contract shape, router registration, query and mutation helpers, and anti-patterns that degrade inference. -- Use `references/runtime-rules.md` for conditional queries, invalidation, `mutate` versus `mutateAsync`, and legacy migration rules. - -Treat this skill as the single query and mutation entry point for Dify frontend work. Keep detailed rules in the reference files instead of duplicating them in project docs. diff --git a/.agents/skills/frontend-query-mutation/agents/openai.yaml b/.agents/skills/frontend-query-mutation/agents/openai.yaml deleted file mode 100644 index 79e7e7d214..0000000000 --- a/.agents/skills/frontend-query-mutation/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Frontend Query & Mutation" - short_description: "Dify TanStack Query, oRPC, and default option patterns" - default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, oRPC default options, conditional queries, cache updates/invalidation, or legacy query/mutation migrations." diff --git a/.agents/skills/frontend-query-mutation/references/contract-patterns.md b/.agents/skills/frontend-query-mutation/references/contract-patterns.md deleted file mode 100644 index 25ccfc81d7..0000000000 --- a/.agents/skills/frontend-query-mutation/references/contract-patterns.md +++ /dev/null @@ -1,129 +0,0 @@ -# Contract Patterns - -## Table of Contents - -- Intent -- Minimal structure -- Core workflow -- Query usage decision rule -- Mutation usage decision rule -- Thin hook decision rule -- Anti-patterns -- Contract rules -- Type export - -## Intent - -- Keep contract as the single source of truth in `web/contract/*`. -- Default query usage to call-site `useQuery(consoleQuery|marketplaceQuery.xxx.queryOptions(...))` when endpoint behavior maps 1:1 to the contract. -- Keep abstractions minimal and preserve TypeScript inference. - -## Minimal Structure - -```text -web/contract/ -├── base.ts -├── router.ts -├── marketplace.ts -└── console/ - ├── billing.ts - └── ...other domains -web/service/client.ts -``` - -## Core Workflow - -1. Define contract in `web/contract/console/{domain}.ts` or `web/contract/marketplace.ts`. - - Use `base.route({...}).output(type<...>())` as the baseline. - - Add `.input(type<...>())` only when the request has `params`, `query`, or `body`. - - For `GET` without input, omit `.input(...)`; do not use `.input(type())`. -2. Register contract in `web/contract/router.ts`. - - Import directly from domain files and nest by API prefix. -3. Consume from UI call sites via oRPC query utilities. - -```typescript -import { useQuery } from '@tanstack/react-query' -import { consoleQuery } from '@/service/client' - -const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({ - staleTime: 5 * 60 * 1000, - throwOnError: true, - select: invoice => invoice.url, -})) -``` - -## Query Usage Decision Rule - -1. Default to direct `*.queryOptions(...)` usage at the call site. -2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook. -3. Create or keep feature hooks only for orchestration. - - Combine multiple queries or mutations. - - Share domain-level derived state or invalidation helpers. - - Prefer `web/features/{domain}/hooks/*` for feature-owned workflows. -4. Treat `web/service/use-{domain}.ts` as legacy. - - Do not create new thin service wrappers for oRPC contracts. - - When touching existing wrappers, inline direct `consoleQuery` or `marketplaceQuery` consumption when the wrapper is only a passthrough. - -```typescript -const invoicesBaseQueryOptions = () => - consoleQuery.billing.invoices.queryOptions({ retry: false }) - -const invoiceQuery = useQuery({ - ...invoicesBaseQueryOptions(), - throwOnError: true, -}) -``` - -## Mutation Usage Decision Rule - -1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`. -2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic. - -```typescript -const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions()) -``` - -## Thin Hook Decision Rule - -Remove thin hooks when they only rename a single oRPC query or mutation helper. -Keep hooks when they orchestrate business behavior across multiple operations, own local workflow state, or normalize a feature-specific API. -Prefer feature vertical hooks for kept orchestration. Do not move new contract-first wrappers into `web/service/use-*`. - -Use: - -```typescript -const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions()) -``` - -Keep: - -```typescript -const applyTagBindingsMutation = useApplyTagBindingsMutation() -``` - -`useApplyTagBindingsMutation` is acceptable because it coordinates bind and unbind requests, computes deltas, and exposes a feature-level workflow rather than a single endpoint passthrough. - -## Anti-Patterns - -- Do not wrap `useQuery` with `options?: Partial`. -- Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case. -- Do not create thin `use-*` passthrough hooks for a single endpoint. -- Do not create business-layer helpers whose only purpose is to call `consoleQuery.xxx.mutationOptions()` or `queryOptions()`. -- Do not introduce new `web/service/use-*` files for oRPC contract passthroughs. -- These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection. - -## Contract Rules - -- Input structure: always use `{ params, query?, body? }`. -- No-input `GET`: omit `.input(...)`; do not use `.input(type())`. -- Path params: use `{paramName}` in the path and match it in the `params` object. -- Router nesting: group by API prefix, for example `/billing/*` becomes `billing: {}`. -- No barrel files: import directly from specific files. -- Types: import from `@/types/` and use the `type()` helper. -- Mutations: prefer `mutationOptions`; use explicit `mutationKey` mainly for defaults, filtering, and devtools. - -## Type Export - -```typescript -export type ConsoleInputs = InferContractRouterInputs -``` diff --git a/.agents/skills/frontend-query-mutation/references/runtime-rules.md b/.agents/skills/frontend-query-mutation/references/runtime-rules.md deleted file mode 100644 index 91b484d438..0000000000 --- a/.agents/skills/frontend-query-mutation/references/runtime-rules.md +++ /dev/null @@ -1,172 +0,0 @@ -# Runtime Rules - -## Table of Contents - -- Conditional queries -- oRPC default options -- Cache invalidation -- Key API guide -- `mutate` vs `mutateAsync` -- Legacy migration - -## Conditional Queries - -Prefer contract-shaped `queryOptions(...)`. -When required input is missing, prefer `input: skipToken` instead of placeholder params or non-null assertions. -Use `enabled` only for extra business gating after the input itself is already valid. - -```typescript -import { skipToken, useQuery } from '@tanstack/react-query' - -// Disable the query by skipping input construction. -function useAccessMode(appId: string | undefined) { - return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({ - input: appId - ? { params: { appId } } - : skipToken, - })) -} - -// Avoid runtime-only guards that bypass type checking. -function useBadAccessMode(appId: string | undefined) { - return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({ - input: { params: { appId: appId! } }, - enabled: !!appId, - })) -} -``` - -## oRPC Default Options - -Use `experimental_defaults` in `createTanstackQueryUtils` when a contract operation should always carry shared TanStack Query behavior, such as default stale time, mutation cache writes, or invalidation. - -Place defaults at the query utility creation point in `web/service/client.ts`: - -```typescript -export const consoleQuery = createTanstackQueryUtils(consoleClient, { - path: ['console'], - experimental_defaults: { - tags: { - create: { - mutationOptions: { - onSuccess: (tag, _variables, _result, context) => { - context.client.setQueryData( - consoleQuery.tags.list.queryKey({ - input: { - query: { - type: tag.type, - }, - }, - }), - (oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags, - ) - }, - }, - }, - }, - }, -}) -``` - -Rules: - -- Keep defaults inline in the `consoleQuery` or `marketplaceQuery` initialization when they need sibling oRPC key builders. -- Do not create a wrapper function solely to host `createTanstackQueryUtils`. -- Do not split defaults into a vertical feature file if that forces handwritten operation paths such as `generateOperationKey(['console', ...])`. -- Keep feature-level orchestration in the feature vertical; keep query utility lifecycle defaults with the query utility. -- Prefer call-site callbacks for UI feedback only; shared cache behavior belongs in oRPC defaults when it is tied to a contract operation. - -## Cache Invalidation - -Bind shared invalidation in oRPC defaults when it is tied to a contract operation. -Use feature vertical hooks only for multi-operation workflows or domain orchestration that cannot live in a single operation default. -Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate. - -Use: - -- `.key()` for namespace or prefix invalidation -- `.queryKey(...)` only for exact cache reads or writes such as `getQueryData` and `setQueryData` -- `queryClient.invalidateQueries(...)` in mutation `onSuccess` - -Do not use deprecated `useInvalid` from `use-base.ts`. - -```typescript -// Feature orchestration owns cache invalidation only when defaults are not enough. -export const useUpdateAccessMode = () => { - const queryClient = useQueryClient() - - return useMutation(consoleQuery.accessControl.updateAccessMode.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(), - }) - }, - })) -} - -// Component only adds UI behavior. -updateAccessMode({ appId, mode }, { - onSuccess: () => toast.success('...'), -}) - -// Avoid putting invalidation knowledge in the component. -mutate({ appId, mode }, { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(), - }) - }, -}) -``` - -## Key API Guide - -- `.key(...)` - - Use for partial matching operations. - - Prefer it for invalidation, refetch, and cancel patterns. - - Example: `queryClient.invalidateQueries({ queryKey: consoleQuery.billing.key() })` -- `.queryKey(...)` - - Use for a specific query's full key. - - Prefer it for exact cache addressing and direct reads or writes. -- `.mutationKey(...)` - - Use for a specific mutation's full key. - - Prefer it for mutation defaults registration, mutation-status filtering, and devtools grouping. - -## `mutate` vs `mutateAsync` - -Prefer `mutate` by default. -Use `mutateAsync` only when Promise semantics are truly required, such as parallel mutations or sequential steps with result dependencies. - -Rules: - -- Event handlers should usually call `mutate(...)` with `onSuccess` or `onError`. -- Every `await mutateAsync(...)` must be wrapped in `try/catch`. -- Do not use `mutateAsync` when callbacks already express the flow clearly. - -```typescript -// Default case. -mutation.mutate(data, { - onSuccess: result => router.push(result.url), -}) - -// Promise semantics are required. -try { - const order = await createOrder.mutateAsync(orderData) - await confirmPayment.mutateAsync({ orderId: order.id, token }) - router.push(`/orders/${order.id}`) -} -catch (error) { - toast.error(error instanceof Error ? error.message : 'Unknown error') -} -``` - -## Legacy Migration - -When touching old code, migrate it toward these rules: - -| Old pattern | New pattern | -|---|---| -| `useInvalid(key)` in service wrappers | oRPC defaults, or a feature vertical hook for real orchestration | -| component-triggered invalidation after mutation | move invalidation into oRPC defaults or a feature vertical hook | -| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` | -| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` | diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index 105c979c58..21c46d75bc 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -5,7 +5,7 @@ description: Generate Vitest + React Testing Library tests for Dify frontend com # Dify Frontend Testing Skill -This skill enables Claude to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices. +This skill enables Codex 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/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`). @@ -24,35 +24,27 @@ Apply this skill when the user: **Do NOT apply** when: - User is asking about backend/API tests (Python/pytest) -- User is asking about E2E tests (Playwright/Cypress) +- User is asking about E2E tests (Cucumber + Playwright under `e2e/`) - User is only asking conceptual questions without code context ## Quick Reference -### Tech Stack - -| Tool | Version | Purpose | -|------|---------|---------| -| Vitest | 4.0.16 | Test runner | -| React Testing Library | 16.0 | Component testing | -| jsdom | - | Test environment | -| nock | 14.0 | HTTP mocking | -| TypeScript | 5.x | Type safety | - ### Key Commands +Run these commands from `web/`. From the repository root, prefix them with `pnpm -C web`. + ```bash # Run all tests pnpm test # Watch mode -pnpm test:watch +pnpm test --watch # Run specific file pnpm test path/to/file.spec.tsx # Generate coverage report -pnpm test:coverage +pnpm test --coverage # Analyze component complexity pnpm analyze-component @@ -228,7 +220,10 @@ Every test should clearly separate: ### 2. Black-Box Testing - Test observable behavior, not implementation details -- Use semantic queries (getByRole, getByLabelText) +- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`) +- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`. +- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment. +- Do not assert decorative icons by test id. Assert the named control that contains them, or mark decorative icons `aria-hidden`. - Avoid testing internal state directly - **Prefer pattern matching over hardcoded strings** in assertions: diff --git a/.agents/skills/frontend-testing/references/mocking.md b/.agents/skills/frontend-testing/references/mocking.md index 8c2f1c0c58..7723e4df21 100644 --- a/.agents/skills/frontend-testing/references/mocking.md +++ b/.agents/skills/frontend-testing/references/mocking.md @@ -56,7 +56,7 @@ See [Zustand Store Testing](#zustand-store-testing) section for full details. | Location | Purpose | |----------|---------| -| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `next/image`, `zustand`) | +| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) | | `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) | | `web/__mocks__/` | Reusable mock factories shared across multiple test files | | Test file | Test-specific mocks, inline with `vi.mock()` | @@ -216,28 +216,21 @@ describe('Component', () => { }) ``` -### 5. HTTP Mocking with Nock +### 5. HTTP and `fetch` Mocking ```typescript -import nock from 'nock' - -const GITHUB_HOST = 'https://api.github.com' -const GITHUB_PATH = '/repos/owner/repo' - -const mockGithubApi = (status: number, body: Record, delayMs = 0) => { - return nock(GITHUB_HOST) - .get(GITHUB_PATH) - .delay(delayMs) - .reply(status, body) -} - describe('GithubComponent', () => { - afterEach(() => { - nock.cleanAll() + beforeEach(() => { + vi.clearAllMocks() }) it('should display repo info', async () => { - mockGithubApi(200, { name: 'dify', stars: 1000 }) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ name: 'dify', stars: 1000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) render() @@ -247,7 +240,12 @@ describe('GithubComponent', () => { }) it('should handle API error', async () => { - mockGithubApi(500, { message: 'Server error' }) + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }), + ) render() @@ -258,6 +256,8 @@ describe('GithubComponent', () => { }) ``` +Prefer mocking `@/service/*` modules or spying on `global.fetch` / `ky` clients with deterministic responses. Do not introduce an HTTP interception dependency such as `nock` or MSW unless it is already declared in the workspace or adding it is part of the task. + ### 6. Context Providers ```typescript @@ -332,7 +332,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => { 1. **Don't mock Zustand store modules** - Use real stores with `setState()` 1. Don't mock components you can import directly 1. Don't create overly simplified mocks that miss conditional logic -1. Don't forget to clean up nock after each test +1. Don't leave HTTP mocks or service mock state leaking between tests 1. Don't use `any` types in mocks without necessity ### Mock Decision Tree diff --git a/.agents/skills/frontend-testing/references/workflow.md b/.agents/skills/frontend-testing/references/workflow.md index bc4ed8285a..27755d42a7 100644 --- a/.agents/skills/frontend-testing/references/workflow.md +++ b/.agents/skills/frontend-testing/references/workflow.md @@ -227,12 +227,12 @@ Failing tests compound: **Fix failures immediately before proceeding.** -## Integration with Claude's Todo Feature +## Integration with Codex's Todo Feature -When using Claude for multi-file testing: +When using Codex for multi-file testing: -1. **Ask Claude to create a todo list** before starting -1. **Request one file at a time** or ensure Claude processes incrementally +1. **Create a todo list** before starting +1. **Process one file at a time** 1. **Verify each test passes** before asking for the next 1. **Mark todos complete** as you progress diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md new file mode 100644 index 0000000000..ac77112993 --- /dev/null +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -0,0 +1,71 @@ +--- +name: how-to-write-component +description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling. +--- + +# How To Write A Component + +Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation. + +## Core Defaults + +- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit. +- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them. +- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature. +- Use Tailwind CSS v4.1+ rules via the `tailwind-css-rules` skill. Prefer v4 utilities, `gap`, `text-size/line-height`, `min-h-dvh`, and avoid deprecated utilities and `@apply`. + +## Ownership + +- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home. +- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing. +- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children. +- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow. +- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action. +- Prefer uncontrolled DOM state and CSS variables before adding controlled props. + +## Components, Props, And Types + +- Type component signatures directly; do not use `FC` or `React.FC`. +- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs. +- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files. +- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer. +- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them. +- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary. +- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks. + +## Queries And Mutations + +- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape. +- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. +- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it. +- Keep feature hooks for real orchestration, workflow state, or shared domain behavior. +- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid. +- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows. +- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules. +- Do not use deprecated `useInvalid` or `useReset`. +- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`. + +## Component Boundaries + +- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner. +- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer. +- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary. +- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow. +- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment. +- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible. +- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. + +## You Might Not Need An Effect + +- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration. +- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive. +- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known. +- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render. +- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary. +- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components. +- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow. + +## Navigation And Performance + +- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission. +- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. diff --git a/.agents/skills/tailwind-css-rules/SKILL.md b/.agents/skills/tailwind-css-rules/SKILL.md new file mode 100644 index 0000000000..3528548036 --- /dev/null +++ b/.agents/skills/tailwind-css-rules/SKILL.md @@ -0,0 +1,367 @@ +--- +name: tailwind-css-rules +description: Tailwind CSS v4.1+ rules and best practices. Use when writing, reviewing, refactoring, or upgrading Tailwind CSS classes and styles, especially v4 utility migrations, layout spacing, typography, responsive variants, dark mode, gradients, CSS variables, and component styling. +--- + +# Tailwind CSS Rules and Best Practices + +## Core Principles + +- **Always use Tailwind CSS v4.1+** - Ensure the codebase is using the latest version +- **Do not use deprecated or removed utilities** - ALWAYS use the replacement +- **Never use `@apply`** - Use CSS variables, the `--spacing()` function, or framework components instead +- **Check for redundant classes** - Remove any classes that aren't necessary +- **Group elements logically** to simplify responsive tweaks later + +## Upgrading to Tailwind CSS v4 + +### Before Upgrading + +- **Always read the upgrade documentation first** - Read https://tailwindcss.com/docs/upgrade-guide and https://tailwindcss.com/blog/tailwindcss-v4 before starting an upgrade. +- Ensure the git repository is in a clean state before starting + +### Upgrade Process + +1. Run the upgrade command: `npx @tailwindcss/upgrade@latest` for both major and minor updates +2. The tool will convert JavaScript config files to the new CSS format +3. Review all changes extensively to clean up any false positives +4. Test thoroughly across your application + +## Breaking Changes Reference + +### Removed Utilities (NEVER use these in v4) + +| ❌ Deprecated | ✅ Replacement | +| ----------------------- | ------------------------------------------------- | +| `bg-opacity-*` | Use opacity modifiers like `bg-black/50` | +| `text-opacity-*` | Use opacity modifiers like `text-black/50` | +| `border-opacity-*` | Use opacity modifiers like `border-black/50` | +| `divide-opacity-*` | Use opacity modifiers like `divide-black/50` | +| `ring-opacity-*` | Use opacity modifiers like `ring-black/50` | +| `placeholder-opacity-*` | Use opacity modifiers like `placeholder-black/50` | +| `flex-shrink-*` | `shrink-*` | +| `flex-grow-*` | `grow-*` | +| `overflow-ellipsis` | `text-ellipsis` | +| `decoration-slice` | `box-decoration-slice` | +| `decoration-clone` | `box-decoration-clone` | + +### Renamed Utilities + +Use the v4 name when migrating code that still carries Tailwind v3 semantics. Do not blanket-replace existing v4 classes: classes such as `rounded-sm`, `shadow-sm`, `ring-1`, and `ring-2` are valid in this codebase when they intentionally represent the current design scale. + +| ❌ v3 pattern | ✅ v4 pattern | +| ------------------- | -------------------------------------------------- | +| `bg-gradient-*` | `bg-linear-*` | +| old shadow scale | verify against the current Tailwind/design scale | +| old blur scale | verify against the current Tailwind/design scale | +| old radius scale | use the Dify radius token mapping when applicable | +| `outline-none` | `outline-hidden` | +| bare `ring` utility | use an explicit ring width such as `ring-1`/`ring-2`/`ring-3` | + +For Figma radius tokens, follow `packages/dify-ui/AGENTS.md`. For example, `--radius/xs` maps to `rounded-sm`; do not rewrite it to `rounded-xs`. + +## Layout and Spacing Rules + +### Flexbox and Grid Spacing + +#### Always use gap utilities for internal spacing + +Gap provides consistent spacing without edge cases (no extra space on last items). It's cleaner and more maintainable than margins on children. + +```html + +
+
Item 1
+
Item 2
+
Item 3
+ +
+ + +
+
Item 1
+
Item 2
+
Item 3
+
+``` + +#### Gap vs Space utilities + +- **Never use `space-x-*` or `space-y-*` in flex/grid layouts** - always use gap +- Space utilities add margins to children and have issues with wrapped items +- Gap works correctly with flex-wrap and all flex directions + +```html + +
+ +
+ + +
+ +
+``` + +### General Spacing Guidelines + +- **Prefer top and left margins** over bottom and right margins (unless conditionally rendered) +- **Use padding on parent containers** instead of bottom margins on the last child +- **Always use `min-h-dvh` instead of `min-h-screen`** - `min-h-screen` is buggy on mobile Safari +- **Prefer `size-*` utilities** over separate `w-*` and `h-*` when setting equal dimensions +- For max-widths, prefer the container scale (e.g., `max-w-2xs` over `max-w-72`) + +## Typography Rules + +### Line Heights + +- **Never use `leading-*` classes** - Always use line height modifiers with text size +- **Always use fixed line heights from the spacing scale** - Don't use named values + +```html + +

Text with separate line height

+

Text with named line height

+ + +

Text with line height modifier

+

Text with specific line height

+``` + +### Font Size Reference + +Be precise with font sizes - know the actual pixel values: + +- `text-xs` = 12px +- `text-sm` = 14px +- `text-base` = 16px +- `text-lg` = 18px +- `text-xl` = 20px + +## Color and Opacity + +### Opacity Modifiers + +**Never use `bg-opacity-*`, `text-opacity-*`, etc.** - use the opacity modifier syntax: + +```html + +
Old opacity syntax
+ + +
Modern opacity syntax
+``` + +## Responsive Design + +### Breakpoint Optimization + +- **Check for redundant classes across breakpoints** +- **Only add breakpoint variants when values change** + +```html + +
+ +
+ + +
+ +
+``` + +## Dark Mode + +### Dark Mode Best Practices + +- Use the plain `dark:` variant pattern +- Put light mode styles first, then dark mode styles +- Ensure `dark:` variant comes before other variants + +```html + +
+ +
+``` + +## Gradient Utilities + +- **ALWAYS Use `bg-linear-*` instead of `bg-gradient-*` utilities** - The gradient utilities were renamed in v4 +- Use the new `bg-radial` or `bg-radial-[]` to create radial gradients +- Use the new `bg-conic` or `bg-conic-*` to create conic gradients + +```html + +
+
+
+ + +
+``` + +## Working with CSS Variables + +### Accessing Theme Values + +Tailwind CSS v4 exposes all theme values as CSS variables: + +```css +/* Access colors, and other theme values */ +.custom-element { + background: var(--color-red-500); + border-radius: var(--radius-lg); +} +``` + +### The `--spacing()` Function + +Use the dedicated `--spacing()` function for spacing calculations: + +```css +.custom-class { + margin-top: calc(100vh - --spacing(16)); +} +``` + +### Extending theme values + +Use CSS to extend theme values: + +```css +@import "tailwindcss"; + +@theme { + --color-mint-500: oklch(0.72 0.11 178); +} +``` + +```html +
+ +
+``` + +## New v4 Features + +### Container Queries + +Use the `@container` class and size variants: + +```html +
+
+ +
+ +
+
+
+``` + +### Container Query Units + +Use container-based units like `cqw` for responsive sizing: + +```html +
+

Responsive to container width

+
+``` + +### Text Shadows (v4.1) + +Use text-shadow-\* utilities from text-shadow-2xs to text-shadow-lg: + +```html + +

Large shadow

+

Small shadow with opacity

+``` + +### Masking (v4.1) + +Use the new composable mask utilities for image and gradient masks: + +```html + +
Top fade
+
Bottom gradient
+
+ Fade from white to black +
+ + +
+ Radial mask +
+``` + +## Component Patterns + +### Avoiding Utility Inheritance + +Don't add utilities to parents that you override in children: + +```html + +
+

Centered Heading

+
Left-aligned content
+
+ + +
+

Centered Heading

+
Left-aligned content
+
+``` + +### Component Extraction + +- Extract repeated patterns into framework components, not CSS classes +- Keep utility classes in templates/JSX +- Use data attributes for complex state-based styling + +## CSS Best Practices + +### Nesting Guidelines + +- Use nesting when styling both parent and children +- Avoid empty parent selectors + +```css +/* ✅ Good nesting - parent has styles */ +.card { + padding: --spacing(4); + + > .card-title { + font-weight: bold; + } +} + +/* ❌ Avoid empty parents */ +ul { + > li { + /* Parent has no styles */ + } +} +``` + +## Common Pitfalls to Avoid + +1. **Using old opacity utilities** - Always use `/opacity` syntax like `bg-red-500/60` +2. **Redundant breakpoint classes** - Only specify changes +3. **Space utilities in flex/grid** - Always use gap +4. **Leading utilities** - Use line-height modifiers like `text-sm/6` +5. **Arbitrary values** - Use the design scale +6. **@apply directive** - Use components or CSS variables +7. **min-h-screen on mobile** - Use min-h-dvh +8. **Separate width/height** - Use size utilities when equal +9. **Arbitrary values** - Always use Tailwind's predefined scale whenever possible (e.g., use `ml-4` over `ml-[16px]`) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index f59cc6be48..aefcf1b5ac 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -9,6 +9,6 @@ jobs: pull-requests: write runs-on: depot-ubuntu-24.04 steps: - - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: sync-labels: true diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 7bb6fc1bbd..4e738df684 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -158,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 + uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index ae7589bbd6..3b5024683f 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,10 @@ DOCKER_REGISTRY=langgenius WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web API_IMAGE=$(DOCKER_REGISTRY)/dify-api VERSION=latest +DOCKER_DIR=docker +DOCKER_MIDDLEWARE_ENV=$(DOCKER_DIR)/middleware.env +DOCKER_MIDDLEWARE_ENV_EXAMPLE=$(DOCKER_DIR)/envs/middleware.env.example +DOCKER_MIDDLEWARE_PROJECT=dify-middlewares-dev # Default target - show help .DEFAULT_GOAL := help @@ -17,8 +21,13 @@ dev-setup: prepare-docker prepare-web prepare-api # Step 1: Prepare Docker middleware prepare-docker: @echo "🐳 Setting up Docker middleware..." - @cp -n docker/middleware.env.example docker/middleware.env 2>/dev/null || echo "Docker middleware.env already exists" - @cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev up -d + @if [ ! -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \ + cp "$(DOCKER_MIDDLEWARE_ENV_EXAMPLE)" "$(DOCKER_MIDDLEWARE_ENV)"; \ + echo "Docker middleware.env created"; \ + else \ + echo "Docker middleware.env already exists"; \ + fi + @cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) up -d @echo "✅ Docker middleware started" # Step 2: Prepare web environment @@ -39,12 +48,18 @@ prepare-api: # Clean dev environment dev-clean: @echo "⚠️ Stopping Docker containers..." - @cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev down + @if [ -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \ + cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) down; \ + else \ + echo "Docker middleware.env does not exist, skipping compose down"; \ + fi @echo "🗑️ Removing volumes..." @rm -rf docker/volumes/db + @rm -rf docker/volumes/mysql @rm -rf docker/volumes/redis @rm -rf docker/volumes/plugin_daemon @rm -rf docker/volumes/weaviate + @rm -rf docker/volumes/sandbox/dependencies @rm -rf api/storage @echo "✅ Cleanup complete" @@ -132,7 +147,7 @@ help: @echo " make prepare-docker - Set up Docker middleware" @echo " make prepare-web - Set up web environment" @echo " make prepare-api - Set up API environment" - @echo " make dev-clean - Stop Docker middleware containers" + @echo " make dev-clean - Stop Docker middleware containers and remove dev data" @echo "" @echo "Backend Code Quality:" @echo " make format - Format code with ruff" diff --git a/api/.env.example b/api/.env.example index 56ba8a6c5d..40fed7403c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -34,7 +34,7 @@ TRIGGER_URL=http://localhost:5001 FILES_ACCESS_TIMEOUT=300 # Collaboration mode toggle -ENABLE_COLLABORATION_MODE=false +ENABLE_COLLABORATION_MODE=true # Access token expiration time in minutes ACCESS_TOKEN_EXPIRE_MINUTES=60 @@ -88,6 +88,10 @@ REDIS_HEALTH_CHECK_INTERVAL=30 CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1 CELERY_BACKEND=redis +# Ops trace retry configuration +OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60 +OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5 + # Database configuration DB_TYPE=postgresql DB_USERNAME=postgres diff --git a/api/app_factory.py b/api/app_factory.py index 48e50ceae9..5583071980 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -181,7 +181,6 @@ def initialize_extensions(app: DifyApp): ext_import_modules, ext_orjson, ext_forward_refs, - ext_set_secretkey, ext_compress, ext_code_based_extension, ext_database, @@ -189,6 +188,7 @@ def initialize_extensions(app: DifyApp): ext_migrate, ext_redis, ext_storage, + ext_set_secretkey, ext_logstore, # Initialize logstore after storage, before celery ext_celery, ext_login, diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 52e33c1789..ccb97d96ef 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -23,9 +23,9 @@ class SecurityConfig(BaseSettings): """ SECRET_KEY: str = Field( - description="Secret key for secure session cookie signing." - "Make sure you are changing this key for your deployment with a strong key." - "Generate a strong key using `openssl rand -base64 42` or set via the `SECRET_KEY` environment variable.", + description="Secret key for secure session cookie signing. " + "Leave empty to let Dify generate a persistent key in the storage directory, " + "or set a strong value via the `SECRET_KEY` environment variable.", default="", ) @@ -1137,6 +1137,18 @@ class MultiModalTransferConfig(BaseSettings): ) +class OpsTraceConfig(BaseSettings): + OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field( + description="Maximum retry attempts for transient ops trace provider dispatch failures.", + default=60, + ) + + OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field( + description="Delay in seconds between transient ops trace provider dispatch retry attempts.", + default=5, + ) + + class CeleryBeatConfig(BaseSettings): CELERY_BEAT_SCHEDULER_TIME: int = Field( description="Interval in days for Celery Beat scheduler execution, default to 1 day", @@ -1298,7 +1310,7 @@ class PositionConfig(BaseSettings): class CollaborationConfig(BaseSettings): ENABLE_COLLABORATION_MODE: bool = Field( description="Whether to enable collaboration mode features across the workspace", - default=False, + default=True, ) @@ -1417,6 +1429,7 @@ class FeatureConfig( ModelLoadBalanceConfig, ModerationConfig, MultiModalTransferConfig, + OpsTraceConfig, PositionConfig, RagEtlConfig, RepositoryConfig, diff --git a/api/configs/secret_key.py b/api/configs/secret_key.py new file mode 100644 index 0000000000..f8c33f6a2c --- /dev/null +++ b/api/configs/secret_key.py @@ -0,0 +1,38 @@ +"""SECRET_KEY persistence helpers for runtime setup.""" + +from __future__ import annotations + +import secrets + +from extensions.ext_storage import storage + +GENERATED_SECRET_KEY_FILENAME = ".dify_secret_key" + + +def resolve_secret_key(secret_key: str) -> str: + """Return an explicit SECRET_KEY or a generated key persisted in storage.""" + if secret_key: + return secret_key + + return _load_or_create_secret_key() + + +def _load_or_create_secret_key() -> str: + try: + persisted_key = storage.load_once(GENERATED_SECRET_KEY_FILENAME).decode("utf-8").strip() + if persisted_key: + return persisted_key + except FileNotFoundError: + pass + + generated_key = secrets.token_urlsafe(48) + + try: + storage.save(GENERATED_SECRET_KEY_FILENAME, f"{generated_key}\n".encode()) + except Exception as exc: + raise ValueError( + f"SECRET_KEY is not set and could not be generated at {GENERATED_SECRET_KEY_FILENAME}. " + "Set SECRET_KEY explicitly or make storage writable." + ) from exc + + return generated_key diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index a8ab5bec48..4429039d79 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -39,7 +39,7 @@ from libs.login import current_account_with_tenant, login_required from models import App, DatasetPermissionEnum, Workflow from models.model import IconType from services.app_dsl_service import AppDslService -from services.app_service import AppService +from services.app_service import AppListParams, AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService from services.entities.dsl_entities import ImportMode, ImportStatus from services.entities.knowledge_entities.knowledge_entities import ( @@ -478,11 +478,18 @@ class AppListApi(Resource): current_user, current_tenant_id = current_account_with_tenant() args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args)) - args_dict = args.model_dump() + params = AppListParams( + page=args.page, + limit=args.limit, + mode=args.mode, + name=args.name, + tag_ids=args.tag_ids, + is_created_by_me=args.is_created_by_me, + ) # get app list app_service = AppService() - app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, args_dict) + app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params) if not app_pagination: empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json"), 200 @@ -546,9 +553,17 @@ class AppListApi(Resource): """Create app""" current_user, current_tenant_id = current_account_with_tenant() args = CreateAppPayload.model_validate(console_ns.payload) + params = CreateAppParams( + name=args.name, + description=args.description, + mode=args.mode, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + ) app_service = AppService() - app = app_service.create_app(current_tenant_id, args.model_dump(), current_user) + app = app_service.create_app(current_tenant_id, params, current_user) app_detail = AppDetail.model_validate(app, from_attributes=True) return app_detail.model_dump(mode="json"), 201 diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index d001dfba64..0e91779b2c 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -606,63 +606,63 @@ class DatasetIndexingEstimateApi(Resource): # validate args DocumentService.estimate_args_validate(args) extract_settings = [] - if args["info_list"]["data_source_type"] == "upload_file": - file_ids = args["info_list"]["file_info_list"]["file_ids"] - file_details = db.session.scalars( - select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids)) - ).all() + match args["info_list"]["data_source_type"]: + case "upload_file": + file_ids = args["info_list"]["file_info_list"]["file_ids"] + file_details = db.session.scalars( + select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids)) + ).all() + if file_details is None: + raise NotFound("File not found.") - if file_details is None: - raise NotFound("File not found.") - - if file_details: - for file_detail in file_details: + if file_details: + for file_detail in file_details: + extract_setting = ExtractSetting( + datasource_type=DatasourceType.FILE, + upload_file=file_detail, + document_model=args["doc_form"], + ) + extract_settings.append(extract_setting) + case "notion_import": + notion_info_list = args["info_list"]["notion_info_list"] + for notion_info in notion_info_list: + workspace_id = notion_info["workspace_id"] + credential_id = notion_info.get("credential_id") + for page in notion_info["pages"]: + extract_setting = ExtractSetting( + datasource_type=DatasourceType.NOTION, + notion_info=NotionInfo.model_validate( + { + "credential_id": credential_id, + "notion_workspace_id": workspace_id, + "notion_obj_id": page["page_id"], + "notion_page_type": page["type"], + "tenant_id": current_tenant_id, + } + ), + document_model=args["doc_form"], + ) + extract_settings.append(extract_setting) + case "website_crawl": + website_info_list = args["info_list"]["website_info_list"] + for url in website_info_list["urls"]: extract_setting = ExtractSetting( - datasource_type=DatasourceType.FILE, - upload_file=file_detail, - document_model=args["doc_form"], - ) - extract_settings.append(extract_setting) - elif args["info_list"]["data_source_type"] == "notion_import": - notion_info_list = args["info_list"]["notion_info_list"] - for notion_info in notion_info_list: - workspace_id = notion_info["workspace_id"] - credential_id = notion_info.get("credential_id") - for page in notion_info["pages"]: - extract_setting = ExtractSetting( - datasource_type=DatasourceType.NOTION, - notion_info=NotionInfo.model_validate( + datasource_type=DatasourceType.WEBSITE, + website_info=WebsiteInfo.model_validate( { - "credential_id": credential_id, - "notion_workspace_id": workspace_id, - "notion_obj_id": page["page_id"], - "notion_page_type": page["type"], + "provider": website_info_list["provider"], + "job_id": website_info_list["job_id"], + "url": url, "tenant_id": current_tenant_id, + "mode": "crawl", + "only_main_content": website_info_list["only_main_content"], } ), document_model=args["doc_form"], ) extract_settings.append(extract_setting) - elif args["info_list"]["data_source_type"] == "website_crawl": - website_info_list = args["info_list"]["website_info_list"] - for url in website_info_list["urls"]: - extract_setting = ExtractSetting( - datasource_type=DatasourceType.WEBSITE, - website_info=WebsiteInfo.model_validate( - { - "provider": website_info_list["provider"], - "job_id": website_info_list["job_id"], - "url": url, - "tenant_id": current_tenant_id, - "mode": "crawl", - "only_main_content": website_info_list["only_main_content"], - } - ), - document_model=args["doc_form"], - ) - extract_settings.append(extract_setting) - else: - raise ValueError("Data source type not support") + case _: + raise ValueError("Data source type not support") indexing_runner = IndexingRunner() try: response = indexing_runner.indexing_estimate( diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py index 3372a967d9..c4e13c41a5 100644 --- a/api/controllers/console/datasets/datasets_document.py +++ b/api/controllers/console/datasets/datasets_document.py @@ -369,28 +369,31 @@ class DatasetDocumentListApi(Resource): else: sort_logic = asc - if sort == "hit_count": - sub_query = ( - sa.select(DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count")) - .where(DocumentSegment.dataset_id == str(dataset_id)) - .group_by(DocumentSegment.document_id) - .subquery() - ) + match sort: + case "hit_count": + sub_query = ( + sa.select( + DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count") + ) + .where(DocumentSegment.dataset_id == str(dataset_id)) + .group_by(DocumentSegment.document_id) + .subquery() + ) - query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id).order_by( - sort_logic(sa.func.coalesce(sub_query.c.total_hit_count, 0)), - sort_logic(Document.position), - ) - elif sort == "created_at": - query = query.order_by( - sort_logic(Document.created_at), - sort_logic(Document.position), - ) - else: - query = query.order_by( - desc(Document.created_at), - desc(Document.position), - ) + query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id).order_by( + sort_logic(sa.func.coalesce(sub_query.c.total_hit_count, 0)), + sort_logic(Document.position), + ) + case "created_at": + query = query.order_by( + sort_logic(Document.created_at), + sort_logic(Document.position), + ) + case _: + query = query.order_by( + desc(Document.created_at), + desc(Document.position), + ) paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False) documents = paginated_documents.items diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 025c517d20..26b48ec599 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -106,7 +106,7 @@ app_detail_fields_with_site_copy["tags"] = fields.List(fields.Nested(tag_model)) app_detail_fields_with_site_copy["site"] = fields.Nested(site_model) app_detail_with_site_model = get_or_create_model("TrialAppDetailWithSite", app_detail_fields_with_site_copy) -simple_account_model = get_or_create_model("SimpleAccount", simple_account_fields) +simple_account_model = get_or_create_model("TrialSimpleAccount", simple_account_fields) conversation_variable_model = get_or_create_model("TrialConversationVariable", conversation_variable_fields) pipeline_variable_model = get_or_create_model("TrialPipelineVariable", pipeline_variable_fields) diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index cb48fe6715..e68eeeca25 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -136,7 +136,7 @@ def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[ if not dataset: raise ValueError("Dataset does not exist.") - if not dataset.indexing_technique and not args["indexing_technique"]: + if not dataset.indexing_technique and not args.get("indexing_technique"): raise ValueError("indexing_technique is required.") embedding_model_provider = payload.embedding_model_provider diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index e811c2b2e0..43546d57f5 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -32,7 +32,7 @@ from core.app.entities.task_entities import ( ) from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer from core.db.session_factory import session_factory -from core.helper.trace_id_helper import extract_external_trace_id_from_args +from core.helper.trace_id_helper import extract_external_trace_id_from_args, extract_parent_trace_context_from_args from core.ops.ops_trace_manager import TraceQueueManager from core.repositories import DifyCoreRepositoryFactory from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository @@ -166,6 +166,7 @@ class WorkflowAppGenerator(BaseAppGenerator): extras = { **extract_external_trace_id_from_args(args), + **extract_parent_trace_context_from_args(args), } workflow_run_id = str(workflow_run_id or uuid.uuid4()) # FIXME (Yeuoly): we need to remove the SKIP_PREPARE_USER_INPUTS_KEY from the args diff --git a/api/core/app/workflow/file_runtime.py b/api/core/app/workflow/file_runtime.py index 3a6f9d575a..587f700286 100644 --- a/api/core/app/workflow/file_runtime.py +++ b/api/core/app/workflow/file_runtime.py @@ -128,7 +128,7 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol): @staticmethod def _secret_key() -> bytes: - return dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + return dify_config.SECRET_KEY.encode() def _sign_query(self, *, payload: str) -> dict[str, str]: timestamp = str(int(time.time())) diff --git a/api/core/app/workflow/layers/persistence.py b/api/core/app/workflow/layers/persistence.py index d521304615..19152cebae 100644 --- a/api/core/app/workflow/layers/persistence.py +++ b/api/core/app/workflow/layers/persistence.py @@ -15,6 +15,7 @@ from datetime import datetime from typing import Any, Union from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity +from core.helper.trace_id_helper import ParentTraceContext from core.ops.entities.trace_entity import TraceTaskName from core.ops.ops_trace_manager import TraceQueueManager, TraceTask from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository @@ -403,8 +404,13 @@ class WorkflowPersistenceLayer(GraphEngineLayer): conversation_id = self._system_variables().get(SystemVariableKey.CONVERSATION_ID.value) external_trace_id = None + parent_trace_context = None if isinstance(self._application_generate_entity, (WorkflowAppGenerateEntity, AdvancedChatAppGenerateEntity)): - external_trace_id = self._application_generate_entity.extras.get("external_trace_id") + extras = self._application_generate_entity.extras + external_trace_id = extras.get("external_trace_id") + parent_trace_context = extras.get("parent_trace_context") + if isinstance(parent_trace_context, ParentTraceContext): + parent_trace_context = parent_trace_context.model_dump(exclude_none=True) trace_task = TraceTask( TraceTaskName.WORKFLOW_TRACE, @@ -412,6 +418,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer): conversation_id=conversation_id, user_id=self._trace_manager.user_id, external_trace_id=external_trace_id, + parent_trace_context=parent_trace_context, ) self._trace_manager.add_trace_task(trace_task) diff --git a/api/core/datasource/datasource_file_manager.py b/api/core/datasource/datasource_file_manager.py index 492b507aa9..79b84a28be 100644 --- a/api/core/datasource/datasource_file_manager.py +++ b/api/core/datasource/datasource_file_manager.py @@ -35,8 +35,11 @@ class DatasourceFileManager: timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -47,8 +50,11 @@ class DatasourceFileManager: verify signature """ data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature diff --git a/api/core/helper/trace_id_helper.py b/api/core/helper/trace_id_helper.py index e827859109..e4890c8d4d 100644 --- a/api/core/helper/trace_id_helper.py +++ b/api/core/helper/trace_id_helper.py @@ -3,6 +3,17 @@ import re from collections.abc import Mapping from typing import Any +from pydantic import BaseModel, ConfigDict, StrictStr, ValidationError + + +class ParentTraceContext(BaseModel): + """Typed parent trace context propagated from an outer workflow tool node.""" + + parent_workflow_run_id: StrictStr + parent_node_execution_id: StrictStr | None = None + + model_config = ConfigDict(extra="forbid") + def is_valid_trace_id(trace_id: str) -> bool: """ @@ -61,6 +72,30 @@ def extract_external_trace_id_from_args(args: Mapping[str, Any]): return {} +def extract_parent_trace_context_from_args(args: Mapping[str, Any]) -> dict[str, ParentTraceContext]: + """ + Extract 'parent_trace_context' from args. + + Returns a dict suitable for use in extras when both parent identifiers exist. + Returns an empty dict if the context is missing or incomplete. + """ + parent_trace_context = args.get("parent_trace_context") + if isinstance(parent_trace_context, ParentTraceContext): + context = parent_trace_context + elif isinstance(parent_trace_context, Mapping): + try: + context = ParentTraceContext.model_validate(parent_trace_context) + except ValidationError: + return {} + else: + return {} + + if context.parent_node_execution_id is None: + return {} + + return {"parent_trace_context": context} + + def get_trace_id_from_otel_context() -> str | None: """ Retrieve the current trace ID from the active OpenTelemetry trace context. diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index b6e33396d1..537b14388e 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -324,9 +324,10 @@ class IndexingRunner: # one extract_setting is one source document for extract_setting in extract_settings: # extract - processing_rule = DatasetProcessRule( - mode=tmp_processing_rule["mode"], rules=json.dumps(tmp_processing_rule["rules"]) - ) + processing_rule = { + "mode": tmp_processing_rule["mode"], + "rules": tmp_processing_rule.get("rules"), + } # Extract document content text_docs = index_processor.extract(extract_setting, process_rule_mode=tmp_processing_rule["mode"]) # Cleaning and segmentation @@ -334,7 +335,7 @@ class IndexingRunner: text_docs, current_user=None, embedding_model_instance=embedding_model_instance, - process_rule=processing_rule.to_dict(), + process_rule=processing_rule, tenant_id=tenant_id, doc_language=doc_language, preview=True, diff --git a/api/core/ops/entities/trace_entity.py b/api/core/ops/entities/trace_entity.py index 45b2f635ba..98e87a0ceb 100644 --- a/api/core/ops/entities/trace_entity.py +++ b/api/core/ops/entities/trace_entity.py @@ -5,6 +5,8 @@ from typing import Any, Union from pydantic import BaseModel, ConfigDict, field_serializer, field_validator +from core.helper.trace_id_helper import ParentTraceContext + class BaseTraceInfo(BaseModel): message_id: str | None = None @@ -51,8 +53,8 @@ class BaseTraceInfo(BaseModel): def resolved_parent_context(self) -> tuple[str | None, str | None]: """Resolve cross-workflow parent linking from metadata. - Extracts typed parent IDs from the untyped ``parent_trace_context`` - metadata dict (set by tool_node when invoking nested workflows). + Extracts typed parent IDs from the ``parent_trace_context`` metadata + payload (set by tool_node when invoking nested workflows). Returns: (trace_correlation_override, parent_span_id_source) where @@ -60,13 +62,18 @@ class BaseTraceInfo(BaseModel): parent_span_id_source is the outer node_execution_id. """ parent_ctx = self.metadata.get("parent_trace_context") - if not isinstance(parent_ctx, dict): + if isinstance(parent_ctx, ParentTraceContext): + context = parent_ctx + elif isinstance(parent_ctx, Mapping): + try: + context = ParentTraceContext.model_validate(parent_ctx) + except ValueError: + return None, None + else: return None, None - trace_override = parent_ctx.get("parent_workflow_run_id") - parent_span = parent_ctx.get("parent_node_execution_id") return ( - trace_override if isinstance(trace_override, str) else None, - parent_span if isinstance(parent_span, str) else None, + context.parent_workflow_run_id, + context.parent_node_execution_id, ) @field_serializer("start_time", "end_time") diff --git a/api/core/ops/exceptions.py b/api/core/ops/exceptions.py new file mode 100644 index 0000000000..4551704687 --- /dev/null +++ b/api/core/ops/exceptions.py @@ -0,0 +1,22 @@ +"""Core exceptions shared by ops trace dispatchers and trace providers. + +Provider packages may raise these types to request generic task behavior, but +generic Celery tasks should not import provider-specific exception classes. +""" + + +class RetryableTraceDispatchError(RuntimeError): + """Base class for transient trace dispatch failures that Celery may retry.""" + + +class PendingTraceParentContextError(RetryableTraceDispatchError): + """Raised when a nested trace arrives before its parent span context is available.""" + + parent_node_execution_id: str + + def __init__(self, parent_node_execution_id: str) -> None: + self.parent_node_execution_id = parent_node_execution_id + super().__init__( + "Pending trace parent context for parent_node_execution_id=" + f"{parent_node_execution_id}. Retry after the parent span context is published." + ) diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py index bae0016744..61fd0e5c1f 100644 --- a/api/core/ops/ops_trace_manager.py +++ b/api/core/ops/ops_trace_manager.py @@ -16,6 +16,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token +from core.helper.trace_id_helper import ParentTraceContext from core.ops.entities.config_entity import ( OPS_FILE_PATH, BaseTracingConfig, @@ -52,6 +53,17 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _dump_parent_trace_context(parent_trace_context: Any) -> dict[str, str] | None: + if isinstance(parent_trace_context, ParentTraceContext): + return parent_trace_context.model_dump(exclude_none=True) + if isinstance(parent_trace_context, dict): + try: + return ParentTraceContext.model_validate(parent_trace_context).model_dump(exclude_none=True) + except ValueError: + return None + return None + + class _AppTracingConfig(TypedDict, total=False): enabled: bool tracing_provider: str | None @@ -857,8 +869,9 @@ class TraceTask: } parent_trace_context = self.kwargs.get("parent_trace_context") - if parent_trace_context: - metadata["parent_trace_context"] = parent_trace_context + dumped_parent_trace_context = _dump_parent_trace_context(parent_trace_context) + if dumped_parent_trace_context: + metadata["parent_trace_context"] = dumped_parent_trace_context workflow_trace_info = WorkflowTraceInfo( trace_id=self.trace_id, @@ -1371,13 +1384,14 @@ class TraceTask: } parent_trace_context = node_data.get("parent_trace_context") - if parent_trace_context: - metadata["parent_trace_context"] = parent_trace_context + dumped_parent_trace_context = _dump_parent_trace_context(parent_trace_context) + if dumped_parent_trace_context: + metadata["parent_trace_context"] = dumped_parent_trace_context message_id: str | None = None conversation_id = node_data.get("conversation_id") workflow_execution_id = node_data.get("workflow_execution_id") - if conversation_id and workflow_execution_id and not parent_trace_context: + if conversation_id and workflow_execution_id and not dumped_parent_trace_context: with Session(db.engine) as session: msg_id = session.scalar( select(Message.id).where( diff --git a/api/core/prompt/simple_prompt_transform.py b/api/core/prompt/simple_prompt_transform.py index 1665bdeb52..e836554ca0 100644 --- a/api/core/prompt/simple_prompt_transform.py +++ b/api/core/prompt/simple_prompt_transform.py @@ -123,12 +123,15 @@ class SimplePromptTransform(PromptTransform): for v in special_variable_keys: # support #context#, #query# and #histories# - if v == "#context#": - variables["#context#"] = context or "" - elif v == "#query#": - variables["#query#"] = query or "" - elif v == "#histories#": - variables["#histories#"] = histories or "" + match v: + case "#context#": + variables["#context#"] = context or "" + case "#query#": + variables["#query#"] = query or "" + case "#histories#": + variables["#histories#"] = histories or "" + case _: + pass prompt_template = prompt_template_config["prompt_template"] if not isinstance(prompt_template, PromptTemplateParser): diff --git a/api/core/rag/datasource/keyword/jieba/jieba.py b/api/core/rag/datasource/keyword/jieba/jieba.py index 392af351b6..b3f174bf78 100644 --- a/api/core/rag/datasource/keyword/jieba/jieba.py +++ b/api/core/rag/datasource/keyword/jieba/jieba.py @@ -245,6 +245,7 @@ class Jieba(BaseKeyword): segment = pre_segment_data["segment"] if pre_segment_data["keywords"]: segment.keywords = pre_segment_data["keywords"] + assert segment.index_node_id keyword_table = self._add_text_to_keyword_table( keyword_table or {}, segment.index_node_id, pre_segment_data["keywords"] ) @@ -253,6 +254,7 @@ class Jieba(BaseKeyword): keywords = keyword_table_handler.extract_keywords(segment.content, keyword_number) segment.keywords = list(keywords) + assert segment.index_node_id keyword_table = self._add_text_to_keyword_table( keyword_table or {}, segment.index_node_id, list(keywords) ) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index 7769878e70..8cc2be8feb 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -1,5 +1,6 @@ import concurrent.futures import logging +from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from typing import Any, NotRequired, TypedDict @@ -526,7 +527,7 @@ class RetrievalService: index_node_ids = [i for i in index_node_ids if i] segment_ids: list[str] = [] - index_node_segments: list[DocumentSegment] = [] + index_node_segments: Sequence[DocumentSegment] = [] segments: list[DocumentSegment] = [] attachment_map: dict[str, list[AttachmentInfoDict]] = {} child_chunk_map: dict[str, list[ChildChunk]] = {} @@ -568,8 +569,9 @@ class RetrievalService: DocumentSegment.status == "completed", DocumentSegment.index_node_id.in_(index_node_ids), ) - index_node_segments = session.execute(document_segment_stmt).scalars().all() # type: ignore + index_node_segments = session.execute(document_segment_stmt).scalars().all() for index_node_segment in index_node_segments: + assert index_node_segment.index_node_id doc_segment_map[index_node_segment.id] = [index_node_segment.index_node_id] if segment_ids: diff --git a/api/core/rag/docstore/dataset_docstore.py b/api/core/rag/docstore/dataset_docstore.py index 78305a6ac0..c7d52d74cb 100644 --- a/api/core/rag/docstore/dataset_docstore.py +++ b/api/core/rag/docstore/dataset_docstore.py @@ -50,6 +50,7 @@ class DatasetDocumentStore: output = {} for document_segment in document_segments: + assert document_segment.index_node_id doc_id = document_segment.index_node_id output[doc_id] = Document( page_content=document_segment.content, @@ -103,7 +104,7 @@ class DatasetDocumentStore: if not segment_document: max_position += 1 - + assert self._document_id segment_document = DocumentSegment( tenant_id=self._dataset.tenant_id, dataset_id=self._dataset.id, diff --git a/api/core/rag/index_processor/index_processor.py b/api/core/rag/index_processor/index_processor.py index aded5315bd..757134e734 100644 --- a/api/core/rag/index_processor/index_processor.py +++ b/api/core/rag/index_processor/index_processor.py @@ -84,7 +84,7 @@ class IndexProcessor: select(DocumentSegment).where(DocumentSegment.document_id == original_document_id) ).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] indexing_start_at = time.perf_counter() # delete from vector index diff --git a/api/core/rag/index_processor/processor/parent_child_index_processor.py b/api/core/rag/index_processor/processor/parent_child_index_processor.py index ba277d5018..a26a900512 100644 --- a/api/core/rag/index_processor/processor/parent_child_index_processor.py +++ b/api/core/rag/index_processor/processor/parent_child_index_processor.py @@ -29,6 +29,7 @@ from libs import helper from models import Account from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment from models.dataset import Document as DatasetDocument +from models.enums import ProcessRuleMode from services.account_service import AccountService from services.summary_index_service import SummaryIndexService @@ -325,7 +326,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor): # update document parent mode dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode="hierarchical", + mode=ProcessRuleMode.HIERARCHICAL, rules=json.dumps( { "parent_mode": parent_childs.parent_mode, diff --git a/api/core/tools/signature.py b/api/core/tools/signature.py index 3c7b523ff1..ca4756f2a4 100644 --- a/api/core/tools/signature.py +++ b/api/core/tools/signature.py @@ -8,6 +8,10 @@ import urllib.parse from configs import dify_config +def _secret_key() -> bytes: + return dify_config.SECRET_KEY.encode() + + def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str: """ sign file to get a temporary url for plugin access @@ -19,8 +23,7 @@ def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -39,8 +42,7 @@ def sign_upload_file_preview_url(upload_file_id: str, extension: str) -> str: timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -51,8 +53,7 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s verify signature """ data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature @@ -71,8 +72,7 @@ def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str, timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() query = urllib.parse.urlencode( { @@ -92,8 +92,7 @@ def verify_plugin_file_signature( """Verify the signature used by the plugin-facing file upload endpoint.""" data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() if sign != recalculated_encoded_sign: diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py index c87e8a3ae0..f2552e7cbd 100644 --- a/api/core/tools/tool_file_manager.py +++ b/api/core/tools/tool_file_manager.py @@ -51,8 +51,11 @@ class ToolFileManager: timestamp = str(int(time.time())) nonce = os.urandom(16).hex() data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}" @@ -63,8 +66,11 @@ class ToolFileManager: verify signature """ data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" - recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() + recalculated_sign = hmac.new( + dify_config.SECRET_KEY.encode(), + data_to_sign.encode(), + hashlib.sha256, + ).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() # verify signature diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index cd8c6352b5..3fbd456fe5 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -9,6 +9,7 @@ from sqlalchemy import select from core.app.file_access import DatabaseFileAccessController from core.db.session_factory import session_factory +from core.helper.trace_id_helper import ParentTraceContext, extract_parent_trace_context_from_args from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime from core.tools.entities.tool_entities import ( @@ -36,6 +37,8 @@ class WorkflowTool(Tool): Workflow tool. """ + _parent_trace_context: ParentTraceContext | None + def __init__( self, workflow_app_id: str, @@ -54,6 +57,7 @@ class WorkflowTool(Tool): self.workflow_call_depth = workflow_call_depth self.label = label self._latest_usage = LLMUsage.empty_usage() + self._parent_trace_context = None super().__init__(entity=entity, runtime=runtime) @@ -94,11 +98,17 @@ class WorkflowTool(Tool): self._latest_usage = LLMUsage.empty_usage() + generator_args: dict[str, Any] = {"inputs": tool_parameters, "files": files} + if self._parent_trace_context: + generator_args.update( + extract_parent_trace_context_from_args({"parent_trace_context": self._parent_trace_context}) + ) + result = generator.generate( app_model=app, workflow=workflow, user=user, - args={"inputs": tool_parameters, "files": files}, + args=generator_args, invoke_from=self.runtime.invoke_from, streaming=False, call_depth=self.workflow_call_depth + 1, @@ -194,7 +204,7 @@ class WorkflowTool(Tool): :return: the new tool """ - return self.__class__( + forked = self.__class__( entity=self.entity.model_copy(), runtime=runtime, workflow_app_id=self.workflow_app_id, @@ -204,6 +214,24 @@ class WorkflowTool(Tool): version=self.version, label=self.label, ) + forked._parent_trace_context = self._parent_trace_context.model_copy() if self._parent_trace_context else None + return forked + + def set_parent_trace_context( + self, + *, + parent_workflow_run_id: str, + parent_node_execution_id: str, + ) -> None: + """Attach outer workflow trace context without exposing it as tool input.""" + self._parent_trace_context = ParentTraceContext( + parent_workflow_run_id=parent_workflow_run_id, + parent_node_execution_id=parent_node_execution_id, + ) + + def clear_parent_trace_context(self) -> None: + """Remove parent trace context before invoking this tool outside a nested workflow.""" + self._parent_trace_context = None def _resolve_user(self, user_id: str) -> Account | EndUser | None: """ diff --git a/api/core/workflow/node_runtime.py b/api/core/workflow/node_runtime.py index c1d3a856fb..db7d78bf45 100644 --- a/api/core/workflow/node_runtime.py +++ b/api/core/workflow/node_runtime.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext from core.app.file_access import DatabaseFileAccessController from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler +from core.helper.trace_id_helper import ParentTraceContext from core.llm_generator.output_parser.errors import OutputParserError from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output from core.model_manager import ModelInstance @@ -358,6 +359,7 @@ class _WorkflowToolRuntimeBinding: tool: Tool conversation_id: str | None = None + parent_trace_context: ParentTraceContext | None = None class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): @@ -378,6 +380,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): node_id: str, node_data: ToolNodeData, variable_pool, + node_execution_id: str | None = None, ) -> ToolRuntimeHandle: try: tool_runtime = ToolManager.get_workflow_tool_runtime( @@ -397,7 +400,25 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): conversation_id = ( None if variable_pool is None else get_system_text(variable_pool, SystemVariableKey.CONVERSATION_ID) ) - return ToolRuntimeHandle(raw=_WorkflowToolRuntimeBinding(tool=tool_runtime, conversation_id=conversation_id)) + parent_trace_context: ParentTraceContext | None = None + if self._is_workflow_tool_provider(node_data): + outer_workflow_run_id = ( + None + if variable_pool is None + else get_system_text(variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID) + ) + if isinstance(outer_workflow_run_id, str) and isinstance(node_execution_id, str): + parent_trace_context = ParentTraceContext( + parent_workflow_run_id=outer_workflow_run_id, + parent_node_execution_id=node_execution_id, + ) + return ToolRuntimeHandle( + raw=_WorkflowToolRuntimeBinding( + tool=tool_runtime, + conversation_id=conversation_id, + parent_trace_context=parent_trace_context, + ) + ) def get_runtime_parameters( self, @@ -421,6 +442,13 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): runtime_binding = self._binding_from_handle(tool_runtime) tool = runtime_binding.tool callback = DifyWorkflowCallbackHandler() + if runtime_binding.parent_trace_context and hasattr(tool, "set_parent_trace_context"): + tool.set_parent_trace_context( + parent_workflow_run_id=runtime_binding.parent_trace_context.parent_workflow_run_id, + parent_node_execution_id=runtime_binding.parent_trace_context.parent_node_execution_id, + ) + elif hasattr(tool, "clear_parent_trace_context"): + tool.clear_parent_trace_context() try: messages = ToolEngine.generic_invoke( @@ -513,6 +541,10 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol): credential_id=node_data.credential_id, ) + @staticmethod + def _is_workflow_tool_provider(node_data: ToolNodeData) -> bool: + return node_data.provider_type.value == CoreToolProviderType.WORKFLOW.value + def _adapt_messages( self, messages: Generator[CoreToolInvokeMessage, None, None], diff --git a/api/extensions/ext_app_metrics.py b/api/extensions/ext_app_metrics.py index 4a6490b9f0..914baaadaf 100644 --- a/api/extensions/ext_app_metrics.py +++ b/api/extensions/ext_app_metrics.py @@ -5,6 +5,7 @@ import threading from flask import Response from configs import dify_config +from controllers.console.admin import admin_required from dify_app import DifyApp @@ -25,6 +26,7 @@ def init_app(app: DifyApp): ) @app.route("/threads") + @admin_required def threads(): # pyright: ignore[reportUnusedFunction] num_threads = threading.active_count() threads = threading.enumerate() @@ -50,6 +52,7 @@ def init_app(app: DifyApp): } @app.route("/db-pool-stat") + @admin_required def pool_stat(): # pyright: ignore[reportUnusedFunction] from extensions.ext_database import db diff --git a/api/extensions/ext_set_secretkey.py b/api/extensions/ext_set_secretkey.py index dfb87c0167..ca59a2de4d 100644 --- a/api/extensions/ext_set_secretkey.py +++ b/api/extensions/ext_set_secretkey.py @@ -1,6 +1,13 @@ from configs import dify_config +from configs.secret_key import resolve_secret_key from dify_app import DifyApp -def init_app(app: DifyApp): - app.secret_key = dify_config.SECRET_KEY +def init_app(app: DifyApp) -> None: + """Resolve SECRET_KEY after config loading and before session/login setup.""" + secret_key = dify_config.SECRET_KEY + if not secret_key: + secret_key = resolve_secret_key(secret_key) + dify_config.SECRET_KEY = secret_key + app.config["SECRET_KEY"] = secret_key + app.secret_key = secret_key diff --git a/api/models/comment.py b/api/models/comment.py index 5d4a08e783..6d151fe13d 100644 --- a/api/models/comment.py +++ b/api/models/comment.py @@ -1,19 +1,22 @@ """Workflow comment models.""" +from __future__ import annotations + from datetime import datetime -from typing import Optional import sqlalchemy as sa from sqlalchemy import Index, func from sqlalchemy.orm import Mapped, mapped_column, relationship +from models.base import TypeBase + from .account import Account -from .base import Base, gen_uuidv7_string +from .base import gen_uuidv7_string from .engine import db from .types import StringUUID -class WorkflowComment(Base): +class WorkflowComment(TypeBase): """Workflow comment model for canvas commenting functionality. Comments are associated with apps rather than specific workflow versions, @@ -42,27 +45,33 @@ class WorkflowComment(Base): Index("workflow_comments_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) position_x: Mapped[float] = mapped_column(sa.Float) position_y: Mapped[float] = mapped_column(sa.Float) content: Mapped[str] = mapped_column(sa.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) updated_at: Mapped[datetime] = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) - resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) - resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime) - resolved_by: Mapped[str | None] = mapped_column(StringUUID) + resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime, default=None) + resolved_by: Mapped[str | None] = mapped_column(StringUUID, default=None) + resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False) # Relationships - replies: Mapped[list["WorkflowCommentReply"]] = relationship( - "WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan" + replies: Mapped[list[WorkflowCommentReply]] = relationship( + lambda: WorkflowCommentReply, back_populates="comment", cascade="all, delete-orphan", init=False ) - mentions: Mapped[list["WorkflowCommentMention"]] = relationship( - "WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan" + mentions: Mapped[list[WorkflowCommentMention]] = relationship( + lambda: WorkflowCommentMention, back_populates="comment", cascade="all, delete-orphan", init=False ) @property @@ -131,7 +140,7 @@ class WorkflowComment(Base): return participants -class WorkflowCommentReply(Base): +class WorkflowCommentReply(TypeBase): """Workflow comment reply model. Attributes: @@ -149,18 +158,24 @@ class WorkflowCommentReply(Base): Index("comment_replies_created_at_idx", "created_at"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) content: Mapped[str] = mapped_column(sa.Text, nullable=False) created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + created_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) updated_at: Mapped[datetime] = mapped_column( - sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + sa.DateTime, + nullable=False, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) # Relationships - comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies") + comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="replies", init=False) @property def created_by_account(self): @@ -174,7 +189,7 @@ class WorkflowCommentReply(Base): self._created_by_account_cache = account -class WorkflowCommentMention(Base): +class WorkflowCommentMention(TypeBase): """Workflow comment mention model. Mentions are only for internal accounts since end users @@ -194,18 +209,18 @@ class WorkflowCommentMention(Base): Index("comment_mentions_user_idx", "mentioned_user_id"), ) - id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string) + id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False) comment_id: Mapped[str] = mapped_column( StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False ) - reply_id: Mapped[str | None] = mapped_column( - StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True - ) mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + reply_id: Mapped[str | None] = mapped_column( + StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True, default=None + ) # Relationships - comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions") - reply: Mapped[Optional["WorkflowCommentReply"]] = relationship("WorkflowCommentReply") + comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="mentions", init=False) + reply: Mapped[WorkflowCommentReply | None] = relationship(lambda: WorkflowCommentReply, init=False) @property def mentioned_user_account(self): diff --git a/api/models/dataset.py b/api/models/dataset.py index ed7727e0f1..8137ed4ff3 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -8,10 +8,9 @@ import os import pickle import re import time -from collections.abc import Sequence from datetime import datetime from json import JSONDecodeError -from typing import Any, TypedDict, cast +from typing import Any, ClassVar, TypedDict, cast from uuid import uuid4 import sqlalchemy as sa @@ -441,23 +440,27 @@ class Dataset(Base): return f"{dify_config.VECTOR_INDEX_NAME_PREFIX}_{normalized_dataset_id}_Node" -class DatasetProcessRule(Base): # bug +class DatasetProcessRule(TypeBase): __tablename__ = "dataset_process_rules" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="dataset_process_rule_pkey"), sa.Index("dataset_process_rule_dataset_id_idx", "dataset_id"), ) - id = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4())) - dataset_id = mapped_column(StringUUID, nullable=False) - mode = mapped_column(EnumText(ProcessRuleMode, length=255), nullable=False, server_default=sa.text("'automatic'")) - rules = mapped_column(LongText, nullable=True) - created_by = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + id: Mapped[str] = mapped_column(StringUUID, nullable=False, default_factory=lambda: str(uuid4()), init=False) + dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + mode: Mapped[ProcessRuleMode] = mapped_column( + EnumText(ProcessRuleMode, length=255), nullable=False, server_default=sa.text("'automatic'") + ) + rules: Mapped[str | None] = mapped_column(LongText, nullable=True) + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) MODES = ["automatic", "custom", "hierarchical"] PRE_PROCESSING_RULES = ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"] - AUTOMATIC_RULES: AutomaticRulesConfig = { + AUTOMATIC_RULES: ClassVar[AutomaticRulesConfig] = { "pre_processing_rules": [ {"id": "remove_extra_spaces", "enabled": True}, {"id": "remove_urls_emails", "enabled": False}, @@ -827,7 +830,7 @@ class Document(Base): ) -class DocumentSegment(Base): +class DocumentSegment(TypeBase): __tablename__ = "document_segments" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="document_segment_pkey"), @@ -840,35 +843,40 @@ class DocumentSegment(Base): ) # initial fields - id = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4())) - tenant_id = mapped_column(StringUUID, nullable=False) - dataset_id = mapped_column(StringUUID, nullable=False) - document_id = mapped_column(StringUUID, nullable=False) + id: Mapped[str] = mapped_column(StringUUID, nullable=False, default_factory=lambda: str(uuid4()), init=False) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + document_id: Mapped[str] = mapped_column(StringUUID, nullable=False) position: Mapped[int] - content = mapped_column(LongText, nullable=False) - answer = mapped_column(LongText, nullable=True) + content: Mapped[str] = mapped_column(LongText, nullable=False) word_count: Mapped[int] tokens: Mapped[int] - # indexing fields - keywords = mapped_column(sa.JSON, nullable=True) - index_node_id = mapped_column(String(255), nullable=True) - index_node_hash = mapped_column(String(255), nullable=True) - + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) # basic fields + # indexing fields + index_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + index_node_hash: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"), default=True) + answer: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + keywords: Mapped[Any] = mapped_column(sa.JSON, nullable=True, default=None) + disabled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + disabled_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + status: Mapped[SegmentStatus] = mapped_column( + EnumText(SegmentStatus, length=255), server_default=sa.text("'waiting'"), default=SegmentStatus.WAITING + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), init=False + ) + indexing_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + error: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) + stopped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) - enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true")) - disabled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - disabled_by = mapped_column(StringUUID, nullable=True) - status: Mapped[str] = mapped_column(EnumText(SegmentStatus, length=255), server_default=sa.text("'waiting'")) - created_by = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) - updated_by = mapped_column(StringUUID, nullable=True) - updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) - indexing_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - error = mapped_column(LongText, nullable=True) - stopped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) @property def dataset(self): @@ -895,7 +903,7 @@ class DocumentSegment(Base): ) @property - def child_chunks(self) -> Sequence[Any]: + def child_chunks(self): if not self.document: return [] process_rule = self.document.dataset_process_rule @@ -910,7 +918,7 @@ class DocumentSegment(Base): return child_chunks or [] return [] - def get_child_chunks(self) -> Sequence[Any]: + def get_child_chunks(self): if not self.document: return [] process_rule = self.document.dataset_process_rule @@ -941,7 +949,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -958,7 +966,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -977,7 +985,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() @@ -1015,7 +1023,7 @@ class DocumentSegment(Base): nonce = os.urandom(16).hex() timestamp = str(int(time.time())) data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}" - secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b"" + secret_key = dify_config.SECRET_KEY.encode() sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index f3c188fc06..e56d5f6fe5 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -13786,6 +13786,14 @@ Tag type | unit | string | | No | | variable | string | | No | +#### TrialSimpleAccount + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | No | +| id | string | | No | +| name | string | | No | + #### TrialSite | Name | Type | Description | Required | @@ -13829,7 +13837,7 @@ Tag type | ---- | ---- | ----------- | -------- | | conversation_variables | [ [TrialConversationVariable](#trialconversationvariable) ] | | No | | created_at | object | | No | -| created_by | [SimpleAccount](#simpleaccount) | | No | +| created_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | | environment_variables | [ object ] | | No | | features | object | | No | | graph | object | | No | @@ -13840,7 +13848,7 @@ Tag type | rag_pipeline_variables | [ [TrialPipelineVariable](#trialpipelinevariable) ] | | No | | tool_published | boolean | | No | | updated_at | object | | No | -| updated_by | [SimpleAccount](#simpleaccount) | | No | +| updated_by | [TrialSimpleAccount](#trialsimpleaccount) | | No | | version | string | | No | #### TrialWorkflowPartial diff --git a/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py index 96df49ed0e..a0d150e1b6 100644 --- a/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py @@ -1,9 +1,11 @@ import json import logging import os +import re import traceback +from collections.abc import Mapping, Sequence from datetime import datetime, timedelta -from typing import Any, Union, cast +from typing import Any, Protocol, Union, cast from urllib.parse import urlparse from openinference.semconv.trace import ( @@ -19,7 +21,7 @@ from opentelemetry.sdk import trace as trace_sdk from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.semconv.attributes import exception_attributes -from opentelemetry.trace import Span, Status, StatusCode, set_span_in_context, use_span +from opentelemetry.trace import Span, Status, StatusCode, get_current_span, set_span_in_context, use_span from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from opentelemetry.util.types import AttributeValue from sqlalchemy.orm import sessionmaker @@ -36,16 +38,106 @@ from core.ops.entities.trace_entity import ( TraceTaskName, WorkflowTraceInfo, ) +from core.ops.exceptions import PendingTraceParentContextError from core.ops.utils import JSON_DICT_ADAPTER from core.repositories import DifyCoreRepositoryFactory from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig from extensions.ext_database import db +from extensions.ext_redis import redis_client from graphon.enums import WorkflowNodeExecutionStatus from models.model import EndUser, MessageFile from models.workflow import WorkflowNodeExecutionTriggeredFrom logger = logging.getLogger(__name__) +# This parent-span carrier store is intentionally Phoenix-local for the current +# nested workflow tracing feature. If other trace providers need the same +# cross-task parent restoration behavior, move the storage and retry signaling +# behind a core trace coordination interface instead of duplicating it here. +_PHOENIX_PARENT_SPAN_CONTEXT_TTL_SECONDS = 300 +_TRACEPARENT_PATTERN = re.compile( + r"^(?P[0-9a-f]{2})-(?P[0-9a-f]{32})-(?P[0-9a-f]{16})-(?P[0-9a-f]{2})$" +) + + +def _phoenix_parent_span_redis_key(parent_node_execution_id: str) -> str: + """Build the Redis key that stores a restorable Phoenix parent span carrier.""" + return f"trace:phoenix:parent_span:{parent_node_execution_id}" + + +def _publish_parent_span_context(parent_node_execution_id: str, carrier: Mapping[str, str]) -> None: + """Persist a tracecontext carrier so nested workflow spans can restore the tool span parent.""" + redis_client.setex( + _phoenix_parent_span_redis_key(parent_node_execution_id), + _PHOENIX_PARENT_SPAN_CONTEXT_TTL_SECONDS, + safe_json_dumps(dict(carrier)), + ) + + +def _resolve_published_parent_span_context(parent_node_execution_id: str) -> dict[str, str]: + """Load a previously published tool-span carrier for nested workflow parenting.""" + raw_carrier = redis_client.get(_phoenix_parent_span_redis_key(parent_node_execution_id)) + if raw_carrier is None: + raise PendingTraceParentContextError(parent_node_execution_id) + + if isinstance(raw_carrier, bytes): + raw_carrier = raw_carrier.decode("utf-8") + + carrier = json.loads(raw_carrier) + if not isinstance(carrier, dict): + raise ValueError( + "Phoenix parent span context must be stored as a JSON object: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + normalized_carrier = {str(key): str(value) for key, value in carrier.items()} + if not normalized_carrier: + raise ValueError( + f"Phoenix parent span context payload is empty: parent_node_execution_id={parent_node_execution_id}" + ) + + traceparent = normalized_carrier.get("traceparent") + if not isinstance(traceparent, str): + raise ValueError( + "Phoenix parent span context payload is missing traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + traceparent_match = _TRACEPARENT_PATTERN.fullmatch(traceparent) + if traceparent_match is None: + raise ValueError( + "Phoenix parent span context payload has invalid traceparent format: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("version") == "ff": + raise ValueError( + "Phoenix parent span context payload has unsupported traceparent version: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("trace_id") == "0" * 32: + raise ValueError( + "Phoenix parent span context payload has zero trace_id in traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + if traceparent_match.group("span_id") == "0" * 16: + raise ValueError( + "Phoenix parent span context payload has zero span_id in traceparent: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + extracted_context = TraceContextTextMapPropagator().extract(carrier=normalized_carrier) + extracted_span_context = get_current_span(extracted_context).get_span_context() + if not extracted_span_context.is_valid or not extracted_span_context.is_remote: + raise ValueError( + "Phoenix parent span context payload could not be restored into a valid parent span: " + f"parent_node_execution_id={parent_node_execution_id}" + ) + + return normalized_carrier + def setup_tracer(arize_phoenix_config: ArizeConfig | PhoenixConfig) -> tuple[trace_sdk.Tracer, SimpleSpanProcessor]: """Configure OpenTelemetry tracer with OTLP exporter for Arize/Phoenix.""" @@ -177,6 +269,246 @@ def _get_node_span_kind(node_type: str) -> OpenInferenceSpanKindValues: return _NODE_TYPE_TO_SPAN_KIND.get(node_type, OpenInferenceSpanKindValues.CHAIN) +def _resolve_workflow_session_id(trace_info: WorkflowTraceInfo) -> str: + """Resolve the workflow session ID for Phoenix workflow spans.""" + if trace_info.conversation_id: + return trace_info.conversation_id + + parent_workflow_run_id, _ = _resolve_workflow_parent_context(trace_info) + if parent_workflow_run_id: + return parent_workflow_run_id + + return trace_info.workflow_run_id + + +def _resolve_workflow_parent_context(trace_info: BaseTraceInfo) -> tuple[str | None, str | None]: + """Expose the typed parent context already resolved on the trace info.""" + return trace_info.resolved_parent_context + + +def _resolve_workflow_root_trace_id(trace_info: WorkflowTraceInfo) -> str: + """Resolve the canonical root trace ID for Phoenix workflow spans.""" + trace_correlation_override, _ = _resolve_workflow_parent_context(trace_info) + return trace_correlation_override or trace_info.resolved_trace_id or trace_info.workflow_run_id + + +class _NodeExecutionIdentityLike(Protocol): + @property + def node_execution_id(self) -> str | None: ... + + @property + def node_id(self) -> str: ... + + @property + def predecessor_node_id(self) -> str | None: ... + + +class _NodeExecutionLike(_NodeExecutionIdentityLike, Protocol): + @property + def id(self) -> str: ... + + @property + def node_type(self) -> str: ... + + @property + def title(self) -> str | None: ... + + @property + def inputs(self) -> Mapping[str, Any] | None: ... + + @property + def process_data(self) -> Mapping[str, Any] | None: ... + + @property + def outputs(self) -> Mapping[str, Any] | None: ... + + @property + def status(self) -> WorkflowNodeExecutionStatus: ... + + @property + def error(self) -> str | None: ... + + @property + def elapsed_time(self) -> float | None: ... + + @property + def metadata(self) -> Mapping[Any, Any] | None: ... + + @property + def created_at(self) -> datetime | None: ... + + +_PHOENIX_STRUCTURED_NODE_TYPES = frozenset({"start", "end", "loop", "iteration"}) + + +def _resolve_workflow_span_name(trace_info: WorkflowTraceInfo) -> str: + """Resolve the Phoenix workflow span display name.""" + workflow_run_id = trace_info.workflow_run_id.strip() if trace_info.workflow_run_id else "" + if workflow_run_id: + return f"{TraceTaskName.WORKFLOW_TRACE.value}_{workflow_run_id}" + return TraceTaskName.WORKFLOW_TRACE.value + + +def _build_node_title_by_id(trace_info: WorkflowTraceInfo) -> dict[str, str]: + """Build an authoritative node-title index from the persisted workflow graph.""" + workflow_data = trace_info.workflow_data + workflow_graph = getattr(workflow_data, "graph_dict", None) + if not isinstance(workflow_graph, Mapping): + workflow_graph = workflow_data.get("graph") if isinstance(workflow_data, Mapping) else None + if not isinstance(workflow_graph, Mapping): + return {} + + graph_nodes = workflow_graph.get("nodes") + if not isinstance(graph_nodes, Sequence): + return {} + + node_title_by_id: dict[str, str] = {} + for graph_node in graph_nodes: + if not isinstance(graph_node, Mapping): + continue + node_id = graph_node.get("id") + node_data = graph_node.get("data") + if not isinstance(node_id, str) or not isinstance(node_data, Mapping): + continue + node_title = node_data.get("title") + if isinstance(node_title, str) and node_title.strip(): + node_title_by_id[node_id] = node_title.strip() + + return node_title_by_id + + +def _resolve_workflow_node_span_name( + node_execution: _NodeExecutionLike, + node_title_by_id: Mapping[str, str] | None = None, +) -> str: + """Resolve the Phoenix workflow node span display name.""" + node_type = str(node_execution.node_type or "") + graph_node_title = None + if node_title_by_id is not None and isinstance(node_execution.node_id, str): + graph_node_title = node_title_by_id.get(node_execution.node_id) + + node_title = graph_node_title or (node_execution.title.strip() if isinstance(node_execution.title, str) else "") + if node_title: + return f"{node_type}_{node_title}" + return node_type + + +def _get_node_execution_id(node_execution: _NodeExecutionIdentityLike) -> str: + """Return the stable execution identifier for a workflow node execution.""" + return str(getattr(node_execution, "id", None) or node_execution.node_execution_id) + + +def _build_execution_id_by_node_id(node_executions: Sequence[_NodeExecutionIdentityLike]) -> dict[str, str]: + """Index unique workflow graph node ids by execution id. + + This Phoenix-local hierarchy reconstruction intentionally drops ambiguous + node ids instead of guessing based on repository order. That keeps parent + selection deterministic until upstream tracing exposes explicit parent span + data for repeated executions. + """ + execution_id_by_node_id: dict[str, str] = {} + ambiguous_node_ids: set[str] = set() + + for node_execution in node_executions: + node_id = node_execution.node_id + if not isinstance(node_id, str): + continue + execution_id = _get_node_execution_id(node_execution) + + if node_id in ambiguous_node_ids: + continue + + existing_execution_id = execution_id_by_node_id.get(node_id) + if existing_execution_id is None: + execution_id_by_node_id[node_id] = execution_id + continue + + if existing_execution_id != execution_id: + ambiguous_node_ids.add(node_id) + execution_id_by_node_id.pop(node_id, None) + + return execution_id_by_node_id + + +def _build_graph_parent_index(node_executions: Sequence[_NodeExecutionIdentityLike]) -> dict[str, str]: + """Build an execution-id parent index from predecessor node ids.""" + execution_id_by_node_id = _build_execution_id_by_node_id(node_executions) + graph_parent_index: dict[str, str] = {} + + for node_execution in node_executions: + predecessor_node_id = node_execution.predecessor_node_id + if not isinstance(predecessor_node_id, str): + continue + + predecessor_execution_id = execution_id_by_node_id.get(predecessor_node_id) + if predecessor_execution_id is not None: + execution_id = _get_node_execution_id(node_execution) + graph_parent_index[execution_id] = predecessor_execution_id + + return graph_parent_index + + +def _resolve_structured_parent_execution_id( + node_execution: object, execution_id_by_node_id: Mapping[str, str] +) -> str | None: + """Resolve Phoenix-local structured parents from loop/iteration node ids. + + Any execution carrying ``iteration_id`` or ``loop_id`` belongs to an + enclosing structured node. When predecessor node ids are ambiguous because + the graph node repeats inside that structure, Phoenix can still keep the + child span under the enclosing loop/iteration span without relying on + execution-order heuristics. + """ + execution_metadata = getattr(node_execution, "execution_metadata_dict", None) + if not isinstance(execution_metadata, Mapping): + execution_metadata = getattr(node_execution, "metadata", None) + if not isinstance(execution_metadata, Mapping): + execution_metadata = {} + + for enclosing_node_id in ( + getattr(node_execution, "iteration_id", None), + getattr(node_execution, "loop_id", None), + execution_metadata.get("iteration_id"), + execution_metadata.get("loop_id"), + ): + if not isinstance(enclosing_node_id, str): + continue + + enclosing_execution_id = execution_id_by_node_id.get(enclosing_node_id) + if enclosing_execution_id is not None: + return enclosing_execution_id + + return None + + +def _resolve_node_parent( + execution_id: str, + predecessor_execution_id: str | None, + structured_parent_execution_id: str | None, + span_by_execution_id: Mapping[str, Span], + graph_parent_index: Mapping[str, str], + workflow_span: Span, +) -> Span: + """Resolve the parent span for a workflow node execution.""" + if predecessor_execution_id is not None: + predecessor_span = span_by_execution_id.get(predecessor_execution_id) + if predecessor_span is not None: + return predecessor_span + + graph_parent_execution_id = graph_parent_index.get(execution_id) + if graph_parent_execution_id is not None: + graph_parent_span = span_by_execution_id.get(graph_parent_execution_id) + if graph_parent_span is not None: + return graph_parent_span + + if structured_parent_execution_id is not None: + structured_parent_span = span_by_execution_id.get(structured_parent_execution_id) + if structured_parent_span is not None: + return structured_parent_span + + return workflow_span + + class ArizePhoenixDataTrace(BaseTraceInstance): def __init__( self, @@ -189,6 +521,8 @@ class ArizePhoenixDataTrace(BaseTraceInstance): self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001") self.propagator = TraceContextTextMapPropagator() self.dify_trace_ids: set[str] = set() + self.root_span_carriers: dict[str, dict[str, str]] = {} + self.carrier: dict[str, str] = {} def trace(self, trace_info: BaseTraceInfo): logger.info("[Arize/Phoenix] Trace Entity Info: %s", trace_info) @@ -235,13 +569,41 @@ class ArizePhoenixDataTrace(BaseTraceInstance): file_list=safe_json_dumps(file_list), query=trace_info.query or "", ) + workflow_session_id = _resolve_workflow_session_id(trace_info) + parent_workflow_run_id, parent_node_execution_id = _resolve_workflow_parent_context(trace_info) + logger.info( + "[Arize/Phoenix] Workflow session resolution: workflow_run_id=%s conversation_id=%s " + "parent_workflow_run_id=%s parent_node_execution_id=%s resolved_session_id=%s", + trace_info.workflow_run_id, + trace_info.conversation_id, + parent_workflow_run_id, + parent_node_execution_id, + workflow_session_id, + ) - dify_trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id - self.ensure_root_span(dify_trace_id) - root_span_context = self.propagator.extract(carrier=self.carrier) + if parent_node_execution_id: + workflow_parent_carrier = _resolve_published_parent_span_context(parent_node_execution_id) + else: + root_trace_id = _resolve_workflow_root_trace_id(trace_info) + workflow_root_span_name: str | None = trace_info.workflow_run_id + if not isinstance(workflow_root_span_name, str) or not workflow_root_span_name.strip(): + workflow_root_span_name = None + + workflow_parent_carrier = self.ensure_root_span( + root_trace_id, + root_span_name=workflow_root_span_name, + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, + }, + ) + + workflow_span_context = self.propagator.extract(carrier=workflow_parent_carrier) workflow_span = self.tracer.start_span( - name=TraceTaskName.WORKFLOW_TRACE.value, + name=_resolve_workflow_span_name(trace_info), attributes={ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.workflow_run_inputs), @@ -249,10 +611,10 @@ class ArizePhoenixDataTrace(BaseTraceInstance): SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs), SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, SpanAttributes.METADATA: safe_json_dumps(metadata), - SpanAttributes.SESSION_ID: trace_info.conversation_id or "", + SpanAttributes.SESSION_ID: workflow_session_id or "", }, start_time=datetime_to_nanos(trace_info.start_time), - context=root_span_context, + context=workflow_span_context, ) # Through workflow_run_id, get all_nodes_execution using repository @@ -276,16 +638,50 @@ class ArizePhoenixDataTrace(BaseTraceInstance): workflow_node_executions = workflow_node_execution_repository.get_by_workflow_execution( workflow_execution_id=trace_info.workflow_run_id ) + node_title_by_id = _build_node_title_by_id(trace_info) + execution_id_by_node_id = _build_execution_id_by_node_id(workflow_node_executions) + graph_parent_index = _build_graph_parent_index(workflow_node_executions) + node_execution_by_execution_id = { + _get_node_execution_id(node_execution): node_execution for node_execution in workflow_node_executions + } + span_by_execution_id: dict[str, Span] = {} + emitting_execution_ids: set[str] = set() + workflow_span_error: Exception | str | None = trace_info.error try: - for node_execution in workflow_node_executions: + + def emit_node_span(node_execution: _NodeExecutionLike) -> Span: + execution_id = _get_node_execution_id(node_execution) + existing_span = span_by_execution_id.get(execution_id) + if existing_span is not None: + return existing_span + + graph_parent_execution_id = graph_parent_index.get(execution_id) + structured_parent_execution_id = _resolve_structured_parent_execution_id( + node_execution, execution_id_by_node_id + ) + + if execution_id not in emitting_execution_ids: + emitting_execution_ids.add(execution_id) + try: + for parent_execution_id in (graph_parent_execution_id, structured_parent_execution_id): + if parent_execution_id is None or parent_execution_id == execution_id: + continue + if parent_execution_id in span_by_execution_id: + continue + parent_node_execution = node_execution_by_execution_id.get(parent_execution_id) + if parent_node_execution is not None: + emit_node_span(parent_node_execution) + finally: + emitting_execution_ids.discard(execution_id) + tenant_id = trace_info.tenant_id # Use from trace_info instead app_id = trace_info.metadata.get("app_id") # Use from trace_info instead inputs_value = node_execution.inputs or {} outputs_value = node_execution.outputs or {} created_at = node_execution.created_at or datetime.now() - elapsed_time = node_execution.elapsed_time + elapsed_time = node_execution.elapsed_time or 0 finished_at = created_at + timedelta(seconds=elapsed_time) process_data = node_execution.process_data or {} @@ -324,9 +720,17 @@ class ArizePhoenixDataTrace(BaseTraceInstance): node_metadata["prompt_tokens"] = usage_data.get("prompt_tokens", 0) node_metadata["completion_tokens"] = usage_data.get("completion_tokens", 0) - workflow_span_context = set_span_in_context(workflow_span) + parent_span = _resolve_node_parent( + execution_id=execution_id, + predecessor_execution_id=None, + structured_parent_execution_id=structured_parent_execution_id, + span_by_execution_id=span_by_execution_id, + graph_parent_index=graph_parent_index, + workflow_span=workflow_span, + ) + workflow_span_context = set_span_in_context(parent_span) node_span = self.tracer.start_span( - name=node_execution.node_type, + name=_resolve_workflow_node_span_name(node_execution, node_title_by_id), attributes={ SpanAttributes.OPENINFERENCE_SPAN_KIND: span_kind.value, SpanAttributes.INPUT_VALUE: safe_json_dumps(inputs_value), @@ -334,13 +738,20 @@ class ArizePhoenixDataTrace(BaseTraceInstance): SpanAttributes.OUTPUT_VALUE: safe_json_dumps(outputs_value), SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value, SpanAttributes.METADATA: safe_json_dumps(node_metadata), - SpanAttributes.SESSION_ID: trace_info.conversation_id or "", + SpanAttributes.SESSION_ID: workflow_session_id or "", }, start_time=datetime_to_nanos(created_at), context=workflow_span_context, ) - + span_by_execution_id[execution_id] = node_span + node_span_error: Exception | str | None = None try: + if node_execution.node_type == "tool": + parent_span_carrier: dict[str, str] = {} + with use_span(node_span, end_on_exit=False): + self.propagator.inject(carrier=parent_span_carrier) + _publish_parent_span_context(execution_id, parent_span_carrier) + if node_execution.node_type == "llm": llm_attributes: dict[str, Any] = { SpanAttributes.INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False), @@ -362,17 +773,26 @@ class ArizePhoenixDataTrace(BaseTraceInstance): ) llm_attributes.update(self._construct_llm_attributes(process_data.get("prompts", []))) node_span.set_attributes(llm_attributes) + except Exception as e: + node_span_error = e + raise finally: - if node_execution.status == WorkflowNodeExecutionStatus.FAILED: + if node_span_error is not None: + set_span_status(node_span, node_span_error) + elif node_execution.status == WorkflowNodeExecutionStatus.FAILED: set_span_status(node_span, node_execution.error) else: set_span_status(node_span) node_span.end(end_time=datetime_to_nanos(finished_at)) + return node_span + + for node_execution in workflow_node_executions: + emit_node_span(node_execution) + except Exception as e: + workflow_span_error = e + raise finally: - if trace_info.error: - set_span_status(workflow_span, trace_info.error) - else: - set_span_status(workflow_span) + set_span_status(workflow_span, workflow_span_error) workflow_span.end(end_time=datetime_to_nanos(trace_info.end_time)) def message_trace(self, trace_info: MessageTraceInfo): @@ -735,22 +1155,39 @@ class ArizePhoenixDataTrace(BaseTraceInstance): finally: span.end(end_time=datetime_to_nanos(trace_info.end_time)) - def ensure_root_span(self, dify_trace_id: str | None): + def ensure_root_span( + self, + dify_trace_id: str | None, + *, + root_span_name: str | None = None, + root_span_attributes: Mapping[str, AttributeValue] | None = None, + ): """Ensure a unique root span exists for the given Dify trace ID.""" - if str(dify_trace_id) not in self.dify_trace_ids: - self.carrier: dict[str, str] = {} + trace_key = str(dify_trace_id) + if trace_key not in self.dify_trace_ids: + carrier: dict[str, str] = {} - root_span = self.tracer.start_span(name="Dify") - root_span.set_attribute(SpanAttributes.OPENINFERENCE_SPAN_KIND, OpenInferenceSpanKindValues.CHAIN.value) - root_span.set_attribute("dify_project_name", str(self.project)) - root_span.set_attribute("dify_trace_id", str(dify_trace_id)) + span_name = root_span_name.strip() if isinstance(root_span_name, str) and root_span_name.strip() else "Dify" + root_span_attributes_dict: dict[str, AttributeValue] = { + SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value, + "dify_project_name": str(self.project), + "dify_trace_id": trace_key, + } + if root_span_attributes: + root_span_attributes_dict.update(root_span_attributes) + + root_span = self.tracer.start_span(name=span_name, attributes=root_span_attributes_dict) with use_span(root_span, end_on_exit=False): - self.propagator.inject(carrier=self.carrier) + self.propagator.inject(carrier=carrier) set_span_status(root_span) root_span.end() - self.dify_trace_ids.add(str(dify_trace_id)) + self.dify_trace_ids.add(trace_key) + self.root_span_carriers[trace_key] = carrier + + self.carrier = self.root_span_carriers[trace_key] + return self.carrier def api_check(self): try: diff --git a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py index e9ecc2e083..dd260aeee5 100644 --- a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py @@ -1,10 +1,21 @@ from datetime import UTC, datetime, timedelta -from typing import cast +from types import SimpleNamespace +from typing import Any, cast from unittest.mock import MagicMock, patch +import dify_trace_arize_phoenix.arize_phoenix_trace as arize_phoenix_trace_module import pytest from dify_trace_arize_phoenix.arize_phoenix_trace import ( + _NODE_TYPE_TO_SPAN_KIND, ArizePhoenixDataTrace, + _build_graph_parent_index, + _get_node_span_kind, + _phoenix_parent_span_redis_key, + _resolve_node_parent, + _resolve_published_parent_span_context, + _resolve_structured_parent_execution_id, + _resolve_workflow_parent_context, + _resolve_workflow_session_id, datetime_to_nanos, error_to_string, safe_json_dumps, @@ -13,6 +24,7 @@ from dify_trace_arize_phoenix.arize_phoenix_trace import ( wrap_span_metadata, ) from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig +from openinference.semconv.trace import OpenInferenceSpanKindValues, SpanAttributes from opentelemetry.sdk.trace import Tracer from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes from opentelemetry.trace import StatusCode @@ -24,8 +36,12 @@ from core.ops.entities.trace_entity import ( ModerationTraceInfo, SuggestedQuestionTraceInfo, ToolTraceInfo, + TraceTaskName, + WorkflowNodeTraceInfo, WorkflowTraceInfo, ) +from core.ops.exceptions import PendingTraceParentContextError +from graphon.enums import BUILT_IN_NODE_TYPES, BuiltinNodeTypes # --- Helpers --- @@ -73,6 +89,80 @@ def _make_message_info(**kwargs): return MessageTraceInfo(**defaults) +def _get_start_span_call(start_span_mock, *, span_name: str): + for call in start_span_mock.call_args_list: + if call.kwargs.get("name") == span_name: + return call + raise AssertionError(f"Could not find start_span call with name={span_name!r}") + + +def _make_node_execution(**kwargs): + defaults = { + "node_type": "tool", + "status": "succeeded", + "inputs": {}, + "outputs": {}, + "created_at": _dt(), + "elapsed_time": 1.0, + "process_data": {}, + "metadata": {}, + "title": "Node", + "id": "node-execution-1", + "node_execution_id": "node-execution-1", + "node_id": "node-1", + "predecessor_node_id": None, + "iteration_id": None, + "loop_id": None, + "error": None, + } + defaults.update(kwargs) + node_execution = MagicMock() + for key, value in defaults.items(): + setattr(node_execution, key, value) + return node_execution + + +def _make_workflow_trace_info(**kwargs) -> WorkflowTraceInfo: + defaults = { + "workflow_id": "workflow-1", + "tenant_id": "tenant-1", + "workflow_run_id": "workflow-run-1", + "workflow_run_elapsed_time": 1.0, + "workflow_run_status": "succeeded", + "workflow_run_inputs": {"input": "value"}, + "workflow_run_outputs": {"output": "value"}, + "workflow_run_version": "1.0", + "total_tokens": 10, + "file_list": ["file-1"], + "query": "hello", + "metadata": {"app_id": "app-1"}, + "start_time": _dt(), + "end_time": _dt() + timedelta(seconds=1), + } + defaults.update(kwargs) + return WorkflowTraceInfo(**defaults) + + +def _make_workflow_node_trace_info(**kwargs) -> WorkflowNodeTraceInfo: + defaults = { + "workflow_id": "workflow-1", + "workflow_run_id": "workflow-run-1", + "tenant_id": "tenant-1", + "node_execution_id": "node-execution-1", + "node_id": "node-1", + "node_type": "tool", + "title": "Node 1", + "status": "succeeded", + "elapsed_time": 1.0, + "index": 1, + "metadata": {"app_id": "app-1"}, + "start_time": _dt(), + "end_time": _dt() + timedelta(seconds=1), + } + defaults.update(kwargs) + return WorkflowNodeTraceInfo(**defaults) + + # --- Utility Function Tests --- @@ -143,6 +233,258 @@ def test_wrap_span_metadata(): assert res == {"a": 1, "b": 2, "created_from": "Dify"} +class TestGetNodeSpanKind: + def test_all_node_types_are_mapped_correctly(self): + special_mappings = { + BuiltinNodeTypes.LLM: OpenInferenceSpanKindValues.LLM, + BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: OpenInferenceSpanKindValues.RETRIEVER, + BuiltinNodeTypes.TOOL: OpenInferenceSpanKindValues.TOOL, + BuiltinNodeTypes.AGENT: OpenInferenceSpanKindValues.AGENT, + } + + for node_type in BUILT_IN_NODE_TYPES: + expected_span_kind = special_mappings.get(node_type, OpenInferenceSpanKindValues.CHAIN) + actual_span_kind = _get_node_span_kind(node_type) + assert actual_span_kind == expected_span_kind, ( + f"Node type {node_type!r} was mapped to {actual_span_kind}, but {expected_span_kind} was expected." + ) + + def test_unknown_string_defaults_to_chain(self): + assert _get_node_span_kind("some-future-node-type") == OpenInferenceSpanKindValues.CHAIN + + def test_stale_dataset_retrieval_not_in_mapping(self): + assert "dataset_retrieval" not in _NODE_TYPE_TO_SPAN_KIND + + +class TestWorkflowSessionResolution: + def test_prefers_conversation_id(self): + info = _make_workflow_trace_info(conversation_id="conversation-1") + + assert _resolve_workflow_session_id(info) == "conversation-1" + + def test_nested_workflow_keeps_own_conversation_id_when_parent_context_exists(self): + info = _make_workflow_trace_info( + conversation_id="conversation-1", + metadata={ + "app_id": "app-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + + assert _resolve_workflow_session_id(info) == "conversation-1" + + def test_uses_parent_workflow_run_id_for_nested_parent_trace_context(self): + info = _make_workflow_trace_info( + conversation_id=None, + metadata={ + "app_id": "app-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + + assert _resolve_workflow_session_id(info) == "outer-workflow-run-1" + + def test_falls_back_to_workflow_run_id(self): + info = _make_workflow_trace_info(conversation_id=None) + + assert _resolve_workflow_session_id(info) == "workflow-run-1" + + def test_parent_context_helper_delegates_to_resolved_parent_context(self): + info = MagicMock() + info.resolved_parent_context = ("outer-workflow-run-1", "outer-node-execution-1") + + assert _resolve_workflow_parent_context(info) == info.resolved_parent_context + + +class TestPhoenixParentSpanBridgeHelpers: + def test_parent_span_redis_key_is_stable(self): + assert _phoenix_parent_span_redis_key("outer-node-execution-1") == ( + "trace:phoenix:parent_span:outer-node-execution-1" + ) + + def test_pending_parent_exception_exposes_execution_id(self): + error = PendingTraceParentContextError("outer-node-execution-1") + + assert error.parent_node_execution_id == "outer-node-execution-1" + assert "outer-node-execution-1" in str(error) + + def test_resolve_parent_span_context_rejects_payload_without_traceparent(self, monkeypatch): + mock_redis = MagicMock() + mock_redis.get.return_value = '{"tracestate": "vendor=value"}' + monkeypatch.setattr(arize_phoenix_trace_module, "redis_client", mock_redis) + + with pytest.raises(ValueError, match="traceparent"): + _resolve_published_parent_span_context("outer-node-execution-1") + + @pytest.mark.parametrize( + "stored_payload", + [ + '{"traceparent": ""}', + '{"traceparent": "not-a-traceparent"}', + '{"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb"}', + ], + ) + def test_resolve_parent_span_context_rejects_malformed_traceparent(self, monkeypatch, stored_payload): + mock_redis = MagicMock() + mock_redis.get.return_value = stored_payload + monkeypatch.setattr(arize_phoenix_trace_module, "redis_client", mock_redis) + + with pytest.raises(ValueError, match="traceparent"): + _resolve_published_parent_span_context("outer-node-execution-1") + + +class TestWorkflowHierarchyHelpers: + def test_build_graph_parent_index_uses_predecessor_nodes_without_order_heuristics(self): + later_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-3", + node_id="node-3", + predecessor_node_id="node-2", + index=3, + ) + root_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-1", + node_id="node-1", + predecessor_node_id=None, + index=1, + ) + middle_node = _make_workflow_node_trace_info( + node_execution_id="node-execution-2", + node_id="node-2", + predecessor_node_id="node-1", + index=2, + ) + + graph_parent_index = _build_graph_parent_index([later_node, root_node, middle_node]) + + assert graph_parent_index == { + "node-execution-2": "node-execution-1", + "node-execution-3": "node-execution-2", + } + + def test_build_graph_parent_index_drops_ambiguous_parallel_like_predecessors(self): + first_parallel_node = _make_workflow_node_trace_info( + node_execution_id="parallel-node-execution-1", + node_id="parallel-node-1", + predecessor_node_id=None, + index=1, + parallel_id="parallel-1", + ) + second_parallel_node = _make_workflow_node_trace_info( + node_execution_id="parallel-node-execution-2", + node_id="parallel-node-1", + predecessor_node_id=None, + index=2, + parallel_id="parallel-2", + ) + child_node = _make_workflow_node_trace_info( + node_execution_id="child-node-execution-1", + node_id="child-node-1", + predecessor_node_id="parallel-node-1", + index=3, + ) + + graph_parent_index = _build_graph_parent_index([child_node, first_parallel_node, second_parallel_node]) + + assert graph_parent_index == {} + + def test_resolve_node_parent_prefers_predecessor_span(self): + workflow_span = MagicMock(name="workflow-span") + predecessor_span = MagicMock(name="predecessor-span") + graph_parent_span = MagicMock(name="graph-parent-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id="node-execution-1", + structured_parent_execution_id=None, + span_by_execution_id={ + "node-execution-1": predecessor_span, + "node-execution-0": graph_parent_span, + }, + graph_parent_index={ + "node-execution-2": "node-execution-0", + }, + workflow_span=workflow_span, + ) + + assert parent is predecessor_span + + def test_resolve_node_parent_falls_back_to_graph_parent_span(self): + workflow_span = MagicMock(name="workflow-span") + graph_parent_span = MagicMock(name="graph-parent-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id="missing-predecessor", + structured_parent_execution_id=None, + span_by_execution_id={ + "node-execution-0": graph_parent_span, + }, + graph_parent_index={ + "node-execution-2": "node-execution-0", + }, + workflow_span=workflow_span, + ) + + assert parent is graph_parent_span + + def test_resolve_node_parent_falls_back_to_workflow_span(self): + workflow_span = MagicMock(name="workflow-span") + + parent = _resolve_node_parent( + execution_id="node-execution-2", + predecessor_execution_id=None, + structured_parent_execution_id=None, + span_by_execution_id={}, + graph_parent_index={}, + workflow_span=workflow_span, + ) + + assert parent is workflow_span + + def test_resolve_structured_parent_execution_id_allows_body_nodes_to_use_enclosing_structure(self): + body_node = _make_workflow_node_trace_info( + node_execution_id="body-execution-1", + node_id="body-node-1", + node_type="tool", + loop_id="loop-node-1", + ) + + structured_parent_execution_id = _resolve_structured_parent_execution_id( + body_node, + execution_id_by_node_id={ + "loop-node-1": "loop-execution-1", + }, + ) + + assert structured_parent_execution_id == "loop-execution-1" + + def test_resolve_structured_parent_execution_id_reads_execution_metadata_dict_for_models(self): + body_node = SimpleNamespace( + node_execution_id="body-execution-1", + node_id="body-node-1", + execution_metadata_dict={ + "iteration_id": "iteration-node-1", + "loop_id": "loop-node-1", + }, + ) + + structured_parent_execution_id = _resolve_structured_parent_execution_id( + body_node, + execution_id_by_node_id={ + "iteration-node-1": "iteration-execution-1", + "loop-node-1": "loop-execution-1", + }, + ) + + assert structured_parent_execution_id == "iteration-execution-1" + + @patch("dify_trace_arize_phoenix.arize_phoenix_trace.GrpcOTLPSpanExporter") @patch("dify_trace_arize_phoenix.arize_phoenix_trace.trace_sdk.TracerProvider") def test_setup_tracer_arize(mock_provider, mock_exporter): @@ -173,12 +515,17 @@ def test_setup_tracer_exception(): @pytest.fixture def trace_instance(): - with patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer") as mock_setup: + with ( + patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer") as mock_setup, + patch("dify_trace_arize_phoenix.arize_phoenix_trace.redis_client", new=MagicMock()) as mock_redis, + ): mock_tracer = MagicMock(spec=Tracer) mock_processor = MagicMock() mock_setup.return_value = (mock_tracer, mock_processor) config = ArizeConfig(endpoint="http://a.com", api_key="k", space_id="s", project="p") - return ArizePhoenixDataTrace(config) + instance = ArizePhoenixDataTrace(config) + cast(Any, instance)._mock_redis_client = mock_redis + yield instance def test_trace_dispatch(trace_instance): @@ -273,23 +620,821 @@ def test_workflow_trace_no_app_id(mock_db, trace_instance): @patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") -def test_message_trace_success(mock_db, trace_instance): +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_canonical_root_context_for_top_level_workflow( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info(message_id="message-1", workflow_run_id="workflow-run-1") + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + root_carrier = {} + root_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=root_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=root_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + info.resolved_trace_id, + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=root_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is root_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_workflow_run_id_for_root_span_and_populates_root_inputs_outputs( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + workflow_run_inputs={"prompt": "hello"}, + workflow_run_outputs={"result": "world"}, + metadata={ + "app_id": "app1", + "app_name": "Workflow Name", + }, + workflow_run_id="workflow-run-xyz", + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + root_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow-run-xyz") + assert root_span_call.kwargs["attributes"][SpanAttributes.INPUT_VALUE] == safe_json_dumps(info.workflow_run_inputs) + assert root_span_call.kwargs["attributes"][SpanAttributes.OUTPUT_VALUE] == safe_json_dumps( + info.workflow_run_outputs + ) + assert root_span_call.kwargs["attributes"][SpanAttributes.INPUT_MIME_TYPE] == "application/json" + assert root_span_call.kwargs["attributes"][SpanAttributes.OUTPUT_MIME_TYPE] == "application/json" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_dify_name_when_workflow_run_id_is_blank( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + metadata={ + "app_id": "app1", + "app_name": "", + }, + workflow_run_id="", + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + root_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="Dify") + assert root_span_call.kwargs["attributes"]["dify_trace_id"] == "" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_reuses_upstream_parent_workflow_context_when_no_parent_node_execution_id_is_available( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + parent_carrier = {} + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=parent_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + "outer-workflow-run-1", + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=parent_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is parent_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_published_parent_node_context_for_nested_workflow( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + stored_carrier = '{"traceparent":"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"}' + trace_instance._mock_redis_client.get.return_value = stored_carrier + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + trace_instance._mock_redis_client.get.assert_called_once_with( + _phoenix_parent_span_redis_key("outer-node-execution-1") + ) + mock_ensure_root_span.assert_not_called() + mock_extract.assert_called_once_with( + carrier={"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"} + ) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + assert workflow_span_call.kwargs["context"] is parent_context + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_raises_pending_parent_error_when_parent_node_context_is_missing( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + ) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + trace_instance._mock_redis_client.get.return_value = None + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span") as mock_ensure_root_span, + pytest.raises(PendingTraceParentContextError) as exc_info, + ): + trace_instance.workflow_trace(info) + + assert exc_info.value.parent_node_execution_id == "outer-node-execution-1" + trace_instance._mock_redis_client.get.assert_called_once_with( + _phoenix_parent_span_redis_key("outer-node-execution-1") + ) + mock_ensure_root_span.assert_not_called() + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_uses_parent_workflow_run_id_for_workflow_and_nodes_when_nested_context_is_present( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + conversation_id=None, + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + node_execution = MagicMock() + node_execution.node_type = "tool" + node_execution.status = "succeeded" + node_execution.inputs = {"tool_input": "value"} + node_execution.outputs = {"tool_output": "value"} + node_execution.created_at = _dt() + node_execution.elapsed_time = 1.0 + node_execution.process_data = {} + node_execution.metadata = {} + node_execution.title = "Tool node" + node_execution.id = "node-1" + node_execution.error = None + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_r1") + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Tool node") + + assert workflow_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "outer-workflow-run-1" + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "outer-workflow-run-1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_node_type_when_node_title_is_blank( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + title=" ", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool") + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "r1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_prefers_workflow_graph_node_title_over_execution_title( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + workflow_data={ + "graph": { + "nodes": [ + { + "id": "nested-tool-node", + "data": { + "type": "tool", + "title": "nested workflow tool", + }, + } + ] + } + } + ) + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="nested-tool-node", + node_type="tool", + title="2", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + with patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()): + trace_instance.workflow_trace(info) + + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_nested workflow tool") + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "r1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_keeps_nested_conversation_session_while_reusing_parent_root_context( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info( + conversation_id="conversation-1", + message_id="message-1", + workflow_run_id="workflow-run-1", + metadata={ + "app_id": "app1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + }, + }, + ) + repo = MagicMock() + node_execution = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + parent_carrier = {} + parent_context = object() + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value=parent_carrier) as mock_ensure_root_span, + patch.object(trace_instance.propagator, "extract", return_value=parent_context) as mock_extract, + ): + trace_instance.workflow_trace(info) + + mock_ensure_root_span.assert_called_once_with( + "outer-workflow-run-1", + root_span_name="workflow-run-1", + root_span_attributes={ + SpanAttributes.INPUT_VALUE: safe_json_dumps(info.workflow_run_inputs), + SpanAttributes.INPUT_MIME_TYPE: "application/json", + SpanAttributes.OUTPUT_VALUE: safe_json_dumps(info.workflow_run_outputs), + SpanAttributes.OUTPUT_MIME_TYPE: "application/json", + }, + ) + mock_extract.assert_called_once_with(carrier=parent_carrier) + workflow_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="workflow_workflow-run-1") + node_span_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Node") + assert workflow_span_call.kwargs["context"] is parent_context + assert workflow_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-1" + assert node_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-1" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_publishes_tool_node_parent_span_context_to_redis( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="tool-execution-1", + node_execution_id="tool-execution-1", + node_id="tool-node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + tool_span = MagicMock(name="tool-span") + tool_span._context_label = "tool" + trace_instance.tracer.start_span.side_effect = [workflow_span, tool_span] + + def inject_side_effect(carrier): + carrier["traceparent"] = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch.object(trace_instance.propagator, "inject", side_effect=inject_side_effect) as mock_inject, + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + mock_inject.assert_called_once() + trace_instance._mock_redis_client.setex.assert_called_once_with( + _phoenix_parent_span_redis_key("tool-execution-1"), + 300, + '{"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"}', + ) + + +@pytest.mark.parametrize( + ("failing_step", "expected_message"), + [ + ("inject", "inject failed"), + ("publish", "publish failed"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_cleans_up_tool_span_when_parent_context_publish_fails( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + failing_step, + expected_message, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + node_execution = _make_node_execution( + id="tool-execution-1", + node_execution_id="tool-execution-1", + node_id="tool-node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [node_execution] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + tool_span = MagicMock(name="tool-span") + tool_span._context_label = "tool" + trace_instance.tracer.start_span.side_effect = [workflow_span, tool_span] + + inject_side_effect = None + if failing_step == "inject": + inject_side_effect = RuntimeError(expected_message) + else: + trace_instance._mock_redis_client.setex.side_effect = RuntimeError(expected_message) + + def inject_side_effect(carrier): + carrier["traceparent"] = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01" + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch.object(trace_instance.propagator, "inject", side_effect=inject_side_effect), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + pytest.raises(RuntimeError, match=expected_message), + ): + trace_instance.workflow_trace(info) + + tool_span.end.assert_called_once() + workflow_span.end.assert_called_once() + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_parents_serial_nodes_to_resolved_predecessor_span( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + second_node = _make_node_execution( + id="node-execution-2", + node_execution_id="node-execution-2", + node_id="node-2", + node_type="llm", + predecessor_node_id="node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + ) + first_node = _make_node_execution( + id="node-execution-1", + node_execution_id="node-execution-1", + node_id="node-1", + node_type="tool", + ) + repo.get_by_workflow_execution.return_value = [second_node, first_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + first_node_span = MagicMock(name="first-node-span") + first_node_span._context_label = "node-1" + second_node_span = MagicMock(name="second-node-span") + second_node_span._context_label = "node-2" + trace_instance.tracer.start_span.side_effect = [workflow_span, first_node_span, second_node_span] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + first_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="tool_Node") + second_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert first_node_call.kwargs["context"] == "context:workflow" + assert second_node_call.kwargs["context"] == "context:node-1" + + +@pytest.mark.parametrize( + ("enclosing_node_type", "structured_field"), + [ + ("loop", "loop_id"), + ("iteration", "iteration_id"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_parents_structured_start_nodes_to_enclosing_structure_span( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + enclosing_node_type, + structured_field, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + enclosing_node = _make_node_execution( + id=f"{enclosing_node_type}-execution-1", + node_execution_id=f"{enclosing_node_type}-execution-1", + node_id=f"{enclosing_node_type}-node-1", + node_type=enclosing_node_type, + ) + structured_kwargs = {structured_field: f"{enclosing_node_type}-node-1"} + start_node = _make_node_execution( + id="start-execution-1", + node_execution_id="start-execution-1", + node_id="start-node-1", + node_type="start", + **structured_kwargs, + ) + repo.get_by_workflow_execution.return_value = [start_node, enclosing_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + enclosing_node_span = MagicMock(name="enclosing-node-span") + enclosing_node_span._context_label = enclosing_node_type + start_node_span = MagicMock(name="start-node-span") + start_node_span._context_label = "start" + trace_instance.tracer.start_span.side_effect = [workflow_span, enclosing_node_span, start_node_span] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + start_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="start_Node") + assert start_node_call.kwargs["context"] == f"context:{enclosing_node_type}" + + +@pytest.mark.parametrize( + ("enclosing_node_type", "structured_field"), + [ + ("loop", "loop_id"), + ("iteration", "iteration_id"), + ], +) +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_keeps_duplicate_body_node_children_under_enclosing_structure( + mock_sessionmaker, + mock_repo_factory, + mock_db, + trace_instance, + enclosing_node_type, + structured_field, +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + enclosing_node = _make_node_execution( + id=f"{enclosing_node_type}-execution-1", + node_execution_id=f"{enclosing_node_type}-execution-1", + node_id=f"{enclosing_node_type}-node-1", + node_type=enclosing_node_type, + ) + structured_kwargs = {structured_field: f"{enclosing_node_type}-node-1"} + repeated_body_node_1 = _make_node_execution( + id="body-execution-1", + node_execution_id="body-execution-1", + node_id="body-node-1", + node_type="tool", + **structured_kwargs, + ) + repeated_body_node_2 = _make_node_execution( + id="body-execution-2", + node_execution_id="body-execution-2", + node_id="body-node-1", + node_type="tool", + **structured_kwargs, + ) + child_node = _make_node_execution( + id="child-execution-1", + node_execution_id="child-execution-1", + node_id="child-node-1", + node_type="llm", + predecessor_node_id="body-node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + **structured_kwargs, + ) + repo.get_by_workflow_execution.return_value = [ + child_node, + repeated_body_node_1, + repeated_body_node_2, + enclosing_node, + ] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + enclosing_node_span = MagicMock(name="enclosing-node-span") + enclosing_node_span._context_label = enclosing_node_type + child_node_span = MagicMock(name="child-node-span") + child_node_span._context_label = "child" + repeated_body_node_1_span = MagicMock(name="repeated-body-node-1-span") + repeated_body_node_1_span._context_label = "body-1" + repeated_body_node_2_span = MagicMock(name="repeated-body-node-2-span") + repeated_body_node_2_span._context_label = "body-2" + trace_instance.tracer.start_span.side_effect = [ + workflow_span, + enclosing_node_span, + child_node_span, + repeated_body_node_1_span, + repeated_body_node_2_span, + ] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + child_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert child_node_call.kwargs["context"] == f"context:{enclosing_node_type}" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.DifyCoreRepositoryFactory") +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.sessionmaker") +def test_workflow_trace_falls_back_to_workflow_span_for_parallel_like_ambiguous_predecessors( + mock_sessionmaker, mock_repo_factory, mock_db, trace_instance +): + mock_db.engine = MagicMock() + info = _make_workflow_info() + repo = MagicMock() + child_node = _make_node_execution( + id="child-execution-1", + node_execution_id="child-execution-1", + node_id="child-node-1", + node_type="llm", + predecessor_node_id="parallel-node-1", + process_data={ + "prompts": [{"role": "user", "content": "hi"}], + "model_provider": "openai", + "model_name": "gpt-4", + }, + ) + first_parallel_node = _make_node_execution( + id="parallel-execution-1", + node_execution_id="parallel-execution-1", + node_id="parallel-node-1", + node_type="tool", + parallel_id="parallel-1", + ) + second_parallel_node = _make_node_execution( + id="parallel-execution-2", + node_execution_id="parallel-execution-2", + node_id="parallel-node-1", + node_type="tool", + parallel_id="parallel-2", + ) + repo.get_by_workflow_execution.return_value = [child_node, first_parallel_node, second_parallel_node] + mock_repo_factory.create_workflow_node_execution_repository.return_value = repo + + workflow_span = MagicMock(name="workflow-span") + workflow_span._context_label = "workflow" + child_node_span = MagicMock(name="child-node-span") + child_node_span._context_label = "child" + first_parallel_node_span = MagicMock(name="first-parallel-node-span") + first_parallel_node_span._context_label = "parallel-1" + second_parallel_node_span = MagicMock(name="second-parallel-node-span") + second_parallel_node_span._context_label = "parallel-2" + trace_instance.tracer.start_span.side_effect = [ + workflow_span, + child_node_span, + first_parallel_node_span, + second_parallel_node_span, + ] + + with ( + patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()), + patch.object(trace_instance, "ensure_root_span", return_value={}), + patch.object(trace_instance.propagator, "extract", return_value="root-context"), + patch( + "dify_trace_arize_phoenix.arize_phoenix_trace.set_span_in_context", + side_effect=lambda span: f"context:{span._context_label}", + ), + ): + trace_instance.workflow_trace(info) + + child_node_call = _get_start_span_call(trace_instance.tracer.start_span, span_name="llm_Node") + assert child_node_call.kwargs["context"] == "context:workflow" + + +@patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") +def test_message_trace_keeps_conversation_id_as_session(mock_db, trace_instance): mock_db.engine = MagicMock() info = _make_message_info() info.message_data = MagicMock() - info.message_data.from_account_id = "acc1" + info.message_data.conversation_id = "conversation-2" + info.message_data.from_account_id = "acc2" info.message_data.from_end_user_id = None - info.message_data.query = "q" - info.message_data.answer = "a" - info.message_data.status = "s" - info.message_data.model_id = "m" - info.message_data.model_provider = "p" + info.message_data.query = "q2" + info.message_data.answer = "a2" + info.message_data.status = "s2" + info.message_data.model_id = "m2" + info.message_data.model_provider = "p2" info.message_data.message_metadata = "{}" info.message_data.error = None info.error = None + root_span = MagicMock() + message_span = MagicMock() + llm_span = MagicMock() + trace_instance.tracer.start_span.side_effect = [root_span, message_span, llm_span] + trace_instance.message_trace(info) - assert trace_instance.tracer.start_span.call_count >= 1 + + message_span_call = _get_start_span_call( + trace_instance.tracer.start_span, span_name=TraceTaskName.MESSAGE_TRACE.value + ) + assert message_span_call.kwargs["attributes"][SpanAttributes.SESSION_ID] == "conversation-2" @patch("dify_trace_arize_phoenix.arize_phoenix_trace.db") @@ -397,3 +1542,30 @@ def test_api_check_success(trace_instance): def test_ensure_root_span_basic(trace_instance): trace_instance.ensure_root_span("tid") assert "tid" in trace_instance.dify_trace_ids + + +def test_ensure_root_span_uses_custom_name_and_attributes(trace_instance): + root_attributes = { + SpanAttributes.INPUT_VALUE: '{"input":"value"}', + SpanAttributes.OUTPUT_VALUE: '{"output":"value"}', + } + + trace_instance.ensure_root_span("tid", root_span_name="Workflow Name", root_span_attributes=root_attributes) + + trace_instance.tracer.start_span.assert_called_once_with( + name="Workflow Name", + attributes={ + SpanAttributes.OPENINFERENCE_SPAN_KIND: "CHAIN", + "dify_project_name": "p", + "dify_trace_id": "tid", + SpanAttributes.INPUT_VALUE: '{"input":"value"}', + SpanAttributes.OUTPUT_VALUE: '{"output":"value"}', + }, + ) + + +def test_ensure_root_span_falls_back_to_dify_name_when_custom_name_is_blank(trace_instance): + trace_instance.ensure_root_span("tid", root_span_name=" ") + + trace_instance.tracer.start_span.assert_called_once() + assert trace_instance.tracer.start_span.call_args.kwargs["name"] == "Dify" diff --git a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py deleted file mode 100644 index a01c63ae61..0000000000 --- a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/test_arize_phoenix_trace.py +++ /dev/null @@ -1,36 +0,0 @@ -from dify_trace_arize_phoenix.arize_phoenix_trace import _NODE_TYPE_TO_SPAN_KIND, _get_node_span_kind -from openinference.semconv.trace import OpenInferenceSpanKindValues - -from graphon.enums import BUILT_IN_NODE_TYPES, BuiltinNodeTypes - - -class TestGetNodeSpanKind: - """Tests for _get_node_span_kind helper.""" - - def test_all_node_types_are_mapped_correctly(self): - """Ensure every built-in node type is mapped to the correct span kind.""" - # Mappings for node types that have a specialised span kind. - special_mappings = { - BuiltinNodeTypes.LLM: OpenInferenceSpanKindValues.LLM, - BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: OpenInferenceSpanKindValues.RETRIEVER, - BuiltinNodeTypes.TOOL: OpenInferenceSpanKindValues.TOOL, - BuiltinNodeTypes.AGENT: OpenInferenceSpanKindValues.AGENT, - } - - # Test that every built-in node type is mapped to the correct span kind. - # Node types not in `special_mappings` should default to CHAIN. - for node_type in BUILT_IN_NODE_TYPES: - expected_span_kind = special_mappings.get(node_type, OpenInferenceSpanKindValues.CHAIN) - actual_span_kind = _get_node_span_kind(node_type) - assert actual_span_kind == expected_span_kind, ( - f"Node type {node_type!r} was mapped to {actual_span_kind}, but {expected_span_kind} was expected." - ) - - def test_unknown_string_defaults_to_chain(self): - """An unrecognised node type string should still return CHAIN.""" - assert _get_node_span_kind("some-future-node-type") == OpenInferenceSpanKindValues.CHAIN - - def test_stale_dataset_retrieval_not_in_mapping(self): - """The old 'dataset_retrieval' string was never a valid NodeType value; - make sure it is not present in the mapping dictionary.""" - assert "dataset_retrieval" not in _NODE_TYPE_TO_SPAN_KIND diff --git a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py index f73ba01c8b..be9d64ae01 100644 --- a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/entities/langsmith_trace_entity.py @@ -65,35 +65,18 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel): } file_list = values.get("file_list", []) if isinstance(v, str): - if field_name == "inputs": - return { - "messages": { - "role": "user", - "content": v, - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } - elif field_name == "outputs": - return { - "choices": { - "role": "ai", - "content": v, - "usage_metadata": usage_metadata, - "file_list": file_list, - }, - } - elif isinstance(v, list): - data = {} - if len(v) > 0 and isinstance(v[0], dict): - # rename text to content - v = replace_text_with_content(data=v) - if field_name == "inputs": - data = { - "messages": v, + match field_name: + case "inputs": + return { + "messages": { + "role": "user", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, } - elif field_name == "outputs": - data = { + case "outputs": + return { "choices": { "role": "ai", "content": v, @@ -101,6 +84,29 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel): "file_list": file_list, }, } + case _: + pass + elif isinstance(v, list): + data = {} + if len(v) > 0 and isinstance(v[0], dict): + # rename text to content + v = replace_text_with_content(data=v) + match field_name: + case "inputs": + data = { + "messages": v, + } + case "outputs": + data = { + "choices": { + "role": "ai", + "content": v, + "usage_metadata": usage_metadata, + "file_list": file_list, + }, + } + case _: + pass return data else: return { diff --git a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py index 145bd70dbc..045ec44e4e 100644 --- a/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/src/dify_trace_langsmith/langsmith_trace.py @@ -64,7 +64,9 @@ class LangSmithDataTrace(BaseTraceInstance): self.generate_name_trace(trace_info) def workflow_trace(self, trace_info: WorkflowTraceInfo): - trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id + # trace_id must equal the root run's run_id (LangSmith protocol); external trace_id + # cannot be used here as it would cause HTTP 400. + trace_id = trace_info.message_id or trace_info.workflow_run_id if trace_info.start_time is None: trace_info.start_time = datetime.now() message_dotted_order = ( @@ -77,6 +79,8 @@ class LangSmithDataTrace(BaseTraceInstance): ) metadata = trace_info.metadata metadata["workflow_app_log_id"] = trace_info.workflow_app_log_id + if trace_info.trace_id: + metadata["external_trace_id"] = trace_info.trace_id if trace_info.message_id: message_run = LangSmithRunModel( diff --git a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py index ee59acb17e..edc4aafd87 100644 --- a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py @@ -208,13 +208,17 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): assert call_args[0].id == "msg-1" assert call_args[0].name == TraceTaskName.MESSAGE_TRACE + # trace_id must equal root run's id (message_id), not the external trace_id "trace-1" + assert call_args[0].trace_id == "msg-1" assert call_args[1].id == "run-1" assert call_args[1].name == TraceTaskName.WORKFLOW_TRACE assert call_args[1].parent_run_id == "msg-1" + assert call_args[1].trace_id == "msg-1" assert call_args[2].id == "node-llm" assert call_args[2].run_type == LangSmithRunType.llm + assert call_args[2].trace_id == "msg-1" assert call_args[3].id == "node-other" assert call_args[3].run_type == LangSmithRunType.tool @@ -604,3 +608,83 @@ def test_get_project_url_error(trace_instance): trace_instance.langsmith_client.get_run_url.side_effect = Exception("error") with pytest.raises(ValueError, match="LangSmith get run url failed: error"): trace_instance.get_project_url() + + +def _make_workflow_trace_info( + *, message_id: str | None, workflow_run_id: str, trace_id: str | None +) -> WorkflowTraceInfo: + workflow_data = MagicMock() + workflow_data.created_at = _dt() + workflow_data.finished_at = _dt() + timedelta(seconds=1) + return WorkflowTraceInfo( + tenant_id="tenant-1", + workflow_id="wf-1", + workflow_run_id=workflow_run_id, + workflow_run_inputs={}, + workflow_run_outputs={}, + workflow_run_status="succeeded", + workflow_run_version="1.0", + workflow_run_elapsed_time=1.0, + total_tokens=0, + file_list=[], + query="q", + message_id=message_id, + conversation_id="conv-1" if message_id else None, + start_time=_dt(), + end_time=_dt() + timedelta(seconds=1), + trace_id=trace_id, + metadata={"app_id": "app-1"}, + workflow_app_log_id=None, + error=None, + workflow_data=workflow_data, + ) + + +def _patch_workflow_trace_deps(monkeypatch, trace_instance): + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock()) + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) + repo = MagicMock() + repo.get_by_workflow_execution.return_value = [] + factory = MagicMock() + factory.create_workflow_node_execution_repository.return_value = repo + monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", factory) + monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) + trace_instance.add_run = MagicMock() + + +def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch): + """Chatflow with external trace_id: LangSmith trace_id must be message_id, not external.""" + trace_info = _make_workflow_trace_info( + message_id="msg-abc", + workflow_run_id="run-xyz", + trace_id="external-999", + ) + _patch_workflow_trace_deps(monkeypatch, trace_instance) + + trace_instance.workflow_trace(trace_info) + + calls = [c[0][0] for c in trace_instance.add_run.call_args_list] + # message run (root) and workflow run (child) must both use message_id as trace_id + assert calls[0].id == "msg-abc" + assert calls[0].trace_id == "msg-abc" + assert calls[1].id == "run-xyz" + assert calls[1].trace_id == "msg-abc" + # external_trace_id preserved in metadata + assert trace_info.metadata.get("external_trace_id") == "external-999" + + +def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch): + """Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id.""" + trace_info = _make_workflow_trace_info( + message_id=None, + workflow_run_id="run-xyz", + trace_id="external-999", + ) + _patch_workflow_trace_deps(monkeypatch, trace_instance) + + trace_instance.workflow_trace(trace_info) + + calls = [c[0][0] for c in trace_instance.add_run.call_args_list] + # workflow run is the root; trace_id must equal its run_id + assert calls[0].id == "run-xyz" + assert calls[0].trace_id == "run-xyz" diff --git a/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py index 843c495d82..d6998f6672 100644 --- a/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py +++ b/api/providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py @@ -81,14 +81,15 @@ class OpenSearchConfig(BaseModel): pool_maxsize=20, ) - if self.auth_method == "basic": - logger.info("Using basic authentication for OpenSearch Vector DB") + match self.auth_method: + case AuthMethod.BASIC: + logger.info("Using basic authentication for OpenSearch Vector DB") - params["http_auth"] = (self.user, self.password) - elif self.auth_method == "aws_managed_iam": - logger.info("Using AWS managed IAM role for OpenSearch Vector DB") + params["http_auth"] = (self.user, self.password) + case AuthMethod.AWS_MANAGED_IAM: + logger.info("Using AWS managed IAM role for OpenSearch Vector DB") - params["http_auth"] = self.create_aws_managed_iam_auth() + params["http_auth"] = self.create_aws_managed_iam_auth() return params diff --git a/api/pyproject.toml b/api/pyproject.toml index 6c30779f9d..40834b806f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "dify-api" -version = "1.14.0" +version = "1.14.1" requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed "bleach>=6.3.0", - "boto3>=1.43.3", + "boto3>=1.43.6", "celery>=5.6.3", "croniter>=6.2.2", "flask>=3.1.3,<4.0.0", @@ -14,8 +14,8 @@ dependencies = [ "gevent>=26.4.0", "gevent-websocket>=0.10.1", "gmpy2>=2.3.0", - "google-api-python-client>=2.195.0", - "gunicorn>=25.3.0", + "google-api-python-client>=2.196.0", + "gunicorn>=26.0.0", "psycogreen>=1.0.2", "psycopg2-binary>=2.9.12", "python-socketio>=5.13.0", @@ -31,7 +31,7 @@ dependencies = [ "flask-migrate>=4.1.0,<5.0.0", "flask-orjson>=2.0.0,<3.0.0", "flask-restx>=1.3.2,<2.0.0", - "google-cloud-aiplatform>=1.149.0,<2.0.0", + "google-cloud-aiplatform>=1.151.0,<2.0.0", "httpx[socks]>=0.28.1,<1.0.0", "opentelemetry-distro>=0.62b1,<1.0.0", "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", @@ -45,7 +45,7 @@ dependencies = [ # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]~=0.7.0", - "graphon~=0.3.0", + "graphon~=0.3.1", "httpx-sse~=0.4.0", "json-repair~=0.59.4", ] @@ -191,7 +191,7 @@ storage = [ "google-cloud-storage>=3.10.1", "opendal>=0.46.0", "oss2>=2.19.1", - "supabase>=2.29.0", + "supabase>=2.30.0", "tos>=2.9.0", ] diff --git a/api/services/app_service.py b/api/services/app_service.py index a046b909b3..6716833f6c 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -1,9 +1,10 @@ import json import logging -from typing import Any, TypedDict, cast +from typing import Any, Literal, TypedDict, cast import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination +from pydantic import BaseModel, Field from sqlalchemy import select from configs import dify_config @@ -31,39 +32,59 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t logger = logging.getLogger(__name__) +class AppListParams(BaseModel): + page: int = Field(default=1, ge=1) + limit: int = Field(default=20, ge=1, le=100) + mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "channel", "all"] = "all" + name: str | None = None + tag_ids: list[str] | None = None + is_created_by_me: bool | None = None + + +class CreateAppParams(BaseModel): + name: str = Field(min_length=1) + description: str | None = None + mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] + icon_type: str | None = None + icon: str | None = None + icon_background: str | None = None + api_rph: int = 0 + api_rpm: int = 0 + max_active_requests: int | None = None + + class AppService: - def get_paginate_apps(self, user_id: str, tenant_id: str, args: dict[str, Any]) -> Pagination | None: + def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None: """ Get app list with pagination :param user_id: user id :param tenant_id: tenant id - :param args: request args + :param params: query parameters :return: """ filters = [App.tenant_id == tenant_id, App.is_universal == False] - if args["mode"] == "workflow": + if params.mode == "workflow": filters.append(App.mode == AppMode.WORKFLOW) - elif args["mode"] == "completion": + elif params.mode == "completion": filters.append(App.mode == AppMode.COMPLETION) - elif args["mode"] == "chat": + elif params.mode == "chat": filters.append(App.mode == AppMode.CHAT) - elif args["mode"] == "advanced-chat": + elif params.mode == "advanced-chat": filters.append(App.mode == AppMode.ADVANCED_CHAT) - elif args["mode"] == "agent-chat": + elif params.mode == "agent-chat": filters.append(App.mode == AppMode.AGENT_CHAT) - if args.get("is_created_by_me", False): + if params.is_created_by_me: filters.append(App.created_by == user_id) - if args.get("name"): + if params.name: from libs.helper import escape_like_pattern - name = args["name"][:30] + name = params.name[:30] escaped_name = escape_like_pattern(name) filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\")) - # Check if tag_ids is not empty to avoid WHERE false condition - if args.get("tag_ids") and len(args["tag_ids"]) > 0: - target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, args["tag_ids"]) + if params.tag_ids and len(params.tag_ids) > 0: + target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids) if target_ids and len(target_ids) > 0: filters.append(App.id.in_(target_ids)) else: @@ -71,21 +92,21 @@ class AppService: app_models = db.paginate( sa.select(App).where(*filters).order_by(App.created_at.desc()), - page=args["page"], - per_page=args["limit"], + page=params.page, + per_page=params.limit, error_out=False, ) return app_models - def create_app(self, tenant_id: str, args: dict[str, Any], account: Account) -> App: + def create_app(self, tenant_id: str, params: CreateAppParams, account: Account) -> App: """ Create app :param tenant_id: tenant id - :param args: request args + :param params: app creation parameters :param account: Account instance """ - app_mode = AppMode.value_of(args["mode"]) + app_mode = AppMode.value_of(params.mode) app_template = default_app_templates[app_mode] # get model config @@ -143,15 +164,16 @@ class AppService: default_model_config["model"] = json.dumps(default_model_dict) app = App(**app_template["app"]) - app.name = args["name"] - app.description = args.get("description", "") - app.mode = args["mode"] - app.icon_type = args.get("icon_type", "emoji") - app.icon = args["icon"] - app.icon_background = args["icon_background"] + app.name = params.name + app.description = params.description or "" + app.mode = app_mode + app.icon_type = IconType(params.icon_type) if params.icon_type else IconType.EMOJI + app.icon = params.icon + app.icon_background = params.icon_background app.tenant_id = tenant_id - app.api_rph = args.get("api_rph", 0) - app.api_rpm = args.get("api_rpm", 0) + app.api_rph = params.api_rph + app.api_rpm = params.api_rpm + app.max_active_requests = params.max_active_requests app.created_by = account.id app.updated_by = account.id diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index eef38f1ce2..4f5a95dcde 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -7,9 +7,10 @@ import time import uuid from collections import Counter from collections.abc import Sequence -from typing import Any, Literal, TypedDict, cast +from typing import Annotated, Any, Literal, TypedDict, cast import sqlalchemy as sa +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from redis.exceptions import LockNotOwnedError from sqlalchemy import delete, exists, func, select, update from sqlalchemy.orm import Session, sessionmaker @@ -108,7 +109,7 @@ logger = logging.getLogger(__name__) class ProcessRulesDict(TypedDict): - mode: str + mode: ProcessRuleMode rules: dict[str, Any] @@ -117,6 +118,86 @@ class AutoDisableLogsDict(TypedDict): count: int +class _EstimatePreProcessingRule(BaseModel): + id: str = Field(min_length=1) + enabled: bool + + @field_validator("id") + @classmethod + def _validate_id(cls, v: str) -> str: + if v not in DatasetProcessRule.PRE_PROCESSING_RULES: + raise ValueError("Process rule pre_processing_rules id is invalid") + return v + + +class _EstimateSegmentation(BaseModel): + separator: str = Field(min_length=1) + max_tokens: int = Field(gt=0) + + +class _EstimateRules(BaseModel): + pre_processing_rules: list[_EstimatePreProcessingRule] + segmentation: _EstimateSegmentation + + @field_validator("pre_processing_rules") + @classmethod + def _deduplicate(cls, v: list[_EstimatePreProcessingRule]) -> list[_EstimatePreProcessingRule]: + seen: dict[str, _EstimatePreProcessingRule] = {} + for rule in v: + seen[rule.id] = rule + return list(seen.values()) + + +class _SummaryIndexSettingDisabled(BaseModel): + enable: Literal[False] = False + + +class _SummaryIndexSettingEnabled(BaseModel): + enable: Literal[True] + model_name: str = Field(min_length=1) + model_provider_name: str = Field(min_length=1) + + +_SummaryIndexSetting = Annotated[ + _SummaryIndexSettingDisabled | _SummaryIndexSettingEnabled, + Field(discriminator="enable"), +] + + +class _AutomaticProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.AUTOMATIC] + summary_index_setting: _SummaryIndexSetting | None = None + + +class _CustomProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.CUSTOM] + rules: _EstimateRules + summary_index_setting: _SummaryIndexSetting | None = None + + +class _HierarchicalProcessRule(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Literal[ProcessRuleMode.HIERARCHICAL] + rules: _EstimateRules + summary_index_setting: _SummaryIndexSetting | None = None + + +_EstimateProcessRule = Annotated[ + _AutomaticProcessRule | _CustomProcessRule | _HierarchicalProcessRule, + Field(discriminator="mode"), +] + + +class _EstimateArgs(BaseModel): + info_list: dict[str, Any] + process_rule: _EstimateProcessRule + + class DatasetService: @staticmethod def get_datasets(page, per_page, tenant_id=None, user=None, search=None, tag_ids=None, include_all=False): @@ -204,7 +285,7 @@ class DatasetService: mode = dataset_process_rule.mode rules = dataset_process_rule.rules_dict or {} else: - mode = str(DocumentService.DEFAULT_RULES["mode"]) + mode = ProcessRuleMode(DocumentService.DEFAULT_RULES["mode"]) rules = dict(DocumentService.DEFAULT_RULES.get("rules") or {}) return {"mode": mode, "rules": rules} @@ -1984,7 +2065,7 @@ class DocumentService: if process_rule.rules: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode(process_rule.mode), rules=process_rule.rules.model_dump_json() if process_rule.rules else None, created_by=account.id, ) @@ -1995,7 +2076,7 @@ class DocumentService: elif process_rule.mode == ProcessRuleMode.AUTOMATIC: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode.AUTOMATIC, rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES), created_by=account.id, ) @@ -2572,14 +2653,14 @@ class DocumentService: if process_rule.mode in {ProcessRuleMode.CUSTOM, ProcessRuleMode.HIERARCHICAL}: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode(process_rule.mode), rules=process_rule.rules.model_dump_json() if process_rule.rules else None, created_by=account.id, ) elif process_rule.mode == ProcessRuleMode.AUTOMATIC: dataset_process_rule = DatasetProcessRule( dataset_id=dataset.id, - mode=process_rule.mode, + mode=ProcessRuleMode.AUTOMATIC, rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES), created_by=account.id, ) @@ -2851,94 +2932,16 @@ class DocumentService: @classmethod def estimate_args_validate(cls, args: dict[str, Any]): - if "info_list" not in args or not args["info_list"]: - raise ValueError("Data source info is required") - - if not isinstance(args["info_list"], dict): - raise ValueError("Data info is invalid") - - if "process_rule" not in args or not args["process_rule"]: - raise ValueError("Process rule is required") - - if not isinstance(args["process_rule"], dict): - raise ValueError("Process rule is invalid") - - if "mode" not in args["process_rule"] or not args["process_rule"]["mode"]: - raise ValueError("Process rule mode is required") - - if args["process_rule"]["mode"] not in DatasetProcessRule.MODES: - raise ValueError("Process rule mode is invalid") - - if args["process_rule"]["mode"] == ProcessRuleMode.AUTOMATIC: - args["process_rule"]["rules"] = {} - else: - if "rules" not in args["process_rule"] or not args["process_rule"]["rules"]: - raise ValueError("Process rule rules is required") - - if not isinstance(args["process_rule"]["rules"], dict): - raise ValueError("Process rule rules is invalid") - - if ( - "pre_processing_rules" not in args["process_rule"]["rules"] - or args["process_rule"]["rules"]["pre_processing_rules"] is None - ): - raise ValueError("Process rule pre_processing_rules is required") - - if not isinstance(args["process_rule"]["rules"]["pre_processing_rules"], list): - raise ValueError("Process rule pre_processing_rules is invalid") - - unique_pre_processing_rule_dicts = {} - for pre_processing_rule in args["process_rule"]["rules"]["pre_processing_rules"]: - if "id" not in pre_processing_rule or not pre_processing_rule["id"]: - raise ValueError("Process rule pre_processing_rules id is required") - - if pre_processing_rule["id"] not in DatasetProcessRule.PRE_PROCESSING_RULES: - raise ValueError("Process rule pre_processing_rules id is invalid") - - if "enabled" not in pre_processing_rule or pre_processing_rule["enabled"] is None: - raise ValueError("Process rule pre_processing_rules enabled is required") - - if not isinstance(pre_processing_rule["enabled"], bool): - raise ValueError("Process rule pre_processing_rules enabled is invalid") - - unique_pre_processing_rule_dicts[pre_processing_rule["id"]] = pre_processing_rule - - args["process_rule"]["rules"]["pre_processing_rules"] = list(unique_pre_processing_rule_dicts.values()) - - if ( - "segmentation" not in args["process_rule"]["rules"] - or args["process_rule"]["rules"]["segmentation"] is None - ): - raise ValueError("Process rule segmentation is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"], dict): - raise ValueError("Process rule segmentation is invalid") - - if ( - "separator" not in args["process_rule"]["rules"]["segmentation"] - or not args["process_rule"]["rules"]["segmentation"]["separator"] - ): - raise ValueError("Process rule segmentation separator is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"]["separator"], str): - raise ValueError("Process rule segmentation separator is invalid") - - if ( - "max_tokens" not in args["process_rule"]["rules"]["segmentation"] - or not args["process_rule"]["rules"]["segmentation"]["max_tokens"] - ): - raise ValueError("Process rule segmentation max_tokens is required") - - if not isinstance(args["process_rule"]["rules"]["segmentation"]["max_tokens"], int): - raise ValueError("Process rule segmentation max_tokens is invalid") - - # valid summary index setting - summary_index_setting = args["process_rule"].get("summary_index_setting") - if summary_index_setting and summary_index_setting.get("enable"): - if "model_name" not in summary_index_setting or not summary_index_setting["model_name"]: - raise ValueError("Summary index model name is required") - if "model_provider_name" not in summary_index_setting or not summary_index_setting["model_provider_name"]: - raise ValueError("Summary index model provider name is required") + try: + validated = _EstimateArgs.model_validate(args) + except ValidationError as e: + first = e.errors()[0] + original = first.get("ctx", {}).get("error") + raise ValueError(str(original) if isinstance(original, ValueError) else first["msg"]) from e + process_rule_dict = validated.process_rule.model_dump(exclude_none=True) + if validated.process_rule.mode == ProcessRuleMode.AUTOMATIC: + process_rule_dict["rules"] = {} + args["process_rule"] = process_rule_dict @staticmethod def batch_update_document_status( diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 9477c28bf3..257c4bea9a 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -166,7 +166,7 @@ class SystemFeatureModel(BaseModel): enable_email_code_login: bool = False enable_email_password_login: bool = True enable_social_oauth_login: bool = False - enable_collaboration_mode: bool = False + enable_collaboration_mode: bool = True is_allow_register: bool = False is_allow_create_workspace: bool = False is_email_setup: bool = False diff --git a/api/services/vector_service.py b/api/services/vector_service.py index 7e689af35d..49c3b85831 100644 --- a/api/services/vector_service.py +++ b/api/services/vector_service.py @@ -111,6 +111,7 @@ class VectorService: "dataset_id": segment.dataset_id, }, ) + assert segment.index_node_id if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY: # update vector index vector = Vector(dataset=dataset) @@ -138,6 +139,7 @@ class VectorService: regenerate: bool = False, ): index_processor = IndexProcessorFactory(dataset.doc_form).init_index_processor() + assert segment.index_node_id if regenerate: # delete child chunks index_processor.clean(dataset, [segment.index_node_id], with_keywords=True, delete_child_chunks=True) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index b8c2ed5e6f..eb78e0a68b 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -1066,8 +1066,13 @@ class WorkflowService: ) rendered_content = node.render_form_content_before_submission() + selected_action = next( + (user_action for user_action in node_data.user_actions if user_action.id == action), + None, + ) outputs: dict[str, Any] = dict(form_inputs) outputs["__action_id"] = action + outputs["__action_value"] = selected_action.title if selected_action else "" outputs["__rendered_content"] = node.render_form_content_with_outputs( rendered_content, outputs, node_data.outputs_field_names() ) diff --git a/api/tasks/batch_clean_document_task.py b/api/tasks/batch_clean_document_task.py index 56c371fcc1..5794726716 100644 --- a/api/tasks/batch_clean_document_task.py +++ b/api/tasks/batch_clean_document_task.py @@ -50,7 +50,7 @@ def batch_clean_document_task(document_ids: list[str], dataset_id: str, doc_form ).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] segment_ids = [segment.id for segment in segments] # Collect image file IDs from segment content diff --git a/api/tasks/batch_create_segment_to_index_task.py b/api/tasks/batch_create_segment_to_index_task.py index beb23d8354..9f19b03544 100644 --- a/api/tasks/batch_create_segment_to_index_task.py +++ b/api/tasks/batch_create_segment_to_index_task.py @@ -19,6 +19,7 @@ from graphon.model_runtime.entities.model_entities import ModelType from libs import helper from libs.datetime_utils import naive_utc_now from models.dataset import Dataset, Document, DocumentSegment +from models.enums import SegmentStatus from models.model import UploadFile from services.vector_service import VectorService @@ -156,7 +157,7 @@ def batch_create_segment_to_index_task( tokens=tokens, created_by=user_id, indexing_at=naive_utc_now(), - status="completed", + status=SegmentStatus.COMPLETED, completed_at=naive_utc_now(), ) if document_config["doc_form"] == IndexStructureType.QA_INDEX: diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py index c8d0e31c06..869e2b3028 100644 --- a/api/tasks/clean_document_task.py +++ b/api/tasks/clean_document_task.py @@ -53,7 +53,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i binding_ids = [binding.id for binding, _ in attachments_with_bindings] total_attachment_files.extend([attachment_file.key for _, attachment_file in attachments_with_bindings]) - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] segment_contents = [segment.content for segment in segments] except Exception: logger.exception("Cleaned document when document deleted failed") diff --git a/api/tasks/clean_notion_document_task.py b/api/tasks/clean_notion_document_task.py index 017d60efac..782d7d0226 100644 --- a/api/tasks/clean_notion_document_task.py +++ b/api/tasks/clean_notion_document_task.py @@ -38,7 +38,7 @@ def clean_notion_document_task(document_ids: list[str], dataset_id: str): for document_id in document_ids: segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() - total_index_node_ids.extend([segment.index_node_id for segment in segments]) + total_index_node_ids.extend([segment.index_node_id for segment in segments if segment.index_node_id]) # Wrap vector / keyword index cleanup in try/except so that a transient # failure here (e.g. billing API hiccup propagated via FeatureService when diff --git a/api/tasks/disable_segment_from_index_task.py b/api/tasks/disable_segment_from_index_task.py index dd1a40844b..d00e143093 100644 --- a/api/tasks/disable_segment_from_index_task.py +++ b/api/tasks/disable_segment_from_index_task.py @@ -9,6 +9,7 @@ from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory from extensions.ext_redis import redis_client from models.dataset import DocumentSegment +from models.enums import SegmentStatus logger = logging.getLogger(__name__) @@ -30,7 +31,7 @@ def disable_segment_from_index_task(segment_id: str): logger.info(click.style(f"Segment not found: {segment_id}", fg="red")) return - if segment.status != "completed": + if segment.status != SegmentStatus.COMPLETED: logger.info(click.style(f"Segment is not completed, disable is not allowed: {segment_id}", fg="red")) return @@ -59,6 +60,7 @@ def disable_segment_from_index_task(segment_id: str): index_type = dataset_document.doc_form index_processor = IndexProcessorFactory(index_type).init_index_processor() + assert segment.index_node_id index_processor.clean(dataset, [segment.index_node_id]) # Disable summary index for this segment diff --git a/api/tasks/disable_segments_from_index_task.py b/api/tasks/disable_segments_from_index_task.py index 86e96ea3f0..cd91ddd074 100644 --- a/api/tasks/disable_segments_from_index_task.py +++ b/api/tasks/disable_segments_from_index_task.py @@ -55,7 +55,7 @@ def disable_segments_from_index_task(segment_ids: list, dataset_id: str, documen return try: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] if dataset.is_multimodal: segment_ids = [segment.id for segment in segments] segment_attachment_bindings = session.scalars( diff --git a/api/tasks/document_indexing_sync_task.py b/api/tasks/document_indexing_sync_task.py index 90c80be3a1..842e7dcdb2 100644 --- a/api/tasks/document_indexing_sync_task.py +++ b/api/tasks/document_indexing_sync_task.py @@ -69,7 +69,7 @@ def document_indexing_sync_task(dataset_id: str, document_id: str): index_type = document.doc_form segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] # Get credentials from datasource provider datasource_provider_service = DatasourceProviderService() diff --git a/api/tasks/document_indexing_update_task.py b/api/tasks/document_indexing_update_task.py index 15f0e0162b..39564bbede 100644 --- a/api/tasks/document_indexing_update_task.py +++ b/api/tasks/document_indexing_update_task.py @@ -45,7 +45,7 @@ def document_indexing_update_task(dataset_id: str, document_id: str): index_type = document.doc_form segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] clean_success = False try: diff --git a/api/tasks/duplicate_document_indexing_task.py b/api/tasks/duplicate_document_indexing_task.py index 6bc58bdf9c..71f367c5e7 100644 --- a/api/tasks/duplicate_document_indexing_task.py +++ b/api/tasks/duplicate_document_indexing_task.py @@ -137,7 +137,7 @@ def _duplicate_document_indexing_task(dataset_id: str, document_ids: Sequence[st select(DocumentSegment).where(DocumentSegment.document_id == document.id) ).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] # delete from vector index index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True) diff --git a/api/tasks/ops_trace_task.py b/api/tasks/ops_trace_task.py index c95b8db078..49fe68ad7e 100644 --- a/api/tasks/ops_trace_task.py +++ b/api/tasks/ops_trace_task.py @@ -1,11 +1,31 @@ +""" +Celery task for asynchronous ops trace dispatch. + +Trace providers may report explicitly retryable dispatch failures through the +core retryable exception contract. The task preserves the payload file only +when Celery accepts the retry request; successful dispatches and terminal +failures clean up the stored payload. + +One concrete producer today is Phoenix nested workflow tracing. The outer +workflow tool span publishes a restorable parent span context asynchronously, +while the nested workflow trace may be picked up by Celery first. In that +ordering window, the provider raises a retryable core exception instead of +dropping the trace or emitting it under the wrong parent. The task intentionally +does not know that the provider is Phoenix; it only honors the core retryable +dispatch contract. +""" + import json import logging from celery import shared_task +from celery.exceptions import Retry from flask import current_app +from configs import dify_config from core.ops.entities.config_entity import OPS_FILE_PATH, OPS_TRACE_FAILED_KEY from core.ops.entities.trace_entity import trace_info_info_map +from core.ops.exceptions import RetryableTraceDispatchError from core.rag.models.document import Document from extensions.ext_redis import redis_client from extensions.ext_storage import storage @@ -14,9 +34,17 @@ from models.workflow import WorkflowRun logger = logging.getLogger(__name__) +_RETRYABLE_TRACE_DISPATCH_LIMIT = dify_config.OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES +_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS = dify_config.OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS -@shared_task(queue="ops_trace") -def process_trace_tasks(file_info): + +@shared_task( + queue="ops_trace", + bind=True, + max_retries=_RETRYABLE_TRACE_DISPATCH_LIMIT, + default_retry_delay=_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS, +) +def process_trace_tasks(self, file_info): """ Async process trace tasks Usage: process_trace_tasks.delay(tasks_data) @@ -29,6 +57,7 @@ def process_trace_tasks(file_info): file_data = json.loads(storage.load(file_path)) trace_info = file_data.get("trace_info") trace_info_type = file_data.get("trace_info_type") + enterprise_trace_dispatched = bool(file_data.get("_enterprise_trace_dispatched")) trace_instance = OpsTraceManager.get_ops_trace_instance(app_id) if trace_info.get("message_data"): @@ -38,6 +67,8 @@ def process_trace_tasks(file_info): if trace_info.get("documents"): trace_info["documents"] = [Document.model_validate(doc) for doc in trace_info["documents"]] + should_delete_file = True + try: trace_type = trace_info_info_map.get(trace_info_type) if trace_type: @@ -45,30 +76,66 @@ def process_trace_tasks(file_info): from extensions.ext_enterprise_telemetry import is_enabled as is_ee_telemetry_enabled - if is_ee_telemetry_enabled(): + if is_ee_telemetry_enabled() and not enterprise_trace_dispatched: from enterprise.telemetry.enterprise_trace import EnterpriseOtelTrace try: EnterpriseOtelTrace().trace(trace_info) except Exception: logger.exception("Enterprise trace failed for app_id: %s", app_id) + else: + file_data["_enterprise_trace_dispatched"] = True + enterprise_trace_dispatched = True if trace_instance: with current_app.app_context(): trace_instance.trace(trace_info) logger.info("Processing trace tasks success, app_id: %s", app_id) + except RetryableTraceDispatchError as e: + # Retryable dispatch failures represent a transient provider-side + # ordering gap, not corrupt payload data. Keep the payload only after + # Celery accepts the retry request; otherwise this attempt becomes a + # terminal failure and the stored file is cleaned up in `finally`. + # + # Enterprise telemetry runs before provider dispatch. If it already ran + # and provider dispatch asks for a retry, persist that private flag so + # the next attempt does not emit the same enterprise trace twice. + if self.request.retries >= _RETRYABLE_TRACE_DISPATCH_LIMIT: + logger.exception("Retryable trace dispatch budget exhausted, app_id: %s", app_id) + failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" + redis_client.incr(failed_key) + else: + logger.warning( + "Retryable trace dispatch failure, scheduling retry %s/%s for app_id %s: %s", + self.request.retries + 1, + _RETRYABLE_TRACE_DISPATCH_LIMIT, + app_id, + e, + ) + try: + if enterprise_trace_dispatched: + storage.save(file_path, json.dumps(file_data).encode("utf-8")) + raise self.retry(exc=e, countdown=_RETRYABLE_TRACE_DISPATCH_DELAY_SECONDS) + except Retry: + should_delete_file = False + raise + except Exception: + logger.exception("Failed to schedule trace dispatch retry, app_id: %s", app_id) + failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" + redis_client.incr(failed_key) except Exception as e: logger.exception("Processing trace tasks failed, app_id: %s", app_id) failed_key = f"{OPS_TRACE_FAILED_KEY}_{app_id}" redis_client.incr(failed_key) finally: - try: - storage.delete(file_path) - except Exception as e: - logger.warning( - "Failed to delete trace file %s for app_id %s: %s", - file_path, - app_id, - e, - ) + if should_delete_file: + try: + storage.delete(file_path) + except Exception as e: + logger.warning( + "Failed to delete trace file %s for app_id %s: %s", + file_path, + app_id, + e, + ) diff --git a/api/tasks/remove_document_from_index_task.py b/api/tasks/remove_document_from_index_task.py index 74e8a012cf..2314d32232 100644 --- a/api/tasks/remove_document_from_index_task.py +++ b/api/tasks/remove_document_from_index_task.py @@ -61,7 +61,7 @@ def remove_document_from_index_task(document_id: str): except Exception as e: logger.warning("Failed to disable summaries for document %s: %s", document.id, str(e)) - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] if index_node_ids: try: index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=False) diff --git a/api/tasks/retry_document_indexing_task.py b/api/tasks/retry_document_indexing_task.py index 7cc28d5226..0df5896ce3 100644 --- a/api/tasks/retry_document_indexing_task.py +++ b/api/tasks/retry_document_indexing_task.py @@ -85,7 +85,7 @@ def retry_document_indexing_task(dataset_id: str, document_ids: list[str], user_ select(DocumentSegment).where(DocumentSegment.document_id == document_id) ).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] # delete from vector index index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True) diff --git a/api/tasks/sync_website_document_indexing_task.py b/api/tasks/sync_website_document_indexing_task.py index ab21f63f7e..06eb460311 100644 --- a/api/tasks/sync_website_document_indexing_task.py +++ b/api/tasks/sync_website_document_indexing_task.py @@ -70,7 +70,7 @@ def sync_website_document_indexing_task(dataset_id: str, document_id: str): segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() if segments: - index_node_ids = [segment.index_node_id for segment in segments] + index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id] # delete from vector index index_processor.clean(dataset, index_node_ids, with_keywords=True, delete_child_chunks=True) diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py index 92f3a1926c..5b7790f6f4 100644 --- a/api/tests/integration_tests/workflow/nodes/test_llm.py +++ b/api/tests/integration_tests/workflow/nodes/test_llm.py @@ -91,7 +91,11 @@ def init_llm_node(config: dict) -> LLMNode: return node -def test_execute_llm(): +def _mock_db_session_close(monkeypatch) -> None: + monkeypatch.setattr(db.session, "close", MagicMock()) + + +def test_execute_llm(monkeypatch): node = init_llm_node( config={ "id": "llm", @@ -118,7 +122,7 @@ def test_execute_llm(): }, ) - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) def build_mock_model_instance() -> MagicMock: from decimal import Decimal @@ -195,7 +199,7 @@ def test_execute_llm(): assert item.node_run_result.outputs.get("usage", {})["total_tokens"] > 0 -def test_execute_llm_with_jinja2(): +def test_execute_llm_with_jinja2(monkeypatch): """ Test execute LLM node with jinja2 """ @@ -233,8 +237,7 @@ def test_execute_llm_with_jinja2(): }, ) - # Mock db.session.close() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) def build_mock_model_instance() -> MagicMock: from decimal import Decimal diff --git a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py index f11188323a..fc230a2a68 100644 --- a/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py +++ b/api/tests/integration_tests/workflow/nodes/test_parameter_extractor.py @@ -83,7 +83,11 @@ def init_parameter_extractor_node(config: dict, memory=None): return node -def test_function_calling_parameter_extractor(setup_model_mock): +def _mock_db_session_close(monkeypatch) -> None: + monkeypatch.setattr(db.session, "close", MagicMock()) + + +def test_function_calling_parameter_extractor(setup_model_mock, monkeypatch): """ Test function calling for parameter extractor. """ @@ -114,7 +118,7 @@ def test_function_calling_parameter_extractor(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -124,7 +128,7 @@ def test_function_calling_parameter_extractor(setup_model_mock): assert result.outputs.get("__reason") == None -def test_instructions(setup_model_mock): +def test_instructions(setup_model_mock, monkeypatch): """ Test chat parameter extractor. """ @@ -155,7 +159,7 @@ def test_instructions(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -174,7 +178,7 @@ def test_instructions(setup_model_mock): assert "what's the weather in SF" in prompt.get("text") -def test_chat_parameter_extractor(setup_model_mock): +def test_chat_parameter_extractor(setup_model_mock, monkeypatch): """ Test chat parameter extractor. """ @@ -205,7 +209,7 @@ def test_chat_parameter_extractor(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -225,7 +229,7 @@ def test_chat_parameter_extractor(setup_model_mock): assert '\n{"type": "object"' in prompt.get("text") -def test_completion_parameter_extractor(setup_model_mock): +def test_completion_parameter_extractor(setup_model_mock, monkeypatch): """ Test completion parameter extractor. """ @@ -256,7 +260,7 @@ def test_completion_parameter_extractor(setup_model_mock): mode="completion", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() @@ -350,7 +354,7 @@ def test_extract_json_from_tool_call(): assert result["location"] == "kawaii" -def test_chat_parameter_extractor_with_memory(setup_model_mock): +def test_chat_parameter_extractor_with_memory(setup_model_mock, monkeypatch): """ Test chat parameter extractor with memory. """ @@ -382,7 +386,7 @@ def test_chat_parameter_extractor_with_memory(setup_model_mock): mode="chat", credentials={"openai_api_key": os.environ.get("OPENAI_API_KEY")}, )() - db.session.close = MagicMock() + _mock_db_session_close(monkeypatch) result = node._run() diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py index 290be87697..a071d22ee9 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py +++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_workflow_draft_variable.py @@ -168,6 +168,7 @@ def test_node_variable_collection_get_success( account, tenant = create_console_account_and_tenant(db_session_with_containers) app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW) node_variable = _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_123") + node_variable_id = node_variable.id _create_node_variable(db_session_with_containers, app.id, account.id, node_id="node_456", name="other") response = test_client_with_containers.get( @@ -178,7 +179,7 @@ def test_node_variable_collection_get_success( assert response.status_code == 200 payload = response.get_json() assert payload is not None - assert [item["id"] for item in payload["items"]] == [node_variable.id] + assert [item["id"] for item in payload["items"]] == [node_variable_id] def test_node_variable_collection_get_invalid_node_id( @@ -377,6 +378,7 @@ def test_system_variable_collection_get( account, tenant = create_console_account_and_tenant(db_session_with_containers) app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.WORKFLOW) variable = _create_system_variable(db_session_with_containers, app.id, account.id) + variable_id = variable.id response = test_client_with_containers.get( f"/console/api/apps/{app.id}/workflows/draft/system-variables", @@ -386,7 +388,7 @@ def test_system_variable_collection_get( assert response.status_code == 200 payload = response.get_json() assert payload is not None - assert [item["id"] for item in payload["items"]] == [variable.id] + assert [item["id"] for item in payload["items"]] == [variable_id] def test_environment_variable_collection_get( diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py index 81b5423261..f2c45f76da 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_data_source_oauth.py @@ -17,6 +17,8 @@ def test_get_oauth_url_successful( test_client_with_containers: FlaskClient, ) -> None: account, tenant = create_console_account_and_tenant(db_session_with_containers) + tenant_id = tenant.id + current_tenant_id = account.current_tenant_id provider = MagicMock() provider.get_authorization_url.return_value = "http://oauth.provider/auth" @@ -29,7 +31,7 @@ def test_get_oauth_url_successful( headers=authenticate_console_client(test_client_with_containers, account), ) - assert tenant.id == account.current_tenant_id + assert tenant_id == current_tenant_id assert response.status_code == 200 assert response.get_json() == {"data": "http://oauth.provider/auth"} provider.get_authorization_url.assert_called_once() diff --git a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py b/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py index d017e8f2bd..5fc3b3084a 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/test_containers_integration_tests/controllers/console/auth/test_password_reset.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch import pytest from flask import Flask +from sqlalchemy.orm import Session from controllers.console.auth.error import ( EmailCodeError, @@ -20,13 +21,15 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordSendEmailApi, ) from controllers.console.error import AccountNotFound, EmailSendIpLimitError +from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup class TestForgotPasswordSendEmailApi: """Test cases for sending password reset emails.""" @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @pytest.fixture @@ -139,7 +142,8 @@ class TestForgotPasswordCheckApi: """Test cases for verifying password reset codes.""" @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @patch("controllers.console.auth.forgot_password.AccountService.is_forgot_password_error_rate_limit") @@ -322,7 +326,8 @@ class TestForgotPasswordResetApi: """Test cases for resetting password with verified token.""" @pytest.fixture - def app(self, flask_app_with_containers: Flask): + def app(self, flask_app_with_containers: Flask, db_session_with_containers: Session): + ensure_dify_setup(db_session_with_containers) return flask_app_with_containers @pytest.fixture diff --git a/api/tests/test_containers_integration_tests/services/test_agent_service.py b/api/tests/test_containers_integration_tests/services/test_agent_service.py index cbd939c7a4..670b4d00da 100644 --- a/api/tests/test_containers_integration_tests/services/test_agent_service.py +++ b/api/tests/test_containers_integration_tests/services/test_agent_service.py @@ -11,7 +11,7 @@ from models.enums import ConversationFromSource, MessageFileBelongsTo from models.model import AppModelConfig, Conversation, EndUser, Message, MessageAgentThought from services.account_service import AccountService, TenantService from services.agent_service import AgentService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -119,16 +119,16 @@ class TestAgentService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "agent-chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="agent-chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_annotation_service.py b/api/tests/test_containers_integration_tests/services/test_annotation_service.py index 95fc73f45a..bc75562d15 100644 --- a/api/tests/test_containers_integration_tests/services/test_annotation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_annotation_service.py @@ -9,7 +9,7 @@ from models import Account from models.enums import ConversationFromSource, InvokeFrom from models.model import MessageAnnotation from services.annotation_service import AppAnnotationService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -86,16 +86,16 @@ class TestAnnotationService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app app_service = AppService() diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index a5ec06dc13..c77bbd3e44 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -37,7 +37,7 @@ from services.app_dsl_service import ( PendingData, _check_version_compatibility, ) -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from tests.test_containers_integration_tests.helpers import generate_valid_password _DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001" @@ -147,16 +147,16 @@ class TestAppDslService: ) TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) return app, account diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index e2fe6c8476..8be4c040b7 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -1,4 +1,5 @@ import uuid +from typing import Literal from unittest.mock import ANY, MagicMock, patch import pytest @@ -133,7 +134,10 @@ class TestAppGenerateService: } def _create_test_app_and_account( - self, db_session_with_containers: Session, mock_external_service_dependencies, mode="chat" + self, + db_session_with_containers: Session, + mock_external_service_dependencies, + mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = "chat", ): """ Helper method to create a test app and account for testing. @@ -165,20 +169,20 @@ class TestAppGenerateService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": mode, - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - "max_active_requests": 5, - } + from services.app_service import AppService, CreateAppParams - from services.app_service import AppService + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode=mode, + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + max_active_requests=5, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index 837b63d1ea..c37fce296f 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -2,6 +2,7 @@ from unittest.mock import create_autospec, patch import pytest from faker import Faker +from pydantic import ValidationError from sqlalchemy.orm import Session from constants.model_template import default_app_templates @@ -12,7 +13,7 @@ from services.account_service import AccountService, TenantService from tests.test_containers_integration_tests.helpers import generate_valid_password # Delay import of AppService to avoid circular dependency -# from services.app_service import AppService +# from services.app_service import AppService, AppListParams, CreateAppParams class TestAppService: @@ -64,34 +65,34 @@ class TestAppService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + # Import here to avoid circular dependency + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app - # Import here to avoid circular dependency - from services.app_service import AppService - app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Verify app was created correctly - assert app.name == app_args["name"] - assert app.description == app_args["description"] - assert app.mode == app_args["mode"] - assert app.icon_type == app_args["icon_type"] - assert app.icon == app_args["icon"] - assert app.icon_background == app_args["icon_background"] + assert app.name == app_params.name + assert app.description == app_params.description + assert app.mode == app_params.mode + assert app.icon_type == app_params.icon_type + assert app.icon == app_params.icon + assert app.icon_background == app_params.icon_background assert app.tenant_id == tenant.id - assert app.api_rph == app_args["api_rph"] - assert app.api_rpm == app_args["api_rpm"] + assert app.api_rph == app_params.api_rph + assert app.api_rpm == app_params.api_rpm assert app.created_by == account.id assert app.updated_by == account.id assert app.status == "normal" @@ -120,7 +121,7 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() @@ -129,20 +130,20 @@ class TestAppService: app_modes = [v.value for v in default_app_templates] for mode in app_modes: - app_args = { - "name": f"{fake.company()} {mode}", - "description": f"Test app for {mode} mode", - "mode": mode, - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_params = CreateAppParams( + name=f"{fake.company()} {mode}", + description=f"Test app for {mode} mode", + mode=mode, + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Verify app mode was set correctly assert app.mode == mode - assert app.name == app_args["name"] + assert app.name == app_params.name assert app.tenant_id == tenant.id assert app.created_by == account.id @@ -163,20 +164,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - created_app = app_service.create_app(tenant.id, app_args, account) + created_app = app_service.create_app(tenant.id, app_params, account) # Get app using the service - needs current_user mock mock_current_user = create_autospec(Account, instance=True) @@ -211,31 +212,27 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create multiple apps app_names = [fake.company() for _ in range(5)] for name in app_names: - app_args = { - "name": name, - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "📱", - "icon_background": "#96CEB4", - } - app_service.create_app(tenant.id, app_args, account) + app_params = CreateAppParams( + name=name, + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="📱", + icon_background="#96CEB4", + ) + app_service.create_app(tenant.id, app_params, account) # Get paginated apps - args = { - "page": 1, - "limit": 10, - "mode": "chat", - } + params = AppListParams(page=1, limit=10, mode="chat") - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Verify pagination results assert paginated_apps is not None @@ -267,60 +264,47 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create apps with different modes - chat_app_args = { - "name": "Chat App", - "description": "A chat application", - "mode": "chat", - "icon_type": "emoji", - "icon": "💬", - "icon_background": "#FF6B6B", - } - completion_app_args = { - "name": "Completion App", - "description": "A completion application", - "mode": "completion", - "icon_type": "emoji", - "icon": "✍️", - "icon_background": "#4ECDC4", - } + chat_app_params = CreateAppParams( + name="Chat App", + description="A chat application", + mode="chat", + icon_type="emoji", + icon="💬", + icon_background="#FF6B6B", + ) + completion_app_params = CreateAppParams( + name="Completion App", + description="A completion application", + mode="completion", + icon_type="emoji", + icon="✍️", + icon_background="#4ECDC4", + ) - chat_app = app_service.create_app(tenant.id, chat_app_args, account) - completion_app = app_service.create_app(tenant.id, completion_app_args, account) + chat_app = app_service.create_app(tenant.id, chat_app_params, account) + completion_app = app_service.create_app(tenant.id, completion_app_params, account) # Test filter by mode - chat_args = { - "page": 1, - "limit": 10, - "mode": "chat", - } - chat_apps = app_service.get_paginate_apps(account.id, tenant.id, chat_args) + chat_apps = app_service.get_paginate_apps(account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat")) assert len(chat_apps.items) == 1 assert chat_apps.items[0].mode == "chat" # Test filter by name - name_args = { - "page": 1, - "limit": 10, - "mode": "chat", - "name": "Chat", - } - filtered_apps = app_service.get_paginate_apps(account.id, tenant.id, name_args) + filtered_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="chat", name="Chat") + ) assert len(filtered_apps.items) == 1 assert "Chat" in filtered_apps.items[0].name # Test filter by created_by_me - created_by_me_args = { - "page": 1, - "limit": 10, - "mode": "completion", - "is_created_by_me": True, - } - my_apps = app_service.get_paginate_apps(account.id, tenant.id, created_by_me_args) + my_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(page=1, limit=10, mode="completion", is_created_by_me=True) + ) assert len(my_apps.items) == 1 def test_get_paginate_apps_with_tag_filters( @@ -342,34 +326,29 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create an app - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🏷️", - "icon_background": "#FFEAA7", - } - app = app_service.create_app(tenant.id, app_args, account) + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🏷️", + icon_background="#FFEAA7", + ) + app = app_service.create_app(tenant.id, app_params, account) # Mock TagService to return the app ID for tag filtering with patch("services.app_service.TagService.get_target_ids_by_tag_ids") as mock_tag_service: mock_tag_service.return_value = [app.id] # Test with tag filter - args = { - "page": 1, - "limit": 10, - "mode": "chat", - "tag_ids": ["tag1", "tag2"], - } + params = AppListParams(page=1, limit=10, mode="chat", tag_ids=["tag1", "tag2"]) - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Verify tag service was called mock_tag_service.assert_called_once_with("app", tenant.id, ["tag1", "tag2"]) @@ -383,14 +362,9 @@ class TestAppService: with patch("services.app_service.TagService.get_target_ids_by_tag_ids") as mock_tag_service: mock_tag_service.return_value = [] - args = { - "page": 1, - "limit": 10, - "mode": "chat", - "tag_ids": ["nonexistent_tag"], - } + params = AppListParams(page=1, limit=10, mode="chat", tag_ids=["nonexistent_tag"]) - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, params) # Should return None when no apps match tag filter assert paginated_apps is None @@ -412,20 +386,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original values original_name = app.name @@ -481,19 +455,19 @@ class TestAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ), account, ) @@ -533,19 +507,19 @@ class TestAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ), account, ) @@ -584,20 +558,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original name original_name = app.name @@ -637,20 +611,20 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🎯", - "icon_background": "#45B7D1", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + app_params = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🎯", + icon_background="#45B7D1", + ) app_service = AppService() - app = app_service.create_app(tenant.id, app_args, account) + app = app_service.create_app(tenant.id, app_params, account) # Store original values original_icon = app.icon @@ -698,18 +672,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🌐", - "icon_background": "#74B9FF", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🌐", + icon_background="#74B9FF", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -758,18 +731,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔌", - "icon_background": "#A29BFE", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔌", + icon_background="#A29BFE", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -818,18 +790,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔄", - "icon_background": "#FD79A8", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔄", + icon_background="#FD79A8", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -869,18 +840,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🗑️", - "icon_background": "#E17055", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🗑️", + icon_background="#E17055", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -921,18 +891,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🧹", - "icon_background": "#00B894", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🧹", + icon_background="#00B894", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -981,18 +950,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "📊", - "icon_background": "#6C5CE7", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="📊", + icon_background="#6C5CE7", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1020,18 +988,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🔗", - "icon_background": "#FDCB6E", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🔗", + icon_background="#FDCB6E", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1060,18 +1027,17 @@ class TestAppService: tenant = account.current_tenant # Create app first - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🆔", - "icon_background": "#E84393", - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🆔", + icon_background="#E84393", + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -1107,26 +1073,20 @@ class TestAppService: password=generate_valid_password(fake), ) TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) - tenant = account.current_tenant - - # Setup app creation arguments with invalid mode - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "invalid_mode", # Invalid mode - "icon_type": "emoji", - "icon": "❌", - "icon_background": "#D63031", - } # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import CreateAppParams - app_service = AppService() - - # Attempt to create app with invalid mode - with pytest.raises(ValueError, match="invalid mode value"): - app_service.create_app(tenant.id, app_args, account) + # Attempt to create app with invalid mode - Pydantic will reject invalid literal + with pytest.raises(ValidationError): + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="invalid_mode", # type: ignore[arg-type] + icon_type="emoji", + icon="❌", + icon_background="#D63031", + ) def test_get_apps_with_special_characters_in_name( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -1152,99 +1112,103 @@ class TestAppService: tenant = account.current_tenant # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppListParams, AppService, CreateAppParams app_service = AppService() # Create apps with special characters in names app_with_percent = app_service.create_app( tenant.id, - { - "name": "App with 50% discount", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="App with 50% discount", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) app_with_underscore = app_service.create_app( tenant.id, - { - "name": "test_data_app", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="test_data_app", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) app_with_backslash = app_service.create_app( tenant.id, - { - "name": "path\\to\\app", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="path\\to\\app", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) # Create app that should NOT match app_no_match = app_service.create_app( tenant.id, - { - "name": "100% different", - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - }, + CreateAppParams( + name="100% different", + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ), account, ) # Test 1: Search with % character - args = {"name": "50%", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "App with 50% discount" # Test 2: Search with _ character - args = {"name": "test_data", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="test_data", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "test_data_app" # Test 3: Search with \ character - args = {"name": "path\\to\\app", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="path\\to\\app", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert len(paginated_apps.items) == 1 assert paginated_apps.items[0].name == "path\\to\\app" # Test 4: Search with % should NOT match 100% (verifies escaping works) - args = {"name": "50%", "mode": "chat", "page": 1, "limit": 10} - paginated_apps = app_service.get_paginate_apps(account.id, tenant.id, args) + paginated_apps = app_service.get_paginate_apps( + account.id, tenant.id, AppListParams(name="50%", mode="chat", page=1, limit=10) + ) assert paginated_apps is not None assert paginated_apps.total == 1 assert all("50%" in app.name for app in paginated_apps.items) diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_get_segments.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_get_segments.py index 87239b2cb3..bd8f5371b8 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_get_segments.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_get_segments.py @@ -13,9 +13,9 @@ from uuid import uuid4 from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexTechniqueType -from models import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, DatasetPermissionEnum, Document, DocumentSegment -from models.enums import DataSourceType, DocumentCreatedFrom +from models.enums import DataSourceType, DocumentCreatedFrom, SegmentStatus from services.dataset_service import SegmentService @@ -35,13 +35,13 @@ class SegmentServiceTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.commit() if tenant is None: - tenant = Tenant(name=f"tenant-{uuid4()}", status="normal") + tenant = Tenant(name=f"tenant-{uuid4()}", status=TenantStatus.NORMAL) db_session_with_containers.add(tenant) db_session_with_containers.commit() @@ -103,7 +103,7 @@ class SegmentServiceTestDataFactory: created_by: str, position: int = 1, content: str = "Test content", - status: str = "completed", + status: SegmentStatus = SegmentStatus.COMPLETED, word_count: int = 10, tokens: int = 15, ) -> DocumentSegment: @@ -203,7 +203,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=1, - status="completed", + status=SegmentStatus.COMPLETED, ) SegmentServiceTestDataFactory.create_segment( db_session_with_containers, @@ -212,7 +212,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=2, - status="indexing", + status=SegmentStatus.INDEXING, ) SegmentServiceTestDataFactory.create_segment( db_session_with_containers, @@ -221,7 +221,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=3, - status="waiting", + status=SegmentStatus.WAITING, ) # Act @@ -257,7 +257,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=1, - status="completed", + status=SegmentStatus.COMPLETED, ) SegmentServiceTestDataFactory.create_segment( db_session_with_containers, @@ -266,7 +266,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=2, - status="indexing", + status=SegmentStatus.INDEXING, ) # Act @@ -415,7 +415,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=1, - status="completed", + status=SegmentStatus.COMPLETED, content="This is important information", ) SegmentServiceTestDataFactory.create_segment( @@ -425,7 +425,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=2, - status="indexing", + status=SegmentStatus.INDEXING, content="This is also important", ) SegmentServiceTestDataFactory.create_segment( @@ -435,7 +435,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=3, - status="completed", + status=SegmentStatus.COMPLETED, content="This is irrelevant", ) @@ -477,7 +477,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=1, - status="completed", + status=SegmentStatus.COMPLETED, ) SegmentServiceTestDataFactory.create_segment( db_session_with_containers, @@ -486,7 +486,7 @@ class TestSegmentServiceGetSegments: document_id=document.id, created_by=owner.id, position=2, - status="waiting", + status=SegmentStatus.WAITING, ) # Act diff --git a/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py b/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py index 2f90d16176..0c610311bb 100644 --- a/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py +++ b/api/tests/test_containers_integration_tests/services/test_dataset_service_retrieval.py @@ -16,6 +16,7 @@ from uuid import uuid4 from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexTechniqueType +from models import AccountStatus, CreatorUserRole, TenantStatus from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import ( AppDatasetJoin, @@ -25,7 +26,7 @@ from models.dataset import ( DatasetProcessRule, DatasetQuery, ) -from models.enums import DatasetQuerySource, DataSourceType, ProcessRuleMode +from models.enums import DatasetQuerySource, DataSourceType, ProcessRuleMode, TagType from models.model import Tag, TagBinding from services.dataset_service import DatasetService, DocumentService @@ -42,11 +43,11 @@ class DatasetRetrievalTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) tenant = Tenant( name=f"tenant-{uuid4()}", - status="normal", + status=TenantStatus.NORMAL, ) db_session_with_containers.add_all([account, tenant]) db_session_with_containers.flush() @@ -72,7 +73,7 @@ class DatasetRetrievalTestDataFactory: email=f"{uuid4()}@example.com", name=f"user-{uuid4()}", interface_language="en-US", - status="active", + status=AccountStatus.ACTIVE, ) db_session_with_containers.add(account) db_session_with_containers.flush() @@ -130,7 +131,7 @@ class DatasetRetrievalTestDataFactory: @staticmethod def create_process_rule( - db_session_with_containers: Session, dataset_id: str, created_by: str, mode: str, rules: dict + db_session_with_containers: Session, dataset_id: str, created_by: str, mode: ProcessRuleMode, rules: dict ) -> DatasetProcessRule: """Create a dataset process rule.""" process_rule = DatasetProcessRule( @@ -153,7 +154,7 @@ class DatasetRetrievalTestDataFactory: content=content, source=DatasetQuerySource.APP, source_app_id=None, - created_by_role="account", + created_by_role=CreatorUserRole.ACCOUNT, created_by=created_by, ) db_session_with_containers.add(dataset_query) @@ -176,7 +177,7 @@ class DatasetRetrievalTestDataFactory: """Create a knowledge tag and bind it to the target dataset.""" tag = Tag( tenant_id=tenant_id, - type="knowledge", + type=TagType.KNOWLEDGE, name=f"tag-{uuid4()}", created_by=created_by, ) diff --git a/api/tests/test_containers_integration_tests/services/test_message_service.py b/api/tests/test_containers_integration_tests/services/test_message_service.py index bdf6d9b951..6d0d281c6b 100644 --- a/api/tests/test_containers_integration_tests/services/test_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_message_service.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from models.enums import ConversationFromSource, FeedbackRating, InvokeFrom from models.model import MessageFeedback -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.errors.message import ( FirstMessageNotExistsError, LastMessageNotExistsError, @@ -103,16 +103,16 @@ class TestMessageService: tenant = account.current_tenant # Setup app creation arguments - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "advanced-chat", # Use advanced-chat mode to use mocked workflow - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="advanced-chat", # Use advanced-chat mode to use mocked workflow, + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) # Create app app_service = AppService() diff --git a/api/tests/test_containers_integration_tests/services/test_ops_service.py b/api/tests/test_containers_integration_tests/services/test_ops_service.py index e2e1a228b2..ff76bce416 100644 --- a/api/tests/test_containers_integration_tests/services/test_ops_service.py +++ b/api/tests/test_containers_integration_tests/services/test_ops_service.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from core.ops.entities.config_entity import TracingProviderEnum from models.model import TraceAppConfig from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.ops_service import OpsService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -57,14 +57,14 @@ class TestOpsService: app_service = AppService() app = app_service.create_app( tenant.id, - { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - }, + CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + ), account, ) return app, account diff --git a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py index 7b9e9924cd..7368ad4249 100644 --- a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py @@ -8,7 +8,7 @@ from models import App, CreatorUserRole from models.enums import ConversationFromSource from models.model import EndUser, Message from models.web import SavedMessage -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.saved_message_service import SavedMessageService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -73,16 +73,16 @@ class TestSavedMessageService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py index 797731d04b..8e53a2d6cd 100644 --- a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py @@ -11,7 +11,7 @@ from models.enums import ConversationFromSource from models.model import Conversation, EndUser from models.web import PinnedConversation from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.web_conversation_service import WebConversationService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -77,16 +77,16 @@ class TestWebConversationService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py index a2cdddad61..07a49130d0 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py @@ -17,7 +17,7 @@ from models.workflow import WorkflowAppLogCreatedFrom from services.account_service import AccountService, TenantService # Delay import of AppService to avoid circular dependency -# from services.app_service import AppService +# from services.app_service import AppService, CreateAppParams from services.workflow_app_service import LogView, WorkflowAppService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -82,20 +82,20 @@ class TestWorkflowAppService: TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) tenant = account.current_tenant - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -146,20 +146,20 @@ class TestWorkflowAppService: """ fake = Faker() - # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } - # Import here to avoid circular dependency - from services.app_service import AppService + from services.app_service import AppService, CreateAppParams + + # Create app with realistic data + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py index d02a078281..09fe1570bc 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py @@ -13,7 +13,7 @@ from models.model import ( ) from models.workflow import WorkflowRun from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.workflow_run_service import WorkflowRunService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -79,16 +79,16 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "chat", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="chat", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) @@ -535,13 +535,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Create workflow run without node executions @@ -586,13 +586,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Use invalid workflow run ID @@ -637,13 +637,13 @@ class TestWorkflowRunService: tenant = account.current_tenant # Create app - app_args = { - "name": "Test App", - "mode": "chat", - "icon_type": "emoji", - "icon": "🚀", - "icon_background": "#4ECDC4", - } + app_args = CreateAppParams( + name="Test App", + mode="chat", + icon_type="emoji", + icon="🚀", + icon_background="#4ECDC4", + ) app = app_service.create_app(tenant.id, app_args, account) # Create workflow run diff --git a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py index 21a1975879..9b574fe2df 100644 --- a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py +++ b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py @@ -11,7 +11,7 @@ from core.tools.errors import WorkflowToolHumanInputNotSupportedError from models.tools import WorkflowToolProvider from models.workflow import Workflow as WorkflowModel from services.account_service import AccountService, TenantService -from services.app_service import AppService +from services.app_service import AppService, CreateAppParams from services.tools.workflow_tools_manage_service import WorkflowToolManageService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -94,16 +94,16 @@ class TestWorkflowToolManageService: tenant = account.current_tenant # Create app with realistic data - app_args = { - "name": fake.company(), - "description": fake.text(max_nb_chars=100), - "mode": "workflow", - "icon_type": "emoji", - "icon": "🤖", - "icon_background": "#FF6B6B", - "api_rph": 100, - "api_rpm": 10, - } + app_args = CreateAppParams( + name=fake.company(), + description=fake.text(max_nb_chars=100), + mode="workflow", + icon_type="emoji", + icon="🤖", + icon_background="#FF6B6B", + api_rph=100, + api_rpm=10, + ) app_service = AppService() app = app_service.create_app(tenant.id, app_args, account) diff --git a/api/tests/test_containers_integration_tests/tasks/test_add_document_to_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_add_document_to_index_task.py index fcc15aad42..94fd7602f5 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_add_document_to_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_add_document_to_index_task.py @@ -128,7 +128,6 @@ class TestAddDocumentToIndexTask: for i in range(3): segment = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -451,7 +450,6 @@ class TestAddDocumentToIndexTask: segments = [] for i in range(3): segment = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -630,7 +628,6 @@ class TestAddDocumentToIndexTask: # Segment 1: Should be processed (enabled=False, status=SegmentStatus.COMPLETED) segment1 = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -650,7 +647,6 @@ class TestAddDocumentToIndexTask: # Segment 2: Should be processed (enabled=True, status=SegmentStatus.COMPLETED) # Note: Implementation doesn't filter by enabled status, only by status=SegmentStatus.COMPLETED segment2 = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -669,7 +665,6 @@ class TestAddDocumentToIndexTask: # Segment 3: Should NOT be processed (enabled=False, status="processing") segment3 = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, @@ -688,7 +683,6 @@ class TestAddDocumentToIndexTask: # Segment 4: Should be processed (enabled=False, status=SegmentStatus.COMPLETED) segment4 = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py b/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py index e29ca7ebab..436c8f11b0 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py @@ -177,7 +177,6 @@ class TestBatchCleanDocumentTask: fake = Faker() segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=account.current_tenant.id, dataset_id=document.dataset_id, document_id=document.id, @@ -290,10 +289,9 @@ class TestBatchCleanDocumentTask: account = self._create_test_account(db_session_with_containers) dataset = self._create_test_dataset(db_session_with_containers, account) document = self._create_test_document(db_session_with_containers, dataset, account) - + assert account.current_tenant # Create segment with simple content (no image references) segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=account.current_tenant.id, dataset_id=document.dataset_id, document_id=document.id, @@ -692,9 +690,9 @@ class TestBatchCleanDocumentTask: # Create multiple segments for the document segments = [] + assert account.current_tenant for i in range(3): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=account.current_tenant.id, dataset_id=document.dataset_id, document_id=document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py b/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py index 32bc2fc0bd..a31552a09e 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py @@ -220,7 +220,6 @@ class TestCleanDatasetTask: DocumentSegment: Created document segment instance """ segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -232,8 +231,6 @@ class TestCleanDatasetTask: status=SegmentStatus.COMPLETED, index_node_id=str(uuid.uuid4()), index_node_hash="test_hash", - created_at=datetime.now(), - updated_at=datetime.now(), ) db_session_with_containers.add(segment) @@ -614,7 +611,6 @@ class TestCleanDatasetTask: """ segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -626,8 +622,6 @@ class TestCleanDatasetTask: status=SegmentStatus.COMPLETED, index_node_id=str(uuid.uuid4()), index_node_hash="test_hash", - created_at=datetime.now(), - updated_at=datetime.now(), ) db_session_with_containers.add(segment) @@ -729,8 +723,6 @@ class TestCleanDatasetTask: type=DatasetMetadataType.STRING, created_by=account.id, ) - metadata.id = str(uuid.uuid4()) - metadata.created_at = datetime.now() metadata_items.append(metadata) # Create binding for each metadata item @@ -741,8 +733,6 @@ class TestCleanDatasetTask: document_id=documents[i % len(documents)].id, created_by=account.id, ) - binding.id = str(uuid.uuid4()) - binding.created_at = datetime.now() bindings.append(binding) db_session_with_containers.add_all(metadata_items) @@ -946,7 +936,6 @@ class TestCleanDatasetTask: long_content = "Very long content " * 100 # Long content within reasonable limits segment_content = f"Segment with special chars: {special_content}\n{long_content}" segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -958,8 +947,6 @@ class TestCleanDatasetTask: status=SegmentStatus.COMPLETED, index_node_id=str(uuid.uuid4()), index_node_hash="test_hash_" + "x" * 50, # Long hash within limits - created_at=datetime.now(), - updated_at=datetime.now(), ) db_session_with_containers.add(segment) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py b/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py index 1c8d5969e0..ef65b90508 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py @@ -132,11 +132,10 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(document) db_session_with_containers.flush() document_ids.append(document.id) - + assert tenant # Create segments for each document for j in range(2): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -297,10 +296,9 @@ class TestCleanNotionDocumentTask: ) db_session_with_containers.add(document) db_session_with_containers.flush() - + assert tenant # Create test segment segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -379,12 +377,11 @@ class TestCleanNotionDocumentTask: ) db_session_with_containers.add(document) db_session_with_containers.flush() - + assert tenant # Create segments without index_node_ids segments = [] for i in range(3): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -468,11 +465,10 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(document) db_session_with_containers.flush() documents.append(document) - + assert tenant # Create segments for each document for j in range(2): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -569,10 +565,9 @@ class TestCleanNotionDocumentTask: segment_statuses = [SegmentStatus.WAITING, SegmentStatus.INDEXING, SegmentStatus.COMPLETED, SegmentStatus.ERROR] segments = [] index_node_ids = [] - + assert tenant for i, status in enumerate(segment_statuses): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -665,10 +660,9 @@ class TestCleanNotionDocumentTask: ) db_session_with_containers.add(document) db_session_with_containers.flush() - + assert tenant # Create segment segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -765,12 +759,11 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(document) db_session_with_containers.flush() documents.append(document) - + assert tenant # Create multiple segments for each document num_segments_per_doc = 5 for j in range(num_segments_per_doc): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -875,7 +868,6 @@ class TestCleanNotionDocumentTask: # Create segments for each document for j in range(3): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=account.current_tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -984,11 +976,10 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(document) db_session_with_containers.flush() documents.append(document) - + assert tenant # Create segments for each document for j in range(2): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -1093,10 +1084,9 @@ class TestCleanNotionDocumentTask: # Create segments with metadata segments = [] index_node_ids = [] - + assert tenant for i in range(3): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py index e4cbb9e589..aba2458d55 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_deal_dataset_vector_index_task.py @@ -90,7 +90,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -150,7 +149,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -202,7 +200,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -253,7 +250,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset with parent-child index dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -305,7 +301,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -371,7 +366,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset without documents dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -403,7 +397,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -461,7 +454,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset without documents dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -494,7 +486,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -546,7 +537,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -592,7 +582,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset with custom index type dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -624,7 +613,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -670,7 +658,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset without doc_form (should use default) dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -702,7 +689,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -748,7 +734,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -806,7 +791,6 @@ class TestDealDatasetVectorIndexTask: for i, document in enumerate(documents): for j in range(2): segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -832,6 +816,7 @@ class TestDealDatasetVectorIndexTask: updated_document = db_session_with_containers.scalar( select(Document).where(Document.id == document.id).limit(1) ) + assert updated_document assert updated_document.indexing_status == IndexingStatus.COMPLETED # Verify index processor load was called multiple times @@ -853,7 +838,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -905,7 +889,6 @@ class TestDealDatasetVectorIndexTask: # Create segments segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=document.id, @@ -952,7 +935,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -1024,7 +1006,6 @@ class TestDealDatasetVectorIndexTask: # Create segments for enabled document only segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=enabled_document.id, @@ -1075,7 +1056,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -1147,7 +1127,6 @@ class TestDealDatasetVectorIndexTask: # Create segments for active document only segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=active_document.id, @@ -1198,7 +1177,6 @@ class TestDealDatasetVectorIndexTask: # Create dataset dataset = Dataset( - id=str(uuid.uuid4()), tenant_id=tenant.id, name=fake.company(), description=fake.text(max_nb_chars=100), @@ -1270,7 +1248,6 @@ class TestDealDatasetVectorIndexTask: # Create segments for completed document only segment = DocumentSegment( - id=str(uuid.uuid4()), tenant_id=tenant.id, dataset_id=dataset.id, document_id=completed_document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_delete_segment_from_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_delete_segment_from_index_task.py index f4a71040c1..a7edf4f77a 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_delete_segment_from_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_delete_segment_from_index_task.py @@ -209,26 +209,25 @@ class TestDeleteSegmentFromIndexTask: segments = [] for i in range(count): - segment = DocumentSegment() - segment.id = fake.uuid4() - segment.tenant_id = document.tenant_id - segment.dataset_id = document.dataset_id - segment.document_id = document.id - segment.position = i + 1 - segment.content = f"Test segment content {i + 1}: {fake.text(max_nb_chars=200)}" - segment.answer = f"Test segment answer {i + 1}: {fake.text(max_nb_chars=100)}" - segment.word_count = fake.random_int(min=10, max=100) - segment.tokens = fake.random_int(min=5, max=50) - segment.keywords = [fake.word() for _ in range(3)] - segment.index_node_id = f"node_{fake.uuid4()}" - segment.index_node_hash = fake.sha256() - segment.hit_count = 0 - segment.enabled = True - segment.status = SegmentStatus.COMPLETED - segment.created_by = account.id - segment.created_at = fake.date_time_this_year() - segment.updated_by = account.id - segment.updated_at = segment.created_at + created_at = fake.date_time_this_year() + segment = DocumentSegment( + tenant_id=document.tenant_id, + dataset_id=document.dataset_id, + document_id=document.id, + position=i + 1, + content=f"Test segment content {i + 1}: {fake.text(max_nb_chars=200)}", + answer=f"Test segment answer {i + 1}: {fake.text(max_nb_chars=100)}", + word_count=fake.random_int(min=10, max=100), + tokens=fake.random_int(min=5, max=50), + keywords=[fake.word() for _ in range(3)], + index_node_id=f"node_{fake.uuid4()}", + index_node_hash=fake.sha256(), + hit_count=0, + enabled=True, + status=SegmentStatus.COMPLETED, + created_by=account.id, + updated_by=account.id, + ) db_session_with_containers.add(segment) segments.append(segment) diff --git a/api/tests/test_containers_integration_tests/tasks/test_disable_segment_from_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_disable_segment_from_index_task.py index 5bdf7d1389..34e2ce4e80 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_disable_segment_from_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_disable_segment_from_index_task.py @@ -159,7 +159,7 @@ class TestDisableSegmentFromIndexTask: dataset: Dataset, tenant: Tenant, account: Account, - status: str = "completed", + status: SegmentStatus = SegmentStatus.COMPLETED, enabled: bool = True, ) -> DocumentSegment: """ diff --git a/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py index 6bfb1e1f1e..cb5fb5483c 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py @@ -7,6 +7,7 @@ The task is responsible for removing document segments from the search index whe """ from unittest.mock import MagicMock, patch +from uuid import uuid4 from faker import Faker from sqlalchemy import select @@ -82,7 +83,7 @@ class TestDisableSegmentsFromIndexTask: return account - def _create_test_dataset(self, db_session_with_containers: Session, account, fake: Faker | None = None): + def _create_test_dataset(self, db_session_with_containers: Session, account: Account, fake: Faker | None = None): """ Helper method to create a test dataset with realistic data. @@ -117,7 +118,7 @@ class TestDisableSegmentsFromIndexTask: return dataset def _create_test_document( - self, db_session_with_containers: Session, dataset, account: Account, fake: Faker | None = None + self, db_session_with_containers: Session, dataset: Dataset, account: Account, fake: Faker | None = None ): """ Helper method to create a test document with realistic data. @@ -164,7 +165,7 @@ class TestDisableSegmentsFromIndexTask: return document def _create_test_segments( - self, db_session_with_containers: Session, document, dataset, account, count=3, fake=None + self, db_session_with_containers: Session, document, dataset: Dataset, account: Account, count=3, fake=None ): """ Helper method to create test document segments with realistic data. @@ -184,30 +185,31 @@ class TestDisableSegmentsFromIndexTask: segments = [] for i in range(count): - segment = DocumentSegment() - segment.id = fake.uuid4() - segment.tenant_id = dataset.tenant_id - segment.dataset_id = dataset.id - segment.document_id = document.id - segment.position = i + 1 - segment.content = f"Test segment content {i + 1}: {fake.text(max_nb_chars=200)}" - segment.answer = f"Test answer {i + 1}" if i % 2 == 0 else None - segment.word_count = fake.random_int(min=10, max=100) - segment.tokens = fake.random_int(min=5, max=50) - segment.keywords = [fake.word() for _ in range(3)] - segment.index_node_id = f"node_{segment.id}" - segment.index_node_hash = fake.sha256() - segment.hit_count = 0 - segment.enabled = True - segment.disabled_at = None - segment.disabled_by = None - segment.status = SegmentStatus.COMPLETED - segment.created_by = account.id - segment.updated_by = account.id - segment.indexing_at = fake.date_time_this_year() - segment.completed_at = fake.date_time_this_year() - segment.error = None - segment.stopped_at = None + id = fake.uuid4() + segment = DocumentSegment( + tenant_id=dataset.tenant_id, + dataset_id=dataset.id, + document_id=document.id, + position=i + 1, + content=f"Test segment content {i + 1}: {fake.text(max_nb_chars=200)}", + answer=f"Test answer {i + 1}" if i % 2 == 0 else None, + word_count=fake.random_int(min=10, max=100), + tokens=fake.random_int(min=5, max=50), + keywords=[fake.word() for _ in range(3)], + index_node_id=f"node_{id}", + index_node_hash=fake.sha256(), + hit_count=0, + enabled=True, + disabled_at=None, + disabled_by=None, + status=SegmentStatus.COMPLETED, + created_by=account.id, + updated_by=account.id, + indexing_at=fake.date_time_this_year(), + completed_at=fake.date_time_this_year(), + error=None, + stopped_at=None, + ) segments.append(segment) @@ -217,7 +219,9 @@ class TestDisableSegmentsFromIndexTask: return segments - def _create_dataset_process_rule(self, db_session_with_containers: Session, dataset, fake: Faker | None = None): + def _create_dataset_process_rule( + self, db_session_with_containers: Session, dataset: Dataset, fake: Faker | None = None + ): """ Helper method to create a dataset process rule. @@ -230,21 +234,19 @@ class TestDisableSegmentsFromIndexTask: DatasetProcessRule: Created process rule instance """ fake = fake or Faker() - process_rule = DatasetProcessRule() - process_rule.id = fake.uuid4() - process_rule.tenant_id = dataset.tenant_id - process_rule.dataset_id = dataset.id - process_rule.mode = ProcessRuleMode.AUTOMATIC - process_rule.rules = ( - "{" - '"mode": "automatic", ' - '"rules": {' - '"pre_processing_rules": [], "segmentation": ' - '{"separator": "\\n\\n", "max_tokens": 1000, "chunk_overlap": 50}}' - "}" + process_rule = DatasetProcessRule( + dataset_id=dataset.id, + mode=ProcessRuleMode.AUTOMATIC, + rules=( + "{" + '"mode": "automatic", ' + '"rules": {' + '"pre_processing_rules": [], "segmentation": ' + '{"separator": "\\n\\n", "max_tokens": 1000, "chunk_overlap": 50}}' + "}" + ), + created_by=str(uuid4()), ) - process_rule.created_by = dataset.created_by - process_rule.updated_by = dataset.updated_by db_session_with_containers.add(process_rule) db_session_with_containers.commit() diff --git a/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py b/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py index 12440f3e6b..e1c7e3e09a 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py @@ -175,7 +175,6 @@ class TestDuplicateDocumentIndexingTasks: for document in documents: for i in range(segments_per_doc): segment = DocumentSegment( - id=fake.uuid4(), tenant_id=dataset.tenant_id, dataset_id=dataset.id, document_id=document.id, diff --git a/api/tests/test_containers_integration_tests/tasks/test_enable_segments_to_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_enable_segments_to_index_task.py index e2f35067e3..6d3b90d41c 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_enable_segments_to_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_enable_segments_to_index_task.py @@ -139,7 +139,6 @@ class TestEnableSegmentsToIndexTask: for i in range(count): text = fake.text(max_nb_chars=200) segment = DocumentSegment( - id=fake.uuid4(), tenant_id=document.tenant_id, dataset_id=dataset.id, document_id=document.id, diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index 57dbf453de..919ebbc656 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -8,6 +8,47 @@ from yarl import URL from configs.app_config import DifyConfig +def _set_basic_config_env(monkeypatch: pytest.MonkeyPatch) -> None: + os.environ.clear() + monkeypatch.setenv("CONSOLE_API_URL", "https://example.com") + monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com") + monkeypatch.setenv("DB_TYPE", "postgresql") + monkeypatch.setenv("DB_USERNAME", "postgres") + monkeypatch.setenv("DB_PASSWORD", "postgres") + monkeypatch.setenv("DB_HOST", "localhost") + monkeypatch.setenv("DB_PORT", "5432") + monkeypatch.setenv("DB_DATABASE", "dify") + + +def test_dify_config_keeps_secret_key_empty_when_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + _set_basic_config_env(monkeypatch) + monkeypatch.delenv("SECRET_KEY", raising=False) + monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path)) + + config = DifyConfig(_env_file=None) + + assert config.SECRET_KEY == "" + assert not hasattr(config, "OPENDAL_FS_ROOT") + assert not (tmp_path / ".dify_secret_key").exists() + + +def test_dify_config_preserves_explicit_secret_key( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + _set_basic_config_env(monkeypatch) + monkeypatch.setenv("SECRET_KEY", "explicit") + monkeypatch.setenv("OPENDAL_FS_ROOT", str(tmp_path)) + + config = DifyConfig(_env_file=None) + + assert config.SECRET_KEY == "explicit" + assert not (tmp_path / ".dify_secret_key").exists() + + def test_dify_config(monkeypatch: pytest.MonkeyPatch): # clear system environment variables os.environ.clear() diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index 14f00e6295..82a063307b 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -88,6 +88,11 @@ def valid_parameters(): } +def test_trial_workflow_uses_trial_scoped_simple_account_model(): + assert module.simple_account_model.name == "TrialSimpleAccount" + assert hasattr(module.simple_account_model, "items") + + class TestTrialAppWorkflowRunApi: def test_not_workflow_app(self, app: Flask): api = module.TrialAppWorkflowRunApi() diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index 230c51161f..738238d10a 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -1057,8 +1057,8 @@ class TestDocumentAddByTextApi: """Test error when both dataset and payload lack indexing_technique. When ``indexing_technique`` is ``None`` in the payload, ``model_dump(exclude_none=True)`` - omits the key. The production code accesses ``args["indexing_technique"]`` which raises - ``KeyError`` before the ``ValueError`` guard can fire. + omits the key. The service API should still raise the same validation error as other + document creation paths instead of leaking a ``KeyError`` from the dumped payload dict. """ # Arrange — neutralise billing decorators self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id) @@ -1074,7 +1074,7 @@ class TestDocumentAddByTextApi: headers={"Authorization": "Bearer test_token"}, ): api = DocumentAddByTextApi() - with pytest.raises(KeyError): + with pytest.raises(ValueError, match="indexing_technique is required."): api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 0e9f8b6f35..2e4e469eb5 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -1,3 +1,4 @@ +import contextlib from types import SimpleNamespace from unittest.mock import MagicMock @@ -24,6 +25,76 @@ def test_should_prepare_user_inputs_keeps_validation_when_flag_false(): assert WorkflowAppGenerator()._should_prepare_user_inputs(args) +def test_generate_includes_parent_trace_context_in_extras(monkeypatch): + generator = WorkflowAppGenerator() + + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppGenerator._bind_file_access_scope", + lambda *args, **kwargs: contextlib.nullcontext(), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config", + lambda *args, **kwargs: SimpleNamespace( + app_id="app-1", tenant_id="tenant-1", workflow_id="workflow-1", variables=[] + ), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.file_factory.build_from_mappings", lambda *args, **kwargs: [] + ) + monkeypatch.setattr("core.app.apps.workflow.app_generator.TraceQueueManager", MagicMock()) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", + MagicMock(return_value=MagicMock()), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + MagicMock(return_value=MagicMock()), + ) + monkeypatch.setattr("core.app.apps.workflow.app_generator.db", SimpleNamespace(engine=MagicMock())) + monkeypatch.setattr(generator, "_prepare_user_inputs", lambda *, user_inputs, **kwargs: user_inputs) + + captured = {} + + def fake_workflow_app_generate_entity(**kwargs): + captured["workflow_app_generate_entity_kwargs"] = kwargs + return SimpleNamespace(**kwargs) + + def fake_generate(**kwargs): + captured["application_generate_entity"] = kwargs["application_generate_entity"] + return {"data": {}} + + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppGenerateEntity", fake_workflow_app_generate_entity + ) + monkeypatch.setattr(generator, "_generate", fake_generate) + + result = generator.generate( + app_model=SimpleNamespace(tenant_id="tenant-1", id="app-1"), + workflow=SimpleNamespace(features_dict={}), + user=SimpleNamespace(id="user-1", session_id="session-1"), + args={ + "inputs": {"query": "hello"}, + "files": [], + "external_trace_id": "trace-1", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + invoke_from="service-api", + streaming=False, + call_depth=0, + ) + + assert result == {"data": {}} + extras = captured["workflow_app_generate_entity_kwargs"]["extras"] + assert extras["external_trace_id"] == "trace-1" + assert extras["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + def test_resume_delegates_to_generate(mocker: MockerFixture): generator = WorkflowAppGenerator() mock_generate = mocker.patch.object(generator, "_generate", return_value="ok") diff --git a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py index 7e87c088ce..9cefa97bef 100644 --- a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py +++ b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py @@ -7,6 +7,7 @@ import pytest from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer +from core.ops.ops_trace_manager import TraceTask, TraceTaskName from core.workflow.system_variables import SystemVariableKey, build_system_variables from graphon.entities import WorkflowNodeExecution from graphon.entities.pause_reason import SchedulingPause @@ -217,6 +218,59 @@ class TestWorkflowPersistenceLayer: assert exec_repo.saved[-1].status == WorkflowExecutionStatus.FAILED assert trace_tasks + def test_handle_graph_run_succeeded_enqueues_parent_trace_context(self, monkeypatch): + trace_tasks: list[TraceTask] = [] + trace_manager = SimpleNamespace(user_id="user", add_trace_task=lambda task: trace_tasks.append(task)) + layer, _, _, _ = _make_layer( + extras={ + "external_trace_id": "trace", + "parent_trace_context": { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + }, + }, + trace_manager=trace_manager, + ) + layer._handle_graph_run_started() + + captured: dict[str, object] = {} + + def fake_workflow_trace( + self: TraceTask, + *, + workflow_run_id: str | None, + conversation_id: str | None, + user_id: str | None, + total_tokens_override: int | None = None, + ): + captured["trace_type"] = self.trace_type + captured["external_trace_id"] = self.kwargs.get("external_trace_id") + captured["parent_trace_context"] = self.kwargs.get("parent_trace_context") + captured["workflow_run_id"] = workflow_run_id + return {"ok": True} + + monkeypatch.setattr(TraceTask, "workflow_trace", fake_workflow_trace) + + layer._handle_graph_run_succeeded(GraphRunSucceededEvent(outputs={"ok": True})) + + assert trace_tasks + trace_task = trace_tasks[0] + assert trace_task.trace_type == TraceTaskName.WORKFLOW_TRACE + assert trace_task.kwargs["external_trace_id"] == "trace" + assert trace_task.kwargs["parent_trace_context"] == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + trace_task.execute() + + assert captured["trace_type"] == TraceTaskName.WORKFLOW_TRACE + assert captured["external_trace_id"] == "trace" + assert captured["parent_trace_context"] == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + def test_handle_graph_run_aborted_sets_status(self): layer, exec_repo, _, _ = _make_layer() layer._handle_graph_run_started() diff --git a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py index 4f39d38831..cee7d46083 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py @@ -34,20 +34,6 @@ class TestDatasourceFileManager: assert f"nonce={mock_urandom.return_value.hex()}" in signed_url assert "sign=" in signed_url - @patch("core.datasource.datasource_file_manager.time.time") - @patch("core.datasource.datasource_file_manager.os.urandom") - @patch("core.datasource.datasource_file_manager.dify_config") - def test_sign_file_empty_secret(self, mock_config, mock_urandom, mock_time): - # Setup - mock_config.FILES_URL = "http://localhost:5001" - mock_config.SECRET_KEY = None # Empty secret - mock_time.return_value = 1700000000 - mock_urandom.return_value = b"1234567890abcdef" - - # Execute - signed_url = DatasourceFileManager.sign_file("file_id", ".png") - assert "sign=" in signed_url - @patch("core.datasource.datasource_file_manager.time.time") @patch("core.datasource.datasource_file_manager.dify_config") def test_verify_file(self, mock_config, mock_time): @@ -76,25 +62,6 @@ class TestDatasourceFileManager: mock_time.return_value = 1700000500 # 700 seconds after timestamp (300 is timeout) assert DatasourceFileManager.verify_file(datasource_file_id, timestamp, nonce, encoded_sign) is False - @patch("core.datasource.datasource_file_manager.time.time") - @patch("core.datasource.datasource_file_manager.dify_config") - def test_verify_file_empty_secret(self, mock_config, mock_time): - # Setup - mock_config.SECRET_KEY = "" # Empty string secret - mock_config.FILES_ACCESS_TIMEOUT = 300 - mock_time.return_value = 1700000000 - - datasource_file_id = "file_id_123" - timestamp = "1699999800" - nonce = "some_nonce" - - # Calculate with empty secret - data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}" - sign = hmac.new(b"", data_to_sign.encode(), hashlib.sha256).digest() - encoded_sign = base64.urlsafe_b64encode(sign).decode() - - assert DatasourceFileManager.verify_file(datasource_file_id, timestamp, nonce, encoded_sign) is True - @patch("core.datasource.datasource_file_manager.db") @patch("core.datasource.datasource_file_manager.storage") @patch("core.datasource.datasource_file_manager.uuid4") diff --git a/api/tests/unit_tests/core/helper/test_trace_id_helper.py b/api/tests/unit_tests/core/helper/test_trace_id_helper.py index 27bfe1af05..96e2d44730 100644 --- a/api/tests/unit_tests/core/helper/test_trace_id_helper.py +++ b/api/tests/unit_tests/core/helper/test_trace_id_helper.py @@ -1,6 +1,12 @@ import pytest -from core.helper.trace_id_helper import extract_external_trace_id_from_args, get_external_trace_id, is_valid_trace_id +from core.helper.trace_id_helper import ( + ParentTraceContext, + extract_external_trace_id_from_args, + extract_parent_trace_context_from_args, + get_external_trace_id, + is_valid_trace_id, +) class DummyRequest: @@ -84,3 +90,92 @@ class TestTraceIdHelper: def test_extract_external_trace_id_from_args(self, args, expected): """Test extraction of external_trace_id from args mapping""" assert extract_external_trace_id_from_args(args) == expected + + @pytest.mark.parametrize( + ("args", "expected"), + [ + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": "node-execution-1", + } + }, + { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id="node-execution-1", + ) + }, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_node_execution_id": "node-execution-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": 123, + "parent_node_execution_id": "node-execution-1", + } + }, + {}, + ), + ( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": None, + } + }, + {}, + ), + ({}, {}), + ], + ) + def test_extract_parent_trace_context_from_args(self, args, expected): + """Test extraction of parent_trace_context from args mapping""" + assert extract_parent_trace_context_from_args(args) == expected + + def test_extract_parent_trace_context_returns_typed_context(self): + """Parent trace context is parsed into a Pydantic value object.""" + result = extract_parent_trace_context_from_args( + { + "parent_trace_context": { + "parent_workflow_run_id": "workflow-run-1", + "parent_node_execution_id": "node-execution-1", + } + } + ) + + assert result == { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id="node-execution-1", + ) + } + + def test_extract_parent_trace_context_rejects_incomplete_typed_context(self): + """Typed parent trace context follows the same completeness rule as raw mappings.""" + result = extract_parent_trace_context_from_args( + { + "parent_trace_context": ParentTraceContext( + parent_workflow_run_id="workflow-run-1", + parent_node_execution_id=None, + ) + } + ) + + assert result == {} diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py index 72a73dd936..6c563b0912 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py @@ -147,6 +147,142 @@ def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.Monke assert call_kwargs["pause_state_config"] is None +def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pytest.MonkeyPatch): + """Ensure nested workflow runtime metadata is forwarded as parent trace context.""" + tool = _build_tool() + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert call_kwargs["args"]["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + +def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypatch: pytest.MonkeyPatch): + """Ensure private trace context does not overwrite same-named workflow inputs.""" + tool = _build_tool() + tool.entity.parameters = [ + ToolParameter.get_simple_instance( + name="outer_workflow_run_id", + llm_description="User workflow input", + typ=ToolParameter.ToolParameterType.STRING, + required=False, + ), + ToolParameter.get_simple_instance( + name="outer_node_execution_id", + llm_description="User node input", + typ=ToolParameter.ToolParameterType.STRING, + required=False, + ), + ] + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list( + tool.invoke( + "test_user", + { + "outer_workflow_run_id": "user-workflow-input", + "outer_node_execution_id": "user-node-input", + }, + ) + ) + + call_kwargs = generate_mock.call_args.kwargs + assert call_kwargs["args"]["inputs"]["outer_workflow_run_id"] == "user-workflow-input" + assert call_kwargs["args"]["inputs"]["outer_node_execution_id"] == "user-node-input" + assert call_kwargs["args"]["parent_trace_context"].model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-1", + "parent_node_execution_id": "outer-node-execution-1", + } + + +def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.MonkeyPatch): + """Ensure reused WorkflowTool instances do not keep stale parent trace context.""" + tool = _build_tool() + tool.set_parent_trace_context( + parent_workflow_run_id="outer-workflow-run-1", + parent_node_execution_id="outer-node-execution-1", + ) + tool.clear_parent_trace_context() + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert "parent_trace_context" not in call_kwargs["args"] + + +@pytest.mark.parametrize( + "runtime_parameters", + [ + {}, + {"outer_workflow_run_id": "outer-workflow-run-1"}, + {"outer_node_execution_id": "outer-node-execution-1"}, + {"outer_workflow_run_id": None, "outer_node_execution_id": None}, + ], +) +def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete( + monkeypatch: pytest.MonkeyPatch, + runtime_parameters: dict[str, Any], +): + """Ensure incomplete runtime metadata does not leak parent trace context into generator args.""" + tool = _build_tool() + tool.runtime.runtime_parameters = runtime_parameters + + monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) + monkeypatch.setattr(tool, "_get_workflow", lambda *args, **kwargs: None) + + mock_user = Mock() + monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: mock_user) + + generate_mock = MagicMock(return_value={"data": {}}) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) + monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) + + list(tool.invoke("test_user", {})) + + call_kwargs = generate_mock.call_args.kwargs + assert "parent_trace_context" not in call_kwargs["args"] + + def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch: pytest.MonkeyPatch): """Test that WorkflowTool should generate variable messages when there are outputs""" tool = _build_tool() diff --git a/api/tests/unit_tests/core/variables/test_segment_type.py b/api/tests/unit_tests/core/variables/test_segment_type.py index baa2ac2dc7..009899a92d 100644 --- a/api/tests/unit_tests/core/variables/test_segment_type.py +++ b/api/tests/unit_tests/core/variables/test_segment_type.py @@ -233,8 +233,6 @@ class TestSegmentTypeAdditionalMethods: assert SegmentType.GROUP.is_valid([StringSegment(value="b")]) is True assert SegmentType.GROUP.is_valid(["not-segment"]) is False - def test_unreachable_assertion_branch(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(SegmentType, "is_array_type", lambda self: False) - - with pytest.raises(AssertionError, match="unreachable"): - SegmentType.ARRAY_STRING.is_valid(["a"]) + def test_unreachable_assertion_branch(self): + with pytest.raises(AssertionError, match="Expected code to be unreachable"): + SegmentType.is_valid("not-a-segment-type", None) # type: ignore[arg-type] diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py index 212ad07bd3..6a2fc81fef 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py @@ -613,7 +613,7 @@ def test_combine_message_content_with_role_handles_all_supported_roles(): SystemPromptMessage(content=contents) ) - with pytest.raises(NotImplementedError, match="Role custom is not supported"): + with pytest.raises(AssertionError, match="Expected code to be unreachable"): llm_utils.combine_message_content_with_role(contents=contents, role="custom") # type: ignore[arg-type] diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py index 4aa5803ac7..4d30746e5c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -15,16 +15,23 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import StreamChunkEvent, StreamCompletedEvent from graphon.nodes.tool.entities import ToolNodeData from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage -from graphon.runtime import GraphRuntimeState, VariablePool +from graphon.runtime import GraphRuntimeState from graphon.variables.segments import ArrayFileSegment -from tests.workflow_test_utils import build_test_graph_init_params +from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool if TYPE_CHECKING: # pragma: no cover - imported for type checking only from graphon.nodes.tool.tool_node import ToolNode class _StubToolRuntime: - def get_runtime(self, *, node_id: str, node_data: Any, variable_pool: Any) -> ToolRuntimeHandle: + def get_runtime( + self, + *, + node_id: str, + node_data: Any, + variable_pool: Any, + node_execution_id: str | None = None, + ) -> ToolRuntimeHandle: raise NotImplementedError def get_runtime_parameters(self, *, tool_runtime: ToolRuntimeHandle) -> list[Any]: @@ -99,7 +106,7 @@ def tool_node(monkeypatch) -> ToolNode: call_depth=0, ) - variable_pool = VariablePool.from_bootstrap(system_variables=build_system_variables(user_id="user-id")) + variable_pool = build_test_variable_pool(variables=build_system_variables(user_id="user-id")) graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) config = graph_config["nodes"][0] @@ -227,3 +234,22 @@ def test_image_link_messages_use_tool_file_id_metadata(tool_node: ToolNode): files_segment = completed_events[0].node_run_result.outputs["files"] assert isinstance(files_segment, ArrayFileSegment) assert files_segment.value == [file_obj] + + +def test_tool_node_passes_node_execution_id_when_runtime_accepts_it(tool_node: ToolNode): + runtime_handle = ToolRuntimeHandle(raw=object()) + tool_node._runtime.get_runtime = MagicMock(return_value=runtime_handle) + tool_node.ensure_execution_id = MagicMock(return_value="node-execution-id") + + result = tool_node._get_tool_runtime( + variable_pool=tool_node.graph_runtime_state.variable_pool, + node_execution_id="node-execution-id", + ) + + assert result is runtime_handle + tool_node._runtime.get_runtime.assert_called_once_with( + node_id="node-instance", + node_data=tool_node.node_data, + variable_pool=tool_node.graph_runtime_state.variable_pool, + node_execution_id="node-execution-id", + ) diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py index 438af211f3..aece73ce8c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py @@ -147,6 +147,69 @@ def test_get_runtime_converts_graph_provider_type_for_tool_manager(runtime: Dify assert workflow_tool.provider_type == CoreToolProviderType.BUILT_IN +def test_get_runtime_stores_parent_trace_context_for_workflow_tools( + runtime: DifyToolNodeRuntime, +) -> None: + variable_pool: VariablePool = build_test_variable_pool( + variables=build_system_variables( + conversation_id="conversation-id", + workflow_execution_id="workflow-run-id", + ) + ) + workflow_runtime = MagicMock() + workflow_runtime.runtime.runtime_parameters = {} + node_data = ToolNodeData.model_validate( + { + "type": "tool", + "title": "Tool", + "provider_id": "provider", + "provider_type": ToolProviderType.WORKFLOW, + "provider_name": "provider", + "tool_name": "lookup", + "tool_label": "Lookup", + "tool_configurations": {}, + "tool_parameters": {}, + } + ) + + with patch.object(ToolManager, "get_workflow_tool_runtime", return_value=workflow_runtime): + tool_runtime = runtime.get_runtime( + node_id="node-id", + node_data=node_data, + variable_pool=variable_pool, + node_execution_id="node-execution-id", + ) + + assert tool_runtime.raw.parent_trace_context.model_dump() == { + "parent_workflow_run_id": "workflow-run-id", + "parent_node_execution_id": "node-execution-id", + } + assert workflow_runtime.runtime.runtime_parameters == {} + + +def test_get_runtime_leaves_non_workflow_tool_runtime_parameters_unchanged( + runtime: DifyToolNodeRuntime, +) -> None: + variable_pool: VariablePool = build_test_variable_pool( + variables=build_system_variables( + conversation_id="conversation-id", + workflow_execution_id="workflow-run-id", + ) + ) + builtin_runtime = MagicMock() + builtin_runtime.runtime.runtime_parameters = {} + + with patch.object(ToolManager, "get_workflow_tool_runtime", return_value=builtin_runtime): + runtime.get_runtime( + node_id="node-id", + node_data=_build_tool_node_data(), + variable_pool=variable_pool, + node_execution_id="node-execution-id", + ) + + assert builtin_runtime.runtime.runtime_parameters == {} + + def test_get_runtime_parameters_reads_required_flags(runtime: DifyToolNodeRuntime) -> None: tool_runtime = ToolRuntimeHandle( raw=SimpleNamespace( diff --git a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py index d18fc262ef..2dd3953d9a 100644 --- a/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py +++ b/api/tests/unit_tests/core/workflow/test_node_mapping_bootstrap.py @@ -7,6 +7,17 @@ from pathlib import Path def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): api_root = Path(__file__).resolve().parents[4] + + # `PYTHONSAFEPATH=1` enables Python's safe-path mode, which suppresses the + # usual implicit insertion of the working directory into `sys.path`. + # Set `PYTHONPATH` explicitly so this subprocess test stays deterministic in + # both CI and local shells that may export `PYTHONSAFEPATH`. + env = os.environ.copy() + existing_pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(api_root) if not existing_pythonpath else os.pathsep.join([str(api_root), existing_pythonpath]) + ) + env["PYTHONSAFEPATH"] = "1" script = textwrap.dedent( """ from core.app.apps import workflow_app_runner @@ -34,7 +45,7 @@ def test_moved_core_nodes_resolve_after_importing_production_entrypoints(): completed = subprocess.run( [sys.executable, "-c", script], cwd=api_root, - env=os.environ.copy(), + env=env, capture_output=True, text=True, check=False, diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 0d13151f42..d2925fd1a8 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -316,6 +316,81 @@ def test_dify_tool_file_manager_delegates_file_generator_lookup(monkeypatch: pyt get_file_generator.assert_called_once_with("tool-file-id") +def test_dify_tool_node_runtime_injects_outer_workflow_run_id_for_workflow_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) + get_runtime = MagicMock(return_value=runtime_tool) + monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + monkeypatch.setattr( + node_runtime, + "get_system_text", + lambda _pool, key: ( + "outer-workflow-run-id" if key == node_runtime.SystemVariableKey.WORKFLOW_EXECUTION_ID else None + ), + ) + + runtime = node_runtime.DifyToolNodeRuntime(_build_run_context()) + node_data = ToolNodeData( + title="Workflow Tool Node", + desc=None, + provider_id="workflow-provider-id", + provider_type=ToolProviderType.WORKFLOW, + provider_name="workflow-provider", + tool_name="workflow-tool", + tool_label="Workflow Tool", + tool_configurations={}, + tool_parameters={}, + ) + + handle = runtime.get_runtime( + node_id="tool-node", + node_data=node_data, + variable_pool=object(), + node_execution_id="node-execution-id", + ) + + assert handle.raw.tool is runtime_tool + assert handle.raw.parent_trace_context.model_dump() == { + "parent_workflow_run_id": "outer-workflow-run-id", + "parent_node_execution_id": "node-execution-id", + } + assert runtime_tool.runtime.runtime_parameters == {} + get_runtime.assert_called_once() + + +def test_dify_tool_node_runtime_does_not_inject_outer_workflow_run_id_for_non_workflow_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) + get_runtime = MagicMock(return_value=runtime_tool) + monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) + monkeypatch.setattr(node_runtime, "get_system_text", lambda _pool, _key: None) + + runtime = node_runtime.DifyToolNodeRuntime(_build_run_context()) + node_data = ToolNodeData( + title="Builtin Tool Node", + desc=None, + provider_id="builtin-provider-id", + provider_type=ToolProviderType.BUILT_IN, + provider_name="builtin-provider", + tool_name="builtin-tool", + tool_label="Builtin Tool", + tool_configurations={}, + tool_parameters={}, + ) + + handle = runtime.get_runtime( + node_id="tool-node", + node_data=node_data, + variable_pool=object(), + ) + + assert handle.raw.tool is runtime_tool + assert "outer_workflow_run_id" not in runtime_tool.runtime.runtime_parameters + get_runtime.assert_called_once() + + def test_dify_human_input_runtime_builds_debug_repository(monkeypatch: pytest.MonkeyPatch) -> None: repository = MagicMock() repository_cls = MagicMock(return_value=repository) diff --git a/api/tests/unit_tests/extensions/test_set_secretkey.py b/api/tests/unit_tests/extensions/test_set_secretkey.py new file mode 100644 index 0000000000..8a8e4e2b19 --- /dev/null +++ b/api/tests/unit_tests/extensions/test_set_secretkey.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest +from flask import Flask + +from extensions import ext_set_secretkey + + +class InMemoryStorage: + def __init__(self, files: dict[str, bytes] | None = None) -> None: + self.files = files or {} + self.saved_files: list[tuple[str, bytes]] = [] + + def load_once(self, filename: str) -> bytes: + try: + return self.files[filename] + except KeyError: + raise FileNotFoundError(filename) + + def save(self, filename: str, data: bytes) -> None: + self.files[filename] = data + self.saved_files.append((filename, data)) + + +def test_init_app_uses_configured_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: + secret_key = "configured-secret-key" + storage = InMemoryStorage() + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", secret_key) + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = secret_key + + ext_set_secretkey.init_app(app) + + assert app.secret_key == secret_key + assert app.config["SECRET_KEY"] == secret_key + assert storage.saved_files == [] + + +def test_init_app_generates_and_persists_secret_key_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + storage = InMemoryStorage() + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = "" + + ext_set_secretkey.init_app(app) + + persisted_key = storage.files[".dify_secret_key"].decode("utf-8").strip() + assert persisted_key + assert storage.saved_files == [(".dify_secret_key", f"{persisted_key}\n".encode())] + assert persisted_key == ext_set_secretkey.dify_config.SECRET_KEY + assert persisted_key == app.config["SECRET_KEY"] + assert persisted_key == app.secret_key + + +def test_init_app_reuses_persisted_secret_key_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + persisted_key = "persisted-secret-key" + storage = InMemoryStorage({".dify_secret_key": f"{persisted_key}\n".encode()}) + monkeypatch.setattr("extensions.ext_set_secretkey.dify_config.SECRET_KEY", "") + monkeypatch.setattr("configs.secret_key.storage", storage) + app = Flask(__name__) + app.config["SECRET_KEY"] = "" + + ext_set_secretkey.init_app(app) + + assert persisted_key == ext_set_secretkey.dify_config.SECRET_KEY + assert persisted_key == app.config["SECRET_KEY"] + assert persisted_key == app.secret_key + assert storage.saved_files == [] diff --git a/api/tests/unit_tests/libs/test_passport.py b/api/tests/unit_tests/libs/test_passport.py index f33484c18d..90b58ae548 100644 --- a/api/tests/unit_tests/libs/test_passport.py +++ b/api/tests/unit_tests/libs/test_passport.py @@ -143,28 +143,13 @@ class TestPassportService: assert str(exc_info.value) == "401 Unauthorized: Token has expired." # Configuration tests - def test_should_handle_empty_secret_key(self): - """Test behavior when SECRET_KEY is empty""" + def test_should_use_configured_secret_key_without_policy_validation(self): + """Test that policy decisions are owned by config, not PassportService.""" with patch("libs.passport.dify_config") as mock_config: - mock_config.SECRET_KEY = "" + mock_config.SECRET_KEY = "configured" service = PassportService() - # Empty secret key should still work but is insecure - payload = {"test": "data"} - token = service.issue(payload) - decoded = service.verify(token) - assert decoded == payload - - def test_should_handle_none_secret_key(self): - """Test behavior when SECRET_KEY is None""" - with patch("libs.passport.dify_config") as mock_config: - mock_config.SECRET_KEY = None - service = PassportService() - - payload = {"test": "data"} - # JWT library will raise TypeError when secret is None - with pytest.raises((TypeError, jwt.exceptions.InvalidKeyError)): - service.issue(payload) + assert service.sk == "configured" # Boundary condition tests def test_should_handle_large_payload(self, passport_service): diff --git a/api/tests/unit_tests/models/test_comment_models.py b/api/tests/unit_tests/models/test_comment_models.py index 277335cbef..8c8985aff8 100644 --- a/api/tests/unit_tests/models/test_comment_models.py +++ b/api/tests/unit_tests/models/test_comment_models.py @@ -4,7 +4,15 @@ from models.comment import WorkflowComment, WorkflowCommentMention, WorkflowComm def test_workflow_comment_account_properties_and_cache() -> None: - comment = WorkflowComment(created_by="user-1", resolved_by="user-2", content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by="user-2", + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) created_account = Mock(id="user-1") resolved_account = Mock(id="user-2") @@ -21,6 +29,8 @@ def test_workflow_comment_account_properties_and_cache() -> None: get_mock.assert_not_called() comment_without_resolver = WorkflowComment( + tenant_id="xxx", + app_id="yyy", created_by="user-1", resolved_by=None, content="hello", @@ -37,7 +47,15 @@ def test_workflow_comment_counts_and_participants() -> None: reply_2 = WorkflowCommentReply(comment_id="comment-1", content="reply-2", created_by="user-2") mention_1 = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-3") mention_2 = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-4") - comment = WorkflowComment(created_by="user-1", resolved_by=None, content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by=None, + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) comment.replies = [reply_1, reply_2] comment.mentions = [mention_1, mention_2] @@ -63,7 +81,15 @@ def test_workflow_comment_counts_and_participants() -> None: def test_workflow_comment_participants_use_cached_accounts() -> None: reply = WorkflowCommentReply(comment_id="comment-1", content="reply-1", created_by="user-2") mention = WorkflowCommentMention(comment_id="comment-1", mentioned_user_id="user-3") - comment = WorkflowComment(created_by="user-1", resolved_by=None, content="hello", position_x=1, position_y=2) + comment = WorkflowComment( + created_by="user-1", + resolved_by=None, + content="hello", + position_x=1, + position_y=2, + tenant_id="xxx", + app_id="yyy", + ) comment.replies = [reply] comment.mentions = [mention] diff --git a/api/tests/unit_tests/models/test_dataset_models.py b/api/tests/unit_tests/models/test_dataset_models.py index 3f14ebe8bf..f4ccfb4191 100644 --- a/api/tests/unit_tests/models/test_dataset_models.py +++ b/api/tests/unit_tests/models/test_dataset_models.py @@ -847,9 +847,7 @@ class TestDatasetProcessRule: # Act process_rule = DatasetProcessRule( - dataset_id=dataset_id, - mode=ProcessRuleMode.AUTOMATIC, - created_by=created_by, + dataset_id=dataset_id, mode=ProcessRuleMode.AUTOMATIC, created_by=created_by, rules=None ) # Assert diff --git a/api/tests/unit_tests/services/test_dataset_service_document.py b/api/tests/unit_tests/services/test_dataset_service_document.py index 1633194aa8..a78bc7f9d6 100644 --- a/api/tests/unit_tests/services/test_dataset_service_document.py +++ b/api/tests/unit_tests/services/test_dataset_service_document.py @@ -1297,7 +1297,7 @@ class TestDocumentServiceEstimateValidation: """Unit tests for estimate_args_validate branches.""" def test_estimate_args_validate_rejects_missing_info_list(self): - with pytest.raises(ValueError, match="Data source info is required"): + with pytest.raises(ValueError, match="Field required"): DocumentService.estimate_args_validate({}) def test_estimate_args_validate_sets_empty_rules_for_automatic_mode(self): @@ -1357,7 +1357,7 @@ class TestDocumentServiceEstimateValidation: }, } - with pytest.raises(ValueError, match="Summary index model provider name is required"): + with pytest.raises(ValueError, match="Field required"): DocumentService.estimate_args_validate(args) diff --git a/api/tests/unit_tests/services/test_dataset_service_segment.py b/api/tests/unit_tests/services/test_dataset_service_segment.py index 6330e53765..1f8586e32f 100644 --- a/api/tests/unit_tests/services/test_dataset_service_segment.py +++ b/api/tests/unit_tests/services/test_dataset_service_segment.py @@ -282,7 +282,6 @@ class TestSegmentServiceQueries: def test_get_segment_by_id_returns_only_document_segment_instances(self): segment = DocumentSegment( - id="segment-1", tenant_id="tenant-1", dataset_id="dataset-1", document_id="doc-1", @@ -292,7 +291,7 @@ class TestSegmentServiceQueries: tokens=2, created_by="user-1", ) - + segment.id = "segment-1" with patch("services.dataset_service.db") as mock_db: mock_db.session.scalar.return_value = segment result = SegmentService.get_segment_by_id("segment-1", "tenant-1") @@ -307,7 +306,6 @@ class TestSegmentServiceQueries: def test_get_segments_by_document_and_dataset_returns_scalars_result(self): segment = DocumentSegment( - id="segment-1", tenant_id="tenant-1", dataset_id="dataset-1", document_id="doc-1", @@ -318,6 +316,7 @@ class TestSegmentServiceQueries: created_by="user-1", ) + segment.id = "segment-1" with patch("services.dataset_service.db") as mock_db: mock_db.session.scalars.return_value.all.return_value = [segment] @@ -461,6 +460,7 @@ class TestSegmentServiceMutations: vector_service.create_segments_vector.side_effect = RuntimeError("vector failed") result = SegmentService.multi_create_segment(segments, document, dataset) + assert result assert len(result) == 2 assert [segment.position for segment in result] == [2, 3] diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 1711e66b23..e152ab923c 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -11,6 +11,7 @@ This test suite covers: import json import uuid +from types import SimpleNamespace from typing import Any, cast from unittest.mock import ANY, MagicMock, Mock, patch, sentinel @@ -2649,7 +2650,12 @@ class TestWorkflowServiceHumanInputOperations: mock_node = MagicMock() mock_node.node_data = MagicMock() + mock_node.node_data.user_actions = [ + SimpleNamespace(id="submit", title="card_visa_enterprise_001"), + ] mock_node.node_data.outputs_field_names.return_value = ["field1"] + mock_node.render_form_content_before_submission.return_value = "Ticket: {{#$output.field1#}}" + mock_node.render_form_content_with_outputs.return_value = "Ticket: val1" with ( patch("services.workflow_service.db"), @@ -2665,6 +2671,8 @@ class TestWorkflowServiceHumanInputOperations: app_model=app_model, account=account, node_id="node-1", form_inputs={"field1": "val1"}, action="submit" ) assert result["__action_id"] == "submit" + assert result["__action_value"] == "card_visa_enterprise_001" + assert result["__rendered_content"] == "Ticket: val1" mock_saver_cls.return_value.save.assert_called_once() def test_test_human_input_delivery_success(self, service: WorkflowService) -> None: diff --git a/api/tests/unit_tests/tasks/test_ops_trace_task.py b/api/tests/unit_tests/tasks/test_ops_trace_task.py new file mode 100644 index 0000000000..5844c55c04 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_ops_trace_task.py @@ -0,0 +1,301 @@ +import json +import sys +from contextlib import contextmanager +from types import ModuleType +from unittest.mock import MagicMock, patch + +import pytest +from celery.exceptions import Retry + +from core.ops.entities.config_entity import OPS_TRACE_FAILED_KEY +from core.ops.exceptions import RetryableTraceDispatchError +from tasks.ops_trace_task import process_trace_tasks + + +@contextmanager +def fake_app_context(): + yield + + +class FakeCurrentApp: + def app_context(self): + return fake_app_context() + + +def _install_trace_manager( + trace_instance: MagicMock, + *, + enterprise_enabled: bool = False, + enterprise_trace_cls: MagicMock | None = None, +) -> dict[str, ModuleType]: + ops_trace_manager_module = ModuleType("core.ops.ops_trace_manager") + + class StubOpsTraceManager: + @staticmethod + def get_ops_trace_instance(app_id: str) -> MagicMock: + return trace_instance + + telemetry_module = ModuleType("extensions.ext_enterprise_telemetry") + telemetry_module.is_enabled = lambda: enterprise_enabled + + ops_trace_manager_module.OpsTraceManager = StubOpsTraceManager + modules = { + "core.ops.ops_trace_manager": ops_trace_manager_module, + "extensions.ext_enterprise_telemetry": telemetry_module, + } + if enterprise_trace_cls is not None: + enterprise_module = ModuleType("enterprise") + enterprise_telemetry_module = ModuleType("enterprise.telemetry") + enterprise_trace_module = ModuleType("enterprise.telemetry.enterprise_trace") + enterprise_trace_module.EnterpriseOtelTrace = enterprise_trace_cls + modules.update( + { + "enterprise": enterprise_module, + "enterprise.telemetry": enterprise_telemetry_module, + "enterprise.telemetry.enterprise_trace": enterprise_trace_module, + } + ) + return modules + + +def _make_payload() -> str: + return json.dumps({"trace_info": {}, "trace_info_type": None}) + + +def _decode_saved_payload(payload: bytes | str) -> dict[str, object]: + if isinstance(payload, bytes): + payload = payload.decode("utf-8") + return json.loads(payload) + + +def _retryable_dispatch_error() -> RetryableTraceDispatchError: + return RetryableTraceDispatchError("transient trace dispatch failure") + + +def _run_task(file_info: dict[str, str], retries: int = 0) -> None: + process_trace_tasks.push_request(retries=retries) + try: + process_trace_tasks.run(file_info) + finally: + process_trace_tasks.pop_request() + + +def test_process_trace_tasks_retries_retryable_dispatch_failure_and_preserves_payload(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_marks_enterprise_trace_dispatched_before_retryable_dispatch_retry(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + enterprise_tracer = MagicMock() + enterprise_trace_cls = MagicMock(return_value=enterprise_tracer) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + enterprise_tracer.trace.assert_called_once_with({}) + saved_path, saved_payload = mock_save.call_args.args + assert saved_path == "ops_trace/app-id/file-id.json" + assert _decode_saved_payload(saved_payload)["_enterprise_trace_dispatched"] is True + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_does_not_mark_failed_enterprise_trace_as_dispatched_before_retry(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + retry_error = Retry() + enterprise_tracer = MagicMock() + enterprise_tracer.trace.side_effect = RuntimeError("enterprise trace failed") + enterprise_trace_cls = MagicMock(return_value=enterprise_tracer) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_error) as mock_retry, + pytest.raises(Retry), + ): + _run_task(file_info) + + enterprise_tracer.trace.assert_called_once_with({}) + mock_save.assert_not_called() + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_not_called() + mock_incr.assert_not_called() + + +def test_process_trace_tasks_skips_enterprise_trace_when_retry_payload_was_already_dispatched(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + enterprise_trace_cls = MagicMock() + payload = json.dumps({"trace_info": {}, "trace_info_type": None, "_enterprise_trace_dispatched": True}) + + with ( + patch.dict( + sys.modules, + _install_trace_manager( + trace_instance, + enterprise_enabled=True, + enterprise_trace_cls=enterprise_trace_cls, + ), + ), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=payload), + patch("tasks.ops_trace_task.storage.save") as mock_save, + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + enterprise_trace_cls.assert_not_called() + trace_instance.trace.assert_called_once_with({}) + mock_save.assert_not_called() + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_not_called() + + +def test_process_trace_tasks_default_retry_window_covers_parent_span_context_ttl(): + assert process_trace_tasks.max_retries * process_trace_tasks.default_retry_delay >= 300 + + +def test_process_trace_tasks_deletes_payload_on_success(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + trace_instance.trace.assert_called_once_with({}) + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_not_called() + + +def test_process_trace_tasks_deletes_payload_and_counts_terminal_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + trace_instance.trace.side_effect = RuntimeError("trace failed") + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + ): + _run_task(file_info) + + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") + + +def test_process_trace_tasks_treats_retry_enqueue_failure_as_terminal_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + retry_enqueue_error = RuntimeError("retry enqueue failed") + trace_instance.trace.side_effect = pending_error + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry", side_effect=retry_enqueue_error) as mock_retry, + ): + _run_task(file_info) + + mock_retry.assert_called_once_with( + exc=pending_error, + countdown=process_trace_tasks.default_retry_delay, + ) + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") + + +def test_process_trace_tasks_deletes_payload_and_counts_exhausted_retryable_dispatch_failure(): + file_info = {"app_id": "app-id", "file_id": "file-id"} + trace_instance = MagicMock() + pending_error = _retryable_dispatch_error() + trace_instance.trace.side_effect = pending_error + + with ( + patch.dict(sys.modules, _install_trace_manager(trace_instance)), + patch("tasks.ops_trace_task.current_app", FakeCurrentApp()), + patch("tasks.ops_trace_task.storage.load", return_value=_make_payload()), + patch("tasks.ops_trace_task.storage.delete") as mock_delete, + patch("tasks.ops_trace_task.redis_client.incr") as mock_incr, + patch.object(process_trace_tasks, "retry") as mock_retry, + ): + _run_task(file_info, retries=process_trace_tasks.max_retries) + + mock_retry.assert_not_called() + mock_delete.assert_called_once_with("ops_trace/app-id/file-id.json") + mock_incr.assert_called_once_with(f"{OPS_TRACE_FAILED_KEY}_app-id") diff --git a/api/uv.lock b/api/uv.lock index ad9ce2c4a4..634dcc74b8 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -607,16 +607,16 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.3" +version = "1.43.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/50/ea184e159c4ac64fef816a72094fb8656eb071361a39ed22c0e3b15a35b4/boto3-1.43.3.tar.gz", hash = "sha256:7c7777862ffc898f05efa566032bbabfe226dbb810e35ec11125817f128bc5c5", size = 113111, upload-time = "2026-05-04T19:34:09.731Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/ad/8a6946a329f0127322108e537dc1c0d9f8eea4f1d1231702c073d2e85f46/boto3-1.43.3-py3-none-any.whl", hash = "sha256:fb9fe51849ef2a78198d582756fc06f14f7de27f73e0fa90275d6aa4171eb4d0", size = 140501, upload-time = "2026-05-04T19:34:07.991Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" }, ] [[package]] @@ -639,16 +639,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.43.3" +version = "1.43.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/ac/cd55f886e17b6b952dbc95b792d3645a73d58586a1400ababe54406073bd/botocore-1.43.3.tar.gz", hash = "sha256:eac6da0fffccf87888ebf4d89f0b2378218a707efa748cd955b838995e944695", size = 15308705, upload-time = "2026-05-04T19:33:56.28Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/99/1d9e296edf244f47e0508032f20999f8fd40704dd3c5b601fed099424eb6/botocore-1.43.3-py3-none-any.whl", hash = "sha256:ec0769eb0f7c5034856bb406a92698dbc02a3d4be0f78a384747106b161d8ea3", size = 14989027, upload-time = "2026-05-04T19:33:50.81Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" }, ] [[package]] @@ -1292,7 +1292,7 @@ wheels = [ [[package]] name = "dify-api" -version = "1.14.0" +version = "1.14.1" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, @@ -1581,7 +1581,7 @@ requires-dist = [ { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.3.0" }, - { name = "boto3", specifier = ">=1.43.3" }, + { name = "boto3", specifier = ">=1.43.6" }, { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, @@ -1595,10 +1595,10 @@ requires-dist = [ { name = "gevent", specifier = ">=26.4.0" }, { name = "gevent-websocket", specifier = ">=0.10.1" }, { name = "gmpy2", specifier = ">=2.3.0" }, - { name = "google-api-python-client", specifier = ">=2.195.0" }, - { name = "google-cloud-aiplatform", specifier = ">=1.149.0,<2.0.0" }, - { name = "graphon", specifier = "~=0.3.0" }, - { name = "gunicorn", specifier = ">=25.3.0" }, + { name = "google-api-python-client", specifier = ">=2.196.0" }, + { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, + { name = "graphon", specifier = "~=0.3.1" }, + { name = "gunicorn", specifier = ">=26.0.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, { name = "json-repair", specifier = "~=0.59.4" }, @@ -1692,7 +1692,7 @@ storage = [ { name = "google-cloud-storage", specifier = ">=3.10.1" }, { name = "opendal", specifier = ">=0.46.0" }, { name = "oss2", specifier = ">=2.19.1" }, - { name = "supabase", specifier = ">=2.29.0" }, + { name = "supabase", specifier = ">=2.30.0" }, { name = "tos", specifier = ">=2.9.0" }, ] tools = [ @@ -2722,7 +2722,7 @@ grpc = [ [[package]] name = "google-api-python-client" -version = "2.195.0" +version = "2.196.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -2731,9 +2731,9 @@ dependencies = [ { name = "httplib2" }, { name = "uritemplate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/07/08d759b9cb10f48af14b25262dd0d6685ca8cda6c1f9e8a8109f57457205/google_api_python_client-2.195.0.tar.gz", hash = "sha256:c72cf2661c3addf01c880ce60541e83e1df354644b874f7f9d8d5ed2070446ae", size = 14584819, upload-time = "2026-04-30T21:51:50.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129, upload-time = "2026-05-06T23:47:35.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/b9/2c71095e31fff57668fec7c07ac897df065f15521d070e63229e13689590/google_api_python_client-2.195.0-py3-none-any.whl", hash = "sha256:753e62057f23049a89534bea0162b60fe391b85fb86d80bcdf884d05ec91c5bf", size = 15162418, upload-time = "2026-04-30T21:51:47.444Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663, upload-time = "2026-05-06T23:47:32.886Z" }, ] [[package]] @@ -2769,7 +2769,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.149.0" +version = "1.151.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -2785,9 +2785,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/2c/fba4adc56f74c0ee0fbd91a39d414ca2c3588dd8b71f9be8a507015ca886/google_cloud_aiplatform-1.149.0.tar.gz", hash = "sha256:a4d73485bf1d727a9e1bbbd13d08d7031490686bbf7d125eb905c1a6c1559a35", size = 10451466, upload-time = "2026-04-27T23:11:54.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/f6/e2fbe175a011f5080da8c1f7d9169a6875a00ea2c7bee4193d952b097400/google_cloud_aiplatform-1.151.0.tar.gz", hash = "sha256:2f29b1853f790a7371a746c747bf1f664380b534254682441acd4b5ee26fafd2", size = 10617421, upload-time = "2026-05-07T21:56:52.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/a0/27719ba23967ef62e52a1d54e013e0fc174bdab8dd84fb300bab9bf0d4a3/google_cloud_aiplatform-1.149.0-py2.py3-none-any.whl", hash = "sha256:e6b5299fa5d303e971cb29a19f03fdbb7b1e3b9d2faa3a788ca933341fba2f2e", size = 8570410, upload-time = "2026-04-27T23:11:50.495Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/cd35f8ba622d563b1335222284d2838aa789b953b40516b1b997e50fe5b6/google_cloud_aiplatform-1.151.0-py2.py3-none-any.whl", hash = "sha256:61372bb0923b14b8027f45b83393452df3a85bf4ea86fa48e08844fb5ec50049", size = 8732627, upload-time = "2026-05-07T21:56:49.014Z" }, ] [[package]] @@ -2940,7 +2940,7 @@ httpx = [ [[package]] name = "graphon" -version = "0.3.0" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -2961,9 +2961,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/62/83593d6e7a139ff124711ea05882cadca7065c11a38763aa9360d7e76804/graphon-0.3.0.tar.gz", hash = "sha256:cd38f842ae3dcfa956428b952efbe2a3ea9c1581446647142accbbdeb638b876", size = 241176, upload-time = "2026-04-21T15:18:48.291Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/ef/43217842e84160acca64a95858f1689389a50e04a53fc94f2aa836b4eaf7/graphon-0.3.1.tar.gz", hash = "sha256:49971baed1eb16c8e1983f755e659902e4f117a68dc62fad19e91472950b937d", size = 242210, upload-time = "2026-05-07T06:58:21.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/f7/81ee8f0368aa6a2d47f97fecc5d4a12865c987906798cbddd0e3b8387f33/graphon-0.3.0-py3-none-any.whl", hash = "sha256:9cca45ebab2a79fd4d04432f55b5b962e9e4f34fa037cc20fee7f18ec80eaa5d", size = 348486, upload-time = "2026-04-21T15:18:46.737Z" }, + { url = "https://files.pythonhosted.org/packages/62/37/bef16ed3d6da7446b36769fa388f4dc79f95337ffa16d6dfc3177152507e/graphon-0.3.1-py3-none-any.whl", hash = "sha256:e6422c7e3f1ce7d2185979c17e08201816ca25d46d400ebdd035c95d501c04fe", size = 349368, upload-time = "2026-05-07T06:58:20.217Z" }, ] [[package]] @@ -3102,14 +3102,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "25.3.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] @@ -4265,32 +4265,32 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/b7/845565a2ab5d22c1486bc7729a06b05cd0964c61539d766e1f107c9eea0c/opentelemetry_exporter_otlp-1.41.0.tar.gz", hash = "sha256:97ff847321f8d4c919032a67d20d3137fb7b34eac0c47f13f71112858927fc5b", size = 6152, upload-time = "2026-04-09T14:38:35.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/84/d55baf8e1a222f40282956083e67de9fa92d5fa451108df4839505fa2a24/opentelemetry_exporter_otlp-1.41.1.tar.gz", hash = "sha256:299a2f0541ca175df186f5ac58fd5db177ba1e9b72b0826049062f750d55b47f", size = 6152, upload-time = "2026-04-24T13:15:40.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f2/f1076fff152858773f22cda146713f9ae3661795af6bacd411a76f2151ac/opentelemetry_exporter_otlp-1.41.0-py3-none-any.whl", hash = "sha256:443b6a45c990ae4c55e147f97049a86c5f5b704f3d78b48b44a073a886ec4d6e", size = 7022, upload-time = "2026-04-09T14:38:13.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/ea4aa7dfc458fd537bd9519ea0e7226eef2a6212dfe952694984167daaba/opentelemetry_exporter_otlp-1.41.1-py3-none-any.whl", hash = "sha256:db276c5a80c02b063994e80950d00ca1bfddcf6520f608335b7dc2db0c0eb9c6", size = 7025, upload-time = "2026-04-24T13:15:17.839Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -4301,14 +4301,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/46/d75a3f8c91915f2e58f61d0a2e4ada63891e7c7a37a20ff7949ba184a6b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0.tar.gz", hash = "sha256:f704201251c6f65772b11bddea1c948000554459101bdbb0116e0a01b70592f6", size = 25754, upload-time = "2026-04-09T14:38:37.423Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/f6/b09e2e0c9f0b5750cebc6eaf31527b910821453cef40a5a0fe93550422b2/opentelemetry_exporter_otlp_proto_grpc-1.41.0-py3-none-any.whl", hash = "sha256:3a1a86bd24806ccf136ec9737dbfa4c09b069f9130ff66b0acb014f9c5255fd1", size = 20299, upload-time = "2026-04-09T14:38:17.01Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -4319,9 +4319,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, ] [[package]] @@ -4479,14 +4479,14 @@ wheels = [ [[package]] name = "opentelemetry-proto" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, ] [[package]] @@ -4813,7 +4813,7 @@ wheels = [ [[package]] name = "postgrest" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -4821,9 +4821,9 @@ dependencies = [ { name = "pydantic" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/98/f216b8b5c4d116ab6a2fb21339b5821da279ee773e163612418e1c56c012/postgrest-2.29.0.tar.gz", hash = "sha256:a87081858f627fcd57e8e7137004a1ef0adbdf0dbdfed1384e9ea1d7a9c525ec", size = 14217, upload-time = "2026-04-24T13:13:00.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/7c/54e7be05adc9fd6fd98dc572ddfc8982d45bec314a55711e37277d440698/postgrest-2.30.0.tar.gz", hash = "sha256:4f89eec56ce605ab6fbddd9b96d526a9bb44962796d44a5d85cb77640eb766c3", size = 14430, upload-time = "2026-05-06T17:35:21.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/0b/08b670a93a90d625c557b9e64b8a5fdeec80c3542d2d0265f0b4d6b16646/postgrest-2.29.0-py3-none-any.whl", hash = "sha256:3ee48e146f726272733d20e2b12de354cdb6cb9dd9cc3a61ed97ce69047aeb96", size = 22735, upload-time = "2026-04-24T13:12:58.405Z" }, + { url = "https://files.pythonhosted.org/packages/22/aa/ff2e09f99f95ea96fddeb373646bf907dd89a24fc00b5d38e5674ca7c9ca/postgrest-2.30.0-py3-none-any.whl", hash = "sha256:30631e7993da542419f4217cf3b60aa641084731ea15e66a18526a3a52e40a7d", size = 23108, upload-time = "2026-05-06T17:35:20.531Z" }, ] [[package]] @@ -5726,16 +5726,16 @@ wheels = [ [[package]] name = "realtime" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/f1/08c42a42653942fadfbef495d5b0239356140e7186cc528704956c5f06d4/realtime-2.29.0.tar.gz", hash = "sha256:8efe4a1b3a548a5fda09de701bd041fa0970c5a2fe7d13db0b9861ce11828be2", size = 18715, upload-time = "2026-04-24T13:13:02.315Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/a2/0328d49d3b5fb427068e9200e7de5b0d708d021a1ad98d004bc685d2529e/realtime-2.30.0.tar.gz", hash = "sha256:7aa593da52ed5f92c34ec4e50e32043afa62f219c94f717ad64a66ab0ef9f1ba", size = 18718, upload-time = "2026-05-06T17:35:23.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/48/f6375c0a24923beb988f0c71c052604c96641cf43c2d22b91ec1df86afa0/realtime-2.29.0-py3-none-any.whl", hash = "sha256:1a4891e6c82e88ac9d96ac715e435e086f6f8c7665212a8717346de829cbb509", size = 22374, upload-time = "2026-04-24T13:13:01.103Z" }, + { url = "https://files.pythonhosted.org/packages/b4/75/1b2cfc949595e22d8c05a2aa2cfc222921f7f94177d7e8a90542f3f73b33/realtime-2.30.0-py3-none-any.whl", hash = "sha256:7c93b63d2cf99aa1da4fa8826b03b00cd32f7b38abb27ff47b19eb5dcb5707c6", size = 22376, upload-time = "2026-05-06T17:35:22.568Z" }, ] [[package]] @@ -6217,7 +6217,7 @@ wheels = [ [[package]] name = "storage3" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -6226,9 +6226,9 @@ dependencies = [ { name = "pyiceberg" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/be/771246434b5caf3c6187bfdc932eaede00bf5f2937b47475ab25209ede3e/storage3-2.29.0.tar.gz", hash = "sha256:b0cc2f6714655d725c998d2c5ae8c6fb4f56a513bd31e4f85770df557fe021e3", size = 20160, upload-time = "2026-04-24T13:13:04.626Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/b2/6df208d64630744704d00f2c07197170390d6b4d0098617740f6a7a4fa98/storage3-2.30.0.tar.gz", hash = "sha256:b74e3cac149f2c0553dcb5f4d55d8c35d420d88183a1a2df77727d482665972b", size = 20162, upload-time = "2026-05-06T17:35:25.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/c3/790c31866f52c13b26f108b45759bf50dafae3a0bafb4511fadc98ba7c33/storage3-2.29.0-py3-none-any.whl", hash = "sha256:043ef7ff27cc8b9da12be403cf78ee4586180edfcf62b227ff61e1bd79594b06", size = 28284, upload-time = "2026-04-24T13:13:03.338Z" }, + { url = "https://files.pythonhosted.org/packages/91/5c/bb8c8cc448cfae671c4ffee67f3651892ea59b341f27bed54666190eb8ef/storage3-2.30.0-py3-none-any.whl", hash = "sha256:2bd23a34011c018bd9c130d8a70a09ebd060ae80d946c6204a6fc08161ad728d", size = 28284, upload-time = "2026-05-06T17:35:24.659Z" }, ] [[package]] @@ -6254,7 +6254,7 @@ wheels = [ [[package]] name = "supabase" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -6265,37 +6265,37 @@ dependencies = [ { name = "supabase-functions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/a0/2407d616fdf68e8632bbbfb063d1685c38377ac0199e8ca11deaea1f3bf0/supabase-2.29.0.tar.gz", hash = "sha256:a88c4a4eb50fbb903e2e962fbc7c27733b00589140139f9e837bc9fe30dd3615", size = 9689, upload-time = "2026-04-24T13:13:06.728Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/a6/d2b17021c2db1a9d219c383e0762ac03a62b25468e61ab126b6b561c2f21/supabase-2.30.0.tar.gz", hash = "sha256:efdba41d474038ed220736ba4e64946df56043057ad785c4c3499d27e459975c", size = 9689, upload-time = "2026-05-06T17:35:27.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/52/232f6bbf5326e04ae12e2ef04a24f011a0d7cab379a8b9698652bc8ff78f/supabase-2.29.0-py3-none-any.whl", hash = "sha256:16c3ec4b7094f6b92efc5cd3bb3f96826d3b6dd5d24fe15c89c81166efce88fe", size = 16633, upload-time = "2026-04-24T13:13:05.722Z" }, + { url = "https://files.pythonhosted.org/packages/f0/82/d213be7d0ce0bb18018744c0ee38ba0d6648d41dbc46ac8558cffe80541f/supabase-2.30.0-py3-none-any.whl", hash = "sha256:f9b259194554f7bfd2dca6c23261f2df588016ca18b18e774f4d85bc941edb03", size = 16634, upload-time = "2026-05-06T17:35:26.696Z" }, ] [[package]] name = "supabase-auth" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/7f/7ceeb4c7a2caa188062e934897f0e08e1af0a0e47e376c7645c26b4c39d8/supabase_auth-2.29.0.tar.gz", hash = "sha256:46efc6a3455a23957b846dc974303a844ba0413718cfa899425477ac977f95b3", size = 39154, upload-time = "2026-04-24T13:13:08.509Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/8a/48bbbe0b6703d0670b67e45b90d6a791fd01aace67443d286f760bf48895/supabase_auth-2.30.0.tar.gz", hash = "sha256:6138a53a306a95ed59c03d4e4975469dfc3343a0ade33cc4b37e4ef967ad83f8", size = 39135, upload-time = "2026-05-06T17:35:30.371Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/ac/3c35cf52281f940b9497cf17abfc5c2050ca49f342d60cfafe22dac3482b/supabase_auth-2.29.0-py3-none-any.whl", hash = "sha256:64de6ef8cae80f97d3aa8d5ca507d5427dda5c89885c0bcfe9f8b0263b6fb9a4", size = 48379, upload-time = "2026-04-24T13:13:07.417Z" }, + { url = "https://files.pythonhosted.org/packages/db/40/a99cb4373353bcbf302d962e51da9eac78b3b0f257eb0362c0852b1667f4/supabase_auth-2.30.0-py3-none-any.whl", hash = "sha256:e85e1f51ec0de2172c3a2a8514205f71731a9914f9a770ed199ac0cf054bc82c", size = 48352, upload-time = "2026-05-06T17:35:28.936Z" }, ] [[package]] name = "supabase-functions" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "strenum" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/19/1a1d22749f38f2a6cbca93a6f5a35c9f816c2c3c06bfaa077fa336e90537/supabase_functions-2.29.0.tar.gz", hash = "sha256:0f8a14a2ea9f12b1c208f61dc6f55e2f4b1121f81bf01c08f9b487d22888744d", size = 4683, upload-time = "2026-04-24T13:13:10.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/e6/5cd8559ec2bb332e6027840c1be292f9989c2fc7b47bf40800aec5586791/supabase_functions-2.30.0.tar.gz", hash = "sha256:025acfd25f1c000ba43d0f7b8e366b0d2e9dfc784b842528e21973eb33006113", size = 4683, upload-time = "2026-05-06T17:35:32.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/10/6f8ef0b408ade76b5a439afab588ce5849e9604a23040ca73cfe0b90cb9e/supabase_functions-2.29.0-py3-none-any.whl", hash = "sha256:6f08de52eec5820eae53616868b85e849e181beffaa5d05b8ea1708ceae5e48e", size = 8799, upload-time = "2026-04-24T13:13:09.214Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/9dedab32775df04cc22ca72f194b78e895d940f195bed3e02882a65daa9b/supabase_functions-2.30.0-py3-none-any.whl", hash = "sha256:92419459f102767b954cd034856e4ded8e34c78660b32442d66c8b2899c68011", size = 8803, upload-time = "2026-05-06T17:35:31.342Z" }, ] [[package]] @@ -7147,11 +7147,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] diff --git a/dev/pytest/pytest_full.sh b/dev/pytest/pytest_full.sh index 2989a74ad8..ca09aeb729 100755 --- a/dev/pytest/pytest_full.sh +++ b/dev/pytest/pytest_full.sh @@ -15,7 +15,7 @@ mkdir -p "${OPENDAL_FS_ROOT}" # Prepare env files like CI cp -n docker/.env.example docker/.env || true -cp -n docker/middleware.env.example docker/middleware.env || true +cp -n docker/envs/middleware.env.example docker/middleware.env || true cp -n api/tests/integration_tests/.env.example api/tests/integration_tests/.env || true # Expose service ports (same as CI) without leaving the repo dirty diff --git a/dev/setup b/dev/setup index 4236ff7fa7..1d2501a48b 100755 --- a/dev/setup +++ b/dev/setup @@ -8,7 +8,7 @@ API_ENV_EXAMPLE="$ROOT/api/.env.example" API_ENV="$ROOT/api/.env" WEB_ENV_EXAMPLE="$ROOT/web/.env.example" WEB_ENV="$ROOT/web/.env.local" -MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/middleware.env.example" +MIDDLEWARE_ENV_EXAMPLE="$ROOT/docker/envs/middleware.env.example" MIDDLEWARE_ENV="$ROOT/docker/middleware.env" # 1) Copy api/.env.example -> api/.env @@ -17,7 +17,7 @@ cp "$API_ENV_EXAMPLE" "$API_ENV" # 2) Copy web/.env.example -> web/.env.local cp "$WEB_ENV_EXAMPLE" "$WEB_ENV" -# 3) Copy docker/middleware.env.example -> docker/middleware.env +# 3) Copy docker/envs/middleware.env.example -> docker/middleware.env cp "$MIDDLEWARE_ENV_EXAMPLE" "$MIDDLEWARE_ENV" # 4) Install deps diff --git a/docker/.env.example b/docker/.env.example index 82bd837ffb..c708a40c15 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,5 +1,6 @@ # ------------------------------------------------------------------ # Essential defaults for Docker Compose deployments. +# Only include variables required for services to start. # # For a default deployment, copy this file to .env and run: # docker compose up -d @@ -27,14 +28,16 @@ LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONIOENCODING=utf-8 UV_CACHE_DIR=/tmp/.uv-cache -SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U +# Leave empty to auto-generate a persistent key in the storage directory. +SECRET_KEY= INIT_PASSWORD= DEPLOY_ENV=PRODUCTION CHECK_UPDATE_URL=https://updates.dify.ai OPENAI_API_BASE=https://api.openai.com/v1 MIGRATION_ENABLED=true FILES_ACCESS_TIMEOUT=300 -ENABLE_COLLABORATION_MODE=false +# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. +ENABLE_COLLABORATION_MODE=true # Logging and server workers LOG_LEVEL=INFO @@ -52,6 +55,9 @@ DIFY_PORT=5001 SERVER_WORKER_AMOUNT=1 SERVER_WORKER_CLASS=gevent SERVER_WORKER_CONNECTIONS=10 +API_WEBSOCKET_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker +API_WEBSOCKET_WORKER_CONNECTIONS=1000 +API_WEBSOCKET_GUNICORN_TIMEOUT=360 GUNICORN_TIMEOUT=360 CELERY_WORKER_CLASS= CELERY_WORKER_AMOUNT=4 @@ -246,6 +252,7 @@ NGINX_KEEPALIVE_TIMEOUT=65 NGINX_PROXY_READ_TIMEOUT=3600s NGINX_PROXY_SEND_TIMEOUT=3600s NGINX_ENABLE_CERTBOT_CHALLENGE=false +NGINX_SOCKET_IO_UPSTREAM=api_websocket:5001 EXPOSE_NGINX_PORT=80 EXPOSE_NGINX_SSL_PORT=443 -COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} +COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql},collaboration diff --git a/docker/README.md b/docker/README.md index a2d9b2eeba..26b1dac9ac 100644 --- a/docker/README.md +++ b/docker/README.md @@ -87,7 +87,7 @@ The root `.env.example` file contains the essential startup settings. Optional a 1. **Server Configuration**: - `LOG_LEVEL`, `DEBUG`, `FLASK_DEBUG`: Logging and debug settings. - - `SECRET_KEY`: A key for encrypting session cookies and other sensitive data. + - `SECRET_KEY`: A key for signing sessions, JWTs, and file URLs. Leave it empty to let Dify generate a persistent key in the storage directory, or set a unique value yourself. 1. **Database Configuration**: diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 0f65c38098..d9e2fc5bc9 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -220,7 +220,7 @@ services: # API service api: <<: *shared-api-worker-config - image: langgenius/dify-api:1.14.0 + image: langgenius/dify-api:1.14.1 environment: MODE: api SENTRY_DSN: ${API_SENTRY_DSN:-} @@ -261,11 +261,36 @@ services: - ssrf_proxy_network - default + # WebSocket service for workflow collaboration. + api_websocket: + <<: *shared-api-worker-config + image: langgenius/dify-api:1.14.1 + profiles: + - collaboration + environment: + MODE: api + SERVER_WORKER_AMOUNT: 1 + SERVER_WORKER_CLASS: ${API_WEBSOCKET_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} + SERVER_WORKER_CONNECTIONS: ${API_WEBSOCKET_WORKER_CONNECTIONS:-1000} + GUNICORN_TIMEOUT: ${API_WEBSOCKET_GUNICORN_TIMEOUT:-360} + depends_on: + db_postgres: + condition: service_healthy + required: false + db_mysql: + condition: service_healthy + required: false + redis: + condition: service_started + networks: + - ssrf_proxy_network + - default + # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: <<: *shared-worker-config - image: langgenius/dify-api:1.14.0 + image: langgenius/dify-api:1.14.1 environment: MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} @@ -308,7 +333,7 @@ services: # Celery beat for scheduling periodic tasks. worker_beat: <<: *shared-worker-beat-config - image: langgenius/dify-api:1.14.0 + image: langgenius/dify-api:1.14.1 environment: MODE: beat depends_on: @@ -341,7 +366,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.14.0 + image: langgenius/dify-web:1.14.1 restart: always env_file: - path: ./envs/core-services/web.env @@ -661,6 +686,7 @@ services: NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false} + NGINX_SOCKET_IO_UPSTREAM: ${NGINX_SOCKET_IO_UPSTREAM:-api_websocket:5001} CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-} depends_on: - api diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 0f8458a58f..004140abfb 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -226,7 +226,7 @@ services: # API service api: <<: *shared-api-worker-config - image: langgenius/dify-api:1.14.0 + image: langgenius/dify-api:1.14.1 environment: MODE: api SENTRY_DSN: ${API_SENTRY_DSN:-} @@ -267,11 +267,36 @@ services: - ssrf_proxy_network - default + # WebSocket service for workflow collaboration. + api_websocket: + <<: *shared-api-worker-config + image: langgenius/dify-api:1.14.1 + profiles: + - collaboration + environment: + MODE: api + SERVER_WORKER_AMOUNT: 1 + SERVER_WORKER_CLASS: ${API_WEBSOCKET_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} + SERVER_WORKER_CONNECTIONS: ${API_WEBSOCKET_WORKER_CONNECTIONS:-1000} + GUNICORN_TIMEOUT: ${API_WEBSOCKET_GUNICORN_TIMEOUT:-360} + depends_on: + db_postgres: + condition: service_healthy + required: false + db_mysql: + condition: service_healthy + required: false + redis: + condition: service_started + networks: + - ssrf_proxy_network + - default + # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: <<: *shared-worker-config - image: langgenius/dify-api:1.14.0 + image: langgenius/dify-api:1.14.1 environment: MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} @@ -314,7 +339,7 @@ services: # Celery beat for scheduling periodic tasks. worker_beat: <<: *shared-worker-beat-config - image: langgenius/dify-api:1.14.0 + image: langgenius/dify-api:1.14.1 environment: MODE: beat depends_on: @@ -347,7 +372,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.14.0 + image: langgenius/dify-web:1.14.1 restart: always env_file: - path: ./envs/core-services/web.env @@ -667,6 +692,7 @@ services: NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s} NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s} NGINX_ENABLE_CERTBOT_CHALLENGE: ${NGINX_ENABLE_CERTBOT_CHALLENGE:-false} + NGINX_SOCKET_IO_UPSTREAM: ${NGINX_SOCKET_IO_UPSTREAM:-api_websocket:5001} CERTBOT_DOMAIN: ${CERTBOT_DOMAIN:-} depends_on: - api diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 2a57f6954a..80cfe42c38 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -16,7 +16,8 @@ CHECK_UPDATE_URL=https://updates.dify.ai OPENAI_API_BASE=https://api.openai.com/v1 MIGRATION_ENABLED=true FILES_ACCESS_TIMEOUT=300 -ENABLE_COLLABORATION_MODE=false +# Remove `collaboration` from COMPOSE_PROFILES to stop the dedicated websocket service. +ENABLE_COLLABORATION_MODE=true CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1 CELERY_TASK_ANNOTATIONS=null AZURE_BLOB_ACCOUNT_URL=https://.blob.core.windows.net @@ -70,6 +71,8 @@ LOG_TZ=UTC DEBUG=false FLASK_DEBUG=false ENABLE_REQUEST_LOGGING=False +OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60 +OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5 WORKFLOW_LOG_CLEANUP_ENABLED=false WORKFLOW_LOG_RETENTION_DAYS=30 WORKFLOW_LOG_CLEANUP_BATCH_SIZE=100 @@ -87,6 +90,9 @@ DIFY_PORT=5001 SERVER_WORKER_AMOUNT=1 SERVER_WORKER_CLASS=gevent SERVER_WORKER_CONNECTIONS=10 +API_WEBSOCKET_WORKER_CLASS=geventwebsocket.gunicorn.workers.GeventWebSocketWorker +API_WEBSOCKET_WORKER_CONNECTIONS=1000 +API_WEBSOCKET_GUNICORN_TIMEOUT=360 CELERY_SENTINEL_PASSWORD= S3_ACCESS_KEY= S3_SECRET_KEY= @@ -399,7 +405,7 @@ TABLESTORE_ENDPOINT=https://instance-name.cn-hangzhou.ots.aliyuncs.com TABLESTORE_INSTANCE_NAME=instance-name CLICKZETTA_USERNAME= CLICKZETTA_VECTOR_DISTANCE_FUNCTION=cosine_distance -COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql} +COMPOSE_PROFILES=${VECTOR_STORE:-weaviate},${DB_TYPE:-postgresql},collaboration EXPOSE_NGINX_PORT=80 EXPOSE_NGINX_SSL_PORT=443 POSITION_TOOL_PINS= diff --git a/docker/envs/infrastructure/nginx.env.example b/docker/envs/infrastructure/nginx.env.example index fbe86680ba..fcb369a47d 100644 --- a/docker/envs/infrastructure/nginx.env.example +++ b/docker/envs/infrastructure/nginx.env.example @@ -15,3 +15,4 @@ NGINX_KEEPALIVE_TIMEOUT=65 NGINX_PROXY_READ_TIMEOUT=3600s NGINX_PROXY_SEND_TIMEOUT=3600s NGINX_ENABLE_CERTBOT_CHALLENGE=false +NGINX_SOCKET_IO_UPSTREAM=api_websocket:5001 diff --git a/docker/envs/security.env.example b/docker/envs/security.env.example index 787aef2706..d7556d91e5 100644 --- a/docker/envs/security.env.example +++ b/docker/envs/security.env.example @@ -36,5 +36,6 @@ TIDB_PUBLIC_KEY=dify TIDB_PRIVATE_KEY=dify VIKINGDB_ACCESS_KEY=your-ak VIKINGDB_SECRET_KEY=your-sk -SECRET_KEY=sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U +# Leave empty to auto-generate a persistent key in the storage directory. +SECRET_KEY= INIT_PASSWORD= diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template index 94a748290f..64c720ca2b 100644 --- a/docker/nginx/conf.d/default.conf.template +++ b/docker/nginx/conf.d/default.conf.template @@ -15,7 +15,9 @@ server { } location /socket.io/ { - proxy_pass http://api:5001; + resolver 127.0.0.11 valid=30s ipv6=off; + set $socket_io_upstream ${NGINX_SOCKET_IO_UPSTREAM}; + proxy_pass http://$socket_io_upstream; include proxy.conf; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index e56aab20a7..c05b5105be 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -31,7 +31,7 @@ pnpm -C e2e check `pnpm install` is resolved through the repository workspace and uses the shared root lockfile plus `pnpm-workspace.yaml`. -Use `pnpm check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package. +Use `pnpm -C e2e check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package. Common commands: @@ -68,8 +68,8 @@ flowchart TD C --> D["Cucumber loads config, steps, and support modules"] D --> E["BeforeAll bootstraps shared auth state via /install"] E --> F{"Which command is running?"} - F -->|`pnpm e2e`| G["Run config default tags: not @fresh and not @skip"] - F -->|`pnpm e2e:full*`| H["Override tags to not @skip"] + F -->|`pnpm -C e2e e2e`| G["Run config default tags: not @fresh and not @skip"] + F -->|`pnpm -C e2e e2e:full*`| H["Override tags to not @skip"] G --> I["Per-scenario BrowserContext from shared browser"] H --> I I --> J["Failure artifacts written to cucumber-report/artifacts"] @@ -99,7 +99,7 @@ Behavior depends on instance state: - uninitialized instance: completes install and stores authenticated state - initialized instance: signs in and reuses authenticated state -Because of that, the `@fresh` install scenario only runs in the `pnpm e2e:full*` flows. The default `pnpm e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance. +Because of that, the `@fresh` install scenario only runs in the `pnpm -C e2e e2e:full*` flows. The default `pnpm -C e2e e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance. Reset all persisted E2E state: @@ -126,7 +126,7 @@ pnpm -C e2e e2e:middleware:up Stop the full middleware stack: ```bash -pnpm e2e:middleware:down +pnpm -C e2e e2e:middleware:down ``` The middleware stack includes: @@ -141,15 +141,15 @@ The middleware stack includes: Fresh install verification: ```bash -pnpm e2e:full +pnpm -C e2e e2e:full ``` Run the Cucumber suite against an already running middleware stack: ```bash -pnpm e2e:middleware:up -pnpm e2e -pnpm e2e:middleware:down +pnpm -C e2e e2e:middleware:up +pnpm -C e2e e2e +pnpm -C e2e e2e:middleware:down ``` Artifacts and diagnostics: diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index d5742bdaa8..3ec038b065 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -40,7 +40,7 @@ Then('the shared app page should be accessible', async function (this: DifyWorld When('I run the shared workflow app', async function (this: DifyWorld) { const page = this.getPage() - const runButton = page.getByTestId('run-button') + const runButton = page.getByRole('button', { name: 'Execute' }) await expect(runButton).toBeEnabled({ timeout: 15_000 }) await runButton.click() diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2de84456ee..46277d3349 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -246,11 +246,6 @@ "count": 1 } }, - "web/app/components/app/app-access-control/add-member-or-group-pop.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/app/app-publisher/features-wrapper.tsx": { "ts/no-explicit-any": { "count": 4 @@ -1334,11 +1329,6 @@ "count": 9 } }, - "web/app/components/base/markdown-blocks/form.tsx": { - "erasable-syntax-only/enums": { - "count": 3 - } - }, "web/app/components/base/markdown-blocks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 10 @@ -1746,11 +1736,6 @@ "count": 4 } }, - "web/app/components/billing/upgrade-btn/index.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/datasets/common/image-previewer/index.tsx": { "no-irregular-whitespace": { "count": 1 @@ -2532,11 +2517,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/strategy-detail.tsx": { "ts/no-explicit-any": { "count": 2 @@ -4435,11 +4415,6 @@ "count": 1 } }, - "web/app/signin/one-more-step.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/signup/layout.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 73eb850001..6b9b76470a 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -7,63 +7,6 @@ import { zConsoleSsoOAuth2LoginResponse, zConsoleSsoOidcLoginResponse, zConsoleSsoSamlLoginResponse, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath, - zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse, - zEnterpriseAppDeployConsoleCreateAppInstanceBody, - zEnterpriseAppDeployConsoleCreateAppInstanceResponse, - zEnterpriseAppDeployConsoleCreateDeploymentBody, - zEnterpriseAppDeployConsoleCreateDeploymentPath, - zEnterpriseAppDeployConsoleCreateDeploymentResponse, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath, - zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse, - zEnterpriseAppDeployConsoleCreateReleaseBody, - zEnterpriseAppDeployConsoleCreateReleasePath, - zEnterpriseAppDeployConsoleCreateReleaseResponse, - zEnterpriseAppDeployConsoleDeleteAppInstancePath, - zEnterpriseAppDeployConsoleDeleteAppInstanceResponse, - zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath, - zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse, - zEnterpriseAppDeployConsoleGetAppInstanceAccessPath, - zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse, - zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath, - zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse, - zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath, - zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse, - zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath, - zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse, - zEnterpriseAppDeployConsoleListAppInstancesQuery, - zEnterpriseAppDeployConsoleListAppInstancesResponse, - zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath, - zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse, - zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse, - zEnterpriseAppDeployConsoleListReleasesPath, - zEnterpriseAppDeployConsoleListReleasesQuery, - zEnterpriseAppDeployConsoleListReleasesResponse, - zEnterpriseAppDeployConsoleListRuntimeInstancesPath, - zEnterpriseAppDeployConsoleListRuntimeInstancesResponse, - zEnterpriseAppDeployConsolePreviewReleaseBody, - zEnterpriseAppDeployConsolePreviewReleasePath, - zEnterpriseAppDeployConsolePreviewReleaseResponse, - zEnterpriseAppDeployConsoleSearchAccessSubjectsPath, - zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery, - zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse, - zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody, - zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath, - zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse, - zEnterpriseAppDeployConsoleUpdateAccessChannelsBody, - zEnterpriseAppDeployConsoleUpdateAccessChannelsPath, - zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse, - zEnterpriseAppDeployConsoleUpdateAppInstanceBody, - zEnterpriseAppDeployConsoleUpdateAppInstancePath, - zEnterpriseAppDeployConsoleUpdateAppInstanceResponse, - zEnterpriseAppDeployConsoleUpdateDeveloperApiBody, - zEnterpriseAppDeployConsoleUpdateDeveloperApiPath, - zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath, - zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse, zWebAppAuthGetGroupSubjectsQuery, zWebAppAuthGetGroupSubjectsResponse, zWebAppAuthGetWebAppAccessModeQuery, @@ -78,344 +21,6 @@ import { zWebAppAuthUpdateWebAppWhitelistSubjectsResponse, } from './zod.gen' -export const listAppInstances = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListAppInstances', - path: '/enterprise/app-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ query: zEnterpriseAppDeployConsoleListAppInstancesQuery.optional() })) - .output(zEnterpriseAppDeployConsoleListAppInstancesResponse) - -export const createAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateAppInstance', - path: '/enterprise/app-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ body: zEnterpriseAppDeployConsoleCreateAppInstanceBody })) - .output(zEnterpriseAppDeployConsoleCreateAppInstanceResponse) - -export const deleteAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'EnterpriseAppDeployConsole_DeleteAppInstance', - path: '/enterprise/app-instances/{appInstanceId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleDeleteAppInstancePath })) - .output(zEnterpriseAppDeployConsoleDeleteAppInstanceResponse) - -export const updateAppInstance = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateAppInstance', - path: '/enterprise/app-instances/{appInstanceId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateAppInstanceBody, - params: zEnterpriseAppDeployConsoleUpdateAppInstancePath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateAppInstanceResponse) - -export const getAppInstanceAccess = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceAccess', - path: '/enterprise/app-instances/{appInstanceId}/access', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceAccessPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse) - -export const updateAccessChannels = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateAccessChannels', - path: '/enterprise/app-instances/{appInstanceId}/access-channels', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateAccessChannelsBody, - params: zEnterpriseAppDeployConsoleUpdateAccessChannelsPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse) - -export const searchAccessSubjects = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_SearchAccessSubjects', - path: '/enterprise/app-instances/{appInstanceId}/access-subjects:search', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - params: zEnterpriseAppDeployConsoleSearchAccessSubjectsPath, - query: zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery.optional(), - }), - ) - .output(zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse) - -export const createDeveloperApiKey = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateDeveloperApiKey', - path: '/enterprise/app-instances/{appInstanceId}/api-keys', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody, - params: zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse) - -export const deleteDeveloperApiKey = oc - .route({ - inputStructure: 'detailed', - method: 'DELETE', - operationId: 'EnterpriseAppDeployConsole_DeleteDeveloperApiKey', - path: '/enterprise/app-instances/{appInstanceId}/api-keys/{apiKeyId}', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath })) - .output(zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse) - -export const listDeploymentBindingOptions = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListDeploymentBindingOptions', - path: '/enterprise/app-instances/{appInstanceId}/deployment-binding-options', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath })) - .output(zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse) - -export const createDeployment = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateDeployment', - path: '/enterprise/app-instances/{appInstanceId}/deployments', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateDeploymentBody, - params: zEnterpriseAppDeployConsoleCreateDeploymentPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateDeploymentResponse) - -export const updateDeveloperApi = oc - .route({ - inputStructure: 'detailed', - method: 'PATCH', - operationId: 'EnterpriseAppDeployConsole_UpdateDeveloperApi', - path: '/enterprise/app-instances/{appInstanceId}/developer-api', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateDeveloperApiBody, - params: zEnterpriseAppDeployConsoleUpdateDeveloperApiPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse) - -export const getEnvironmentAccessPolicy = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetEnvironmentAccessPolicy', - path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath })) - .output(zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse) - -export const updateEnvironmentAccessPolicy = oc - .route({ - inputStructure: 'detailed', - method: 'PUT', - operationId: 'EnterpriseAppDeployConsole_UpdateEnvironmentAccessPolicy', - path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody, - params: zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath, - }), - ) - .output(zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse) - -export const getAppInstanceOverview = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceOverview', - path: '/enterprise/app-instances/{appInstanceId}/overview', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse) - -export const listReleases = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListReleases', - path: '/enterprise/app-instances/{appInstanceId}/releases', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - params: zEnterpriseAppDeployConsoleListReleasesPath, - query: zEnterpriseAppDeployConsoleListReleasesQuery.optional(), - }), - ) - .output(zEnterpriseAppDeployConsoleListReleasesResponse) - -export const createRelease = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CreateRelease', - path: '/enterprise/app-instances/{appInstanceId}/releases', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCreateReleaseBody, - params: zEnterpriseAppDeployConsoleCreateReleasePath, - }), - ) - .output(zEnterpriseAppDeployConsoleCreateReleaseResponse) - -export const previewRelease = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_PreviewRelease', - path: '/enterprise/app-instances/{appInstanceId}/releases:preview', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsolePreviewReleaseBody, - params: zEnterpriseAppDeployConsolePreviewReleasePath, - }), - ) - .output(zEnterpriseAppDeployConsolePreviewReleaseResponse) - -export const listRuntimeInstances = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListRuntimeInstances', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleListRuntimeInstancesPath })) - .output(zEnterpriseAppDeployConsoleListRuntimeInstancesResponse) - -export const cancelRuntimeDeployment = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_CancelRuntimeDeployment', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}/deployment:cancel', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody, - params: zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath, - }), - ) - .output(zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse) - -export const undeployRuntimeInstance = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'EnterpriseAppDeployConsole_UndeployRuntimeInstance', - path: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}:undeploy', - tags: ['EnterpriseAppDeployConsole'], - }) - .input( - z.object({ - body: zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody, - params: zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath, - }), - ) - .output(zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse) - -export const getAppInstanceSettings = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_GetAppInstanceSettings', - path: '/enterprise/app-instances/{appInstanceId}/settings', - tags: ['EnterpriseAppDeployConsole'], - }) - .input(z.object({ params: zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath })) - .output(zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse) - -export const listDeploymentEnvironmentOptions = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'EnterpriseAppDeployConsole_ListDeploymentEnvironmentOptions', - path: '/enterprise/deployment-environment-options', - tags: ['EnterpriseAppDeployConsole'], - }) - .output(zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse) - -export const enterpriseAppDeployConsole = { - listAppInstances, - createAppInstance, - deleteAppInstance, - updateAppInstance, - getAppInstanceAccess, - updateAccessChannels, - searchAccessSubjects, - createDeveloperApiKey, - deleteDeveloperApiKey, - listDeploymentBindingOptions, - createDeployment, - updateDeveloperApi, - getEnvironmentAccessPolicy, - updateEnvironmentAccessPolicy, - getAppInstanceOverview, - listReleases, - createRelease, - previewRelease, - listRuntimeInstances, - cancelRuntimeDeployment, - undeployRuntimeInstance, - getAppInstanceSettings, - listDeploymentEnvironmentOptions, -} - export const oAuth2Login = oc .route({ inputStructure: 'detailed', @@ -528,7 +133,6 @@ export const webAppAuth = { } export const contract = { - enterpriseAppDeployConsole, consoleSso, webAppAuth, } diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index 56228f2738..b747c4baa8 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -4,46 +4,6 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type AccessChannels = { - enabled?: boolean - webappRows?: Array - cli?: CliAccess -} - -export type AccessModeOption = { - mode?: string - label?: string - disabled?: boolean - selected?: boolean -} - -export type AccessPolicyDetail = { - accessMode?: string - subjects?: Array - options?: Array -} - -export type AccessStatus = { - accessChannelsEnabled?: boolean - webappUrl?: string - cliUrl?: string - developerApiEnabled?: boolean - apiKeyCount?: number -} - -export type AccessSubject = { - subjectId?: string - subjectType?: string -} - -export type AccessSubjectDisplay = { - id?: string - subjectType?: string - name?: string - avatarUrl?: string - memberCount?: string -} - export type Account = { id?: string email?: string @@ -70,104 +30,9 @@ export type AccountInWorkspace = { role?: string } -export type AckDeploymentReply = { - accepted?: boolean - newVersion?: string -} - -export type AckDeploymentReq = { - deploymentId?: string - instanceId?: string - expectedVersion?: string - status?: string - observedReleaseId?: string - lastError?: LastError -} - -export type AppInstanceBasicInfo = { +export type AddGroupAppsRequest = { id?: string - name?: string - description?: string - sourceAppId?: string - sourceAppName?: string - mode?: string - createdAt?: string -} - -export type AppInstanceCard = { - id?: string - name?: string - icon?: string - mode?: string - sourceAppName?: string - statuses?: Array - lastDeployedAt?: string -} - -export type AppRunnerBatchRuntimeArtifactReply = { - results?: Array -} - -export type AppRunnerBatchRuntimeArtifactRequest = { - artifacts?: Array -} - -export type AppRunnerBootstrapAssignment = { - appId?: string - environmentId?: string - workflowId?: string - instanceId?: string - workspaceId?: string - instanceVersion?: string - bindingSnapshotVersion?: string - executionTokenVersion?: string - executionToken?: string - releaseId?: string -} - -export type AppRunnerBootstrapReply = { - runnerId?: string - assignmentRevision?: string - assignments?: Array -} - -export type AppRunnerBootstrapRequest = { - runner?: AppRunnerRunnerInfo -} - -export type AppRunnerRunnerInfo = { - hostname?: string -} - -export type AppRunnerRuntimeArtifactReply = { - dslYaml?: string - bindingSnapshotVersion?: string - bindingSnapshot?: { - [key: string]: unknown - } -} - -export type AppRunnerRuntimeArtifactRequest = { - instanceId?: string - releaseId?: string - bindingSnapshotVersion?: string -} - -export type AppRunnerRuntimeArtifactResult = { - instanceId?: string - releaseId?: string - artifact?: AppRunnerRuntimeArtifactReply - errorCode?: string - errorMessage?: string -} - -export type AppRunnerTokenExchangeReply = { - accessToken?: string - expiresAt?: string -} - -export type AppRunnerTokenExchangeRequest = { - joinToken?: string + app_ids?: Array } export type AuthSettingsReply = { @@ -193,15 +58,6 @@ export type AuthSettingsReq = { ssoSettings?: SsoSettings } -export type BootstrapProgress = { - currentStep?: string - completedSteps?: Array - attemptCount?: number - lastAttemptAt?: string - lastErrorCode?: string - lastErrorMessage?: string -} - export type BrandingInfo = { enabled?: boolean applicationTitle?: string @@ -210,15 +66,6 @@ export type BrandingInfo = { favicon?: string } -export type CancelRuntimeDeploymentReply = { - status?: string -} - -export type CancelRuntimeDeploymentReq = { - appInstanceId?: string - runtimeInstanceId?: string -} - export type CheckPasswordStatusReply = { requirePasswordChange?: boolean changeReason?: number @@ -230,82 +77,10 @@ export type ClearDefaultWorkspaceReply = { [key: string]: unknown } -export type CliAccess = { - url?: string -} - -export type ConsoleEnvironment = { - id?: string - name?: string - runtime?: string - type?: string - status?: string -} - -export type ConsoleRelease = { - id?: string - name?: string - shortCommitId?: string - createdAt?: string -} - -export type ConsoleUser = { - id?: string - name?: string -} - -export type CreateAppInstanceReply = { - appInstanceId?: string - initialRelease?: ConsoleRelease -} - -export type CreateAppInstanceReq = { - sourceAppId?: string - name?: string - description?: string -} - export type CreateBearerTokenResponse = { token?: string } -export type CreateDeploymentReply = { - runtimeInstanceId?: string - deploymentId?: string - status?: string -} - -export type CreateDeploymentReq = { - appInstanceId?: string - environmentId?: string - releaseId?: string - bindings?: Array -} - -export type CreateDeveloperApiKeyReply = { - apiKey?: DeveloperApiKeyRow - token?: string -} - -export type CreateDeveloperApiKeyReq = { - appInstanceId?: string - environmentId?: string - name?: string -} - -export type CreateEnvironmentReply = { - environment?: Environment -} - -export type CreateEnvironmentReq = { - name?: string - description?: string - mode?: number - backend?: number - k8s?: K8sEnvironmentConfig - host?: HostEnvironmentConfig -} - export type CreateMemberReply = { id?: string password?: string @@ -329,12 +104,7 @@ export type CreateNewGroupsRes = { groups?: Array } -export type CreateReleaseReply = { - release?: ConsoleRelease -} - -export type CreateReleaseReq = { - appInstanceId?: string +export type CreateResourceGroupRequest = { name?: string description?: string } @@ -394,27 +164,10 @@ export type DashboardSsosamlLoginReply = { url?: string } -export type DeleteAppInstanceReply = { - [key: string]: unknown -} - -export type DeleteDeveloperApiKeyReply = { - [key: string]: unknown -} - -export type DeleteEnvironmentReply = { - [key: string]: unknown -} - export type DeleteGroupsRes = { message?: string } -export type DeleteGuard = { - canDelete?: boolean - disabledReason?: string -} - export type DeleteMemberReply = { account?: Account } @@ -431,70 +184,6 @@ export type DeleteWorkspaceReply = { [key: string]: unknown } -export type DeployedEnvironment = { - environmentId?: string - environmentName?: string -} - -export type DeploymentBindingOptionSlot = { - slot?: string - kind?: string - label?: string - required?: boolean - candidates?: Array - envVarCandidates?: Array -} - -export type DeploymentCredentialOption = { - credentialId?: string - displayName?: string - pluginId?: string - pluginName?: string - pluginVersion?: string -} - -export type DeploymentEnvVarOption = { - envVarId?: string - name?: string - valueType?: string - displayValue?: string -} - -export type DeploymentEnvironmentOption = { - id?: string - name?: string - type?: string - backend?: string - status?: string - managedBy?: string - deployable?: boolean - disabledReason?: string -} - -export type DeploymentRuntimeBinding = { - slot?: string - credentialId?: string - envVarId?: string -} - -export type DeploymentStatusRow = { - environment?: ConsoleEnvironment - release?: ConsoleRelease - status?: string -} - -export type DeveloperApiAccess = { - enabled?: boolean - apiKeys?: Array -} - -export type DeveloperApiKeyRow = { - id?: string - name?: string - environment?: ConsoleEnvironment - maskedKey?: string -} - export type EndpointReply = { mode?: number metricsEndpoint?: OtelExporterEndpoint @@ -507,55 +196,6 @@ export type EnterpriseSystemUserSettingReply = { enableEmailPasswordLogin?: boolean } -export type Environment = { - id?: string - name?: string - description?: string - mode?: number - namespace?: string - apiServer?: string - status?: number - statusMessage?: string - bootstrapProgress?: BootstrapProgress - managedBy?: string - createdAt?: string - updatedAt?: string - backend?: number - host?: string -} - -export type EnvironmentAccessRow = { - environment?: ConsoleEnvironment - currentRelease?: ConsoleRelease - accessMode?: string - accessModeLabel?: string - hint?: string -} - -export type EnvironmentFilter = { - id?: string - name?: string - kind?: string -} - -export type GetAppInstanceAccessReply = { - permissions?: Array - accessChannels?: AccessChannels - developerApi?: DeveloperApiAccess -} - -export type GetAppInstanceOverviewReply = { - instance?: AppInstanceBasicInfo - deployments?: Array - access?: AccessStatus -} - -export type GetAppInstanceSettingsReply = { - name?: string - description?: string - deleteGuard?: DeleteGuard -} - export type GetBearerTokenResponse = { maskedToken?: string } @@ -571,14 +211,6 @@ export type GetDefaultWorkspaceReply = { workspace?: Workspace } -export type GetEnvironmentAccessPolicyReply = { - policy?: AccessPolicyDetail -} - -export type GetEnvironmentReply = { - environment?: Environment -} - export type GetGroupSubjectsRes = { subjects?: Array } @@ -587,15 +219,6 @@ export type GetGroupsRes = { groups?: Array } -export type GetInstanceReply = { - instanceId?: string - status?: string - desiredReleaseId?: string - observedReleaseId?: string - currentDeploymentId?: string - version?: string -} - export type GetJoinedGroupsRes = { groups?: Array } @@ -652,16 +275,22 @@ export type GetWorkspaceReply = { workspace?: Workspace } +export type GroupAppItem = { + app_id?: string + app_name?: string + workspace_id?: string + workspace_name?: string + app_status?: number + token_usage?: string + rpm?: string + concurrency?: string +} + export type HealthzReply = { message?: string status?: string } -export type HostEnvironmentConfig = { - machineId?: string - joinTokenHash?: string -} - export type InfoConfigReply = { SSOEnforcedForSignin?: boolean SSOEnforcedForSigninProtocol?: string @@ -677,6 +306,11 @@ export type InfoConfigReply = { PluginInstallationPermission?: PluginInstallationPermissionInfo } +export type InnerAdmission = { + marker?: string + concurrencyGroupIds?: Array +} + export type InnerBatchGetWebAppAccessModesByIdReq = { appIds?: Array } @@ -698,42 +332,10 @@ export type InnerBatchIsUserAllowedToAccessWebAppRes = { } } -export type InnerCheckAppDeployAccessReply = { - allowed?: boolean - matchedPolicyId?: string - matchedScopeType?: string - reason?: string - cacheTtlSeconds?: number -} - -export type InnerCheckAppDeployAccessReq = { - appInstanceId?: string - environmentId?: string - principalType?: string - principalId?: string -} - export type InnerCleanAppRes = { message?: string } -export type InnerGetTokenRouteReply = { - environmentId?: string - namespace?: string - serviceName?: string - servicePort?: number - environmentStatus?: string - appId?: string - tenantId?: string - instanceId?: string - observedReleaseId?: string - instanceStatus?: string -} - -export type InnerGetTokenRouteReq = { - token?: string -} - export type InnerGetWebAppAccessModeByCodeRes = { accessMode?: string } @@ -742,10 +344,34 @@ export type InnerGetWebAppAccessModeByIdRes = { accessMode?: string } +export type InnerGroupConfig = { + id?: string + enabled?: boolean + membershipId?: string + limits?: Array +} + export type InnerIsUserAllowedToAccessWebAppRes = { result?: boolean } +export type InnerReleaseAdmissionRequest = { + admission?: InnerAdmission +} + +export type InnerReleaseAdmissionResponse = { + [key: string]: unknown +} + +export type InnerResolveResponse = { + appId?: string + groups?: Array + blocked?: boolean + blockGroupId?: string + blockReason?: string + admission?: InnerAdmission +} + export type InnerTryAddAccountToDefaultWorkspaceReply = { workspaceId?: string joined?: boolean @@ -770,20 +396,6 @@ export type JoinWorkspaceReq = { role?: string } -export type K8sEnvironmentConfig = { - namespace?: string - apiServer?: string - caBundle?: string - bearerToken?: string -} - -export type LastError = { - phase?: string - code?: string - message?: string - releaseId?: string -} - export type LicenseInfo = { uuid?: string expiredAt?: string @@ -798,28 +410,21 @@ export type LicenseStatus = { workspaces?: ResourceQuota } +export type LimitConfig = { + type?: number + threshold?: string + action?: number + reached?: boolean +} + export type LimitFields = { workspaceMembers?: number workspaces?: ResourceQuota } -export type ListAppInstancesReply = { - filters?: Array - data?: Array - pagination?: Pagination -} - -export type ListDeploymentBindingOptionsReply = { - slots?: Array -} - -export type ListDeploymentEnvironmentOptionsReply = { - environments?: Array -} - -export type ListEnvironmentsReply = { - data?: Array - pagination?: Pagination +export type ListGroupAppsResponse = { + items?: Array + total?: string } export type ListMembersReply = { @@ -827,13 +432,9 @@ export type ListMembersReply = { pagination?: Pagination } -export type ListReleasesReply = { - data?: Array - pagination?: Pagination -} - -export type ListRuntimeInstancesReply = { - data?: Array +export type ListResourceGroupsResponse = { + items?: Array + total?: string } export type ListSecretKeysReply = { @@ -981,31 +582,6 @@ export type PluginInstallationSettingsReply = { restrictToMarketplaceOnly?: boolean } -export type PreviewReleaseReply = { - release?: ConsoleRelease - bindings?: Array -} - -export type PreviewReleaseReq = { - appInstanceId?: string - releaseId?: string -} - -export type ReleaseRow = { - id?: string - name?: string - createdAt?: string - createdBy?: ConsoleUser - deployedTo?: Array -} - -export type ReleaseRuntimeBinding = { - kind?: string - label?: string - displayValue?: string - valueType?: string -} - export type ResetMemberPasswordReply = { id?: string password?: string @@ -1034,21 +610,35 @@ export type ResetUserPasswordReq = { id?: string } -export type ResolveCredentialsReply = { - resolved?: Array +export type ResourceGroupDetail = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + rpm_action?: number + concurrency_limit?: number + concurrency_action?: number + token_quota?: string + token_action?: number + created_at?: string + updated_at?: string } -export type ResolveCredentialsReq = { - instanceId?: string - deploymentId?: string - slots?: Array -} - -export type ResolvedCredential = { - slot?: string - credentialId?: string - envVarId?: string - value?: string +export type ResourceGroupItem = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + concurrency_limit?: number + token_quota?: string + token_usage?: string + app_count?: string + rpm_status?: number + conc_status?: number + created_at?: string + updated_at?: string } export type ResourceQuota = { @@ -1057,36 +647,6 @@ export type ResourceQuota = { enabled?: boolean } -export type RetryEnvironmentReply = { - environment?: Environment -} - -export type RetryEnvironmentReq = { - id?: string -} - -export type RuntimeEndpoints = { - run?: string - health?: string -} - -export type RuntimeInstanceDetail = { - deploymentName?: string - replicas?: number - runtimeMode?: string - runtimeNote?: string - endpoints?: RuntimeEndpoints - bindings?: Array -} - -export type RuntimeInstanceRow = { - id?: string - environment?: ConsoleEnvironment - status?: string - currentRelease?: ConsoleRelease - detail?: RuntimeInstanceDetail -} - export type SamlConfig = { idpSsoUrl?: string certificate?: string @@ -1119,8 +679,21 @@ export type ScimSettings = { lastSyncTime?: string } -export type SearchAccessSubjectsReply = { - data?: Array +export type SearchAppItem = { + app_id?: string + app_name?: string + workspace_id?: string + workspace_name?: string + app_status?: number + icon?: string + icon_type?: string + icon_background?: string + created_by_name?: string +} + +export type SearchAppsResponse = { + items?: Array + total?: string } export type SearchForWhilteListCandidatesRes = { @@ -1145,11 +718,6 @@ export type SetDefaultWorkspaceReq = { id?: string } -export type StatusCount = { - status?: string - count?: number -} - export type Subject = { subjectId?: string subjectType?: string @@ -1185,42 +753,10 @@ export type TestConnectionReply = { error?: string } -export type TestEnvironmentConnectionReply = { - ok?: boolean - reachableServerVersion?: string - namespaceExists?: boolean - missingPermissions?: Array - error?: string - probedAt?: string -} - -export type TestEnvironmentConnectionReq = { - id?: string -} - export type ToggleEndpointRequest = { enabled?: boolean } -export type UndeployRuntimeInstanceReply = { - deploymentId?: string - status?: string -} - -export type UndeployRuntimeInstanceReq = { - appInstanceId?: string - runtimeInstanceId?: string -} - -export type UpdateAccessChannelsReply = { - accessChannels?: AccessChannels -} - -export type UpdateAccessChannelsReq = { - appInstanceId?: string - enabled?: boolean -} - export type UpdateAccessModeReq = { appId?: string accessMode?: string @@ -1230,16 +766,6 @@ export type UpdateAccessModeRes = { message?: string } -export type UpdateAppInstanceReply = { - appInstanceId?: string -} - -export type UpdateAppInstanceReq = { - appInstanceId?: string - name?: string - description?: string -} - export type UpdateBrandingInfoReq = { enabled?: boolean applicationTitle?: string @@ -1248,36 +774,6 @@ export type UpdateBrandingInfoReq = { favicon?: string } -export type UpdateDeveloperApiReply = { - developerApi?: DeveloperApiAccess -} - -export type UpdateDeveloperApiReq = { - appInstanceId?: string - enabled?: boolean -} - -export type UpdateEnvironmentAccessPolicyReply = { - permission?: EnvironmentAccessRow -} - -export type UpdateEnvironmentAccessPolicyReq = { - appInstanceId?: string - environmentId?: string - accessMode?: string - subjects?: Array -} - -export type UpdateEnvironmentReply = { - environment?: Environment -} - -export type UpdateEnvironmentReq = { - id?: string - name?: string - description?: string -} - export type UpdateGroupSubjectsReq = { groupId?: string subjects?: Array @@ -1358,6 +854,19 @@ export type UpdatePluginInstallationSettingsRequest = { restrictToMarketplaceOnly?: boolean } +export type UpdateResourceGroupRequest = { + id?: string + name?: string + description?: string + enabled?: boolean + rpm_limit?: number + rpm_action?: number + concurrency_limit?: number + concurrency_action?: number + token_quota?: string + token_action?: number +} + export type UpdateUserReply = { account?: AccountDetail } @@ -1410,11 +919,6 @@ export type UpdateWorkspaceReq = { status?: string } -export type WebAppAccessRow = { - environment?: ConsoleEnvironment - url?: string -} - export type WebAppAuthInfo = { allowSso?: boolean allowEmailCodeLogin?: boolean @@ -1459,385 +963,6 @@ export type Pagination = { totalPages?: number } -export type EnterpriseAppDeployConsoleListAppInstancesData = { - body?: never - path?: never - query?: { - environmentId?: string - notDeployed?: boolean - query?: string - pageNumber?: number - resultsPerPage?: number - } - url: '/enterprise/app-instances' -} - -export type EnterpriseAppDeployConsoleListAppInstancesResponses = { - 200: ListAppInstancesReply -} - -export type EnterpriseAppDeployConsoleListAppInstancesResponse - = EnterpriseAppDeployConsoleListAppInstancesResponses[keyof EnterpriseAppDeployConsoleListAppInstancesResponses] - -export type EnterpriseAppDeployConsoleCreateAppInstanceData = { - body: CreateAppInstanceReq - path?: never - query?: never - url: '/enterprise/app-instances' -} - -export type EnterpriseAppDeployConsoleCreateAppInstanceResponses = { - 200: CreateAppInstanceReply -} - -export type EnterpriseAppDeployConsoleCreateAppInstanceResponse - = EnterpriseAppDeployConsoleCreateAppInstanceResponses[keyof EnterpriseAppDeployConsoleCreateAppInstanceResponses] - -export type EnterpriseAppDeployConsoleDeleteAppInstanceData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}' -} - -export type EnterpriseAppDeployConsoleDeleteAppInstanceResponses = { - 200: DeleteAppInstanceReply -} - -export type EnterpriseAppDeployConsoleDeleteAppInstanceResponse - = EnterpriseAppDeployConsoleDeleteAppInstanceResponses[keyof EnterpriseAppDeployConsoleDeleteAppInstanceResponses] - -export type EnterpriseAppDeployConsoleUpdateAppInstanceData = { - body: UpdateAppInstanceReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}' -} - -export type EnterpriseAppDeployConsoleUpdateAppInstanceResponses = { - 200: UpdateAppInstanceReply -} - -export type EnterpriseAppDeployConsoleUpdateAppInstanceResponse - = EnterpriseAppDeployConsoleUpdateAppInstanceResponses[keyof EnterpriseAppDeployConsoleUpdateAppInstanceResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/access' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessResponses = { - 200: GetAppInstanceAccessReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceAccessResponse - = EnterpriseAppDeployConsoleGetAppInstanceAccessResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceAccessResponses] - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsData = { - body: UpdateAccessChannelsReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/access-channels' -} - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsResponses = { - 200: UpdateAccessChannelsReply -} - -export type EnterpriseAppDeployConsoleUpdateAccessChannelsResponse - = EnterpriseAppDeployConsoleUpdateAccessChannelsResponses[keyof EnterpriseAppDeployConsoleUpdateAccessChannelsResponses] - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsData = { - body?: never - path: { - appInstanceId: string - } - query?: { - keyword?: string - subjectTypes?: Array - } - url: '/enterprise/app-instances/{appInstanceId}/access-subjects:search' -} - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsResponses = { - 200: SearchAccessSubjectsReply -} - -export type EnterpriseAppDeployConsoleSearchAccessSubjectsResponse - = EnterpriseAppDeployConsoleSearchAccessSubjectsResponses[keyof EnterpriseAppDeployConsoleSearchAccessSubjectsResponses] - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyData = { - body: CreateDeveloperApiKeyReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/api-keys' -} - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses = { - 200: CreateDeveloperApiKeyReply -} - -export type EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse - = EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses[keyof EnterpriseAppDeployConsoleCreateDeveloperApiKeyResponses] - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyData = { - body?: never - path: { - appInstanceId: string - apiKeyId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/api-keys/{apiKeyId}' -} - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses = { - 200: DeleteDeveloperApiKeyReply -} - -export type EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse - = EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses[keyof EnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponses] - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/deployment-binding-options' -} - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses = { - 200: ListDeploymentBindingOptionsReply -} - -export type EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse - = EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses[keyof EnterpriseAppDeployConsoleListDeploymentBindingOptionsResponses] - -export type EnterpriseAppDeployConsoleCreateDeploymentData = { - body: CreateDeploymentReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/deployments' -} - -export type EnterpriseAppDeployConsoleCreateDeploymentResponses = { - 200: CreateDeploymentReply -} - -export type EnterpriseAppDeployConsoleCreateDeploymentResponse - = EnterpriseAppDeployConsoleCreateDeploymentResponses[keyof EnterpriseAppDeployConsoleCreateDeploymentResponses] - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiData = { - body: UpdateDeveloperApiReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/developer-api' -} - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiResponses = { - 200: UpdateDeveloperApiReply -} - -export type EnterpriseAppDeployConsoleUpdateDeveloperApiResponse - = EnterpriseAppDeployConsoleUpdateDeveloperApiResponses[keyof EnterpriseAppDeployConsoleUpdateDeveloperApiResponses] - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyData = { - body?: never - path: { - appInstanceId: string - environmentId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy' -} - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses = { - 200: GetEnvironmentAccessPolicyReply -} - -export type EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse - = EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses[keyof EnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponses] - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyData = { - body: UpdateEnvironmentAccessPolicyReq - path: { - appInstanceId: string - environmentId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy' -} - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses = { - 200: UpdateEnvironmentAccessPolicyReply -} - -export type EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse - = EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses[keyof EnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/overview' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses = { - 200: GetAppInstanceOverviewReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceOverviewResponse - = EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceOverviewResponses] - -export type EnterpriseAppDeployConsoleListReleasesData = { - body?: never - path: { - appInstanceId: string - } - query?: { - pageNumber?: number - resultsPerPage?: number - } - url: '/enterprise/app-instances/{appInstanceId}/releases' -} - -export type EnterpriseAppDeployConsoleListReleasesResponses = { - 200: ListReleasesReply -} - -export type EnterpriseAppDeployConsoleListReleasesResponse - = EnterpriseAppDeployConsoleListReleasesResponses[keyof EnterpriseAppDeployConsoleListReleasesResponses] - -export type EnterpriseAppDeployConsoleCreateReleaseData = { - body: CreateReleaseReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/releases' -} - -export type EnterpriseAppDeployConsoleCreateReleaseResponses = { - 200: CreateReleaseReply -} - -export type EnterpriseAppDeployConsoleCreateReleaseResponse - = EnterpriseAppDeployConsoleCreateReleaseResponses[keyof EnterpriseAppDeployConsoleCreateReleaseResponses] - -export type EnterpriseAppDeployConsolePreviewReleaseData = { - body: PreviewReleaseReq - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/releases:preview' -} - -export type EnterpriseAppDeployConsolePreviewReleaseResponses = { - 200: PreviewReleaseReply -} - -export type EnterpriseAppDeployConsolePreviewReleaseResponse - = EnterpriseAppDeployConsolePreviewReleaseResponses[keyof EnterpriseAppDeployConsolePreviewReleaseResponses] - -export type EnterpriseAppDeployConsoleListRuntimeInstancesData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances' -} - -export type EnterpriseAppDeployConsoleListRuntimeInstancesResponses = { - 200: ListRuntimeInstancesReply -} - -export type EnterpriseAppDeployConsoleListRuntimeInstancesResponse - = EnterpriseAppDeployConsoleListRuntimeInstancesResponses[keyof EnterpriseAppDeployConsoleListRuntimeInstancesResponses] - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentData = { - body: CancelRuntimeDeploymentReq - path: { - appInstanceId: string - runtimeInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}/deployment:cancel' -} - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses = { - 200: CancelRuntimeDeploymentReply -} - -export type EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse - = EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses[keyof EnterpriseAppDeployConsoleCancelRuntimeDeploymentResponses] - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceData = { - body: UndeployRuntimeInstanceReq - path: { - appInstanceId: string - runtimeInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/runtime-instances/{runtimeInstanceId}:undeploy' -} - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses = { - 200: UndeployRuntimeInstanceReply -} - -export type EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse - = EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses[keyof EnterpriseAppDeployConsoleUndeployRuntimeInstanceResponses] - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsData = { - body?: never - path: { - appInstanceId: string - } - query?: never - url: '/enterprise/app-instances/{appInstanceId}/settings' -} - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses = { - 200: GetAppInstanceSettingsReply -} - -export type EnterpriseAppDeployConsoleGetAppInstanceSettingsResponse - = EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses[keyof EnterpriseAppDeployConsoleGetAppInstanceSettingsResponses] - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsData = { - body?: never - path?: never - query?: never - url: '/enterprise/deployment-environment-options' -} - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses = { - 200: ListDeploymentEnvironmentOptionsReply -} - -export type EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse - = EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses[keyof EnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponses] - export type ConsoleSsoOAuth2LoginData = { body?: never path?: never diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index 1e7e3d44ae..cef500a906 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -2,44 +2,6 @@ import * as z from 'zod' -export const zAccessModeOption = z.object({ - mode: z.string().optional(), - label: z.string().optional(), - disabled: z.boolean().optional(), - selected: z.boolean().optional(), -}) - -export const zAccessStatus = z.object({ - accessChannelsEnabled: z.boolean().optional(), - webappUrl: z.string().optional(), - cliUrl: z.string().optional(), - developerApiEnabled: z.boolean().optional(), - apiKeyCount: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zAccessSubject = z.object({ - subjectId: z.string().optional(), - subjectType: z.string().optional(), -}) - -export const zAccessSubjectDisplay = z.object({ - id: z.string().optional(), - subjectType: z.string().optional(), - name: z.string().optional(), - avatarUrl: z.string().optional(), - memberCount: z.string().optional(), -}) - -export const zAccessPolicyDetail = z.object({ - accessMode: z.string().optional(), - subjects: z.array(zAccessSubjectDisplay).optional(), - options: z.array(zAccessModeOption).optional(), -}) - /** * Account represents a basic user account */ @@ -75,101 +37,9 @@ export const zAccountDetail = z.object({ groups: z.array(zAccountDetailGroup).optional(), }) -export const zAckDeploymentReply = z.object({ - accepted: z.boolean().optional(), - newVersion: z.string().optional(), -}) - -export const zAppInstanceBasicInfo = z.object({ +export const zAddGroupAppsRequest = z.object({ id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - sourceAppId: z.string().optional(), - sourceAppName: z.string().optional(), - mode: z.string().optional(), - createdAt: z.iso.datetime().optional(), -}) - -export const zAppRunnerBootstrapAssignment = z.object({ - appId: z.string().optional(), - environmentId: z.string().optional(), - workflowId: z.string().optional(), - instanceId: z.string().optional(), - workspaceId: z.string().optional(), - instanceVersion: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), - executionTokenVersion: z.string().optional(), - executionToken: z.string().optional(), - releaseId: z.string().optional(), -}) - -export const zAppRunnerBootstrapReply = z.object({ - runnerId: z.string().optional(), - assignmentRevision: z.string().optional(), - assignments: z.array(zAppRunnerBootstrapAssignment).optional(), -}) - -export const zAppRunnerRunnerInfo = z.object({ - hostname: z.string().optional(), -}) - -export const zAppRunnerBootstrapRequest = z.object({ - runner: zAppRunnerRunnerInfo.optional(), -}) - -export const zAppRunnerRuntimeArtifactReply = z.object({ - dslYaml: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), - bindingSnapshot: z.record(z.string(), z.unknown()).optional(), -}) - -export const zAppRunnerRuntimeArtifactRequest = z.object({ - instanceId: z.string().optional(), - releaseId: z.string().optional(), - bindingSnapshotVersion: z.string().optional(), -}) - -export const zAppRunnerBatchRuntimeArtifactRequest = z.object({ - artifacts: z.array(zAppRunnerRuntimeArtifactRequest).optional(), -}) - -export const zAppRunnerRuntimeArtifactResult = z.object({ - instanceId: z.string().optional(), - releaseId: z.string().optional(), - artifact: zAppRunnerRuntimeArtifactReply.optional(), - errorCode: z.string().optional(), - errorMessage: z.string().optional(), -}) - -export const zAppRunnerBatchRuntimeArtifactReply = z.object({ - results: z.array(zAppRunnerRuntimeArtifactResult).optional(), -}) - -export const zAppRunnerTokenExchangeReply = z.object({ - accessToken: z.string().optional(), - expiresAt: z.iso.datetime().optional(), -}) - -export const zAppRunnerTokenExchangeRequest = z.object({ - joinToken: z.string().optional(), -}) - -/** - * BootstrapProgress is step-list-agnostic. Reconcilers emit step names as - * strings owned by each executor (e.g. "connectivity", "namespace"), so adding - * or removing steps does not break the API. - */ -export const zBootstrapProgress = z.object({ - currentStep: z.string().optional(), - completedSteps: z.array(z.string()).optional(), - attemptCount: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - lastAttemptAt: z.iso.datetime().optional(), - lastErrorCode: z.string().optional(), - lastErrorMessage: z.string().optional(), + app_ids: z.array(z.string()).optional(), }) export const zBrandingInfo = z.object({ @@ -180,15 +50,6 @@ export const zBrandingInfo = z.object({ favicon: z.string().optional(), }) -export const zCancelRuntimeDeploymentReply = z.object({ - status: z.string().optional(), -}) - -export const zCancelRuntimeDeploymentReq = z.object({ - appInstanceId: z.string().optional(), - runtimeInstanceId: z.string().optional(), -}) - export const zCheckPasswordStatusReply = z.object({ requirePasswordChange: z.boolean().optional(), changeReason: z.int().optional(), @@ -202,57 +63,10 @@ export const zCheckPasswordStatusReply = z.object({ export const zClearDefaultWorkspaceReply = z.record(z.string(), z.unknown()) -export const zCliAccess = z.object({ - url: z.string().optional(), -}) - -export const zConsoleEnvironment = z.object({ - id: z.string().optional(), - name: z.string().optional(), - runtime: z.string().optional(), - type: z.string().optional(), - status: z.string().optional(), -}) - -export const zConsoleRelease = z.object({ - id: z.string().optional(), - name: z.string().optional(), - shortCommitId: z.string().optional(), - createdAt: z.iso.datetime().optional(), -}) - -export const zConsoleUser = z.object({ - id: z.string().optional(), - name: z.string().optional(), -}) - -export const zCreateAppInstanceReply = z.object({ - appInstanceId: z.string().optional(), - initialRelease: zConsoleRelease.optional(), -}) - -export const zCreateAppInstanceReq = z.object({ - sourceAppId: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zCreateBearerTokenResponse = z.object({ token: z.string().optional(), }) -export const zCreateDeploymentReply = z.object({ - runtimeInstanceId: z.string().optional(), - deploymentId: z.string().optional(), - status: z.string().optional(), -}) - -export const zCreateDeveloperApiKeyReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - name: z.string().optional(), -}) - export const zCreateMemberReply = z.object({ id: z.string().optional(), password: z.string().optional(), @@ -275,12 +89,7 @@ export const zCreateNewGroupsReq = z.object({ groups: z.array(zCreateNewGroupsReqGroup).optional(), }) -export const zCreateReleaseReply = z.object({ - release: zConsoleRelease.optional(), -}) - -export const zCreateReleaseReq = z.object({ - appInstanceId: z.string().optional(), +export const zCreateResourceGroupRequest = z.object({ name: z.string().optional(), description: z.string().optional(), }) @@ -342,21 +151,10 @@ export const zDashboardSsosamlLoginReply = z.object({ url: z.string().optional(), }) -export const zDeleteAppInstanceReply = z.record(z.string(), z.unknown()) - -export const zDeleteDeveloperApiKeyReply = z.record(z.string(), z.unknown()) - -export const zDeleteEnvironmentReply = z.record(z.string(), z.unknown()) - export const zDeleteGroupsRes = z.object({ message: z.string().optional(), }) -export const zDeleteGuard = z.object({ - canDelete: z.boolean().optional(), - disabledReason: z.string().optional(), -}) - export const zDeleteMemberReply = z.object({ account: zAccount.optional(), }) @@ -371,82 +169,6 @@ export const zDeleteUserReply = z.object({ export const zDeleteWorkspaceReply = z.record(z.string(), z.unknown()) -export const zDeployedEnvironment = z.object({ - environmentId: z.string().optional(), - environmentName: z.string().optional(), -}) - -export const zDeploymentCredentialOption = z.object({ - credentialId: z.string().optional(), - displayName: z.string().optional(), - pluginId: z.string().optional(), - pluginName: z.string().optional(), - pluginVersion: z.string().optional(), -}) - -export const zDeploymentEnvVarOption = z.object({ - envVarId: z.string().optional(), - name: z.string().optional(), - valueType: z.string().optional(), - displayValue: z.string().optional(), -}) - -export const zDeploymentBindingOptionSlot = z.object({ - slot: z.string().optional(), - kind: z.string().optional(), - label: z.string().optional(), - required: z.boolean().optional(), - candidates: z.array(zDeploymentCredentialOption).optional(), - envVarCandidates: z.array(zDeploymentEnvVarOption).optional(), -}) - -export const zDeploymentEnvironmentOption = z.object({ - id: z.string().optional(), - name: z.string().optional(), - type: z.string().optional(), - backend: z.string().optional(), - status: z.string().optional(), - managedBy: z.string().optional(), - deployable: z.boolean().optional(), - disabledReason: z.string().optional(), -}) - -export const zDeploymentRuntimeBinding = z.object({ - slot: z.string().optional(), - credentialId: z.string().optional(), - envVarId: z.string().optional(), -}) - -export const zCreateDeploymentReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - releaseId: z.string().optional(), - bindings: z.array(zDeploymentRuntimeBinding).optional(), -}) - -export const zDeploymentStatusRow = z.object({ - environment: zConsoleEnvironment.optional(), - release: zConsoleRelease.optional(), - status: z.string().optional(), -}) - -export const zDeveloperApiKeyRow = z.object({ - id: z.string().optional(), - name: z.string().optional(), - environment: zConsoleEnvironment.optional(), - maskedKey: z.string().optional(), -}) - -export const zCreateDeveloperApiKeyReply = z.object({ - apiKey: zDeveloperApiKeyRow.optional(), - token: z.string().optional(), -}) - -export const zDeveloperApiAccess = z.object({ - enabled: z.boolean().optional(), - apiKeys: z.array(zDeveloperApiKeyRow).optional(), -}) - /** * System user setting messages */ @@ -456,53 +178,6 @@ export const zEnterpriseSystemUserSettingReply = z.object({ enableEmailPasswordLogin: z.boolean().optional(), }) -export const zEnvironment = z.object({ - id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - mode: z.int().optional(), - namespace: z.string().optional(), - apiServer: z.string().optional(), - status: z.int().optional(), - statusMessage: z.string().optional(), - bootstrapProgress: zBootstrapProgress.optional(), - managedBy: z.string().optional(), - createdAt: z.iso.datetime().optional(), - updatedAt: z.iso.datetime().optional(), - backend: z.int().optional(), - host: z.string().optional(), -}) - -export const zCreateEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zEnvironmentAccessRow = z.object({ - environment: zConsoleEnvironment.optional(), - currentRelease: zConsoleRelease.optional(), - accessMode: z.string().optional(), - accessModeLabel: z.string().optional(), - hint: z.string().optional(), -}) - -export const zEnvironmentFilter = z.object({ - id: z.string().optional(), - name: z.string().optional(), - kind: z.string().optional(), -}) - -export const zGetAppInstanceOverviewReply = z.object({ - instance: zAppInstanceBasicInfo.optional(), - deployments: z.array(zDeploymentStatusRow).optional(), - access: zAccessStatus.optional(), -}) - -export const zGetAppInstanceSettingsReply = z.object({ - name: z.string().optional(), - description: z.string().optional(), - deleteGuard: zDeleteGuard.optional(), -}) - export const zGetBearerTokenResponse = z.object({ maskedToken: z.string().optional(), }) @@ -513,23 +188,6 @@ export const zGetClusterInfoReply = z.object({ verifyMode: z.string().optional(), }) -export const zGetEnvironmentAccessPolicyReply = z.object({ - policy: zAccessPolicyDetail.optional(), -}) - -export const zGetEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zGetInstanceReply = z.object({ - instanceId: z.string().optional(), - status: z.string().optional(), - desiredReleaseId: z.string().optional(), - observedReleaseId: z.string().optional(), - currentDeploymentId: z.string().optional(), - version: z.string().optional(), -}) - export const zGetLicenseStatusReply = z.object({ status: z.string().optional(), }) @@ -565,14 +223,25 @@ export const zGetWebAppWhitelistSubjectsResMember = z.object({ avatar: z.string().optional(), }) +export const zGroupAppItem = z.object({ + app_id: z.string().optional(), + app_name: z.string().optional(), + workspace_id: z.string().optional(), + workspace_name: z.string().optional(), + app_status: z.int().optional(), + token_usage: z.string().optional(), + rpm: z.string().optional(), + concurrency: z.string().optional(), +}) + export const zHealthzReply = z.object({ message: z.string().optional(), status: z.string().optional(), }) -export const zHostEnvironmentConfig = z.object({ - machineId: z.string().optional(), - joinTokenHash: z.string().optional(), +export const zInnerAdmission = z.object({ + marker: z.string().optional(), + concurrencyGroupIds: z.array(z.string()).optional(), }) export const zInnerBatchGetWebAppAccessModesByIdReq = z.object({ @@ -592,50 +261,10 @@ export const zInnerBatchIsUserAllowedToAccessWebAppRes = z.object({ permissions: z.record(z.string(), z.boolean()).optional(), }) -export const zInnerCheckAppDeployAccessReply = z.object({ - allowed: z.boolean().optional(), - matchedPolicyId: z.string().optional(), - matchedScopeType: z.string().optional(), - reason: z.string().optional(), - cacheTtlSeconds: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zInnerCheckAppDeployAccessReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - principalType: z.string().optional(), - principalId: z.string().optional(), -}) - export const zInnerCleanAppRes = z.object({ message: z.string().optional(), }) -export const zInnerGetTokenRouteReply = z.object({ - environmentId: z.string().optional(), - namespace: z.string().optional(), - serviceName: z.string().optional(), - servicePort: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - environmentStatus: z.string().optional(), - appId: z.string().optional(), - tenantId: z.string().optional(), - instanceId: z.string().optional(), - observedReleaseId: z.string().optional(), - instanceStatus: z.string().optional(), -}) - -export const zInnerGetTokenRouteReq = z.object({ - token: z.string().optional(), -}) - export const zInnerGetWebAppAccessModeByCodeRes = z.object({ accessMode: z.string().optional(), }) @@ -648,6 +277,12 @@ export const zInnerIsUserAllowedToAccessWebAppRes = z.object({ result: z.boolean().optional(), }) +export const zInnerReleaseAdmissionRequest = z.object({ + admission: zInnerAdmission.optional(), +}) + +export const zInnerReleaseAdmissionResponse = z.record(z.string(), z.unknown()) + export const zInnerTryAddAccountToDefaultWorkspaceReply = z.object({ workspaceId: z.string().optional(), joined: z.boolean().optional(), @@ -678,48 +313,32 @@ export const zJoinWorkspaceReq = z.object({ role: z.string().optional(), }) -export const zK8sEnvironmentConfig = z.object({ - namespace: z.string().optional(), - apiServer: z.string().optional(), - caBundle: z.string().optional(), - bearerToken: z.string().optional(), +export const zLimitConfig = z.object({ + type: z.int().optional(), + threshold: z.string().optional(), + action: z.int().optional(), + reached: z.boolean().optional(), }) -/** - * Field-level validation only; target (api_server) and RBAC validation happen - * in the bootstrap reconciler. - */ -export const zCreateEnvironmentReq = z.object({ - name: z.string().optional(), - description: z.string().optional(), - mode: z.int().optional(), - backend: z.int().optional(), - k8s: zK8sEnvironmentConfig.optional(), - host: zHostEnvironmentConfig.optional(), +export const zInnerGroupConfig = z.object({ + id: z.string().optional(), + enabled: z.boolean().optional(), + membershipId: z.string().optional(), + limits: z.array(zLimitConfig).optional(), }) -export const zLastError = z.object({ - phase: z.string().optional(), - code: z.string().optional(), - message: z.string().optional(), - releaseId: z.string().optional(), +export const zInnerResolveResponse = z.object({ + appId: z.string().optional(), + groups: z.array(zInnerGroupConfig).optional(), + blocked: z.boolean().optional(), + blockGroupId: z.string().optional(), + blockReason: z.string().optional(), + admission: zInnerAdmission.optional(), }) -export const zAckDeploymentReq = z.object({ - deploymentId: z.string().optional(), - instanceId: z.string().optional(), - expectedVersion: z.string().optional(), - status: z.string().optional(), - observedReleaseId: z.string().optional(), - lastError: zLastError.optional(), -}) - -export const zListDeploymentBindingOptionsReply = z.object({ - slots: z.array(zDeploymentBindingOptionSlot).optional(), -}) - -export const zListDeploymentEnvironmentOptionsReply = z.object({ - environments: z.array(zDeploymentEnvironmentOption).optional(), +export const zListGroupAppsResponse = z.object({ + items: z.array(zGroupAppItem).optional(), + total: z.string().optional(), }) export const zLoginTypesReply = z.object({ @@ -871,31 +490,6 @@ export const zPluginInstallationSettingsReply = z.object({ restrictToMarketplaceOnly: z.boolean().optional(), }) -export const zPreviewReleaseReq = z.object({ - appInstanceId: z.string().optional(), - releaseId: z.string().optional(), -}) - -export const zReleaseRow = z.object({ - id: z.string().optional(), - name: z.string().optional(), - createdAt: z.iso.datetime().optional(), - createdBy: zConsoleUser.optional(), - deployedTo: z.array(zDeployedEnvironment).optional(), -}) - -export const zReleaseRuntimeBinding = z.object({ - kind: z.string().optional(), - label: z.string().optional(), - displayValue: z.string().optional(), - valueType: z.string().optional(), -}) - -export const zPreviewReleaseReply = z.object({ - release: zConsoleRelease.optional(), - bindings: z.array(zReleaseRuntimeBinding).optional(), -}) - export const zResetMemberPasswordReply = z.object({ id: z.string().optional(), password: z.string().optional(), @@ -930,26 +524,56 @@ export const zResetUserPasswordReq = z.object({ id: z.string().optional(), }) -export const zResolveCredentialsReq = z.object({ - instanceId: z.string().optional(), - deploymentId: z.string().optional(), - slots: z.array(z.string()).optional(), +export const zResourceGroupDetail = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + rpm_action: z.int().optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_action: z.int().optional(), + token_quota: z.string().optional(), + token_action: z.int().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), }) -/** - * Exactly one of credential_id / env_var_id is populated; model/plugin slots - * carry credential_id (pool A), env_var slots carry env_var_id (pool B). - * See design §4.1. - */ -export const zResolvedCredential = z.object({ - slot: z.string().optional(), - credentialId: z.string().optional(), - envVarId: z.string().optional(), - value: z.string().optional(), +export const zResourceGroupItem = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + token_quota: z.string().optional(), + token_usage: z.string().optional(), + app_count: z.string().optional(), + rpm_status: z.int().optional(), + conc_status: z.int().optional(), + created_at: z.string().optional(), + updated_at: z.string().optional(), }) -export const zResolveCredentialsReply = z.object({ - resolved: z.array(zResolvedCredential).optional(), +export const zListResourceGroupsResponse = z.object({ + items: z.array(zResourceGroupItem).optional(), + total: z.string().optional(), }) /** @@ -1002,44 +626,6 @@ export const zGetLicenseReply = z.object({ license: zLicenseInfo.optional(), }) -export const zRetryEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zRetryEnvironmentReq = z.object({ - id: z.string().optional(), -}) - -export const zRuntimeEndpoints = z.object({ - run: z.string().optional(), - health: z.string().optional(), -}) - -export const zRuntimeInstanceDetail = z.object({ - deploymentName: z.string().optional(), - replicas: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - runtimeMode: z.string().optional(), - runtimeNote: z.string().optional(), - endpoints: zRuntimeEndpoints.optional(), - bindings: z.array(zReleaseRuntimeBinding).optional(), -}) - -export const zRuntimeInstanceRow = z.object({ - id: z.string().optional(), - environment: zConsoleEnvironment.optional(), - status: z.string().optional(), - currentRelease: zConsoleRelease.optional(), - detail: zRuntimeInstanceDetail.optional(), -}) - -export const zListRuntimeInstancesReply = z.object({ - data: z.array(zRuntimeInstanceRow).optional(), -}) - /** * SSO Configuration messages */ @@ -1102,8 +688,21 @@ export const zScimSettings = z.object({ lastSyncTime: z.iso.datetime().optional(), }) -export const zSearchAccessSubjectsReply = z.object({ - data: z.array(zAccessSubjectDisplay).optional(), +export const zSearchAppItem = z.object({ + app_id: z.string().optional(), + app_name: z.string().optional(), + workspace_id: z.string().optional(), + workspace_name: z.string().optional(), + app_status: z.int().optional(), + icon: z.string().optional(), + icon_type: z.string().optional(), + icon_background: z.string().optional(), + created_by_name: z.string().optional(), +}) + +export const zSearchAppsResponse = z.object({ + items: z.array(zSearchAppItem).optional(), + total: z.string().optional(), }) export const zSecretKey = z.object({ @@ -1122,25 +721,6 @@ export const zSetDefaultWorkspaceReq = z.object({ id: z.string().optional(), }) -export const zStatusCount = z.object({ - status: z.string().optional(), - count: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -export const zAppInstanceCard = z.object({ - id: z.string().optional(), - name: z.string().optional(), - icon: z.string().optional(), - mode: z.string().optional(), - sourceAppName: z.string().optional(), - statuses: z.array(zStatusCount).optional(), - lastDeployedAt: z.iso.datetime().optional(), -}) - export const zSubjectAccountData = z.object({ id: z.string().optional(), name: z.string().optional(), @@ -1214,38 +794,10 @@ export const zTestConnectionReply = z.object({ error: z.string().optional(), }) -export const zTestEnvironmentConnectionReply = z.object({ - ok: z.boolean().optional(), - reachableServerVersion: z.string().optional(), - namespaceExists: z.boolean().optional(), - missingPermissions: z.array(z.string()).optional(), - error: z.string().optional(), - probedAt: z.iso.datetime().optional(), -}) - -export const zTestEnvironmentConnectionReq = z.object({ - id: z.string().optional(), -}) - export const zToggleEndpointRequest = z.object({ enabled: z.boolean().optional(), }) -export const zUndeployRuntimeInstanceReply = z.object({ - deploymentId: z.string().optional(), - status: z.string().optional(), -}) - -export const zUndeployRuntimeInstanceReq = z.object({ - appInstanceId: z.string().optional(), - runtimeInstanceId: z.string().optional(), -}) - -export const zUpdateAccessChannelsReq = z.object({ - appInstanceId: z.string().optional(), - enabled: z.boolean().optional(), -}) - export const zUpdateAccessModeReq = z.object({ appId: z.string().optional(), accessMode: z.string().optional(), @@ -1255,16 +807,6 @@ export const zUpdateAccessModeRes = z.object({ message: z.string().optional(), }) -export const zUpdateAppInstanceReply = z.object({ - appInstanceId: z.string().optional(), -}) - -export const zUpdateAppInstanceReq = z.object({ - appInstanceId: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zUpdateBrandingInfoReq = z.object({ enabled: z.boolean().optional(), applicationTitle: z.string().optional(), @@ -1273,36 +815,6 @@ export const zUpdateBrandingInfoReq = z.object({ favicon: z.string().optional(), }) -export const zUpdateDeveloperApiReply = z.object({ - developerApi: zDeveloperApiAccess.optional(), -}) - -export const zUpdateDeveloperApiReq = z.object({ - appInstanceId: z.string().optional(), - enabled: z.boolean().optional(), -}) - -export const zUpdateEnvironmentAccessPolicyReply = z.object({ - permission: zEnvironmentAccessRow.optional(), -}) - -export const zUpdateEnvironmentAccessPolicyReq = z.object({ - appInstanceId: z.string().optional(), - environmentId: z.string().optional(), - accessMode: z.string().optional(), - subjects: z.array(zAccessSubject).optional(), -}) - -export const zUpdateEnvironmentReply = z.object({ - environment: zEnvironment.optional(), -}) - -export const zUpdateEnvironmentReq = z.object({ - id: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), -}) - export const zUpdateGroupSubjectsReq = z.object({ groupId: z.string().optional(), subjects: z.array(zSubject).optional(), @@ -1386,6 +898,27 @@ export const zUpdatePluginInstallationSettingsRequest = z.object({ restrictToMarketplaceOnly: z.boolean().optional(), }) +export const zUpdateResourceGroupRequest = z.object({ + id: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + enabled: z.boolean().optional(), + rpm_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + rpm_action: z.int().optional(), + concurrency_limit: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + concurrency_action: z.int().optional(), + token_quota: z.string().optional(), + token_action: z.int().optional(), +}) + export const zUpdateUserReply = z.object({ account: zAccountDetail.optional(), }) @@ -1430,27 +963,6 @@ export const zUpdateWorkspaceReq = z.object({ status: z.string().optional(), }) -export const zWebAppAccessRow = z.object({ - environment: zConsoleEnvironment.optional(), - url: z.string().optional(), -}) - -export const zAccessChannels = z.object({ - enabled: z.boolean().optional(), - webappRows: z.array(zWebAppAccessRow).optional(), - cli: zCliAccess.optional(), -}) - -export const zGetAppInstanceAccessReply = z.object({ - permissions: z.array(zEnvironmentAccessRow).optional(), - accessChannels: zAccessChannels.optional(), - developerApi: zDeveloperApiAccess.optional(), -}) - -export const zUpdateAccessChannelsReply = z.object({ - accessChannels: zAccessChannels.optional(), -}) - export const zWebAppAuthInfo = z.object({ allowSso: z.boolean().optional(), allowEmailCodeLogin: z.boolean().optional(), @@ -1572,27 +1084,11 @@ export const zPagination = z.object({ .optional(), }) -export const zListAppInstancesReply = z.object({ - filters: z.array(zEnvironmentFilter).optional(), - data: z.array(zAppInstanceCard).optional(), - pagination: zPagination.optional(), -}) - -export const zListEnvironmentsReply = z.object({ - data: z.array(zEnvironment).optional(), - pagination: zPagination.optional(), -}) - export const zListMembersReply = z.object({ data: z.array(zAccountDetail).optional(), pagination: zPagination.optional(), }) -export const zListReleasesReply = z.object({ - data: z.array(zReleaseRow).optional(), - pagination: zPagination.optional(), -}) - export const zListSecretKeysReply = z.object({ data: z.array(zSecretKey).optional(), pagination: zPagination.optional(), @@ -1608,271 +1104,6 @@ export const zListWorkspacesReply = z.object({ pagination: zPagination.optional(), }) -export const zEnterpriseAppDeployConsoleListAppInstancesQuery = z.object({ - environmentId: z.string().optional(), - notDeployed: z.boolean().optional(), - query: z.string().optional(), - pageNumber: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - resultsPerPage: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListAppInstancesResponse = zListAppInstancesReply - -export const zEnterpriseAppDeployConsoleCreateAppInstanceBody = zCreateAppInstanceReq - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateAppInstanceResponse = zCreateAppInstanceReply - -export const zEnterpriseAppDeployConsoleDeleteAppInstancePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleDeleteAppInstanceResponse = zDeleteAppInstanceReply - -export const zEnterpriseAppDeployConsoleUpdateAppInstanceBody = zUpdateAppInstanceReq - -export const zEnterpriseAppDeployConsoleUpdateAppInstancePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateAppInstanceResponse = zUpdateAppInstanceReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceAccessPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceAccessResponse = zGetAppInstanceAccessReply - -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsBody = zUpdateAccessChannelsReq - -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateAccessChannelsResponse = zUpdateAccessChannelsReply - -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsPath = z.object({ - appInstanceId: z.string(), -}) - -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsQuery = z.object({ - keyword: z.string().optional(), - subjectTypes: z.array(z.string()).optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleSearchAccessSubjectsResponse = zSearchAccessSubjectsReply - -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyBody = zCreateDeveloperApiKeyReq - -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateDeveloperApiKeyResponse = zCreateDeveloperApiKeyReply - -export const zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyPath = z.object({ - appInstanceId: z.string(), - apiKeyId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleDeleteDeveloperApiKeyResponse = zDeleteDeveloperApiKeyReply - -export const zEnterpriseAppDeployConsoleListDeploymentBindingOptionsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListDeploymentBindingOptionsResponse - = zListDeploymentBindingOptionsReply - -export const zEnterpriseAppDeployConsoleCreateDeploymentBody = zCreateDeploymentReq - -export const zEnterpriseAppDeployConsoleCreateDeploymentPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateDeploymentResponse = zCreateDeploymentReply - -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiBody = zUpdateDeveloperApiReq - -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateDeveloperApiResponse = zUpdateDeveloperApiReply - -export const zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyPath = z.object({ - appInstanceId: z.string(), - environmentId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetEnvironmentAccessPolicyResponse - = zGetEnvironmentAccessPolicyReply - -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyBody - = zUpdateEnvironmentAccessPolicyReq - -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyPath = z.object({ - appInstanceId: z.string(), - environmentId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUpdateEnvironmentAccessPolicyResponse - = zUpdateEnvironmentAccessPolicyReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceOverviewPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceOverviewResponse - = zGetAppInstanceOverviewReply - -export const zEnterpriseAppDeployConsoleListReleasesPath = z.object({ - appInstanceId: z.string(), -}) - -export const zEnterpriseAppDeployConsoleListReleasesQuery = z.object({ - pageNumber: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), - resultsPerPage: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) - .optional(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListReleasesResponse = zListReleasesReply - -export const zEnterpriseAppDeployConsoleCreateReleaseBody = zCreateReleaseReq - -export const zEnterpriseAppDeployConsoleCreateReleasePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCreateReleaseResponse = zCreateReleaseReply - -export const zEnterpriseAppDeployConsolePreviewReleaseBody = zPreviewReleaseReq - -export const zEnterpriseAppDeployConsolePreviewReleasePath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsolePreviewReleaseResponse = zPreviewReleaseReply - -export const zEnterpriseAppDeployConsoleListRuntimeInstancesPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListRuntimeInstancesResponse = zListRuntimeInstancesReply - -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentBody = zCancelRuntimeDeploymentReq - -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentPath = z.object({ - appInstanceId: z.string(), - runtimeInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleCancelRuntimeDeploymentResponse - = zCancelRuntimeDeploymentReply - -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstanceBody = zUndeployRuntimeInstanceReq - -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstancePath = z.object({ - appInstanceId: z.string(), - runtimeInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleUndeployRuntimeInstanceResponse - = zUndeployRuntimeInstanceReply - -export const zEnterpriseAppDeployConsoleGetAppInstanceSettingsPath = z.object({ - appInstanceId: z.string(), -}) - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleGetAppInstanceSettingsResponse - = zGetAppInstanceSettingsReply - -/** - * OK - */ -export const zEnterpriseAppDeployConsoleListDeploymentEnvironmentOptionsResponse - = zListDeploymentEnvironmentOptionsReply - /** * OK */ diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index 9524394214..6eadd200f0 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -9,6 +9,7 @@ Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headles - No imports from `web/`. No dependencies on next / i18next / ky / jotai / zustand. - One component per folder: `src//index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./` subpath to `package.json#exports`. - Props pattern: `Omit & VariantProps & { /* custom */ }`. +- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props. - When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath. ## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover diff --git a/web/AGENTS.md b/web/AGENTS.md index 5df19d015c..a2ca3857e7 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -9,18 +9,6 @@ - In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`. - Do not introduce overlay imports from `@/app/components/base/*`; when touching existing callers, migrate them. -## Query & Mutation (Mandatory) - -- `frontend-query-mutation` is the source of truth for Dify frontend contracts, query and mutation call-site patterns, conditional queries, invalidation, and mutation error handling. - -## SVG Icons (Mandatory) - -- New custom SVG icons must be added under `../packages/iconify-collections/assets/...`. -- Run `pnpm --filter @dify/iconify-collections generate` and consume generated icons with Tailwind `i-custom-*` classes. -- Restart the web dev server after regenerating icons because Tailwind loads the custom icon collection at startup. -- Do not add new generated React icon components or JSON files under `app/components/base/icons/src/...`. -- See `../packages/iconify-collections/README.md` for the full workflow. - ## Design Token Mapping - When translating Figma designs to code, read `../packages/dify-ui/AGENTS.md` for the Figma `--radius/*` token to Tailwind `rounded-*` class mapping. The two scales are offset by one step. diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index 079ea9949a..a487f102dd 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -79,6 +79,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + useQuery: () => ({ + data: [], + }), useInfiniteQuery: () => ({ data: { pages: mockPages }, isLoading: mockIsLoading, diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx index 6295d2dc00..ef813ee4bc 100644 --- a/web/__tests__/base/notion-page-selector-flow.test.tsx +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -111,7 +111,7 @@ describe('Base Notion Page Selector Flow', () => { await user.type(screen.getByTestId('notion-search-input'), 'missing-page') expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() - await user.click(screen.getByTestId('notion-search-input-clear')) + await user.click(screen.getByRole('button', { name: 'common.operation.clear' })) expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument() await user.click(screen.getByTestId('notion-page-preview-root-1')) @@ -134,7 +134,7 @@ describe('Base Notion Page Selector Flow', () => { expect(onSelectCredential).toHaveBeenCalledWith('c1') - await user.click(screen.getByTestId('notion-credential-selector-btn')) + await user.click(screen.getByRole('combobox', { name: /Workspace 1/ })) await user.click(screen.getByTestId('notion-credential-item-c2')) expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' }) diff --git a/web/__tests__/share/text-generation-run-once-flow.test.tsx b/web/__tests__/share/text-generation-run-once-flow.test.tsx index 2a5d1b882c..1471effa2d 100644 --- a/web/__tests__/share/text-generation-run-once-flow.test.tsx +++ b/web/__tests__/share/text-generation-run-once-flow.test.tsx @@ -119,7 +119,7 @@ describe('RunOnce – integration flow', () => { fireEvent.change(screen.getByPlaceholderText('Bio'), { target: { value: 'Hello' } }) // Phase 3 – submit - fireEvent.click(screen.getByTestId('run-button')) + fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' })) expect(onSend).toHaveBeenCalledTimes(1) // Phase 4 – simulate "running" state @@ -132,7 +132,7 @@ describe('RunOnce – integration flow', () => { />, ) - const stopBtn = screen.getByTestId('stop-button') + const stopBtn = screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' }) expect(stopBtn).toBeInTheDocument() fireEvent.click(stopBtn) expect(onStop).toHaveBeenCalledTimes(1) @@ -145,7 +145,7 @@ describe('RunOnce – integration flow', () => { runControl={{ onStop, isStopping: true }} />, ) - expect(screen.getByTestId('stop-button')).toBeDisabled() + expect(screen.getByRole('button', { name: 'share.generation.stopRun:{"defaultValue":"Stop Run"}' })).toBeDisabled() }) it('clear resets all field types and allows re-submit', async () => { @@ -174,7 +174,7 @@ describe('RunOnce – integration flow', () => { // Re-fill and submit fireEvent.change(screen.getByPlaceholderText('Question'), { target: { value: 'New' } }) - fireEvent.click(screen.getByTestId('run-button')) + fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' })) expect(onSend).toHaveBeenCalledTimes(1) }) @@ -212,7 +212,7 @@ describe('RunOnce – integration flow', () => { fireEvent.change(screen.getByPlaceholderText('Text'), { target: { value: 'hello' } }) fireEvent.change(screen.getByTestId('code-editor'), { target: { value: '{"a":1}' } }) - fireEvent.click(screen.getByTestId('run-button')) + fireEvent.click(screen.getByRole('button', { name: 'share.generation.run' })) expect(onSend).toHaveBeenCalledTimes(1) }) }) diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx new file mode 100644 index 0000000000..0d7f01a210 --- /dev/null +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/__tests__/layout-main.spec.tsx @@ -0,0 +1,151 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { usePathname, useRouter } from '@/next/navigation' +import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' +import DatasetDetailLayout from '../layout-main' + +const mockReplace = vi.fn() +const mockSetAppSidebarExpand = vi.fn() + +vi.mock('@/next/navigation', () => ({ + usePathname: vi.fn(), + useRouter: vi.fn(), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetDetail: vi.fn(), + useDatasetRelatedApps: vi.fn(), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({ + setAppSidebarExpand: mockSetAppSidebarExpand, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + isCurrentWorkspaceDatasetOperator: false, + }), +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: undefined, + }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => 'desktop', + MediaType: { + mobile: 'mobile', + }, +})) + +vi.mock('@/hooks/use-document-title', () => ({ + default: vi.fn(), +})) + +vi.mock('@/app/components/app-sidebar', () => ({ + default: () =>
{dragging &&
} @@ -113,9 +119,14 @@ const CSVUploader: FC = ({
-
- -
+
)} diff --git a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx index 0f6c27fd5a..7f1905c025 100644 --- a/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx +++ b/web/app/components/app/annotation/batch-add-annotation-modal/index.tsx @@ -91,9 +91,14 @@ const BatchModal: FC = ({
{t('batchModal.title', { ns: 'appAnnotation' })}
-
- -
+ { expect(screen.getByText(baseMember.name)).toBeInTheDocument() }) - const groupItem = screen.getByText(baseGroup.name).closest('div') - const groupRemove = groupItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + const groupRemove = screen.getAllByRole('button', { name: /operation\.remove$/ })[0]! + fireEvent.click(groupRemove) await waitFor(() => { expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() }) - const memberItem = screen.getByText(baseMember.name).closest('div') - const memberRemove = memberItem?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + const memberRemove = screen.getAllByRole('button', { name: /operation\.remove$/ })[0]! + fireEvent.click(memberRemove) await waitFor(() => { @@ -254,9 +254,7 @@ describe('AddMemberOrGroupDialog', () => { await user.click(expandButton) expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) - const memberLabel = screen.getByText(baseMember.name) - const memberCheckbox = memberLabel.parentElement?.previousElementSibling as HTMLElement - fireEvent.click(memberCheckbox) + await user.click(screen.getByRole('option', { name: /Member One/ })) expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) }) @@ -277,13 +275,13 @@ describe('AddMemberOrGroupDialog', () => { await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group') expect(document.querySelector('.spin-animation')).toBeInTheDocument() - const groupCheckbox = screen.getByText(baseGroup.name).closest('div')?.previousElementSibling as HTMLElement - fireEvent.click(groupCheckbox) - fireEvent.click(groupCheckbox) + const groupOption = screen.getByRole('option', { name: /Group One/ }) + fireEvent.click(groupOption) + fireEvent.click(groupOption) - const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement - fireEvent.click(memberCheckbox) - fireEvent.click(memberCheckbox) + const memberOption = screen.getByRole('option', { name: /Member One/ }) + fireEvent.click(memberOption) + fireEvent.click(memberOption) fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers')) @@ -307,7 +305,7 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') }) }) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index 725b121d30..d34756e85e 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -1,5 +1,5 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import useAccessControlStore from '@/context/access-control-store' import { SubjectType } from '@/models/access-control' @@ -106,8 +106,7 @@ describe('AddMemberOrGroupDialog', () => { expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) - const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement - fireEvent.click(memberCheckbox) + await user.click(screen.getByRole('option', { name: /Member One/ })) expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) }) @@ -125,6 +124,31 @@ describe('AddMemberOrGroupDialog', () => { await user.click(screen.getByText('common.operation.add')) - expect(screen.getByText('app.accessControlDialog.operateGroupAndMember.noResult')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') + }) + + it('should keep breadcrumbs visible when the current group has no candidates', async () => { + useAccessControlStore.setState({ + selectedGroupsForBreadcrumb: [baseGroup], + }) + mockUseSearchForWhiteListCandidates.mockReturnValue({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [{ currPage: 1, subjects: [], hasMore: false }] }, + }) + + const user = userEvent.setup() + render() + + await user.click(screen.getByText('common.operation.add')) + + expect(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })).toBeInTheDocument() + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('app.accessControlDialog.operateGroupAndMember.noResult') + + await user.click(screen.getByRole('button', { name: 'app.accessControlDialog.operateGroupAndMember.allMembers' })) + + expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index 7b198c4e66..e763521940 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -86,11 +86,13 @@ describe('SpecificGroupsOrMembers', () => { expect(screen.getByText(baseMember.name)).toBeInTheDocument() }) - const groupRemove = screen.getByText(baseGroup.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement + const removeButtons = screen.getAllByRole('button', { name: /operation\.remove$/ }) + const groupRemove = removeButtons[0]! + const memberRemove = removeButtons[1]! + fireEvent.click(groupRemove) expect(useAccessControlStore.getState().specificGroups).toEqual([]) - const memberRemove = screen.getByText(baseMember.name).closest('div')?.querySelector('.h-4.w-4.cursor-pointer') as HTMLElement fireEvent.click(memberRemove) expect(useAccessControlStore.getState().specificMembers).toEqual([]) }) diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx index 38f9c2ab50..1e3a992136 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 @@ -1,110 +1,207 @@ 'use client' +import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox' import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control' -import { FloatingOverlay } from '@floating-ui/react' import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxItemText, + ComboboxList, + ComboboxStatus, + ComboboxTrigger, +} from '@langgenius/dify-ui/combobox' import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react' import { useDebounce } from 'ahooks' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from '@/context/app-context' import { SubjectType } from '@/models/access-control' import { useSearchForWhiteListCandidates } from '@/service/access-control' import useAccessControlStore from '../../../../context/access-control-store' -import Checkbox from '../../base/checkbox' -import Input from '../../base/input' import Loading from '../../base/loading' export default function AddMemberOrGroupDialog() { const { t } = useTranslation() const [open, setOpen] = useState(false) const [keyword, setKeyword] = useState('') + const scrollRootRef = useRef(null) + const anchorRef = useRef(null) + const specificGroups = useAccessControlStore(s => s.specificGroups) + const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) + const specificMembers = useAccessControlStore(s => s.specificMembers) + const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) const debouncedKeyword = useDebounce(keyword, { wait: 500 }) const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1] const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open) - const handleKeywordChange = (e: React.ChangeEvent) => { - setKeyword(e.target.value) - } + const pages = data?.pages ?? [] + const subjects = pages.flatMap(page => page.subjects ?? []) + const selectedSubjects = [ + ...specificGroups.map(groupToSubject), + ...specificMembers.map(memberToSubject), + ] + const hasResults = pages.length > 0 && subjects.length > 0 + const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0 + const hasMore = pages[pages.length - 1]?.hasMore ?? false - const anchorRef = useRef(null) useEffect(() => { - const hasMore = data?.pages?.[0]?.hasMore ?? false let observer: IntersectionObserver | undefined if (anchorRef.current) { observer = new IntersectionObserver((entries) => { if (entries[0]!.isIntersecting && !isLoading && hasMore) fetchNextPage() - }, { rootMargin: '20px' }) + }, { root: scrollRootRef.current, rootMargin: '20px' }) observer.observe(anchorRef.current) } return () => observer?.disconnect() - }, [isLoading, fetchNextPage, anchorRef, data]) + }, [isLoading, fetchNextPage, hasMore]) + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) + setKeyword('') + + setOpen(nextOpen) + } + + const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => { + if (details.reason !== 'item-press') + setKeyword(inputValue) + } + + const handleValueChange = (nextSubjects: Subject[]) => { + const nextGroups: AccessControlGroup[] = [] + const nextMembers: AccessControlAccount[] = [] + + for (const subject of nextSubjects) { + if (subject.subjectType === SubjectType.GROUP) + nextGroups.push((subject as SubjectGroup).groupData) + else + nextMembers.push((subject as SubjectAccount).accountData) + } + + setSpecificGroups(nextGroups) + setSpecificMembers(nextMembers) + } return ( - - - - {t('operation.add', { ns: 'common' })} - - )} - /> - {open && } - + multiple + open={open} + value={selectedSubjects} + inputValue={keyword} + items={subjects} + itemToStringLabel={getSubjectLabel} + itemToStringValue={getSubjectValue} + isItemEqualToValue={isSameSubject} + filter={null} + onOpenChange={handleOpenChange} + onInputValueChange={handleInputValueChange} + onValueChange={handleValueChange} + > + + + -
+
- + +
- { - isLoading - ?
- : (data?.pages?.length ?? 0) > 0 - ? ( - <> -
- -
-
- {renderGroupOrMember(data?.pages ?? [])} + {isLoading + ? ( + + + + ) + : ( + <> + {shouldShowBreadcrumb && ( +
+ +
+ )} + {hasResults + ? ( + <> + + {(subject: Subject) => } + {isFetchingNextPage && } -
-
- - ) - : ( -
- {t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })} -
- ) - } +
+ + ) + : ( + + {t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })} + + )} + + )}
- - + + ) } -type GroupOrMemberData = { subjects: Subject[], currPage: number }[] -function renderGroupOrMember(data: GroupOrMemberData) { - return data?.map((page) => { - return ( -
- {page.subjects?.map((item, index) => { - if (item.subjectType === SubjectType.GROUP) - return - return - })} -
- ) - }) ?? null +function groupToSubject(group: AccessControlGroup): SubjectGroup { + return { + subjectId: group.id, + subjectType: SubjectType.GROUP, + groupData: group, + } +} + +function memberToSubject(member: AccessControlAccount): SubjectAccount { + return { + subjectId: member.id, + subjectType: SubjectType.ACCOUNT, + accountData: member, + } +} + +function getSubjectLabel(subject: Subject) { + if (subject.subjectType === SubjectType.GROUP) + return (subject as SubjectGroup).groupData.name + + return (subject as SubjectAccount).accountData.name +} + +function getSubjectValue(subject: Subject) { + return `${subject.subjectType}:${subject.subjectId}` +} + +function isSameSubject(item: Subject, value: Subject) { + return item.subjectId === value.subjectId && item.subjectType === value.subjectType +} + +function SubjectItem({ subject }: { subject: Subject }) { + if (subject.subjectType === SubjectType.GROUP) + return + + return } function SelectedGroupsBreadCrumb() { @@ -112,21 +209,47 @@ function SelectedGroupsBreadCrumb() { const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) const { t } = useTranslation() - const handleBreadCrumbClick = useCallback((index: number) => { + const handleBreadCrumbClick = (index: number) => { const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1) setSelectedGroupsForBreadcrumb(newGroups) - }, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb]) - const handleReset = useCallback(() => { + } + const handleReset = () => { setSelectedGroupsForBreadcrumb([]) - }, [setSelectedGroupsForBreadcrumb]) + } + const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0 + return (
- 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })} + {hasBreadcrumb + ? ( + + ) + : ( + {t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })} + )} {selectedGroupsForBreadcrumb.map((group, index) => { + const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1 + return (
/ - handleBreadCrumbClick(index)}>{group.name} + {isLastGroup + ? {group.name} + : ( + + )}
) })} @@ -136,104 +259,111 @@ function SelectedGroupsBreadCrumb() { type GroupItemProps = { group: AccessControlGroup + subject: Subject } -function GroupItem({ group }: GroupItemProps) { +function GroupItem({ group, subject }: GroupItemProps) { const { t } = useTranslation() const specificGroups = useAccessControlStore(s => s.specificGroups) - const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups) const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb) const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb) const isChecked = specificGroups.some(g => g.id === group.id) - const handleCheckChange = useCallback(() => { - if (!isChecked) { - const newGroups = [...specificGroups, group] - setSpecificGroups(newGroups) - } - else { - const newGroups = specificGroups.filter(g => g.id !== group.id) - setSpecificGroups(newGroups) - } - }, [specificGroups, setSpecificGroups, group, isChecked]) - const handleExpandClick = useCallback(() => { + const handleExpandClick = () => { setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group]) - }, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group]) + } + return ( - - -
-
-
- +
+ + + +
+
+
-
-

{group.name}

-

{group.groupSize}

-
+ {group.name} + {group.groupSize} + + - +
) } type MemberItemProps = { member: AccessControlAccount + subject: Subject } -function MemberItem({ member }: MemberItemProps) { +function MemberItem({ member, subject }: MemberItemProps) { const currentUser = useSelector(s => s.userProfile) const { t } = useTranslation() const specificMembers = useAccessControlStore(s => s.specificMembers) - const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers) const isChecked = specificMembers.some(m => m.id === member.id) - const handleCheckChange = useCallback(() => { - if (!isChecked) { - const newMembers = [...specificMembers, member] - setSpecificMembers(newMembers) - } - else { - const newMembers = specificMembers.filter(m => m.id !== member.id) - setSpecificMembers(newMembers) - } - }, [specificMembers, setSpecificMembers, member, isChecked]) return ( - - -
+ + +
-

{member.name}

+ {member.name} {currentUser.email === member.email && ( -

+ ( {t('you', { ns: 'common' })} ) -

+ )} -
-

{member.email}

+ + {member.email}
) } type BaseItemProps = { className?: string + subject: Subject children: React.ReactNode } -function BaseItem({ children, className }: BaseItemProps) { +function BaseItem({ children, className, subject }: BaseItemProps) { return ( -
+ {children} -
+ + ) +} + +function SelectionBox({ checked }: { checked: boolean }) { + return ( +
) } diff --git a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx index 942a199a87..252eddb47e 100644 --- a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx @@ -103,6 +103,22 @@ describe('VersionInfoModal', () => { expect(handleClose).toHaveBeenCalledTimes(1) }) + it('should close when the close button is clicked', () => { + const handleClose = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'operation.close' })) + + expect(handleClose).toHaveBeenCalledTimes(1) + }) + it('should validate release note length and clear previous errors before publishing', () => { const handlePublish = vi.fn() const handleClose = vi.fn() diff --git a/web/app/components/app/app-publisher/version-info-modal.tsx b/web/app/components/app/app-publisher/version-info-modal.tsx index 264975a08b..4e5493d1b2 100644 --- a/web/app/components/app/app-publisher/version-info-modal.tsx +++ b/web/app/components/app/app-publisher/version-info-modal.tsx @@ -79,9 +79,14 @@ const VersionInfoModal: FC = ({
{versionInfo?.marked_name ? t('versionHistory.editVersionInfo', { ns: 'workflow' }) : t('versionHistory.nameThisVersion', { ns: 'workflow' })}
-
- -
+
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 5fd394ad45..418982fcb6 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 @@ -5,11 +5,6 @@ import type { PromptRole, PromptVariable } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' import { RiDeleteBinLine, RiErrorWarningFill, @@ -25,6 +20,7 @@ import { Copy, CopyCheck, } from '@/app/components/base/icons/src/vender/line/files' +import { Infotip } from '@/app/components/base/infotip' import PromptEditor from '@/app/components/base/prompt-editor' import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' import ConfigContext from '@/context/debug-configuration' @@ -183,18 +179,13 @@ const AdvancedPromptInput: FC = ({
{t('pageTitle.line1', { ns: 'appDebug' })}
- - - )} - /> - -
- {t('promptTip', { ns: 'appDebug' })} -
-
-
+ + {t('promptTip', { ns: 'appDebug' })} +
)}
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 2935504f15..63cb16083e 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 @@ -5,11 +5,6 @@ import type { PromptVariable } from '@/models/debug' import type { GenRes } from '@/service/debug' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/function' import { produce } from 'immer' @@ -21,6 +16,7 @@ import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/confi import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn' import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res' import { useFeaturesStore } from '@/app/components/base/features/hooks' +import { Infotip } from '@/app/components/base/infotip' import PromptEditor from '@/app/components/base/prompt-editor' import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block' import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block' @@ -183,18 +179,13 @@ const Prompt: FC = ({
{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}
{!readonly && ( - - - )} - /> - -
- {t('promptTip', { ns: 'appDebug' })} -
-
-
+ + {t('promptTip', { ns: 'appDebug' })} + )}
diff --git a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx index 51683ad948..604db8288a 100644 --- a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx @@ -233,9 +233,7 @@ describe('ConfigVar', () => { const item = screen.getByTitle('name · Name') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - expect(actionButtons).toHaveLength(2) - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const editDialog = await screen.findByRole('dialog') const saveButton = within(editDialog).getByRole('button', { name: 'common.operation.save' }) @@ -259,9 +257,7 @@ describe('ConfigVar', () => { const item = screen.getByTitle('first · First') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - expect(actionButtons).toHaveLength(2) - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder') fireEvent.change(inputs[0]!, { target: { value: 'second' } }) @@ -285,9 +281,7 @@ describe('ConfigVar', () => { const item = screen.getByTitle('first · First') const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - expect(actionButtons).toHaveLength(2) - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const inputs = await screen.findAllByPlaceholderText('appDebug.variableConfig.inputPlaceholder') fireEvent.change(inputs[1]!, { target: { value: 'Second' } }) @@ -318,7 +312,7 @@ describe('ConfigVar', () => { onPromptVariablesChange, }) - const removeBtn = screen.getByTestId('var-item-delete-btn') + const removeBtn = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(removeBtn) expect(onPromptVariablesChange).toHaveBeenCalledWith([]) @@ -343,7 +337,7 @@ describe('ConfigVar', () => { }, ) - const deleteBtn = screen.getByTestId('var-item-delete-btn') + const deleteBtn = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(deleteBtn) // confirmation modal should show up fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) @@ -411,8 +405,7 @@ describe('ConfigVar', () => { const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0] @@ -460,8 +453,7 @@ describe('ConfigVar', () => { const itemContainer = item.closest('div.group') expect(itemContainer).not.toBeNull() - const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6') - fireEvent.click(actionButtons[0]!) + fireEvent.click(within(itemContainer as HTMLElement).getByRole('button', { name: 'common.operation.edit' })) const modalState = setShowExternalDataToolModal.mock.calls.at(-1)?.[0] diff --git a/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx index aae00bb2b7..6f4fe5f11a 100644 --- a/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/var-item.spec.tsx @@ -39,7 +39,7 @@ describe('VarItem', () => { />, ) - fireEvent.click(screen.getByTestId('var-item-delete-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.delete' })) expect(onRemove).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx index 094a293943..a0679a0376 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index.spec.tsx @@ -71,6 +71,25 @@ describe('ConfigModal', () => { }), undefined) }) + it('should keep scrolling inside the form body so scrollbars do not cover dialog corners', () => { + render( + , + ) + + const dialog = screen.getByRole('dialog') + const scrollArea = screen.getByTestId('config-modal-scroll-area') + + expect(dialog).toHaveClass('overflow-hidden!') + expect(scrollArea).toHaveClass('overflow-y-auto') + expect(scrollArea).toHaveClass('overflow-x-hidden') + }) + it('should block save when the label is missing', () => { render( = ({ onClose() }} > - - + + {t(`variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`, { ns: 'appDebug' })} -
+
= ({ t={t} />
- +
+ +
) diff --git a/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx index 337b3bfe1c..24517eb341 100644 --- a/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-select/__tests__/index.spec.tsx @@ -44,12 +44,7 @@ describe('ConfigSelect Component', () => { it('handles option deletion', () => { render() - const optionContainer = screen.getByDisplayValue('Option 1').closest('div') - const deleteButton = optionContainer?.querySelector('div[role="button"]') - - if (!deleteButton) - return - fireEvent.click(deleteButton) + fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.delete' })[0]!) expect(defaultProps.onChange).toHaveBeenCalledWith(['Option 2']) }) @@ -86,7 +81,7 @@ describe('ConfigSelect Component', () => { it('applies delete hover styles', () => { render() const optionContainer = screen.getByDisplayValue('Option 1').closest('div') - const deleteButton = optionContainer?.querySelector('div[role="button"]') + const deleteButton = screen.getAllByRole('button', { name: 'common.operation.delete' })[0] if (!deleteButton) return diff --git a/web/app/components/app/configuration/config-var/config-select/index.tsx b/web/app/components/app/configuration/config-var/config-select/index.tsx index 24bc3b4a06..42878852d9 100644 --- a/web/app/components/app/configuration/config-var/config-select/index.tsx +++ b/web/app/components/app/configuration/config-var/config-select/index.tsx @@ -67,9 +67,10 @@ const ConfigSelect: FC = ({ onFocus={() => setFocusID(index)} onBlur={() => setFocusID(null)} /> -
{ onChange(options.filter((_, i) => index !== i)) setDeletingID(null) @@ -77,8 +78,8 @@ const ConfigSelect: FC = ({ onMouseEnter={() => setDeletingID(index)} onMouseLeave={() => setDeletingID(null)} > - -
+
))} diff --git a/web/app/components/app/configuration/config-var/var-item.tsx b/web/app/components/app/configuration/config-var/var-item.tsx index 80c0bf6ac6..17568683d2 100644 --- a/web/app/components/app/configuration/config-var/var-item.tsx +++ b/web/app/components/app/configuration/config-var/var-item.tsx @@ -9,6 +9,7 @@ import { } from '@remixicon/react' import * as React from 'react' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { BracketsX as VarIcon } from '@/app/components/base/icons/src/vender/line/development' import IconTypeIcon from './input-type-icon' @@ -36,6 +37,7 @@ const VarItem: FC = ({ onRemove, canDrag, }) => { + const { t } = useTranslation() const [isDeleting, setIsDeleting] = useState(false) return ( @@ -58,21 +60,24 @@ const VarItem: FC = ({
-
- -
- +
diff --git a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx index d668737a0a..628e34bbe0 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/__tests__/index.spec.tsx @@ -368,7 +368,7 @@ describe('AgentTools', () => { it('should remove tool when delete action is clicked', async () => { const { getModelConfig } = renderAgentTools() - const deleteButton = screen.getByTestId('delete-removed-tool') + const deleteButton = screen.getByRole('button', { name: /operation\.delete/i }) if (!deleteButton) throw new Error('Delete button not found') await userEvent.click(deleteButton) 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 3242bcdcf8..8ad9ad1f8f 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 @@ -96,6 +96,7 @@ const AgentTools: FC = () => { } const [isDeleting, setIsDeleting] = useState(-1) + const getDeleteToolLabel = (tool: AgentTool) => `${t('operation.delete', { ns: 'common' })} ${tool.tool_label || tool.tool_name}` const getToolValue = (tool: ToolDefaultValue) => { const currToolInCollections = collectionList.find(c => c.id === tool.provider_id) const currToolWithConfigs = currToolInCollections?.tools.find(t => t.name === tool.tool_name) @@ -249,7 +250,7 @@ const AgentTools: FC = () => {
{t('toolNameUsageTip', { ns: 'tools' })}
)} {!item.isDeleted && !readonly && ( @@ -320,8 +323,10 @@ const AgentTools: FC = () => { )} -
{ const newModelConfig = produce(modelConfig, (draft) => { draft.agentConfig.tools.splice(index, 1) @@ -331,10 +336,9 @@ const AgentTools: FC = () => { }} onMouseOver={() => setIsDeleting(index)} onMouseLeave={() => setIsDeleting(-1)} - data-testid="delete-removed-tool" > - -
+
)}
diff --git a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx index c1d7edac13..da4f591b51 100644 --- a/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx +++ b/web/app/components/app/configuration/config/automatic/__tests__/instruction-editor.spec.tsx @@ -82,7 +82,7 @@ describe('InstructionEditor', () => { expect(screen.getByTestId('error-block')).toHaveTextContent('true') expect(screen.getByTestId('last-run-block')).toHaveTextContent('true') - fireEvent.click(screen.getByText('generate.insertContext')) + fireEvent.click(screen.getByRole('button', { name: 'generate.insertContext' })) expect(mockEmit).toHaveBeenCalledWith(expect.objectContaining({ instanceId: 'editor-1', 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 5596b335df..710f530c9e 100644 --- a/web/app/components/app/configuration/config/automatic/instruction-editor.tsx +++ b/web/app/components/app/configuration/config/automatic/instruction-editor.tsx @@ -113,7 +113,13 @@ const InstructionEditor: FC = ({ {t('generate.press', { ns: 'appDebug' })} / {t('generate.to', { ns: 'appDebug' })} - {t('generate.insertContext', { ns: 'appDebug' })} +
) diff --git a/web/app/components/app/configuration/configuration-view.tsx b/web/app/components/app/configuration/configuration-view.tsx index 04cb3ffeda..80c2934195 100644 --- a/web/app/components/app/configuration/configuration-view.tsx +++ b/web/app/components/app/configuration/configuration-view.tsx @@ -218,7 +218,6 @@ const ConfigurationView: FC = ({
{ render() // Assert - expect(screen.getByTestId('apply-btn')).toBeInTheDocument() - expect(screen.getByTestId('reset-btn')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appDebug.operation.resetConfig' })).toBeInTheDocument() }) }) @@ -31,8 +31,8 @@ describe('ContrlBtnGroup', () => { render() // Act - fireEvent.click(screen.getByTestId('apply-btn')) - fireEvent.click(screen.getByTestId('reset-btn')) + fireEvent.click(screen.getByRole('button', { name: 'appDebug.operation.applyConfig' })) + fireEvent.click(screen.getByRole('button', { name: 'appDebug.operation.resetConfig' })) // Assert expect(onSave).toHaveBeenCalledTimes(1) diff --git a/web/app/components/app/configuration/ctrl-btn-group/index.tsx b/web/app/components/app/configuration/ctrl-btn-group/index.tsx index 6ac485c097..c746d273ba 100644 --- a/web/app/components/app/configuration/ctrl-btn-group/index.tsx +++ b/web/app/components/app/configuration/ctrl-btn-group/index.tsx @@ -15,8 +15,8 @@ const ContrlBtnGroup: FC = ({ onSave, onReset }) => { return (
- - + +
) 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 15d34f19d7..edfffd5726 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 @@ -131,7 +131,7 @@ const ParamsConfig = ({ } }} > - + { // Act await renderSettingsModal(createDataset()) - await user.click(screen.getByText('datasetSettings.form.embeddingModelTipLink')) + await user.click(screen.getByRole('button', { name: 'datasetSettings.form.embeddingModelTipLink' })) // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER }) 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 74e4ca5fe5..7e32ecde11 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 @@ -281,7 +281,13 @@ const SettingsModal: FC = ({
{t('form.embeddingModelTip', { ns: 'datasetSettings' })} - setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}>{t('form.embeddingModelTipLink', { ns: 'datasetSettings' })} +
diff --git a/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx b/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx index 81c90deab6..f935abcee6 100644 --- a/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/__tests__/index.spec.tsx @@ -439,7 +439,7 @@ describe('PromptValuePanel', () => { it('collapses the user input panel and hides the clear and run actions', () => { renderPanel() - fireEvent.click(screen.getByText('appDebug.inputs.userInputField')) + fireEvent.click(screen.getByRole('button', { name: 'appDebug.inputs.userInputField' })) expect(screen.queryByRole('button', { name: 'common.operation.clear' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'appDebug.inputs.run' })).not.toBeInTheDocument() 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 bfcc13c23c..97988939d4 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -111,11 +111,15 @@ const PromptValuePanel: FC = ({ <>
-
setUserInputFieldCollapse(!userInputFieldCollapse)}> +
+ {userInputFieldCollapse &&
{isAppsFull && }
-
+
+
{dragging &&
} diff --git a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx index ceec1aa3c0..d8473378a0 100644 --- a/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/duplicate-modal/__tests__/index.spec.tsx @@ -106,6 +106,27 @@ describe('DuplicateAppModal', () => { expect(onHide).toHaveBeenCalled() }) + it('should call onHide when close button is clicked', async () => { + const onHide = vi.fn() + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'operation.close' })) + + expect(onHide).toHaveBeenCalledTimes(1) + }) + it('should restore the original image icon when the picker closes without selecting', async () => { const onConfirm = vi.fn() const user = userEvent.setup() diff --git a/web/app/components/app/duplicate-modal/index.tsx b/web/app/components/app/duplicate-modal/index.tsx index 7a83c6c277..996952ff2f 100644 --- a/web/app/components/app/duplicate-modal/index.tsx +++ b/web/app/components/app/duplicate-modal/index.tsx @@ -72,9 +72,14 @@ const DuplicateAppModal = ({ -
- -
+
{t('duplicateTitle', { ns: 'app' })}
{t('appCustomize.subTitle', { ns: 'explore' })}
diff --git a/web/app/components/app/log-annotation/__tests__/index.spec.tsx b/web/app/components/app/log-annotation/__tests__/index.spec.tsx index f75e782953..db657ed29a 100644 --- a/web/app/components/app/log-annotation/__tests__/index.spec.tsx +++ b/web/app/components/app/log-annotation/__tests__/index.spec.tsx @@ -15,19 +15,19 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/app/components/app/annotation', () => ({ default: ({ appDetail }: { appDetail: App }) => ( -
+
{appDetail.id}
), })) vi.mock('@/app/components/app/log', () => ({ default: ({ appDetail }: { appDetail: App }) => ( -
+
{appDetail.id}
), })) vi.mock('@/app/components/app/workflow-log', () => ({ default: ({ appDetail }: { appDetail: App }) => ( -
+
{appDetail.id}
), })) @@ -113,7 +113,7 @@ describe('LogAnnotation', () => { // Assert expect(screen.queryByText('appLog.title')).not.toBeInTheDocument() - expect(screen.getByTestId('workflow-log')).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Workflow log' })).toBeInTheDocument() }) }) @@ -127,8 +127,8 @@ describe('LogAnnotation', () => { render() // Assert - expect(screen.getByTestId('log')).toBeInTheDocument() - expect(screen.queryByTestId('annotation')).not.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'App log' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Annotation log' })).not.toBeInTheDocument() }) it('should render annotation content when page type is annotation', () => { @@ -139,8 +139,8 @@ describe('LogAnnotation', () => { render() // Assert - expect(screen.getByTestId('annotation')).toBeInTheDocument() - expect(screen.queryByTestId('log')).not.toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Annotation log' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'App log' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app/log/__tests__/empty-element.spec.tsx b/web/app/components/app/log/__tests__/empty-element.spec.tsx index 3967097c12..41f82375f9 100644 --- a/web/app/components/app/log/__tests__/empty-element.spec.tsx +++ b/web/app/components/app/log/__tests__/empty-element.spec.tsx @@ -8,7 +8,7 @@ vi.mock('react-i18next', () => ({ t: (key: string) => key, }), Trans: ({ i18nKey, components }: { i18nKey: string, components: Record }) => ( - + {i18nKey} {components.shareLink} {components.testLink} @@ -54,8 +54,7 @@ describe('EmptyElement', () => { const appDetail = createMockAppDetail(AppModeEnum.CHAT) render() - const transComponent = screen.getByTestId('trans-component') - expect(transComponent).toHaveAttribute('data-i18n-key', 'table.empty.element.content') + expect(screen.getByText('table.empty.element.content', { exact: false })).toBeInTheDocument() }) it('should render ThreeDotsIcon SVG', () => { diff --git a/web/app/components/app/log/__tests__/filter.spec.tsx b/web/app/components/app/log/__tests__/filter.spec.tsx index ec1a0183be..3692c76a85 100644 --- a/web/app/components/app/log/__tests__/filter.spec.tsx +++ b/web/app/components/app/log/__tests__/filter.spec.tsx @@ -146,7 +146,7 @@ describe('Filter', () => { render() - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'operation.clear' }) fireEvent.click(clearButton) expect(mockSetQueryParams).toHaveBeenCalledWith({ diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx index 8db5193f2d..5328efab02 100644 --- a/web/app/components/app/overview/app-card-sections.tsx +++ b/web/app/components/app/overview/app-card-sections.tsx @@ -351,52 +351,46 @@ export const AppCardOperations = ({ if (key === 'launch' && launchConfigAction) { return ( - +
+ + + + ) } diff --git a/web/app/components/app/overview/customize/__tests__/index.spec.tsx b/web/app/components/app/overview/customize/__tests__/index.spec.tsx index 1f703afcd8..6b33cadcf7 100644 --- a/web/app/components/app/overview/customize/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/customize/__tests__/index.spec.tsx @@ -310,7 +310,7 @@ describe('CustomizeModal', () => { expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument() }) - const closeButton = screen.getByTestId('modal-close-button') + const closeButton = screen.getByRole('button', { name: 'Close' }) fireEvent.click(closeButton) expect(onClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/overview/customize/index.tsx b/web/app/components/app/overview/customize/index.tsx index 46527c30f9..89b621f32b 100644 --- a/web/app/components/app/overview/customize/index.tsx +++ b/web/app/components/app/overview/customize/index.tsx @@ -53,7 +53,7 @@ const CustomizeModal: FC = ({ {t(`${prefixCustomize}.explanation`, { ns: 'appOverview' })} - +
{t(`${prefixCustomize}.way`, { ns: 'appOverview' })} diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index ae772d0750..f64972b1a1 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -17,7 +17,7 @@ import AppIcon from '@/app/components/base/app-icon' import AppIconPicker from '@/app/components/base/app-icon-picker' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' -import PremiumBadge from '@/app/components/base/premium-badge' +import { PremiumBadgeButton } from '@/app/components/base/premium-badge' import Textarea from '@/app/components/base/textarea' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' @@ -55,10 +55,12 @@ export type ConfigParams = { const prefixSettings = 'overview.appInfo.settings' type SelectOption = { - value: string + value: Language name: string } +const LANGUAGE_OPTIONS: SelectOption[] = languages.filter(item => item.supported) + const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { const { title, @@ -139,8 +141,13 @@ const SettingsModal: FC = ({ const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext() const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const isFreePlan = plan.type === 'sandbox' - const languageOptions: SelectOption[] = languages.filter(item => item.supported) - const selectedLanguage = languageOptions.find(item => item.value === language) + const selectedLanguage = LANGUAGE_OPTIONS.find(item => item.value === language) + + const handleLanguageChange = (nextValue: string | null) => { + const nextLanguage = LANGUAGE_OPTIONS.find(item => item.value === nextValue) + if (nextLanguage) + setLanguage(nextLanguage.value) + } const handlePlanClick = useCallback(() => { if (isFreePlan) setShowPricingModal() @@ -308,17 +315,17 @@ const SettingsModal: FC = ({
{t(`${prefixSettings}.language`, { ns: 'appOverview' })}
handleKeywordsChange(e.target.value)} - onClear={() => handleKeywordsChange('')} + onChange={e => setKeywords(e.target.value)} + onClear={() => setKeywords('')} />
@@ -267,7 +230,7 @@ const List: FC = ({ ref={newAppCardRef} isLoading={isLoadingCurrentWorkspace} onSuccess={refetch} - selectedAppType={activeTab} + selectedAppType={category} className={cn(!hasAnyApp && 'z-10')} /> )} diff --git a/web/app/components/base/__tests__/alert.spec.tsx b/web/app/components/base/__tests__/alert.spec.tsx index 10c1a6bbfa..3d4e683d19 100644 --- a/web/app/components/base/__tests__/alert.spec.tsx +++ b/web/app/components/base/__tests__/alert.spec.tsx @@ -25,8 +25,7 @@ describe('Alert', () => { it('should render the close icon', () => { render() - const closeIcon = screen.getByTestId('close-icon') - expect(closeIcon).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) }) @@ -66,7 +65,7 @@ describe('Alert', () => { it('should call onHide when close button is clicked', () => { const onHide = vi.fn() render() - const closeButton = screen.getByTestId('close-icon') + const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) fireEvent.click(closeButton) expect(onHide).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx index 78d4a06c4a..4c8a1d24bf 100644 --- a/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/detail.spec.tsx @@ -172,7 +172,7 @@ describe('AgentLogDetail', () => { await renderAndWaitForData() - fireEvent.click(screen.getByText(/runLog.tracing/i)) + fireEvent.click(screen.getByRole('button', { name: /runLog.tracing/i })) await waitFor(() => { const tracingTab = screen.getByText(/runLog.tracing/i) @@ -188,13 +188,13 @@ describe('AgentLogDetail', () => { await renderAndWaitForData() - fireEvent.click(screen.getByText(/runLog.tracing/i)) + fireEvent.click(screen.getByRole('button', { name: /runLog.tracing/i })) await waitFor(() => { expect(screen.getByText(/runLog.tracing/i).getAttribute('data-active')).toBe('true') }) - fireEvent.click(screen.getByText(/runLog.detail/i)) + fireEvent.click(screen.getByRole('button', { name: /runLog.detail/i })) await waitFor(() => { const detailTab = screen.getByText(/runLog.detail/i) diff --git a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx index d8c595202a..1c8e79f4aa 100644 --- a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx @@ -124,7 +124,7 @@ describe('AgentLogModal', () => { render() - const closeBtn = screen.getByRole('heading', { name: /appLog.runDetail.workflowTitle/i }).nextElementSibling! + const closeBtn = screen.getByRole('button', { name: 'common.operation.close' }) fireEvent.click(closeBtn) expect(mockProps.onCancel).toHaveBeenCalledTimes(1) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index b12777dfe5..24c192c47e 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -67,12 +67,22 @@ const AgentLogDetail: FC = ({ activeTab = 'DETAIL', convers
{/* tab */}
-
switchTab('DETAIL')}> +
-
switchTab('TRACING')}> + +
+
{/* panel detail */}
diff --git a/web/app/components/base/agent-log-modal/index.tsx b/web/app/components/base/agent-log-modal/index.tsx index 0739d0774a..c6f036ede0 100644 --- a/web/app/components/base/agent-log-modal/index.tsx +++ b/web/app/components/base/agent-log-modal/index.tsx @@ -46,9 +46,14 @@ const AgentLogModal: FC = ({ ref={ref} >

{t('runDetail.workflowTitle', { ns: 'appLog' })}

- - - + = ({ onHide, className, }) => { + const { t } = useTranslation() + return (
= ({ {message}
-
- -
+
) diff --git a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx index 7f452e64e9..589ad5554e 100644 --- a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx @@ -195,10 +195,10 @@ describe('AppIconPicker', () => { const { onSelect } = renderPicker() await waitFor(() => { - expect(screen.queryAllByTestId(/emoji-container-/i).length).toBeGreaterThan(0) + expect(document.querySelector('em-emoji')?.closest('button'))!.toBeInTheDocument() }) - const firstEmoji = screen.queryAllByTestId(/emoji-container-/i)[0] + const firstEmoji = document.querySelector('em-emoji')?.closest('button') if (!firstEmoji) throw new Error('Could not find emoji option') diff --git a/web/app/components/base/audio-btn/index.tsx b/web/app/components/base/audio-btn/index.tsx index 3ca453213d..999193deed 100644 --- a/web/app/components/base/audio-btn/index.tsx +++ b/web/app/components/base/audio-btn/index.tsx @@ -88,8 +88,9 @@ const AudioBtn = ({
) diff --git a/web/app/components/base/float-right-container/__tests__/index.spec.tsx b/web/app/components/base/float-right-container/__tests__/index.spec.tsx index 4466b2cadc..6f84e5afd8 100644 --- a/web/app/components/base/float-right-container/__tests__/index.spec.tsx +++ b/web/app/components/base/float-right-container/__tests__/index.spec.tsx @@ -90,7 +90,7 @@ describe('FloatRightContainer', () => { ) await screen.findByRole('dialog') - const closeIcon = screen.getByTestId('close-icon') + const closeIcon = screen.getByRole('button', { name: 'common.operation.close' }) expect(closeIcon).toBeInTheDocument() await userEvent.click(closeIcon) diff --git a/web/app/components/base/float-right-container/index.tsx b/web/app/components/base/float-right-container/index.tsx index db3b73da95..ee79b22226 100644 --- a/web/app/components/base/float-right-container/index.tsx +++ b/web/app/components/base/float-right-container/index.tsx @@ -63,7 +63,6 @@ const FloatRightContainer = ({ )}
diff --git a/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx b/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx index 6005d9261b..d5e5a6d25c 100644 --- a/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/checkbox.spec.tsx @@ -21,7 +21,7 @@ describe('CheckboxField', () => { it('should toggle on when unchecked users click the checkbox', () => { mockField.state.value = false render() - fireEvent.click(screen.getByTestId('checkbox-checkbox-field')) + fireEvent.click(screen.getByRole('checkbox', { name: 'Enable feature' })) expect(mockField.handleChange).toHaveBeenCalledWith(true) }) diff --git a/web/app/components/base/form/components/field/checkbox.tsx b/web/app/components/base/form/components/field/checkbox.tsx index cd5b95f2fa..29ba50d56b 100644 --- a/web/app/components/base/form/components/field/checkbox.tsx +++ b/web/app/components/base/form/components/field/checkbox.tsx @@ -19,6 +19,7 @@ const CheckboxField = ({ { field.handleChange(!field.state.value) }} diff --git a/web/app/components/base/form/components/field/input-type-select/index.tsx b/web/app/components/base/form/components/field/input-type-select/index.tsx index 37f9a510d4..176d82a492 100644 --- a/web/app/components/base/form/components/field/input-type-select/index.tsx +++ b/web/app/components/base/form/components/field/input-type-select/index.tsx @@ -12,6 +12,7 @@ import Label from '../../label' import { useInputTypeOptions } from './hooks' import Option from './option' import Trigger from './trigger' +import { InputTypeEnum } from './types' type InputTypeSelectFieldProps = { label: string @@ -32,6 +33,12 @@ const InputTypeSelectField = ({ const inputTypeOptions = useInputTypeOptions(supportFile) const selected = inputTypeOptions.find(option => option.value === field.state.value) + const handleInputTypeChange = (next: string | null) => { + const inputType = InputTypeEnum.safeParse(next) + if (inputType.success) + field.handleChange(inputType.data) + } + return (
-
- -
+
diff --git a/web/app/components/base/qrcode/__tests__/index.spec.tsx b/web/app/components/base/qrcode/__tests__/index.spec.tsx index cfc78cef85..0029f1840a 100644 --- a/web/app/components/base/qrcode/__tests__/index.spec.tsx +++ b/web/app/components/base/qrcode/__tests__/index.spec.tsx @@ -17,7 +17,7 @@ describe('ShareQRCode', () => { describe('Rendering', () => { it('renders correctly', () => { render() - expect(screen.getByRole('button').firstElementChild).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' })).toBeInTheDocument() }) }) @@ -27,7 +27,7 @@ describe('ShareQRCode', () => { render() expect(screen.queryByRole('img')).not.toBeInTheDocument() - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) expect(screen.getByRole('img')).toBeInTheDocument() @@ -40,16 +40,16 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render(
-
Outside
+
Outside
, ) - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) expect(screen.getByRole('img')).toBeInTheDocument() - await user.click(screen.getByTestId('outside')) + await user.click(screen.getByText('Outside')) expect(screen.queryByRole('img')).not.toBeInTheDocument() }) @@ -57,7 +57,7 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render() - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) const canvas = screen.getByRole('img') @@ -75,10 +75,10 @@ describe('ShareQRCode', () => { try { render() - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger!) - const downloadBtn = screen.getByText('appOverview.overview.appInfo.qrcode.download') + const downloadBtn = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.download' }) await user.click(downloadBtn) expect(downloadUrl).toHaveBeenCalledWith({ @@ -95,7 +95,7 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render() - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) // Override querySelector on the panel to simulate canvas not being found @@ -108,7 +108,7 @@ describe('ShareQRCode', () => { }) as typeof panel.querySelector try { - const downloadBtn = screen.getByText('appOverview.overview.appInfo.qrcode.download') + const downloadBtn = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.download' }) await user.click(downloadBtn) expect(downloadUrl).not.toHaveBeenCalled() } @@ -121,7 +121,7 @@ describe('ShareQRCode', () => { const user = userEvent.setup() render() - const trigger = screen.getByTestId('qrcode-container') + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.qrcode.title' }) await user.click(trigger) // Click on the scan text inside the panel — panel should remain open diff --git a/web/app/components/base/qrcode/index.tsx b/web/app/components/base/qrcode/index.tsx index cfc30d752b..d4c9918aa6 100644 --- a/web/app/components/base/qrcode/index.tsx +++ b/web/app/components/base/qrcode/index.tsx @@ -52,32 +52,39 @@ const ShareQRCode = ({ content }: Props) => { const tooltipText = t(`${prefixEmbedded}`, { ns: 'appOverview' }) /* v8 ignore next -- react-i18next returns a non-empty key/string in configured runtime; empty fallback protects against missing i18n payloads. @preserve */ const safeTooltipText = tooltipText || '' + const downloadText = t('overview.appInfo.qrcode.download', { ns: 'appOverview' }) return ( - - - +
+ +
{safeTooltipText} diff --git a/web/app/components/base/radio-card/__tests__/index.spec.tsx b/web/app/components/base/radio-card/__tests__/index.spec.tsx index f4bc7a5b0e..b2d21fa123 100644 --- a/web/app/components/base/radio-card/__tests__/index.spec.tsx +++ b/web/app/components/base/radio-card/__tests__/index.spec.tsx @@ -32,7 +32,7 @@ describe('RadioCard', () => { />, ) - await user.click(screen.getByText('Clickable')) + await user.click(screen.getByRole('button', { name: /Clickable/ })) expect(onChosen).toHaveBeenCalledTimes(1) }) @@ -117,7 +117,7 @@ describe('RadioCard', () => { ) // click title should trigger onChosen - await user.click(screen.getByText('ClickNoRadio')) + await user.click(screen.getByRole('button', { name: /ClickNoRadio/ })) expect(onChosen).toHaveBeenCalledTimes(1) // radio area should be absent diff --git a/web/app/components/base/radio-card/index.tsx b/web/app/components/base/radio-card/index.tsx index 87e2c96258..dad810e1e1 100644 --- a/web/app/components/base/radio-card/index.tsx +++ b/web/app/components/base/radio-card/index.tsx @@ -37,7 +37,11 @@ const RadioCard: FC = ({ className, )} > -
+
{!noRadio && (
-
)} -
+ {!!((isChosen && chosenConfig) || noRadio) && (
diff --git a/web/app/components/base/sort/__tests__/index.spec.tsx b/web/app/components/base/sort/__tests__/index.spec.tsx index 18dfd242ce..ea85e8f3fe 100644 --- a/web/app/components/base/sort/__tests__/index.spec.tsx +++ b/web/app/components/base/sort/__tests__/index.spec.tsx @@ -11,10 +11,10 @@ const mockItems = [ ] describe('Sort component — real portal integration', () => { - const setup = (props = {}) => { + const setup = (props: Partial> = {}) => { const onSelect = vi.fn() const user = userEvent.setup() - const { container, rerender } = render( + const { rerender } = render( , ) @@ -28,10 +28,9 @@ describe('Sort component — real portal integration', () => { // helper: returns right-side sort button element const getSortButton = (): HTMLElement => { - const btn = container.querySelector('.rounded-r-lg') - if (!btn) - throw new Error('Sort button (rounded-r-lg) not found in rendered container') - return btn as HTMLElement + return screen.getByRole('button', { + name: props.order ? 'appLog.filter.ascending' : 'appLog.filter.descending', + }) } return { user, onSelect, rerender, getTriggerWrapper, getSortButton } diff --git a/web/app/components/base/sort/index.tsx b/web/app/components/base/sort/index.tsx index 69cf4dd220..57affdd96b 100644 --- a/web/app/components/base/sort/index.tsx +++ b/web/app/components/base/sort/index.tsx @@ -85,10 +85,15 @@ const Sort: FC = ({
-
onSelect(`${order ? '' : '-'}${value}`)}> - {!order && } - {order && } -
+ ) diff --git a/web/app/components/base/svg-gallery/__tests__/index.spec.tsx b/web/app/components/base/svg-gallery/__tests__/index.spec.tsx index c08b527d94..a93cc9da0e 100644 --- a/web/app/components/base/svg-gallery/__tests__/index.spec.tsx +++ b/web/app/components/base/svg-gallery/__tests__/index.spec.tsx @@ -131,7 +131,7 @@ describe('SVGRenderer', () => { expect(screen.getByAltText('Preview'))!.toBeInTheDocument() - await user.click(screen.getByTestId('image-preview-close-button')) + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) await waitFor(() => { expect(screen.queryByAltText('Preview')).not.toBeInTheDocument() diff --git a/web/app/components/base/tag-input/__tests__/index.spec.tsx b/web/app/components/base/tag-input/__tests__/index.spec.tsx index b64f4aaab6..78bfeb33a8 100644 --- a/web/app/components/base/tag-input/__tests__/index.spec.tsx +++ b/web/app/components/base/tag-input/__tests__/index.spec.tsx @@ -63,7 +63,7 @@ describe('TagInput', () => { it('should hide remove controls when remove is disabled', () => { renderTagInput({ items: ['alpha'], disableRemove: true }) - expect(screen.queryByTestId('remove-tag')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.remove alpha' })).not.toBeInTheDocument() }) it('should apply focused style in special mode when input is focused', async () => { @@ -83,9 +83,9 @@ describe('TagInput', () => { it('should remove item when remove control is clicked', async () => { const { onChange } = renderTagInput({ items: ['alpha', 'beta'] }) - const removeControl = screen.getAllByTestId('remove-tag')[0] + const removeControl = screen.getByRole('button', { name: 'common.operation.remove alpha' }) - await userEvent.click(removeControl!) + await userEvent.click(removeControl) expect(onChange).toHaveBeenCalledWith(['beta']) }) diff --git a/web/app/components/base/tag-input/index.tsx b/web/app/components/base/tag-input/index.tsx index 716f1571e6..81f4d12af5 100644 --- a/web/app/components/base/tag-input/index.tsx +++ b/web/app/components/base/tag-input/index.tsx @@ -67,9 +67,14 @@ const TagInput: FC = ({ items, onChange, disableAdd, disableRemov
{item} {!disableRemove && ( -
handleRemove(index)}> - -
+ )}
))} diff --git a/web/app/components/base/theme-selector.tsx b/web/app/components/base/theme-selector.tsx index 1676dfff72..67dc7b7b29 100644 --- a/web/app/components/base/theme-selector.tsx +++ b/web/app/components/base/theme-selector.tsx @@ -12,7 +12,12 @@ import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -export type Theme = 'light' | 'dark' | 'system' +const THEMES = ['light', 'dark', 'system'] as const +export type Theme = typeof THEMES[number] + +const isTheme = (value: string): value is Theme => { + return (THEMES as readonly string[]).includes(value) +} export default function ThemeSelector() { const { t } = useTranslation() @@ -22,6 +27,11 @@ export default function ThemeSelector() { setTheme(newTheme) } + const handleThemeValueChange = (value: string) => { + if (isTheme(value)) + handleThemeChange(value) + } + const getCurrentIcon = () => { switch (theme) { case 'light': return @@ -43,7 +53,7 @@ export default function ThemeSelector() { {getCurrentIcon()} - handleThemeChange(value as Theme)}> + {t('theme.light', { ns: 'common' })} diff --git a/web/app/components/base/video-gallery/VideoPlayer.tsx b/web/app/components/base/video-gallery/VideoPlayer.tsx index 889836258f..6f7304474a 100644 --- a/web/app/components/base/video-gallery/VideoPlayer.tsx +++ b/web/app/components/base/video-gallery/VideoPlayer.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import styles from './VideoPlayer.module.css' type VideoPlayerProps = { @@ -8,36 +9,37 @@ type VideoPlayerProps = { } const PlayIcon = () => ( - + ) const PauseIcon = () => ( - + ) const MuteIcon = () => ( - + ) const UnmuteIcon = () => ( - + ) const FullscreenIcon = () => ( - + ) const VideoPlayer: React.FC = ({ src, srcs }) => { + const { t } = useTranslation() const [isPlaying, setIsPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) @@ -52,6 +54,9 @@ const VideoPlayer: React.FC = ({ src, srcs }) => { const controlsTimeoutRef = useRef(null) const [isSmallSize, setIsSmallSize] = useState(false) const containerRef = useRef(null) + const playPauseLabel = t(isPlaying ? 'operation.pause' : 'operation.play', { ns: 'common' }) + const toggleMuteLabel = t('operation.toggleMute', { ns: 'common' }) + const toggleFullscreenLabel = t('operation.toggleFullscreen', { ns: 'common' }) useEffect(() => { const video = videoRef.current @@ -256,7 +261,7 @@ const VideoPlayer: React.FC = ({ src, srcs }) => {
- {!isSmallSize && ( @@ -270,7 +275,7 @@ const VideoPlayer: React.FC = ({ src, srcs }) => { )}
- {!isSmallSize && ( @@ -295,7 +300,7 @@ const VideoPlayer: React.FC = ({ src, srcs }) => {
)} - diff --git a/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx b/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx index 20a6298419..6a72170607 100644 --- a/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx +++ b/web/app/components/base/video-gallery/__tests__/VideoPlayer.spec.tsx @@ -21,6 +21,11 @@ describe('VideoPlayer', () => { } as DOMRect) } + const getPlayButton = () => screen.getByRole('button', { name: 'common.operation.play' }) + const getPauseButton = () => screen.getByRole('button', { name: 'common.operation.pause' }) + const getMuteButton = () => screen.getByRole('button', { name: 'common.operation.toggleMute' }) + const getFullscreenButton = () => screen.getByRole('button', { name: 'common.operation.toggleFullscreen' }) + beforeEach(() => { vi.clearAllMocks() vi.useRealTimers() @@ -98,12 +103,12 @@ describe('VideoPlayer', () => { it('should toggle play/pause on button click', async () => { const user = userEvent.setup() render() - const playPauseBtn = screen.getByTestId('video-play-pause-button') + const playPauseBtn = getPlayButton() await user.click(playPauseBtn) expect(window.HTMLVideoElement.prototype.play).toHaveBeenCalled() - await user.click(playPauseBtn) + await user.click(getPauseButton()) expect(window.HTMLVideoElement.prototype.pause).toHaveBeenCalled() }) @@ -111,7 +116,7 @@ describe('VideoPlayer', () => { const user = userEvent.setup() render() const video = screen.getByTestId('video-element') as HTMLVideoElement - const muteBtn = screen.getByTestId('video-mute-button') + const muteBtn = getMuteButton() // Ensure volume is positive before muting video.volume = 0.7 @@ -132,7 +137,7 @@ describe('VideoPlayer', () => { it('should toggle fullscreen on button click', async () => { const user = userEvent.setup() render() - const fullscreenBtn = screen.getByTestId('video-fullscreen-button') + const fullscreenBtn = getFullscreenButton() await user.click(fullscreenBtn) expect(window.HTMLVideoElement.prototype.requestFullscreen).toHaveBeenCalled() @@ -166,12 +171,12 @@ describe('VideoPlayer', () => { const user = userEvent.setup() render() const video = screen.getByTestId('video-element') - const playPauseBtn = screen.getByTestId('video-play-pause-button') + const playPauseBtn = getPlayButton() await user.click(playPauseBtn) fireEvent(video, new Event('ended')) - expect(playPauseBtn)!.toBeInTheDocument() + expect(getPlayButton())!.toBeInTheDocument() }) it('should show/hide controls on mouse move and timeout', () => { @@ -273,7 +278,7 @@ describe('VideoPlayer', () => { try { render() - const playPauseBtn = screen.getByTestId('video-play-pause-button') + const playPauseBtn = getPlayButton() await user.click(playPauseBtn) @@ -290,7 +295,7 @@ describe('VideoPlayer', () => { const user = userEvent.setup() render() const video = screen.getByTestId('video-element') as HTMLVideoElement - const muteBtn = screen.getByTestId('video-mute-button') + const muteBtn = getMuteButton() // First click mutes — this sets volume to 0 and muted to true await user.click(muteBtn) diff --git a/web/app/components/billing/annotation-full/modal.tsx b/web/app/components/billing/annotation-full/modal.tsx index c3c6aab2ce..00f38734d1 100644 --- a/web/app/components/billing/annotation-full/modal.tsx +++ b/web/app/components/billing/annotation-full/modal.tsx @@ -28,7 +28,7 @@ const AnnotationFullModal: FC = ({ }} > - +
diff --git a/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx b/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx index 7eda24b944..5f6dc60038 100644 --- a/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx +++ b/web/app/components/billing/upgrade-btn/__tests__/index.spec.tsx @@ -38,10 +38,11 @@ describe('UpgradeBtn', () => { expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() }) - it('should render premium badge by default', () => { + it('should render premium badge button by default', () => { render() - expect(screen.getByText(/billing\.upgradeBtn\.encourage/i)).toBeInTheDocument() + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + expect(button).toHaveClass('premium-badge') }) it('should render plain button when isPlain is true', () => { @@ -75,7 +76,7 @@ describe('UpgradeBtn', () => { // Props tests (REQUIRED) describe('Props', () => { - it('should apply custom className to premium badge', () => { + it('should apply custom className to premium badge button', () => { const customClass = 'custom-upgrade-btn' const { container } = render() @@ -93,7 +94,7 @@ describe('UpgradeBtn', () => { expect(button).toHaveClass(customClass) }) - it('should apply custom style to premium badge', () => { + it('should apply custom style to premium badge button', () => { const customStyle = { padding: '10px' } const { container } = render() @@ -132,13 +133,13 @@ describe('UpgradeBtn', () => { // User Interactions describe('User Interactions', () => { - it('should call custom onClick when provided and premium badge is clicked', async () => { + it('should call custom onClick when provided and premium badge button is clicked', async () => { const user = userEvent.setup() const handleClick = vi.fn() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(handleClick).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).not.toHaveBeenCalled() @@ -156,12 +157,12 @@ describe('UpgradeBtn', () => { expect(mockSetShowPricingModal).not.toHaveBeenCalled() }) - it('should open pricing modal when no custom onClick is provided and premium badge is clicked', async () => { + it('should open pricing modal when no custom onClick is provided and premium badge button is clicked', async () => { const user = userEvent.setup() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) @@ -176,13 +177,13 @@ describe('UpgradeBtn', () => { expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) - it('should track gtag event when loc is provided and badge is clicked', async () => { + it('should track gtag event when loc is provided and badge button is clicked', async () => { const user = userEvent.setup() const loc = 'header-navigation' render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledWith('event', 'click_upgrade_btn', { @@ -208,8 +209,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).not.toHaveBeenCalled() }) @@ -219,8 +220,8 @@ describe('UpgradeBtn', () => { delete gtagWindow.gtag render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).not.toHaveBeenCalled() }) @@ -231,8 +232,8 @@ describe('UpgradeBtn', () => { const loc = 'settings-page' render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(handleClick).toHaveBeenCalledTimes(1) expect(mockGtag).toHaveBeenCalledTimes(1) @@ -260,8 +261,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) }) @@ -270,8 +271,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).not.toHaveBeenCalled() }) @@ -292,8 +293,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) expect(mockGtag).not.toHaveBeenCalled() }) @@ -391,19 +392,26 @@ describe('UpgradeBtn', () => { expect(handleClick).toHaveBeenCalledTimes(1) }) - it('should be clickable for premium badge variant', async () => { + it('should be keyboard accessible for premium badge button variant', async () => { const user = userEvent.setup() const handleClick = vi.fn() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - - // Click badge - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.tab() + expect(button).toHaveFocus() + await user.keyboard('{Enter}') expect(handleClick).toHaveBeenCalledTimes(1) }) + it('should have proper button role for premium badge button variant', () => { + render() + + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + expect(button).toHaveClass('premium-badge') + }) + it('should have proper button role when isPlain is true', () => { render() @@ -418,8 +426,8 @@ describe('UpgradeBtn', () => { const user = userEvent.setup() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) await waitFor(() => { expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) @@ -431,8 +439,8 @@ describe('UpgradeBtn', () => { const handleClick = vi.fn() render() - const badge = screen.getByText(/billing\.upgradeBtn\.encourage/i) - await user.click(badge) + const button = screen.getByRole('button', { name: /billing\.upgradeBtn\.encourage/i }) + await user.click(button) await waitFor(() => { expect(handleClick).toHaveBeenCalledTimes(1) diff --git a/web/app/components/billing/upgrade-btn/index.tsx b/web/app/components/billing/upgrade-btn/index.tsx index 5eb1eb1d7f..e5b53555c2 100644 --- a/web/app/components/billing/upgrade-btn/index.tsx +++ b/web/app/components/billing/upgrade-btn/index.tsx @@ -6,7 +6,7 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { SparklesSoft } from '@/app/components/base/icons/src/public/common' import { useModalContext } from '@/context/modal-context' -import PremiumBadge from '../../base/premium-badge' +import { PremiumBadgeButton } from '../../base/premium-badge' type Props = { className?: string @@ -20,6 +20,8 @@ type Props = { labelKey?: Exclude, 'plans.community.features' | 'plans.enterprise.features' | 'plans.premium.features'> } +type GtagHandler = (command: 'event', action: 'click_upgrade_btn', payload: { loc: string }) => void + const UpgradeBtn: FC = ({ className, size = 'm', @@ -36,12 +38,13 @@ const UpgradeBtn: FC = ({ if (_onClick) _onClick() else - (setShowPricingModal as any)() + setShowPricingModal() } const onClick = () => { handleClick() - if (loc && (window as any).gtag) { - (window as any).gtag('event', 'click_upgrade_btn', { + const gtag = (window as Window & { gtag?: GtagHandler }).gtag + if (loc && gtag) { + gtag('event', 'click_upgrade_btn', { loc, }) } @@ -63,21 +66,20 @@ const UpgradeBtn: FC = ({ } return ( - - + + ) } export default React.memo(UpgradeBtn) diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index 1f3655a9f8..b31db0a29f 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -133,7 +133,7 @@ describe('CustomPage', () => { expect(screen.getByText('custom.upgradeTip.title')).toBeInTheDocument() expect(screen.queryByText('custom.customize.contactUs')).not.toBeInTheDocument() - await user.click(screen.getByText('billing.upgradeBtn.encourageShort')) + await user.click(screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' })) expect(setShowPricingModal).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/custom/custom-page/index.tsx b/web/app/components/custom/custom-page/index.tsx index cd85a91230..0362267a6f 100644 --- a/web/app/components/custom/custom-page/index.tsx +++ b/web/app/components/custom/custom-page/index.tsx @@ -20,7 +20,13 @@ const CustomPage = () => {
{t('upgradeTip.title', { ns: 'custom' })}
{t('upgradeTip.des', { ns: 'custom' })}
-
setShowPricingModal()}>{t('upgradeBtn.encourageShort', { ns: 'billing' })}
+ )} diff --git a/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx index 83063cedf5..fa21f71c9d 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx @@ -1,6 +1,8 @@ -import type { DocumentItem } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SimpleDocumentDetail } from '@/models/datasets' +import { Combobox } from '@langgenius/dify-ui/combobox' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ChunkingMode, DataSourceType } from '@/models/datasets' import DocumentList from '../document-list' vi.mock('../../document-file-icon', () => ({ @@ -13,37 +15,92 @@ vi.mock('../../document-file-icon', () => ({ ), })) +const createDocument = (overrides: Partial = {}): SimpleDocumentDetail => ({ + id: 'doc-1', + batch: 'batch-1', + position: 1, + dataset_id: 'dataset-1', + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { + id: 'file-1', + name: 'report.pdf', + size: 1024, + extension: 'pdf', + mime_type: 'application/pdf', + created_by: 'user-1', + created_at: Date.now(), + }, + job_id: 'job-1', + url: '', + }, + dataset_process_rule_id: 'rule-1', + name: 'report', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + indexing_status: 'completed', + display_status: 'enabled', + doc_form: ChunkingMode.text, + doc_language: 'en', + enabled: true, + word_count: 1000, + archived: false, + updated_at: Date.now(), + hit_count: 0, + data_source_detail_dict: { + upload_file: { + name: 'report.pdf', + extension: 'pdf', + }, + }, + ...overrides, +}) + +const renderDocumentList = (list: SimpleDocumentDetail[], onValueChange = vi.fn()) => ({ + onValueChange, + ...render( + document.name} + itemToStringValue={document => document.id} + onValueChange={onValueChange} + > + + , + ), +}) + describe('DocumentList', () => { - const mockList = [ - { id: 'doc-1', name: 'report', extension: 'pdf' }, - { id: 'doc-2', name: 'data', extension: 'csv' }, - ] as DocumentItem[] - - const onChange = vi.fn() - beforeEach(() => { vi.clearAllMocks() }) - it('should render all documents', () => { - render() - expect(screen.getByText('report')).toBeInTheDocument() - expect(screen.getByText('data')).toBeInTheDocument() - }) + it('should render documents as combobox options', () => { + renderDocumentList([ + createDocument({ id: 'doc-1', name: 'report' }), + createDocument({ id: 'doc-2', name: 'data' }), + ]) - it('should render file icons', () => { - render() + expect(screen.getByRole('option', { name: /report/ })).toBeInTheDocument() + expect(screen.getByRole('option', { name: /data/ })).toBeInTheDocument() expect(screen.getAllByTestId('file-icon')).toHaveLength(2) }) - it('should call onChange with document on click', () => { - render() - fireEvent.click(screen.getByText('report')) - expect(onChange).toHaveBeenCalledWith(mockList[0]) + it('should keep item spacing symmetric with the search field', () => { + renderDocumentList([createDocument({ id: 'doc-1', name: 'report' })]) + + expect(screen.getByRole('option', { name: /report/ })).toHaveClass('px-3') }) - it('should render empty list without errors', () => { - const { container } = render() - expect(container.firstChild).toBeInTheDocument() + it('should select a document through combobox value change', async () => { + const user = userEvent.setup() + const selectedDocument = createDocument({ id: 'doc-1', name: 'report' }) + const { onValueChange } = renderDocumentList([selectedDocument]) + + await user.click(screen.getByRole('option', { name: /report/ })) + + expect(onValueChange).toHaveBeenCalledWith(selectedDocument, expect.any(Object)) }) }) diff --git a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx index 1251eab9fb..a6c2078836 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx @@ -1,33 +1,22 @@ -import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' +import type { SimpleDocumentDetail } from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ChunkingMode, DataSourceType } from '@/models/datasets' -import DocumentPicker from '../index' +import { DocumentPicker } from '../index' -vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) - -// Mock useDocumentList hook with controllable return value let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined -let mockDocumentListLoading = false const { mockUseDocumentList } = vi.hoisted(() => ({ mockUseDocumentList: vi.fn(), })) -// Set up the implementation after variables are defined -mockUseDocumentList.mockImplementation(() => ({ - data: mockDocumentListLoading ? undefined : mockDocumentListData, - isLoading: mockDocumentListLoading, -})) - vi.mock('@/service/knowledge/use-document', () => ({ useDocumentList: mockUseDocumentList, })) -// Factory function to create mock SimpleDocumentDetail -const createMockDocument = (overrides: Partial = {}): SimpleDocumentDetail => ({ - id: `doc-${Math.random().toString(36).substr(2, 9)}`, +const createDocument = (overrides: Partial = {}): SimpleDocumentDetail => ({ + id: 'doc-1', batch: 'batch-1', position: 1, dataset_id: 'dataset-1', @@ -35,19 +24,18 @@ const createMockDocument = (overrides: Partial = {}): Simp data_source_info: { upload_file: { id: 'file-1', - name: 'test-file.txt', + name: 'document.pdf', size: 1024, - extension: 'txt', - mime_type: 'text/plain', + extension: 'pdf', + mime_type: 'application/pdf', created_by: 'user-1', created_at: Date.now(), }, - // Required fields for LegacyDataSourceInfo job_id: 'job-1', url: '', }, dataset_process_rule_id: 'rule-1', - name: 'Test Document', + name: 'Document 1', created_from: 'web', created_by: 'user-1', created_at: Date.now(), @@ -62,937 +50,146 @@ const createMockDocument = (overrides: Partial = {}): Simp hit_count: 0, data_source_detail_dict: { upload_file: { - name: 'test-file.txt', - extension: 'txt', + name: 'document.pdf', + extension: 'pdf', }, }, ...overrides, }) -// Factory function to create multiple documents -const createMockDocumentList = (count: number): SimpleDocumentDetail[] => { - return Array.from({ length: count }, (_, index) => - createMockDocument({ - id: `doc-${index + 1}`, - name: `Document ${index + 1}`, - data_source_detail_dict: { - upload_file: { - name: `document-${index + 1}.pdf`, - extension: 'pdf', - }, - }, - })) -} - -// Factory function to create props -const createDefaultProps = (overrides: Partial> = {}) => ({ +const createProps = (overrides: Partial> = {}) => ({ datasetId: 'dataset-1', - value: { - name: 'Test Document', - extension: 'txt', - chunkingMode: ChunkingMode.text, - parentMode: undefined as ParentMode | undefined, - }, + value: createDocument({ id: 'doc-1', name: 'Document 1' }), onChange: vi.fn(), ...overrides, }) -// Create a new QueryClient for each test -const createTestQueryClient = () => - new QueryClient({ +const renderDocumentPicker = (props: Partial> = {}) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, - gcTime: 0, - staleTime: 0, }, }, }) - -// Helper to render component with providers -const renderComponent = (props: Partial> = {}) => { - const queryClient = createTestQueryClient() - const defaultProps = createDefaultProps(props) + const defaultProps = createProps(props) return { + props: defaultProps, ...render( , ), - queryClient, - props: defaultProps, } } -const openPopover = () => { - fireEvent.click(screen.getByTestId('popover-trigger')) -} - describe('DocumentPicker', () => { beforeEach(() => { vi.clearAllMocks() - // Reset mock state - mockDocumentListData = { data: createMockDocumentList(5) } - mockDocumentListLoading = false + mockDocumentListData = { + data: [ + createDocument({ id: 'doc-1', name: 'Document 1' }), + createDocument({ id: 'doc-2', name: 'Document 2' }), + ], + } + mockUseDocumentList.mockImplementation(() => ({ + data: mockDocumentListData, + })) }) - // Tests for basic rendering - describe('Rendering', () => { - it('should render without crashing', () => { - renderComponent() - - expect(screen.getByTestId('popover')).toBeInTheDocument() + it('should render the current document and chunking mode', () => { + renderDocumentPicker({ + value: createDocument({ + id: 'current-doc', + name: 'Current Document', + doc_form: ChunkingMode.parentChild, + }), + parentMode: 'paragraph', }) - it('should render document name when provided', () => { - renderComponent({ - value: { - name: 'My Document', - extension: 'pdf', - chunkingMode: ChunkingMode.text, - }, - }) - - expect(screen.getByText('My Document')).toBeInTheDocument() - }) - - it('should render placeholder when name is not provided', () => { - renderComponent({ - value: { - name: undefined, - extension: 'pdf', - chunkingMode: ChunkingMode.text, - }, - }) - - expect(screen.getByText('--')).toBeInTheDocument() - }) - - it('should render general mode label', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) - - expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument() - }) - - it('should render QA mode label', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.qa, - }, - }) - - expect(screen.getByText('dataset.chunkingMode.qa')).toBeInTheDocument() - }) - - it('should render parentChild mode label with paragraph parent mode', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'paragraph', - }, - }) - - expect(screen.getByText(/dataset.chunkingMode.parentChild/)).toBeInTheDocument() - expect(screen.getByText(/dataset.parentMode.paragraph/)).toBeInTheDocument() - }) - - it('should render parentChild mode label with full-doc parent mode', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'full-doc', - }, - }) - - expect(screen.getByText(/dataset.chunkingMode.parentChild/)).toBeInTheDocument() - expect(screen.getByText(/dataset.parentMode.fullDoc/)).toBeInTheDocument() - }) - - it('should render placeholder for parentMode when not provided', () => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: undefined, - }, - }) - - // parentModeLabel should be '--' when parentMode is not provided - expect(screen.getByText(/--/)).toBeInTheDocument() - }) + expect(screen.getByRole('combobox', { name: 'Current Document' })).toBeInTheDocument() + expect(screen.getByText(/dataset.chunkingMode.parentChild/)).toBeInTheDocument() + expect(screen.getByText(/dataset.parentMode.paragraph/)).toBeInTheDocument() }) - // Tests for props handling - describe('Props', () => { - it('should accept required props', () => { - const onChange = vi.fn() - renderComponent({ - datasetId: 'test-dataset', - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - onChange, - }) + it('should fetch documents with the current dataset and search keyword', async () => { + const user = userEvent.setup() + renderDocumentPicker({ datasetId: 'dataset-custom' }) - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) + await user.click(screen.getByRole('combobox', { name: 'Document 1' })) + await user.type(screen.getByPlaceholderText('common.operation.search'), 'report') - it('should handle value with all fields', () => { - renderComponent({ - value: { - name: 'Full Document', - extension: 'docx', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'paragraph', - }, - }) - - expect(screen.getByText('Full Document')).toBeInTheDocument() - }) - - it('should handle value with minimal fields', () => { - renderComponent({ - value: { - name: undefined, - extension: undefined, - chunkingMode: undefined, - parentMode: undefined, - }, - }) - - expect(screen.getByText('--')).toBeInTheDocument() - }) - - it('should pass datasetId to mockUseDocumentList hook', () => { - renderComponent({ datasetId: 'custom-dataset-id' }) - - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - datasetId: 'custom-dataset-id', - }), - ) - }) - }) - - // Tests for state management and updates - describe('State Management', () => { - it('should initialize with popup closed', () => { - renderComponent() - - expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false') - }) - - it('should open popup when trigger is clicked', () => { - renderComponent() - - const trigger = screen.getByTestId('popover-trigger') - fireEvent.click(trigger) - - // Verify click handler is called - expect(trigger).toBeInTheDocument() - }) - - it('should maintain search query state', async () => { - renderComponent() - - // Initial call should have empty keyword - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - keyword: '', - }), - }), - ) - }) - - it('should update query when search input changes', () => { - renderComponent() - - // Verify the component uses mockUseDocumentList with query parameter - - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - keyword: '', - }), - }), - ) - }) - }) - - // Tests for callback stability and memoization - describe('Callback Stability', () => { - it('should maintain stable onChange callback when value changes', () => { - const onChange = vi.fn() - const value1 = { - name: 'Doc 1', - extension: 'txt', - chunkingMode: ChunkingMode.text, - } - const value2 = { - name: 'Doc 2', - extension: 'pdf', - chunkingMode: ChunkingMode.text, - } - - const queryClient = createTestQueryClient() - const { rerender } = render( - - - , - ) - - rerender( - - - , - ) - - // Component should still render correctly after rerender - expect(screen.getByText('Doc 2')).toBeInTheDocument() - }) - - it('should use updated onChange callback after rerender', () => { - const onChange1 = vi.fn() - const onChange2 = vi.fn() - const value = { - name: 'Test Doc', - extension: 'txt', - chunkingMode: ChunkingMode.text, - } - - const queryClient = createTestQueryClient() - const { rerender } = render( - - - , - ) - - rerender( - - - , - ) - - // The component should use the new callback - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should memoize handleChange callback with useCallback', () => { - // The handleChange callback is created with useCallback and depends on - // documentsList, onChange, and setOpen - const onChange = vi.fn() - renderComponent({ onChange }) - - // Verify component renders correctly, callback memoization is internal - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - }) - - // Tests for memoization logic and dependencies - describe('Memoization Logic', () => { - it('should be wrapped with React.memo', () => { - // React.memo components have a $$typeof property - expect((DocumentPicker as unknown as { $$typeof: symbol }).$$typeof).toBeDefined() - }) - - it('should compute parentModeLabel correctly with useMemo', () => { - // Test paragraph mode - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'paragraph', - }, - }) - - expect(screen.getByText(/dataset.parentMode.paragraph/)).toBeInTheDocument() - }) - - it('should update parentModeLabel when parentMode changes', () => { - // Test full-doc mode - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: 'full-doc', - }, - }) - - expect(screen.getByText(/dataset.parentMode.fullDoc/)).toBeInTheDocument() - }) - - it('should not re-render when props are the same', () => { - const onChange = vi.fn() - const value = { - name: 'Stable Doc', - extension: 'txt', - chunkingMode: ChunkingMode.text, - } - - const queryClient = createTestQueryClient() - const { rerender } = render( - - - , - ) - - // Rerender with same props reference - rerender( - - - , - ) - - expect(screen.getByText('Stable Doc')).toBeInTheDocument() - }) - }) - - // Tests for user interactions and event handlers - describe('User Interactions', () => { - it('should toggle popup when trigger is clicked', () => { - renderComponent() - - const trigger = screen.getByTestId('popover-trigger') - fireEvent.click(trigger) - - // Trigger click should be handled - expect(trigger).toBeInTheDocument() - }) - - it('should handle document selection when popup is open', () => { - // Test the handleChange callback logic - const onChange = vi.fn() - const mockDocs = createMockDocumentList(3) - mockDocumentListData = { data: mockDocs } - - renderComponent({ onChange }) - - // The handleChange callback should find the document and call onChange - // We can verify this by checking that mockUseDocumentList was called - - expect(mockUseDocumentList).toHaveBeenCalled() - }) - - it('should handle search input change', () => { - renderComponent() - - // The search input is only visible when popup is open - // We verify that the component initializes with empty query - - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - keyword: '', - }), - }), - ) - }) - - it('should initialize with default query parameters', () => { - renderComponent() - - expect(mockUseDocumentList).toHaveBeenCalledWith( - expect.objectContaining({ - query: { - keyword: '', - page: 1, - limit: 20, - }, - }), - ) - }) - }) - - // Tests for API calls - describe('API Calls', () => { - it('should call mockUseDocumentList with correct parameters', () => { - renderComponent({ datasetId: 'test-dataset-123' }) - - expect(mockUseDocumentList).toHaveBeenCalledWith({ - datasetId: 'test-dataset-123', + await waitFor(() => { + expect(mockUseDocumentList).toHaveBeenLastCalledWith({ + datasetId: 'dataset-custom', query: { - keyword: '', + keyword: 'report', page: 1, limit: 20, }, }) }) - - it('should handle loading state', () => { - mockDocumentListLoading = true - mockDocumentListData = undefined - - renderComponent() - - // When loading, component should still render without crashing - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should fetch documents on mount', () => { - mockDocumentListLoading = false - mockDocumentListData = { data: createMockDocumentList(3) } - - renderComponent() - - // Verify the hook was called - - expect(mockUseDocumentList).toHaveBeenCalled() - }) - - it('should handle empty document list', () => { - mockDocumentListData = { data: [] } - - renderComponent() - - // Component should render without crashing - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should handle undefined data response', () => { - mockDocumentListData = undefined - - renderComponent() - - // Should not crash - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) }) - // Tests for component memoization - describe('Component Memoization', () => { - it('should export as React.memo wrapped component', () => { - // Check that the component is memoized - expect(DocumentPicker).toBeDefined() - expect(typeof DocumentPicker).toBe('object') // React.memo returns an object - }) + it('should keep focus in the search input while deleting quickly', async () => { + const user = userEvent.setup() + renderDocumentPicker() - it('should preserve render output when datasetId is the same', () => { - const queryClient = createTestQueryClient() - const value = { - name: 'Memo Test', - extension: 'txt', - chunkingMode: ChunkingMode.text, - } - const onChange = vi.fn() + const trigger = screen.getByRole('combobox', { name: 'Document 1' }) + await user.click(trigger) - const { rerender } = render( - - - , - ) + const searchInput = screen.getByPlaceholderText('common.operation.search') + await user.type(searchInput, 'report') + await user.keyboard('{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}') - expect(screen.getByText('Memo Test')).toBeInTheDocument() - - rerender( - - - , - ) - - expect(screen.getByText('Memo Test')).toBeInTheDocument() - }) + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(searchInput).toHaveFocus() + expect(trigger).not.toHaveFocus() }) - // Tests for edge cases and error handling - describe('Edge Cases', () => { - it('should handle null name', () => { - renderComponent({ - value: { - name: undefined, - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) + it('should keep focus in the search input while typing quickly', async () => { + const user = userEvent.setup() + renderDocumentPicker() - expect(screen.getByText('--')).toBeInTheDocument() - }) + const trigger = screen.getByRole('combobox', { name: 'Document 1' }) + await user.click(trigger) - it('should handle empty string name', () => { - renderComponent({ - value: { - name: '', - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) + const searchInput = screen.getByPlaceholderText('common.operation.search') + await user.keyboard('quarterly-report-final') - // Empty string is falsy, so should show '--' - expect(screen.queryByText('--')).toBeInTheDocument() - }) - - it('should handle undefined extension', () => { - renderComponent({ - value: { - name: 'Test Doc', - extension: undefined, - chunkingMode: ChunkingMode.text, - }, - }) - - // Should not crash - expect(screen.getByText('Test Doc')).toBeInTheDocument() - }) - - it('should handle undefined chunkingMode', () => { - renderComponent({ - value: { - name: 'Test Doc', - extension: 'txt', - chunkingMode: undefined, - }, - }) - - // When chunkingMode is undefined, none of the mode conditions are true - expect(screen.getByText('Test Doc')).toBeInTheDocument() - }) - - it('should handle document without data_source_detail_dict', () => { - const docWithoutDetail = createMockDocument({ - id: 'doc-no-detail', - name: 'Doc Without Detail', - data_source_detail_dict: undefined, - }) - mockDocumentListData = { data: [docWithoutDetail] } - - // Component should handle mapping documents even without data_source_detail_dict - renderComponent() - - // Should not crash - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should handle rapid toggle clicks', () => { - renderComponent() - - const trigger = screen.getByTestId('popover-trigger') - - // Rapid clicks - fireEvent.click(trigger) - fireEvent.click(trigger) - fireEvent.click(trigger) - fireEvent.click(trigger) - - // Should not crash - expect(trigger).toBeInTheDocument() - }) - - it('should handle very long document names in trigger', () => { - const longName = 'A'.repeat(500) - renderComponent({ - value: { - name: longName, - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) - - // Should render long name without crashing - expect(screen.getByText(longName)).toBeInTheDocument() - }) - - it('should handle special characters in document name', () => { - const specialName = '' - renderComponent({ - value: { - name: specialName, - extension: 'txt', - chunkingMode: ChunkingMode.text, - }, - }) - - // React should escape the text - expect(screen.getByText(specialName)).toBeInTheDocument() - }) - - it('should handle documents with missing extension in data_source_detail_dict', () => { - const docWithEmptyExtension = createMockDocument({ - id: 'doc-empty-ext', - name: 'Doc Empty Ext', - data_source_detail_dict: { - upload_file: { - name: 'file-no-ext', - extension: '', - }, - }, - }) - mockDocumentListData = { data: [docWithEmptyExtension] } - - // Component should handle mapping documents with empty extension - renderComponent() - - // Should not crash - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should handle document list mapping with various data_source_detail_dict states', () => { - // Test the mapping logic: d.data_source_detail_dict?.upload_file?.extension || '' - const docs = [ - createMockDocument({ - id: 'doc-1', - name: 'With Extension', - data_source_detail_dict: { - upload_file: { name: 'file.pdf', extension: 'pdf' }, - }, - }), - createMockDocument({ - id: 'doc-2', - name: 'Without Detail Dict', - data_source_detail_dict: undefined, - }), - ] - mockDocumentListData = { data: docs } - - renderComponent() - - // Should not crash during mapping - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(searchInput).toHaveFocus() + expect(trigger).not.toHaveFocus() }) - // Tests for all prop variations - describe('Prop Variations', () => { - describe('datasetId variations', () => { - it('should handle empty datasetId', () => { - renderComponent({ datasetId: '' }) + it('should call onChange with the selected document', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const selectedDocument = createDocument({ id: 'doc-2', name: 'Document 2' }) + mockDocumentListData = { + data: [ + createDocument({ id: 'doc-1', name: 'Document 1' }), + selectedDocument, + ], + } - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) + renderDocumentPicker({ onChange }) - it('should handle UUID format datasetId', () => { - renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' }) + await user.click(screen.getByRole('combobox', { name: 'Document 1' })) + await user.click(await screen.findByRole('option', { name: /Document 2/ })) - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - }) - - describe('value.chunkingMode variations', () => { - const chunkingModes = [ - { mode: ChunkingMode.text, label: 'dataset.chunkingMode.general' }, - { mode: ChunkingMode.qa, label: 'dataset.chunkingMode.qa' }, - { mode: ChunkingMode.parentChild, label: 'dataset.chunkingMode.parentChild' }, - ] - - it.each(chunkingModes)( - 'should display correct label for $mode mode', - ({ mode, label }) => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: mode, - parentMode: mode === ChunkingMode.parentChild ? 'paragraph' : undefined, - }, - }) - - expect(screen.getByText(new RegExp(label))).toBeInTheDocument() - }, - ) - }) - - describe('value.parentMode variations', () => { - const parentModes: Array<{ mode: ParentMode, label: string }> = [ - { mode: 'paragraph', label: 'dataset.parentMode.paragraph' }, - { mode: 'full-doc', label: 'dataset.parentMode.fullDoc' }, - ] - - it.each(parentModes)( - 'should display correct label for $mode parentMode', - ({ mode, label }) => { - renderComponent({ - value: { - name: 'Test', - extension: 'txt', - chunkingMode: ChunkingMode.parentChild, - parentMode: mode, - }, - }) - - expect(screen.getByText(new RegExp(label))).toBeInTheDocument() - }, - ) - }) - - describe('value.extension variations', () => { - const extensions = ['txt', 'pdf', 'docx', 'xlsx', 'csv', 'md', 'html'] - - it.each(extensions)('should handle %s extension', (ext) => { - renderComponent({ - value: { - name: `File.${ext}`, - extension: ext, - chunkingMode: ChunkingMode.text, - }, - }) - - expect(screen.getByText(`File.${ext}`)).toBeInTheDocument() - }) - }) + expect(onChange).toHaveBeenCalledWith(selectedDocument) }) - // Tests for document selection - describe('Document Selection', () => { - it('should fetch documents list via mockUseDocumentList', () => { - const mockDoc = createMockDocument({ - id: 'selected-doc', - name: 'Selected Document', - }) - mockDocumentListData = { data: [mockDoc] } - const onChange = vi.fn() + it('should show an empty state when no documents match', async () => { + const user = userEvent.setup() + mockDocumentListData = { data: [] } - renderComponent({ onChange }) + renderDocumentPicker() - // Verify the hook was called + await user.click(screen.getByRole('combobox', { name: 'Document 1' })) - expect(mockUseDocumentList).toHaveBeenCalled() - }) - - it('should call onChange when document is selected', () => { - const docs = createMockDocumentList(3) - mockDocumentListData = { data: docs } - const onChange = vi.fn() - - renderComponent({ onChange }) - openPopover() - - fireEvent.click(screen.getByText('Document 2')) - - // handleChange should find the document and call onChange with full document - expect(onChange).toHaveBeenCalledTimes(1) - expect(onChange).toHaveBeenCalledWith(docs[1]) - }) - - it('should map document list items correctly', () => { - const docs = createMockDocumentList(3) - mockDocumentListData = { data: docs } - - renderComponent() - openPopover() - - // Documents should be rendered in the list - expect(screen.getByText('Document 1')).toBeInTheDocument() - expect(screen.getByText('Document 2')).toBeInTheDocument() - expect(screen.getByText('Document 3')).toBeInTheDocument() - }) - }) - - // Tests for integration with child components - describe('Child Component Integration', () => { - it('should pass correct data to DocumentList when popup is open', () => { - const docs = createMockDocumentList(3) - mockDocumentListData = { data: docs } - - renderComponent() - - // DocumentList receives mapped documents: { id, name, extension } - // We verify the data is fetched - - expect(mockUseDocumentList).toHaveBeenCalled() - }) - - it('should map document data_source_detail_dict extension correctly', () => { - const doc = createMockDocument({ - id: 'mapped-doc', - name: 'Mapped Document', - data_source_detail_dict: { - upload_file: { - name: 'mapped.pdf', - extension: 'pdf', - }, - }, - }) - mockDocumentListData = { data: [doc] } - - renderComponent() - - // The mapping: d.data_source_detail_dict?.upload_file?.extension || '' - // Should extract 'pdf' from the document - expect(screen.getByTestId('popover')).toBeInTheDocument() - }) - - it('should render trigger with SearchInput integration', () => { - renderComponent() - - // The trigger is always rendered - expect(screen.getByTestId('popover-trigger')).toBeInTheDocument() - }) - - it('should integrate FileIcon component', () => { - // Use empty document list to avoid duplicate icons from list - mockDocumentListData = { data: [] } - - renderComponent({ - value: { - name: 'test.pdf', - extension: 'pdf', - chunkingMode: ChunkingMode.text, - }, - }) - - // FileIcon should render an SVG icon for the file extension - const trigger = screen.getByTestId('popover-trigger') - expect(trigger.querySelector('svg')).toBeInTheDocument() - }) - }) - - // Tests for visual states - describe('Visual States', () => { - it('should render portal content for document selection', () => { - renderComponent() - openPopover() - - // Popover content is rendered after opening the trigger in our mock - expect(screen.getByTestId('popover-content')).toBeInTheDocument() - }) + expect(await screen.findByRole('status')).toHaveTextContent('common.noData') }) }) diff --git a/web/app/components/datasets/common/document-picker/document-list.tsx b/web/app/components/datasets/common/document-picker/document-list.tsx index d2d8d1966c..366e744cbd 100644 --- a/web/app/components/datasets/common/document-picker/document-list.tsx +++ b/web/app/components/datasets/common/document-picker/document-list.tsx @@ -1,43 +1,49 @@ 'use client' -import type { FC } from 'react' -import type { DocumentItem } from '@/models/datasets' +import type { SimpleDocumentDetail } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useCallback } from 'react' +import { + ComboboxItem, + ComboboxItemText, + ComboboxList, +} from '@langgenius/dify-ui/combobox' import FileIcon from '../document-file-icon' type Props = { className?: string - list: DocumentItem[] - onChange: (value: DocumentItem) => void } -const DocumentList: FC = ({ - className, - list, - onChange, -}) => { - const handleChange = useCallback((item: DocumentItem) => { - return () => onChange(item) - }, [onChange]) +function getDocumentExtension(document: SimpleDocumentDetail) { + const detailExtension = document.data_source_detail_dict?.upload_file?.extension + if (detailExtension) + return detailExtension + const dataSourceInfo = document.data_source_info + if (dataSourceInfo && 'upload_file' in dataSourceInfo) + return dataSourceInfo.upload_file.extension + + return '' +} + +export default function DocumentList({ + className, +}: Props) { return ( -
- {list.map((item) => { - const { id, name, extension } = item + + {(item: SimpleDocumentDetail) => { + const extension = getDocumentExtension(item) return ( -
-
{name}
-
+ + {item.name} + + ) - })} -
+ }} + ) } - -export default React.memo(DocumentList) diff --git a/web/app/components/datasets/common/document-picker/index.tsx b/web/app/components/datasets/common/document-picker/index.tsx index 0566b590de..07f7443764 100644 --- a/web/app/components/datasets/common/document-picker/index.tsx +++ b/web/app/components/datasets/common/document-picker/index.tsx @@ -1,20 +1,22 @@ 'use client' -import type { FC } from 'react' -import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets' +import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox' +import type { ParentMode, SimpleDocumentDetail } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@langgenius/dify-ui/popover' + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxStatus, + ComboboxTrigger, + ComboboxValue, +} from '@langgenius/dify-ui/combobox' import { RiArrowDownSLine } from '@remixicon/react' -import { useBoolean } from 'ahooks' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' +import { useDeferredValue, useState } from 'react' import { useTranslation } from 'react-i18next' import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge' import Loading from '@/app/components/base/loading' -import SearchInput from '@/app/components/base/search-input' import { ChunkingMode } from '@/models/datasets' import { useDocumentList } from '@/service/knowledge/use-document' import FileIcon from '../document-file-icon' @@ -22,116 +24,177 @@ import DocumentList from './document-list' type Props = { datasetId: string - value: { - name?: string - extension?: string - chunkingMode?: ChunkingMode - parentMode?: ParentMode - } + value?: SimpleDocumentDetail | null + parentMode?: ParentMode onChange: (value: SimpleDocumentDetail) => void } -const DocumentPicker: FC = ({ +function getDocumentLabel(document: SimpleDocumentDetail) { + return document.name +} + +function getDocumentValue(document: SimpleDocumentDetail) { + return document.id +} + +function isSameDocument(item: SimpleDocumentDetail, value: SimpleDocumentDetail) { + return item.id === value.id +} + +function getDocumentExtension(document?: SimpleDocumentDetail | null) { + if (!document) + return '' + + const detailExtension = document.data_source_detail_dict?.upload_file?.extension + if (detailExtension) + return detailExtension + + const dataSourceInfo = document.data_source_info + if (dataSourceInfo && 'upload_file' in dataSourceInfo) + return dataSourceInfo.upload_file.extension + + return '' +} + +function DocumentPickerTriggerValue({ + document, + parentMode, +}: { + document?: SimpleDocumentDetail | null + parentMode?: ParentMode +}) { + const { t } = useTranslation() + const isGeneralMode = document?.doc_form === ChunkingMode.text + const isParentChild = document?.doc_form === ChunkingMode.parentChild + const isQAMode = document?.doc_form === ChunkingMode.qa + const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk + const ArrowIcon = RiArrowDownSLine + const parentModeLabel = (() => { + if (!parentMode) + return '--' + return parentMode === 'paragraph' ? t('parentMode.paragraph', { ns: 'dataset' }) : t('parentMode.fullDoc', { ns: 'dataset' }) + })() + + return ( + + + + + + {document?.name || '--'} + + + + + + {isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })} + {isQAMode && t('chunkingMode.qa', { ns: 'dataset' })} + {isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`} + + + + + ) +} + +export function DocumentPicker({ datasetId, value, + parentMode, onChange, -}) => { +}: Props) { const { t } = useTranslation() - const { - name, - extension, - chunkingMode, - parentMode, - } = value - const [query, setQuery] = useState('') + const [searchValue, setSearchValue] = useState('') + const deferredSearchValue = useDeferredValue(searchValue) const { data } = useDocumentList({ datasetId, query: { - keyword: query, + keyword: deferredSearchValue, page: 1, limit: 20, }, }) - const documentsList = data?.data - const isGeneralMode = chunkingMode === ChunkingMode.text - const isParentChild = chunkingMode === ChunkingMode.parentChild - const isQAMode = chunkingMode === ChunkingMode.qa - const TypeIcon = isParentChild ? ParentChildChunk : GeneralChunk + const documentsList = data?.data ?? [] - const [open, { - set: setOpen, - }] = useBoolean(false) - const ArrowIcon = RiArrowDownSLine + const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => { + if (details.reason !== 'item-press') + setSearchValue(inputValue) + } - const handleChange = useCallback(({ id }: DocumentItem) => { - onChange(documentsList?.find(item => item.id === id) as SimpleDocumentDetail) - setOpen(false) - }, [documentsList, onChange, setOpen]) + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) + setSearchValue('') + } - const parentModeLabel = useMemo(() => { - if (!parentMode) - return '--' - return parentMode === 'paragraph' ? t('parentMode.paragraph', { ns: 'dataset' }) : t('parentMode.fullDoc', { ns: 'dataset' }) - }, [parentMode, t]) + const handleDocumentChange = (document: SimpleDocumentDetail | null) => { + if (!document) + return + + onChange(document) + setSearchValue('') + } return ( - + items={documentsList} + value={value ?? null} + inputValue={searchValue} + onOpenChange={handleOpenChange} + onInputValueChange={handleInputValueChange} + onValueChange={handleDocumentChange} + isItemEqualToValue={isSameDocument} + itemToStringLabel={getDocumentLabel} + itemToStringValue={getDocumentValue} + filter={null} > - - -
-
- - {' '} - {name || '--'} - - -
-
- - - {isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })} - {isQAMode && t('chunkingMode.qa', { ns: 'dataset' })} - {isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`} - -
-
- + - + + {(document: SimpleDocumentDetail | null) => ( + + )} + + + -
- - {documentsList - ? ( - ({ - id: d.id, - name: d.name, - extension: d.data_source_detail_dict?.upload_file?.extension || '', - }))} - onChange={handleChange} - /> - ) - : ( -
- -
- )} -
- -
+ + + {data + ? ( + documentsList.length > 0 + ? ( + + ) + : ( + + {t('noData', { ns: 'common' })} + + ) + ) + : ( + + + + )} + + ) } -export default React.memo(DocumentPicker) diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx index 597ceda9a5..fb90bf57f7 100644 --- a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx +++ b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx @@ -14,7 +14,6 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import FileIcon from '../document-file-icon' -import DocumentList from './document-list' type Props = { className?: string @@ -74,7 +73,7 @@ const PreviewDocumentPicker: FC = ({ {files?.length > 1 &&
{t('preprocessDocument', { ns: 'dataset', num: files.length })}
} {files?.length > 0 ? ( - @@ -90,3 +89,27 @@ const PreviewDocumentPicker: FC = ({ ) } export default React.memo(PreviewDocumentPicker) + +function PreviewDocumentList({ + list, + onChange, +}: { + list: DocumentItem[] + onChange: (value: DocumentItem) => void +}) { + return ( +
+ {list.map(item => ( + + ))} +
+ ) +} diff --git a/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx b/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx index ee8b4eff1e..0688adf1c6 100644 --- a/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx +++ b/web/app/components/datasets/common/document-status-with-action/__tests__/status-with-action.spec.tsx @@ -60,7 +60,7 @@ describe('StatusWithAction', () => { onAction={onAction} />, ) - expect(screen.getByText('Click me')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() }) it('should not render action button when onAction is not provided', () => { @@ -92,12 +92,11 @@ describe('StatusWithAction', () => { />, ) - fireEvent.click(screen.getByText('Click me')) + fireEvent.click(screen.getByRole('button', { name: 'Click me' })) expect(onAction).toHaveBeenCalledTimes(1) }) - it('should call onAction even when disabled (style only)', () => { - // Note: disabled prop only affects styling, not actual click behavior + it('should not call onAction when disabled', () => { const onAction = vi.fn() render( { />, ) - fireEvent.click(screen.getByText('Click me')) - expect(onAction).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByRole('button', { name: 'Click me' })) + expect(onAction).not.toHaveBeenCalled() }) it('should apply disabled styles when disabled prop is true', () => { @@ -122,9 +121,10 @@ describe('StatusWithAction', () => { />, ) - const actionButton = screen.getByText('Click me') + const actionButton = screen.getByRole('button', { name: 'Click me' }) expect(actionButton).toHaveClass('cursor-not-allowed') expect(actionButton).toHaveClass('text-text-disabled') + expect(actionButton).toBeDisabled() }) }) diff --git a/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx b/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx index 3c2536fa5e..08053aa7bf 100644 --- a/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx +++ b/web/app/components/datasets/common/document-status-with-action/status-with-action.tsx @@ -58,10 +58,17 @@ const StatusAction: FC = ({
{description}
- {onAction && ( + {onAction && actionText && ( <> -
{actionText}
+ )}
diff --git a/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx b/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx index cc60160595..12d44a3eec 100644 --- a/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/image-list/__tests__/index.spec.tsx @@ -135,7 +135,7 @@ describe('ImageList', () => { const images = createMockImages(15) render() - const moreButton = screen.getByText(/\+6/) + const moreButton = screen.getByRole('button', { name: '+6' }) fireEvent.click(moreButton) // More button should disappear diff --git a/web/app/components/datasets/common/image-list/__tests__/more.spec.tsx b/web/app/components/datasets/common/image-list/__tests__/more.spec.tsx index cc6341694a..a192c7acc6 100644 --- a/web/app/components/datasets/common/image-list/__tests__/more.spec.tsx +++ b/web/app/components/datasets/common/image-list/__tests__/more.spec.tsx @@ -47,7 +47,7 @@ describe('More', () => { const onClick = vi.fn() render() - fireEvent.click(screen.getByText('+5')) + fireEvent.click(screen.getByRole('button', { name: '+5' })) expect(onClick).toHaveBeenCalledTimes(1) }) @@ -56,7 +56,7 @@ describe('More', () => { // Should not throw expect(() => { - fireEvent.click(screen.getByText('+5')) + fireEvent.click(screen.getByRole('button', { name: '+5' })) }).not.toThrow() }) @@ -70,7 +70,7 @@ describe('More', () => { , ) - fireEvent.click(screen.getByText('+5')) + fireEvent.click(screen.getByRole('button', { name: '+5' })) expect(childClick).toHaveBeenCalled() expect(parentClick).not.toHaveBeenCalled() }) diff --git a/web/app/components/datasets/common/image-list/more.tsx b/web/app/components/datasets/common/image-list/more.tsx index 255e5e4d87..39f6367556 100644 --- a/web/app/components/datasets/common/image-list/more.tsx +++ b/web/app/components/datasets/common/image-list/more.tsx @@ -17,23 +17,29 @@ const More = ({ count, onClick }: MoreProps) => { return `${(num / 1000000).toFixed(1)}M` } - const handleClick = useCallback((e: React.MouseEvent) => { + const handleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation() e.preventDefault() onClick?.() }, [onClick]) + const label = `+${formatNumber(count)}` + return ( -
+ ) } diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx index b5c73f3422..2021320616 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx @@ -1352,7 +1352,7 @@ describe('Uploader', () => { ) expect(screen.getByText('app.dslUploader.button'))!.toBeInTheDocument() - expect(screen.getByText('app.dslUploader.browse'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'app.dslUploader.browse' }))!.toBeInTheDocument() }) it('should render file info when file is selected', () => { @@ -1436,7 +1436,7 @@ describe('Uploader', () => { />, ) - const browseLink = screen.getByText('app.dslUploader.browse') + const browseLink = screen.getByRole('button', { name: 'app.dslUploader.browse' }) const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement // Mock click on input @@ -1659,7 +1659,7 @@ describe('Uploader', () => { // After click, oncancel should be set }) - const browseLink = screen.getByText('app.dslUploader.browse') + const browseLink = screen.getByRole('button', { name: 'app.dslUploader.browse' }) fireEvent.click(browseLink) // selectHandle should have triggered click on input diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx index f4263ab439..bab1d505b7 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/uploader.spec.tsx @@ -50,7 +50,7 @@ describe('Uploader', () => { it('should render browse link when no file', () => { render() - expect(screen.getByText(/dslUploader\.browse/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /dslUploader\.browse/i })).toBeInTheDocument() }) it('should render upload icon when no file', () => { @@ -108,7 +108,7 @@ describe('Uploader', () => { const input = document.getElementById('fileUploader') as HTMLInputElement const clickSpy = vi.spyOn(input, 'click') - const browseLink = screen.getByText(/dslUploader\.browse/i) + const browseLink = screen.getByRole('button', { name: /dslUploader\.browse/i }) fireEvent.click(browseLink) expect(clickSpy).toHaveBeenCalled() diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx index f0499c6965..9b4cf2c488 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/uploader.tsx @@ -90,9 +90,13 @@ const Uploader: FC = ({ file, updateFile, className }) => {
{t('dslUploader.button', { ns: 'app' })} - +
{dragging &&
} diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx index a9b91fb1bc..32b7b1d17d 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx @@ -148,14 +148,8 @@ describe('EmptyDatasetCreationModal', () => { const mockOnHide = vi.fn() render() - // Act - Wait for modal to be rendered, then find the close span - // The close span is located in the modalHeader div, next to the title - const titleElement = await screen.findByText('datasetCreation.stepOne.modal.title') - const headerDiv = titleElement.parentElement - const closeButton = headerDiv?.querySelector('span') - - expect(closeButton).toBeInTheDocument() - fireEvent.click(closeButton!) + const closeButton = await screen.findByRole('button', { name: /operation\.close$/ }) + fireEvent.click(closeButton) expect(mockOnHide).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx index 0de10a8ecd..caf84e31ac 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx @@ -57,7 +57,12 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => {
{t('stepOne.modal.title', { ns: 'datasetCreation' })}
- +
{t('stepOne.modal.tip', { ns: 'datasetCreation' })}
diff --git a/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx b/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx index 6839a8cc87..c016807186 100644 --- a/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/file-preview/__tests__/index.spec.tsx @@ -73,9 +73,9 @@ describe('FilePreview', () => { }) it('should render close button with XMarkIcon', async () => { - const { container } = renderFilePreview() + renderFilePreview() - const closeButton = container.querySelector('.cursor-pointer') + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) expect(closeButton)!.toBeInTheDocument() const xMarkIcon = closeButton?.querySelector('svg') expect(xMarkIcon)!.toBeInTheDocument() @@ -269,20 +269,18 @@ describe('FilePreview', () => { describe('User Interactions', () => { it('should call hidePreview when close button is clicked', async () => { const hidePreview = vi.fn() - const { container } = renderFilePreview({ hidePreview }) + renderFilePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(hidePreview).toHaveBeenCalledTimes(1) }) it('should call hidePreview with event object when clicked', async () => { const hidePreview = vi.fn() - const { container } = renderFilePreview({ hidePreview }) + renderFilePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) // Assert - onClick receives the event object expect(hidePreview).toHaveBeenCalled() @@ -291,9 +289,9 @@ describe('FilePreview', () => { it('should handle multiple clicks on close button', async () => { const hidePreview = vi.fn() - const { container } = renderFilePreview({ hidePreview }) + renderFilePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) fireEvent.click(closeButton) fireEvent.click(closeButton) fireEvent.click(closeButton) diff --git a/web/app/components/datasets/create/file-preview/index.tsx b/web/app/components/datasets/create/file-preview/index.tsx index 2b4041eaf2..ae1cdd3dd1 100644 --- a/web/app/components/datasets/create/file-preview/index.tsx +++ b/web/app/components/datasets/create/file-preview/index.tsx @@ -50,9 +50,14 @@ const FilePreview = ({
{t('stepOne.filePreview', { ns: 'datasetCreation' })} -
- -
+
{getFileName(file)} diff --git a/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx index 572677ced7..d69a3b5513 100644 --- a/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx @@ -118,9 +118,9 @@ describe('NotionPagePreview', () => { }) it('should render close button with XMarkIcon', async () => { - const { container } = await renderNotionPagePreview() + await renderNotionPagePreview() - const closeButton = container.querySelector('.cursor-pointer') + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) expect(closeButton).toBeInTheDocument() const xMarkIcon = closeButton?.querySelector('svg') expect(xMarkIcon).toBeInTheDocument() @@ -348,19 +348,18 @@ describe('NotionPagePreview', () => { describe('User Interactions', () => { it('should call hidePreview when close button is clicked', async () => { const hidePreview = vi.fn() - const { container } = await renderNotionPagePreview({ hidePreview }) + await renderNotionPagePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(hidePreview).toHaveBeenCalledTimes(1) }) it('should handle multiple clicks on close button', async () => { const hidePreview = vi.fn() - const { container } = await renderNotionPagePreview({ hidePreview }) + await renderNotionPagePreview({ hidePreview }) - const closeButton = container.querySelector('.cursor-pointer') as HTMLElement + const closeButton = screen.getByRole('button', { name: /operation\.close$/ }) fireEvent.click(closeButton) fireEvent.click(closeButton) fireEvent.click(closeButton) diff --git a/web/app/components/datasets/create/notion-page-preview/index.tsx b/web/app/components/datasets/create/notion-page-preview/index.tsx index 116090413a..c2eed225c0 100644 --- a/web/app/components/datasets/create/notion-page-preview/index.tsx +++ b/web/app/components/datasets/create/notion-page-preview/index.tsx @@ -52,9 +52,14 @@ const NotionPagePreview = ({
{t('stepOne.pagePreview', { ns: 'datasetCreation' })} -
- -
+
= ({ onSelect={onDocLanguageChange} disabled={currentDocForm !== ChunkingMode.qa} /> - - - - - )} - /> - - {t('stepTwo.QATip', { ns: 'datasetCreation' })} - - + + {t('stepTwo.QATip', { ns: 'datasetCreation' })} +
{currentDocForm === ChunkingMode.qa && (
{ it('should render buttons in correct order (cancel first, then confirm)', () => { renderStopEmbeddingModal({ show: true }) - // Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonCancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonConfirm' })).toBeInTheDocument() }) it('should render confirm button with primary variant styling', () => { @@ -290,48 +289,28 @@ describe('StopEmbeddingModal', () => { }) describe('Close Icon', () => { - it('should call onHide when close span is clicked', async () => { + it('should call onHide when close button is clicked', async () => { const onConfirm = vi.fn() const onHide = vi.fn() - const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) + renderStopEmbeddingModal({ onConfirm, onHide }) - // Act - Find the close span (it should be the span with onClick handler) - const spans = container.querySelectorAll('span') - const closeSpan = Array.from(spans).find(span => - span.className && span.getAttribute('class')?.includes('close'), - ) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) + }) - if (closeSpan) { - await act(async () => { - fireEvent.click(closeSpan) - }) - - expect(onHide).toHaveBeenCalledTimes(1) - } - else { - // If no close span found with class, just verify the modal renders - // If no close span found with class, just verify the modal renders - expect(screen.getByText('datasetCreation.stepThree.modelTitle'))!.toBeInTheDocument() - } + expect(onHide).toHaveBeenCalledTimes(1) }) - it('should not call onConfirm when close span is clicked', async () => { + it('should not call onConfirm when close button is clicked', async () => { const onConfirm = vi.fn() const onHide = vi.fn() - const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) + renderStopEmbeddingModal({ onConfirm, onHide }) - const spans = container.querySelectorAll('span') - const closeSpan = Array.from(spans).find(span => - span.className && span.getAttribute('class')?.includes('close'), - ) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) + }) - if (closeSpan) { - await act(async () => { - fireEvent.click(closeSpan) - }) - - expect(onConfirm).not.toHaveBeenCalled() - } + expect(onConfirm).not.toHaveBeenCalled() }) }) @@ -444,8 +423,8 @@ describe('StopEmbeddingModal', () => { it('should have buttons container with flex-row-reverse', () => { renderStopEmbeddingModal({ show: true }) - const buttons = screen.getAllByRole('button') - expect(buttons[0]!.closest('div'))!.toHaveClass('flex', 'flex-row-reverse') + const confirmButton = screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonConfirm' }) + expect(confirmButton.closest('div'))!.toHaveClass('flex', 'flex-row-reverse') }) it('should render title and content elements', () => { @@ -455,11 +434,11 @@ describe('StopEmbeddingModal', () => { expect(screen.getByText('datasetCreation.stepThree.modelContent'))!.toBeInTheDocument() }) - it('should render two buttons', () => { + it('should render two action buttons', () => { renderStopEmbeddingModal({ show: true }) - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonCancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonConfirm' })).toBeInTheDocument() }) }) @@ -563,8 +542,9 @@ describe('StopEmbeddingModal', () => { it('should have semantic button elements', () => { renderStopEmbeddingModal({ show: true }) - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) + expect(screen.getByRole('button', { name: /operation\.close$/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonCancel' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'datasetCreation.stepThree.modelButtonConfirm' })).toBeInTheDocument() }) it('should have accessible text content', () => { diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.tsx b/web/app/components/datasets/create/stop-embedding-modal/index.tsx index 41faf58b09..a696f90585 100644 --- a/web/app/components/datasets/create/stop-embedding-modal/index.tsx +++ b/web/app/components/datasets/create/stop-embedding-modal/index.tsx @@ -41,7 +41,12 @@ const StopEmbeddingModal = ({ >
- + diff --git a/web/app/components/datasets/create/website/preview.tsx b/web/app/components/datasets/create/website/preview.tsx index a46437325f..edbd72d9a1 100644 --- a/web/app/components/datasets/create/website/preview.tsx +++ b/web/app/components/datasets/create/website/preview.tsx @@ -22,9 +22,14 @@ const WebsitePreview = ({
{t('stepOne.pagePreview', { ns: 'datasetCreation' })} -
- -
+
{payload.title} diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index 7dc184aee4..d4bd828de6 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -192,13 +192,15 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele closeOperationsMenu() setShowModal(true) }, [closeOperationsMenu]) - const handleDownloadClick = useCallback((evt: React.MouseEvent) => { + const handleDownloadClick = useCallback((evt: React.MouseEvent) => { evt.preventDefault() evt.stopPropagation() evt.nativeEvent.stopImmediatePropagation?.() closeOperationsMenu() void handleDownload() }, [closeOperationsMenu, handleDownload]) + const menuActionClassName = cn(s.actionItem, 'border-none bg-transparent') + const menuDeleteActionClassName = cn(menuActionClassName, s.deleteActionItem, 'group') return (
e.stopPropagation()}> {isListScene && !embeddingAvailable && ()} @@ -227,7 +229,7 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele aria-label={t('list.action.settings', { ns: 'datasetDocuments' })} className={cn('mr-2 cursor-pointer rounded-lg', !isListScene ? 'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover' - : 'p-0.5 hover:bg-state-base-hover')} + : 'border-none bg-transparent p-0.5 hover:bg-state-base-hover')} onClick={() => router.push(`/datasets/${datasetId}/documents/${detail.id}/settings`)} > @@ -266,68 +268,68 @@ const Operations = ({ embeddingAvailable, datasetId, detail, selectedIds, onSele
{!archived && ( <> -
+
+ {data_source_type === DataSourceType.FILE && ( -
+
+ )} {['notion_import', DataSourceType.WEB].includes(data_source_type) && ( -
handleMenuOperation('sync')}> +
+ )} {IS_CE_EDITION && ( -
handleMenuOperation('summary')}> +
+ )} )} {archived && data_source_type === DataSourceType.FILE && ( <> -
+
+ )} {!archived && display_status?.toLowerCase() === 'indexing' && ( -
handleMenuOperation('pause')}> +
+ )} {!archived && display_status?.toLowerCase() === 'paused' && ( -
handleMenuOperation('resume')}> +
+ )} {!archived && ( -
handleMenuOperation('archive')}> +
+ )} {archived && ( -
handleMenuOperation('un_archive')}> +
+ )} -
+
+
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx index b0cbedd428..4ec1565090 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx @@ -337,7 +337,7 @@ describe('FileList', () => { render() // Act - Click the clear icon div (it contains RiCloseCircleFill icon) - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) @@ -351,7 +351,7 @@ describe('FileList', () => { fireEvent.change(input, { target: { value: 'some-search' } }) // Act - Find and click the clear icon - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx index dcb1922fe9..c4ce60274b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx @@ -320,7 +320,7 @@ describe('Header', () => { render(
) // Act - Find and click the clear icon container - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearButton)!.toBeInTheDocument() fireEvent.click(clearButton!) @@ -332,7 +332,7 @@ describe('Header', () => { render(
) // Act & Assert - Clear icon should not be visible - const clearIcon = screen.queryByTestId('input-clear') + const clearIcon = screen.queryByRole('button', { name: 'common.operation.clear' }) expect(clearIcon).not.toBeInTheDocument() }) @@ -341,7 +341,7 @@ describe('Header', () => { render(
) // Act & Assert - Clear icon should be visible - const clearIcon = screen.getByTestId('input-clear') + const clearIcon = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearIcon)!.toBeInTheDocument() }) }) @@ -582,9 +582,9 @@ describe('Header', () => { const { rerender } = render(
) // Act - Click clear, rerender, click again - fireEvent.click(screen.getByTestId('input-clear')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) rerender(
) - fireEvent.click(screen.getByTestId('input-clear')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2) }) diff --git a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx index 3eb1017b8d..b48575d209 100644 --- a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx @@ -1,6 +1,7 @@ +import type { SimpleDocumentDetail } from '@/models/datasets' import { render } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ChunkingMode } from '@/models/datasets' +import { ChunkingMode, DataSourceType } from '@/models/datasets' import { DocumentTitle } from '../document-title' @@ -11,13 +12,23 @@ vi.mock('@/next/navigation', () => ({ }), })) -// Mock DocumentPicker vi.mock('../../../common/document-picker', () => ({ - default: ({ datasetId, value, onChange }: { datasetId: string, value: unknown, onChange: (doc: { id: string }) => void }) => ( + DocumentPicker: ({ + datasetId, + value, + parentMode, + onChange, + }: { + datasetId: string + value?: SimpleDocumentDetail | null + parentMode?: string + onChange: (doc: { id: string }) => void + }) => (
onChange({ id: 'new-doc-id' })} > Document Picker @@ -25,6 +36,42 @@ vi.mock('../../../common/document-picker', () => ({ ), })) +const createDocument = (overrides: Partial = {}): SimpleDocumentDetail => ({ + id: 'doc-1', + batch: 'batch-1', + position: 1, + dataset_id: 'dataset-1', + data_source_type: DataSourceType.FILE, + data_source_info: { + upload_file: { + id: 'file-1', + name: 'document.pdf', + size: 1024, + extension: 'pdf', + mime_type: 'application/pdf', + created_by: 'user-1', + created_at: Date.now(), + }, + job_id: 'job-1', + url: '', + }, + dataset_process_rule_id: 'rule-1', + name: 'Document 1', + created_from: 'web', + created_by: 'user-1', + created_at: Date.now(), + indexing_status: 'completed', + display_status: 'enabled', + doc_form: ChunkingMode.text, + doc_language: 'en', + enabled: true, + word_count: 1000, + archived: false, + updated_at: Date.now(), + hit_count: 0, + ...overrides, +}) + describe('DocumentTitle', () => { beforeEach(() => { vi.clearAllMocks() @@ -69,31 +116,26 @@ describe('DocumentTitle', () => { expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id') }) - it('should pass value props to DocumentPicker', () => { + it('should pass the selected document to DocumentPicker', () => { + const document = createDocument({ id: 'doc-current' }) const { getByTestId } = render( , ) - const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') - expect(value.name).toBe('test-document') - expect(value.extension).toBe('pdf') - expect(value.chunkingMode).toBe(ChunkingMode.text) - expect(value.parentMode).toBe('paragraph') + expect(getByTestId('document-picker')).toHaveAttribute('data-value-id', 'doc-current') + expect(getByTestId('document-picker')).toHaveAttribute('data-parent-mode', 'paragraph') }) - it('should default parentMode to paragraph when parent_mode is undefined', () => { + it('should pass no parent mode when it is undefined', () => { const { getByTestId } = render( , ) - const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') - expect(value.parentMode).toBe('paragraph') + expect(getByTestId('document-picker')).toHaveAttribute('data-parent-mode', '') }) it('should apply custom wrapperCls', () => { @@ -119,24 +161,23 @@ describe('DocumentTitle', () => { }) describe('Edge Cases', () => { - it('should handle undefined optional props', () => { + it('should handle an empty document value', () => { const { getByTestId } = render( , ) - const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') - expect(value.name).toBeUndefined() - expect(value.extension).toBeUndefined() + expect(getByTestId('document-picker')).toHaveAttribute('data-value-id', '') }) it('should maintain structure when rerendered', () => { const { rerender, getByTestId } = render( - , + , ) - rerender() + rerender() expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('dataset-2') + expect(getByTestId('document-picker').getAttribute('data-value-id')).toBe('doc-2') }) }) }) diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index 900c12a416..e8946ce584 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -114,9 +114,20 @@ vi.mock('../batch-modal', () => ({ })) vi.mock('../document-title', () => ({ - DocumentTitle: ({ name, extension }: { name?: string, extension?: string }) => ( -
{name}
- ), + DocumentTitle: ({ + document, + }: { + document?: { + name?: string + data_source_detail_dict?: { upload_file?: { extension?: string } } + data_source_info?: { upload_file?: { extension?: string } } + } | null + }) => { + const extension = document?.data_source_detail_dict?.upload_file?.extension + ?? document?.data_source_info?.upload_file?.extension + + return
{document?.name}
+ }, })) vi.mock('../segment-add', () => ({ @@ -311,19 +322,19 @@ describe('DocumentDetail', () => { describe('Navigation', () => { it('should navigate back when back button clicked', () => { render() - fireEvent.click(screen.getByTestId('document-detail-back-button')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.back' })) expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents') }) it('should expose aria label for back button', () => { render() - expect(screen.getByTestId('document-detail-back-button')).toHaveAttribute('aria-label') + expect(screen.getByRole('button', { name: 'common.operation.back' })).toHaveAttribute('aria-label') }) it('should preserve query params when navigating back', () => { mocks.state.searchParams = 'page=2&status=active' render() - fireEvent.click(screen.getByTestId('document-detail-back-button')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.back' })) expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents?page=2&status=active') }) }) diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index f6ebf0d29d..c361c8be13 100644 --- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -201,13 +201,9 @@ describe('NewSegmentModal', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - const { container } = render() + render() - // Act - find and click close button (RiCloseLine icon wrapper) - const closeButtons = container.querySelectorAll('.cursor-pointer') - // The close button is the second cursor-pointer element - if (closeButtons.length > 1) - fireEvent.click(closeButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) @@ -350,12 +346,9 @@ describe('NewSegmentModal', () => { }) it('should call toggleFullScreen when expand button is clicked', () => { - const { container } = render() + render() - // Act - click the expand button (first cursor-pointer) - const expandButtons = container.querySelectorAll('.cursor-pointer') - if (expandButtons.length > 0) - fireEvent.click(expandButtons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.zoomIn' })) expect(mockToggleFullScreen).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx index 0cd2a31491..a4d7871719 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx @@ -74,7 +74,7 @@ describe('CSVUploader', () => { render() expect(screen.getByText(/list\.batchModal\.csvUploadTitle/i)).toBeInTheDocument() - expect(screen.getByText(/list\.batchModal\.browse/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /list\.batchModal\.browse/i })).toBeInTheDocument() }) it('should render hidden file input', () => { @@ -139,11 +139,32 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(fileInput, 'click') - fireEvent.click(screen.getByText(/list\.batchModal\.browse/i)) + fireEvent.click(screen.getByRole('button', { name: /list\.batchModal\.browse/i })) expect(clickSpy).toHaveBeenCalled() }) + it('should clear the selected file when delete is clicked', () => { + const mockUpdateFile = vi.fn() + const mockFile: FileItem = { + fileID: 'file-1', + file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, + progress: 100, + } + const { container } = render() + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + Object.defineProperty(fileInput, 'value', { + configurable: true, + value: 'C:\\fakepath\\test.csv', + writable: true, + }) + + fireEvent.click(screen.getByRole('button', { name: /operation\.delete$/ })) + + expect(fileInput.value).toBe('') + expect(mockUpdateFile).toHaveBeenCalledWith() + }) + it('should call updateFile when file is selected', async () => { const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx index 0e86c057cc..15d6d803d4 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx @@ -175,7 +175,13 @@ const CSVUploader: FC = ({ file, updateFile }) => {
{t('list.batchModal.csvUploadTitle', { ns: 'datasetDocuments' })} - {t('list.batchModal.browse', { ns: 'datasetDocuments' })} +
{dragging &&
} @@ -197,9 +203,14 @@ const CSVUploader: FC = ({ file, updateFile }) => { )}
-
- -
+
)} diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx index b3bec1b0b8..a7de0258c0 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx @@ -129,23 +129,19 @@ describe('ChildSegmentDetail', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - const { container } = render( + render( , ) - const closeButtons = container.querySelectorAll('.cursor-pointer') - if (closeButtons.length > 1) - fireEvent.click(closeButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - const { container } = render() + render() - const expandButtons = container.querySelectorAll('.cursor-pointer') - if (expandButtons.length > 0) - fireEvent.click(expandButtons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.zoomIn' })) expect(mockToggleFullScreen).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index d993ff849e..1fa92b5b0d 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -141,23 +141,19 @@ describe('NewChildSegmentModal', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - const { container } = render( + render( , ) - const closeButtons = container.querySelectorAll('.cursor-pointer') - if (closeButtons.length > 1) - fireEvent.click(closeButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - const { container } = render() + render() - const expandButtons = container.querySelectorAll('.cursor-pointer') - if (expandButtons.length > 0) - fireEvent.click(expandButtons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.zoomIn' })) expect(mockToggleFullScreen).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx index 1f10053596..8ac57759cd 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx @@ -278,21 +278,17 @@ describe('SegmentDetail', () => { describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { const mockOnCancel = vi.fn() - const { container } = render() + render() - const closeButtons = container.querySelectorAll('.cursor-pointer') - if (closeButtons.length > 1) - fireEvent.click(closeButtons[1]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - const { container } = render() + render() - const expandButtons = container.querySelectorAll('.cursor-pointer') - if (expandButtons.length > 0) - fireEvent.click(expandButtons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.zoomIn' })) expect(mockToggleFullScreen).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx index 88767d628f..c4b3fc89f4 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx +++ b/web/app/components/datasets/documents/detail/completed/child-segment-detail.tsx @@ -99,12 +99,24 @@ const ChildSegmentDetail: FC = ({ )} -
- {fullScreen ? : } -
-
- -
+ +
diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx index 9c170f3a32..15cd617aaa 100644 --- a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx @@ -69,7 +69,7 @@ describe('MenuBar', () => { it('should call onInputChange with empty string when input is cleared', () => { render() - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) fireEvent.click(clearButton) expect(defaultProps.onInputChange).toHaveBeenCalledWith('') }) diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx index 386757a5b2..572cf65bf7 100644 --- a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx +++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx @@ -111,12 +111,22 @@ const NewChildSegmentModal: FC = ({ )} -
- -
-
- -
+ +
diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx index fd431af95d..6df95ddaba 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx @@ -221,8 +221,8 @@ describe('SegmentCard', () => { />, ) - expect(screen.getByTestId('segment-edit-button')).toBeInTheDocument() - expect(screen.getByTestId('segment-delete-button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.edit' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.delete' })).toBeInTheDocument() expect(screen.getByRole('switch')).toBeInTheDocument() }) @@ -270,7 +270,7 @@ describe('SegmentCard', () => { />, ) - const deleteButton = screen.getByTestId('segment-delete-button') + const deleteButton = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(deleteButton) await waitFor(() => { @@ -290,7 +290,7 @@ describe('SegmentCard', () => { />, ) - const deleteButton = screen.getByTestId('segment-delete-button') + const deleteButton = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(deleteButton) await waitFor(() => { @@ -365,7 +365,7 @@ describe('SegmentCard', () => { />, ) - const editButton = screen.getByTestId('segment-edit-button') + const editButton = screen.getByRole('button', { name: 'common.operation.edit' }) fireEvent.click(editButton) expect(onClickEdit).toHaveBeenCalledTimes(1) @@ -385,7 +385,7 @@ describe('SegmentCard', () => { />, ) - const deleteButton = screen.getByTestId('segment-delete-button') + const deleteButton = screen.getByRole('button', { name: 'common.operation.delete' }) fireEvent.click(deleteButton) await waitFor(() => { @@ -437,7 +437,7 @@ describe('SegmentCard', () => { />, ) - const editButton = screen.getByTestId('segment-edit-button') + const editButton = screen.getByRole('button', { name: 'common.operation.edit' }) fireEvent.click(editButton) expect(onClickEdit).toHaveBeenCalledTimes(1) diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx index 865ffbce15..c30d123052 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/index.tsx @@ -187,15 +187,14 @@ const SegmentCard: FC = ({ render={( )} /> @@ -206,15 +205,14 @@ const SegmentCard: FC = ({ render={( )} /> @@ -269,7 +267,7 @@ const SegmentCard: FC = ({ ? (
-
- -
+ +
= ({ +export function DocumentTitle({ datasetId, - extension, - name, - chunkingMode, - parent_mode, + document, + parentMode, wrapperCls, -}) => { +}: DocumentTitleProps) { const router = useRouter() + return (
{ router.push(`/datasets/${datasetId}/documents/${doc.id}`) }} diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 43fc99851d..190cf8edf7 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { DataSourceInfo, DocumentDisplayStatus, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets' +import type { DocumentDisplayStatus, FileItem, FullDocumentDetail } from '@/models/datasets' import type { SegmentImportStatus } from '@/types/dataset' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' @@ -38,10 +38,6 @@ const NON_TERMINAL_DISPLAY_STATUSES = new Set( DisplayStatusList.filter(s => s === 'queuing' || s === 'indexing' || s === 'paused'), ) -const isLegacyDataSourceInfo = (info?: DataSourceInfo): info is LegacyDataSourceInfo => { - return !!info && 'upload_file' in info -} - const DocumentDetail: FC = ({ datasetId, documentId }) => { const router = useRouter() const searchParams = useSearchParams() @@ -123,14 +119,6 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => { const embedding = NON_TERMINAL_DISPLAY_STATUSES.has(documentDetail?.display_status as DocumentDisplayStatus) - const documentUploadFile = useMemo(() => { - if (!documentDetail?.data_source_info) - return undefined - if (isLegacyDataSourceInfo(documentDetail.data_source_info)) - return documentDetail.data_source_info.upload_file - return undefined - }, [documentDetail?.data_source_info]) - const invalidChunkList = useInvalid(useSegmentListKey) const invalidChildChunkList = useInvalid(useChildSegmentListKey) const invalidDocumentList = useInvalidDocumentList(datasetId) @@ -200,11 +188,10 @@ const DocumentDetail: FC = ({ datasetId, documentId }) => {
{embeddingAvailable && documentDetail && !documentDetail.archived && !isFullDocMode && ( diff --git a/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx b/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx index 55295579f0..d09077846e 100644 --- a/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx +++ b/web/app/components/datasets/documents/detail/metadata/components/__tests__/doc-type-selector.spec.tsx @@ -133,7 +133,7 @@ describe('DocumentTypeDisplay', () => { const onClick = vi.fn() render() - fireEvent.click(screen.getByText(/operation\.change/)) + fireEvent.click(screen.getByRole('button', { name: /operation\.change/ })) expect(onClick).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx index 5dfa02aa80..9cce66822b 100644 --- a/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx +++ b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx @@ -122,9 +122,13 @@ export const DocumentTypeDisplay: FC = ({ {showChangeLink && (
· -
+
+
)} diff --git a/web/app/components/datasets/documents/detail/new-segment.tsx b/web/app/components/datasets/documents/detail/new-segment.tsx index 0426bb9584..5bf5641735 100644 --- a/web/app/components/datasets/documents/detail/new-segment.tsx +++ b/web/app/components/datasets/documents/detail/new-segment.tsx @@ -142,12 +142,22 @@ const NewSegmentModal: FC = ({ )} -
- -
-
- -
+ +
diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 0783af8569..b31641a076 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -106,7 +106,7 @@ describe('SegmentAdd', () => { />, ) - fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) + fireEvent.click(screen.getByRole('button', { name: /list\.batchModal\.ok/i })) expect(mockClearImportStatus).toHaveBeenCalledTimes(1) }) @@ -121,7 +121,7 @@ describe('SegmentAdd', () => { />, ) - fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) + fireEvent.click(screen.getByRole('button', { name: /list\.batchModal\.ok/i })) expect(mockClearImportStatus).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index da4a0109c0..43b30f7906 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -80,7 +80,13 @@ export function SegmentAdd({ {t('list.batchModal.completed', { ns: 'datasetDocuments' })}
- {t('list.batchModal.ok', { ns: 'datasetDocuments' })} +
@@ -92,7 +98,13 @@ export function SegmentAdd({ {t('list.batchModal.error', { ns: 'datasetDocuments' })}
- {t('list.batchModal.ok', { ns: 'datasetDocuments' })} +
@@ -111,7 +123,7 @@ export function SegmentAdd({ > ), @@ -104,13 +104,19 @@ describe('ModifyRetrievalModal', () => { it('should call onHide when cancel button clicked', () => { render() - fireEvent.click(screen.getByTestId('cancel-button')) + fireEvent.click(screen.getByRole('button', { name: /operation\.cancel$/ })) + expect(defaultProps.onHide).toHaveBeenCalled() + }) + + it('should call onHide when close button clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(defaultProps.onHide).toHaveBeenCalled() }) it('should call onSave with retrieval config when save clicked', () => { render() - fireEvent.click(screen.getByTestId('save-button')) + fireEvent.click(screen.getByRole('button', { name: /operation\.save$/ })) expect(defaultProps.onSave).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx index b4d017d0df..90e52bf271 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx @@ -127,7 +127,7 @@ describe('ChunkDetailModal', () => { it('should call onHide when close button is clicked', () => { render() - fireEvent.click(screen.getByTestId('modal-close-button')) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(onHide).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx index 44a7dc2c89..425066dbb6 100644 --- a/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx @@ -61,8 +61,7 @@ describe('ResultItemFooter', () => { />, ) - const openButton = screen.getByText(/open/i) - fireEvent.click(openButton) + fireEvent.click(screen.getByRole('button', { name: /open/i })) expect(mockShowDetailModal).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx b/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx index 6f789a81ce..2e3c1f95db 100644 --- a/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx +++ b/web/app/components/datasets/hit-testing/components/chunk-detail-modal.tsx @@ -61,7 +61,6 @@ const ChunkDetailModal = ({ > { e.stopPropagation() onHide() diff --git a/web/app/components/datasets/hit-testing/components/result-item-external.tsx b/web/app/components/datasets/hit-testing/components/result-item-external.tsx index 9f68e054d1..4f7f65b8a2 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-external.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-external.tsx @@ -46,7 +46,7 @@ const ResultItemExternal: FC = ({ payload, positionId }) => { }} > - + {t(`${i18nPrefix}chunkDetail`, { ns: 'datasetHitTesting' })} diff --git a/web/app/components/datasets/hit-testing/components/result-item-footer.tsx b/web/app/components/datasets/hit-testing/components/result-item-footer.tsx index 5b1198fcc1..bb1b7c172d 100644 --- a/web/app/components/datasets/hit-testing/components/result-item-footer.tsx +++ b/web/app/components/datasets/hit-testing/components/result-item-footer.tsx @@ -28,13 +28,14 @@ const ResultItemFooter: FC = ({ {docTitle}
-
{t(`${i18nPrefix}open`, { ns: 'datasetHitTesting' })}
- -
+ +
) } diff --git a/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx b/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx index 98bcab2755..02103e02c8 100644 --- a/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx +++ b/web/app/components/datasets/hit-testing/modify-retrieval-modal.tsx @@ -85,9 +85,14 @@ const ModifyRetrievalModal: FC = ({ indexMethod, value, isShow, onHide, o
-
- -
+
diff --git a/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx index 2684278777..c42186c375 100644 --- a/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx +++ b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx @@ -20,13 +20,13 @@ vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({ handleClickTrigger: () => {}, }) return ( -
+
{trigger} - -
) @@ -49,21 +49,20 @@ describe('WrappedDatePicker', () => { it('should render without crashing', () => { const handleChange = vi.fn() render() - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should render placeholder text when no value', () => { const handleChange = vi.fn() render() - // When no value, should show placeholder from i18n - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.chooseTime' })).toBeInTheDocument() }) it('should render formatted date when value is provided', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) render() - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should render calendar icon', () => { @@ -76,24 +75,22 @@ describe('WrappedDatePicker', () => { it('should render select date button', () => { const handleChange = vi.fn() render() - expect(screen.getByTestId('select-date')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Select Date' })).toBeInTheDocument() }) it('should render clear date button', () => { const handleChange = vi.fn() render() - expect(screen.getByTestId('clear-date')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Clear Date' })).toBeInTheDocument() }) it('should render close icon for clearing', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - const { container } = render( + render( , ) - // RiCloseCircleFill should be rendered - const closeIcon = container.querySelectorAll('svg') - expect(closeIcon.length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: 'common.operation.clear' })).toBeInTheDocument() }) }) @@ -110,14 +107,14 @@ describe('WrappedDatePicker', () => { it('should accept undefined value', () => { const handleChange = vi.fn() render() - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should accept number value', () => { const handleChange = vi.fn() const timestamp = 1609459200 // 2021-01-01 render() - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) }) @@ -127,7 +124,7 @@ describe('WrappedDatePicker', () => { const timestamp = Math.floor(Date.now() / 1000) render() - fireEvent.click(screen.getByTestId('select-date')) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) expect(handleChange).toHaveBeenCalled() }) @@ -137,7 +134,7 @@ describe('WrappedDatePicker', () => { const timestamp = Math.floor(Date.now() / 1000) render() - fireEvent.click(screen.getByTestId('clear-date')) + fireEvent.click(screen.getByRole('button', { name: 'Clear Date' })) expect(handleChange).toHaveBeenCalledWith(null) }) @@ -145,16 +142,13 @@ describe('WrappedDatePicker', () => { it('should call onChange with null when close icon is clicked directly', () => { const handleChange = vi.fn() const timestamp = Math.floor(Date.now() / 1000) - const { container } = render( + render( , ) - // Find the RiCloseCircleFill icon (it has specific classes) - const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled') - if (closeIcon) { - fireEvent.click(closeIcon) - expect(handleChange).toHaveBeenCalledWith(null) - } + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) + + expect(handleChange).toHaveBeenCalledWith(null) }) it('should show close button on hover when value exists', () => { @@ -180,7 +174,7 @@ describe('WrappedDatePicker', () => { if (trigger) fireEvent.click(trigger) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) }) @@ -224,15 +218,14 @@ describe('WrappedDatePicker', () => { it('should handle timestamp of 0', () => { const handleChange = vi.fn() render() - // 0 is falsy but is a valid timestamp (epoch) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should handle very large timestamp', () => { const handleChange = vi.fn() const farFuture = 4102444800 // 2100-01-01 render() - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should handle switching between no value and value', () => { @@ -241,12 +234,12 @@ describe('WrappedDatePicker', () => { , ) - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() const timestamp = Math.floor(Date.now() / 1000) rerender() - expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Date picker' })).toBeInTheDocument() }) it('should handle clearing date multiple times', () => { @@ -254,9 +247,9 @@ describe('WrappedDatePicker', () => { const timestamp = Math.floor(Date.now() / 1000) render() - fireEvent.click(screen.getByTestId('clear-date')) - fireEvent.click(screen.getByTestId('clear-date')) - fireEvent.click(screen.getByTestId('clear-date')) + fireEvent.click(screen.getByRole('button', { name: 'Clear Date' })) + fireEvent.click(screen.getByRole('button', { name: 'Clear Date' })) + fireEvent.click(screen.getByRole('button', { name: 'Clear Date' })) expect(handleChange).toHaveBeenCalledTimes(3) }) @@ -266,9 +259,9 @@ describe('WrappedDatePicker', () => { const timestamp = Math.floor(Date.now() / 1000) render() - fireEvent.click(screen.getByTestId('select-date')) - fireEvent.click(screen.getByTestId('select-date')) - fireEvent.click(screen.getByTestId('select-date')) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) expect(handleChange).toHaveBeenCalledTimes(3) }) @@ -277,10 +270,8 @@ describe('WrappedDatePicker', () => { const handleChange = vi.fn() render() - // The mock triggers onChange with the value prop - fireEvent.click(screen.getByTestId('select-date')) + fireEvent.click(screen.getByRole('button', { name: 'Select Date' })) - // onChange should have been called expect(handleChange).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/metadata/base/date-picker.tsx b/web/app/components/datasets/metadata/base/date-picker.tsx index ed583b8411..b76c2e8060 100644 --- a/web/app/components/datasets/metadata/base/date-picker.tsx +++ b/web/app/components/datasets/metadata/base/date-picker.tsx @@ -35,29 +35,49 @@ const WrappedDatePicker = ({ const renderTrigger = useCallback(({ handleClickTrigger, }: TriggerProps) => { + const hasValue = Boolean(value) + const triggerText = value ? formatTimestamp(value, t('metadata.dateTimeFormat', { ns: 'datasetDocuments' })) : t('metadata.chooseTime', { ns: 'dataset' }) + return ( -
-
+
- handleDateChange()} - /> - + + {triggerText} + +
) }, [className, value, formatTimestamp, t, handleDateChange]) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx index 39c8c9effc..3bb83e79d5 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import EditedBeacon from '../edited-beacon' @@ -97,16 +97,8 @@ describe('EditedBeacon', () => { // Hover to show reset button fireEvent.mouseEnter(wrapper) - await waitFor(() => { - const resetButton = container.querySelector('.bg-text-accent-secondary') - expect(resetButton).toBeInTheDocument() - }) - - // Find and click the reset button (the clickable element with onClick) - const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary') - if (clickableElement) { - fireEvent.click(clickableElement) - } + const resetButton = await screen.findByRole('button', { name: 'common.operation.reset' }) + fireEvent.click(resetButton) expect(handleReset).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx index 25f1e19f8d..da531146af 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.tsx @@ -25,9 +25,14 @@ const EditedBeacon: FC = ({ - -
+ )} /> diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx index a8b5875541..abc5cb7fcc 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/modal.tsx @@ -98,7 +98,7 @@ const EditMetadataBatchModal: FC = ({ datasetId, documentNum, list, onSav }} > - + {t(`${i18nPrefix}.editMetadata`, { ns: 'dataset' })} diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx index da77a50419..1eb063c437 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx @@ -50,7 +50,7 @@ describe('CreateContent', () => { it('should render close button', () => { const handleSave = vi.fn() renderCreateContent({ onSave: handleSave }) - expect(screen.getByTestId('modal-close-btn')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument() }) }) @@ -162,7 +162,7 @@ describe('CreateContent', () => { const handleClose = vi.fn() renderCreateContent({ onSave: handleSave, onClose: handleClose }) - fireEvent.click(screen.getByTestId('modal-close-btn')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(handleClose).toHaveBeenCalled() }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index f05bd53b7f..f10f9a72a7 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -86,6 +86,10 @@ describe('DatasetMetadataDrawer', () => { vi.clearAllMocks() }) + const clickFirstMetadataAction = (name: string) => { + fireEvent.click(screen.getAllByRole('button', { name })[0]!) + } + describe('Rendering', () => { it('should render without crashing', async () => { render() @@ -143,7 +147,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - fireEvent.click(screen.getByTestId('close-icon')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(onClose).toHaveBeenCalledTimes(1) }) @@ -247,21 +251,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find user metadata items with group/item class (these have edit/delete icons) - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - expect(items.length).toBe(2) // 2 user metadata items - - // Find the hidden container with edit/delete icons - const actionsContainer = items[0]!.querySelector('.hidden.items-center') - expect(actionsContainer).toBeTruthy() - - // Find and click the first SVG (edit icon) - if (actionsContainer) { - const svgs = actionsContainer.querySelectorAll('svg') - expect(svgs.length).toBeGreaterThan(0) - fireEvent.click(svgs[0]!) - } + clickFirstMetadataAction('common.operation.edit') // Wait for rename modal (contains input) await waitFor(() => { @@ -278,14 +268,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click edit icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const actionsContainer = items[0]!.querySelector('.hidden.items-center') - if (actionsContainer) { - const svgs = actionsContainer.querySelectorAll('svg') - fireEvent.click(svgs[0]!) - } + clickFirstMetadataAction('common.operation.edit') // Change name and save await waitFor(() => { @@ -319,14 +302,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click edit icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const actionsContainer = items[0]!.querySelector('.hidden.items-center') - if (actionsContainer) { - const svgs = actionsContainer.querySelectorAll('svg') - fireEvent.click(svgs[0]!) - } + clickFirstMetadataAction('common.operation.edit') // Wait for modal and click cancel await waitFor(() => { @@ -355,14 +331,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click edit icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const actionsContainer = items[0]!.querySelector('.hidden.items-center') - if (actionsContainer) { - const svgs = actionsContainer.querySelectorAll('svg') - fireEvent.click(svgs[0]!) - } + clickFirstMetadataAction('common.operation.edit') // Wait for rename modal await waitFor(() => { @@ -387,19 +356,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find user metadata items - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - - // Find the delete container - const deleteContainer = items[0]!.querySelector('.hover\\:text-text-destructive') - expect(deleteContainer).toBeTruthy() - - if (deleteContainer) { - const deleteIcon = deleteContainer.querySelector('svg') - if (deleteIcon) - fireEvent.click(deleteIcon) - } + clickFirstMetadataAction('common.operation.remove') // Confirm dialog should appear await waitFor(() => { @@ -419,15 +376,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click delete icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const deleteContainer = items[0]!.querySelector('.hover\\:text-text-destructive') - if (deleteContainer) { - const deleteIcon = deleteContainer.querySelector('svg') - if (deleteIcon) - fireEvent.click(deleteIcon) - } + clickFirstMetadataAction('common.operation.remove') // Wait for confirm dialog await waitFor(() => { @@ -465,15 +414,7 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByRole('dialog'))!.toBeInTheDocument() }) - // Find and click delete icon - const dialog = screen.getByRole('dialog') - const items = dialog.querySelectorAll('.group\\/item') - const deleteContainer = items[0]!.querySelector('.hover\\:text-text-destructive') - if (deleteContainer) { - const deleteIcon = deleteContainer.querySelector('svg') - if (deleteIcon) - fireEvent.click(deleteIcon) - } + clickFirstMetadataAction('common.operation.remove') // Wait for confirm dialog await waitFor(() => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx index c1406d1233..70432ebf9d 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx @@ -68,8 +68,7 @@ describe('SelectMetadata', () => { onManage={vi.fn()} />, ) - // New action button should be present (from i18n) - expect(screen.getByText(/new/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })).toBeInTheDocument() }) it('should render manage action button', () => { @@ -81,8 +80,7 @@ describe('SelectMetadata', () => { onManage={vi.fn()} />, ) - // Manage action button should be present (from i18n) - expect(screen.getByText(/manage/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })).toBeInTheDocument() }) it('should display type for each item', () => { @@ -187,7 +185,7 @@ describe('SelectMetadata', () => { />, ) - fireEvent.click(screen.getByText('field_one')) + fireEvent.click(screen.getByRole('button', { name: /field_one/i })) expect(handleSelect).toHaveBeenCalledWith({ id: '1', @@ -207,9 +205,7 @@ describe('SelectMetadata', () => { />, ) - // Find and click the new action button - const newButton = screen.getByText(/new/i) - fireEvent.click(newButton.closest('div') || newButton) + fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })) expect(handleNew).toHaveBeenCalled() }) @@ -225,9 +221,7 @@ describe('SelectMetadata', () => { />, ) - // Find and click the manage action button - const manageButton = screen.getByText(/manage/i) - fireEvent.click(manageButton.closest('div') || manageButton) + fireEvent.click(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })) expect(handleManage).toHaveBeenCalled() }) @@ -255,8 +249,8 @@ describe('SelectMetadata', () => { onManage={vi.fn()} />, ) - expect(screen.getByText(/new/i)).toBeInTheDocument() - expect(screen.getByText(/manage/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.newAction' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dataset.metadata.selectMetadata.manageAction' })).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx index b0824d14a9..691d1512bc 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/create-content.tsx @@ -50,10 +50,10 @@ const CreateContent: FC = ({ {hasBack && ( )} @@ -65,10 +65,10 @@ const CreateContent: FC = ({ )}
diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx index b6597e5f51..8553eddacf 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.tsx @@ -70,7 +70,7 @@ const Item: FC = ({ onRename?.() }, [onRename]) - const deleteBtnRef = useRef(null) + const deleteBtnRef = useRef(null) const isDeleteHovering = useHover(deleteBtnRef) const [isShowDeleteConfirm, { setTrue: showDeleteConfirm, @@ -107,10 +107,23 @@ const Item: FC = ({ )}
- -
- -
+ +
!open && hideDeleteConfirm()}> @@ -205,7 +218,6 @@ const DatasetMetadataDrawer: FC = ({
diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx b/web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx index 4d86ddf28e..2886601170 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/select-metadata.tsx @@ -45,9 +45,10 @@ const SelectMetadata: FC = ({ {list.map((item) => { const Icon = getIcon(item.type) return ( -
onSelect({ id: item.id, name: item.name, @@ -55,27 +56,35 @@ const SelectMetadata: FC = ({ })} >
- +
{item.type}
-
+ ) })}
-
- +
+
-
+
+
diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index c4dc89cafc..bcae6de66f 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -41,7 +41,7 @@ vi.mock('@/hooks/use-timestamp', () => ({ // Mock AddMetadataButton vi.mock('../../add-metadata-button', () => ({ - default: () => , + default: () => , })) // Mock InputCombined @@ -61,9 +61,9 @@ vi.mock('../../metadata-dataset/select-metadata-modal', () => ({ default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
{trigger} - - - + + +
), })) @@ -145,14 +145,14 @@ describe('InfoGroup', () => { render( , ) - expect(screen.getByTestId('add-metadata-btn'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add Metadata' }))!.toBeInTheDocument() }) it('should not render add metadata button when isEdit is false', () => { render( , ) - expect(screen.queryByTestId('add-metadata-btn')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Add Metadata' })).not.toBeInTheDocument() }) it('should render input combined for each item in edit mode', () => { @@ -164,11 +164,10 @@ describe('InfoGroup', () => { }) it('should render delete icons in edit mode', () => { - const { container } = render( + render( , ) - const deleteIcons = container.querySelectorAll('.cursor-pointer svg') - expect(deleteIcons.length).toBeGreaterThan(0) + expect(screen.getAllByRole('button', { name: 'common.operation.remove' })).toHaveLength(3) }) }) @@ -187,14 +186,11 @@ describe('InfoGroup', () => { it('should call onDelete when delete icon is clicked', () => { const handleDelete = vi.fn() - const { container } = render( + render( , ) - // Find delete icons (RiDeleteBinLine SVGs inside cursor-pointer divs) - const deleteButtons = container.querySelectorAll('svg.size-4') - if (deleteButtons.length > 0) - fireEvent.click(deleteButtons[0]!) + fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[0]!) expect(handleDelete).toHaveBeenCalled() }) @@ -205,7 +201,7 @@ describe('InfoGroup', () => { , ) - fireEvent.click(screen.getByTestId('select-action')) + fireEvent.click(screen.getByRole('button', { name: 'Select' })) expect(handleSelect).toHaveBeenCalledWith({ id: '1', @@ -221,7 +217,7 @@ describe('InfoGroup', () => { , ) - fireEvent.click(screen.getByTestId('save-action')) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) expect(handleAdd).toHaveBeenCalledWith({ name: 'new_field', @@ -234,11 +230,9 @@ describe('InfoGroup', () => { , ) - fireEvent.click(screen.getByTestId('manage-action')) + fireEvent.click(screen.getByRole('button', { name: 'Manage' })) - // The onManage callback triggers the navigation - // The onManage callback triggers the navigation - expect(screen.getByTestId('manage-action'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Manage' }))!.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx index 5d174b2534..0368aa18b6 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx +++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx @@ -99,9 +99,14 @@ const InfoGroup: FC = ({ value={item.value} onChange={value => onChange?.({ ...item, value })} /> -
- onDelete?.(item)} /> -
+ ) : (
{(item.value && item.type === DataType.time) ? formatTimestamp((item.value as number), t('metadata.dateTimeFormat', { ns: 'datasetDocuments' })) : item.value}
)} diff --git a/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx index 4f8a421a52..ac4075fa5b 100644 --- a/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx @@ -167,11 +167,7 @@ describe('RenameDatasetModal', () => { it('should render close icon button', () => { render() - // The modal renders with title and other elements - // The close functionality is tested in user interactions - // The modal renders with title and other elements - // The close functionality is tested in user interactions - expect(screen.getByText('datasetSettings.title'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: /operation\.close$/ }))!.toBeInTheDocument() }) it('should render form labels', () => { @@ -296,14 +292,10 @@ describe('RenameDatasetModal', () => { }) it('should call onClose when close icon is clicked', () => { - // This test is covered by the cancel button test - // The close icon functionality works the same way as cancel button const handleClose = vi.fn() render() - // Use the cancel button to verify close callback works - const cancelButton = screen.getByText('common.operation.cancel') - fireEvent.click(cancelButton) + fireEvent.click(screen.getByRole('button', { name: /operation\.close$/ })) expect(handleClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/rename-modal/index.tsx b/web/app/components/datasets/rename-modal/index.tsx index ae8f9f5d6a..d2750ae21b 100644 --- a/web/app/components/datasets/rename-modal/index.tsx +++ b/web/app/components/datasets/rename-modal/index.tsx @@ -93,9 +93,14 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
{t('title', { ns: 'datasetSettings' })}
-
- -
+
diff --git a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx index 7aa11b4d7a..eb55acfb38 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx @@ -14,7 +14,9 @@ describe('IndexMethod', () => { vi.clearAllMocks() }) - const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords') + const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render without crashing', () => { diff --git a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx index cd0d56bbeb..92516f00a4 100644 --- a/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx @@ -11,7 +11,9 @@ describe('KeyWordNumber', () => { vi.clearAllMocks() }) - const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords') + const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords', { + selector: 'input[type="range"]', + }) describe('Rendering', () => { it('should render without crashing', () => { @@ -24,9 +26,10 @@ describe('KeyWordNumber', () => { expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument() }) - it('should render tooltip with question icon', () => { + it('should render infotip with question icon', () => { render() - const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement + const trigger = screen.getByRole('button', { name: 'datasetSettings.form.numberOfKeywords' }) + const container = trigger.parentElement const questionIcon = container?.querySelector('.i-ri-question-line') expect(questionIcon).toBeInTheDocument() }) diff --git a/web/app/components/datasets/settings/index-method/keyword-number.tsx b/web/app/components/datasets/settings/index-method/keyword-number.tsx index a31ab07160..b6a90155f0 100644 --- a/web/app/components/datasets/settings/index-method/keyword-number.tsx +++ b/web/app/components/datasets/settings/index-method/keyword-number.tsx @@ -7,10 +7,10 @@ import { NumberFieldInput, } from '@langgenius/dify-ui/number-field' import { Slider } from '@langgenius/dify-ui/slider' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' const MIN_KEYWORD_NUMBER = 0 const MAX_KEYWORD_NUMBER = 50 @@ -36,16 +36,12 @@ const KeyWordNumber = ({
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
- - - )} - /> - - {t('form.numberOfKeywords', { ns: 'datasetSettings' })} - - + + {t('form.numberOfKeywords', { ns: 'datasetSettings' })} +
{ fireEvent.change(searchInput, { target: { value: 'test' } }) expect(searchInput)!.toHaveValue('test') - const clearButton = screen.getByTestId('input-clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) fireEvent.click(clearButton) // After clicking clear, input should be empty diff --git a/web/app/components/develop/__tests__/ApiServer.spec.tsx b/web/app/components/develop/__tests__/ApiServer.spec.tsx index 1364513ce8..a805a3d2e0 100644 --- a/web/app/components/develop/__tests__/ApiServer.spec.tsx +++ b/web/app/components/develop/__tests__/ApiServer.spec.tsx @@ -5,7 +5,13 @@ import ApiServer from '../ApiServer' vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( - isShow ?
: null + isShow + ? ( +
+ +
+ ) + : null ), })) @@ -81,7 +87,7 @@ describe('ApiServer', () => { await user.click(apiKeyButton) }) - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Secret key' })).toBeInTheDocument() }) it('should close modal when close button is clicked', async () => { @@ -93,14 +99,14 @@ describe('ApiServer', () => { await user.click(apiKeyButton) }) - expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Secret key' })).toBeInTheDocument() - const closeButton = screen.getByText('Close Modal') + const closeButton = screen.getByRole('button', { name: 'Close Modal' }) await act(async () => { await user.click(closeButton) }) - expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog', { name: 'Secret key' })).not.toBeInTheDocument() }) }) diff --git a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx index e7a0ebb880..accd106305 100644 --- a/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/input-copy.spec.tsx @@ -158,8 +158,9 @@ describe('InputCopy', () => { it('should have cursor-pointer on clickable area', async () => { await renderAndFlush() const valueText = screen.getByText('test') - const clickableArea = valueText.closest('div[class*="cursor-pointer"]') + const clickableArea = valueText.closest('button') expect(clickableArea).toBeInTheDocument() + expect(clickableArea?.className).toContain('cursor-pointer') }) }) @@ -188,8 +189,9 @@ describe('InputCopy', () => { it('should have truncate class for long values', async () => { await renderAndFlush() const valueText = screen.getByText('very-long-api-key-value-that-might-overflow') - const container = valueText.closest('div[class*="truncate"]') + const container = valueText.closest('button') expect(container).toBeInTheDocument() + expect(container?.className).toContain('truncate') }) it('should have text-secondary color on value', async () => { @@ -201,8 +203,9 @@ describe('InputCopy', () => { it('should have absolute positioning for overlay', async () => { await renderAndFlush() const valueText = screen.getByText('test') - const container = valueText.closest('div[class*="absolute"]') + const container = valueText.closest('button') expect(container).toBeInTheDocument() + expect(container?.className).toContain('absolute') }) }) diff --git a/web/app/components/develop/secret-key/input-copy.tsx b/web/app/components/develop/secret-key/input-copy.tsx index 28a6aa4cc5..741cdd9bf5 100644 --- a/web/app/components/develop/secret-key/input-copy.tsx +++ b/web/app/components/develop/secret-key/input-copy.tsx @@ -42,18 +42,11 @@ const InputCopy = ({
{children}
-
{ - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - handleCopy() - } - }} > -
+
diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 6608dd2f1a..b71bcc5672 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -345,7 +345,7 @@ describe('AppList', () => { }) expect(screen.queryByText('Alpha')).not.toBeInTheDocument() - fireEvent.click(screen.getByTestId('input-clear')) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) await act(async () => { await vi.advanceTimersByTimeAsync(500) }) diff --git a/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx index 6335678a19..8fc2cb530e 100644 --- a/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx +++ b/web/app/components/explore/try-app/app/__tests__/chat.spec.tsx @@ -172,7 +172,7 @@ describe('TryApp (chat.tsx)', () => { />, ) - expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'share.chat.resetChat' })).not.toBeInTheDocument() }) it('renders reset button when conversation exists', () => { @@ -193,7 +193,7 @@ describe('TryApp (chat.tsx)', () => { />, ) - expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'share.chat.resetChat' })).toBeInTheDocument() }) it('calls handleNewConversation when reset button is clicked', () => { @@ -214,7 +214,7 @@ describe('TryApp (chat.tsx)', () => { />, ) - fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getByRole('button', { name: 'share.chat.resetChat' })) expect(mockRemoveConversationIdInfo).toHaveBeenCalledWith('test-app-id') expect(mockHandleNewConversation).toHaveBeenCalled() diff --git a/web/app/components/explore/try-app/app/chat.tsx b/web/app/components/explore/try-app/app/chat.tsx index 0c319b4c21..c1c404e49e 100644 --- a/web/app/components/explore/try-app/app/chat.tsx +++ b/web/app/components/explore/try-app/app/chat.tsx @@ -81,8 +81,12 @@ const TryApp: FC = ({ - + + )} /> diff --git a/web/app/components/header/__tests__/index.spec.tsx b/web/app/components/header/__tests__/index.spec.tsx index ba284829f3..0e9ef6493d 100644 --- a/web/app/components/header/__tests__/index.spec.tsx +++ b/web/app/components/header/__tests__/index.spec.tsx @@ -45,7 +45,7 @@ vi.mock('@/app/components/header/tools-nav', () => ({ })) vi.mock('@/app/components/header/plan-badge', () => ({ - default: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => ( + PlanBadge: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => (
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo ? ( diff --git a/web/app/components/header/account-dropdown/compliance.tsx b/web/app/components/header/account-dropdown/compliance.tsx index 3a67a8d273..2e57d0d186 100644 --- a/web/app/components/header/account-dropdown/compliance.tsx +++ b/web/app/components/header/account-dropdown/compliance.tsx @@ -65,7 +65,7 @@ function ComplianceDocActionVisual({ disabled={!canShowUpgradeTooltip} render={( - +