Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .axioma/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,7 @@
## 2026-06-10 - [Robust Hook Input Validation]
**Learning:** React hooks that wrap timing-sensitive browser APIs (like `useThrottle`) should implement defensive checks for their numeric parameters (e.g., `limit`). Using a pattern like `if (!limit || limit <= 0)` covers `NaN`, `0`, and negative values, ensuring the hook falls back to an immediate update rather than passing invalid values to `setTimeout`.
**Action:** Always validate numeric inputs in hooks that interact with browser timers or scheduling APIs to provide a safe and predictable fallback.

## 2024-06-30 - [Mocking Global Types for Branch Coverage]
**Learning:** To achieve 100% branch coverage in functions that use `typeof Type !== 'undefined' && value instanceof Type` (a common pattern for SSR-safe browser API checks), tests must explicitly mock both the presence and absence of the global `Type`. Using `vi.stubGlobal('Type', class {})` allows exercising the `instanceof` branch even in environments where the API is not natively available.
**Action:** Always use `vi.stubGlobal` to provide mock constructors when testing branch logic that depends on environment-specific global types.
233 changes: 233 additions & 0 deletions lib/hooks/useAIPrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('useAIPrompt', () => {

// Also mock window.ai for implementation that uses (window as any).ai
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).ai = {
languageModel: mockLanguageModel,
}
Expand All @@ -38,7 +39,9 @@ describe('useAIPrompt', () => {
vi.unstubAllGlobals()
vi.clearAllMocks()
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).ai
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).LanguageModel
}
})
Expand Down Expand Up @@ -309,7 +312,9 @@ describe('useAIPrompt', () => {
it('should handle download progress', async () => {
mockLanguageModel.availability.mockResolvedValue('after-download')

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let monitorCallback: ((m: any) => void) | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockLanguageModel.create.mockImplementation((options: any) => {
monitorCallback = options.monitor
return new Promise((resolve) => {
Expand All @@ -325,8 +330,10 @@ describe('useAIPrompt', () => {

await waitFor(() => expect(result.current.status).toBe('downloading'))

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventListeners = new Map<string, (e: any) => void>()
const mockMonitor = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
addEventListener: (event: string, callback: (e: any) => void) => {
eventListeners.set(event, callback)
},
Expand Down Expand Up @@ -385,4 +392,230 @@ describe('useAIPrompt', () => {
expect(result.current.status).toBe('idle')
expect(result.current.error).toBeNull()
})

describe('inferContentType coverage', () => {
it('should correctly infer audio for AudioBuffer', async () => {
class MockAudioBuffer {}
vi.stubGlobal('AudioBuffer', MockAudioBuffer)
mockSession.prompt.mockResolvedValue('ok')
const { result } = renderHook(() => useAIPrompt())

const buffer = new MockAudioBuffer()
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await result.current.prompt([{ role: 'user', content: buffer as any }])
})

expect(mockSession.prompt).toHaveBeenCalledWith(
[{ role: 'user', content: [{ type: 'audio', value: buffer }] }],
expect.any(Object),
)
})

it('should correctly infer audio for Uint8Array (ArrayBufferView)', async () => {
mockSession.prompt.mockResolvedValue('ok')
const { result } = renderHook(() => useAIPrompt())

const view = new Uint8Array(8)
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await result.current.prompt([{ role: 'user', content: view as any }])
})

expect(mockSession.prompt).toHaveBeenCalledWith(
[{ role: 'user', content: [{ type: 'audio', value: view }] }],
expect.any(Object),
)
})

it('should correctly infer audio for audio/ Blobs', async () => {
mockSession.prompt.mockResolvedValue('ok')
const { result } = renderHook(() => useAIPrompt())

const audioBlob = new Blob(['data'], { type: 'audio/wav' })
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await result.current.prompt([{ role: 'user', content: audioBlob as any }])
})

expect(mockSession.prompt).toHaveBeenCalledWith(
[{ role: 'user', content: [{ type: 'audio', value: audioBlob }] }],
expect.any(Object),
)
})

it('should correctly infer image for visual elements', async () => {
class MockHTMLImageElement {}
class MockSVGImageElement {}
class MockHTMLVideoElement {}
class MockHTMLCanvasElement {}
class MockImageBitmap {}
class MockOffscreenCanvas {}
class MockVideoFrame {}
class MockImageData {}

vi.stubGlobal('HTMLImageElement', MockHTMLImageElement)
vi.stubGlobal('SVGImageElement', MockSVGImageElement)
vi.stubGlobal('HTMLVideoElement', MockHTMLVideoElement)
vi.stubGlobal('HTMLCanvasElement', MockHTMLCanvasElement)
vi.stubGlobal('ImageBitmap', MockImageBitmap)
vi.stubGlobal('OffscreenCanvas', MockOffscreenCanvas)
vi.stubGlobal('VideoFrame', MockVideoFrame)
vi.stubGlobal('ImageData', MockImageData)

const elements = [
new MockHTMLImageElement(),
new MockSVGImageElement(),
new MockHTMLVideoElement(),
new MockHTMLCanvasElement(),
new MockImageBitmap(),
new MockOffscreenCanvas(),
new MockVideoFrame(),
new MockImageData(),
]

mockSession.prompt.mockResolvedValue('ok')
const { result } = renderHook(() => useAIPrompt())

for (const el of elements) {
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await result.current.prompt([{ role: 'user', content: el as any }])
})
expect(mockSession.prompt).toHaveBeenLastCalledWith(
[{ role: 'user', content: [{ type: 'image', value: el }] }],
expect.any(Object),
)
}
})

it('should fallback to text for unknown types', async () => {
mockSession.prompt.mockResolvedValue('ok')
const { result } = renderHook(() => useAIPrompt())

const unknown = { foo: 'bar' }
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await result.current.prompt([{ role: 'user', content: unknown as any }])
})

expect(mockSession.prompt).toHaveBeenCalledWith(
[{ role: 'user', content: [{ type: 'text', value: unknown }] }],
expect.any(Object),
)
})
})

