Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b49059231 | ||
|
|
b7666af311 | ||
|
|
37a2199029 | ||
|
|
df389eba1c | ||
|
|
5cae61eb5a | ||
|
|
ba8e0681d5 | ||
|
|
de123a8695 | ||
|
|
21e5962f98 | ||
|
|
db60e649b8 | ||
|
|
e561788809 | ||
|
|
3cd6ef4464 | ||
|
|
39dc636b02 | ||
|
|
4f03b7193e | ||
|
|
0d921cd21d | ||
|
|
1a7e46368e | ||
|
|
8c8ad02a6f | ||
|
|
8f070f2190 | ||
|
|
c7641bb1ce | ||
|
|
97bb338e7d | ||
|
|
ceec00e172 | ||
|
|
d81444683b | ||
|
|
3627c1e720 | ||
|
|
ba5d8c1b29 |
@@ -200,7 +200,7 @@ When assigned to test a directory/path, test **ALL content** within that path:
|
||||
|
||||
- ✅ **Import real project components** directly (including base components and siblings)
|
||||
- ✅ **Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
|
||||
- ❌ **DO NOT mock** base components (`@/app/components/base/*`)
|
||||
- ❌ **DO NOT mock** base components (`@/app/components/base/*`) or dify-ui primitives (`@langgenius/dify-ui/*`)
|
||||
- ❌ **DO NOT mock** sibling/child components in the same directory
|
||||
|
||||
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
|
||||
@@ -325,12 +325,12 @@ For more detailed information, refer to:
|
||||
### Reference Examples in Codebase
|
||||
|
||||
- `web/utils/classnames.spec.ts` - Utility function tests
|
||||
- `web/app/components/base/button/index.spec.tsx` - Component tests
|
||||
- `web/app/components/base/radio/__tests__/index.spec.tsx` - Component tests
|
||||
- `web/__mocks__/provider-context.ts` - Mock factory example
|
||||
|
||||
### Project Configuration
|
||||
|
||||
- `web/vitest.config.ts` - Vitest configuration
|
||||
- `web/vite.config.ts` - Vite/Vitest configuration
|
||||
- `web/vitest.setup.ts` - Test environment setup
|
||||
- `web/scripts/analyze-component.js` - Component analysis tool
|
||||
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.
|
||||
|
||||
@@ -36,7 +36,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
||||
|
||||
### Integration vs Mocking
|
||||
|
||||
- [ ] **DO NOT mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
||||
- [ ] **DO NOT mock base components or dify-ui primitives** (base `Loading`, `Input`, `Badge`; dify-ui `Button`, `Tooltip`, `Dialog`, etc.)
|
||||
- [ ] Import real project components instead of mocking
|
||||
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects
|
||||
- [ ] Prefer integration testing when using single spec file
|
||||
@@ -73,7 +73,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
||||
|
||||
### Mocks
|
||||
|
||||
- [ ] **DO NOT mock base components** (`@/app/components/base/*`)
|
||||
- [ ] **DO NOT mock base components or dify-ui primitives** (`@/app/components/base/*` or `@langgenius/dify-ui/*`)
|
||||
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
|
||||
- [ ] Shared mock state reset in `beforeEach`
|
||||
- [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations
|
||||
|
||||
@@ -2,29 +2,27 @@
|
||||
|
||||
## ⚠️ Important: What NOT to Mock
|
||||
|
||||
### DO NOT Mock Base Components
|
||||
### DO NOT Mock Base Components or dify-ui Primitives
|
||||
|
||||
**Never mock components from `@/app/components/base/`** such as:
|
||||
**Never mock components from `@/app/components/base/` or from `@langgenius/dify-ui/*`** such as:
|
||||
|
||||
- `Loading`, `Spinner`
|
||||
- `Button`, `Input`, `Select`
|
||||
- `Tooltip`, `Modal`, `Dropdown`
|
||||
- `Icon`, `Badge`, `Tag`
|
||||
- Legacy base (`@/app/components/base/*`): `Loading`, `Spinner`, `Input`, `Badge`, `Tag`
|
||||
- dify-ui primitives (`@langgenius/dify-ui/*`): `Button`, `Tooltip`, `Dialog`, `Popover`, `DropdownMenu`, `ContextMenu`, `Select`, `AlertDialog`, `Toast`
|
||||
|
||||
**Why?**
|
||||
|
||||
- Base components will have their own dedicated tests
|
||||
- These components have their own dedicated tests
|
||||
- Mocking them creates false positives (tests pass but real integration fails)
|
||||
- Using real components tests actual integration behavior
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Don't mock base components
|
||||
// ❌ WRONG: Don't mock base components or dify-ui primitives
|
||||
vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>)
|
||||
vi.mock('@/app/components/base/button', () => ({ children }: any) => <button>{children}</button>)
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children }: any) => <button>{children}</button> }))
|
||||
|
||||
// ✅ CORRECT: Import and use real base components
|
||||
// ✅ CORRECT: Import and use the real components
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
// They will render normally in tests
|
||||
```
|
||||
|
||||
@@ -319,7 +317,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
|
||||
### ✅ DO
|
||||
|
||||
1. **Use real base components** - Import from `@/app/components/base/` directly
|
||||
1. **Use real base components and dify-ui primitives** - Import from `@/app/components/base/` or `@langgenius/dify-ui/*` directly
|
||||
1. **Use real project components** - Prefer importing over mocking
|
||||
1. **Use real Zustand stores** - Set test state via `store.setState()`
|
||||
1. **Reset mocks in `beforeEach`**, not `afterEach`
|
||||
@@ -330,7 +328,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
1. **Don't mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
|
||||
1. **Don't mock base components or dify-ui primitives** (`Loading`, `Input`, `Button`, `Tooltip`, `Dialog`, etc.)
|
||||
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
|
||||
@@ -342,7 +340,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
```
|
||||
Need to use a component in test?
|
||||
│
|
||||
├─ Is it from @/app/components/base/*?
|
||||
├─ Is it from @/app/components/base/* or @langgenius/dify-ui/*?
|
||||
│ └─ YES → Import real component, DO NOT mock
|
||||
│
|
||||
├─ Is it a project component?
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Anti-Slop PR Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
close-pr: false
|
||||
failure-add-pr-labels: "needs-revision"
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
run: sh .github/workflows/expose_service_ports.sh
|
||||
|
||||
- name: Set up Sandbox
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Check Docker Compose inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
id: docker-compose-changes
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
docker/generate_docker_compose
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Check web inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
id: web-changes
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
web/**
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Check api inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
id: api-changes
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
api/**
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
@@ -123,4 +123,4 @@ jobs:
|
||||
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
|
||||
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
cp middleware.env.example middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
api/**
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
web/**
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
- name: Restore ESLint cache
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
id: eslint-cache-restore
|
||||
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: .eslintcache
|
||||
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
- name: Save ESLint cache
|
||||
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: .eslintcache
|
||||
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Check changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
**.sh
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: ''
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1.0.93
|
||||
uses: anthropics/claude-code-action@38ec876110f9fbf8b950c79f534430740c3ac009 # v1.0.101
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
# tiflash
|
||||
|
||||
- name: Set up Full Vector Store Matrix
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.yaml
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
# tiflash
|
||||
|
||||
- name: Set up Vector Stores for Smoke Coverage
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.yaml
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -245,3 +245,9 @@ scripts/stress-test/reports/
|
||||
.qoder/*
|
||||
|
||||
.eslintcache
|
||||
|
||||
docker/.codex/
|
||||
docker/.claude/
|
||||
docker/defaults
|
||||
docker/openspec
|
||||
docker/AGENTS.md
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Generate a backend env reference from the authoritative config model.
|
||||
|
||||
This module derives backend env input metadata from ``DifyConfig`` instead of
|
||||
grepping individual files. The exported reference intentionally captures only
|
||||
code-defined semantics and fallback defaults; it does not attempt to represent
|
||||
deployment defaults or runtime-effective values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from types import UnionType
|
||||
from typing import Any, TypedDict, get_args, get_origin
|
||||
|
||||
from pydantic import AliasChoices, BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from .app_config import DifyConfig
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_API_ROOT = Path(__file__).resolve().parents[1]
|
||||
_DOCS_ROOT = _API_ROOT / "docs"
|
||||
_JSON_OUTPUT = _DOCS_ROOT / "backend-env.reference.json"
|
||||
_MARKDOWN_OUTPUT = _DOCS_ROOT / "backend-env.reference.md"
|
||||
_SENSITIVE_SUFFIXES = (
|
||||
"_PASSWORD",
|
||||
"_SECRET",
|
||||
"_TOKEN",
|
||||
"_API_KEY",
|
||||
"_ACCESS_KEY",
|
||||
"_SECRET_KEY",
|
||||
"_PRIVATE_KEY",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DESCRIPTION_REWRITES = {
|
||||
"Duration in minutes for which a account deletion token remains valid": (
|
||||
"Duration in minutes for which an account deletion token remains valid."
|
||||
),
|
||||
"whether to enable education identity": "Whether to enable education identity.",
|
||||
(
|
||||
"Granularity for async workflow scheduler, sometime, few users could block the queue "
|
||||
"due to some time-consuming tasks, to avoid this, workflow can be suspended if needed, "
|
||||
"to achievethis, a time-based checker is required, every granularity seconds, "
|
||||
"the checker will check the workflow queue and suspend the workflow"
|
||||
): (
|
||||
"Granularity for the async workflow scheduler. Some users could block the queue with "
|
||||
"time-consuming tasks, so workflows can be suspended when needed. A time-based checker "
|
||||
"runs every granularity seconds to inspect the queue and suspend workflows."
|
||||
),
|
||||
(
|
||||
"Base URL for file preview or download, used for frontend display and multi-model "
|
||||
"inputsUrl is signed and has expiration time."
|
||||
): (
|
||||
"Base URL for file preview or download, used for frontend display and multi-model "
|
||||
"inputs. The URL is signed and has an expiration time."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BackendEnvVariableReference(TypedDict):
|
||||
name: str
|
||||
accepted_names: list[str]
|
||||
group: str
|
||||
type: str
|
||||
description: str
|
||||
code_default: Any | None
|
||||
required: bool
|
||||
applies_when: str | None
|
||||
|
||||
|
||||
class BackendEnvReference(TypedDict):
|
||||
schema_version: str
|
||||
artifact_policy: str
|
||||
authority: dict[str, str]
|
||||
resolution: dict[str, list[str]]
|
||||
variables: list[BackendEnvVariableReference]
|
||||
|
||||
|
||||
def _config_classes() -> list[type[BaseSettings]]:
|
||||
return [
|
||||
cls
|
||||
for cls in DifyConfig.__mro__[1:]
|
||||
if inspect.isclass(cls)
|
||||
and issubclass(cls, BaseSettings)
|
||||
and cls is not BaseSettings
|
||||
and cls.__module__.startswith("configs.")
|
||||
]
|
||||
|
||||
|
||||
def _owner_class_for_field(field_name: str) -> type[BaseSettings] | None:
|
||||
for cls in _config_classes():
|
||||
if field_name in getattr(cls, "__annotations__", {}):
|
||||
return cls
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
return re.sub(r"(?<!^)(?=[A-Z])", "-", name).replace("_", "-").lower()
|
||||
|
||||
|
||||
def _group_for_owner(owner: type[BaseSettings]) -> str:
|
||||
module_parts = owner.__module__.removeprefix("configs.").split(".")
|
||||
if module_parts[-1].endswith("_config"):
|
||||
module_parts = module_parts[:-1]
|
||||
return ".".join([*module_parts, _normalize_name(owner.__name__.removesuffix("Config"))])
|
||||
|
||||
|
||||
def _accepted_names(field_name: str, field_info: FieldInfo) -> list[str]:
|
||||
alias = field_info.validation_alias
|
||||
if isinstance(alias, AliasChoices):
|
||||
names = [str(choice) for choice in alias.choices]
|
||||
elif isinstance(alias, str):
|
||||
names = [alias]
|
||||
else:
|
||||
names = [field_name]
|
||||
|
||||
if field_name not in names:
|
||||
names.append(field_name)
|
||||
return names
|
||||
|
||||
|
||||
def _type_name(annotation: Any) -> str:
|
||||
origin = get_origin(annotation)
|
||||
if origin is None:
|
||||
if annotation in {str, Any}:
|
||||
return "string"
|
||||
if annotation is bool:
|
||||
return "boolean"
|
||||
if annotation is int:
|
||||
return "integer"
|
||||
if annotation is float:
|
||||
return "float"
|
||||
if annotation is type(None):
|
||||
return "null"
|
||||
if inspect.isclass(annotation):
|
||||
if issubclass(annotation, Enum):
|
||||
return "enum"
|
||||
if issubclass(annotation, str):
|
||||
return "string"
|
||||
if issubclass(annotation, bool):
|
||||
return "boolean"
|
||||
if issubclass(annotation, int):
|
||||
return "integer"
|
||||
if issubclass(annotation, float):
|
||||
return "float"
|
||||
return getattr(annotation, "__name__", str(annotation))
|
||||
|
||||
if origin is UnionType or str(origin).endswith("Union"):
|
||||
args = [arg for arg in get_args(annotation) if arg is not type(None)]
|
||||
rendered = " | ".join(_type_name(arg) for arg in args) if args else "null"
|
||||
if len(args) != len(get_args(annotation)):
|
||||
return f"{rendered} | null"
|
||||
return rendered
|
||||
|
||||
if str(origin).endswith("Literal"):
|
||||
values = ", ".join(repr(value) for value in get_args(annotation))
|
||||
return f"literal[{values}]"
|
||||
|
||||
if str(origin).endswith("Annotated"):
|
||||
args = get_args(annotation)
|
||||
return _type_name(args[0]) if args else "annotated"
|
||||
|
||||
if origin in {list, tuple, set}:
|
||||
args = get_args(annotation)
|
||||
item_type = _type_name(args[0]) if args else "any"
|
||||
return f"{origin.__name__}[{item_type}]"
|
||||
|
||||
return str(annotation)
|
||||
|
||||
|
||||
def _serialize_default(value: Any) -> Any | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_serialize_default(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _serialize_default(item) for key, item in value.items()}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _markdown_cell(value: Any | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
text = str(value)
|
||||
normalized = " ".join(text.split())
|
||||
return normalized.replace("|", "\\|")
|
||||
|
||||
|
||||
def _markdown_code_cell(value: Any | None, *, empty: str = "") -> str:
|
||||
text = _markdown_cell(value)
|
||||
if not text:
|
||||
return empty
|
||||
return f"`{text.replace('`', '\\`')}`"
|
||||
|
||||
|
||||
def _render_code_default(value: Any | None) -> str:
|
||||
if value is None:
|
||||
return _markdown_code_cell(json.dumps("", ensure_ascii=False))
|
||||
|
||||
if isinstance(value, str):
|
||||
return _markdown_code_cell(json.dumps(" ".join(value.split()), ensure_ascii=False))
|
||||
|
||||
return _markdown_code_cell(json.dumps(value, ensure_ascii=False))
|
||||
|
||||
|
||||
def _normalize_description(description: str) -> str:
|
||||
normalized = " ".join(description.split())
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
rewritten = _DESCRIPTION_REWRITES.get(normalized, normalized)
|
||||
rewritten = re.sub(r"(?<=[.!?])(?=[A-Z])", " ", rewritten)
|
||||
rewritten = re.sub(r"(?<=\w),(?=[A-Za-z])", ", ", rewritten)
|
||||
rewritten = re.sub(r"(?<=:)(?=https?://)", " ", rewritten)
|
||||
rewritten = re.sub(r"(?<=\w)\((?=e\.g\.,)", " (", rewritten)
|
||||
return rewritten
|
||||
|
||||
|
||||
def _render_group_applicability_notes(variables: list[BackendEnvVariableReference]) -> list[str]:
|
||||
applies_when_groups: dict[str, list[str]] = defaultdict(list)
|
||||
for variable in variables:
|
||||
applies_when = variable["applies_when"]
|
||||
if applies_when:
|
||||
applies_when_groups[applies_when].append(variable["name"])
|
||||
|
||||
if not applies_when_groups:
|
||||
return []
|
||||
|
||||
if len(applies_when_groups) == 1 and len(next(iter(applies_when_groups.values()))) == len(variables):
|
||||
applies_when = next(iter(applies_when_groups))
|
||||
return [f"> Applies when: {_markdown_code_cell(applies_when)}", ""]
|
||||
|
||||
lines = ["Applies when:"]
|
||||
for applies_when, names in sorted(applies_when_groups.items()):
|
||||
joined_names = ", ".join(f"`{name}`" for name in sorted(names))
|
||||
lines.append(f"- {joined_names}: {_markdown_code_cell(applies_when)}")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def _provider_applies_when(owner: type[BaseSettings], field_name: str) -> str | None:
|
||||
source_file = Path(inspect.getsourcefile(owner) or "")
|
||||
source_name = source_file.name
|
||||
|
||||
storage_map = {
|
||||
"amazon_s3_storage_config.py": "STORAGE_TYPE=s3",
|
||||
"aliyun_oss_storage_config.py": "STORAGE_TYPE=aliyun-oss",
|
||||
"azure_blob_storage_config.py": "STORAGE_TYPE=azure-blob",
|
||||
"baidu_obs_storage_config.py": "STORAGE_TYPE=baidu-obs",
|
||||
"clickzetta_volume_storage_config.py": "STORAGE_TYPE=clickzetta-volume",
|
||||
"google_cloud_storage_config.py": "STORAGE_TYPE=google-storage",
|
||||
"huawei_obs_storage_config.py": "STORAGE_TYPE=huawei-obs",
|
||||
"oci_storage_config.py": "STORAGE_TYPE=oci-storage",
|
||||
"opendal_storage_config.py": "STORAGE_TYPE=opendal",
|
||||
"supabase_storage_config.py": "STORAGE_TYPE=supabase",
|
||||
"tencent_cos_storage_config.py": "STORAGE_TYPE=tencent-cos",
|
||||
"volcengine_tos_storage_config.py": "STORAGE_TYPE=volcengine-tos",
|
||||
}
|
||||
if field_name == "STORAGE_LOCAL_PATH":
|
||||
return "STORAGE_TYPE=local"
|
||||
if source_name in storage_map:
|
||||
return storage_map[source_name]
|
||||
|
||||
vector_map = {
|
||||
"analyticdb_config.py": "VECTOR_STORE=analyticdb",
|
||||
"baidu_vector_config.py": "VECTOR_STORE=baidu_vector",
|
||||
"chroma_config.py": "VECTOR_STORE=chroma",
|
||||
"clickzetta_config.py": "VECTOR_STORE=clickzetta",
|
||||
"couchbase_config.py": "VECTOR_STORE=couchbase",
|
||||
"elasticsearch_config.py": "VECTOR_STORE=elasticsearch",
|
||||
"hologres_config.py": "VECTOR_STORE=hologres",
|
||||
"huawei_cloud_config.py": "VECTOR_STORE=huawei-cloud",
|
||||
"iris_config.py": "VECTOR_STORE=iris",
|
||||
"lindorm_config.py": "VECTOR_STORE=lindorm",
|
||||
"matrixone_config.py": "VECTOR_STORE=matrixone",
|
||||
"milvus_config.py": "VECTOR_STORE=milvus",
|
||||
"myscale_config.py": "VECTOR_STORE=myscale",
|
||||
"oceanbase_config.py": "VECTOR_STORE=oceanbase",
|
||||
"opengauss_config.py": "VECTOR_STORE=opengauss",
|
||||
"opensearch_config.py": "VECTOR_STORE=opensearch",
|
||||
"oracle_config.py": "VECTOR_STORE=oracle",
|
||||
"pgvector_config.py": "VECTOR_STORE=pgvector",
|
||||
"pgvectors_config.py": "VECTOR_STORE=pgvectors",
|
||||
"qdrant_config.py": "VECTOR_STORE=qdrant",
|
||||
"relyt_config.py": "VECTOR_STORE=relyt",
|
||||
"tablestore_config.py": "VECTOR_STORE=tablestore",
|
||||
"tencent_vector_config.py": "VECTOR_STORE=tencent",
|
||||
"tidb_on_qdrant_config.py": "VECTOR_STORE=tidb_on_qdrant",
|
||||
"tidb_vector_config.py": "VECTOR_STORE=tidb_vector",
|
||||
"upstash_config.py": "VECTOR_STORE=upstash",
|
||||
"vastbase_vector_config.py": "VECTOR_STORE=vastbase",
|
||||
"vikingdb_config.py": "VECTOR_STORE=vikingdb",
|
||||
"weaviate_config.py": "VECTOR_STORE=weaviate",
|
||||
"alibabacloud_mysql_config.py": "VECTOR_STORE=alibabacloud-mysql",
|
||||
}
|
||||
applies_when = vector_map.get(source_name)
|
||||
if (
|
||||
applies_when
|
||||
and source_name == "elasticsearch_config.py"
|
||||
and ("CLOUD" in field_name or field_name in {"ELASTICSEARCH_API_KEY", "ELASTICSEARCH_CA_CERTS"})
|
||||
):
|
||||
return f"{applies_when}; ELASTICSEARCH_USE_CLOUD=true"
|
||||
return applies_when
|
||||
|
||||
|
||||
def build_backend_env_reference() -> BackendEnvReference:
|
||||
variables: list[BackendEnvVariableReference] = []
|
||||
|
||||
for field_name, field_info in sorted(DifyConfig.model_fields.items()):
|
||||
if not field_name.isupper():
|
||||
continue
|
||||
|
||||
owner = _owner_class_for_field(field_name)
|
||||
if owner is None:
|
||||
continue
|
||||
|
||||
variables.append(
|
||||
{
|
||||
"name": field_name,
|
||||
"accepted_names": _accepted_names(field_name, field_info),
|
||||
"group": _group_for_owner(owner),
|
||||
"type": _type_name(field_info.annotation),
|
||||
"description": field_info.description or "",
|
||||
"code_default": None if field_info.is_required() else _serialize_default(field_info.default),
|
||||
"required": field_info.is_required(),
|
||||
"applies_when": _provider_applies_when(owner, field_name),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {
|
||||
"kind": "backend-code-defaults",
|
||||
"source_root": "api/configs",
|
||||
"model": "configs.app_config.DifyConfig",
|
||||
},
|
||||
"resolution": {
|
||||
"precedence": [
|
||||
"init_settings",
|
||||
"process_env",
|
||||
"remote_settings",
|
||||
"dotenv",
|
||||
"file_secrets",
|
||||
"toml",
|
||||
"code_default",
|
||||
]
|
||||
},
|
||||
"variables": variables,
|
||||
}
|
||||
|
||||
|
||||
def render_backend_env_reference_markdown(reference: BackendEnvReference) -> str:
|
||||
grouped: dict[str, list[BackendEnvVariableReference]] = defaultdict(list)
|
||||
for variable in reference["variables"]:
|
||||
grouped[variable["group"]].append(variable)
|
||||
|
||||
lines = [
|
||||
"# Backend Env Reference",
|
||||
"",
|
||||
"> Generated from `api/configs/**/*.py`. Do not edit manually.",
|
||||
"",
|
||||
"This reference documents backend env input semantics and code defaults only.",
|
||||
"Deployment defaults, `.env.example`, and runtime-effective values are intentionally excluded.",
|
||||
"",
|
||||
"## Value Resolution Order",
|
||||
"",
|
||||
"```text",
|
||||
" > ".join(reference["resolution"]["precedence"]),
|
||||
"```",
|
||||
"",
|
||||
"Code defaults are fallback values only. Runtime process environment, remote settings, and dotenv values can override them.",
|
||||
"",
|
||||
]
|
||||
|
||||
for group in sorted(grouped):
|
||||
lines.extend([f"## `{group}`", ""])
|
||||
lines.extend(_render_group_applicability_notes(grouped[group]))
|
||||
lines.append("| Name | Type | Default | Accepted Env Names | Description |")
|
||||
lines.append("| --- | --- | --- | --- | --- |")
|
||||
|
||||
for variable in grouped[group]:
|
||||
code_default = _render_code_default(variable["code_default"])
|
||||
aliases = _markdown_code_cell(", ".join(variable["accepted_names"]))
|
||||
description = _markdown_cell(_normalize_description(variable["description"]))
|
||||
variable_type = _markdown_code_cell(variable["type"])
|
||||
lines.append(
|
||||
f"| `{variable['name']}` | {variable_type} | {code_default} | {aliases} | {description} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_backend_env_reference(
|
||||
json_output: Path = _JSON_OUTPUT,
|
||||
markdown_output: Path = _MARKDOWN_OUTPUT,
|
||||
) -> tuple[Path, Path]:
|
||||
reference = build_backend_env_reference()
|
||||
json_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
markdown_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_output.write_text(json.dumps(reference, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
markdown_output.write_text(render_backend_env_reference_markdown(reference) + "\n", encoding="utf-8")
|
||||
return json_output, markdown_output
|
||||
|
||||
|
||||
def main() -> None:
|
||||
json_output, markdown_output = write_backend_env_reference()
|
||||
logger.info("Wrote %s", json_output.relative_to(_REPO_ROOT))
|
||||
logger.info("Wrote %s", markdown_output.relative_to(_REPO_ROOT))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -129,6 +129,7 @@ class AppNamePayload(BaseModel):
|
||||
|
||||
class AppIconPayload(BaseModel):
|
||||
icon: str | None = Field(default=None, description="Icon data")
|
||||
icon_type: IconType | None = Field(default=None, description="Icon type")
|
||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||
|
||||
|
||||
@@ -729,7 +730,12 @@ class AppIconApi(Resource):
|
||||
args = AppIconPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
app_service = AppService()
|
||||
app_model = app_service.update_app_icon(app_model, args.icon or "", args.icon_background or "")
|
||||
app_model = app_service.update_app_icon(
|
||||
app_model,
|
||||
args.icon or "",
|
||||
args.icon_background or "",
|
||||
args.icon_type,
|
||||
)
|
||||
response_model = AppDetail.model_validate(app_model, from_attributes=True)
|
||||
return response_model.model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from fields.dataset_fields import (
|
||||
from fields.document_fields import document_status_fields
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from libs.url_utils import normalize_api_base_url
|
||||
from models import ApiToken, Dataset, Document, DocumentSegment, UploadFile
|
||||
from models.dataset import DatasetPermission, DatasetPermissionEnum
|
||||
from models.enums import ApiTokenType, SegmentStatus
|
||||
@@ -889,7 +890,8 @@ class DatasetApiBaseUrlApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
return {"api_base_url": (dify_config.SERVICE_API_URL or request.host_url.rstrip("/")) + "/v1"}
|
||||
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")
|
||||
return {"api_base_url": normalize_api_base_url(base)}
|
||||
|
||||
|
||||
@console_ns.route("/datasets/retrieval-setting")
|
||||
|
||||
@@ -595,13 +595,25 @@ class ChangeEmailSendEmailApi(Resource):
|
||||
account = None
|
||||
user_email = None
|
||||
email_for_sending = args.email.lower()
|
||||
if args.phase is not None and args.phase == "new_email":
|
||||
# Default to the initial phase; any legacy/unexpected client input is
|
||||
# coerced back to `old_email` so we never trust the caller to declare
|
||||
# later phases without a verified predecessor token.
|
||||
send_phase = AccountService.CHANGE_EMAIL_PHASE_OLD
|
||||
if args.phase is not None and args.phase == AccountService.CHANGE_EMAIL_PHASE_NEW:
|
||||
send_phase = AccountService.CHANGE_EMAIL_PHASE_NEW
|
||||
if args.token is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
reset_data = AccountService.get_change_email_data(args.token)
|
||||
if reset_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# The token used to request a new-email code must come from the
|
||||
# old-email verification step. This prevents the bypass described
|
||||
# in GHSA-4q3w-q5mc-45rq where the phase-1 token was reused here.
|
||||
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||
if token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
|
||||
raise InvalidTokenError()
|
||||
user_email = reset_data.get("email", "")
|
||||
|
||||
if user_email.lower() != current_user.email.lower():
|
||||
@@ -620,7 +632,7 @@ class ChangeEmailSendEmailApi(Resource):
|
||||
email=email_for_sending,
|
||||
old_email=user_email,
|
||||
language=language,
|
||||
phase=args.phase,
|
||||
phase=send_phase,
|
||||
)
|
||||
return {"result": "success", "data": token}
|
||||
|
||||
@@ -655,12 +667,31 @@ class ChangeEmailCheckApi(Resource):
|
||||
AccountService.add_change_email_error_rate_limit(user_email)
|
||||
raise EmailCodeError()
|
||||
|
||||
# Only advance tokens that were minted by the matching send-code step;
|
||||
# refuse tokens that have already progressed or lack a phase marker so
|
||||
# the chain `old_email -> old_email_verified -> new_email -> new_email_verified`
|
||||
# is strictly enforced.
|
||||
phase_transitions = {
|
||||
AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
}
|
||||
token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||
if not isinstance(token_phase, str):
|
||||
raise InvalidTokenError()
|
||||
refreshed_phase = phase_transitions.get(token_phase)
|
||||
if refreshed_phase is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Verified, revoke the first token
|
||||
AccountService.revoke_change_email_token(args.token)
|
||||
|
||||
# Refresh token data by generating a new token
|
||||
# Refresh token data by generating a new token that carries the
|
||||
# upgraded phase so later steps can check it.
|
||||
_, new_token = AccountService.generate_change_email_token(
|
||||
user_email, code=args.code, old_email=token_data.get("old_email"), additional_data={}
|
||||
user_email,
|
||||
code=args.code,
|
||||
old_email=token_data.get("old_email"),
|
||||
additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase},
|
||||
)
|
||||
|
||||
AccountService.reset_change_email_error_rate_limit(user_email)
|
||||
@@ -690,13 +721,29 @@ class ChangeEmailResetApi(Resource):
|
||||
if not reset_data:
|
||||
raise InvalidTokenError()
|
||||
|
||||
AccountService.revoke_change_email_token(args.token)
|
||||
# Only tokens that completed both verification phases may be used to
|
||||
# change the email. This closes GHSA-4q3w-q5mc-45rq where a token from
|
||||
# the initial send-code step could be replayed directly here.
|
||||
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
|
||||
if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Bind the new email to the token that was mailed and verified, so a
|
||||
# verified token cannot be reused with a different `new_email` value.
|
||||
token_email = reset_data.get("email")
|
||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||
if normalized_token_email != normalized_new_email:
|
||||
raise InvalidTokenError()
|
||||
|
||||
old_email = reset_data.get("old_email", "")
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if current_user.email.lower() != old_email.lower():
|
||||
raise AccountNotFound()
|
||||
|
||||
# Revoke only after all checks pass so failed attempts don't burn a
|
||||
# legitimately verified token.
|
||||
AccountService.revoke_change_email_token(args.token)
|
||||
|
||||
updated_account = AccountService.update_account_email(current_user, email=normalized_new_email)
|
||||
|
||||
AccountService.send_change_email_completed_notify_email(
|
||||
|
||||
@@ -1131,6 +1131,14 @@ class ToolMCPAuthApi(Resource):
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
service = MCPToolManageService(session=session)
|
||||
service.clear_provider_credentials(provider_id=provider_id, tenant_id=tenant_id)
|
||||
parsed = urlparse(server_url)
|
||||
sanitized_url = f"{parsed.scheme}://{parsed.hostname}{parsed.path}"
|
||||
logger.warning(
|
||||
"MCP authorization failed for provider %s (url=%s)",
|
||||
provider_id,
|
||||
sanitized_url,
|
||||
exc_info=True,
|
||||
)
|
||||
raise ValueError(f"Failed to connect to MCP server: {e}") from e
|
||||
|
||||
|
||||
|
||||
@@ -303,9 +303,16 @@ class StreamableHTTPTransport:
|
||||
|
||||
if response.status_code == 404:
|
||||
if isinstance(message.root, JSONRPCRequest):
|
||||
error_msg = (
|
||||
f"MCP server URL returned 404 Not Found: {self.url} "
|
||||
"— verify the server URL is correct and the server is running"
|
||||
if is_initialization
|
||||
else "Session terminated by server"
|
||||
)
|
||||
self._send_session_terminated_error(
|
||||
ctx.server_to_client_queue,
|
||||
message.root.id,
|
||||
message=error_msg,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -381,12 +388,13 @@ class StreamableHTTPTransport:
|
||||
self,
|
||||
server_to_client_queue: ServerToClientQueue,
|
||||
request_id: RequestId,
|
||||
message: str = "Session terminated by server",
|
||||
):
|
||||
"""Send a session terminated error response."""
|
||||
jsonrpc_error = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=request_id,
|
||||
error=ErrorData(code=32600, message="Session terminated by server"),
|
||||
error=ErrorData(code=32600, message=message),
|
||||
)
|
||||
session_message = SessionMessage(JSONRPCMessage(jsonrpc_error))
|
||||
server_to_client_queue.put(session_message)
|
||||
|
||||
@@ -41,6 +41,10 @@ def safe_json_value(v):
|
||||
return v.hex()
|
||||
elif isinstance(v, memoryview):
|
||||
return v.tobytes().hex()
|
||||
elif isinstance(v, np.integer):
|
||||
return int(v)
|
||||
elif isinstance(v, np.floating):
|
||||
return float(v)
|
||||
elif isinstance(v, np.ndarray):
|
||||
return v.tolist()
|
||||
elif isinstance(v, dict):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+13
-28
@@ -47,23 +47,17 @@ def _cookie_domain() -> str | None:
|
||||
def _real_cookie_name(cookie_name: str) -> str:
|
||||
if is_secure() and _cookie_domain() is None:
|
||||
return "__Host-" + cookie_name
|
||||
else:
|
||||
return cookie_name
|
||||
return cookie_name
|
||||
|
||||
|
||||
def _try_extract_from_header(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header:
|
||||
if " " not in auth_header:
|
||||
return None
|
||||
else:
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
auth_scheme = auth_scheme.lower()
|
||||
if auth_scheme != "bearer":
|
||||
return None
|
||||
else:
|
||||
return auth_token
|
||||
return None
|
||||
if not auth_header or " " not in auth_header:
|
||||
return None
|
||||
auth_scheme, auth_token = auth_header.split(None, 1)
|
||||
if auth_scheme.lower() != "bearer":
|
||||
return None
|
||||
return auth_token
|
||||
|
||||
|
||||
def extract_refresh_token(request: Request) -> str | None:
|
||||
@@ -90,14 +84,9 @@ def extract_webapp_access_token(request: Request) -> str | None:
|
||||
|
||||
|
||||
def extract_webapp_passport(app_code: str, request: Request) -> str | None:
|
||||
def _try_extract_passport_token_from_cookie(request: Request) -> str | None:
|
||||
return request.cookies.get(_real_cookie_name(COOKIE_NAME_PASSPORT + "-" + app_code))
|
||||
|
||||
def _try_extract_passport_token_from_header(request: Request) -> str | None:
|
||||
return request.headers.get(HEADER_NAME_PASSPORT)
|
||||
|
||||
ret = _try_extract_passport_token_from_cookie(request) or _try_extract_passport_token_from_header(request)
|
||||
return ret
|
||||
return request.cookies.get(_real_cookie_name(COOKIE_NAME_PASSPORT + "-" + app_code)) or request.headers.get(
|
||||
HEADER_NAME_PASSPORT
|
||||
)
|
||||
|
||||
|
||||
def set_access_token_to_cookie(request: Request, response: Response, token: str, samesite: str = "Lax"):
|
||||
@@ -209,22 +198,18 @@ def check_csrf_token(request: Request, user_id: str):
|
||||
|
||||
if not csrf_token:
|
||||
_unauthorized()
|
||||
verified = {}
|
||||
try:
|
||||
verified = PassportService().verify(csrf_token)
|
||||
except:
|
||||
except Exception:
|
||||
_unauthorized()
|
||||
raise # unreachable, but helps the type checker see verified is always bound
|
||||
|
||||
if verified.get("sub") != user_id:
|
||||
_unauthorized()
|
||||
|
||||
exp: int | None = verified.get("exp")
|
||||
if not exp:
|
||||
if not exp or exp < int(datetime.now(UTC).timestamp()):
|
||||
_unauthorized()
|
||||
else:
|
||||
time_now = int(datetime.now().timestamp())
|
||||
if exp < time_now:
|
||||
_unauthorized()
|
||||
|
||||
|
||||
def generate_csrf_token(user_id: str) -> str:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
def normalize_api_base_url(base_url: str) -> str:
|
||||
"""Normalize a base URL to always end with /v1, avoiding double /v1 suffixes."""
|
||||
return base_url.rstrip("/").removesuffix("/v1").rstrip("/") + "/v1"
|
||||
+3
-1
@@ -25,6 +25,7 @@ from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType
|
||||
from graphon.file import helpers as file_helpers
|
||||
from libs.helper import generate_string # type: ignore[import-not-found]
|
||||
from libs.url_utils import normalize_api_base_url
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.utils.file_input_compat import build_file_from_input_mapping
|
||||
|
||||
@@ -446,7 +447,8 @@ class App(Base):
|
||||
|
||||
@property
|
||||
def api_base_url(self) -> str:
|
||||
return (dify_config.SERVICE_API_URL or request.host_url.rstrip("/")) + "/v1"
|
||||
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")
|
||||
return normalize_api_base_url(base)
|
||||
|
||||
@property
|
||||
def tenant(self) -> Tenant | None:
|
||||
|
||||
+7
-7
@@ -6,7 +6,7 @@ requires-python = "~=3.12.0"
|
||||
dependencies = [
|
||||
# Legacy: mature and widely deployed
|
||||
"bleach>=6.3.0",
|
||||
"boto3>=1.42.88",
|
||||
"boto3>=1.42.91",
|
||||
"celery>=5.6.3",
|
||||
"croniter>=6.2.2",
|
||||
"flask-cors>=6.0.2",
|
||||
@@ -30,7 +30,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.147.0,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.148.1,<2.0.0",
|
||||
"httpx[socks]>=0.28.1,<1.0.0",
|
||||
"opentelemetry-distro>=0.62b0,<1.0.0",
|
||||
"opentelemetry-instrumentation-celery>=0.62b0,<1.0.0",
|
||||
@@ -46,7 +46,7 @@ dependencies = [
|
||||
"fastopenapi[flask]~=0.7.0",
|
||||
"graphon~=0.2.2",
|
||||
"httpx-sse~=0.4.0",
|
||||
"json-repair~=0.59.2",
|
||||
"json-repair~=0.59.4",
|
||||
]
|
||||
# Before adding new dependency, consider place it in
|
||||
# alphabet order (a-z) and suitable group.
|
||||
@@ -174,7 +174,7 @@ dev = [
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"pyrefly>=0.61.1",
|
||||
"xinference-client>=2.4.0",
|
||||
"xinference-client>=2.5.0",
|
||||
]
|
||||
|
||||
############################################################
|
||||
@@ -183,13 +183,13 @@ dev = [
|
||||
############################################################
|
||||
storage = [
|
||||
"azure-storage-blob>=12.28.0",
|
||||
"bce-python-sdk>=0.9.69",
|
||||
"bce-python-sdk>=0.9.70",
|
||||
"cos-python-sdk-v5>=1.9.41",
|
||||
"esdk-obs-python>=3.22.2",
|
||||
"google-cloud-storage>=3.10.1",
|
||||
"opendal>=0.46.0",
|
||||
"oss2>=2.19.1",
|
||||
"supabase>=2.18.1",
|
||||
"supabase>=2.28.3",
|
||||
"tos>=2.9.0",
|
||||
]
|
||||
|
||||
@@ -266,7 +266,7 @@ vdb-vastbase = ["dify-vdb-vastbase"]
|
||||
vdb-vikingdb = ["dify-vdb-vikingdb"]
|
||||
vdb-weaviate = ["dify-vdb-weaviate"]
|
||||
# Optional client used by some tests / integrations (not a vector backend plugin)
|
||||
vdb-xinference = ["xinference-client>=2.4.0"]
|
||||
vdb-xinference = ["xinference-client>=2.5.0"]
|
||||
|
||||
trace-all = [
|
||||
"dify-trace-aliyun",
|
||||
|
||||
@@ -112,6 +112,14 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
|
||||
class AccountService:
|
||||
# Phase-bound token metadata for the change-email flow. Tokens carry the
|
||||
# current phase so that downstream endpoints can enforce proper progression
|
||||
CHANGE_EMAIL_TOKEN_PHASE_KEY = "email_change_phase"
|
||||
CHANGE_EMAIL_PHASE_OLD = "old_email"
|
||||
CHANGE_EMAIL_PHASE_OLD_VERIFIED = "old_email_verified"
|
||||
CHANGE_EMAIL_PHASE_NEW = "new_email"
|
||||
CHANGE_EMAIL_PHASE_NEW_VERIFIED = "new_email_verified"
|
||||
|
||||
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||
email_code_login_rate_limiter = RateLimiter(
|
||||
@@ -576,13 +584,20 @@ class AccountService:
|
||||
raise ValueError("Email must be provided.")
|
||||
if not phase:
|
||||
raise ValueError("phase must be provided.")
|
||||
if phase not in (cls.CHANGE_EMAIL_PHASE_OLD, cls.CHANGE_EMAIL_PHASE_NEW):
|
||||
raise ValueError("phase must be one of old_email or new_email.")
|
||||
|
||||
if cls.change_email_rate_limiter.is_rate_limited(account_email):
|
||||
from controllers.console.auth.error import EmailChangeRateLimitExceededError
|
||||
|
||||
raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60))
|
||||
|
||||
code, token = cls.generate_change_email_token(account_email, account, old_email=old_email)
|
||||
code, token = cls.generate_change_email_token(
|
||||
account_email,
|
||||
account,
|
||||
old_email=old_email,
|
||||
additional_data={cls.CHANGE_EMAIL_TOKEN_PHASE_KEY: phase},
|
||||
)
|
||||
|
||||
send_change_mail_task.delay(
|
||||
language=language,
|
||||
|
||||
@@ -303,17 +303,22 @@ class AppService:
|
||||
|
||||
return app
|
||||
|
||||
def update_app_icon(self, app: App, icon: str, icon_background: str) -> App:
|
||||
def update_app_icon(
|
||||
self, app: App, icon: str, icon_background: str, icon_type: IconType | str | None = None
|
||||
) -> App:
|
||||
"""
|
||||
Update app icon
|
||||
:param app: App instance
|
||||
:param icon: new icon
|
||||
:param icon_background: new icon_background
|
||||
:param icon_type: new icon type
|
||||
:return: App instance
|
||||
"""
|
||||
assert current_user is not None
|
||||
app.icon = icon
|
||||
app.icon_background = icon_background
|
||||
if icon_type is not None:
|
||||
app.icon_type = icon_type if isinstance(icon_type, IconType) else IconType(icon_type)
|
||||
app.updated_by = current_user.id
|
||||
app.updated_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
|
||||
@@ -234,6 +234,35 @@ class TestAppEndpoints:
|
||||
}
|
||||
)
|
||||
|
||||
def test_app_icon_post_should_forward_icon_type(self, app, monkeypatch):
|
||||
api = app_module.AppIconApi()
|
||||
method = _unwrap(api.post)
|
||||
payload = {
|
||||
"icon": "https://example.com/icon.png",
|
||||
"icon_type": "image",
|
||||
"icon_background": "#FFFFFF",
|
||||
}
|
||||
app_service = MagicMock()
|
||||
app_service.update_app_icon.return_value = SimpleNamespace()
|
||||
response_model = MagicMock()
|
||||
response_model.model_dump.return_value = {"id": "app-1"}
|
||||
|
||||
monkeypatch.setattr(app_module, "AppService", lambda: app_service)
|
||||
monkeypatch.setattr(app_module.AppDetail, "model_validate", MagicMock(return_value=response_model))
|
||||
|
||||
with (
|
||||
app.test_request_context("/console/api/apps/app-1/icon", method="POST", json=payload),
|
||||
patch.object(type(console_ns), "payload", payload),
|
||||
):
|
||||
response = method(app_model=SimpleNamespace())
|
||||
|
||||
assert response == {"id": "app-1"}
|
||||
assert app_service.update_app_icon.call_args.args[1:] == (
|
||||
payload["icon"],
|
||||
payload["icon_background"],
|
||||
app_module.IconType.IMAGE,
|
||||
)
|
||||
|
||||
|
||||
class TestOpsTraceEndpoints:
|
||||
@pytest.fixture
|
||||
|
||||
@@ -658,15 +658,17 @@ class TestAppService:
|
||||
# Update app icon
|
||||
new_icon = "🌟"
|
||||
new_icon_background = "#FFD93D"
|
||||
new_icon_type = "image"
|
||||
mock_current_user = create_autospec(Account, instance=True)
|
||||
mock_current_user.id = account.id
|
||||
mock_current_user.current_tenant_id = account.current_tenant_id
|
||||
|
||||
with patch("services.app_service.current_user", mock_current_user):
|
||||
updated_app = app_service.update_app_icon(app, new_icon, new_icon_background)
|
||||
updated_app = app_service.update_app_icon(app, new_icon, new_icon_background, new_icon_type)
|
||||
|
||||
assert updated_app.icon == new_icon
|
||||
assert updated_app.icon_background == new_icon_background
|
||||
assert str(updated_app.icon_type).lower() == new_icon_type
|
||||
assert updated_app.updated_by == account.id
|
||||
|
||||
# Verify other fields remain unchanged
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import json
|
||||
|
||||
from configs.env_reference import (
|
||||
build_backend_env_reference,
|
||||
render_backend_env_reference_markdown,
|
||||
)
|
||||
|
||||
|
||||
def test_backend_env_reference_uses_backend_authority() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
|
||||
assert reference["authority"]["source_root"] == "api/configs"
|
||||
assert reference["authority"]["model"] == "configs.app_config.DifyConfig"
|
||||
assert reference["resolution"]["precedence"][-1] == "code_default"
|
||||
|
||||
|
||||
def test_backend_env_reference_includes_aliases_and_defaults() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
variables = {variable["name"]: variable for variable in reference["variables"]}
|
||||
|
||||
files_url = variables["FILES_URL"]
|
||||
redis_host = variables["REDIS_HOST"]
|
||||
|
||||
assert "CONSOLE_API_URL" in files_url["accepted_names"]
|
||||
assert redis_host["code_default"] == "localhost"
|
||||
assert redis_host["group"] == "middleware.cache.redis"
|
||||
assert "source_location" not in redis_host
|
||||
assert "sensitive" not in redis_host
|
||||
|
||||
|
||||
def test_backend_env_reference_excludes_computed_and_nested_fields() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
names = {variable["name"] for variable in reference["variables"]}
|
||||
|
||||
assert "SQLALCHEMY_DATABASE_URI" not in names
|
||||
assert "normalized_pubsub_redis_url" not in names
|
||||
assert "project" not in names
|
||||
|
||||
|
||||
def test_backend_env_reference_marks_provider_applicability() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
variables = {variable["name"]: variable for variable in reference["variables"]}
|
||||
|
||||
assert variables["S3_ACCESS_KEY"]["applies_when"] == "STORAGE_TYPE=s3"
|
||||
assert variables["STORAGE_LOCAL_PATH"]["applies_when"] == "STORAGE_TYPE=local"
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_explains_code_default_scope() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
markdown = render_backend_env_reference_markdown(reference)
|
||||
|
||||
assert "Deployment defaults, `.env.example`, and runtime-effective values are intentionally excluded." in markdown
|
||||
assert "Code defaults are fallback values only." in markdown
|
||||
assert "`REDIS_HOST`" in markdown
|
||||
assert "| Name | Type | Default | Accepted Env Names | Description |" in markdown
|
||||
assert "Code Default" not in markdown
|
||||
assert "Required |" not in markdown
|
||||
assert "Applies When |" not in markdown
|
||||
assert "Source |" not in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_normalizes_multiline_cells() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "EXAMPLE_ENV",
|
||||
"accepted_names": ["EXAMPLE_ENV", "EXAMPLE_ALIAS"],
|
||||
"group": "test.group",
|
||||
"type": "string | null",
|
||||
"description": "line one\nline two | extra",
|
||||
"code_default": "value\nwith newline",
|
||||
"applies_when": "MODE=demo\nENABLED=true",
|
||||
"required": False,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "line one line two \\| extra" in markdown
|
||||
assert "> Applies when: `MODE=demo ENABLED=true`" in markdown
|
||||
assert "`string \\| null`" in markdown
|
||||
assert '`"value with newline"`' in markdown
|
||||
assert "\nline two" not in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_groups_partial_applicability_notes() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "S3_ACCESS_KEY",
|
||||
"accepted_names": ["S3_ACCESS_KEY"],
|
||||
"group": "storage.s3",
|
||||
"type": "string",
|
||||
"description": "Access key",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": "STORAGE_TYPE=s3",
|
||||
},
|
||||
{
|
||||
"name": "S3_SECRET_KEY",
|
||||
"accepted_names": ["S3_SECRET_KEY"],
|
||||
"group": "storage.s3",
|
||||
"type": "string",
|
||||
"description": "Secret key",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": "STORAGE_TYPE=s3",
|
||||
},
|
||||
{
|
||||
"name": "STORAGE_ENDPOINT",
|
||||
"accepted_names": ["STORAGE_ENDPOINT"],
|
||||
"group": "storage.s3",
|
||||
"type": "string | null",
|
||||
"description": "Endpoint override",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "Applies when:" in markdown
|
||||
assert "- `S3_ACCESS_KEY`, `S3_SECRET_KEY`: `STORAGE_TYPE=s3`" in markdown
|
||||
assert "Applies When |" not in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_normalizes_awkward_descriptions() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "ENTERPRISE_ENABLED",
|
||||
"accepted_names": ["ENTERPRISE_ENABLED"],
|
||||
"group": "enterprise.feature",
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Enable or disable enterprise-level features.Before using, please contact "
|
||||
"business@dify.ai by email to inquire about licensing matters."
|
||||
),
|
||||
"code_default": False,
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
{
|
||||
"name": "FILES_URL",
|
||||
"accepted_names": ["FILES_URL", "CONSOLE_API_URL"],
|
||||
"group": "feature.file-access",
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base URL for file preview or download, used for frontend display and "
|
||||
"multi-model inputsUrl is signed and has expiration time."
|
||||
),
|
||||
"code_default": "",
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "features. Before using, please contact business@dify.ai" in markdown
|
||||
assert "multi-model inputs. The URL is signed and has an expiration time." in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_renders_missing_defaults_explicitly() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "SENTRY_DSN",
|
||||
"accepted_names": ["SENTRY_DSN"],
|
||||
"group": "extra.sentry",
|
||||
"type": "string | null",
|
||||
"description": "Sentry DSN",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
row = '| `SENTRY_DSN` | `string \\| null` | `""` | `SENTRY_DSN` | Sentry DSN |'
|
||||
|
||||
assert row in markdown
|
||||
assert row.count(" | ") == 4
|
||||
|
||||
|
||||
def test_backend_env_reference_markdown_keeps_code_default_column_styling_consistent() -> None:
|
||||
markdown = render_backend_env_reference_markdown(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"artifact_policy": "committed-generated-artifact",
|
||||
"authority": {"kind": "backend-code-defaults", "source_root": "api/configs", "model": "configs.app_config.DifyConfig"},
|
||||
"resolution": {"precedence": ["process_env", "code_default"]},
|
||||
"variables": [
|
||||
{
|
||||
"name": "EMPTY_DEFAULT",
|
||||
"accepted_names": ["EMPTY_DEFAULT"],
|
||||
"group": "test.group",
|
||||
"type": "string | null",
|
||||
"description": "Empty default placeholder",
|
||||
"code_default": None,
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
{
|
||||
"name": "STRING_DEFAULT",
|
||||
"accepted_names": ["STRING_DEFAULT"],
|
||||
"group": "test.group",
|
||||
"type": "string",
|
||||
"description": "Concrete string default",
|
||||
"code_default": "value",
|
||||
"required": False,
|
||||
"applies_when": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert '| `EMPTY_DEFAULT` | `string \\| null` | `""` | `EMPTY_DEFAULT` | Empty default placeholder |' in markdown
|
||||
assert '| `STRING_DEFAULT` | `string` | `"value"` | `STRING_DEFAULT` | Concrete string default |' in markdown
|
||||
|
||||
|
||||
def test_backend_env_reference_is_json_serializable() -> None:
|
||||
reference = build_backend_env_reference()
|
||||
rendered = json.dumps(reference)
|
||||
|
||||
assert '"schema_version": "1"' in rendered
|
||||
assert '"resolution"' in rendered
|
||||
assert '"source_location"' not in rendered
|
||||
assert '"sensitive"' not in rendered
|
||||
@@ -1772,6 +1772,21 @@ class TestDatasetApiBaseUrlApi:
|
||||
|
||||
assert response["api_base_url"] == "http://localhost:5000/v1"
|
||||
|
||||
def test_get_api_base_url_no_double_v1(self, app):
|
||||
api = DatasetApiBaseUrlApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets.dify_config.SERVICE_API_URL",
|
||||
"https://example.com/v1",
|
||||
),
|
||||
):
|
||||
response = method(api)
|
||||
|
||||
assert response["api_base_url"] == "https://example.com/v1"
|
||||
|
||||
|
||||
class TestDatasetRetrievalSettingApi:
|
||||
def test_get_success(self, app):
|
||||
|
||||
@@ -68,7 +68,10 @@ class TestChangeEmailSend:
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_account = _build_account("current@example.com", "acc1")
|
||||
mock_current_account.return_value = (mock_account, None)
|
||||
mock_get_change_data.return_value = {"email": "current@example.com"}
|
||||
mock_get_change_data.return_value = {
|
||||
"email": "current@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
}
|
||||
mock_send_email.return_value = "token-abc"
|
||||
|
||||
with app.test_request_context(
|
||||
@@ -85,12 +88,55 @@ class TestChangeEmailSend:
|
||||
email="new@example.com",
|
||||
old_email="current@example.com",
|
||||
language="en-US",
|
||||
phase="new_email",
|
||||
phase=AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
)
|
||||
mock_extract_ip.assert_called_once()
|
||||
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
|
||||
mock_csrf.assert_called_once()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
|
||||
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_new_email_phase_when_token_phase_is_not_old_verified(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_extract_ip,
|
||||
mock_is_ip_limit,
|
||||
mock_send_email,
|
||||
mock_get_change_data,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app,
|
||||
):
|
||||
"""GHSA-4q3w-q5mc-45rq: a phase-1 token must not unlock the new-email send step."""
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
_mock_wraps_db(mock_db)
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_account = _build_account("current@example.com", "acc1")
|
||||
mock_current_account.return_value = (mock_account, None)
|
||||
mock_get_change_data.return_value = {
|
||||
"email": "current@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email",
|
||||
method="POST",
|
||||
json={"email": "New@Example.com", "language": "en-US", "phase": "new_email", "token": "token-123"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
ChangeEmailSendEmailApi().post()
|
||||
|
||||
mock_send_email.assert_not_called()
|
||||
|
||||
|
||||
class TestChangeEmailValidity:
|
||||
@patch("controllers.console.wraps.db")
|
||||
@@ -122,7 +168,12 @@ class TestChangeEmailValidity:
|
||||
mock_account = _build_account("user@example.com", "acc2")
|
||||
mock_current_account.return_value = (mock_account, None)
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {"email": "user@example.com", "code": "1234", "old_email": "old@example.com"}
|
||||
mock_get_data.return_value = {
|
||||
"email": "user@example.com",
|
||||
"code": "1234",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
}
|
||||
mock_generate_token.return_value = (None, "new-token")
|
||||
|
||||
with app.test_request_context(
|
||||
@@ -138,11 +189,169 @@ class TestChangeEmailValidity:
|
||||
mock_add_rate.assert_not_called()
|
||||
mock_revoke_token.assert_called_once_with("token-123")
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"user@example.com", code="1234", old_email="old@example.com", additional_data={}
|
||||
"user@example.com",
|
||||
code="1234",
|
||||
old_email="old@example.com",
|
||||
additional_data={
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
|
||||
},
|
||||
)
|
||||
mock_reset_rate.assert_called_once_with("user@example.com")
|
||||
mock_csrf.assert_called_once()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit")
|
||||
@patch("controllers.console.workspace.account.AccountService.generate_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_upgrade_new_phase_token_to_new_verified(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_is_rate_limit,
|
||||
mock_get_data,
|
||||
mock_add_rate,
|
||||
mock_revoke_token,
|
||||
mock_generate_token,
|
||||
mock_reset_rate,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app,
|
||||
):
|
||||
_mock_wraps_db(mock_db)
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {
|
||||
"email": "new@example.com",
|
||||
"code": "1234",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
}
|
||||
mock_generate_token.return_value = (None, "new-verified-token")
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/validity",
|
||||
method="POST",
|
||||
json={"email": "new@example.com", "code": "1234", "token": "token-123"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
response = ChangeEmailCheckApi().post()
|
||||
|
||||
assert response == {"is_valid": True, "email": "new@example.com", "token": "new-verified-token"}
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"new@example.com",
|
||||
code="1234",
|
||||
old_email="old@example.com",
|
||||
additional_data={
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
},
|
||||
)
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit")
|
||||
@patch("controllers.console.workspace.account.AccountService.generate_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_validity_when_token_phase_is_unknown(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_is_rate_limit,
|
||||
mock_get_data,
|
||||
mock_add_rate,
|
||||
mock_revoke_token,
|
||||
mock_generate_token,
|
||||
mock_reset_rate,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app,
|
||||
):
|
||||
"""A token whose phase marker is a string but not a known transition must be rejected."""
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
_mock_wraps_db(mock_db)
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {
|
||||
"email": "user@example.com",
|
||||
"code": "1234",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: "something_else",
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/validity",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "code": "1234", "token": "token-123"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
ChangeEmailCheckApi().post()
|
||||
|
||||
mock_revoke_token.assert_not_called()
|
||||
mock_generate_token.assert_not_called()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit")
|
||||
@patch("controllers.console.workspace.account.AccountService.generate_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_validity_when_token_has_no_phase(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_is_rate_limit,
|
||||
mock_get_data,
|
||||
mock_add_rate,
|
||||
mock_revoke_token,
|
||||
mock_generate_token,
|
||||
mock_reset_rate,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app,
|
||||
):
|
||||
"""A token minted without a phase marker (e.g. a hand-crafted token) must not validate."""
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
_mock_wraps_db(mock_db)
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
|
||||
mock_is_rate_limit.return_value = False
|
||||
mock_get_data.return_value = {
|
||||
"email": "user@example.com",
|
||||
"code": "1234",
|
||||
"old_email": "old@example.com",
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/validity",
|
||||
method="POST",
|
||||
json={"email": "user@example.com", "code": "1234", "token": "token-123"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
ChangeEmailCheckApi().post()
|
||||
|
||||
mock_revoke_token.assert_not_called()
|
||||
mock_generate_token.assert_not_called()
|
||||
|
||||
|
||||
class TestChangeEmailReset:
|
||||
@patch("controllers.console.wraps.db")
|
||||
@@ -175,7 +384,11 @@ class TestChangeEmailReset:
|
||||
mock_current_account.return_value = (current_user, None)
|
||||
mock_is_freeze.return_value = False
|
||||
mock_check_unique.return_value = True
|
||||
mock_get_data.return_value = {"old_email": "OLD@example.com"}
|
||||
mock_get_data.return_value = {
|
||||
"email": "new@example.com",
|
||||
"old_email": "OLD@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
}
|
||||
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
|
||||
mock_update_account.return_value = mock_account_after_update
|
||||
|
||||
@@ -194,6 +407,155 @@ class TestChangeEmailReset:
|
||||
mock_send_notify.assert_called_once_with(email="new@example.com")
|
||||
mock_csrf.assert_called_once()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.update_account_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_reset_when_token_phase_is_not_new_verified(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_is_freeze,
|
||||
mock_check_unique,
|
||||
mock_get_data,
|
||||
mock_revoke_token,
|
||||
mock_update_account,
|
||||
mock_send_notify,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app,
|
||||
):
|
||||
"""GHSA-4q3w-q5mc-45rq PoC: phase-1 token must not be usable against /reset."""
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
_mock_wraps_db(mock_db)
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
current_user = _build_account("old@example.com", "acc3")
|
||||
mock_current_account.return_value = (current_user, None)
|
||||
mock_is_freeze.return_value = False
|
||||
mock_check_unique.return_value = True
|
||||
# Simulate a token straight out of step #1 (phase=old_email) — exactly
|
||||
# the replay used in the advisory PoC.
|
||||
mock_get_data.return_value = {
|
||||
"email": "old@example.com",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/reset",
|
||||
method="POST",
|
||||
json={"new_email": "attacker@example.com", "token": "token-from-step1"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
ChangeEmailResetApi().post()
|
||||
|
||||
mock_revoke_token.assert_not_called()
|
||||
mock_update_account.assert_not_called()
|
||||
mock_send_notify.assert_not_called()
|
||||
|
||||
@patch("controllers.console.wraps.db")
|
||||
@patch("controllers.console.workspace.account.current_account_with_tenant")
|
||||
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.update_account_email")
|
||||
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
|
||||
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
|
||||
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
|
||||
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
|
||||
@patch("libs.login.check_csrf_token", return_value=None)
|
||||
@patch("controllers.console.wraps.FeatureService.get_system_features")
|
||||
def test_should_reject_reset_when_token_email_differs_from_payload_new_email(
|
||||
self,
|
||||
mock_features,
|
||||
mock_csrf,
|
||||
mock_is_freeze,
|
||||
mock_check_unique,
|
||||
mock_get_data,
|
||||
mock_revoke_token,
|
||||
mock_update_account,
|
||||
mock_send_notify,
|
||||
mock_current_account,
|
||||
mock_db,
|
||||
app,
|
||||
):
|
||||
"""A verified token for address A must not be replayed to change to address B."""
|
||||
from controllers.console.auth.error import InvalidTokenError
|
||||
|
||||
_mock_wraps_db(mock_db)
|
||||
mock_features.return_value = SimpleNamespace(enable_change_email=True)
|
||||
current_user = _build_account("old@example.com", "acc3")
|
||||
mock_current_account.return_value = (current_user, None)
|
||||
mock_is_freeze.return_value = False
|
||||
mock_check_unique.return_value = True
|
||||
mock_get_data.return_value = {
|
||||
"email": "verified@example.com",
|
||||
"old_email": "old@example.com",
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/account/change-email/reset",
|
||||
method="POST",
|
||||
json={"new_email": "attacker@example.com", "token": "token-verified"},
|
||||
):
|
||||
_set_logged_in_user(_build_account("tester@example.com", "tester"))
|
||||
with pytest.raises(InvalidTokenError):
|
||||
ChangeEmailResetApi().post()
|
||||
|
||||
mock_revoke_token.assert_not_called()
|
||||
mock_update_account.assert_not_called()
|
||||
mock_send_notify.assert_not_called()
|
||||
|
||||
|
||||
class TestAccountServiceSendChangeEmailEmail:
|
||||
"""Service-level coverage for the phase-bound changes in `send_change_email_email`."""
|
||||
|
||||
def test_should_raise_value_error_for_invalid_phase(self):
|
||||
with pytest.raises(ValueError, match="phase must be one of"):
|
||||
AccountService.send_change_email_email(
|
||||
email="user@example.com",
|
||||
old_email="user@example.com",
|
||||
phase="old_email_verified",
|
||||
)
|
||||
|
||||
@patch("services.account_service.send_change_mail_task")
|
||||
@patch("services.account_service.AccountService.change_email_rate_limiter")
|
||||
@patch("services.account_service.AccountService.generate_change_email_token")
|
||||
def test_should_stamp_phase_into_generated_token(
|
||||
self,
|
||||
mock_generate_token,
|
||||
mock_rate_limiter,
|
||||
mock_mail_task,
|
||||
):
|
||||
mock_rate_limiter.is_rate_limited.return_value = False
|
||||
mock_generate_token.return_value = ("123456", "the-token")
|
||||
|
||||
returned = AccountService.send_change_email_email(
|
||||
email="user@example.com",
|
||||
old_email="user@example.com",
|
||||
language="en-US",
|
||||
phase=AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
)
|
||||
|
||||
assert returned == "the-token"
|
||||
mock_generate_token.assert_called_once_with(
|
||||
"user@example.com",
|
||||
None,
|
||||
old_email="user@example.com",
|
||||
additional_data={
|
||||
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
|
||||
},
|
||||
)
|
||||
mock_mail_task.delay.assert_called_once()
|
||||
mock_rate_limiter.increment_rate_limit.assert_called_once_with("user@example.com")
|
||||
|
||||
|
||||
class TestAccountDeletionFeedback:
|
||||
@patch("controllers.console.wraps.db")
|
||||
|
||||
@@ -971,6 +971,23 @@ class TestHandlePostRequestNew:
|
||||
assert isinstance(item, SessionMessage)
|
||||
assert isinstance(item.message.root, JSONRPCError)
|
||||
assert item.message.root.id == 77
|
||||
assert item.message.root.error.message == "Session terminated by server"
|
||||
|
||||
def test_404_on_initialization_includes_url_in_error(self):
|
||||
t = _new_transport(url="http://example.com/mcp/server/abc123/mcp")
|
||||
q: queue.Queue = queue.Queue()
|
||||
msg = _make_request_msg("initialize", 1)
|
||||
ctx = self._make_ctx(t, q, message=msg)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
ctx.client.stream = self._stream_ctx(mock_resp)
|
||||
t._handle_post_request(ctx)
|
||||
item = q.get_nowait()
|
||||
assert isinstance(item, SessionMessage)
|
||||
assert isinstance(item.message.root, JSONRPCError)
|
||||
assert item.message.root.error.code == 32600
|
||||
assert "404 Not Found" in item.message.root.error.message
|
||||
assert "http://example.com/mcp/server/abc123/mcp" in item.message.root.error.message
|
||||
|
||||
def test_404_for_notification_no_error_sent(self):
|
||||
t = _new_transport()
|
||||
|
||||
Generated
+114
-48
@@ -481,7 +481,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "bce-python-sdk"
|
||||
version = "0.9.69"
|
||||
version = "0.9.70"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "crc32c" },
|
||||
@@ -489,9 +489,9 @@ dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/9c/8fdaf7f9259002b5aa9101bb88252f6d05f65c4535bbca567457da84d765/bce_python_sdk-0.9.69.tar.gz", hash = "sha256:2aaa9f4fc118b3efb720a66d7a541789b7d838a1ddacb9f3c6faa6b75e1c7d23", size = 300008, upload-time = "2026-04-10T08:13:29.769Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/a9/7c21a9073eb9ad7e8cacf6f8a0e47c0d01ad7bf8fd8e0dc42164b117d60b/bce_python_sdk-0.9.70.tar.gz", hash = "sha256:3b37fd7448278dd33f745a6a23198a2cc2490fded9cb8d59b72500784853df4e", size = 299967, upload-time = "2026-04-14T12:02:42.034Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/3b/41c2985d1b3b3bd5cdf103b4156b08320268ee7a0617f2a40c34fdd377e9/bce_python_sdk-0.9.69-py3-none-any.whl", hash = "sha256:50fb94833b5f4931255296396081b85143101bd9a7a894efbf20d1f759779de5", size = 415659, upload-time = "2026-04-10T08:13:27.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2d/70fc866ff98d1f6bd75b0a4235694129b3c519b014254d7bcfc02ffe1bee/bce_python_sdk-0.9.70-py3-none-any.whl", hash = "sha256:fd1f31113e4a8dca314f040662b7caf07ec11cf896c5da232627a9a2c9d2e3a1", size = 415660, upload-time = "2026-04-14T12:02:40.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -604,16 +604,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.88"
|
||||
version = "1.42.91"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/bb/7d4435cca6fccf235dd40c891c731bcb9078e815917b57ebadd1e0ffabaf/boto3-1.42.88.tar.gz", hash = "sha256:2d22c70de5726918676a06f1a03acfb4d5d9ea92fc759354800b67b22aaeef19", size = 113238, upload-time = "2026-04-10T19:41:06.912Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/c0/98b8cec7ca22dde776df48c58940ae1abc425593959b7226e270760d726f/boto3-1.42.91.tar.gz", hash = "sha256:03d70532b17f7f84df37ca7e8c21553280454dea53ae12b15d1cfef9b16fcb8a", size = 113181, upload-time = "2026-04-17T19:31:06.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/2b/8bfddb39a19f5fbc16a869f1a394771e6223f07160dbc0ff6b38e05ea0ae/boto3-1.42.88-py3-none-any.whl", hash = "sha256:2d0f52c971503377e4370d2a83edee6f077ddb8e684366ff38df4f13581d9cfc", size = 140557, upload-time = "2026-04-10T19:41:05.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/29/faba6521257c34085cc9b439ef98235b581772580f417fa3629728007270/boto3-1.42.91-py3-none-any.whl", hash = "sha256:04e72071cde022951ce7f81bd9933c90095ab8923e8ced61c8dacfe9edac0f5c", size = 140553, upload-time = "2026-04-17T19:31:02.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -636,16 +636,16 @@ bedrock-runtime = [
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.42.88"
|
||||
version = "1.42.91"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/93/50/87966238f7aa3f7e5f87081185d5a407a95ede8b551e11bbe134ca3306dc/botocore-1.42.88.tar.gz", hash = "sha256:cbb59ee464662039b0c2c95a520cdf85b1e8ce00b72375ab9cd9f842cc001301", size = 15195331, upload-time = "2026-04-10T19:40:57.012Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/bc/a4b7c46471c2e789ad8c4c7acfd7f302fdb481d93ff870f441249b924ae6/botocore-1.42.91.tar.gz", hash = "sha256:d252e27bc454afdbf5ed3dc617aa423f2c855c081e98b7963093399483ecc698", size = 15213010, upload-time = "2026-04-17T19:30:50.793Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/46/ad14e41245adb8b0c83663ba13e822b68a0df08999dd250e75b0750fdf6c/botocore-1.42.88-py3-none-any.whl", hash = "sha256:032375b213305b6b81eedb269eaeefdf96f674620799bbf96117dca86052cc1a", size = 14876640, upload-time = "2026-04-10T19:40:53.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/fc/24cc0a47c824f13933e210e9ad034b4fba22f7185b8d904c0fbf5a3b2be8/botocore-1.42.91-py3-none-any.whl", hash = "sha256:7a28c3cc6bfab5724ad18899d52402b776a0de7d87fa20c3c5270bcaaf199ce8", size = 14897344, upload-time = "2026-04-17T19:30:44.245Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -710,11 +710,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cachetools"
|
||||
version = "7.0.5"
|
||||
version = "6.2.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1577,7 +1577,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.42.88" },
|
||||
{ name = "boto3", specifier = ">=1.42.91" },
|
||||
{ name = "celery", specifier = ">=5.6.3" },
|
||||
{ name = "croniter", specifier = ">=6.2.2" },
|
||||
{ name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" },
|
||||
@@ -1591,12 +1591,12 @@ requires-dist = [
|
||||
{ name = "gevent-websocket", specifier = ">=0.10.1" },
|
||||
{ name = "gmpy2", specifier = ">=2.3.0" },
|
||||
{ name = "google-api-python-client", specifier = ">=2.194.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = ">=1.147.0,<2.0.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = ">=1.148.1,<2.0.0" },
|
||||
{ name = "graphon", specifier = "~=0.2.2" },
|
||||
{ name = "gunicorn", specifier = ">=25.3.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.2" },
|
||||
{ name = "json-repair", specifier = "~=0.59.4" },
|
||||
{ name = "opentelemetry-distro", specifier = ">=0.62b0,<1.0.0" },
|
||||
{ name = "opentelemetry-instrumentation-celery", specifier = ">=0.62b0,<1.0.0" },
|
||||
{ name = "opentelemetry-instrumentation-flask", specifier = ">=0.62b0,<1.0.0" },
|
||||
@@ -1677,17 +1677,17 @@ dev = [
|
||||
{ name = "types-tensorflow", specifier = ">=2.18.0.20260408" },
|
||||
{ name = "types-tqdm", specifier = ">=4.67.3.20260408" },
|
||||
{ name = "types-ujson", specifier = ">=5.10.0" },
|
||||
{ name = "xinference-client", specifier = ">=2.4.0" },
|
||||
{ name = "xinference-client", specifier = ">=2.5.0" },
|
||||
]
|
||||
storage = [
|
||||
{ name = "azure-storage-blob", specifier = ">=12.28.0" },
|
||||
{ name = "bce-python-sdk", specifier = ">=0.9.69" },
|
||||
{ name = "bce-python-sdk", specifier = ">=0.9.70" },
|
||||
{ name = "cos-python-sdk-v5", specifier = ">=1.9.41" },
|
||||
{ name = "esdk-obs-python", specifier = ">=3.22.2" },
|
||||
{ name = "google-cloud-storage", specifier = ">=3.10.1" },
|
||||
{ name = "opendal", specifier = ">=0.46.0" },
|
||||
{ name = "oss2", specifier = ">=2.19.1" },
|
||||
{ name = "supabase", specifier = ">=2.18.1" },
|
||||
{ name = "supabase", specifier = ">=2.28.3" },
|
||||
{ name = "tos", specifier = ">=2.9.0" },
|
||||
]
|
||||
tools = [
|
||||
@@ -1774,7 +1774,7 @@ vdb-upstash = [{ name = "dify-vdb-upstash", editable = "providers/vdb/vdb-upstas
|
||||
vdb-vastbase = [{ name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vastbase" }]
|
||||
vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }]
|
||||
vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }]
|
||||
vdb-xinference = [{ name = "xinference-client", specifier = ">=2.4.0" }]
|
||||
vdb-xinference = [{ name = "xinference-client", specifier = ">=2.5.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "dify-trace-aliyun"
|
||||
@@ -2764,7 +2764,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.147.0"
|
||||
version = "1.148.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
@@ -2780,9 +2780,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/93/9bfcaaf1ceab12999a881ccf69ebd9b30f467ec5623989c66894e81fc139/google_cloud_aiplatform-1.147.0.tar.gz", hash = "sha256:b2e1b669ba37f02426e03eb13187eebf4cbfeaa0a3bfed37b5578abb375ab689", size = 10235245, upload-time = "2026-04-09T17:14:49.179Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/d2/1c1c582f6bbed9bbc0daa5acf3a5d98751ca8bc48584548d28569b8ce1a7/google_cloud_aiplatform-1.147.0-py2.py3-none-any.whl", hash = "sha256:29f7ae020718d3c45094f0475464e06a97f81b1572bea150ae6a1b22c5f45997", size = 8408951, upload-time = "2026-04-09T17:14:45.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5b/e3515d7bbba602c2b0f6a0da5431785e897252443682e4735d0e6873dc8f/google_cloud_aiplatform-1.148.1-py2.py3-none-any.whl", hash = "sha256:035101e2d8e65c6a706cc3930b2452de7ddcbde50dd130320fcea0d8b03b0c5a", size = 8434481, upload-time = "2026-04-17T23:45:22.919Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3492,11 +3492,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "json-repair"
|
||||
version = "0.59.2"
|
||||
version = "0.59.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ed/cb/a49f1661737a78098ce33668350590c981a4163055bc9a01e0cc688d896a/json_repair-0.59.2.tar.gz", hash = "sha256:1d8abb2fa94c4035a66ef9892ea3785dace8dcf09c583e6de781cfd31b278b3d", size = 48341, upload-time = "2026-04-11T15:55:41.145Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/41/4ae9c6e711647a41b4e0c04bce815113ce9c0286eff6dc6fb86979b2fb9f/json_repair-0.59.4.tar.gz", hash = "sha256:559ca1828f6f566530663cd96d64bee29f8282b9d2ff0e661e05fa87b4171ab3", size = 47624, upload-time = "2026-04-15T06:48:40.776Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/03/7afcecb4242d93b684708b47fb014abdc1922a01b38c0e30f1117ae74a83/json_repair-0.59.2-py3-none-any.whl", hash = "sha256:6ca6238519c24f671bcb05d1f38a0d6a452bb4ca5af82137595c5c2f1a0fb785", size = 46918, upload-time = "2026-04-11T15:55:39.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/c4/ec3068436d2275731539b7a43fbc947f502bc3fe149856a5d00368c7b087/json_repair-0.59.4-py3-none-any.whl", hash = "sha256:46052e646bc0b0c39db672ebbf732f774f3c1a5bde81a54f0b0e19d3af4f45cd", size = 46697, upload-time = "2026-04-15T06:48:39.61Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4808,16 +4808,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "postgrest"
|
||||
version = "1.1.1"
|
||||
version = "2.28.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecation" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/3e/1b50568e1f5db0bdced4a82c7887e37326585faef7ca43ead86849cb4861/postgrest-1.1.1.tar.gz", hash = "sha256:f3bb3e8c4602775c75c844a31f565f5f3dd584df4d36d683f0b67d01a86be322", size = 15431, upload-time = "2025-06-23T19:21:34.742Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/60/9378ddd6e21b6005b34aeb42dc7a9ed9985c673c97c9b6a1858f9c52ebbd/postgrest-2.28.3.tar.gz", hash = "sha256:56336e9304950a78315ec7d6c8eb307cdb964d0878a7bec6111392ddb6c16a45", size = 13758, upload-time = "2026-03-20T14:38:06.542Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/71/188a50ea64c17f73ff4df5196ec1553a8f1723421eb2d1069c73bab47d78/postgrest-1.1.1-py3-none-any.whl", hash = "sha256:98a6035ee1d14288484bfe36235942c5fb2d26af6d8120dfe3efbe007859251a", size = 22366, upload-time = "2025-06-23T19:21:33.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5e/6eeb1d53d010d80e800204c1eee6b3d5419a6a2b985c364f56f36cf48cca/postgrest-2.28.3-py3-none-any.whl", hash = "sha256:5a44d6c6d509abdbe0f928c86f0dc31ef26bda36e0357129836ec54dfb50b083", size = 21865, upload-time = "2026-03-20T14:38:05.55Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5158,6 +5159,35 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyiceberg"
|
||||
version = "0.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cachetools" },
|
||||
{ name = "click" },
|
||||
{ name = "fsspec" },
|
||||
{ name = "mmh3" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyparsing" },
|
||||
{ name = "pyroaring" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "strictyaml" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "zstandard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/f0/7616676603fdbd05ab97816337a9b31be08a5f9e1ffd636260812b217e0f/pyiceberg-0.11.1.tar.gz", hash = "sha256:366fe0d5a74e3cf1d4e7cbf3c49e308da60e7835ea268667be9185388f05d7a5", size = 1076075, upload-time = "2026-03-03T00:10:27.61Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/84/a140466b7e0841207e6b77042e03d4ab3a4f9d47e00f0bbbcc5420792bbb/pyiceberg-0.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd423b8ee2f75fc9db09158875abe5e2c952a26ae5e521c3265ab2f9d3511ddf", size = 532981, upload-time = "2026-03-03T00:10:08.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/10/6bedd784010f707680ffd0606d4d11394cf915f4f9f54ae16e8007e00ad4/pyiceberg-0.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e273242cdca56029af694d7ce18075d47a74d034326d663ff6dd2655a6f44825", size = 533188, upload-time = "2026-03-03T00:10:10.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a3/79db617c3cffc963efa8a332707079d3f22fd58067b31a208d358dd89b39/pyiceberg-0.11.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b347d3cc8510f8fbe191956fcda7da372ebb3302789acefca08e352345959003", size = 729546, upload-time = "2026-03-03T00:10:11.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/64/acc11d230c33817bced80d9d947bb49e7bb3a429d76d906523e3df86faf8/pyiceberg-0.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba3a35b4648694783aeae5b77c235a57191c8b1b375c8602b03ae56a6cf4fe7", size = 730263, upload-time = "2026-03-03T00:10:13.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/1a/fb067d5150c7309fbf5dd126c648a6afed6259e7bc924ba3c65d0f87a333/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0f958cbca18d05846e3081dfff8575e73d45595441d659847479656dc76f91d", size = 724064, upload-time = "2026-03-03T00:10:14.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/71/103fdba5b144d55f3bb07347893737cc1d8fd71308108a77b7817c92c544/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c62636a1e9d8a1fc74ffb70383939b9cd93f2c9ee8e12015a50dd75c98a989e", size = 727239, upload-time = "2026-03-03T00:10:16.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/c3/4db64429304c58c039f8e842cd37a9a1c472f596c2868ed2a5d2907b17ed/pyiceberg-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d6b6f0c1e7dd8357f1ba56524bfc870d04ad3c00979db291784a7145497ad3b", size = 531309, upload-time = "2026-03-03T00:10:17.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
@@ -5342,6 +5372,26 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/32/38ac5af84d96167412024abf5e2f49f15b777987a1942e7a442e8e5fef82/pyrefly-0.61.1-py3-none-win_arm64.whl", hash = "sha256:cef5631e2ab09702274ec2eaaafee28a114891cf85f2d31568b329727e3ff735", size = 12302467, upload-time = "2026-04-17T18:47:31.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyroaring"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/71/134bcaf93d8734051ecbc164dabe695645849249e0fb24209b2dd88e8147/pyroaring-1.0.4.tar.gz", hash = "sha256:99d4217bdfeedc91b82efcec940175a9f9a9137c6476faf7ce5d9c9dd889c8e6", size = 189155, upload-time = "2026-03-19T13:57:27.932Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a7/9f4977405d3a3fa02cf575951f03a9cda4c01efbe27a19230addee06acc2/pyroaring-1.0.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:924ee997ff1a0f2a184e39e153e9f77e0b928fe908d0aef63d03204c3ed90586", size = 322060, upload-time = "2026-03-19T13:56:04.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e9/32fd7125aea82a3d1d29b755edbc7bb531907638c68a5bcc767d20c2be4a/pyroaring-1.0.4-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:b7a527279cc378e893a543a2271d71321b57d21733915a5a14e587532e29265a", size = 685716, upload-time = "2026-03-19T13:56:06.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/b3/6b78bd9d743c053fb3b0485a6b1f487e6a658123f06013bee55610b02120/pyroaring-1.0.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:d73979e1a3a6de2b7039dd9a545afa23f3b33f8b6f90a825390a34253d097f96", size = 363373, upload-time = "2026-03-19T13:56:07.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ff/188c16cdd75d841e50d2779ff7b5d1c5c915a6f23006ef3ab3680f48faca/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dab3d8577b143c64c1c1659b4d1c69d7fec5baa0d0c0181cdddf6b84f43af00a", size = 1914865, upload-time = "2026-03-19T13:56:09.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/12/ae7b4fa3682190597cbfb252be570358c5bc55f46f6b05db3fde66dfacc1/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dcc7d0133f46163b5390dd151e3305bd47289f7710c6e5444f38453d55b15d1c", size = 1742423, upload-time = "2026-03-19T13:56:10.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/25/966ea0a9d857ac3f2af1eaebdbfd56e627507b890f8ff7752c32e8e57b57/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a8b8448c2f7af3b40f17dde23b6739f3b19cf8b24db6f817c549c870d50aae3", size = 2130698, upload-time = "2026-03-19T13:56:12.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/31/0d0320925cf8bc1fcb53db182359bd673bf6b434f31c6cc69e4f5312c55f/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:13ccc488cfe6a227945586090397f299fd40c1a0dc1ed25e8e58a4d068c1ed46", size = 2822746, upload-time = "2026-03-19T13:56:13.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/98/a5f6d619098e307baf71b14a4df955914e8092f459d19daf80fbbd651fa1/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:dd89ebb7496325fb1b3dbe290dad35bc4da8722b5e3afd2a71b1d6e8ad981725", size = 2657370, upload-time = "2026-03-19T13:56:14.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/4d8beb31e4f648326b9b39c8f056fc1cb41422ef4e2be17cb15432c7fd40/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06b15bc8e0c272c3bee0c8adc29130178cd792ad99af64d7d7a1eb3069a1e0b8", size = 3088618, upload-time = "2026-03-19T13:56:16.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/16/95e223db5e60a6def8abcc2a8ebc4c71df23148a602310973c9a6964e3c5/pyroaring-1.0.4-cp312-cp312-win32.whl", hash = "sha256:4dca094f1d0e18901fa3f6b8866fa14a0f9640b22f41f5fc278c20e15e70efee", size = 202455, upload-time = "2026-03-19T13:56:18.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2c/5e41a91822c3bbc735382939d354e2c10bae453b18fe5a133f7fdbb33ce9/pyroaring-1.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:63ab0dcb24933fc9d4ca8c9fa0440f7e177183975990f756a42cddad22fd66db", size = 257479, upload-time = "2026-03-19T13:56:19.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/d7/7bc58e807e7d6739f4f5c45964a35927e1ff7f591e3943372097d29a00a7/pyroaring-1.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:226079645dd4098d3619ae5fc19bf9abfd2187a74aba94a8768443e637d406fa", size = 215516, upload-time = "2026-03-19T13:56:20.286Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
@@ -5671,16 +5721,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "realtime"
|
||||
version = "2.7.0"
|
||||
version = "2.28.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/e408fbdb6b344bf529c7e8bf020372d21114fe538392c72089462edd26e5/realtime-2.7.0.tar.gz", hash = "sha256:6b9434eeba8d756c8faf94fc0a32081d09f250d14d82b90341170602adbb019f", size = 18860, upload-time = "2025-07-28T18:54:22.949Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/3d/ef6ed9221f98766f3a503e6e3ac68fa7ca25c117b383f1efc448294232ac/realtime-2.28.3.tar.gz", hash = "sha256:5cc83a6217874426799d8bf74e96d904ac6fa77c39fa8982fa99287947eb2cbf", size = 18723, upload-time = "2026-03-20T14:38:08.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/07/a5c7aef12f9a3497f5ad77157a37915645861e8b23b89b2ad4b0f11b48ad/realtime-2.7.0-py3-none-any.whl", hash = "sha256:d55a278803529a69d61c7174f16563a9cfa5bacc1664f656959694481903d99c", size = 22409, upload-time = "2025-07-28T18:54:21.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d5/659405f9d4c9b022b7ac02bd52986ccc081f211db081051440f46bf4f358/realtime-2.28.3-py3-none-any.whl", hash = "sha256:efe484d6d39024c7e00ef70f70be600142e9407e5d802de8c96e86e014ce3b36", size = 22378, upload-time = "2026-03-20T14:38:07.144Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6162,16 +6212,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "storage3"
|
||||
version = "0.12.1"
|
||||
version = "2.28.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecation" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyiceberg" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/e2/280fe75f65e7a3ca680b7843acfc572a63aa41230e3d3c54c66568809c85/storage3-0.12.1.tar.gz", hash = "sha256:32ea8f5eb2f7185c2114a4f6ae66d577722e32503f0a30b56e7ed5c7f13e6b48", size = 10198, upload-time = "2025-08-05T18:09:11.989Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/b5/18df59ba92951d74774eb0265072bf236ead5e3cbc4b802d8bf1cf3581a0/storage3-2.28.3.tar.gz", hash = "sha256:2b3f843cbd44c4a3b483ec076a12c27de88c0ad5358a43067ed44ef08292353f", size = 20109, upload-time = "2026-03-20T14:38:11.467Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3b/c5f8709fc5349928e591fee47592eeff78d29a7d75b097f96a4e01de028d/storage3-0.12.1-py3-none-any.whl", hash = "sha256:9da77fd4f406b019fdcba201e9916aefbf615ef87f551253ce427d8136459a34", size = 18420, upload-time = "2025-08-05T18:09:10.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/a5/2dbe216954e026a8c2e2dc7dfa5fd7b1a1ae0824d10972e62462f4f15aca/storage3-2.28.3-py3-none-any.whl", hash = "sha256:bac35c5087619174448fdef6a337db4e3dfebf3de69f685bd706de93ddcdad69", size = 28239, upload-time = "2026-03-20T14:38:10.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6183,9 +6235,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strictyaml"
|
||||
version = "1.7.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supabase"
|
||||
version = "2.18.1"
|
||||
version = "2.28.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -6194,37 +6258,39 @@ dependencies = [
|
||||
{ name = "storage3" },
|
||||
{ name = "supabase-auth" },
|
||||
{ name = "supabase-functions" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/d2/3b135af55dd5788bd47875bb81f99c870054b990c030e51fd641a61b10b5/supabase-2.18.1.tar.gz", hash = "sha256:205787b1fbb43d6bc997c06fe3a56137336d885a1b56ec10f0012f2a2905285d", size = 11549, upload-time = "2025-08-12T19:02:27.852Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/98/2f1c95a2269ce995a34f275760b1c2ee71ee7a75649238ca0470afdfc2ef/supabase-2.28.3.tar.gz", hash = "sha256:1200961e46cdec17c7c280a1e09a159544643eada2759591ea69835303a2e1a4", size = 9687, upload-time = "2026-03-20T14:38:13.272Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/33/0e0062fea22cfe01d466dee83f56b3ed40c89bdcbca671bafeba3fe86b92/supabase-2.18.1-py3-none-any.whl", hash = "sha256:4fdd7b7247178a847f97ecd34f018dcb4775e487c8ff46b1208a01c933691fe9", size = 18683, upload-time = "2025-08-12T19:02:26.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/96/1b48eb664153401c22087bbf77f6a428965e830cc8e0d0c6d68324a28342/supabase-2.28.3-py3-none-any.whl", hash = "sha256:52a7ce4a1d2d55fa6d657bf4760672935058143a5bedc64165851be25ce01dbd", size = 16634, upload-time = "2026-03-20T14:38:12.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supabase-auth"
|
||||
version = "2.12.3"
|
||||
version = "2.28.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/e9/3d6f696a604752803b9e389b04d454f4b26a29b5d155b257fea4af8dc543/supabase_auth-2.12.3.tar.gz", hash = "sha256:8d3b67543f3b27f5adbfe46b66990424c8504c6b08c1141ec572a9802761edc2", size = 38430, upload-time = "2025-07-04T06:49:22.906Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/6f/1bf81293374ba71183b321bf5dfd7151c3db0c2e24715f35783bc1c56385/supabase_auth-2.28.3.tar.gz", hash = "sha256:41c049da82f9d7fc2f111808e57e984015f128d033f58caa67fd76f428472807", size = 39160, upload-time = "2026-03-20T14:38:15.128Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/96/a6/4102d5fa08a8521d9432b4d10bb58fedbd1f92b211d1b45d5394f5cb9021/supabase_auth-2.12.3-py3-none-any.whl", hash = "sha256:15c7580e1313d30ffddeb3221cb3cdb87c2a80fd220bf85d67db19cd1668435b", size = 44417, upload-time = "2025-07-04T06:49:21.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d3/e012315aa895b434fa77bc475e2dfeb87119e67918ecca4d88a25f96814d/supabase_auth-2.28.3-py3-none-any.whl", hash = "sha256:e47c5caec7bbf3c258964d027fbbe99f3cc4a956d3a635f898c962b4d22832dd", size = 48378, upload-time = "2026-03-20T14:38:14.169Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supabase-functions"
|
||||
version = "0.10.1"
|
||||
version = "2.28.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "strenum" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6c/e4/6df7cd4366396553449e9907c745862ebf010305835b2bac99933dd7db9d/supabase_functions-0.10.1.tar.gz", hash = "sha256:4779d33a1cc3d4aea567f586b16d8efdb7cddcd6b40ce367c5fb24288af3a4f1", size = 5025, upload-time = "2025-06-23T18:26:12.239Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/ea/59bf327960e5384fcc9e69afbdf97260a2cf2684a25c0731968a8a393b9c/supabase_functions-2.28.3.tar.gz", hash = "sha256:5a6255d60a263d44251c5ca250fcdde2408a8483a8bf31f4ac80255de8f3fcae", size = 4679, upload-time = "2026-03-20T14:38:16.742Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/06/060118a1e602c9bda8e4bf950bd1c8b5e1542349f2940ec57541266fabe1/supabase_functions-0.10.1-py3-none-any.whl", hash = "sha256:1db85e20210b465075aacee4e171332424f7305f9903c5918096be1423d6fcc5", size = 8275, upload-time = "2025-06-23T18:26:10.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ca/1e720f1347a88519e3d52b6d801cd031c3a7a5df66640c5dc6e81d925057/supabase_functions-2.28.3-py3-none-any.whl", hash = "sha256:eb30578866103fed9322c54e95dd68c2f1a4b6b177e129d9369edd364637904e", size = 8801, upload-time = "2026-03-20T14:38:15.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7413,7 +7479,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "xinference-client"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -7421,9 +7487,9 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/f2/7640528fd4f816df19afe91d52332a658ad2d2bacb13471b0a27dbd0cf46/xinference_client-2.4.0.tar.gz", hash = "sha256:59de6d58f89126c8ff05136818e0756108e534858255d7c4c0673b804fd2d01d", size = 58386, upload-time = "2026-03-29T05:10:58.533Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/8a/4d7c72510f3c462195c2e7aa63559cafcf20f7d1901132d533b7498bab1c/xinference_client-2.5.0.tar.gz", hash = "sha256:0680324e2f438b8b208ca80e8a7e1c22e9152fce54f8c024c75e2ce57bfa5639", size = 58430, upload-time = "2026-04-13T07:21:40.145Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cf/9d27e0095cc28691c73ff186b33556790c7b87f046ca2ecd517c80272592/xinference_client-2.4.0-py3-none-any.whl", hash = "sha256:2f9478b00fe15643f281fe4c0643e74479c8b7837d377000ff120702cda81efc", size = 40012, upload-time = "2026-03-29T05:10:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/dd/4fd501b8092c01f0775142850e3b601d743edf733077b756defe4a01cc37/xinference_client-2.5.0-py3-none-any.whl", hash = "sha256:bb90f069a2c30ac6ea7453ab37a0fadd34c28b655afa51fe20c18e67a361c269", size = 40006, upload-time = "2026-04-13T07:21:38.851Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@
|
||||
# If empty, it is the same domain.
|
||||
# Example: https://api.console.dify.ai
|
||||
CONSOLE_API_URL=
|
||||
CONSOLE_API_URL_NEW=
|
||||
|
||||
# The front-end URL of the console web,
|
||||
# used to concatenate some front-end addresses and for CORS configuration use.
|
||||
@@ -249,7 +250,7 @@ DB_TYPE=postgresql
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=difyai123456
|
||||
DB_HOST=db_postgres
|
||||
DB_PORT=5432
|
||||
DB_PORT=5433
|
||||
DB_DATABASE=dify
|
||||
|
||||
# The size of the database connection pool.
|
||||
@@ -342,7 +343,7 @@ MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT=2
|
||||
# ------------------------------
|
||||
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PORT=6390
|
||||
REDIS_USERNAME=
|
||||
REDIS_PASSWORD=difyai123456
|
||||
REDIS_USE_SSL=false
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
x-shared-env: &shared-api-worker-env
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
CONSOLE_API_URL_NEW: ${CONSOLE_API_URL_NEW:-}
|
||||
CONSOLE_WEB_URL: ${CONSOLE_WEB_URL:-}
|
||||
SERVICE_API_URL: ${SERVICE_API_URL:-}
|
||||
TRIGGER_URL: ${TRIGGER_URL:-http://localhost}
|
||||
@@ -61,7 +62,7 @@ x-shared-env: &shared-api-worker-env
|
||||
DB_USERNAME: ${DB_USERNAME:-postgres}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-difyai123456}
|
||||
DB_HOST: ${DB_HOST:-db_postgres}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_PORT: ${DB_PORT:-5433}
|
||||
DB_DATABASE: ${DB_DATABASE:-dify}
|
||||
SQLALCHEMY_POOL_SIZE: ${SQLALCHEMY_POOL_SIZE:-30}
|
||||
SQLALCHEMY_MAX_OVERFLOW: ${SQLALCHEMY_MAX_OVERFLOW:-10}
|
||||
@@ -82,7 +83,7 @@ x-shared-env: &shared-api-worker-env
|
||||
MYSQL_INNODB_LOG_FILE_SIZE: ${MYSQL_INNODB_LOG_FILE_SIZE:-128M}
|
||||
MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT: ${MYSQL_INNODB_FLUSH_LOG_AT_TRX_COMMIT:-2}
|
||||
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PORT: ${REDIS_PORT:-6390}
|
||||
REDIS_USERNAME: ${REDIS_USERNAME:-}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-difyai123456}
|
||||
REDIS_USE_SSL: ${REDIS_USE_SSL:-false}
|
||||
@@ -748,7 +749,7 @@ services:
|
||||
# Use the shared environment variables.
|
||||
<<: *shared-api-worker-env
|
||||
# Startup mode, 'api' starts the API server.
|
||||
MODE: api
|
||||
MODE: apina
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${API_SENTRY_TRACES_SAMPLE_RATE:-1.0}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${API_SENTRY_PROFILES_SAMPLE_RATE:-1.0}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"web/__tests__/embedded-user-id-store.test.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/__tests__/goto-anything/command-selector.test.tsx": {
|
||||
@@ -1182,14 +1182,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/features/new-feature-panel/feature-bar.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -6076,9 +6068,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/run/node.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -6407,11 +6396,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/context/global-public-context.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/context/hooks/use-trigger-events-limit-modal.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 3
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# @langgenius/dify-ui
|
||||
|
||||
Shared UI primitives, design tokens, Tailwind preset, and the `cn()` utility consumed by Dify's `web/` app.
|
||||
|
||||
The primitives are thin, opinionated wrappers around [Base UI] headless components, styled with `cva` + `cn` and Dify design tokens.
|
||||
|
||||
> `private: true` — this package is consumed by `web/` via the pnpm workspace and is not published to npm. Treat the API as internal to Dify, but stable within the workspace.
|
||||
|
||||
## Installation
|
||||
|
||||
Already wired as a workspace dependency in `web/package.json`. Nothing to install.
|
||||
|
||||
For a new workspace consumer, add:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"dependencies": {
|
||||
"@langgenius/dify-ui": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
Always import from a **subpath export** — there is no barrel:
|
||||
|
||||
```ts
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import '@langgenius/dify-ui/styles.css' // once, in the app root
|
||||
```
|
||||
|
||||
Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported — it keeps tree-shaking trivial and makes Storybook / test coverage attribution per-primitive.
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
|
||||
| Overlay | `./alert-dialog`, `./context-menu`, `./dialog`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Form | `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
|
||||
|
||||
Utilities:
|
||||
|
||||
- `./cn` — `clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
|
||||
- `./tailwind-preset` — Tailwind v4 preset with Dify tokens. Apps extend it from their own `tailwind.config.ts`.
|
||||
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and base reset. Import it once from the app root.
|
||||
|
||||
## Overlay & portal contract
|
||||
|
||||
All overlay primitives (`dialog`, `alert-dialog`, `popover`, `dropdown-menu`, `context-menu`, `select`, `tooltip`, `toast`) render their content inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Consumers **do not** need to wrap anything in a portal manually.
|
||||
|
||||
### Root isolation requirement
|
||||
|
||||
The host app **must** establish an isolated stacking context at its root so the portalled overlay layer is not clipped or re-ordered by ancestor `transform` / `filter` / `contain` styles. In the Dify web app this is done in `web/app/layout.tsx`:
|
||||
|
||||
```tsx
|
||||
<body>
|
||||
<div className="isolate h-full">{children}</div>
|
||||
</body>
|
||||
```
|
||||
|
||||
Equivalent: any root element with `isolation: isolate` in CSS. Without it, overlays can be visually clipped on Safari when a descendant creates a new stacking context.
|
||||
|
||||
### z-index layering
|
||||
|
||||
Every overlay primitive uses a single, shared z-index. Do **not** override it at call sites.
|
||||
|
||||
| Layer | z-index | Where |
|
||||
| ----------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| Overlays (Dialog, AlertDialog, Popover, DropdownMenu, ContextMenu, Select, Tooltip) | `z-1002` | Positioner / Backdrop |
|
||||
| Toast viewport | `z-1003` | One layer above overlays so notifications are never hidden under a dialog. |
|
||||
|
||||
Rationale: during Dify's migration from legacy `portal-to-follow-elem` / `base/modal` / `base/dialog` overlays to this package, new and old overlays coexist in the DOM. `z-1002` sits above any common legacy layer, eliminating per-call-site z-index hacks. Among themselves, new primitives share the same z-index and **rely on DOM order** for stacking — the portal mounted later wins.
|
||||
|
||||
See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for the Dify-web migration history and the remaining legacy allowlist. Once the legacy overlays are gone, the values in this table can drop back to `z-50` / `z-51`.
|
||||
|
||||
### Rules
|
||||
|
||||
- Never add `z-1003` / `z-9999` / etc. overrides on primitives from this package. If something is getting clipped, the **parent** overlay (typically a legacy one) is the problem and should be migrated.
|
||||
- Never portal an overlay manually on top of our primitives — use `DialogTrigger`, `PopoverTrigger`, etc. Base UI handles focus management, scroll-locking, and dismissal.
|
||||
- When a primitive needs additional presentation chrome (e.g. a custom backdrop), add it **inside** the exported component, not at call sites.
|
||||
|
||||
## Development
|
||||
|
||||
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
|
||||
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
|
||||
- `pnpm -C packages/dify-ui type-check` — `tsc --noEmit` for this package only.
|
||||
|
||||
See `[AGENTS.md](./AGENTS.md)` for:
|
||||
|
||||
- Component authoring rules (one-component-per-folder, `cva` + `cn`, relative imports inside the package, subpath imports from consumers).
|
||||
- Figma `--radius/`* token → Tailwind `rounded-*` class mapping.
|
||||
|
||||
## Not part of this package
|
||||
|
||||
- Application state (`jotai`, `zustand`), data fetching (`ky`, `@tanstack/react-query`, `@orpc/*`), i18n (`next-i18next` / `react-i18next`), and routing (`next`) all live in `web/`. This package has zero dependencies on them and must stay that way so it can eventually be consumed by other apps or extracted.
|
||||
- Business components (chat, workflow, dataset views, etc.). Those belong in `web/app/components/...`.
|
||||
|
||||
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
|
||||
[Base UI]: https://base-ui.com/react
|
||||
[Overlay & portal contract]: #overlay--portal-contract
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
|
||||
// All base/ui/* overlay primitives — z-1002
|
||||
// All @langgenius/dify-ui/* overlay primitives — z-1002
|
||||
// Toast stays one layer above overlays at z-1003.
|
||||
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
|
||||
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render
|
||||
|
||||
@@ -174,7 +174,7 @@ const StickyListPane = () => (
|
||||
<div className="mt-1 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="system-md-semibold text-text-primary">Operational queue</div>
|
||||
<p className="mt-1 system-xs-regular text-text-secondary">The scrollbar is still the shared base/ui primitive, while the pane adds sticky structure and a viewport mask.</p>
|
||||
<p className="mt-1 system-xs-regular text-text-secondary">The scrollbar is still the shared dify-ui primitive, while the pane adds sticky structure and a viewport mask.</p>
|
||||
</div>
|
||||
<span className="rounded-lg border border-divider-subtle bg-components-panel-bg-alt px-2.5 py-1 system-xs-medium text-text-secondary">
|
||||
24 items
|
||||
|
||||
@@ -8,7 +8,7 @@ declare global {
|
||||
var BASE_UI_ANIMATIONS_DISABLED: boolean | undefined
|
||||
}
|
||||
|
||||
describe('base/ui/toast', () => {
|
||||
describe('@langgenius/dify-ui/toast', () => {
|
||||
beforeAll(() => {
|
||||
// Base UI waits for `requestAnimationFrame` + `getAnimations().finished`
|
||||
// before unmounting a toast. Fake timers can't reliably drive that path,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-public",
|
||||
"name": "Dify Custom Public",
|
||||
"total": 142,
|
||||
"total": 144,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,9 +23,10 @@
|
||||
"./custom-vender/chars.json": "./custom-vender/chars.json"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "node ./scripts/generate-collections.mjs"
|
||||
"generate": "tsx ./scripts/generate-collections.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"iconify-import-svg": "catalog:"
|
||||
"iconify-import-svg": "catalog:",
|
||||
"tsx": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+115
-46
@@ -3,45 +3,62 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { importSvgCollections } from 'iconify-import-svg'
|
||||
|
||||
type IconData = {
|
||||
body: string
|
||||
left?: number
|
||||
top?: number
|
||||
width?: number
|
||||
height?: number
|
||||
rotate?: 0 | 1 | 2 | 3
|
||||
hFlip?: boolean
|
||||
vFlip?: boolean
|
||||
}
|
||||
|
||||
type AliasData = Omit<IconData, 'body'> & {
|
||||
parent: string
|
||||
}
|
||||
|
||||
type ImportedCollection = {
|
||||
icons?: Record<string, IconData>
|
||||
aliases?: Record<string, AliasData>
|
||||
lastModified?: number
|
||||
}
|
||||
|
||||
type ImportedCollections = Record<string, ImportedCollection>
|
||||
|
||||
type CollectionInfo = {
|
||||
prefix: string
|
||||
name: string
|
||||
total: number
|
||||
version: string
|
||||
author: {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
license: {
|
||||
title: string
|
||||
spdx: string
|
||||
url: string
|
||||
}
|
||||
samples: string[]
|
||||
palette: false
|
||||
}
|
||||
|
||||
type PackageJson = {
|
||||
version: string
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const packageDir = path.resolve(__dirname, '..')
|
||||
|
||||
const parseColorOptions = {
|
||||
fallback: () => 'currentColor',
|
||||
}
|
||||
const svgOptimizeConfig = {
|
||||
cleanupSVG: true,
|
||||
deOptimisePaths: true,
|
||||
runSVGO: true,
|
||||
parseColors: parseColorOptions,
|
||||
}
|
||||
|
||||
const customPublicCollections = importSvgCollections({
|
||||
source: path.resolve(packageDir, 'assets/public'),
|
||||
prefix: 'custom-public',
|
||||
ignoreImportErrors: true,
|
||||
...svgOptimizeConfig,
|
||||
})
|
||||
|
||||
const customVenderCollections = importSvgCollections({
|
||||
source: path.resolve(packageDir, 'assets/vender'),
|
||||
prefix: 'custom-vender',
|
||||
ignoreImportErrors: true,
|
||||
...svgOptimizeConfig,
|
||||
})
|
||||
|
||||
const packageJson = JSON.parse(await readFile(path.resolve(packageDir, 'package.json'), 'utf8'))
|
||||
|
||||
const flattenCollections = (collections, prefix) => {
|
||||
const icons = {}
|
||||
const aliases = {}
|
||||
const flattenCollections = (collections: ImportedCollections, prefix: string) => {
|
||||
const icons: Record<string, IconData> = {}
|
||||
const aliases: Record<string, AliasData> = {}
|
||||
let lastModified = 0
|
||||
|
||||
for (const [collectionKey, collection] of Object.entries(collections)) {
|
||||
const segment = collectionKey.slice(prefix.length + 1)
|
||||
const namePrefix = segment
|
||||
? `${segment}-`
|
||||
: ''
|
||||
const namePrefix = segment ? `${segment}-` : ''
|
||||
|
||||
for (const [iconName, iconData] of Object.entries(collection.icons ?? {}))
|
||||
icons[`${namePrefix}${iconName}`] = iconData
|
||||
@@ -61,11 +78,38 @@ const flattenCollections = (collections, prefix) => {
|
||||
}
|
||||
}
|
||||
|
||||
const createCollectionInfo = (prefix, name, icons) => ({
|
||||
const customPublicCollections = importSvgCollections({
|
||||
source: path.resolve(packageDir, 'assets/public'),
|
||||
prefix: 'custom-public',
|
||||
ignoreImportErrors: true,
|
||||
cleanupSVG: true,
|
||||
deOptimisePaths: true,
|
||||
runSVGO: true,
|
||||
parseColors: false,
|
||||
}) as ImportedCollections
|
||||
|
||||
const customVenderCollections = importSvgCollections({
|
||||
source: path.resolve(packageDir, 'assets/vender'),
|
||||
prefix: 'custom-vender',
|
||||
ignoreImportErrors: true,
|
||||
cleanupSVG: true,
|
||||
deOptimisePaths: true,
|
||||
runSVGO: false,
|
||||
parseColors: {
|
||||
callback: () => 'currentColor',
|
||||
},
|
||||
}) as ImportedCollections
|
||||
|
||||
const createCollectionInfo = (
|
||||
prefix: string,
|
||||
name: string,
|
||||
icons: Record<string, IconData>,
|
||||
version: string,
|
||||
): CollectionInfo => ({
|
||||
prefix,
|
||||
name,
|
||||
total: Object.keys(icons).length,
|
||||
version: packageJson.version,
|
||||
version,
|
||||
author: {
|
||||
name: 'LangGenius, Inc.',
|
||||
url: 'https://github.com/langgenius/dify',
|
||||
@@ -79,7 +123,7 @@ const createCollectionInfo = (prefix, name, icons) => ({
|
||||
palette: false,
|
||||
})
|
||||
|
||||
const createIndexMjs = () => `import icons from './icons.json' with { type: 'json' }
|
||||
const createIndexMjs = (): string => `import icons from './icons.json' with { type: 'json' }
|
||||
import info from './info.json' with { type: 'json' }
|
||||
import metadata from './metadata.json' with { type: 'json' }
|
||||
import chars from './chars.json' with { type: 'json' }
|
||||
@@ -87,7 +131,7 @@ import chars from './chars.json' with { type: 'json' }
|
||||
export { icons, info, metadata, chars }
|
||||
`
|
||||
|
||||
const createIndexJs = () => `'use strict'
|
||||
const createIndexJs = (): string => `'use strict'
|
||||
|
||||
const icons = require('./icons.json')
|
||||
const info = require('./info.json')
|
||||
@@ -97,7 +141,7 @@ const chars = require('./chars.json')
|
||||
module.exports = { icons, info, metadata, chars }
|
||||
`
|
||||
|
||||
const createIndexTypes = () => `export interface IconifyJSON {
|
||||
const createIndexTypes = (): string => `export interface IconifyJSON {
|
||||
prefix: string
|
||||
icons: Record<string, IconifyIcon>
|
||||
aliases?: Record<string, IconifyAlias>
|
||||
@@ -153,9 +197,14 @@ export declare const metadata: IconifyMetaData
|
||||
export declare const chars: IconifyChars
|
||||
`
|
||||
|
||||
const writeCollectionPackage = async (directoryName, collection, name) => {
|
||||
const writeCollectionPackage = async (
|
||||
directoryName: string,
|
||||
collection: ReturnType<typeof flattenCollections>,
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<void> => {
|
||||
const targetDir = path.resolve(packageDir, directoryName)
|
||||
const info = createCollectionInfo(collection.prefix, name, collection.icons)
|
||||
const info = createCollectionInfo(collection.prefix, name, collection.icons, version)
|
||||
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
await writeFile(path.resolve(targetDir, 'icons.json'), `${JSON.stringify(collection, null, 2)}\n`)
|
||||
@@ -167,12 +216,32 @@ const writeCollectionPackage = async (directoryName, collection, name) => {
|
||||
await writeFile(path.resolve(targetDir, 'index.d.ts'), `${createIndexTypes()}\n`)
|
||||
}
|
||||
|
||||
const mergedCustomPublicCollection = flattenCollections(customPublicCollections, 'custom-public')
|
||||
const mergedCustomVenderCollection = flattenCollections(customVenderCollections, 'custom-vender')
|
||||
async function main(): Promise<void> {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.resolve(packageDir, 'package.json'), 'utf8'),
|
||||
) as PackageJson
|
||||
const customPublicCollection = flattenCollections(customPublicCollections, 'custom-public')
|
||||
const customVenderCollection = flattenCollections(customVenderCollections, 'custom-vender')
|
||||
|
||||
await rm(path.resolve(packageDir, 'src'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'custom-public'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'custom-vender'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'src'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'custom-public'), { recursive: true, force: true })
|
||||
await rm(path.resolve(packageDir, 'custom-vender'), { recursive: true, force: true })
|
||||
|
||||
await writeCollectionPackage('custom-public', mergedCustomPublicCollection, 'Dify Custom Public')
|
||||
await writeCollectionPackage('custom-vender', mergedCustomVenderCollection, 'Dify Custom Vender')
|
||||
await writeCollectionPackage(
|
||||
'custom-public',
|
||||
customPublicCollection,
|
||||
'Dify Custom Public',
|
||||
packageJson.version,
|
||||
)
|
||||
await writeCollectionPackage(
|
||||
'custom-vender',
|
||||
customVenderCollection,
|
||||
'Dify Custom Vender',
|
||||
packageJson.version,
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Generated
+9
-6
@@ -352,8 +352,8 @@ catalogs:
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
iconify-import-svg:
|
||||
specifier: 0.1.2
|
||||
version: 0.1.2
|
||||
specifier: 0.2.0
|
||||
version: 0.2.0
|
||||
immer:
|
||||
specifier: 11.1.4
|
||||
version: 11.1.4
|
||||
@@ -718,7 +718,10 @@ importers:
|
||||
devDependencies:
|
||||
iconify-import-svg:
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.2
|
||||
version: 0.2.0
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.21.0
|
||||
|
||||
packages/migrate-no-unchecked-indexed-access:
|
||||
dependencies:
|
||||
@@ -5932,8 +5935,8 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
iconify-import-svg@0.1.2:
|
||||
resolution: {integrity: sha512-8dwxdGK1a7oPDQhLQOPTbx51tpkxYB6HZvf4fxWz2QVYqEtgop0FWE7OXQ+4zqnrTVUpMIGnOsvqIHtPBK9Isw==}
|
||||
iconify-import-svg@0.2.0:
|
||||
resolution: {integrity: sha512-NFuDyiYRKLSNvbiUnR4627DF4QjQR+bC+n+Nh0lcMnKXv9MCwzikOcdzqITU1yFfRacc6S6PeElc2H5l+35T1Q==}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
@@ -13329,7 +13332,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 6.0.2
|
||||
|
||||
iconify-import-svg@0.1.2:
|
||||
iconify-import-svg@0.2.0:
|
||||
dependencies:
|
||||
'@iconify/tools': 4.2.0
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ catalog:
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.0.4
|
||||
i18next-resources-to-backend: 1.2.1
|
||||
iconify-import-svg: 0.1.2
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.4
|
||||
jotai: 2.19.1
|
||||
js-audio-recorder: 1.0.7
|
||||
|
||||
+3
-2
@@ -4,8 +4,9 @@
|
||||
|
||||
## Overlay Components (Mandatory)
|
||||
|
||||
- `./docs/overlay-migration.md` is the source of truth for overlay-related work.
|
||||
- In new or modified code, use only overlay primitives from `@/app/components/base/ui/*`.
|
||||
- `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-1002` / `z-1003` layering.
|
||||
- `./docs/overlay-migration.md` is the source of truth for the ongoing migration (deprecated import paths, allowlist, coexistence rules).
|
||||
- In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`.
|
||||
- Do not introduce deprecated overlay imports from `@/app/components/base/*`; when touching legacy callers, prefer migrating them and keep the allowlist shrinking (never expanding).
|
||||
|
||||
## Query & Mutation (Mandatory)
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ The Dify community can be found on [Discord community], where you can ask questi
|
||||
[Storybook]: https://storybook.js.org
|
||||
[Vite+]: https://viteplus.dev
|
||||
[Vitest]: https://vitest.dev
|
||||
[index.spec.tsx]: ./app/components/base/button/index.spec.tsx
|
||||
[index.spec.tsx]: ./app/components/base/radio/__tests__/index.spec.tsx
|
||||
[pnpm]: https://pnpm.io
|
||||
[vinext]: https://github.com/cloudflare/vinext
|
||||
[web/docs/test.md]: ./docs/test.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import AppPublisher from '@/app/components/app/app-publisher'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -24,27 +24,15 @@ let mockAppDetail: {
|
||||
}
|
||||
} | null = null
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
const renderWithQueryClient = (ui: React.ReactElement) =>
|
||||
renderWithSystemFeatures(ui, {
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
@@ -58,16 +46,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (value: number) => `ago:${value}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import AppPublisher from '@/app/components/app/app-publisher'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -28,27 +28,15 @@ let mockAppDetail: {
|
||||
}
|
||||
} | null = null
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
const renderWithQueryClient = (ui: React.ReactElement) =>
|
||||
renderWithSystemFeatures(ui, {
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
const queryClient = createTestQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
@@ -66,16 +54,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (value: number) => `ago:${value}`,
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
* - Access mode icons
|
||||
*/
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import AppCard from '@/app/components/apps/app-card'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
@@ -96,15 +97,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
if (typeof selector === 'function')
|
||||
return selector(state)
|
||||
return mockSystemFeatures
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
@@ -255,7 +247,10 @@ const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
const mockOnRefresh = vi.fn()
|
||||
|
||||
const renderAppCard = (app?: Partial<App>) => {
|
||||
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />)
|
||||
return renderWithSystemFeatures(
|
||||
<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />,
|
||||
{ systemFeatures: mockSystemFeatures },
|
||||
)
|
||||
}
|
||||
|
||||
const openOperationsMenu = () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
/**
|
||||
* Integration test: App List Browsing Flow
|
||||
*
|
||||
@@ -8,11 +9,12 @@
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
@@ -64,13 +66,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: vi.fn(),
|
||||
@@ -168,11 +163,21 @@ const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse =>
|
||||
total: apps.length,
|
||||
})
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return renderWithNuqs(
|
||||
<List controlRefreshList={0} />,
|
||||
{ searchParams },
|
||||
const renderListUI = (ui: ReactElement, searchParams?: Record<string, string>) => {
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: mockSystemFeatures,
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return renderListUI(<List controlRefreshList={0} />, searchParams)
|
||||
}
|
||||
|
||||
describe('App List Browsing Flow', () => {
|
||||
@@ -216,7 +221,7 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
it('should transition from loading to content when data loads', () => {
|
||||
mockIsLoading = true
|
||||
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
|
||||
const { rerender } = renderListUI(<List controlRefreshList={0} />)
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
@@ -423,7 +428,7 @@ describe('App List Browsing Flow', () => {
|
||||
it('should call refetch when controlRefreshList increments', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
|
||||
const { rerender } = renderListUI(<List controlRefreshList={0} />)
|
||||
|
||||
rerender(<List controlRefreshList={1} />)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
/**
|
||||
* Integration test: Create App Flow
|
||||
*
|
||||
@@ -9,11 +10,12 @@
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
@@ -51,13 +53,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
@@ -222,7 +217,16 @@ const createPage = (apps: App[]): AppListResponse => ({
|
||||
})
|
||||
|
||||
const renderList = () => {
|
||||
return renderWithNuqs(<List controlRefreshList={0} />)
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: mockSystemFeatures,
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper()
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(<List controlRefreshList={0} />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('Create App Flow', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
|
||||
import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks'
|
||||
import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
@@ -19,44 +20,12 @@ vi.mock('@/service/use-share', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
// Store the mock implementation in a way that survives hoisting
|
||||
const mockGetProcessedSystemVariablesFromUrlParams = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/chat/utils', () => ({
|
||||
getProcessedSystemVariablesFromUrlParams: (...args: any[]) => mockGetProcessedSystemVariablesFromUrlParams(...args),
|
||||
}))
|
||||
|
||||
// Use vi.hoisted to define mock state before vi.mock hoisting
|
||||
const { mockGlobalStoreState } = vi.hoisted(() => ({
|
||||
mockGlobalStoreState: {
|
||||
isGlobalPending: false,
|
||||
setIsGlobalPending: vi.fn(),
|
||||
systemFeatures: {},
|
||||
setSystemFeatures: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => {
|
||||
const useGlobalPublicStore = Object.assign(
|
||||
(selector?: (state: typeof mockGlobalStoreState) => any) =>
|
||||
selector ? selector(mockGlobalStoreState) : mockGlobalStoreState,
|
||||
{
|
||||
setState: (updater: any) => {
|
||||
if (typeof updater === 'function')
|
||||
Object.assign(mockGlobalStoreState, updater(mockGlobalStoreState) ?? {})
|
||||
|
||||
else
|
||||
Object.assign(mockGlobalStoreState, updater)
|
||||
},
|
||||
__mockState: mockGlobalStoreState,
|
||||
},
|
||||
)
|
||||
return {
|
||||
useGlobalPublicStore,
|
||||
useIsSystemFeaturesPending: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
const TestConsumer = () => {
|
||||
const embeddedUserId = useWebAppStore(state => state.embeddedUserId)
|
||||
const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId)
|
||||
@@ -91,7 +60,6 @@ const initialWebAppStore = (() => {
|
||||
})()
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobalStoreState.isGlobalPending = false
|
||||
mockGetProcessedSystemVariablesFromUrlParams.mockReset()
|
||||
useWebAppStore.setState(initialWebAppStore, true)
|
||||
})
|
||||
@@ -103,7 +71,7 @@ describe('WebAppStoreProvider embedded user id handling', () => {
|
||||
conversation_id: 'conversation-456',
|
||||
})
|
||||
|
||||
render(
|
||||
renderWithSystemFeatures(
|
||||
<WebAppStoreProvider>
|
||||
<TestConsumer />
|
||||
</WebAppStoreProvider>,
|
||||
@@ -125,7 +93,7 @@ describe('WebAppStoreProvider embedded user id handling', () => {
|
||||
}))
|
||||
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
|
||||
|
||||
render(
|
||||
renderWithSystemFeatures(
|
||||
<WebAppStoreProvider>
|
||||
<TestConsumer />
|
||||
</WebAppStoreProvider>,
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import AppList from '@/app/components/explore/app-list'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import AccountDropdown from '@/app/components/header/account-dropdown'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@@ -52,20 +52,6 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: false,
|
||||
workspace_logo: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
@@ -108,18 +94,14 @@ vi.mock('@/next/link', () => ({
|
||||
}))
|
||||
|
||||
const renderAccountDropdown = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
return renderWithSystemFeatures(<AccountDropdown />, {
|
||||
systemFeatures: {
|
||||
branding: {
|
||||
enabled: false,
|
||||
workspace_logo: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AccountDropdown />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Header Account Dropdown Flow', () => {
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
|
||||
import { InstallationScope } from '@/types/feature'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Plugin Marketplace to Install Flow', () => {
|
||||
describe('install permission validation pipeline', () => {
|
||||
const systemFeaturesAll = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import PluginPage from '@/app/components/plugins/plugin-page'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
const mockFetchManifestFromMarketPlace = vi.fn()
|
||||
|
||||
@@ -35,17 +37,6 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useReferenceSettings: () => ({
|
||||
data: {
|
||||
@@ -104,13 +95,30 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () =
|
||||
}))
|
||||
|
||||
const renderPluginPage = (searchParams = '') => {
|
||||
return renderWithNuqs(
|
||||
<PluginPage
|
||||
plugins={<div data-testid="plugins-view">plugins view</div>}
|
||||
marketplace={<div data-testid="marketplace-view">marketplace view</div>}
|
||||
/>,
|
||||
{ searchParams },
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
plugin_installation_permission: {
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return {
|
||||
...render(
|
||||
<PluginPage
|
||||
plugins={<div data-testid="plugins-view">plugins view</div>}
|
||||
marketplace={<div data-testid="marketplace-view">marketplace view</div>}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
),
|
||||
onUrlUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Plugin Page Shell Flow', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import TextGeneration from '@/app/components/share/text-generation'
|
||||
|
||||
const useSearchParamsMock = vi.fn(() => new URLSearchParams())
|
||||
@@ -117,7 +118,7 @@ vi.mock('@/service/share', async () => {
|
||||
const mockSystemFeatures = {
|
||||
branding: {
|
||||
enabled: false,
|
||||
workspace_logo: null,
|
||||
workspace_logo: '',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -170,11 +171,6 @@ const mockWebAppState = {
|
||||
webAppAccessMode: 'public' as AccessMode,
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
|
||||
selector({ systemFeatures: mockSystemFeatures }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState),
|
||||
}))
|
||||
@@ -189,7 +185,7 @@ describe('TextGeneration', () => {
|
||||
})
|
||||
|
||||
it('should switch between create, batch, and saved tabs after app state loads', async () => {
|
||||
render(<TextGeneration />)
|
||||
renderWithSystemFeatures(<TextGeneration />, { systemFeatures: mockSystemFeatures })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
|
||||
@@ -212,7 +208,7 @@ describe('TextGeneration', () => {
|
||||
})
|
||||
|
||||
it('should wire single-run stop control and clear it when batch execution starts', async () => {
|
||||
render(<TextGeneration />)
|
||||
renderWithSystemFeatures(<TextGeneration />, { systemFeatures: mockSystemFeatures })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import ProviderList from '@/app/components/tools/provider-list'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
const mockInvalidateInstalledPluginList = vi.fn()
|
||||
|
||||
@@ -12,14 +14,6 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
enable_marketplace: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (name: string) => name,
|
||||
@@ -159,7 +153,16 @@ vi.mock('@/app/components/tools/mcp', () => ({
|
||||
}))
|
||||
|
||||
const renderProviderList = (searchParams = '') => {
|
||||
return renderWithNuqs(<ProviderList />, { searchParams })
|
||||
const { wrapper: SysWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
})
|
||||
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsWrapper>
|
||||
<SysWrapper>{children}</SysWrapper>
|
||||
</NuqsWrapper>
|
||||
)
|
||||
return { ...render(<ProviderList />, { wrapper: Wrapper }), onUrlUpdate }
|
||||
}
|
||||
|
||||
describe('Tool Provider List Shell Flow', () => {
|
||||
|
||||
@@ -6,10 +6,10 @@ import type { Collection } from '@/app/components/tools/types'
|
||||
* Input (search), and card rendering. Verifies that tab switching, keyword
|
||||
* filtering, and label filtering work together correctly.
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
|
||||
// ---- Mocks ----
|
||||
@@ -36,10 +36,6 @@ vi.mock('nuqs', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({ enable_marketplace: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/hooks', () => ({
|
||||
useTags: () => ({
|
||||
getTagLabel: (key: string) => key,
|
||||
@@ -237,12 +233,10 @@ vi.mock('@/app/components/workflow/block-selector/types', () => ({
|
||||
const { default: ProviderList } = await import('@/app/components/tools/provider-list')
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
const { wrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_marketplace: false },
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Tool Browsing & Filtering Integration', () => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
|
||||
type DeepPartial<T> = T extends Array<infer U>
|
||||
? Array<U>
|
||||
: T extends object
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T
|
||||
|
||||
const buildSystemFeatures = (
|
||||
overrides: DeepPartial<SystemFeatures> = {},
|
||||
): SystemFeatures => {
|
||||
const o = overrides as Partial<SystemFeatures>
|
||||
return {
|
||||
...defaultSystemFeatures,
|
||||
...o,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
...(o.branding ?? {}),
|
||||
},
|
||||
webapp_auth: {
|
||||
...defaultSystemFeatures.webapp_auth,
|
||||
...(o.webapp_auth ?? {}),
|
||||
sso_config: {
|
||||
...defaultSystemFeatures.webapp_auth.sso_config,
|
||||
...(o.webapp_auth?.sso_config ?? {}),
|
||||
},
|
||||
},
|
||||
plugin_installation_permission: {
|
||||
...defaultSystemFeatures.plugin_installation_permission,
|
||||
...(o.plugin_installation_permission ?? {}),
|
||||
},
|
||||
license: {
|
||||
...defaultSystemFeatures.license,
|
||||
...(o.license ?? {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a QueryClient suitable for tests. Any unseeded query stays in the
|
||||
* "pending" state forever because the default queryFn never resolves; this
|
||||
* mirrors the behaviour of an in-flight network request without touching the
|
||||
* real fetch layer.
|
||||
*/
|
||||
export const createTestQueryClient = (): QueryClient =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
staleTime: Infinity,
|
||||
queryFn: () => new Promise(() => {}),
|
||||
},
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
export const seedSystemFeatures = (
|
||||
queryClient: QueryClient,
|
||||
overrides: DeepPartial<SystemFeatures> = {},
|
||||
): SystemFeatures => {
|
||||
const data = buildSystemFeatures(overrides)
|
||||
queryClient.setQueryData(consoleQuery.systemFeatures.queryKey(), data)
|
||||
return data
|
||||
}
|
||||
|
||||
type SystemFeaturesTestOptions = {
|
||||
/**
|
||||
* Partial overrides for the systemFeatures payload. When omitted, the cache
|
||||
* is seeded with `defaultSystemFeatures` so consumers using
|
||||
* `useSuspenseQuery` resolve immediately. Pass `null` to skip seeding and
|
||||
* keep the systemFeatures query in the pending state.
|
||||
*/
|
||||
systemFeatures?: DeepPartial<SystemFeatures> | null
|
||||
queryClient?: QueryClient
|
||||
}
|
||||
|
||||
type SystemFeaturesWrapper = {
|
||||
queryClient: QueryClient
|
||||
systemFeatures: SystemFeatures | null
|
||||
wrapper: (props: { children: ReactNode }) => ReactElement
|
||||
}
|
||||
|
||||
export const createSystemFeaturesWrapper = (
|
||||
options: SystemFeaturesTestOptions = {},
|
||||
): SystemFeaturesWrapper => {
|
||||
const queryClient = options.queryClient ?? createTestQueryClient()
|
||||
const systemFeatures = options.systemFeatures === null
|
||||
? null
|
||||
: seedSystemFeatures(queryClient, options.systemFeatures)
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
return { queryClient, systemFeatures, wrapper }
|
||||
}
|
||||
|
||||
export const renderWithSystemFeatures = (
|
||||
ui: ReactElement,
|
||||
options: SystemFeaturesTestOptions & Omit<RenderOptions, 'wrapper'> = {},
|
||||
): RenderResult & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => {
|
||||
const { systemFeatures: sf, queryClient: qc, ...renderOptions } = options
|
||||
const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({
|
||||
systemFeatures: sf,
|
||||
queryClient: qc,
|
||||
})
|
||||
const rendered = render(ui, { wrapper, ...renderOptions })
|
||||
return { ...rendered, queryClient, systemFeatures }
|
||||
}
|
||||
|
||||
export const renderHookWithSystemFeatures = <Result, Props = void>(
|
||||
callback: (props: Props) => Result,
|
||||
options: SystemFeaturesTestOptions & Omit<RenderHookOptions<Props>, 'wrapper'> = {},
|
||||
): RenderHookResult<Result, Props> & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => {
|
||||
const { systemFeatures: sf, queryClient: qc, ...hookOptions } = options
|
||||
const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({
|
||||
systemFeatures: sf,
|
||||
queryClient: qc,
|
||||
})
|
||||
const rendered = renderHook(callback, { wrapper, ...hookOptions })
|
||||
return { ...rendered, queryClient, systemFeatures }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { isLegacyBase401 } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
error: Error & { digest?: string }
|
||||
unstable_retry: () => void
|
||||
}
|
||||
|
||||
export default function CommonLayoutError({ error, unstable_retry }: Props) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
// 401 already triggered jumpTo(/signin) inside service/base.ts. Render Loading
|
||||
// until the browser navigation completes, matching main's Splash behavior.
|
||||
// Showing the "Try again" button here would just flash for a few frames before
|
||||
// the page navigates away, and clicking it would 401 again anyway.
|
||||
if (isLegacyBase401(error))
|
||||
return <RootLoading />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
|
||||
<div className="system-sm-regular text-text-tertiary">
|
||||
{t('errorBoundary.message')}
|
||||
</div>
|
||||
<Button size="small" variant="secondary" onClick={() => unstable_retry()}>
|
||||
{t('errorBoundary.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
@@ -14,14 +13,12 @@ import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
import { ProviderContextProvider } from '@/context/provider-context-provider'
|
||||
import PartnerStack from '../components/billing/partner-stack'
|
||||
import Splash from '../components/splash'
|
||||
import RoleRouteGuard from './role-route-guard'
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
@@ -37,7 +34,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
<PartnerStack />
|
||||
<ReadmePanel />
|
||||
<GotoAnything />
|
||||
<Splash />
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
export default function CommonLayoutLoading() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { SSOProtocol } from '@/types/feature'
|
||||
|
||||
const ExternalMemberSSOAuth = () => {
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
|
||||
export default function SignInLayout({ children }: PropsWithChildren) {
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
useDocumentTitle(t('webapp.login', { ns: 'login' }))
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import Link from '@/next/link'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { LicenseStatus } from '@/types/feature'
|
||||
import MailAndCodeAuth from './components/mail-and-code-auth'
|
||||
import MailAndPasswordAuth from './components/mail-and-password-auth'
|
||||
@@ -17,7 +18,7 @@ const NormalForm = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
|
||||
const [showORLine, setShowORLine] = useState(false)
|
||||
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { webAppLogout } from '@/service/webapp-auth'
|
||||
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
|
||||
import NormalForm from './normalForm'
|
||||
|
||||
const WebSSOForm: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import {
|
||||
RiGraduationCapFill,
|
||||
} from '@remixicon/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
@@ -15,11 +15,11 @@ import Input from '@/app/components/base/input'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import Collapse from '@/app/components/header/account-setting/collapse'
|
||||
import { IS_CE_EDITION, validPassword } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useAppList } from '@/service/use-apps'
|
||||
import { commonQueryKeys, useUserProfile } from '@/service/use-common'
|
||||
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
|
||||
import DeleteAccount from '../delete-account'
|
||||
|
||||
import AvatarWithEdit from './AvatarWithEdit'
|
||||
@@ -34,12 +34,13 @@ const descriptionClassName = `
|
||||
|
||||
export default function AccountPage() {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: appList } = useAppList({ page: 1, limit: 100, name: '' })
|
||||
const apps = appList?.data || []
|
||||
const queryClient = useQueryClient()
|
||||
const { data: userProfileResp } = useUserProfile()
|
||||
const userProfile = userProfileResp?.profile
|
||||
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
|
||||
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
|
||||
const userProfile = userProfileResp.profile
|
||||
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import {
|
||||
RiGraduationCapFill,
|
||||
} from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { Fragment } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
@@ -11,13 +12,14 @@ import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout, useUserProfile } from '@/service/use-common'
|
||||
import { useLogout, userProfileQueryOptions } from '@/service/use-common'
|
||||
|
||||
export default function AppSelector() {
|
||||
const router = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const { data: userProfileResp } = useUserProfile()
|
||||
const userProfile = userProfileResp?.profile
|
||||
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
|
||||
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
|
||||
const userProfile = userProfileResp.profile
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
|
||||
const { mutateAsync: logout } = useLogout()
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import Avatar from './avatar'
|
||||
|
||||
const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
const goToStudio = useCallback(() => {
|
||||
router.push('/apps')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
@@ -14,7 +13,6 @@ const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GA gaType={GaType.admin} />
|
||||
<AmplitudeProvider />
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useIsLogin } from '@/service/use-common'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
useDocumentTitle('')
|
||||
const { isLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
// Probe login state. 401 stays as `error` (not thrown) so this layout can render
|
||||
// the signin/oauth UI for unauthenticated users; other errors bubble to error.tsx.
|
||||
// (When unauthenticated, service/base.ts's auto-redirect to /signin still fires.)
|
||||
const { isPending, data: userResp, error } = useQuery({
|
||||
...userProfileQueryOptions(),
|
||||
throwOnError: err => !isLegacyBase401(err),
|
||||
})
|
||||
const isLoggedIn = !!userResp && !error
|
||||
|
||||
if (isLoading) {
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full justify-center bg-background-default-burn">
|
||||
<Loading />
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RiMailLine,
|
||||
RiTranslate2,
|
||||
} from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -17,7 +18,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useIsLogin, useUserProfile } from '@/service/use-common'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
|
||||
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
|
||||
|
||||
function buildReturnUrl(pathname: string, search: string) {
|
||||
@@ -61,15 +62,20 @@ export default function OAuthAuthorize() {
|
||||
const searchParams = useSearchParams()
|
||||
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
|
||||
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
|
||||
const { data: userProfileResp } = useUserProfile()
|
||||
// Probe user profile. 401 stays as `error` (legitimate "not logged in" state),
|
||||
// other errors throw to the nearest error.tsx; jumpTo same-pathname guard in
|
||||
// service/base.ts prevents a redirect loop here.
|
||||
const { data: userProfileResp, isPending: isProfileLoading, error: profileError } = useQuery({
|
||||
...userProfileQueryOptions(),
|
||||
throwOnError: err => !isLegacyBase401(err),
|
||||
})
|
||||
const isLoggedIn = !!userProfileResp && !profileError
|
||||
const userProfile = userProfileResp?.profile
|
||||
const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
|
||||
const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
|
||||
const hasNotifiedRef = useRef(false)
|
||||
|
||||
const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin()
|
||||
const isLoggedIn = loginData?.logged_in
|
||||
const isLoading = isOAuthLoading || isIsLoginLoading
|
||||
const isLoading = isOAuthLoading || isProfileLoading
|
||||
const onLoginSwitchClick = () => {
|
||||
try {
|
||||
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import Header from '../signin/_header'
|
||||
import ActivateForm from './activateForm'
|
||||
|
||||
const Activate = () => {
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
return (
|
||||
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
|
||||
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { MockedFunction } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useUserProfile } from '@/service/use-common'
|
||||
import Splash from '../splash'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useUserProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseUserProfile = useUserProfile as MockedFunction<typeof useUserProfile>
|
||||
|
||||
describe('Splash', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the loading indicator while the profile query is pending', () => {
|
||||
mockUseUserProfile.mockReturnValue({
|
||||
isPending: true,
|
||||
isError: false,
|
||||
data: undefined,
|
||||
} as ReturnType<typeof useUserProfile>)
|
||||
|
||||
render(<Splash />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render the loading indicator when the profile query succeeds', () => {
|
||||
mockUseUserProfile.mockReturnValue({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
data: {
|
||||
profile: { id: 'user-1' },
|
||||
meta: {
|
||||
currentVersion: '1.13.3',
|
||||
currentEnv: 'DEVELOPMENT',
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useUserProfile>)
|
||||
|
||||
render(<Splash />)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stop rendering the loading indicator when the profile query errors', () => {
|
||||
mockUseUserProfile.mockReturnValue({
|
||||
isPending: false,
|
||||
isError: true,
|
||||
data: undefined,
|
||||
error: new Error('profile request failed'),
|
||||
} as ReturnType<typeof useUserProfile>)
|
||||
|
||||
render(<Splash />)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
@@ -98,5 +99,5 @@ export const AppInitializer = ({
|
||||
})()
|
||||
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
|
||||
|
||||
return init ? children : null
|
||||
return init ? children : <RootLoading />
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@ const defaultProps = {
|
||||
setSecretEnvList: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onCopy: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onExport: vi.fn(async () => {}),
|
||||
exportCheck: vi.fn(),
|
||||
handleConfirmExport: vi.fn(),
|
||||
handleConfirmExport: vi.fn(async () => {}),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -238,6 +238,42 @@ describe('AppInfoModals', () => {
|
||||
expect(defaultProps.handleConfirmExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable export confirm button and avoid duplicate submits while confirming export', async () => {
|
||||
let resolveConfirmExport: () => void
|
||||
const handleConfirmExport = vi.fn(() => new Promise<void>((resolve) => {
|
||||
resolveConfirmExport = resolve
|
||||
}))
|
||||
const user = userEvent.setup()
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<AppInfoModals
|
||||
{...defaultProps}
|
||||
activeModal="exportWarning"
|
||||
handleConfirmExport={handleConfirmExport}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
const confirmButton = await screen.findByRole('button', { name: 'common.operation.confirm' })
|
||||
|
||||
const firstClick = user.click(confirmButton)
|
||||
await waitFor(() => {
|
||||
expect(confirmButton).toBeDisabled()
|
||||
expect(confirmButton).toHaveTextContent('common.operation.exporting')
|
||||
})
|
||||
await user.click(confirmButton)
|
||||
|
||||
expect(handleConfirmExport).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveConfirmExport!()
|
||||
await firstClick
|
||||
await waitFor(() => {
|
||||
expect(confirmButton).not.toBeDisabled()
|
||||
expect(confirmButton).toHaveTextContent('common.operation.confirm')
|
||||
})
|
||||
})
|
||||
|
||||
it('should call exportCheck when backup on importDSL modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
await act(async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import dynamic from '@/next/dynamic'
|
||||
@@ -34,7 +34,7 @@ type AppInfoModalsProps = {
|
||||
onCopy: DuplicateAppModalProps['onConfirm']
|
||||
onExport: (include?: boolean) => Promise<void>
|
||||
exportCheck: () => void
|
||||
handleConfirmExport: () => void
|
||||
handleConfirmExport: () => Promise<void>
|
||||
onConfirmDelete: () => void
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ const AppInfoModals = ({
|
||||
}: AppInfoModalsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
|
||||
const [isConfirmingExport, setIsConfirmingExport] = useState(false)
|
||||
const isDeleteConfirmDisabled = confirmDeleteInput !== appDetail.name
|
||||
|
||||
const handleDeleteDialogClose = () => {
|
||||
@@ -60,6 +61,19 @@ const AppInfoModals = ({
|
||||
closeModal()
|
||||
}
|
||||
|
||||
const handleExportWarningConfirm = useCallback(async () => {
|
||||
if (isConfirmingExport)
|
||||
return
|
||||
|
||||
setIsConfirmingExport(true)
|
||||
try {
|
||||
await handleConfirmExport()
|
||||
}
|
||||
finally {
|
||||
setIsConfirmingExport(false)
|
||||
}
|
||||
}, [handleConfirmExport, isConfirmingExport])
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeModal === 'switch' && (
|
||||
@@ -161,8 +175,15 @@ const AppInfoModals = ({
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton tone="default" onClick={handleConfirmExport}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
<AlertDialogConfirmButton
|
||||
tone="default"
|
||||
loading={isConfirmingExport}
|
||||
disabled={isConfirmingExport}
|
||||
onClick={handleExportWarningConfirm}
|
||||
>
|
||||
{isConfirmingExport
|
||||
? t('operation.exporting', { ns: 'common' })
|
||||
: t('operation.confirm', { ns: 'common' })}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => { })
|
||||
}, [appDetail?.id])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +89,7 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => { })
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
@@ -183,7 +183,6 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
|
||||
const handleConfirmExport = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
closeModal()
|
||||
try {
|
||||
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
|
||||
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
|
||||
@@ -196,6 +195,9 @@ export function useAppInfoActions({ onDetailExpand }: UseAppInfoActionsParams) {
|
||||
catch {
|
||||
toast(t('exportFailed', { ns: 'app' }), { type: 'error' })
|
||||
}
|
||||
finally {
|
||||
closeModal()
|
||||
}
|
||||
}, [appDetail, closeModal, onExport, t])
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import AccessControlDialog from '../access-control-dialog'
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import AccessControl from '../index'
|
||||
|
||||
let mockWebappAuth = {
|
||||
enabled: true,
|
||||
allow_sso: true,
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
}
|
||||
|
||||
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { webapp_auth: mockWebappAuth },
|
||||
})
|
||||
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
@@ -12,20 +25,6 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
}))
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
let mockWebappAuth = {
|
||||
enabled: true,
|
||||
allow_sso: true,
|
||||
allow_email_password_login: false,
|
||||
allow_email_code_login: false,
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: mockWebappAuth,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args),
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Description as DialogDescription, DialogTitle } from '@headlessui/react
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { useUpdateAccessMode } from '@/service/access-control'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
@@ -24,7 +25,7 @@ type AccessControlProps = {
|
||||
export default function AccessControl(props: AccessControlProps) {
|
||||
const { app, onClose, onConfirm } = props
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const setAppId = useAccessControlStore(s => s.setAppId)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AppPublisher from '../index'
|
||||
|
||||
const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { webapp_auth: { enabled: true } },
|
||||
})
|
||||
|
||||
const mockOnPublish = vi.fn()
|
||||
const mockOnToggle = vi.fn()
|
||||
const mockSetAppDetail = vi.fn()
|
||||
@@ -49,16 +54,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: () => 'moments ago',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
@@ -21,13 +22,13 @@ import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@@ -103,7 +104,7 @@ const AppPublisher = ({
|
||||
const workflowStore = useContext(WorkflowContext)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { App } from '@/models/explore'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
@@ -4,13 +4,14 @@ import { PlusIcon } from '@heroicons/react/20/solid'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiInformation2Line } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContextSelector } from 'use-context-selector'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
|
||||
|
||||
type AppCardProps = {
|
||||
@@ -26,7 +27,7 @@ const AppCard = ({
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { app: appBasicInfo } = app
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
|
||||
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
|
||||
const handleShowTryAppPanel = useCallback(() => {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AppCard from '../app-card'
|
||||
|
||||
const render = (ui: ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: { webapp_auth: { enabled: true } },
|
||||
})
|
||||
|
||||
const mockFetchAppDetailDirect = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
const mockSetAppDetail = vi.fn()
|
||||
@@ -36,16 +41,6 @@ vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
|
||||
appDetail: mockAppDetail as AppDetailResponse,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ConfigParams } from './settings'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -12,12 +13,12 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
@@ -73,7 +74,7 @@ function AppCard({
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { data: appAccessSubjects } = useAppWhiteListSubjects(
|
||||
appDetail?.id,
|
||||
systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import * as appsService from '@/service/apps'
|
||||
import * as exploreService from '@/service/explore'
|
||||
@@ -9,6 +10,15 @@ import * as workflowService from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from '../app-card'
|
||||
|
||||
let mockWebappAuthEnabled = false
|
||||
|
||||
const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: {
|
||||
webapp_auth: { enabled: mockWebappAuthEnabled },
|
||||
branding: { enabled: false },
|
||||
},
|
||||
})
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -65,16 +75,7 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock global public store - allow dynamic configuration
|
||||
let mockWebappAuthEnabled = false
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: { enabled: mockWebappAuthEnabled },
|
||||
branding: { enabled: false },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
// systemFeatures is seeded into the QueryClient via the local render helper.
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
deleteApp: vi.fn(() => Promise.resolve()),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user