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
10 changes: 10 additions & 0 deletions .axioma/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,13 @@
## 2024-07-15 - [Synchronous Error Handling in Async Components]
**Learning:** Wrapping a function call in `Promise.resolve().then()` introduces a micro-task delay. This can break tests that expect the initial state of a component (like `pending`) to be set synchronously upon mount. For components managing async operations, using a `try...catch` around the initial function call and converting synchronous errors into a rejected promise (`promise = Promise.reject(e)`) ensures both sync and async errors are handled by the same pipeline without breaking synchronous test expectations.
**Action:** Use `try...catch` around initial calls to functions expected to return promises to robustly handle synchronous exceptions without introducing micro-task delays.

## 2024-07-20 - [Defensive API Status Access]

**Learning:** When a hook manages state for a dynamic set of keys (e.g., AI API types in `useAI`), accessing those keys in derived state calculations (like `allApisAvailable`) should use optional chaining (`?.`). This provides a critical safety layer when the hook is used with custom or unrecognized keys that might not yet be initialized in the state record, preventing runtime crashes.
**Action:** Always use optional chaining when accessing record-based state using keys that might be externally provided or dynamically generated.

## 2024-07-20 - [Comprehensive JSDoc for Hook Return Types]

**Learning:** Documenting the return interface of complex hooks with `@example` blocks for each property significantly improves the developer experience. It allows IDEs to provide immediate visual feedback on the expected shape and values of the returned state (e.g., `status` or `apis` mapping), reducing the need for developers to context-switch to the source code.
**Action:** Include `@example` tags for all properties in hook return interfaces to enhance IDE IntelliSense and documentation clarity.
226 changes: 226 additions & 0 deletions lib/hooks/useAI.coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useAI, AIApiType } from './useAI'

describe('useAI Coverage Gaps', () => {
beforeEach(() => {
vi.unstubAllGlobals()

// Default mock navigator.userActivation to active
vi.stubGlobal('navigator', {
userActivation: { isActive: true },
})

if (typeof window !== 'undefined') {
// Clear known AI globals
const globals = [
'Summarizer',
'Translator',
'LanguageDetector',
'PromptAPI',
'Writer',
'Rewriter',
'Proofreader',
]
globals.forEach((g) => {
delete (window as unknown as Record<string, unknown>)[g]
})
}
})

afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})

it('should handle unrecognized API type in checkApiAvailability', async () => {
const { result } = renderHook(() => useAI({ apis: ['invalid-api' as AIApiType] }))

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

await waitFor(() => {
const apiStatus = result.current.apis['invalid-api' as AIApiType]
return expect(apiStatus).toBeDefined()
})

expect(result.current.apis['invalid-api' as AIApiType]?.availability).toBe('unavailable')
expect(result.current.apis['invalid-api' as AIApiType]?.error?.message).toContain('Unrecognized AI API type')
})

it('should handle error in preload when user activation is missing', async () => {
vi.stubGlobal('navigator', {
userActivation: { isActive: false },
})

const { result } = renderHook(() => useAI({ apis: ['summarizer'] }))
await waitFor(() => expect(result.current.status).toBe('ready'))

await result.current.preload('summarizer')

await waitFor(() => expect(result.current.apis.summarizer.error).toBeDefined())
expect(result.current.apis.summarizer.error?.message).toBeDefined()
expect(result.current.apis.summarizer.error?.message).toContain('User activation required')
expect(result.current.apis.summarizer.availability).toBe('unavailable')
})

it('should call onReady from preload when API becomes available', async () => {
const onReady = vi.fn()
const mockCreate = vi.fn().mockResolvedValue({
destroy: vi.fn(),
})

const Summarizer = function() {}
Summarizer.create = mockCreate

if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).Summarizer = Summarizer
}

const { result } = renderHook(() => useAI({ onReady, apis: ['summarizer'] }))
await waitFor(() => expect(result.current.status).toBe('ready'))

await result.current.preload('summarizer')

expect(onReady).toHaveBeenCalledWith('summarizer')
expect(result.current.apis.summarizer.availability).toBe('available')
})

it('should preload all APIs using preloadAll', async () => {
const mockCreate = vi.fn().mockResolvedValue({ destroy: vi.fn() })

const Summarizer = function() {}
Summarizer.create = mockCreate
const Translator = function() {}
Translator.create = mockCreate

if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).Summarizer = Summarizer;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).Translator = Translator;
}

const { result } = renderHook(() => useAI({ apis: ['summarizer', 'translator'] }))
await waitFor(() => expect(result.current.status).toBe('ready'))

await result.current.preloadAll()

expect(mockCreate).toHaveBeenCalledTimes(2)
expect(result.current.apis.summarizer.availability).toBe('available')
expect(result.current.apis.translator.availability).toBe('available')
})

