fix: remove explore context and migrate query to orpc contract (#32320)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
yyh
2026-02-14 16:18:26 +08:00
committed by GitHub
parent db17119a96
commit 1f74a251f7
29 changed files with 787 additions and 827 deletions

View File

@ -9,8 +9,9 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
import type { App } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import AppList from '@/app/components/explore/app-list'
import ExploreContext from '@/context/explore-context'
import { useAppContext } from '@/context/app-context'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { AppModeEnum } from '@/types/app'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
@ -57,6 +58,14 @@ vi.mock('@/service/explore', () => ({
fetchAppList: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-import-dsl', () => ({
useImportDSL: () => ({
handleImportDSL: mockHandleImportDSL,
@ -126,26 +135,25 @@ const createApp = (overrides: Partial<App> = {}): App => ({
is_agent: overrides.is_agent ?? false,
})
const createContextValue = (hasEditPermission = true) => ({
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [] as never[],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
})
const mockMemberRole = (hasEditPermission: boolean) => {
;(useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
})
;(useMembers as Mock).mockReturnValue({
data: {
accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }],
},
})
}
const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => (
<ExploreContext.Provider value={createContextValue(hasEditPermission)}>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>
)
const renderAppList = (hasEditPermission = true, onSuccess?: () => void) => {
mockMemberRole(hasEditPermission)
return render(<AppList onSuccess={onSuccess} />)
}
const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => {
return render(wrapWithContext(hasEditPermission, onSuccess))
const appListElement = (hasEditPermission = true, onSuccess?: () => void) => {
mockMemberRole(hasEditPermission)
return <AppList onSuccess={onSuccess} />
}
describe('Explore App List Flow', () => {
@ -165,7 +173,7 @@ describe('Explore App List Flow', () => {
describe('Browse and Filter Flow', () => {
it('should display all apps when no category filter is applied', () => {
renderWithContext()
renderAppList()
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
expect(screen.getByText('Translator')).toBeInTheDocument()
@ -174,7 +182,7 @@ describe('Explore App List Flow', () => {
it('should filter apps by selected category', () => {
mockTabValue = 'Writing'
renderWithContext()
renderAppList()
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
expect(screen.queryByText('Translator')).not.toBeInTheDocument()
@ -182,7 +190,7 @@ describe('Explore App List Flow', () => {
})
it('should filter apps by search keyword', async () => {
renderWithContext()
renderAppList()
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'trans' } })
@ -207,7 +215,7 @@ describe('Explore App List Flow', () => {
options.onSuccess?.()
})
renderWithContext(true, onSuccess)
renderAppList(true, onSuccess)
// Step 2: Click add to workspace button - opens create modal
fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0])
@ -240,7 +248,7 @@ describe('Explore App List Flow', () => {
// Step 1: Loading state
mockIsLoading = true
mockExploreData = undefined
const { rerender } = render(wrapWithContext())
const { unmount } = render(appListElement())
expect(screen.getByRole('status')).toBeInTheDocument()
@ -250,7 +258,8 @@ describe('Explore App List Flow', () => {
categories: ['Writing'],
allList: [createApp()],
}
rerender(wrapWithContext())
unmount()
renderAppList()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('Alpha')).toBeInTheDocument()
@ -259,13 +268,13 @@ describe('Explore App List Flow', () => {
describe('Permission-Based Behavior', () => {
it('should hide add-to-workspace button when user has no edit permission', () => {
renderWithContext(false)
renderAppList(false)
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
})
it('should show add-to-workspace button when user has edit permission', () => {
renderWithContext(true)
renderAppList(true)
expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0)
})

View File

@ -8,20 +8,13 @@
import type { Mock } from 'vitest'
import type { InstalledApp as InstalledAppModel } from '@/models/explore'
import { render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import InstalledApp from '@/app/components/explore/installed-app'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
// Mock external dependencies
vi.mock('use-context-selector', () => ({
useContext: vi.fn(),
createContext: vi.fn(() => ({})),
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
@ -34,6 +27,7 @@ vi.mock('@/service/use-explore', () => ({
useGetInstalledAppAccessModeByAppId: vi.fn(),
useGetInstalledAppParams: vi.fn(),
useGetInstalledAppMeta: vi.fn(),
useGetInstalledApps: vi.fn(),
}))
vi.mock('@/app/components/share/text-generation', () => ({
@ -86,18 +80,21 @@ describe('Installed App Flow', () => {
}
type MockOverrides = {
context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean }
accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown }
params?: { isFetching?: boolean, data?: unknown, error?: unknown }
meta?: { isFetching?: boolean, data?: unknown, error?: unknown }
installedApps?: { apps?: InstalledAppModel[], isPending?: boolean, isFetching?: boolean }
accessMode?: { isPending?: boolean, data?: unknown, error?: unknown }
params?: { isPending?: boolean, data?: unknown, error?: unknown }
meta?: { isPending?: boolean, data?: unknown, error?: unknown }
userAccess?: { data?: unknown, error?: unknown }
}
const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => {
;(useContext as Mock).mockReturnValue({
installedApps: app ? [app] : [],
isFetchingInstalledApps: false,
...overrides.context,
const installedApps = overrides.installedApps?.apps ?? (app ? [app] : [])
;(useGetInstalledApps as Mock).mockReturnValue({
data: { installed_apps: installedApps },
isPending: false,
isFetching: false,
...overrides.installedApps,
})
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => {
@ -111,21 +108,21 @@ describe('Installed App Flow', () => {
})
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: { accessMode: AccessMode.PUBLIC },
error: null,
...overrides.accessMode,
})
;(useGetInstalledAppParams as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: mockAppParams,
error: null,
...overrides.params,
})
;(useGetInstalledAppMeta as Mock).mockReturnValue({
isFetching: false,
isPending: false,
data: { tool_icons: {} },
error: null,
...overrides.meta,
@ -182,7 +179,7 @@ describe('Installed App Flow', () => {
describe('Data Loading Flow', () => {
it('should show loading spinner when params are being fetched', () => {
const app = createInstalledApp()
setupDefaultMocks(app, { params: { isFetching: true, data: null } })
setupDefaultMocks(app, { params: { isPending: true, data: null } })
const { container } = render(<InstalledApp id="installed-app-1" />)
@ -190,6 +187,17 @@ describe('Installed App Flow', () => {
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
})
it('should defer 404 while installed apps are refetching without a match', () => {
setupDefaultMocks(undefined, {
installedApps: { apps: [], isPending: false, isFetching: true },
})
const { container } = render(<InstalledApp id="nonexistent" />)
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should render content when all data is available', () => {
const app = createInstalledApp()
setupDefaultMocks(app)

View File

@ -1,4 +1,3 @@
import type { IExplore } from '@/context/explore-context'
/**
* Integration test: Sidebar Lifecycle Flow
*
@ -10,14 +9,12 @@ import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import SideBar from '@/app/components/explore/sidebar'
import ExploreContext from '@/context/explore-context'
import { MediaType } from '@/hooks/use-breakpoints'
import { AppModeEnum } from '@/types/app'
let mockMediaType: string = MediaType.pc
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockRefetch = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockInstalledApps: InstalledApp[] = []
@ -40,9 +37,8 @@ vi.mock('@/hooks/use-breakpoints', () => ({
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isFetching: false,
isPending: false,
data: { installed_apps: mockInstalledApps },
refetch: mockRefetch,
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
@ -69,24 +65,8 @@ const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp
},
})
const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps,
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
})
const renderSidebar = (installedApps: InstalledApp[] = []) => {
return render(
<ExploreContext.Provider value={createContextValue(installedApps)}>
<SideBar controlUpdateInstalledApps={0} />
</ExploreContext.Provider>,
)
const renderSidebar = () => {
return render(<SideBar />)
}
describe('Sidebar Lifecycle Flow', () => {
@ -104,7 +84,7 @@ describe('Sidebar Lifecycle Flow', () => {
// Step 1: Start with an unpinned app and pin it
const unpinnedApp = createInstalledApp({ is_pinned: false })
mockInstalledApps = [unpinnedApp]
const { unmount } = renderSidebar(mockInstalledApps)
const { unmount } = renderSidebar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
@ -123,7 +103,7 @@ describe('Sidebar Lifecycle Flow', () => {
const pinnedApp = createInstalledApp({ is_pinned: true })
mockInstalledApps = [pinnedApp]
renderSidebar(mockInstalledApps)
renderSidebar()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
@ -141,7 +121,7 @@ describe('Sidebar Lifecycle Flow', () => {
mockInstalledApps = [app]
mockUninstall.mockResolvedValue(undefined)
renderSidebar(mockInstalledApps)
renderSidebar()
// Step 1: Open operation menu and click delete
fireEvent.click(screen.getByTestId('item-operation-trigger'))
@ -167,7 +147,7 @@ describe('Sidebar Lifecycle Flow', () => {
const app = createInstalledApp()
mockInstalledApps = [app]
renderSidebar(mockInstalledApps)
renderSidebar()
// Open delete flow
fireEvent.click(screen.getByTestId('item-operation-trigger'))
@ -188,7 +168,7 @@ describe('Sidebar Lifecycle Flow', () => {
createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }),
]
const { container } = renderSidebar(mockInstalledApps)
const { container } = renderSidebar()
// Both apps are rendered
const pinnedApp = screen.getByText('Pinned App')
@ -210,14 +190,14 @@ describe('Sidebar Lifecycle Flow', () => {
describe('Empty State', () => {
it('should show NoApps component when no apps are installed on desktop', () => {
mockMediaType = MediaType.pc
renderSidebar([])
renderSidebar()
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
})
it('should hide NoApps on mobile', () => {
mockMediaType = MediaType.mobile
renderSidebar([])
renderSidebar()
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
})