diff --git a/src/renderer/src/assets/styles/ant.css b/src/renderer/src/assets/styles/ant.css index 3bfae251861..d027487008e 100644 --- a/src/renderer/src/assets/styles/ant.css +++ b/src/renderer/src/assets/styles/ant.css @@ -10,8 +10,6 @@ -webkit-app-region: no-drag; } -/* miniapp-drawer 有自己的拖拽规则 */ - /* 下拉菜单和弹出框内容不应该可拖拽 */ .ant-dropdown, .ant-dropdown-menu, @@ -52,17 +50,8 @@ gap: 4px; } -/* miniapp-drawer styles now handled by vaul + Tailwind in MiniAppPopupContainer */ -[navbar-position='left'] .miniapp-drawer { - max-width: calc(100vw - var(--sidebar-width)); -} - -[navbar-position='top'] .miniapp-drawer { - max-width: 100vw; -} - .ant-drawer-header { - /* 普通 drawer header 不应该可拖拽,除非被 miniapp-drawer 覆盖 */ + /* drawer header 不应该可拖拽 */ -webkit-app-region: no-drag; } diff --git a/src/renderer/src/components/MiniApp/MiniApp.tsx b/src/renderer/src/components/MiniApp/MiniApp.tsx index 9af00a6fde7..849956a3c5e 100644 --- a/src/renderer/src/components/MiniApp/MiniApp.tsx +++ b/src/renderer/src/components/MiniApp/MiniApp.tsx @@ -1,3 +1,10 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@cherrystudio/ui' import { loggerService } from '@logger' import MiniAppIcon from '@renderer/components/Icons/MiniAppIcon' import IndicatorLight from '@renderer/components/IndicatorLight' @@ -7,11 +14,8 @@ import { useNavbarPosition } from '@renderer/hooks/useNavbar' import { useTabs } from '@renderer/hooks/useTabs' import { ErrorCode, isDataApiError, toDataApiError } from '@shared/data/api' import type { MiniApp } from '@shared/data/types/miniApp' -import type { MenuProps } from 'antd' -import { Dropdown } from 'antd' import type { FC } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' interface Props { app: MiniApp @@ -62,131 +66,91 @@ const MiniApp: FC = ({ app, onClick, size = 60, isLast }) => { } } - const menuItems: MenuProps['items'] = [ - { - key: 'togglePin', - label: isPinned - ? isTopNavbar - ? t('miniApp.remove_from_launchpad') - : t('miniApp.remove_from_sidebar') - : isTopNavbar - ? t('miniApp.add_to_launchpad') - : t('miniApp.add_to_sidebar'), - onClick: () => { - // Toggle pin: enabled ↔ pinned. Custom apps that were technically - // 'disabled' (shouldn't normally end up in the grid) fall back to - // 'enabled' on unpin, matching the previous diff behavior. - const nextStatus = isPinned ? 'enabled' : 'pinned' - updateAppStatus(app.appId, nextStatus).catch( - reportFailure(isPinned ? 'miniApp.unpin_failed' : 'miniApp.pin_failed') - ) + const handleTogglePin = () => { + const nextStatus = isPinned ? 'enabled' : 'pinned' + updateAppStatus(app.appId, nextStatus).catch( + reportFailure(isPinned ? 'miniApp.unpin_failed' : 'miniApp.pin_failed') + ) + } + + const handleHide = () => { + updateAppStatus(app.appId, 'disabled') + .then(() => { + setOpenedKeepAliveMiniApps(openedKeepAliveMiniApps.filter((item) => item.appId !== app.appId)) + }) + .catch(reportFailure('miniApp.hide_failed')) + } + + const handleRemoveCustom = async () => { + try { + await removeCustomMiniApp(app.appId) + window.toast.success(t('settings.miniApps.custom.remove_success')) + } catch (error) { + if (isDataApiError(error)) { + if (error.code === ErrorCode.NOT_FOUND) { + window.toast.warning(t('miniApp.error.not_found')) + } else if (!error.isRetryable) { + window.toast.error(t('settings.miniApps.custom.remove_error')) + } else { + window.toast.error(t('settings.miniApps.custom.remove_error')) + } + } else { + window.toast.error(t('settings.miniApps.custom.remove_error')) } - }, - ...(!isPinned - ? [ - { - key: 'hide', - label: t('miniApp.sidebar.hide.title'), - onClick: () => { - // Wait for the status flip to land before evicting from the - // keep-alive pool — otherwise a failed PATCH leaves the user - // with a still-disabled tab in the strip and no UI feedback. - updateAppStatus(app.appId, 'disabled') - .then(() => { - setOpenedKeepAliveMiniApps(openedKeepAliveMiniApps.filter((item) => item.appId !== app.appId)) - }) - .catch(reportFailure('miniApp.hide_failed')) - } - } - ] - : []), - ...(app.presetMiniAppId == null - ? [ - { - key: 'removeCustom', - label: t('miniApp.sidebar.remove_custom.title'), - danger: true, - onClick: async () => { - try { - await removeCustomMiniApp(app.appId) - window.toast.success(t('settings.miniApps.custom.remove_success')) - } catch (error) { - if (isDataApiError(error)) { - if (error.code === ErrorCode.NOT_FOUND) { - window.toast.warning(t('miniApp.error.not_found')) - } else if (!error.isRetryable) { - window.toast.error(t('settings.miniApps.custom.remove_error')) - } else { - window.toast.error(t('settings.miniApps.custom.remove_error')) - } - } else { - window.toast.error(t('settings.miniApps.custom.remove_error')) - } - logger.error('Failed to remove custom mini app:', error as Error) - } - } - } - ] - : []) - ] + logger.error('Failed to remove custom mini app:', error as Error) + } + } if (!shouldShow) { return null } return ( - - - - - {isOpened && ( - - - - )} - - - {displayName} - - - + + +
+
+ + {isOpened && ( +
+ +
+ )} +
+
+ {displayName} +
+
+
+ + + {isPinned + ? isTopNavbar + ? t('miniApp.remove_from_launchpad') + : t('miniApp.remove_from_sidebar') + : isTopNavbar + ? t('miniApp.add_to_launchpad') + : t('miniApp.add_to_sidebar')} + + {!isPinned && ( + <> + + {t('miniApp.sidebar.hide.title')} + + )} + {app.presetMiniAppId == null && ( + <> + + + {t('miniApp.sidebar.remove_custom.title')} + + + )} + +
) } -const Container = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - cursor: pointer; - overflow: hidden; - min-height: 85px; -` - -const IconContainer = styled.div` - position: relative; - display: flex; - justify-content: center; - align-items: center; -` - -const StyledIndicator = styled.div` - position: absolute; - bottom: -2px; - right: -2px; - padding: 2px; - background: var(--color-background); - border-radius: 50%; -` - -const AppTitle = styled.div` - font-size: 12px; - margin-top: 5px; - color: var(--color-text-soft); - text-align: center; - user-select: none; - width: 100%; - max-width: 80px; -` - export default MiniApp diff --git a/src/renderer/src/components/MiniApp/__tests__/MiniAppCell.test.tsx b/src/renderer/src/components/MiniApp/__tests__/MiniAppCell.test.tsx new file mode 100644 index 00000000000..2be2d0cbf82 --- /dev/null +++ b/src/renderer/src/components/MiniApp/__tests__/MiniAppCell.test.tsx @@ -0,0 +1,369 @@ +import { DataApiError, ErrorCode } from '@shared/data/api' +import type { MiniApp } from '@shared/data/types/miniApp' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cherrystudio/ui', () => ({ + ContextMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + ContextMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ContextMenuItem: ({ + children, + onSelect, + variant + }: { + children: React.ReactNode + onSelect?: () => void + variant?: string + }) => ( + + ), + ContextMenuSeparator: () =>
, + ContextMenuTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ) +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + error: vi.fn() + }) + } +})) + +vi.mock('@renderer/components/Icons/MiniAppIcon', () => ({ + default: ({ app, size }: { app: MiniApp; size: number }) => ( +
+ {app.logo} +
+ ) +})) + +vi.mock('@renderer/components/IndicatorLight', () => ({ + default: ({ color, size, animation }: { color: string; size: number; animation?: boolean }) => ( +
+ ) +})) + +vi.mock('@renderer/components/MarqueeText', () => ({ + default: ({ children }: { children: React.ReactNode }) => {children} +})) + +const mocks = vi.hoisted(() => ({ + miniApps: [] as MiniApp[], + pinned: [] as MiniApp[], + openedKeepAliveMiniApps: [] as MiniApp[], + currentMiniAppId: null as string | null, + miniAppShow: false, + updateAppStatus: vi.fn().mockResolvedValue(undefined), + setOpenedKeepAliveMiniApps: vi.fn(), + removeCustomMiniApp: vi.fn().mockResolvedValue(undefined), + openTab: vi.fn() +})) + +vi.mock('@renderer/hooks/useMiniApps', () => ({ + useMiniApps: () => ({ + miniApps: mocks.miniApps, + pinned: mocks.pinned, + openedKeepAliveMiniApps: mocks.openedKeepAliveMiniApps, + currentMiniAppId: mocks.currentMiniAppId, + miniAppShow: mocks.miniAppShow, + setOpenedKeepAliveMiniApps: mocks.setOpenedKeepAliveMiniApps, + updateAppStatus: mocks.updateAppStatus, + removeCustomMiniApp: mocks.removeCustomMiniApp + }) +})) + +vi.mock('@renderer/hooks/useTabs', () => ({ + useTabs: () => ({ + openTab: mocks.openTab + }) +})) + +vi.mock('@renderer/hooks/useNavbar', () => ({ + useNavbarPosition: () => ({ + isTopNavbar: true + }) +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +const mockToast = { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn() +} +Object.assign(window, { toast: mockToast }) + +import MiniAppCell from '../MiniApp' + +const createMockApp = (appId: string, overrides?: Partial): MiniApp => ({ + appId, + name: appId, + nameKey: undefined, + url: `https://${appId}.example.com`, + presetMiniAppId: appId, + status: 'enabled', + orderKey: 'a0', + logo: 'default-logo', + ...overrides +}) + +describe('MiniAppCell', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.miniApps = [] + mocks.pinned = [] + mocks.openedKeepAliveMiniApps = [] + mocks.currentMiniAppId = null + mocks.miniAppShow = false + mocks.updateAppStatus.mockResolvedValue(undefined) + mocks.removeCustomMiniApp.mockResolvedValue(undefined) + }) + + it('renders app with correct name and icon', () => { + const app = createMockApp('test-app', { name: 'Test Application' }) + mocks.miniApps = [app] + + render() + + expect(screen.getByTestId('mini-app-icon')).toHaveTextContent('default-logo') + expect(screen.getByTestId('marquee-text')).toHaveTextContent('Test Application') + }) + + it('renders with nameKey translation when available', () => { + const app = createMockApp('test-app', { nameKey: 'custom.name.key' }) + mocks.miniApps = [app] + + render() + + expect(screen.getByTestId('marquee-text')).toHaveTextContent('custom.name.key') + }) + + it('renders isLast with translated title', () => { + const app = createMockApp('custom-app') + mocks.miniApps = [app] + + render() + + expect(screen.getByTestId('marquee-text')).toHaveTextContent('settings.miniApps.custom.title') + }) + + it('calls onClick and opens tab when clicked', () => { + const app = createMockApp('test-app', { name: 'Test App' }) + mocks.miniApps = [app] + const onClick = vi.fn() + + render() + + fireEvent.click(screen.getByTestId('context-menu-trigger').firstChild!) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(mocks.openTab).toHaveBeenCalledWith('/app/mini-app/test-app', { + title: 'Test App', + icon: 'default-logo' + }) + }) + + it('shows indicator light when app is opened', () => { + const app = createMockApp('test-app') + mocks.miniApps = [app] + mocks.openedKeepAliveMiniApps = [app] + + render() + + expect(screen.getByTestId('indicator-light')).toBeInTheDocument() + expect(screen.getByTestId('indicator-light')).toHaveAttribute('data-color', '#22c55e') + }) + + it('does not show indicator light when app is not opened', () => { + const app = createMockApp('test-app') + mocks.miniApps = [app] + mocks.openedKeepAliveMiniApps = [] + + render() + + expect(screen.queryByTestId('indicator-light')).not.toBeInTheDocument() + }) + + it('returns null when app should not be shown', () => { + const app = createMockApp('test-app') + mocks.miniApps = [] + mocks.pinned = [] + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('shows when app is in miniApps list', () => { + const app = createMockApp('test-app') + mocks.miniApps = [app] + + render() + + expect(screen.getByTestId('context-menu')).toBeInTheDocument() + }) + + it('shows when app is pinned even if not in miniApps', () => { + const app = createMockApp('test-app') + mocks.miniApps = [] + mocks.pinned = [app] + + render() + + expect(screen.getByTestId('context-menu')).toBeInTheDocument() + }) + + it('toggles pin status when pin menu item is clicked', async () => { + const app = createMockApp('test-app') + mocks.miniApps = [app] + mocks.pinned = [] + + render() + + const pinButton = screen.getAllByTestId('context-menu-item')[0] + expect(pinButton).toHaveTextContent('miniApp.add_to_launchpad') + + await userEvent.click(pinButton) + + expect(mocks.updateAppStatus).toHaveBeenCalledWith('test-app', 'pinned') + }) + + it('unpins when app is already pinned', async () => { + const app = createMockApp('test-app') + mocks.miniApps = [app] + mocks.pinned = [app] + + render() + + const unpinButton = screen.getAllByTestId('context-menu-item')[0] + expect(unpinButton).toHaveTextContent('miniApp.remove_from_launchpad') + + await userEvent.click(unpinButton) + + expect(mocks.updateAppStatus).toHaveBeenCalledWith('test-app', 'enabled') + }) + + it('shows hide option for non-pinned apps', () => { + const app = createMockApp('test-app') + mocks.miniApps = [app] + mocks.pinned = [] + + render() + + const menuItems = screen.getAllByTestId('context-menu-item') + expect(menuItems.some((item) => item.textContent === 'miniApp.sidebar.hide.title')).toBe(true) + }) + + it('hides hide option for pinned apps', () => { + const app = createMockApp('test-app') + mocks.miniApps = [] + mocks.pinned = [app] + + render() + + const menuItems = screen.getAllByTestId('context-menu-item') + expect(menuItems.some((item) => item.textContent === 'miniApp.sidebar.hide.title')).toBe(false) + }) + + it('hides app when hide is clicked', async () => { + const app = createMockApp('test-app') + const otherApp = createMockApp('other-app') + mocks.miniApps = [app, otherApp] + mocks.openedKeepAliveMiniApps = [app, otherApp] + mocks.pinned = [] + + render() + + const hideButton = screen + .getAllByTestId('context-menu-item') + .find((item) => item.textContent === 'miniApp.sidebar.hide.title') + await userEvent.click(hideButton!) + + expect(mocks.updateAppStatus).toHaveBeenCalledWith('test-app', 'disabled') + await waitFor(() => { + expect(mocks.setOpenedKeepAliveMiniApps).toHaveBeenCalled() + }) + }) + + it('shows remove option for custom apps (no presetMiniAppId)', () => { + const app = createMockApp('custom-app', { presetMiniAppId: undefined }) + mocks.miniApps = [app] + + render() + + const menuItems = screen.getAllByTestId('context-menu-item') + expect(menuItems.some((item) => item.textContent === 'miniApp.sidebar.remove_custom.title')).toBe(true) + }) + + it('does not show remove option for preset apps', () => { + const app = createMockApp('preset-app', { presetMiniAppId: 'preset-id' }) + mocks.miniApps = [app] + + render() + + const menuItems = screen.getAllByTestId('context-menu-item') + expect(menuItems.some((item) => item.textContent === 'miniApp.sidebar.remove_custom.title')).toBe(false) + }) + + it('removes custom app when remove is clicked', async () => { + const app = createMockApp('custom-app', { presetMiniAppId: undefined }) + mocks.miniApps = [app] + + render() + + const removeButton = screen + .getAllByTestId('context-menu-item') + .find((item) => item.textContent === 'miniApp.sidebar.remove_custom.title') + await userEvent.click(removeButton!) + + expect(mocks.removeCustomMiniApp).toHaveBeenCalledWith('custom-app') + await waitFor(() => { + expect(mockToast.success).toHaveBeenCalledWith('settings.miniApps.custom.remove_success') + }) + }) + + it('handles updateAppStatus error', async () => { + mocks.updateAppStatus.mockRejectedValueOnce(new Error('Update failed')) + + const app = createMockApp('test-app') + mocks.miniApps = [app] + mocks.pinned = [] + + render() + + const pinButton = screen.getAllByTestId('context-menu-item')[0] + await userEvent.click(pinButton) + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalled() + }) + }) + + it('handles removeCustomMiniApp NOT_FOUND error', async () => { + const mockError = new DataApiError(ErrorCode.NOT_FOUND, 'Not found', 404) + mocks.removeCustomMiniApp.mockRejectedValueOnce(mockError) + + const app = createMockApp('custom-app', { presetMiniAppId: undefined }) + mocks.miniApps = [app] + + render() + + const removeButton = screen + .getAllByTestId('context-menu-item') + .find((item) => item.textContent === 'miniApp.sidebar.remove_custom.title') + await userEvent.click(removeButton!) + + await waitFor(() => { + expect(mockToast.warning).toHaveBeenCalledWith('miniApp.error.not_found') + }) + }) +}) diff --git a/src/renderer/src/pages/mini-apps/MiniAppPage.tsx b/src/renderer/src/pages/mini-apps/MiniAppPage.tsx index c06a84628bd..ac31a740933 100644 --- a/src/renderer/src/pages/mini-apps/MiniAppPage.tsx +++ b/src/renderer/src/pages/mini-apps/MiniAppPage.tsx @@ -11,7 +11,6 @@ import type { FC } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import BeatLoader from 'react-spinners/BeatLoader' -import styled from 'styled-components' // Tab mode page shell — relies on the global MiniAppTabsPool instead of creating WebViews directly import MinimalToolbar from './components/MinimalToolbar' @@ -122,11 +121,11 @@ const MiniAppPage: FC = () => { // While loading, show a loading indicator instead of returning null if (isLoading) { return ( - - - - - +
+
+ +
+
) } @@ -134,11 +133,13 @@ const MiniAppPage: FC = () => { if (error) { const isNotFound = error instanceof DataApiError && error.code === ErrorCode.NOT_FOUND return ( - - - {t(isNotFound ? 'miniApp.error.not_found' : 'miniApp.error.load_failed')} - - +
+
+ + {t(isNotFound ? 'miniApp.error.not_found' : 'miniApp.error.load_failed')} + +
+
) } @@ -146,11 +147,11 @@ const MiniAppPage: FC = () => { // instead of redirecting away, so the user sees what happened. if (!app) { return ( - - - {t('miniApp.error.not_found')} - - +
+
+ {t('miniApp.error.not_found')} +
+
) } @@ -169,8 +170,8 @@ const MiniAppPage: FC = () => { } return ( - - +
+
{ onReload={handleReload} onOpenDevTools={handleOpenDevTools} /> - +
{!isReady && ( - +
- - + +
)} - +
) } -const ShellContainer = styled.div` - position: relative; - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - z-index: 3; /* Above the webviews in the pool */ - pointer-events: none; /* Let lower webviews be interactive by default */ - > * { - pointer-events: auto; - } -` - -const ToolbarWrapper = styled.div` - flex-shrink: 0; -` - -const LoadingMask = styled.div` - position: absolute; - inset: 35px 0 0 0; /* Avoid toolbar height */ - display: flex; - flex-direction: column; /* Vertical stacking */ - align-items: center; - justify-content: center; - background: var(--color-background); - z-index: 4; - gap: 12px; -` - -const ErrorText = styled.div` - color: var(--color-text-2); - font-size: 14px; -` export default MiniAppPage diff --git a/src/renderer/src/pages/mini-apps/MiniAppSettings/MiniAppDisplaySettings.tsx b/src/renderer/src/pages/mini-apps/MiniAppSettings/MiniAppDisplaySettings.tsx index c0b91cc2f52..4a7611a2fff 100644 --- a/src/renderer/src/pages/mini-apps/MiniAppSettings/MiniAppDisplaySettings.tsx +++ b/src/renderer/src/pages/mini-apps/MiniAppSettings/MiniAppDisplaySettings.tsx @@ -1,8 +1,7 @@ -import { Switch, Tooltip } from '@cherrystudio/ui' +import { Slider, Switch, Tooltip } from '@cherrystudio/ui' import { usePreference } from '@data/hooks/usePreference' import Selector from '@renderer/components/Selector' import type { MiniAppRegionFilter } from '@shared/data/types/miniApp' -import { Slider } from 'antd' import { Undo2 } from 'lucide-react' import type { FC } from 'react' import { useCallback, useEffect, useRef } from 'react' @@ -19,7 +18,7 @@ const MiniAppDisplaySettings: FC = () => { const [maxKeepAlive, setMaxKeepAlive] = usePreference('feature.mini_app.max_keep_alive') const [openLinkExternal, setOpenLinkExternal] = usePreference('feature.mini_app.open_link_external') - const debounceTimerRef = useRef(null) + const debounceTimerRef = useRef | null>(null) useEffect( () => () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) @@ -70,11 +69,13 @@ const MiniAppDisplaySettings: FC = () => { `${value}` }} + value={[maxKeepAlive]} + onValueChange={([v]) => handleCacheChange(v)} + showValueLabel + formatValueLabel={(value) => `${value}`} /> {maxKeepAlive}
diff --git a/src/renderer/src/pages/mini-apps/__tests__/MiniAppPage.test.tsx b/src/renderer/src/pages/mini-apps/__tests__/MiniAppPage.test.tsx new file mode 100644 index 00000000000..3188523ce54 --- /dev/null +++ b/src/renderer/src/pages/mini-apps/__tests__/MiniAppPage.test.tsx @@ -0,0 +1,223 @@ +import type { MiniApp } from '@shared/data/types/miniApp' +import { MockUseCacheUtils } from '@test-mocks/renderer/useCache' +import { MockUseDataApiUtils } from '@test-mocks/renderer/useDataApi' +import { MockUsePreferenceUtils } from '@test-mocks/renderer/usePreference' +import { render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@renderer/utils/webviewStateManager', () => ({ + getWebviewLoaded: vi.fn(() => false), + setWebviewLoaded: vi.fn(), + onWebviewStateChange: vi.fn(() => vi.fn()) +})) + +vi.mock('../components/MinimalToolbar', () => ({ + default: ({ app }: { app: MiniApp }) =>
{app.name}
+})) + +vi.mock('../components/WebviewSearch', () => ({ + default: () =>
Search
+})) + +const mockOpenMiniAppKeepAlive = vi.fn() +vi.mock('@renderer/hooks/useMiniAppPopup', () => ({ + useMiniAppPopup: () => ({ + openMiniAppKeepAlive: mockOpenMiniAppKeepAlive + }) +})) + +vi.mock('@renderer/hooks/useMiniApps', () => ({ + useMiniApps: () => mockUseMiniAppsReturn +})) + +const mockUseParams = vi.fn(() => ({ appId: 'test-app' as string | undefined })) +vi.mock('@tanstack/react-router', () => ({ + useParams: () => mockUseParams() +})) + +vi.mock('@renderer/components/Icons', () => ({ + LogoAvatar: ({ logo }: { logo: string }) =>
{logo}
+})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +vi.mock('react-spinners', () => ({ + BeatLoader: (props: { style?: React.CSSProperties }) => ( + + Loading... + + ) +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + error: vi.fn(), + debug: vi.fn() + }) + } +})) + +import { getWebviewLoaded } from '@renderer/utils/webviewStateManager' + +const mockGetWebviewLoaded = vi.mocked(getWebviewLoaded) + +import MiniAppPage from '../MiniAppPage' + +const mockUseMiniAppsReturn = { + allApps: [] as MiniApp[], + miniApps: [] as MiniApp[], + disabled: [] as MiniApp[], + pinned: [] as MiniApp[], + openedKeepAliveMiniApps: [] as MiniApp[], + isLoading: false, + error: null as Error | null, + currentMiniAppId: null as string | null, + miniAppShow: false, + refetch: vi.fn() +} + +const createMockApp = (appId: string, overrides?: Partial): MiniApp => ({ + appId, + name: appId, + url: `https://${appId}.example.com`, + presetMiniAppId: appId, + status: 'enabled', + orderKey: 'a0', + ...overrides +}) + +describe('MiniAppPage', () => { + beforeEach(() => { + MockUseCacheUtils.resetMocks() + MockUsePreferenceUtils.resetMocks() + MockUseDataApiUtils.resetMocks() + vi.clearAllMocks() + mockUseParams.mockReturnValue({ appId: 'test-app' }) + mockGetWebviewLoaded.mockReturnValue(false) + Object.assign(window, { + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() } + }) + }) + + it('renders loading state when isLoading is true', () => { + mockUseMiniAppsReturn.isLoading = true + mockUseMiniAppsReturn.allApps = [] + + render() + expect( + document.querySelector('[style*="react-spinners"], [class*="beat"], span[style*="inline-block"]') + ).toBeInTheDocument() + }) + + it('renders error state when there is an error', () => { + mockUseMiniAppsReturn.isLoading = false + mockUseMiniAppsReturn.error = new Error('Failed to load') + mockUseMiniAppsReturn.allApps = [] + + render() + + expect(screen.getByText('miniApp.error.load_failed')).toBeInTheDocument() + }) + + it('renders not found state when app is not found', () => { + mockUseMiniAppsReturn.isLoading = false + mockUseMiniAppsReturn.error = null + mockUseMiniAppsReturn.allApps = [] + mockUseMiniAppsReturn.openedKeepAliveMiniApps = [] + + render() + + expect(screen.getByText('miniApp.error.not_found')).toBeInTheDocument() + }) + + it('renders app page when app is found', async () => { + const mockApp = createMockApp('test-app', { name: 'Test App', logo: 'test-logo' }) + mockUseMiniAppsReturn.isLoading = false + mockUseMiniAppsReturn.error = null + mockUseMiniAppsReturn.allApps = [mockApp] + mockUseMiniAppsReturn.openedKeepAliveMiniApps = [] + + render() + + await waitFor(() => { + expect(screen.getByTestId('minimal-toolbar')).toHaveTextContent('Test App') + }) + expect(screen.getByTestId('webview-search')).toBeInTheDocument() + }) + + it('calls openMiniAppKeepAlive when app is found and not loading', async () => { + const mockApp = createMockApp('test-app') + mockUseMiniAppsReturn.isLoading = false + mockUseMiniAppsReturn.error = null + mockUseMiniAppsReturn.allApps = [mockApp] + mockUseMiniAppsReturn.openedKeepAliveMiniApps = [] + mockOpenMiniAppKeepAlive.mockClear() + + render() + + await waitFor(() => { + expect(mockOpenMiniAppKeepAlive).toHaveBeenCalledWith(mockApp) + }) + }) + + it('finds app from openedKeepAliveMiniApps when not in allApps', async () => { + const keepAliveApp = createMockApp('keep-alive-app', { name: 'Keep Alive App' }) + mockUseMiniAppsReturn.isLoading = false + mockUseMiniAppsReturn.error = null + mockUseMiniAppsReturn.allApps = [] + mockUseMiniAppsReturn.openedKeepAliveMiniApps = [keepAliveApp] + mockUseParams.mockReturnValue({ appId: 'keep-alive-app' }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('minimal-toolbar')).toHaveTextContent('Keep Alive App') + }) + }) + + it('shows loading mask when webview is not ready', async () => { + const mockApp = createMockApp('test-app', { logo: 'test-logo' }) + mockUseMiniAppsReturn.isLoading = false + mockUseMiniAppsReturn.error = null + mockUseMiniAppsReturn.allApps = [mockApp] + mockGetWebviewLoaded.mockReturnValue(false) + + render() + + await waitFor(() => { + expect(screen.getByTestId('logo-avatar')).toBeInTheDocument() + }) + + expect(document.querySelector('[style*="react-spinners"]')).toBeInTheDocument() + }) + + it('does not show loading mask when webview is already loaded', async () => { + const mockApp = createMockApp('test-app', { logo: 'test-logo' }) + mockUseMiniAppsReturn.isLoading = false + mockUseMiniAppsReturn.error = null + mockUseMiniAppsReturn.allApps = [mockApp] + mockGetWebviewLoaded.mockReturnValue(true) + + render() + + await waitFor(() => { + expect(screen.getByTestId('minimal-toolbar')).toBeInTheDocument() + }) + expect(screen.queryByTestId('logo-avatar')).not.toBeInTheDocument() + }) + + it('handles missing appId in URL params', () => { + mockUseParams.mockReturnValue({ appId: undefined }) + mockUseMiniAppsReturn.isLoading = false + mockUseMiniAppsReturn.error = null + mockUseMiniAppsReturn.allApps = [] + mockUseMiniAppsReturn.openedKeepAliveMiniApps = [] + + render() + + expect(screen.getByText('miniApp.error.not_found')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/pages/mini-apps/components/MiniAppFullPageView.tsx b/src/renderer/src/pages/mini-apps/components/MiniAppFullPageView.tsx index d4516358e05..80a7d2f38be 100644 --- a/src/renderer/src/pages/mini-apps/components/MiniAppFullPageView.tsx +++ b/src/renderer/src/pages/mini-apps/components/MiniAppFullPageView.tsx @@ -8,7 +8,6 @@ import type { WebviewTag } from 'electron' import type { FC } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import BeatLoader from 'react-spinners/BeatLoader' -import styled from 'styled-components' import MinimalToolbar from './MinimalToolbar' @@ -97,7 +96,7 @@ const MiniAppFullPageView: FC = ({ app }) => { }, []) return ( - +
= ({ app }) => { onOpenDevTools={handleOpenDevTools} /> - +
{!isReady && ( - - +
+
- - +
+
)} = ({ app }) => { onLoadedCallback={handleWebviewLoaded} onNavigateCallback={handleWebviewNavigate} /> - - +
+
) } -const Container = styled.div` - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; -` - -const WebviewArea = styled.div` - flex: 1; - position: relative; - overflow: hidden; - background-color: var(--color-background); - min-height: 0; /* Ensure flex child can shrink */ -` - -const LoadingMask = styled.div` - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--color-background); - z-index: 100; - display: flex; - align-items: center; - justify-content: center; -` - -const LoadingOverlay = styled.div` - display: flex; - flex-direction: column; - align-items: center; - pointer-events: none; -` - export default MiniAppFullPageView diff --git a/src/renderer/src/pages/mini-apps/components/MinimalToolbar.tsx b/src/renderer/src/pages/mini-apps/components/MinimalToolbar.tsx index 6778ef46bc6..79a2890bfaa 100644 --- a/src/renderer/src/pages/mini-apps/components/MinimalToolbar.tsx +++ b/src/renderer/src/pages/mini-apps/components/MinimalToolbar.tsx @@ -1,13 +1,5 @@ -import { - ArrowLeftOutlined, - ArrowRightOutlined, - CodeOutlined, - ExportOutlined, - LinkOutlined, - PushpinOutlined, - ReloadOutlined -} from '@ant-design/icons' -import { Tooltip } from '@cherrystudio/ui' +import { Button, Tooltip } from '@cherrystudio/ui' +import { cn } from '@cherrystudio/ui/lib/utils' import { usePreference } from '@data/hooks/usePreference' import { loggerService } from '@logger' import { isDev } from '@renderer/config/constant' @@ -15,10 +7,10 @@ import { useMiniApps } from '@renderer/hooks/useMiniApps' import { isDataApiError, toDataApiError } from '@shared/data/api' import type { MiniApp } from '@shared/data/types/miniApp' import type { WebviewTag } from 'electron' +import { ArrowLeft, ArrowRight, Code2, ExternalLink, Link, Pin, RotateCw } from 'lucide-react' import type { FC } from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' const logger = loggerService.withContext('MinimalToolbar') @@ -233,44 +225,66 @@ const MinimalToolbar: FC = ({ app, webviewRef, currentUrl, onReload, onOp }, [currentUrl, app.url]) return ( - - - +
+
+
- - - + className={cn( + 'text-xs transition-all duration-200', + !canGoBack && 'cursor-default text-foreground-muted', + canGoBack && 'text-foreground-secondary hover:bg-accent hover:text-foreground active:scale-95' + )}> + + - - - + className={cn( + 'text-xs transition-all duration-200', + !canGoForward && 'cursor-default text-foreground-muted', + canGoForward && 'text-foreground-secondary hover:bg-accent hover:text-foreground active:scale-95' + )}> + + - - - + - - +
+
- - +
+
{canOpenExternalLink && ( - - - + )} @@ -278,13 +292,20 @@ const MinimalToolbar: FC = ({ app, webviewRef, currentUrl, onReload, onOp - - - + aria-pressed={isPinned} + className={cn( + 'text-xs transition-all duration-200 active:scale-95', + isPinned + ? 'text-primary hover:text-primary' + : 'text-foreground-secondary hover:bg-accent hover:text-foreground' + )}> + + )} @@ -293,84 +314,40 @@ const MinimalToolbar: FC = ({ app, webviewRef, currentUrl, onReload, onOp openLinkExternal ? t('miniApp.popup.open_link_external_on') : t('miniApp.popup.open_link_external_off') } placement="bottom"> - - - + aria-pressed={openLinkExternal} + className={cn( + 'text-xs transition-all duration-200 active:scale-95', + openLinkExternal + ? 'text-primary hover:text-primary' + : 'text-foreground-secondary hover:bg-accent hover:text-foreground' + )}> + + {isDev && ( - - - + )} - - - +
+
+
) } -const ToolbarContainer = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - height: 35px; - padding: 0 12px; - background-color: var(--color-background); - flex-shrink: 0; -` - -const LeftSection = styled.div` - display: flex; - align-items: center; - gap: 8px; -` - -const RightSection = styled.div` - display: flex; - align-items: center; -` - -const ButtonGroup = styled.div` - display: flex; - align-items: center; - gap: 2px; -` - -const ToolbarButton = styled.button<{ - $disabled?: boolean - $active?: boolean -}>` - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: 4px; - background: transparent; - color: ${({ $disabled, $active }) => - $disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-2)'}; - cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')}; - transition: all 0.2s ease; - font-size: 12px; - - &:hover { - background: ${({ $disabled }) => ($disabled ? 'transparent' : 'var(--color-background-soft)')}; - color: ${({ $disabled, $active }) => - $disabled ? 'var(--color-text-3)' : $active ? 'var(--color-primary)' : 'var(--color-text-1)'}; - } - - &:active { - transform: ${({ $disabled }) => ($disabled ? 'none' : 'scale(0.95)')}; - } -` - export default MinimalToolbar diff --git a/src/renderer/src/pages/mini-apps/components/WebviewSearch.tsx b/src/renderer/src/pages/mini-apps/components/WebviewSearch.tsx index d484e3181ad..57c1395f106 100644 --- a/src/renderer/src/pages/mini-apps/components/WebviewSearch.tsx +++ b/src/renderer/src/pages/mini-apps/components/WebviewSearch.tsx @@ -1,6 +1,5 @@ +import { Button, Input } from '@cherrystudio/ui' import { loggerService } from '@logger' -import type { InputRef } from 'antd' -import { Button, Input } from 'antd' import type { WebviewTag } from 'electron' import { ChevronDown, ChevronUp, X } from 'lucide-react' import type { FC } from 'react' @@ -23,7 +22,7 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app const [query, setQuery] = useState('') const [matchCount, setMatchCount] = useState(0) const [activeIndex, setActiveIndex] = useState(0) - const inputRef = useRef(null) + const inputRef = useRef(null) const focusFrameRef = useRef(null) const lastAppIdRef = useRef(appId) const attachedWebviewRef = useRef(null) @@ -319,13 +318,10 @@ const WebviewSearch: FC = ({ webviewRef, isWebviewReady, app onChange={(e) => setQuery(e.target.value)} spellCheck={false} placeholder={t('common.search')} - size="small" - variant="borderless" - className="w-[240px]" - style={{ height: '32px' }} + className="h-8 w-60 border-none bg-transparent text-sm shadow-none placeholder:text-foreground-muted focus-visible:ring-0" /> = ({ webviewRef, isWebviewReady, app
) } diff --git a/src/renderer/src/pages/mini-apps/components/__tests__/MinimalToolbar.test.tsx b/src/renderer/src/pages/mini-apps/components/__tests__/MinimalToolbar.test.tsx new file mode 100644 index 00000000000..e6eff571793 --- /dev/null +++ b/src/renderer/src/pages/mini-apps/components/__tests__/MinimalToolbar.test.tsx @@ -0,0 +1,586 @@ +import type { MiniApp } from '@shared/data/types/miniApp' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { WebviewTag } from 'electron' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cherrystudio/ui', () => ({ + Button: ({ + children, + onClick, + disabled, + 'aria-label': ariaLabel, + 'aria-pressed': ariaPressed, + className + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + 'aria-label'?: string + 'aria-pressed'?: boolean + className?: string + }) => ( + + ), + Tooltip: ({ children, content }: { children: React.ReactNode; content: string }) => ( +
+ {children} +
+ ) +})) + +vi.mock('@cherrystudio/ui/lib/utils', () => ({ + cn: (...classes: (string | undefined | false)[]) => classes.filter(Boolean).join(' ') +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn() + }) + } +})) + +vi.mock('@renderer/config/constant', () => ({ + isDev: true +})) + +const mocks = vi.hoisted(() => ({ + allApps: [] as MiniApp[], + pinned: [] as MiniApp[], + openLinkExternal: false, + updateAppStatus: vi.fn().mockResolvedValue(undefined), + setOpenLinkExternal: vi.fn() +})) + +vi.mock('@renderer/hooks/useMiniApps', () => ({ + useMiniApps: () => ({ + allApps: mocks.allApps, + pinned: mocks.pinned, + updateAppStatus: mocks.updateAppStatus + }) +})) + +vi.mock('@data/hooks/usePreference', () => ({ + usePreference: () => [mocks.openLinkExternal, mocks.setOpenLinkExternal] +})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})) + +const mockOpenWebsite = vi.fn().mockResolvedValue(undefined) +Object.defineProperty(window, 'api', { + configurable: true, + value: { + openWebsite: mockOpenWebsite + } +}) + +const mockToast = { + error: vi.fn() +} +Object.defineProperty(window, 'toast', { + configurable: true, + value: mockToast +}) + +import MinimalToolbar from '../MinimalToolbar' + +const createMockApp = (appId: string, overrides?: Partial): MiniApp => ({ + appId, + name: appId, + url: `https://${appId}.example.com`, + presetMiniAppId: appId, + status: 'enabled', + orderKey: 'a0', + ...overrides +}) + +const createMockWebview = (overrides?: Partial): WebviewTag => { + const listeners = new Map>() + + const webview = { + canGoBack: vi.fn(() => true), + canGoForward: vi.fn(() => true), + goBack: vi.fn(), + goForward: vi.fn(), + reload: vi.fn(), + openDevTools: vi.fn(), + src: 'https://test.example.com', + addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + if (!listeners.has(type)) { + listeners.set(type, new Set()) + } + listeners.get(type)!.add(listener) + }), + removeEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + listeners.get(type)?.delete(listener) + }), + emit: (type: string) => { + listeners.get(type)?.forEach((listener) => { + if (typeof listener === 'function') { + listener(new Event(type)) + } else { + listener.handleEvent(new Event(type)) + } + }) + } + } as unknown as WebviewTag & { emit: (type: string) => void } + + return Object.assign(webview, overrides || {}) +} + +describe('MinimalToolbar', () => { + const mockOnReload = vi.fn() + const mockOnOpenDevTools = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mocks.allApps = [] + mocks.pinned = [] + mocks.openLinkExternal = false + mocks.updateAppStatus.mockResolvedValue(undefined) + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders all toolbar buttons', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + expect(screen.getByLabelText('miniApp.popup.goBack')).toBeInTheDocument() + expect(screen.getByLabelText('miniApp.popup.goForward')).toBeInTheDocument() + expect(screen.getByLabelText('miniApp.popup.refresh')).toBeInTheDocument() + expect(screen.getByLabelText('miniApp.popup.openExternal')).toBeInTheDocument() + expect(screen.getByLabelText('miniApp.add_to_launchpad')).toBeInTheDocument() + expect(screen.getByLabelText('miniApp.popup.open_link_external_off')).toBeInTheDocument() + expect(screen.getByLabelText('miniApp.popup.devtools')).toBeInTheDocument() + }) + + it('calls goBack when back button is clicked', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + vi.advanceTimersByTime(200) + + const backButton = screen.getByLabelText('miniApp.popup.goBack') + fireEvent.click(backButton) + + expect(webview.goBack).toHaveBeenCalledTimes(1) + }) + + it('calls goForward when forward button is clicked', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + vi.advanceTimersByTime(200) + + const forwardButton = screen.getByLabelText('miniApp.popup.goForward') + fireEvent.click(forwardButton) + + expect(webview.goForward).toHaveBeenCalledTimes(1) + }) + + it('calls onReload when refresh button is clicked', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const refreshButton = screen.getByLabelText('miniApp.popup.refresh') + fireEvent.click(refreshButton) + + expect(mockOnReload).toHaveBeenCalledTimes(1) + }) + + it('calls onOpenDevTools when devtools button is clicked', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const devtoolsButton = screen.getByLabelText('miniApp.popup.devtools') + fireEvent.click(devtoolsButton) + + expect(mockOnOpenDevTools).toHaveBeenCalledTimes(1) + }) + + it('opens external link when openExternal button is clicked', () => { + const app = createMockApp('test-app', { url: 'https://example.com' }) + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const openExternalButton = screen.getByLabelText('miniApp.popup.openExternal') + fireEvent.click(openExternalButton) + + expect(mockOpenWebsite).toHaveBeenCalledWith('https://current.example.com') + }) + + it('falls back to app.url when currentUrl is null', () => { + const app = createMockApp('test-app', { url: 'https://fallback.example.com' }) + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const openExternalButton = screen.getByLabelText('miniApp.popup.openExternal') + fireEvent.click(openExternalButton) + + expect(mockOpenWebsite).toHaveBeenCalledWith('https://fallback.example.com') + }) + + it('does not show openExternal button for non-HTTP URLs', () => { + const app = createMockApp('test-app', { url: 'file:///local/path' }) + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + expect(screen.queryByLabelText('miniApp.popup.openExternal')).not.toBeInTheDocument() + }) + + it('toggles pin status when pin button is clicked', async () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + mocks.pinned = [] + + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const pinButton = screen.getByLabelText('miniApp.add_to_launchpad') + expect(pinButton).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(pinButton) + + await waitFor(() => { + expect(mocks.updateAppStatus).toHaveBeenCalledWith('test-app', 'pinned') + }) + }) + + it('shows unpinned state when app is already pinned', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + mocks.pinned = [app] + + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const pinButton = screen.getByLabelText('miniApp.remove_from_launchpad') + expect(pinButton).toHaveAttribute('aria-pressed', 'true') + }) + + it('unpins when pin button is clicked on pinned app', async () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + mocks.pinned = [app] + + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const pinButton = screen.getByLabelText('miniApp.remove_from_launchpad') + fireEvent.click(pinButton) + + await waitFor(() => { + expect(mocks.updateAppStatus).toHaveBeenCalledWith('test-app', 'enabled') + }) + }) + + it('does not show pin button when app is not in allApps', () => { + const app = createMockApp('test-app') + mocks.allApps = [] + mocks.pinned = [] + + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + expect(screen.queryByLabelText('miniApp.add_to_launchpad')).not.toBeInTheDocument() + expect(screen.queryByLabelText('miniApp.remove_from_launchpad')).not.toBeInTheDocument() + }) + + it('toggles openLinkExternal preference', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + mocks.openLinkExternal = false + + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const linkButton = screen.getByLabelText('miniApp.popup.open_link_external_off') + expect(linkButton).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(linkButton) + + expect(mocks.setOpenLinkExternal).toHaveBeenCalledWith(true) + }) + + it('shows open_link_external_on when preference is true', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + mocks.openLinkExternal = true + + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const linkButton = screen.getByLabelText('miniApp.popup.open_link_external_on') + expect(linkButton).toHaveAttribute('aria-pressed', 'true') + }) + + it('disables back/forward buttons when navigation is not possible', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + const webview = createMockWebview({ + canGoBack: vi.fn(() => false), + canGoForward: vi.fn(() => false) + }) + const webviewRef = { current: webview } + + render( + + ) + + vi.advanceTimersByTime(200) + + const backButton = screen.getByLabelText('miniApp.popup.goBack') + const forwardButton = screen.getByLabelText('miniApp.popup.goForward') + + expect(backButton).toBeDisabled() + expect(forwardButton).toBeDisabled() + }) + + it('handles webview navigation errors gracefully', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + const webview = createMockWebview({ + canGoBack: vi.fn(() => { + throw new Error('WebView not ready') + }), + canGoForward: vi.fn(() => false) + }) + const webviewRef = { current: webview } + + expect(() => { + render( + + ) + }).not.toThrow() + }) + + it('updates navigation state on webview navigation events', () => { + const app = createMockApp('test-app') + mocks.allApps = [app] + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + vi.advanceTimersByTime(200) + + const backButton = screen.getByLabelText('miniApp.popup.goBack') + expect(backButton).not.toBeDisabled() + }) + + it('handles pin toggle errors', async () => { + mocks.updateAppStatus.mockRejectedValueOnce(new Error('Pin failed')) + + const app = createMockApp('test-app') + mocks.allApps = [app] + mocks.pinned = [] + + const webview = createMockWebview() + const webviewRef = { current: webview } + + render( + + ) + + const pinButton = screen.getByLabelText('miniApp.add_to_launchpad') + fireEvent.click(pinButton) + + await waitFor(() => { + expect(mockToast.error).toHaveBeenCalled() + }) + }) +})