+}
+
+function getPartnerStackGlobal(): PartnerStackGlobal {
+ return globalThis as PartnerStackGlobal
+}
+
+const ensureCookieMocks = () => {
+ const globals = getPartnerStackGlobal()
+ if (!globals.__partnerStackCookieMocks)
+ throw new Error('Cookie mocks not initialized')
+ return globals.__partnerStackCookieMocks
+}
+
+const ensureMutateAsync = () => {
+ const globals = getPartnerStackGlobal()
+ if (!globals.__partnerStackMutateAsync)
+ throw new Error('Mutate mock not initialized')
+ return globals.__partnerStackMutateAsync
+}
+
+vi.mock('js-cookie', () => {
+ const get = vi.fn()
+ const set = vi.fn()
+ const remove = vi.fn()
+ const globals = getPartnerStackGlobal()
+ globals.__partnerStackCookieMocks = { get, set, remove }
+ const cookieApi = { get, set, remove }
+ return {
+ __esModule: true,
+ default: cookieApi,
+ get,
+ set,
+ remove,
+ }
+})
+vi.mock('next/navigation', () => ({
+ useSearchParams: () => ({
+ get: (key: string) => searchParamsValues[key] ?? null,
+ }),
+}))
+vi.mock('@/service/use-billing', () => {
+ const mutateAsync = vi.fn()
+ const globals = getPartnerStackGlobal()
+ globals.__partnerStackMutateAsync = mutateAsync
+ return {
+ useBindPartnerStackInfo: () => ({
+ mutateAsync,
+ }),
+ }
+})
+
+describe('usePSInfo', () => {
+ const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
+
+ beforeAll(() => {
+ Object.defineProperty(globalThis, 'location', {
+ value: { hostname: 'cloud.dify.ai' },
+ configurable: true,
+ })
+ })
+
+ beforeEach(() => {
+ setSearchParams({})
+ const { get, set, remove } = ensureCookieMocks()
+ get.mockReset()
+ set.mockReset()
+ remove.mockReset()
+ const mutate = ensureMutateAsync()
+ mutate.mockReset()
+ mutate.mockResolvedValue(undefined)
+ get.mockReturnValue('{}')
+ })
+
+ afterAll(() => {
+ if (originalLocationDescriptor)
+ Object.defineProperty(globalThis, 'location', originalLocationDescriptor)
+ })
+
+ it('saves partner info when query params change', () => {
+ const { get, set } = ensureCookieMocks()
+ get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' }))
+ setSearchParams({
+ ps_partner_key: 'new-partner',
+ ps_xid: 'new-click',
+ })
+
+ const { result } = renderHook(() => usePSInfo())
+
+ expect(result.current.psPartnerKey).toBe('new-partner')
+ expect(result.current.psClickId).toBe('new-click')
+
+ act(() => {
+ result.current.saveOrUpdate()
+ })
+
+ expect(set).toHaveBeenCalledWith(
+ PARTNER_STACK_CONFIG.cookieName,
+ JSON.stringify({
+ partnerKey: 'new-partner',
+ clickId: 'new-click',
+ }),
+ {
+ expires: PARTNER_STACK_CONFIG.saveCookieDays,
+ path: '/',
+ domain: '.dify.ai',
+ },
+ )
+ })
+
+ it('does not overwrite cookie when params do not change', () => {
+ setSearchParams({
+ ps_partner_key: 'existing',
+ ps_xid: 'existing-click',
+ })
+ const { get } = ensureCookieMocks()
+ get.mockReturnValue(JSON.stringify({
+ partnerKey: 'existing',
+ clickId: 'existing-click',
+ }))
+
+ const { result } = renderHook(() => usePSInfo())
+
+ act(() => {
+ result.current.saveOrUpdate()
+ })
+
+ const { set } = ensureCookieMocks()
+ expect(set).not.toHaveBeenCalled()
+ })
+
+ it('binds partner info and clears cookie once', async () => {
+ setSearchParams({
+ ps_partner_key: 'bind-partner',
+ ps_xid: 'bind-click',
+ })
+
+ const { result } = renderHook(() => usePSInfo())
+
+ const mutate = ensureMutateAsync()
+ const { remove } = ensureCookieMocks()
+ await act(async () => {
+ await result.current.bind()
+ })
+
+ expect(mutate).toHaveBeenCalledWith({
+ partnerKey: 'bind-partner',
+ clickId: 'bind-click',
+ })
+ expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, {
+ path: '/',
+ domain: '.dify.ai',
+ })
+
+ await act(async () => {
+ await result.current.bind()
+ })
+
+ expect(mutate).toHaveBeenCalledTimes(1)
+ })
+
+ it('still removes cookie when bind fails with status 400', async () => {
+ const mutate = ensureMutateAsync()
+ mutate.mockRejectedValueOnce({ status: 400 })
+ setSearchParams({
+ ps_partner_key: 'bind-partner',
+ ps_xid: 'bind-click',
+ })
+
+ const { result } = renderHook(() => usePSInfo())
+
+ await act(async () => {
+ await result.current.bind()
+ })
+
+ const { remove } = ensureCookieMocks()
+ expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, {
+ path: '/',
+ domain: '.dify.ai',
+ })
+ })
+})
diff --git a/web/app/components/billing/plan/index.spec.tsx b/web/app/components/billing/plan/index.spec.tsx
new file mode 100644
index 0000000000..bcdb83b5df
--- /dev/null
+++ b/web/app/components/billing/plan/index.spec.tsx
@@ -0,0 +1,130 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
+import { Plan } from '../type'
+import PlanComp from './index'
+
+let currentPath = '/billing'
+
+const push = vi.fn()
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push }),
+ usePathname: () => currentPath,
+}))
+
+const setShowAccountSettingModalMock = vi.fn()
+vi.mock('@/context/modal-context', () => ({
+ // eslint-disable-next-line ts/no-explicit-any
+ useModalContextSelector: (selector: any) => selector({
+ setShowAccountSettingModal: setShowAccountSettingModalMock,
+ }),
+}))
+
+const providerContextMock = vi.fn()
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => providerContextMock(),
+}))
+
+vi.mock('@/context/app-context', () => ({
+ useAppContext: () => ({
+ userProfile: { email: 'user@example.com' },
+ isCurrentWorkspaceManager: true,
+ }),
+}))
+
+const mutateAsyncMock = vi.fn()
+let isPending = false
+vi.mock('@/service/use-education', () => ({
+ useEducationVerify: () => ({
+ mutateAsync: mutateAsyncMock,
+ isPending,
+ }),
+}))
+
+const verifyStateModalMock = vi.fn(props => (
+
+ {props.isShow ? 'visible' : 'hidden'}
+
+))
+vi.mock('@/app/education-apply/verify-state-modal', () => ({
+ __esModule: true,
+ // eslint-disable-next-line ts/no-explicit-any
+ default: (props: any) => verifyStateModalMock(props),
+}))
+
+vi.mock('../upgrade-btn', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+describe('PlanComp', () => {
+ const planMock = {
+ type: Plan.professional,
+ usage: {
+ teamMembers: 4,
+ documentsUploadQuota: 3,
+ vectorSpace: 8,
+ annotatedResponse: 5,
+ triggerEvents: 60,
+ apiRateLimit: 100,
+ },
+ total: {
+ teamMembers: 10,
+ documentsUploadQuota: 20,
+ vectorSpace: 10,
+ annotatedResponse: 500,
+ triggerEvents: 100,
+ apiRateLimit: 200,
+ },
+ reset: {
+ triggerEvents: 2,
+ apiRateLimit: 1,
+ },
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ currentPath = '/billing'
+ isPending = false
+ providerContextMock.mockReturnValue({
+ plan: planMock,
+ enableEducationPlan: true,
+ allowRefreshEducationVerify: false,
+ isEducationAccount: false,
+ })
+ mutateAsyncMock.mockReset()
+ mutateAsyncMock.mockResolvedValue({ token: 'token' })
+ })
+
+ it('renders plan info and handles education verify success', async () => {
+ render()
+
+ expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument()
+ expect(screen.getByTestId('plan-upgrade-btn')).toBeInTheDocument()
+
+ const verifyBtn = screen.getByText('education.toVerified')
+ fireEvent.click(verifyBtn)
+
+ await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
+ await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token'))
+ expect(localStorage.removeItem).toHaveBeenCalledWith(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
+ })
+
+ it('shows modal when education verify fails', async () => {
+ mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
+ render()
+
+ const verifyBtn = screen.getByText('education.toVerified')
+ fireEvent.click(verifyBtn)
+
+ await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
+ await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true'))
+ })
+
+ it('resets modal context when on education apply path', () => {
+ currentPath = '/education-apply/setup'
+ render()
+
+ expect(setShowAccountSettingModalMock).toHaveBeenCalledWith(null)
+ })
+})
diff --git a/web/app/components/billing/progress-bar/index.spec.tsx b/web/app/components/billing/progress-bar/index.spec.tsx
new file mode 100644
index 0000000000..a9c91468de
--- /dev/null
+++ b/web/app/components/billing/progress-bar/index.spec.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@testing-library/react'
+import ProgressBar from './index'
+
+describe('ProgressBar', () => {
+ it('renders with provided percent and color', () => {
+ render()
+
+ const bar = screen.getByTestId('billing-progress-bar')
+ expect(bar).toHaveClass('bg-test-color')
+ expect(bar.getAttribute('style')).toContain('width: 42%')
+ })
+
+ it('caps width at 100% when percent exceeds max', () => {
+ render()
+
+ const bar = screen.getByTestId('billing-progress-bar')
+ expect(bar.getAttribute('style')).toContain('width: 100%')
+ })
+
+ it('uses the default color when no color prop is provided', () => {
+ render()
+
+ expect(screen.getByTestId('billing-progress-bar')).toHaveClass('#2970FF')
+ })
+})
diff --git a/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx b/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx
new file mode 100644
index 0000000000..a3d04c6031
--- /dev/null
+++ b/web/app/components/billing/trigger-events-limit-modal/index.spec.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from '@testing-library/react'
+import TriggerEventsLimitModal from './index'
+
+const mockOnClose = vi.fn()
+const mockOnUpgrade = vi.fn()
+
+const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => (
+
+ {props.extraInfo}
+
+))
+
+vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
+ __esModule: true,
+ // eslint-disable-next-line ts/no-explicit-any
+ default: (props: any) => planUpgradeModalMock(props),
+}))
+
+describe('TriggerEventsLimitModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('passes the trigger usage props to the upgrade modal', () => {
+ render(
+ ,
+ )
+
+ const modal = screen.getByTestId('plan-upgrade-modal')
+ expect(modal.getAttribute('data-show')).toBe('true')
+ expect(modal.getAttribute('data-title')).toContain('billing.triggerLimitModal.title')
+ expect(modal.getAttribute('data-description')).toContain('billing.triggerLimitModal.description')
+ expect(planUpgradeModalMock).toHaveBeenCalled()
+
+ const passedProps = planUpgradeModalMock.mock.calls[0][0]
+ expect(passedProps.onClose).toBe(mockOnClose)
+ expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
+
+ expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument()
+ expect(screen.getByText('12')).toBeInTheDocument()
+ expect(screen.getByText('20')).toBeInTheDocument()
+ })
+
+ it('renders even when trigger modal is hidden', () => {
+ render(
+ ,
+ )
+
+ expect(planUpgradeModalMock).toHaveBeenCalled()
+ expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
+ })
+})
diff --git a/web/app/components/billing/usage-info/apps-info.spec.tsx b/web/app/components/billing/usage-info/apps-info.spec.tsx
new file mode 100644
index 0000000000..7289b474e5
--- /dev/null
+++ b/web/app/components/billing/usage-info/apps-info.spec.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '@testing-library/react'
+import { defaultPlan } from '../config'
+import AppsInfo from './apps-info'
+
+const appsUsage = 7
+const appsTotal = 15
+
+const mockPlan = {
+ ...defaultPlan,
+ usage: {
+ ...defaultPlan.usage,
+ buildApps: appsUsage,
+ },
+ total: {
+ ...defaultPlan.total,
+ buildApps: appsTotal,
+ },
+}
+
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ plan: mockPlan,
+ }),
+}))
+
+describe('AppsInfo', () => {
+ it('renders build apps usage information with context data', () => {
+ render()
+
+ expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
+ expect(screen.getByText(`${appsUsage}`)).toBeInTheDocument()
+ expect(screen.getByText(`${appsTotal}`)).toBeInTheDocument()
+ expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/billing/usage-info/index.spec.tsx b/web/app/components/billing/usage-info/index.spec.tsx
new file mode 100644
index 0000000000..3137c4865f
--- /dev/null
+++ b/web/app/components/billing/usage-info/index.spec.tsx
@@ -0,0 +1,114 @@
+import { render, screen } from '@testing-library/react'
+import { NUM_INFINITE } from '../config'
+import UsageInfo from './index'
+
+const TestIcon = () =>
+
+describe('UsageInfo', () => {
+ it('renders the metric with a suffix unit and tooltip text', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('usage-icon')).toBeInTheDocument()
+ expect(screen.getByText('Apps')).toBeInTheDocument()
+ expect(screen.getByText('30')).toBeInTheDocument()
+ expect(screen.getByText('100')).toBeInTheDocument()
+ expect(screen.getByText('GB')).toBeInTheDocument()
+ })
+
+ it('renders inline unit when unitPosition is inline', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('100GB')).toBeInTheDocument()
+ })
+
+ it('shows reset hint text instead of the unit when resetHint is provided', () => {
+ const resetHint = 'Resets in 3 days'
+ render(
+ ,
+ )
+
+ expect(screen.getByText(resetHint)).toBeInTheDocument()
+ expect(screen.queryByText('GB')).not.toBeInTheDocument()
+ })
+
+ it('displays unlimited text when total is infinite', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
+ })
+
+ it('applies warning color when usage is close to the limit', () => {
+ render(
+ ,
+ )
+
+ const progressBar = screen.getByTestId('billing-progress-bar')
+ expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
+ })
+
+ it('applies error color when usage exceeds the limit', () => {
+ render(
+ ,
+ )
+
+ const progressBar = screen.getByTestId('billing-progress-bar')
+ expect(progressBar).toHaveClass('bg-components-progress-error-progress')
+ })
+
+ it('does not render the icon when hideIcon is true', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/billing/vector-space-full/index.spec.tsx b/web/app/components/billing/vector-space-full/index.spec.tsx
new file mode 100644
index 0000000000..de5607df41
--- /dev/null
+++ b/web/app/components/billing/vector-space-full/index.spec.tsx
@@ -0,0 +1,58 @@
+import { render, screen } from '@testing-library/react'
+import VectorSpaceFull from './index'
+
+type VectorProviderGlobal = typeof globalThis & {
+ __vectorProviderContext?: ReturnType
+}
+
+function getVectorGlobal(): VectorProviderGlobal {
+ return globalThis as VectorProviderGlobal
+}
+
+vi.mock('@/context/provider-context', () => {
+ const mock = vi.fn()
+ getVectorGlobal().__vectorProviderContext = mock
+ return {
+ useProviderContext: () => mock(),
+ }
+})
+
+vi.mock('../upgrade-btn', () => ({
+ __esModule: true,
+ default: () => ,
+}))
+
+describe('VectorSpaceFull', () => {
+ const planMock = {
+ type: 'team',
+ usage: {
+ vectorSpace: 8,
+ },
+ total: {
+ vectorSpace: 10,
+ },
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ const globals = getVectorGlobal()
+ globals.__vectorProviderContext?.mockReturnValue({
+ plan: planMock,
+ })
+ })
+
+ it('renders tip text and upgrade button', () => {
+ render()
+
+ expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument()
+ expect(screen.getByText('billing.vectorSpace.fullSolution')).toBeInTheDocument()
+ expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument()
+ })
+
+ it('shows vector usage and total', () => {
+ render()
+
+ expect(screen.getByText('8')).toBeInTheDocument()
+ expect(screen.getByText('10MB')).toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx
index 2b2b1ee543..b31c283550 100644
--- a/web/app/components/workflow/index.tsx
+++ b/web/app/components/workflow/index.tsx
@@ -195,9 +195,11 @@ export const Workflow: FC = memo(({
const { nodesReadOnly } = useNodesReadOnly()
const { eventEmitter } = useEventEmitterContextContext()
+ const store = useStoreApi()
eventEmitter?.useSubscription((v: any) => {
if (v.type === WORKFLOW_DATA_UPDATE) {
setNodes(v.payload.nodes)
+ store.getState().setNodes(v.payload.nodes)
setEdges(v.payload.edges)
if (v.payload.viewport)
@@ -359,7 +361,6 @@ export const Workflow: FC = memo(({
}
}, [schemaTypeDefinitions, fetchInspectVars, isLoadedVars, vars, customTools, buildInTools, workflowTools, mcpTools, dataSourceList])
- const store = useStoreApi()
if (process.env.NODE_ENV === 'development') {
store.getState().onError = (code, message) => {
if (code === '002')