Problem: - Multiple rapid calls to sendTranscript() created race conditions - Old requests continued using local abortController variable - Responses from superseded requests could still be processed - Session stop didn't reliably prevent pending responses Solution: - Changed abort checks from `abortController.signal.aborted` to `abortControllerRef.current !== abortController` - Ensures request checks if it's still the active one, not just aborted - Added checks at 4 critical points: before API call, after API call, before retry, and after retry Changes: - VoiceContext.tsx:268 - Check before initial API call - VoiceContext.tsx:308 - Check after API response - VoiceContext.tsx:344 - Check before retry - VoiceContext.tsx:359 - Check after retry response Testing: - Added Jest test configuration - Added test suite with 5 race condition scenarios - Added manual testing documentation - Verified with TypeScript linting (no new errors) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
305 lines
9.0 KiB
TypeScript
305 lines
9.0 KiB
TypeScript
/**
|
|
* Tests for VoiceContext race condition fix
|
|
*
|
|
* These tests verify that the AbortController race condition is properly handled:
|
|
* - When a new request supersedes an old one, the old request is properly aborted
|
|
* - The old request's response is discarded if it arrives after being superseded
|
|
* - Session stop properly cancels in-flight requests
|
|
*/
|
|
|
|
// Mock dependencies before imports
|
|
jest.mock('../../services/api', () => ({
|
|
api: {
|
|
getVoiceApiType: jest.fn(),
|
|
getDeploymentId: jest.fn(),
|
|
setVoiceApiType: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('expo-speech', () => ({
|
|
speak: jest.fn(),
|
|
stop: jest.fn(),
|
|
isSpeakingAsync: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('../VoiceTranscriptContext', () => ({
|
|
useVoiceTranscript: () => ({
|
|
addTranscriptEntry: jest.fn(),
|
|
}),
|
|
VoiceTranscriptProvider: ({ children }: any) => children,
|
|
}));
|
|
|
|
// Mock fetch
|
|
global.fetch = jest.fn();
|
|
|
|
import { renderHook, act, waitFor } from '@testing-library/react-native';
|
|
import { VoiceProvider, useVoice } from '../VoiceContext';
|
|
import { api } from '../../services/api';
|
|
import * as Speech from 'expo-speech';
|
|
import React from 'react';
|
|
|
|
describe('VoiceContext - AbortController Race Condition', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
(api.getVoiceApiType as jest.Mock).mockResolvedValue('ask_wellnuo_ai');
|
|
(api.getDeploymentId as jest.Mock).mockResolvedValue('21');
|
|
(Speech.speak as jest.Mock).mockImplementation((text, options) => {
|
|
setTimeout(() => options?.onDone?.(), 0);
|
|
});
|
|
});
|
|
|
|
const mockApiResponse = (token: string, responseText: string, delay = 0) => {
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => {
|
|
resolve({
|
|
json: async () => ({
|
|
ok: true,
|
|
response: { body: responseText },
|
|
}),
|
|
});
|
|
}, delay);
|
|
});
|
|
};
|
|
|
|
const mockTokenResponse = () => ({
|
|
json: async () => ({
|
|
status: '200 OK',
|
|
access_token: 'test-token',
|
|
}),
|
|
});
|
|
|
|
it('should abort old request when new request comes in', async () => {
|
|
const abortedRequests: AbortSignal[] = [];
|
|
|
|
(global.fetch as jest.Mock).mockImplementation((url, options) => {
|
|
const signal = options?.signal;
|
|
if (signal) {
|
|
abortedRequests.push(signal);
|
|
}
|
|
|
|
// First call - token request
|
|
if (url.includes('function=credentials')) {
|
|
return Promise.resolve(mockTokenResponse());
|
|
}
|
|
|
|
// Subsequent calls - API requests
|
|
return mockApiResponse('test-token', 'Response', 100);
|
|
});
|
|
|
|
const { result } = renderHook(() => useVoice(), {
|
|
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.startSession();
|
|
});
|
|
|
|
// Send first request
|
|
act(() => {
|
|
result.current.sendTranscript('First message');
|
|
});
|
|
|
|
// Wait a bit, then send second request (should abort first)
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
result.current.sendTranscript('Second message');
|
|
});
|
|
|
|
// Wait for requests to complete
|
|
await waitFor(() => {
|
|
expect(abortedRequests.length).toBeGreaterThan(0);
|
|
}, { timeout: 3000 });
|
|
|
|
// Verify that at least one request signal was aborted
|
|
const hasAbortedSignal = abortedRequests.some(signal => signal.aborted);
|
|
expect(hasAbortedSignal).toBe(true);
|
|
});
|
|
|
|
it('should discard response from superseded request', async () => {
|
|
let requestCount = 0;
|
|
const responses = ['First response', 'Second response'];
|
|
|
|
(global.fetch as jest.Mock).mockImplementation((url, options) => {
|
|
// Token request
|
|
if (url.includes('function=credentials')) {
|
|
return Promise.resolve(mockTokenResponse());
|
|
}
|
|
|
|
// API requests - first one is slower
|
|
const currentRequest = requestCount++;
|
|
const delay = currentRequest === 0 ? 200 : 50; // First request is slower
|
|
return mockApiResponse('test-token', responses[currentRequest], delay);
|
|
});
|
|
|
|
const { result } = renderHook(() => useVoice(), {
|
|
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.startSession();
|
|
});
|
|
|
|
// Send first request (will be slow)
|
|
const firstPromise = act(async () => {
|
|
return await result.current.sendTranscript('First message');
|
|
});
|
|
|
|
// Immediately send second request (will be fast and supersede first)
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
await result.current.sendTranscript('Second message');
|
|
});
|
|
|
|
await firstPromise;
|
|
|
|
// Wait for all promises to settle
|
|
await waitFor(() => {
|
|
// The lastResponse should be from the second request only
|
|
// because the first request's response should be discarded
|
|
expect(result.current.lastResponse).toBe('Second response');
|
|
}, { timeout: 3000 });
|
|
});
|
|
|
|
it('should abort request when session is stopped', async () => {
|
|
let abortedSignal: AbortSignal | null = null;
|
|
|
|
(global.fetch as jest.Mock).mockImplementation((url, options) => {
|
|
const signal = options?.signal;
|
|
if (signal && !url.includes('function=credentials')) {
|
|
abortedSignal = signal;
|
|
}
|
|
|
|
// Token request
|
|
if (url.includes('function=credentials')) {
|
|
return Promise.resolve(mockTokenResponse());
|
|
}
|
|
|
|
// API request - slow
|
|
return mockApiResponse('test-token', 'Response', 200);
|
|
});
|
|
|
|
const { result } = renderHook(() => useVoice(), {
|
|
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.startSession();
|
|
});
|
|
|
|
// Send request
|
|
act(() => {
|
|
result.current.sendTranscript('Test message');
|
|
});
|
|
|
|
// Stop session while request is in flight
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
result.current.stopSession();
|
|
});
|
|
|
|
// Wait a bit for abort to process
|
|
await waitFor(() => {
|
|
expect(abortedSignal?.aborted).toBe(true);
|
|
}, { timeout: 3000 });
|
|
|
|
// Session should be idle
|
|
expect(result.current.status).toBe('idle');
|
|
});
|
|
|
|
it('should not speak response if session stopped during API call', async () => {
|
|
(global.fetch as jest.Mock).mockImplementation((url, options) => {
|
|
// Token request
|
|
if (url.includes('function=credentials')) {
|
|
return Promise.resolve(mockTokenResponse());
|
|
}
|
|
|
|
// API request - slow
|
|
return mockApiResponse('test-token', 'Response text', 100);
|
|
});
|
|
|
|
const { result } = renderHook(() => useVoice(), {
|
|
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.startSession();
|
|
});
|
|
|
|
// Send request
|
|
const transcriptPromise = act(async () => {
|
|
return await result.current.sendTranscript('Test message');
|
|
});
|
|
|
|
// Stop session while API call is in flight
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 30));
|
|
result.current.stopSession();
|
|
});
|
|
|
|
await transcriptPromise;
|
|
|
|
// Wait for any pending operations
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 200));
|
|
});
|
|
|
|
// Speech.speak should not have been called with the response
|
|
// (might be called with error message, but not with "Response text")
|
|
const speakCalls = (Speech.speak as jest.Mock).mock.calls;
|
|
const hasResponseText = speakCalls.some(call => call[0] === 'Response text');
|
|
expect(hasResponseText).toBe(false);
|
|
});
|
|
|
|
it('should handle retry with proper abort controller check', async () => {
|
|
let requestCount = 0;
|
|
|
|
(global.fetch as jest.Mock).mockImplementation((url, options) => {
|
|
// Token request
|
|
if (url.includes('function=credentials')) {
|
|
return Promise.resolve(mockTokenResponse());
|
|
}
|
|
|
|
// First API request - return 401 to trigger retry
|
|
if (requestCount === 0) {
|
|
requestCount++;
|
|
return Promise.resolve({
|
|
json: async () => ({
|
|
status: '401 Unauthorized',
|
|
ok: false,
|
|
}),
|
|
});
|
|
}
|
|
|
|
// Retry request - slow
|
|
return mockApiResponse('test-token', 'Retry response', 100);
|
|
});
|
|
|
|
const { result } = renderHook(() => useVoice(), {
|
|
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.startSession();
|
|
});
|
|
|
|
// Send first request (will trigger retry)
|
|
const firstPromise = act(async () => {
|
|
return await result.current.sendTranscript('First message');
|
|
});
|
|
|
|
// Send second request during retry (should supersede first)
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
await result.current.sendTranscript('Second message');
|
|
});
|
|
|
|
await firstPromise;
|
|
|
|
// Wait for operations to complete
|
|
await waitFor(() => {
|
|
// Should not have "Retry response" because first request was superseded
|
|
expect(result.current.lastResponse).not.toBe('Retry response');
|
|
}, { timeout: 3000 });
|
|
});
|
|
});
|