it('should handle unrecognized API type in preload', async () => {
const { result } = renderHook(() => useAI({ apis: ['summarizer'] }))
await waitFor(() => expect(result.current.status).toBe('ready'))

await result.current.preload('invalid-api' as AIApiType)

await waitFor(() => {
const apiStatus = result.current.apis['invalid-api' as AIApiType]
return expect(apiStatus).toBeDefined()
})
expect(result.current.apis['invalid-api' as AIApiType]?.availability).toBe('unavailable')
expect(result.current.apis['invalid-api' as AIApiType]?.error?.message).toContain('Unrecognized AI API type')
})

it('should handle experimental APIs in checkApiAvailability', async () => {
const { result } = renderHook(() => useAI({ apis: ['writer', 'rewriter', 'proofreader', 'prompt'] }))

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

expect(result.current.apis.writer.availability).toBe('unavailable')
expect(result.current.apis.rewriter.availability).toBe('unavailable')
expect(result.current.apis.proofreader.availability).toBe('unavailable')
expect(result.current.apis.prompt.availability).toBe('unavailable')
})

it('should clean up effect on unmount', () => {
const { unmount } = renderHook(() => useAI())
unmount()
// No error means it cleaned up successfully
})

it('should handle base constructors in preload', async () => {
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).Summarizer = Object
}
const { result } = renderHook(() => useAI({ apis: ['summarizer'] }))
await waitFor(() => expect(result.current.status).toBe('ready'))

await result.current.preload('summarizer')
await waitFor(() => expect(result.current.apis.summarizer.error).toBeDefined())
expect(result.current.apis.summarizer.error?.message).toContain('is not a valid AI API')
})

it('should handle base constructors in checkApiAvailability', async () => {
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).Summarizer = Object
}
const { result } = renderHook(() => useAI({ apis: ['summarizer'] }))

await waitFor(() => expect(result.current.status).toBe('ready'))
expect(result.current.apis.summarizer.availability).toBe('unavailable')
})

it('should handle API requiring arguments in availability check', async () => {
const Summarizer = function() {}
Summarizer.availability = vi.fn().mockRejectedValue(new Error('Requires arguments'))

if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).Summarizer = Summarizer
}
const { result } = renderHook(() => useAI({ apis: ['summarizer'] }))

await waitFor(() => expect(result.current.status).toBe('ready'))
expect(result.current.apis.summarizer.availability).toBe('unavailable')
expect(result.current.apis.summarizer.error?.message).toContain('Requires arguments')
})

it('should handle non-Error rejection in availability check', async () => {
const Summarizer = function() {}
Summarizer.availability = vi.fn().mockRejectedValue('String Error')

if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).Summarizer = Summarizer
}
const { result } = renderHook(() => useAI({ apis: ['summarizer'] }))

await waitFor(() => expect(result.current.status).toBe('ready'))
expect(result.current.apis.summarizer.error?.message).toContain('requires arguments or is not supported')
})

it('should handle non-Error rejection in preload', async () => {
const Summarizer = function() {}
Summarizer.create = vi.fn().mockRejectedValue('String Error')

if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).Summarizer = Summarizer
}
const { result } = renderHook(() => useAI({ apis: ['summarizer'] }))
await waitFor(() => expect(result.current.status).toBe('ready'))

await result.current.preload('summarizer')
await waitFor(() => expect(result.current.apis.summarizer.error).toBeDefined())
expect(result.current.apis.summarizer.error?.message).toContain('Failed to preload summarizer')
})

it('should handle stable apis check correctly when apis change', async () => {
const { rerender, result } = renderHook(({ apis }) => useAI({ apis }), {
initialProps: { apis: ['summarizer'] as AIApiType[] }
})

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

rerender({ apis: ['translator'] as AIApiType[] })

await waitFor(() => expect(result.current.status).toBe('loading'))
await waitFor(() => expect(result.current.status).toBe('ready'))
})
})
14 changes: 9 additions & 5 deletions lib/hooks/useAI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,22 @@ export interface UseAIOptions {
export interface UseAIResult {
/**
* Whether all requested APIs (if provided) or at least one default API is available.
* @example true
*/
isAvailable: boolean
/**
* The current status of the availability check.
* The current status of the availability check ('idle', 'loading', 'ready', 'error').
* @example 'ready'
*/
status: AIAvailabilityStatus
/**
* Error object if the check failed.
* Global error object if the overall check or a critical initialization failed.
* @example null
*/
error: Error | null
/**
* Status of each API.
* Status of each supported AI API (summarizer, translator, etc.).
* @example { summarizer: { availability: 'available' } }
*/
apis: Record<AIApiType, AIApiStatus>
/**
Expand Down Expand Up @@ -562,11 +566,11 @@ export function useAI(options: UseAIOptions = {}): UseAIResult {
const allApisAvailable =
stableApisToCheck.current && stableApisToCheck.current.length > 0
? stableApisToCheck.current.every(
(api) => apiStatuses[api].availability === 'available',
(api) => apiStatuses[api]?.availability === 'available',
)
: ['summarizer', 'translator', 'languageDetector'].some(
(api) =>
apiStatuses[api as AIApiType].availability ===
apiStatuses[api as AIApiType]?.availability ===
'available',
)

Expand Down
Loading