|
| 1 | +import { describe, it, expect, vi, beforeEach } from 'vitest' |
| 2 | +import { render, screen, waitFor } from '@testing-library/react' |
| 3 | +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' |
| 4 | +import { MemoryRouter, Route, Routes } from 'react-router-dom' |
| 5 | +import type { QuestionRequest } from '@/api/types' |
| 6 | +import { SessionDetail } from '../SessionDetail' |
| 7 | + |
| 8 | +const mocks = vi.hoisted(() => ({ |
| 9 | + useSession: vi.fn(), |
| 10 | + useMessages: vi.fn(), |
| 11 | + useSSE: vi.fn(), |
| 12 | + useRepoActivity: vi.fn(), |
| 13 | + usePermissions: vi.fn(), |
| 14 | + useQuestions: vi.fn(), |
| 15 | + useSSEHealth: vi.fn(), |
| 16 | + useConfig: vi.fn(), |
| 17 | + useOpenCodeClient: vi.fn(), |
| 18 | + useMobile: vi.fn(), |
| 19 | + useAutoScroll: vi.fn(), |
| 20 | + useDialogParam: vi.fn(), |
| 21 | + useSidebarAction: vi.fn(), |
| 22 | + useSessionStatusForSession: vi.fn(), |
| 23 | +})) |
| 24 | + |
| 25 | +vi.mock('@/config', () => ({ |
| 26 | + OPENCODE_API_ENDPOINT: 'http://localhost:5551/api/opencode', |
| 27 | + API_BASE_URL: 'http://localhost:5551', |
| 28 | + SERVER_PORT: 5003, |
| 29 | + OPENCODE_PORT: 5551, |
| 30 | + FILE_LIMITS: {}, |
| 31 | + DEFAULTS: {}, |
| 32 | + ALLOWED_MIME_TYPES: [], |
| 33 | + GIT_PROVIDERS: [], |
| 34 | +})) |
| 35 | + |
| 36 | +vi.mock('@/hooks/useOpenCode', () => ({ |
| 37 | + useSession: mocks.useSession, |
| 38 | + useAbortSession: vi.fn(() => ({ mutate: vi.fn() })), |
| 39 | + useUpdateSession: vi.fn(() => ({ mutate: vi.fn() })), |
| 40 | + useCreateSession: vi.fn(() => ({ mutateAsync: vi.fn() })), |
| 41 | + useMessages: mocks.useMessages, |
| 42 | + useConfig: mocks.useConfig, |
| 43 | + useSendPrompt: vi.fn(() => ({ mutate: vi.fn() })), |
| 44 | + useSendShell: vi.fn(() => ({ mutate: vi.fn() })), |
| 45 | + useAgents: vi.fn(() => ({ data: [] })), |
| 46 | + useOpenCodeClient: mocks.useOpenCodeClient, |
| 47 | +})) |
| 48 | + |
| 49 | +vi.mock('@/hooks/useModelSelection', () => ({ |
| 50 | + useModelSelection: vi.fn(() => ({ model: null, modelString: null })), |
| 51 | +})) |
| 52 | + |
| 53 | +vi.mock('@/hooks/useTTS', () => ({ |
| 54 | + useTTS: vi.fn(() => ({ isEnabled: false })), |
| 55 | +})) |
| 56 | + |
| 57 | +vi.mock('@/hooks/useSettings', () => ({ |
| 58 | + useSettings: vi.fn(() => ({ |
| 59 | + preferences: { expandToolCalls: false }, |
| 60 | + updateSettings: vi.fn(), |
| 61 | + })), |
| 62 | +})) |
| 63 | + |
| 64 | +vi.mock('@/hooks/useSettingsDialog', () => ({ |
| 65 | + useSettingsDialog: vi.fn(() => ({ open: vi.fn() })), |
| 66 | +})) |
| 67 | + |
| 68 | +vi.mock('@/hooks/useMobile', () => ({ |
| 69 | + useMobile: mocks.useMobile, |
| 70 | + useSwipeBack: vi.fn(() => ({ ref: vi.fn() })), |
| 71 | +})) |
| 72 | + |
| 73 | +vi.mock('@/hooks/useVisualViewport', () => ({ |
| 74 | + useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })), |
| 75 | +})) |
| 76 | + |
| 77 | +vi.mock('@/hooks/useKeyboardShortcuts', () => ({ |
| 78 | + useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })), |
| 79 | +})) |
| 80 | + |
| 81 | +vi.mock('@/hooks/useAutoScroll', () => ({ |
| 82 | + useAutoScroll: mocks.useAutoScroll, |
| 83 | +})) |
| 84 | + |
| 85 | +vi.mock('@/hooks/useDialogParam', () => ({ |
| 86 | + useDialogParam: vi.fn(() => [false, vi.fn()]), |
| 87 | +})) |
| 88 | + |
| 89 | +vi.mock('@/hooks/useSidebarAction', () => ({ |
| 90 | + useSidebarAction: vi.fn(() => {}), |
| 91 | +})) |
| 92 | + |
| 93 | +vi.mock('@/hooks/useAutoPlayLastResponse', () => ({ |
| 94 | + getAssistantText: vi.fn(() => ''), |
| 95 | + getLatestPlayableAssistantMessage: vi.fn(() => null), |
| 96 | + useAutoPlayLastResponse: vi.fn(() => {}), |
| 97 | +})) |
| 98 | + |
| 99 | +vi.mock('@/stores/uiStateStore', () => ({ |
| 100 | + useUIState: vi.fn((selector?: (state: Record<string, unknown>) => unknown) => |
| 101 | + typeof selector === 'function' |
| 102 | + ? selector({ isEditingMessage: false, setActivePromptFileBasePath: vi.fn() }) |
| 103 | + : false |
| 104 | + ), |
| 105 | +})) |
| 106 | + |
| 107 | +vi.mock('@/stores/sessionStatusStore', () => ({ |
| 108 | + useSessionStatus: vi.fn(() => ({ setStatus: vi.fn() })), |
| 109 | + useSessionStatusForSession: mocks.useSessionStatusForSession, |
| 110 | +})) |
| 111 | + |
| 112 | +vi.mock('@/hooks/useSSE', () => ({ |
| 113 | + useSSE: mocks.useSSE, |
| 114 | +})) |
| 115 | + |
| 116 | +vi.mock('@/hooks/useRepoActivity', () => ({ |
| 117 | + useRepoActivity: mocks.useRepoActivity, |
| 118 | +})) |
| 119 | + |
| 120 | +vi.mock('@/contexts/EventContext', async (importOriginal) => { |
| 121 | + const actual = await importOriginal() |
| 122 | + return { |
| 123 | + ...(actual as object), |
| 124 | + usePermissions: mocks.usePermissions, |
| 125 | + useQuestions: mocks.useQuestions, |
| 126 | + useSSEHealth: mocks.useSSEHealth, |
| 127 | + } |
| 128 | +}) |
| 129 | + |
| 130 | +vi.mock('@/api/repos', () => ({ |
| 131 | + getRepo: vi.fn(() => Promise.resolve({ |
| 132 | + id: 1, |
| 133 | + repoUrl: 'https://github.com/test/repo', |
| 134 | + localPath: '/test/repo', |
| 135 | + sourcePath: null, |
| 136 | + fullPath: '/test/repo', |
| 137 | + branch: 'main', |
| 138 | + currentBranch: 'main', |
| 139 | + fullSlug: 'test/repo', |
| 140 | + repoType: 'github' as const, |
| 141 | + })), |
| 142 | + initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })), |
| 143 | +})) |
| 144 | + |
| 145 | +vi.mock('@/components/model/ModelSelectDialog', () => ({ |
| 146 | + ModelSelectDialog: vi.fn(() => null), |
| 147 | +})) |
| 148 | + |
| 149 | +vi.mock('@/components/session/SessionList', () => ({ |
| 150 | + SessionList: vi.fn(() => null), |
| 151 | +})) |
| 152 | + |
| 153 | +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ |
| 154 | + FileBrowserSheet: vi.fn(() => null), |
| 155 | +})) |
| 156 | + |
| 157 | +vi.mock('@/components/repo/RepoMcpDialog', () => ({ |
| 158 | + RepoMcpDialog: vi.fn(() => null), |
| 159 | +})) |
| 160 | + |
| 161 | +vi.mock('@/components/repo/ResetPermissionsDialog', () => ({ |
| 162 | + ResetPermissionsDialog: vi.fn(() => null), |
| 163 | +})) |
| 164 | + |
| 165 | +vi.mock('@/components/repo/RepoLspDialog', () => ({ |
| 166 | + RepoLspDialog: vi.fn(() => null), |
| 167 | +})) |
| 168 | + |
| 169 | +vi.mock('@/components/repo/RepoSkillsDialog', () => ({ |
| 170 | + RepoSkillsDialog: vi.fn(() => null), |
| 171 | +})) |
| 172 | + |
| 173 | +vi.mock('@/components/source-control', () => ({ |
| 174 | + SourceControlPanel: vi.fn(() => null), |
| 175 | +})) |
| 176 | + |
| 177 | +vi.mock('@/components/session/QuestionPrompt', () => ({ |
| 178 | + QuestionPrompt: ({ question }: { question: QuestionRequest }) => ( |
| 179 | + <div data-testid="question-prompt">{question.id}</div> |
| 180 | + ), |
| 181 | +})) |
| 182 | + |
| 183 | +vi.mock('@/components/session/MinimizedQuestionIndicator', () => ({ |
| 184 | + MinimizedQuestionIndicator: vi.fn(() => null), |
| 185 | +})) |
| 186 | + |
| 187 | +vi.mock('@/components/notifications/PendingActionsGroup', () => ({ |
| 188 | + PendingActionsGroup: vi.fn(() => null), |
| 189 | +})) |
| 190 | + |
| 191 | +vi.mock('@/components/message/PromptInput', () => ({ |
| 192 | + PromptInput: vi.fn(() => <div>MockedPromptInput</div>), |
| 193 | +})) |
| 194 | + |
| 195 | +const VIEWED_SESSION_ID = 'viewed-session' |
| 196 | + |
| 197 | +function createQuestion(id: string, sessionID: string): QuestionRequest { |
| 198 | + return { |
| 199 | + id, |
| 200 | + sessionID, |
| 201 | + questions: [ |
| 202 | + { |
| 203 | + question: 'Continue?', |
| 204 | + header: 'Confirm', |
| 205 | + options: [{ label: 'Yes', description: 'Continue' }], |
| 206 | + multiple: false, |
| 207 | + }, |
| 208 | + ], |
| 209 | + } |
| 210 | +} |
| 211 | + |
| 212 | +const viewedSessionQuestion = createQuestion('question-viewed', VIEWED_SESSION_ID) |
| 213 | +const otherSessionQuestion = createQuestion('question-other', 'other-session') |
| 214 | + |
| 215 | +describe('SessionDetail question prompt session scoping', () => { |
| 216 | + beforeEach(() => { |
| 217 | + vi.clearAllMocks() |
| 218 | + |
| 219 | + mocks.useSession.mockReturnValue({ data: undefined, isLoading: false }) |
| 220 | + mocks.useMessages.mockReturnValue({ data: [], isLoading: false }) |
| 221 | + mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false }) |
| 222 | + mocks.useRepoActivity.mockReturnValue(undefined) |
| 223 | + mocks.usePermissions.mockReturnValue({ |
| 224 | + pendingCount: 0, |
| 225 | + syncForSession: vi.fn(), |
| 226 | + }) |
| 227 | + mocks.useSSEHealth.mockReturnValue({ isHealthy: true }) |
| 228 | + mocks.useConfig.mockReturnValue({ data: undefined, isLoading: false }) |
| 229 | + mocks.useOpenCodeClient.mockReturnValue({}) |
| 230 | + mocks.useMobile.mockReturnValue(false) |
| 231 | + mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn() }) |
| 232 | + mocks.useDialogParam.mockReturnValue([false, vi.fn()]) |
| 233 | + mocks.useSidebarAction.mockReturnValue(undefined) |
| 234 | + mocks.useSessionStatusForSession.mockReturnValue({ type: 'idle' }) |
| 235 | + }) |
| 236 | + |
| 237 | + const renderWithQuestions = (questionsBySession: Record<string, QuestionRequest>, current: QuestionRequest | null) => { |
| 238 | + mocks.useQuestions.mockReturnValue({ |
| 239 | + current, |
| 240 | + getForSession: vi.fn((sessionID: string) => questionsBySession[sessionID] ?? null), |
| 241 | + pendingCount: Object.keys(questionsBySession).length, |
| 242 | + reply: vi.fn(), |
| 243 | + reject: vi.fn(), |
| 244 | + syncForSession: vi.fn(), |
| 245 | + }) |
| 246 | + |
| 247 | + return render( |
| 248 | + <MemoryRouter initialEntries={[`/repos/1/sessions/${VIEWED_SESSION_ID}`]}> |
| 249 | + <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}> |
| 250 | + <Routes> |
| 251 | + <Route path="/repos/:id/sessions/:sessionId" element={<SessionDetail />} /> |
| 252 | + </Routes> |
| 253 | + </QueryClientProvider> |
| 254 | + </MemoryRouter> |
| 255 | + ) |
| 256 | + } |
| 257 | + |
| 258 | + it('renders the viewed session question when another session owns the globally current question', async () => { |
| 259 | + renderWithQuestions( |
| 260 | + { |
| 261 | + [VIEWED_SESSION_ID]: viewedSessionQuestion, |
| 262 | + 'other-session': otherSessionQuestion, |
| 263 | + }, |
| 264 | + otherSessionQuestion |
| 265 | + ) |
| 266 | + |
| 267 | + await waitFor(() => { |
| 268 | + expect(screen.getByTestId('question-prompt')).toHaveTextContent('question-viewed') |
| 269 | + }) |
| 270 | + }) |
| 271 | + |
| 272 | + it('renders no question prompt when only another session has a pending question', async () => { |
| 273 | + renderWithQuestions({ 'other-session': otherSessionQuestion }, otherSessionQuestion) |
| 274 | + |
| 275 | + await waitFor(() => expect(screen.getByText('MockedPromptInput')).toBeInTheDocument()) |
| 276 | + expect(screen.queryByTestId('question-prompt')).not.toBeInTheDocument() |
| 277 | + }) |
| 278 | +}) |
0 commit comments