Skip to content

Commit 979b679

Browse files
fix: misc test fixes, mobile button sizing, TTS a11y, and markdown image sanitization
1 parent de2e22f commit 979b679

12 files changed

Lines changed: 116 additions & 94 deletions

File tree

‎backend/test/services/opencode-single-server.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ describe('OpenCodeServerManager - reloadConfig', () => {
254254
const mockPatchResult = { success: true }
255255
vi.mocked(patchOpenCodeConfig).mockResolvedValue(mockPatchResult)
256256

257+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
258+
257259
const { opencodeServerManager } = await import('../../src/services/opencode-single-server')
258260

259261
await opencodeServerManager.reloadConfig()
@@ -263,5 +265,7 @@ describe('OpenCodeServerManager - reloadConfig', () => {
263265
'utf-8'
264266
)
265267
expect(patchOpenCodeConfig).toHaveBeenCalled()
268+
269+
fetchSpy.mockRestore()
266270
})
267271
})

‎frontend/src/components/message/FloatingTTSButton.tsx‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export function FloatingTTSButton({ messageId, content }: FloatingTTSButtonProps
5656
return
5757
}
5858

59+
setIsLongPressVisual(false)
60+
5961
if (isAnyPlaybackActive) {
6062
stop()
6163
return
@@ -74,15 +76,11 @@ export function FloatingTTSButton({ messageId, content }: FloatingTTSButtonProps
7476

7577
const showStop = isAnyPlaybackActive
7678
const pillTitle = showStop
77-
? 'Stop playback (hold to toggle auto-play)'
79+
? 'Stop playback'
7880
: hasContent
79-
? autoPlay
80-
? 'Play latest reply (auto-play enabled; hold to toggle auto-play)'
81-
: 'Play latest reply (hold to toggle auto-play)'
82-
: autoPlay
83-
? 'Auto-play enabled (hold to toggle auto-play)'
84-
: 'Hold to toggle auto-play'
85-
const pillAriaLabel = pillTitle
81+
? 'Play latest reply'
82+
: 'TTS controls'
83+
const pillAriaLabel = `${pillTitle}. ${autoPlay ? 'Auto-play enabled.' : 'Auto-play disabled.'} hold to toggle auto-play`
8684
const buttonToneClasses = showStop
8785
? 'justify-center px-3 py-1.5 rounded-lg bg-gradient-to-br from-red-600 to-red-700 border border-red-500/60 shadow-red-500/30 ring-red-500/20 hover:ring-red-500/40 text-white'
8886
: autoPlay

‎frontend/src/components/repo/RepoCard.test.tsx‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi } from 'vitest'
22
import { render, screen, fireEvent } from '@testing-library/react'
33
import { BrowserRouter } from 'react-router-dom'
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
45
import { RepoCard } from './RepoCard'
56

67
const mockNavigate = vi.fn()
@@ -15,7 +16,7 @@ vi.mock('react-router-dom', async () => {
1516
const defaultProps = {
1617
repo: {
1718
id: 1,
18-
repoUrl: 'https://github.com/test/repo',
19+
repoUrl: 'https://github.com/test/test-repo',
1920
localPath: 'repos/test-repo',
2021
fullPath: '/Users/test/repos/test-repo',
2122
branch: 'main',
@@ -31,7 +32,14 @@ const defaultProps = {
3132
}
3233

3334
const renderWithRouter = (ui: React.ReactElement) => {
34-
return render(<BrowserRouter>{ui}</BrowserRouter>)
35+
const queryClient = new QueryClient({
36+
defaultOptions: { queries: { retry: false } },
37+
})
38+
return render(
39+
<QueryClientProvider client={queryClient}>
40+
<BrowserRouter>{ui}</BrowserRouter>
41+
</QueryClientProvider>
42+
)
3543
}
3644

3745
describe('RepoCard', () => {
@@ -50,8 +58,10 @@ describe('RepoCard', () => {
5058
expect(mockNavigate).not.toHaveBeenCalled()
5159
})
5260

53-
it('should not show checkbox when manageMode is false', () => {
54-
renderWithRouter(<RepoCard {...defaultProps} manageMode={false} />)
61+
it('should not show checkbox when onSelect is not provided', () => {
62+
const { onSelect, ...propsWithoutSelect } = defaultProps
63+
void onSelect
64+
renderWithRouter(<RepoCard {...propsWithoutSelect} />)
5565
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
5666
})
5767

@@ -88,9 +98,9 @@ describe('RepoCard', () => {
8898
hasChanges: true,
8999
},
90100
}
91-
renderWithRouter(<RepoCard {...props} />)
101+
const { container } = renderWithRouter(<RepoCard {...props} />)
92102

93-
const indicator = screen.getByTestId('dirty-indicator') || screen.container.querySelector('.bg-orange-500')
103+
const indicator = container.querySelector('.bg-orange-500')
94104
expect(indicator).toBeTruthy()
95105
})
96106

@@ -107,8 +117,8 @@ describe('RepoCard', () => {
107117
}
108118
renderWithRouter(<RepoCard {...props} />)
109119

110-
expect(screen.getByText('↑2')).toBeInTheDocument()
111-
expect(screen.getByText('↓1')).toBeInTheDocument()
120+
expect(screen.getByText(/↑2/)).toBeInTheDocument()
121+
expect(screen.getByText(/↓1/)).toBeInTheDocument()
112122
})
113123

114124
it('should display activity label when provided', () => {

‎frontend/src/components/repo/repo-list-state.test.ts‎

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -149,22 +149,22 @@ describe('repo-list-state', () => {
149149

150150
it('should filter by display name', () => {
151151
const repos = [
152-
createMockRepo({ id: 1, localPath: 'repos/test-repo' }),
153-
createMockRepo({ id: 2, localPath: 'repos/other-repo' }),
152+
createMockRepo({ id: 1, repoUrl: 'https://github.com/org/test-repo', localPath: 'repos/test-repo' }),
153+
createMockRepo({ id: 2, repoUrl: 'https://github.com/org/other-repo', localPath: 'repos/other-repo' }),
154154
]
155155
const viewModels = repos.map(r => ({ ...r, attentionState: getAttentionState(r), activityTimestamp: getActivityTimestamp(r) }))
156-
const result = filterReposBySearch(viewModels, 'test')
156+
const result = filterReposBySearch(viewModels, 'test-repo')
157157
expect(result).toHaveLength(1)
158158
expect(result[0].id).toBe(1)
159159
})
160160

161161
it('should filter case-insensitively', () => {
162162
const repos = [
163-
createMockRepo({ id: 1, localPath: 'repos/test-repo' }),
164-
createMockRepo({ id: 2, localPath: 'repos/other-repo' }),
163+
createMockRepo({ id: 1, repoUrl: 'https://github.com/org/test-repo', localPath: 'repos/test-repo' }),
164+
createMockRepo({ id: 2, repoUrl: 'https://github.com/org/other-repo', localPath: 'repos/other-repo' }),
165165
]
166166
const viewModels = repos.map(r => ({ ...r, attentionState: getAttentionState(r), activityTimestamp: getActivityTimestamp(r) }))
167-
const result = filterReposBySearch(viewModels, 'TEST')
167+
const result = filterReposBySearch(viewModels, 'TEST-REPO')
168168
expect(result).toHaveLength(1)
169169
})
170170
})
@@ -253,9 +253,9 @@ describe('repo-list-state', () => {
253253

254254
it('should sort alphabetically by name', () => {
255255
const repos = [
256-
createViewModel(1, { localPath: 'repos/zebra' }),
257-
createViewModel(2, { localPath: 'repos/alpha' }),
258-
createViewModel(3, { localPath: 'repos/middle' }),
256+
createViewModel(1, { repoUrl: 'https://github.com/org/zebra', localPath: 'repos/zebra' }),
257+
createViewModel(2, { repoUrl: 'https://github.com/org/alpha', localPath: 'repos/alpha' }),
258+
createViewModel(3, { repoUrl: 'https://github.com/org/middle', localPath: 'repos/middle' }),
259259
]
260260
const result = sortRepos(repos, 'name')
261261
expect(result[0].id).toBe(2)
@@ -315,15 +315,17 @@ describe('repo-list-state', () => {
315315

316316
it('should return attention section for attention filter', () => {
317317
const now = Date.now()
318-
const repos = [
319-
createViewModel(1, {
318+
const repos: RepoViewModel[] = [
319+
{
320+
...createMockRepo({ id: 1 }),
320321
activityTimestamp: now,
321322
attentionState: { hasChanges: true, ahead: 0, behind: 0, isCloneStatusReady: true }
322-
}),
323-
createViewModel(2, {
323+
},
324+
{
325+
...createMockRepo({ id: 2 }),
324326
activityTimestamp: now,
325327
attentionState: { hasChanges: false, ahead: 0, behind: 0, isCloneStatusReady: true }
326-
}),
328+
},
327329
]
328330
const result = groupReposIntoSections(repos, 'attention', 'recent')
329331
expect(result).toHaveLength(1)

‎frontend/src/components/settings/OpenCodeModelsEditor.test.tsx‎

Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ const mockProviders: Record<string, ConfigProvider> = {
2424
},
2525
}
2626

27+
const findTrashButton = (): HTMLButtonElement | null => {
28+
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
29+
for (const btn of buttons) {
30+
const svg = btn.querySelector('svg')
31+
if (svg && svg.getAttribute('class')?.includes('trash')) {
32+
return btn
33+
}
34+
}
35+
return null
36+
}
37+
2738
describe('OpenCodeModelsEditor', () => {
2839
describe('rendering', () => {
2940
it('should render empty state when no providers configured', () => {
@@ -68,15 +79,7 @@ describe('OpenCodeModelsEditor', () => {
6879
const onChange = vi.fn()
6980
render(<OpenCodeModelsEditor providers={mockProviders} onChange={onChange} />)
7081

71-
const deleteButtons = document.querySelectorAll('button')
72-
let deleteButton: HTMLButtonElement | null = null
73-
deleteButtons.forEach(btn => {
74-
const svg = btn.querySelector('svg')
75-
if (svg && svg.className.baseVal.includes('Trash2')) {
76-
deleteButton = btn as HTMLButtonElement
77-
}
78-
})
79-
82+
const deleteButton = findTrashButton()
8083
expect(deleteButton).not.toBeNull()
8184
if (deleteButton) {
8285
fireEvent.click(deleteButton)
@@ -107,15 +110,7 @@ describe('OpenCodeModelsEditor', () => {
107110

108111
render(<OpenCodeModelsEditor providers={providersWithExtras} onChange={onChange} />)
109112

110-
const deleteButtons = document.querySelectorAll('button')
111-
let deleteButton: HTMLButtonElement | null = null
112-
deleteButtons.forEach(btn => {
113-
const svg = btn.querySelector('svg')
114-
if (svg && svg.className.baseVal.includes('Trash2')) {
115-
deleteButton = btn as HTMLButtonElement
116-
}
117-
})
118-
113+
const deleteButton = findTrashButton()
119114
if (deleteButton) {
120115
fireEvent.click(deleteButton)
121116
}
@@ -155,15 +150,13 @@ describe('OpenCodeModelDialog', () => {
155150
})
156151
})
157152

158-
it('should require display name when submitting empty form', async () => {
153+
it('should disable create button when required fields are empty', async () => {
159154
const { OpenCodeModelDialog } = await import('./OpenCodeModelDialog')
160155
render(<OpenCodeModelDialog {...defaultProps} />)
161156

162-
const createButton = screen.getByRole('button', { name: /create/i })
163-
fireEvent.click(createButton)
164-
165157
await waitFor(() => {
166-
expect(screen.getByText(/display name is required/i)).toBeInTheDocument()
158+
const createButton = screen.getByRole('button', { name: /create/i })
159+
expect(createButton).toBeDisabled()
167160
})
168161
})
169162

@@ -172,15 +165,12 @@ describe('OpenCodeModelDialog', () => {
172165
render(<OpenCodeModelDialog {...defaultProps} />)
173166

174167
const modelIdInput = document.querySelector('input[name="modelId"]') as HTMLInputElement
175-
if (modelIdInput) {
176-
fireEvent.change(modelIdInput, { target: { value: 'invalid model id!' } })
177-
const createButton = screen.getByRole('button', { name: /create/i })
178-
fireEvent.click(createButton)
168+
expect(modelIdInput).not.toBeNull()
169+
fireEvent.change(modelIdInput, { target: { value: 'invalid model id!' } })
179170

180-
await waitFor(() => {
181-
expect(screen.getByText(/alphanumeric/i)).toBeInTheDocument()
182-
})
183-
}
171+
await waitFor(() => {
172+
expect(screen.getByText(/must use only letters, numbers/i)).toBeInTheDocument()
173+
})
184174
})
185175
})
186176

@@ -191,15 +181,18 @@ describe('OpenCodeModelDialog', () => {
191181
render(<OpenCodeModelDialog {...defaultProps} onSubmit={onSubmit} />)
192182

193183
const modelIdInput = document.querySelector('input[name="modelId"]') as HTMLInputElement
194-
const nameInput = document.querySelector('input[name="name"]') as HTMLInputElement
184+
const displayNameInput = document.querySelector('input[name="displayName"]') as HTMLInputElement
195185

196-
if (modelIdInput && nameInput) {
197-
fireEvent.change(modelIdInput, { target: { value: 'gpt-5' } })
198-
fireEvent.change(nameInput, { target: { value: 'GPT-5' } })
199-
}
186+
expect(modelIdInput).not.toBeNull()
187+
expect(displayNameInput).not.toBeNull()
188+
fireEvent.change(modelIdInput, { target: { value: 'gpt-5' } })
189+
fireEvent.change(displayNameInput, { target: { value: 'GPT-5' } })
200190

201-
const createButton = screen.getByRole('button', { name: /create/i })
202-
fireEvent.click(createButton)
191+
await waitFor(() => {
192+
expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
193+
})
194+
195+
fireEvent.click(screen.getByRole('button', { name: /create/i }))
203196

204197
await waitFor(() => {
205198
expect(onSubmit).toHaveBeenCalledWith(
@@ -218,19 +211,25 @@ describe('OpenCodeModelDialog', () => {
218211
render(<OpenCodeModelDialog {...defaultProps} onSubmit={onSubmit} />)
219212

220213
const modelIdInput = document.querySelector('input[name="modelId"]') as HTMLInputElement
221-
const nameInput = document.querySelector('input[name="name"]') as HTMLInputElement
214+
const displayNameInput = document.querySelector('input[name="displayName"]') as HTMLInputElement
222215
const contextLimitInput = document.querySelector('input[name="contextLimit"]') as HTMLInputElement
223216
const outputLimitInput = document.querySelector('input[name="outputLimit"]') as HTMLInputElement
224217

225-
if (modelIdInput && nameInput && contextLimitInput && outputLimitInput) {
226-
fireEvent.change(modelIdInput, { target: { value: 'gpt-5' } })
227-
fireEvent.change(nameInput, { target: { value: 'GPT-5' } })
228-
fireEvent.change(contextLimitInput, { target: { value: '256000' } })
229-
fireEvent.change(outputLimitInput, { target: { value: '8192' } })
230-
}
218+
expect(modelIdInput).not.toBeNull()
219+
expect(displayNameInput).not.toBeNull()
220+
expect(contextLimitInput).not.toBeNull()
221+
expect(outputLimitInput).not.toBeNull()
231222

232-
const createButton = screen.getByRole('button', { name: /create/i })
233-
fireEvent.click(createButton)
223+
fireEvent.change(modelIdInput, { target: { value: 'gpt-5' } })
224+
fireEvent.change(displayNameInput, { target: { value: 'GPT-5' } })
225+
fireEvent.change(contextLimitInput, { target: { value: '256000' } })
226+
fireEvent.change(outputLimitInput, { target: { value: '8192' } })
227+
228+
await waitFor(() => {
229+
expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
230+
})
231+
232+
fireEvent.click(screen.getByRole('button', { name: /create/i }))
234233

235234
await waitFor(() => {
236235
expect(onSubmit).toHaveBeenCalledWith(
@@ -300,6 +299,7 @@ describe('OpenCodeModelDialog', () => {
300299
providerId: 'openai',
301300
modelId: 'gpt-4o',
302301
model: {
302+
id: 'gpt-4o',
303303
name: 'GPT-4o',
304304
limit: { context: 128000, output: 4096 },
305305
reasoning: true,
@@ -321,6 +321,10 @@ describe('OpenCodeModelDialog', () => {
321321
target: { value: 'my-friendly-model' },
322322
})
323323

324+
await waitFor(() => {
325+
expect(screen.getByRole('button', { name: /update/i })).not.toBeDisabled()
326+
})
327+
324328
fireEvent.click(screen.getByRole('button', { name: /update/i }))
325329

326330
await waitFor(() => {

‎frontend/src/components/ui/page-header.test.tsx‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,11 @@ describe("PageHeader", () => {
5252
expect(header).toHaveClass("z-10");
5353
});
5454

55-
it("applies background and border styling", () => {
55+
it("applies background styling", () => {
5656
render(<PageHeader data-testid="header">Content</PageHeader>);
5757
const header = screen.getByTestId("header");
58-
expect(header).toHaveClass("border-b");
59-
expect(header).toHaveClass("border-border");
6058
expect(header).toHaveClass("bg-gradient-to-b");
59+
expect(header).toHaveClass("from-background");
6160
});
6261

6362
it("applies backdrop-blur-sm for frosted glass effect", () => {

0 commit comments

Comments
 (0)