mirror of
https://github.com/langgenius/dify.git
synced 2026-05-20 16:57:01 +08:00
Compare commits
3 Commits
dependabot
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 848c15a265 | |||
| be8627233d | |||
| 1fe8b7fb1d |
2
.github/workflows/web-tests.yml
vendored
2
.github/workflows/web-tests.yml
vendored
@ -39,7 +39,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Run tests
|
||||
run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||
|
||||
- name: Upload blob report
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
@ -31,7 +31,7 @@ dependencies = [
|
||||
"flask-migrate>=4.1.0,<5.0.0",
|
||||
"flask-orjson>=2.0.0,<3.0.0",
|
||||
"flask-restx>=1.3.2,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.153.1,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.151.0,<2.0.0",
|
||||
"httpx[socks]==0.28.1",
|
||||
"opentelemetry-distro==0.62b1",
|
||||
"opentelemetry-instrumentation-celery==0.62b1",
|
||||
|
||||
8
api/uv.lock
generated
8
api/uv.lock
generated
@ -1628,7 +1628,7 @@ requires-dist = [
|
||||
{ name = "gevent-websocket", specifier = "==0.10.1" },
|
||||
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
|
||||
{ name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = ">=1.153.1,<2.0.0" },
|
||||
{ name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" },
|
||||
{ name = "graphon", specifier = "==0.4.0" },
|
||||
{ name = "gunicorn", specifier = ">=26.0.0,<27.0.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
|
||||
@ -2813,7 +2813,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-aiplatform"
|
||||
version = "1.153.1"
|
||||
version = "1.151.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docstring-parser" },
|
||||
@ -2829,9 +2829,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/97/1779e66ab845550bc602364311ea093ba156cb805a1c31b7c4d6f25b5863/google_cloud_aiplatform-1.153.1.tar.gz", hash = "sha256:445b6c683d5c630f174d81ae1f69f7da9e27e4d4ec5b70c5fe96de5c1247cfbc", size = 11011349, upload-time = "2026-05-15T06:34:14.851Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/f6/e2fbe175a011f5080da8c1f7d9169a6875a00ea2c7bee4193d952b097400/google_cloud_aiplatform-1.151.0.tar.gz", hash = "sha256:2f29b1853f790a7371a746c747bf1f664380b534254682441acd4b5ee26fafd2", size = 10617421, upload-time = "2026-05-07T21:56:52.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/8a1900e7a742ed480e6037ac4f6541466cb981d81bd4cbd34a9d46204ea1/google_cloud_aiplatform-1.153.1-py2.py3-none-any.whl", hash = "sha256:033fa1595a7e8ed1d97066e261e630f38fbc60e10c98c6487cf228fe9c7ec151", size = 9170782, upload-time = "2026-05-15T06:34:10.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/4a/cd35f8ba622d563b1335222284d2838aa789b953b40516b1b997e50fe5b6/google_cloud_aiplatform-1.151.0-py2.py3-none-any.whl", hash = "sha256:61372bb0923b14b8027f45b83393452df3a85bf4ea86fa48e08844fb5ec50049", size = 8732627, upload-time = "2026-05-07T21:56:49.014Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -17,6 +17,20 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isCloudEdition: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockApp: App = {
|
||||
can_trial: true,
|
||||
app: {
|
||||
@ -70,6 +84,7 @@ describe('AppCard', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.isCloudEdition = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@ -261,6 +276,13 @@ describe('AppCard', () => {
|
||||
app: mockApp,
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide try button outside cloud edition', () => {
|
||||
mockConfig.isCloudEdition = false
|
||||
renderWithProvider(<AppCard {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /explore\.appCard\.try/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard Accessibility', () => {
|
||||
|
||||
@ -4,14 +4,13 @@ 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 { IS_CLOUD_EDITION } from '@/config'
|
||||
import AppListContext from '@/context/app-list-context'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
|
||||
|
||||
type AppCardProps = {
|
||||
@ -27,8 +26,7 @@ const AppCard = ({
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { app: appBasicInfo } = app
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
|
||||
const canViewApp = IS_CLOUD_EDITION
|
||||
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
|
||||
const handleShowTryAppPanel = useCallback(() => {
|
||||
trackEvent('preview_template', {
|
||||
@ -69,19 +67,21 @@ const AppCard = ({
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
{(canCreate || isTrialApp) && (
|
||||
{(canCreate || canViewApp) && (
|
||||
<div className={cn('absolute right-0 bottom-0 left-0 hidden bg-linear-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', canCreate && 'grid-cols-2')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', canCreate && canViewApp && 'grid-cols-2')}>
|
||||
{canCreate && (
|
||||
<Button variant="primary" onClick={() => onCreate()}>
|
||||
<PlusIcon className="mr-1 size-4" />
|
||||
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleShowTryAppPanel}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
{canViewApp && (
|
||||
<Button onClick={handleShowTryAppPanel}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -15,6 +15,20 @@ vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isCloudEdition: true,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const createApp = (overrides?: Partial<App>): App => ({
|
||||
can_trial: true,
|
||||
app_id: 'app-id',
|
||||
@ -62,6 +76,7 @@ describe('AppCard', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig.isCloudEdition = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@ -113,6 +128,13 @@ describe('AppCard', () => {
|
||||
|
||||
expect(screen.getByText('explore.appCard.try')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide try button outside cloud edition', () => {
|
||||
mockConfig.isCloudEdition = false
|
||||
renderComponent({ canCreate: true, isExplore: true })
|
||||
|
||||
expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
|
||||
@ -5,11 +5,10 @@ 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 { useTranslation } from 'react-i18next'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppTypeIcon } from '../../app/type-selector'
|
||||
|
||||
@ -30,8 +29,7 @@ const AppCard = ({
|
||||
}: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { app: appBasicInfo } = app
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
|
||||
const canViewApp = IS_CLOUD_EDITION
|
||||
const handleTryApp = () => {
|
||||
trackEvent('preview_template', {
|
||||
template_id: app.app_id,
|
||||
@ -78,9 +76,9 @@ const AppCard = ({
|
||||
{app.description}
|
||||
</div>
|
||||
</div>
|
||||
{isExplore && (canCreate || isTrialApp) && (
|
||||
{isExplore && (canCreate || canViewApp) && (
|
||||
<div className={cn('absolute right-0 bottom-0 left-0 hidden bg-linear-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 space-x-2', canCreate && 'grid-cols-2')}>
|
||||
<div className={cn('grid h-8 w-full grid-cols-1 space-x-2', canCreate && canViewApp && 'grid-cols-2')}>
|
||||
{
|
||||
canCreate && (
|
||||
<Button variant="primary" className="h-7" onClick={() => onCreate()}>
|
||||
@ -89,10 +87,12 @@ const AppCard = ({
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<Button className="h-7" onClick={handleTryApp}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
{canViewApp && (
|
||||
<Button className="h-7" onClick={handleTryApp}>
|
||||
<RiInformation2Line className="mr-1 size-4" />
|
||||
<span>{t('appCard.try', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -51,6 +51,20 @@ vi.mock('@/utils/create-app-tracking', () => ({
|
||||
trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args),
|
||||
}))
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isCloudEdition: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
default: (props: CreateAppModalProps) => {
|
||||
if (!props.show)
|
||||
@ -137,6 +151,7 @@ const mockMemberRole = (hasEditPermission: boolean) => {
|
||||
|
||||
type RenderOptions = {
|
||||
enableExploreBanner?: boolean
|
||||
isCloudEdition?: boolean
|
||||
}
|
||||
|
||||
const renderAppList = (
|
||||
@ -145,6 +160,7 @@ const renderAppList = (
|
||||
searchParams?: Record<string, string>,
|
||||
options: RenderOptions = {},
|
||||
) => {
|
||||
mockConfig.isCloudEdition = options.isCloudEdition ?? false
|
||||
mockMemberRole(hasEditPermission)
|
||||
const { wrapper: SystemFeaturesWrapper, queryClient } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { enable_explore_banner: options.enableExploreBanner ?? false },
|
||||
@ -166,6 +182,7 @@ describe('AppList', () => {
|
||||
mockExploreData = { categories: [], allList: [] }
|
||||
mockIsLoading = false
|
||||
mockIsError = false
|
||||
mockConfig.isCloudEdition = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -400,7 +417,7 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
}
|
||||
|
||||
renderAppList(true)
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
|
||||
@ -423,7 +440,7 @@ describe('AppList', () => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true)
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
fireEvent.click(screen.getByTestId('try-app-create'))
|
||||
@ -444,7 +461,7 @@ describe('AppList', () => {
|
||||
allList: [createApp()],
|
||||
}
|
||||
|
||||
renderAppList(true)
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
|
||||
fireEvent.click(screen.getByText('explore.appCard.try'))
|
||||
expect(screen.getByTestId('try-app-panel')).toBeInTheDocument()
|
||||
|
||||
86
web/app/forgot-password/ChangePasswordForm.spec.tsx
Normal file
86
web/app/forgot-password/ChangePasswordForm.spec.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { changePasswordWithToken } from '@/service/common'
|
||||
import { useVerifyForgotPasswordToken } from '@/service/use-common'
|
||||
import ChangePasswordForm from './ChangePasswordForm'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => new URLSearchParams('token=url-token-t1'),
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useVerifyForgotPasswordToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
changePasswordWithToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({ basePath: '' }))
|
||||
|
||||
type UseVerifyResult = ReturnType<typeof useVerifyForgotPasswordToken>
|
||||
const mockUseVerify = vi.mocked(useVerifyForgotPasswordToken)
|
||||
const mockChangePassword = vi.mocked(changePasswordWithToken)
|
||||
|
||||
const VALID_PASSWORD = 'ValidPass123!'
|
||||
|
||||
describe('ChangePasswordForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('when token is valid', () => {
|
||||
const T2 = 'verified-token-t2'
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseVerify.mockReturnValue({
|
||||
data: { result: 'success', is_valid: true, email: 'user@example.com', token: T2 },
|
||||
refetch: vi.fn(),
|
||||
} as unknown as UseVerifyResult)
|
||||
})
|
||||
|
||||
it('renders the password form', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
expect(screen.getByText('login.changePassword')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits with T2 (from validity response), NOT T1 (from URL)', async () => {
|
||||
mockChangePassword.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<ChangePasswordForm />)
|
||||
|
||||
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="password"]')) as [HTMLInputElement, HTMLInputElement]
|
||||
fireEvent.change(inputs[0], { target: { value: VALID_PASSWORD } })
|
||||
fireEvent.change(inputs[1], { target: { value: VALID_PASSWORD } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.reset/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangePassword).toHaveBeenCalledWith({
|
||||
url: '/forgot-password/resets',
|
||||
body: {
|
||||
token: T2,
|
||||
new_password: VALID_PASSWORD,
|
||||
password_confirm: VALID_PASSWORD,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when token is invalid', () => {
|
||||
beforeEach(() => {
|
||||
mockUseVerify.mockReturnValue({
|
||||
data: { result: 'success', is_valid: false, email: '', token: '' },
|
||||
refetch: vi.fn(),
|
||||
} as unknown as UseVerifyResult)
|
||||
})
|
||||
|
||||
it('shows invalid token state and no form', () => {
|
||||
render(<ChangePasswordForm />)
|
||||
expect(screen.getByText('login.invalid')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.reset/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -49,7 +49,7 @@ const ChangePasswordForm = () => {
|
||||
}, [password, confirmPassword, showErrorMessage, t])
|
||||
|
||||
const handleChangePassword = useCallback(async () => {
|
||||
const token = searchParams.get('token') || ''
|
||||
const resetToken = verifyTokenRes?.token ?? ''
|
||||
|
||||
if (!valid())
|
||||
return
|
||||
@ -57,7 +57,7 @@ const ChangePasswordForm = () => {
|
||||
await changePasswordWithToken({
|
||||
url: '/forgot-password/resets',
|
||||
body: {
|
||||
token,
|
||||
token: resetToken,
|
||||
new_password: password,
|
||||
password_confirm: confirmPassword,
|
||||
},
|
||||
@ -67,7 +67,7 @@ const ChangePasswordForm = () => {
|
||||
catch {
|
||||
await revalidateToken()
|
||||
}
|
||||
}, [confirmPassword, password, revalidateToken, searchParams, valid])
|
||||
}, [confirmPassword, password, revalidateToken, verifyTokenRes?.token, valid])
|
||||
|
||||
return (
|
||||
<div className={
|
||||
|
||||
@ -250,7 +250,7 @@ export const useLogout = () => {
|
||||
})
|
||||
}
|
||||
|
||||
type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string }
|
||||
type ForgotPasswordValidity = CommonResponse & { is_valid: boolean, email: string, token: string }
|
||||
export const useVerifyForgotPasswordToken = (token?: string | null) => {
|
||||
return useQuery<ForgotPasswordValidity>({
|
||||
queryKey: commonQueryKeys.forgotPasswordValidity(token),
|
||||
|
||||
Reference in New Issue
Block a user