diff --git a/api/pyproject.toml b/api/pyproject.toml
index ab1f523267..c05e884271 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
-version = "1.11.4"
+version = "1.12.0"
requires-python = ">=3.11,<3.13"
dependencies = [
diff --git a/api/uv.lock b/api/uv.lock
index f253976cc1..aefb8e91f0 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -1368,7 +1368,7 @@ wheels = [
[[package]]
name = "dify-api"
-version = "1.11.4"
+version = "1.12.0"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
diff --git a/dev/pytest/pytest_unit_tests.sh b/dev/pytest/pytest_unit_tests.sh
index 7c39a48bf4..a034083304 100755
--- a/dev/pytest/pytest_unit_tests.sh
+++ b/dev/pytest/pytest_unit_tests.sh
@@ -1,5 +1,5 @@
#!/bin/bash
-set -x
+set -euxo pipefail
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
cd "$SCRIPT_DIR/../.."
diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml
index eb8c2b53c5..e27b51bcc0 100644
--- a/docker/docker-compose-template.yaml
+++ b/docker/docker-compose-template.yaml
@@ -21,7 +21,7 @@ services:
# API service
api:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
- image: langgenius/dify-web:1.11.4
+ image: langgenius/dify-web:1.12.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index 02b8146aa9..a0a755f570 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -707,7 +707,7 @@ services:
# API service
api:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -749,7 +749,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -788,7 +788,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -818,7 +818,7 @@ services:
# Frontend web application.
web:
- image: langgenius/dify-web:1.11.4
+ image: langgenius/dify-web:1.12.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx
index 3410ecbe9a..dfbac5d743 100644
--- a/web/app/components/app-initializer.tsx
+++ b/web/app/components/app-initializer.tsx
@@ -3,7 +3,7 @@
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
-import { parseAsString, useQueryState } from 'nuqs'
+import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
@@ -28,7 +28,7 @@ export const AppInitializer = ({
const [init, setInit] = useState(false)
const [oauthNewUser, setOauthNewUser] = useQueryState(
'oauth_new_user',
- parseAsString.withOptions({ history: 'replace' }),
+ parseAsBoolean.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
@@ -46,7 +46,7 @@ export const AppInitializer = ({
(async () => {
const action = searchParams.get('action')
- if (oauthNewUser === 'true') {
+ if (oauthNewUser) {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx
index 15cfbd5411..e203edfc8c 100644
--- a/web/app/components/app/create-app-dialog/app-card/index.tsx
+++ b/web/app/components/app/create-app-dialog/app-card/index.tsx
@@ -62,19 +62,19 @@ const AppCard = ({
{app.description}
- {canCreate && (
+ {(canCreate || isTrialApp) && (
-
-
- {isTrialApp && (
-
)}
diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx
index 15152e0695..827c5c3a23 100644
--- a/web/app/components/explore/app-card/index.tsx
+++ b/web/app/components/explore/app-card/index.tsx
@@ -74,11 +74,15 @@ const AppCard = ({
{isExplore && (canCreate || isTrialApp) && (
-
-
onCreate()}>
-
- {t('appCard.addToWorkspace', { ns: 'explore' })}
-
+
+ {
+ canCreate && (
+
onCreate()}>
+
+ {t('appCard.addToWorkspace', { ns: 'explore' })}
+
+ )
+ }
{t('appCard.try', { ns: 'explore' })}
diff --git a/web/app/components/explore/try-app/index.spec.tsx b/web/app/components/explore/try-app/index.spec.tsx
index 3ae132b7ed..dc057b4d9f 100644
--- a/web/app/components/explore/try-app/index.spec.tsx
+++ b/web/app/components/explore/try-app/index.spec.tsx
@@ -16,6 +16,14 @@ vi.mock('react-i18next', () => ({
}),
}))
+vi.mock('@/config', async (importOriginal) => {
+ const actual = await importOriginal() as object
+ return {
+ ...actual,
+ IS_CLOUD_EDITION: true,
+ }
+})
+
const mockUseGetTryAppInfo = vi.fn()
vi.mock('@/service/use-try-app', () => ({
diff --git a/web/app/components/explore/try-app/tab.spec.tsx b/web/app/components/explore/try-app/tab.spec.tsx
index 81bb841887..af64a93f43 100644
--- a/web/app/components/explore/try-app/tab.spec.tsx
+++ b/web/app/components/explore/try-app/tab.spec.tsx
@@ -14,6 +14,14 @@ vi.mock('react-i18next', () => ({
}),
}))
+vi.mock('@/config', async (importOriginal) => {
+ const actual = await importOriginal() as object
+ return {
+ ...actual,
+ IS_CLOUD_EDITION: true,
+ }
+})
+
describe('Tab', () => {
afterEach(() => {
cleanup()
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
index 543d3deebc..9155fa15be 100644
--- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
@@ -1,5 +1,5 @@
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Import after mocks
@@ -821,6 +821,9 @@ describe('CommonCreateModal', () => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
+ // Flush pending state updates from createBuilder promise resolution
+ await act(async () => {})
+
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx
index b96d3dfb1f..f57bd80d7b 100644
--- a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx
+++ b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx
@@ -1,5 +1,5 @@
import type { PropsWithChildren } from 'react'
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DSLImportStatus } from '@/models/app'
import UpdateDSLModal from './update-dsl-modal'
@@ -140,13 +140,13 @@ class MockFileReader {
onload: ((e: { target: { result: string | null } }) => void) | null = null
readAsText(_file: File) {
- // Simulate async file reading
- setTimeout(() => {
+ // Simulate async file reading using queueMicrotask for more reliable async behavior
+ queueMicrotask(() => {
this.result = 'test file content'
if (this.onload) {
this.onload({ target: { result: this.result } })
}
- }, 0)
+ })
}
}
@@ -174,6 +174,7 @@ describe('UpdateDSLModal', () => {
status: DSLImportStatus.COMPLETED,
pipeline_id: 'test-pipeline-id',
})
+ mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
// Mock FileReader
originalFileReader = globalThis.FileReader
@@ -472,14 +473,14 @@ describe('UpdateDSLModal', () => {
await waitFor(() => {
const importButton = screen.getByText('common.overwriteAndImport')
expect(importButton).not.toBeDisabled()
- })
+ }, { timeout: 1000 })
const importButton = screen.getByText('common.overwriteAndImport')
fireEvent.click(importButton)
await waitFor(() => {
expect(mockOnImport).toHaveBeenCalled()
- })
+ }, { timeout: 1000 })
})
it('should show warning notification on import with warnings', async () => {
@@ -647,6 +648,8 @@ describe('UpdateDSLModal', () => {
})
it('should show error modal when import status is PENDING', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+
mockImportDSL.mockResolvedValue({
id: 'import-id',
status: DSLImportStatus.PENDING,
@@ -659,20 +662,29 @@ describe('UpdateDSLModal', () => {
const fileInput = screen.getByTestId('file-input')
const file = new File(['test content'], 'test.pipeline', { type: 'text/yaml' })
- fireEvent.change(fileInput, { target: { files: [file] } })
- await waitFor(() => {
- const importButton = screen.getByText('common.overwriteAndImport')
- expect(importButton).not.toBeDisabled()
+ await act(async () => {
+ fireEvent.change(fileInput, { target: { files: [file] } })
+ // Flush microtasks scheduled by the FileReader mock (which uses queueMicrotask)
+ await new Promise(resolve => queueMicrotask(resolve))
})
const importButton = screen.getByText('common.overwriteAndImport')
- fireEvent.click(importButton)
+ expect(importButton).not.toBeDisabled()
+
+ await act(async () => {
+ fireEvent.click(importButton)
+ // Flush the promise resolution from mockImportDSL
+ await Promise.resolve()
+ // Advance past the 300ms setTimeout in the component
+ await vi.advanceTimersByTimeAsync(350)
+ })
- // Wait for the error modal to be shown after setTimeout
await waitFor(() => {
expect(screen.getByText('newApp.appCreateDSLErrorTitle')).toBeInTheDocument()
- }, { timeout: 500 })
+ })
+
+ vi.useRealTimers()
})
it('should show version info in error modal', async () => {
diff --git a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts b/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts
index 0f235516e0..0d217f3605 100644
--- a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts
+++ b/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts
@@ -61,6 +61,12 @@ vi.mock('@/service/use-pipeline', () => ({
}),
}))
+// Mock download utility
+const mockDownloadBlob = vi.fn()
+vi.mock('@/utils/download', () => ({
+ downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
+}))
+
// Mock workflow service
const mockFetchWorkflowDraft = vi.fn()
vi.mock('@/service/workflow', () => ({
@@ -77,33 +83,9 @@ vi.mock('@/app/components/workflow/constants', () => ({
// ============================================================================
describe('useDSL', () => {
- let mockLink: { href: string, download: string, click: ReturnType }
- let originalCreateElement: typeof document.createElement
- let mockCreateObjectURL: ReturnType
- let mockRevokeObjectURL: ReturnType
-
beforeEach(() => {
vi.clearAllMocks()
- // Create a proper mock link element
- mockLink = {
- href: '',
- download: '',
- click: vi.fn(),
- }
-
- // Save original and mock selectively - only intercept 'a' elements
- originalCreateElement = document.createElement.bind(document)
- document.createElement = vi.fn((tagName: string) => {
- if (tagName === 'a') {
- return mockLink as unknown as HTMLElement
- }
- return originalCreateElement(tagName)
- }) as typeof document.createElement
-
- mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
- mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
-
// Default store state
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
@@ -118,9 +100,6 @@ describe('useDSL', () => {
})
afterEach(() => {
- document.createElement = originalCreateElement
- mockCreateObjectURL.mockRestore()
- mockRevokeObjectURL.mockRestore()
vi.clearAllMocks()
})
@@ -187,9 +166,7 @@ describe('useDSL', () => {
await result.current.handleExportDSL()
})
- expect(document.createElement).toHaveBeenCalledWith('a')
- expect(mockCreateObjectURL).toHaveBeenCalled()
- expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url')
+ expect(mockDownloadBlob).toHaveBeenCalled()
})
it('should use correct file extension for download', async () => {
@@ -199,17 +176,25 @@ describe('useDSL', () => {
await result.current.handleExportDSL()
})
- expect(mockLink.download).toBe('Test Knowledge Base.pipeline')
+ expect(mockDownloadBlob).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileName: 'Test Knowledge Base.pipeline',
+ }),
+ )
})
- it('should trigger download click', async () => {
+ it('should pass blob data to downloadBlob', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
- expect(mockLink.click).toHaveBeenCalled()
+ expect(mockDownloadBlob).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.any(Blob),
+ }),
+ )
})
it('should show error notification on export failure', async () => {
diff --git a/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx
index 848412f0ac..204772a3e2 100644
--- a/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx
+++ b/web/app/components/tools/edit-custom-collection-modal/index.spec.tsx
@@ -172,6 +172,9 @@ describe('EditCustomCollectionModal', () => {
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
})
+ // Flush pending state updates from parseParamsSchema promise resolution
+ await act(async () => {})
+
await act(async () => {
fireEvent.click(screen.getByText('common.operation.save'))
})
@@ -184,6 +187,10 @@ describe('EditCustomCollectionModal', () => {
credentials: {
auth_type: 'none',
},
+ icon: {
+ content: '🕵️',
+ background: '#FEF7C3',
+ },
labels: [],
}))
expect(toastNotifySpy).not.toHaveBeenCalled()
diff --git a/web/package.json b/web/package.json
index 83a4f98dee..954366fc89 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
- "version": "1.11.4",
+ "version": "1.12.0",
"private": true,
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"imports": {