it('should throw error if LanguageModel is a base constructor', async () => {
vi.stubGlobal('LanguageModel', Object)
const { result } = renderHook(() => useAIPrompt())

await act(async () => {
await result.current.prompt('hello')
})

expect(result.current.status).toBe('error')
expect(result.current.error?.message).toBe('Prompt API is not available')
})

it('should handle non-Error rejection in prompt', async () => {
mockSession.prompt.mockRejectedValue('Something went wrong')
const { result } = renderHook(() => useAIPrompt())

await act(async () => {
await result.current.prompt('hello')
})

expect(result.current.status).toBe('error')
expect(result.current.error?.message).toBe('Unknown error during prompting')
})

it('should handle non-Error rejection in append', async () => {
mockSession.append.mockRejectedValue('Append failed')
const { result } = renderHook(() => useAIPrompt())

await act(async () => {
await result.current.append([{ role: 'user', content: 'test' }])
})

expect(result.current.status).toBe('error')
expect(result.current.error?.message).toBe('Unknown error during append')
})

it('should handle warmup failure and log error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const warmupError = new Error('Warmup failed')
mockLanguageModel.create.mockRejectedValue(warmupError)

renderHook(() => useAIPrompt({ warmup: true }))

await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Failed to warmup AI Prompt:', warmupError)
})
consoleSpy.mockRestore()
})

it('should abort in-flight prompt on unmount', async () => {
let capturedSignal: AbortSignal | undefined
mockSession.prompt.mockImplementation((_, options) => {
capturedSignal = options.signal
return new Promise(() => {}) // Never resolves
})

const { result, unmount } = renderHook(() => useAIPrompt())

act(() => {
result.current.prompt('hello')
})

await waitFor(() => expect(capturedSignal).toBeDefined())
expect(capturedSignal?.aborted).toBe(false)

unmount()
expect(capturedSignal?.aborted).toBe(true)
})

it('should throw error when LanguageModel is missing', async () => {
vi.stubGlobal('ai', undefined)

const { result } = renderHook(() => useAIPrompt())

await act(async () => {
await result.current.prompt('hello')
})

expect(result.current.status).toBe('error')
expect(result.current.error?.message).toBe('Prompt API not supported in this browser')
})

it('should throw error when availability is "unavailable"', async () => {
mockLanguageModel.availability.mockResolvedValue('unavailable')
const { result } = renderHook(() => useAIPrompt())

await act(async () => {
await result.current.prompt('hello')
})

expect(result.current.status).toBe('error')
expect(result.current.error?.message).toBe('Prompt API is not available')
})

it('should return early if already prompting', async () => {
mockSession.prompt.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('ok'), 100)))
const { result } = renderHook(() => useAIPrompt())

act(() => {
result.current.prompt('hello')
})

await waitFor(() => expect(result.current.status).toBe('prompting'))

// Call prompt again while prompting
await act(async () => {
await result.current.prompt('second call')
})

expect(mockSession.prompt).toHaveBeenCalledTimes(1)
expect(mockSession.prompt).not.toHaveBeenCalledWith('second call', expect.any(Object))
})
})
Loading