Skip to content

Commit 5c0a4f7

Browse files
committed
Merge branch 'worktree-agent-af0e20dc'
2 parents 8368daa + cf1d1bb commit 5c0a4f7

10 files changed

Lines changed: 1133 additions & 2 deletions

apps/web/src/app.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import OAuthCallback from './features/auth/OAuthCallback'
44
import ProtectedRoute from './features/auth/ProtectedRoute'
55
import SetupView from './features/installation/SetupView'
66
import SettingsView from './features/installation/SettingsView'
7-
import ChatView from './features/session/ChatView'
7+
import SessionView from './features/session/SessionView'
88
import LandingPage from './features/landing/LandingPage'
99

1010
// Module-level singleton so the cache survives re-renders of <App />.
@@ -19,7 +19,7 @@ export default function App() {
1919
<Route path="/auth/callback" element={<OAuthCallback />} />
2020
<Route path="/installations/:installationId/setup" element={<ProtectedRoute><SetupView /></ProtectedRoute>} />
2121
<Route path="/installations/:installationId/settings" element={<ProtectedRoute><SettingsView /></ProtectedRoute>} />
22-
<Route path="/sessions/:sessionId" element={<ProtectedRoute><ChatView /></ProtectedRoute>} />
22+
<Route path="/session/:installationId/*" element={<ProtectedRoute><SessionView /></ProtectedRoute>} />
2323
</Routes>
2424
</BrowserRouter>
2525
</QueryClientProvider>
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
3+
4+
// Mock the API module
5+
vi.mock('./containerApi', () => ({
6+
createContainerSession: vi.fn(),
7+
stopContainerSession: vi.fn(),
8+
buildStreamUrl: vi.fn(),
9+
}))
10+
11+
// Mock the auth store
12+
vi.mock('../auth/store', () => ({
13+
useAuthStore: {
14+
getState: () => ({
15+
accessToken: 'test-token',
16+
isAuthenticated: true,
17+
}),
18+
setState: vi.fn(),
19+
subscribe: vi.fn(),
20+
},
21+
}))
22+
23+
import {
24+
createContainerSession,
25+
stopContainerSession,
26+
buildStreamUrl,
27+
} from './containerApi'
28+
import ContainerSession from './ContainerSession'
29+
import type { ContainerSessionResponse } from './containerTypes'
30+
31+
const mockedCreate = vi.mocked(createContainerSession)
32+
const mockedStop = vi.mocked(stopContainerSession)
33+
const mockedBuildStreamUrl = vi.mocked(buildStreamUrl)
34+
35+
// Capture EventSource instances for test assertions
36+
type EventSourceHandler = (event: MessageEvent | Event) => void
37+
interface MockEventSource {
38+
url: string
39+
close: ReturnType<typeof vi.fn>
40+
readyState: number
41+
addEventListener: ReturnType<typeof vi.fn>
42+
onmessage: EventSourceHandler | null
43+
listeners: Record<string, EventSourceHandler[]>
44+
CONNECTING: 0
45+
OPEN: 1
46+
CLOSED: 2
47+
}
48+
49+
let mockEventSources: MockEventSource[] = []
50+
51+
// Replace global EventSource
52+
const OriginalEventSource = globalThis.EventSource
53+
54+
beforeEach(() => {
55+
mockEventSources = []
56+
vi.stubGlobal('EventSource', class {
57+
url: string
58+
withCredentials: boolean
59+
readyState = 1
60+
close = vi.fn()
61+
addEventListener = vi.fn()
62+
onmessage: EventSourceHandler | null = null
63+
listeners: Record<string, EventSourceHandler[]> = {}
64+
65+
static CONNECTING = 0
66+
static OPEN = 1
67+
static CLOSED = 2
68+
69+
constructor(url: string, _opts?: { withCredentials: boolean }) {
70+
this.url = url
71+
this.withCredentials = _opts?.withCredentials ?? false
72+
this.addEventListener = vi.fn().mockImplementation((event: string, handler: EventSourceHandler) => {
73+
if (!this.listeners[event]) {
74+
this.listeners[event] = []
75+
}
76+
this.listeners[event].push(handler)
77+
})
78+
mockEventSources.push(this as unknown as MockEventSource)
79+
}
80+
})
81+
82+
mockedBuildStreamUrl.mockReturnValue('http://localhost:8000/api/v1/containers/sessions/test-id/stream?access_token=test-token')
83+
})
84+
85+
afterEach(() => {
86+
cleanup()
87+
vi.restoreAllMocks()
88+
if (OriginalEventSource) {
89+
vi.stubGlobal('EventSource', OriginalEventSource)
90+
}
91+
})
92+
93+
const defaultProps = {
94+
installationId: '11111111-1111-1111-1111-111111111111',
95+
repoFullName: 'acme/helprs',
96+
prNumber: 42,
97+
skillName: 'challenge-me',
98+
onBack: vi.fn(),
99+
}
100+
101+
function makeSessionResponse(overrides: Partial<ContainerSessionResponse> = {}): ContainerSessionResponse {
102+
return {
103+
id: 'test-session-id',
104+
installation_id: defaultProps.installationId,
105+
user_id: null,
106+
pr_number: 42,
107+
repo_full_name: 'acme/helprs',
108+
skill_name: 'challenge-me',
109+
container_id: 'docker-abc123',
110+
status: 'running',
111+
started_at: '2026-04-17T00:00:00Z',
112+
completed_at: null,
113+
created_at: '2026-04-17T00:00:00Z',
114+
updated_at: '2026-04-17T00:00:00Z',
115+
...overrides,
116+
}
117+
}
118+
119+
describe('ContainerSession', () => {
120+
test('renders initial loading state and calls createContainerSession', async () => {
121+
mockedCreate.mockResolvedValue(makeSessionResponse())
122+
123+
render(<ContainerSession {...defaultProps} />)
124+
125+
expect(screen.getByTestId('container-session')).toBeTruthy()
126+
// Repo name and PR number appear in header and terminal output
127+
expect(screen.getAllByText(/acme\/helprs/).length).toBeGreaterThan(0)
128+
expect(screen.getAllByText(/42/).length).toBeGreaterThan(0)
129+
expect(screen.getAllByText(/challenge-me/).length).toBeGreaterThan(0)
130+
131+
await waitFor(() => {
132+
expect(mockedCreate).toHaveBeenCalledWith({
133+
installation_id: defaultProps.installationId,
134+
pr_number: 42,
135+
repo_full_name: 'acme/helprs',
136+
skill_name: 'challenge-me',
137+
})
138+
})
139+
})
140+
141+
test('shows starting message in terminal', async () => {
142+
mockedCreate.mockResolvedValue(makeSessionResponse())
143+
144+
render(<ContainerSession {...defaultProps} />)
145+
146+
await waitFor(() => {
147+
expect(screen.getByText(/Starting challenge-me for acme\/helprs#42/)).toBeTruthy()
148+
})
149+
})
150+
151+
test('shows error when session creation fails', async () => {
152+
mockedCreate.mockRejectedValue(new Error('Network error'))
153+
154+
render(<ContainerSession {...defaultProps} />)
155+
156+
await waitFor(() => {
157+
expect(screen.getByTestId('error-banner')).toBeTruthy()
158+
expect(screen.getByText('Network error')).toBeTruthy()
159+
})
160+
})
161+
162+
test('connects to SSE stream after successful session creation', async () => {
163+
mockedCreate.mockResolvedValue(makeSessionResponse())
164+
165+
render(<ContainerSession {...defaultProps} />)
166+
167+
await waitFor(() => {
168+
expect(mockEventSources.length).toBeGreaterThan(0)
169+
})
170+
171+
expect(mockedBuildStreamUrl).toHaveBeenCalledWith('test-session-id', 'test-token')
172+
})
173+
174+
test('calls onBack when back button is clicked', async () => {
175+
mockedCreate.mockResolvedValue(makeSessionResponse())
176+
const onBack = vi.fn()
177+
178+
render(<ContainerSession {...defaultProps} onBack={onBack} />)
179+
180+
fireEvent.click(screen.getByTestId('back-button'))
181+
expect(onBack).toHaveBeenCalled()
182+
})
183+
184+
test('calls stopContainerSession when stop button is clicked', async () => {
185+
mockedCreate.mockResolvedValue(makeSessionResponse())
186+
mockedStop.mockResolvedValue({ id: 'test-session-id', status: 'stopped', message: 'Stopped' })
187+
188+
render(<ContainerSession {...defaultProps} />)
189+
190+
// Wait for session to be created and status to show running
191+
await waitFor(() => {
192+
expect(screen.getByTestId('session-status')).toBeTruthy()
193+
})
194+
195+
// The stop button appears when status is running/starting
196+
const stopButton = screen.queryByTestId('stop-button')
197+
if (stopButton) {
198+
fireEvent.click(stopButton)
199+
await waitFor(() => {
200+
expect(mockedStop).toHaveBeenCalledWith('test-session-id')
201+
})
202+
}
203+
})
204+
205+
test('shows failed status when container fails to start', async () => {
206+
mockedCreate.mockResolvedValue(makeSessionResponse({ status: 'failed', container_id: null }))
207+
208+
render(<ContainerSession {...defaultProps} />)
209+
210+
await waitFor(() => {
211+
expect(screen.getByTestId('error-banner')).toBeTruthy()
212+
expect(screen.getByTestId('error-banner').textContent).toBe('Container failed to start')
213+
})
214+
})
215+
216+
test('renders terminal output component', async () => {
217+
mockedCreate.mockResolvedValue(makeSessionResponse())
218+
219+
render(<ContainerSession {...defaultProps} />)
220+
221+
await waitFor(() => {
222+
expect(screen.getByTestId('terminal-output')).toBeTruthy()
223+
})
224+
})
225+
226+
test('closes EventSource on unmount', async () => {
227+
mockedCreate.mockResolvedValue(makeSessionResponse())
228+
229+
const { unmount } = render(<ContainerSession {...defaultProps} />)
230+
231+
await waitFor(() => {
232+
expect(mockEventSources.length).toBeGreaterThan(0)
233+
})
234+
235+
unmount()
236+
237+
expect(mockEventSources[0]!.close).toHaveBeenCalled()
238+
})
239+
})

0 commit comments

Comments
 (0)