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
230 changes: 229 additions & 1 deletion lib/hooks/useTranslator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('useTranslator', () => {
if (typeof window !== 'undefined') {
(window as unknown as { Translator?: unknown }).Translator = (global as unknown as { Translator?: unknown }).Translator;
}
mockAvailability.mockResolvedValue('readily');
mockAvailability.mockImplementation(async () => 'readily');
mockTranslatorCreate.mockResolvedValue(mockTranslator);
});

Expand Down Expand Up @@ -140,6 +140,14 @@ describe('useTranslator', () => {
expect(result.current.status).toBe('success');
expect(result.current.data).toBe('Hola mundo');

mockTranslator.translate.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('Hola'), 100)));
act(() => {
void result.current.translate('Hello world');
});

// Wait for the status to change from 'idle'
await waitFor(() => expect(result.current.status).not.toBe('idle'));

act(() => {
result.current.reset();
});
Expand Down Expand Up @@ -262,4 +270,224 @@ describe('useTranslator', () => {
expect(result.current.status).toBe('idle');
expect(result.current.error).toBeNull();
});

it('should return early if already in a busy state', async () => {
mockTranslator.translate.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve('Hola'), 100)));
const { result } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', streaming: false, warmup: false }));

let firstTranslatePromise: Promise<void> | undefined;
act(() => {
firstTranslatePromise = result.current.translate('Hello');
});

// Wait for the status to change from 'idle'
await waitFor(() => expect(result.current.status).not.toBe('idle'));

const secondTranslatePromise = result.current.translate('World');
expect(secondTranslatePromise).resolves.toBeUndefined();
expect(mockTranslator.translate).toHaveBeenCalledTimes(1);

await act(async () => {
await firstTranslatePromise;
});
});

it('should call destroy and abort on unmount', async () => {
mockTranslator.translate.mockResolvedValue('Hola');
const { result, unmount } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', streaming: false, warmup: false }));

await act(async () => {
await result.current.translate('Hello');
});

unmount();
expect(mockTranslator.destroy).toHaveBeenCalled();
});

it('should handle LanguageDetector edge cases in resolveLanguages', async () => {
vi.stubGlobal('navigator', {
userActivation: { isActive: false },
});

const mockLanguageDetector = {
detect: vi.fn(),
destroy: vi.fn(),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const LanguageDetectorConstructor = function () {} as any;
LanguageDetectorConstructor.availability = vi.fn().mockResolvedValue('readily');
LanguageDetectorConstructor.create = vi.fn().mockResolvedValue(mockLanguageDetector);
vi.stubGlobal('LanguageDetector', LanguageDetectorConstructor);

const { result } = renderHook(() => useTranslator({ sourceLanguage: 'auto', targetLanguage: 'en' }));

// Case: No user activation
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.detectedSourceLanguage).toBeUndefined(); // Falls back to 'en'

// Case: availability 'unavailable'
vi.stubGlobal('navigator', { userActivation: { isActive: true } });
LanguageDetectorConstructor.availability.mockResolvedValue('unavailable');
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.detectedSourceLanguage).toBeUndefined();

// Case: No detection results
LanguageDetectorConstructor.availability.mockResolvedValue('readily');
mockLanguageDetector.detect.mockResolvedValue([]);
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.detectedSourceLanguage).toBeUndefined();

// Case: Detected language not supported
mockLanguageDetector.detect.mockResolvedValue([{ detectedLanguage: 'xyz', confidence: 1.0 }]);
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.detectedSourceLanguage).toBeUndefined();

// Case: LanguageDetector throwing error
LanguageDetectorConstructor.availability.mockRejectedValue(new Error('Fail'));
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.detectedSourceLanguage).toBeUndefined();
});

it('should fallback to en when user language is not supported', async () => {
const { getUserLanguage } = await import('../utilities/userLanguage');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(getUserLanguage).mockReturnValue('xyz' as any);

const { result } = renderHook(() => useTranslator({ targetLanguage: 'user', warmup: false }));
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.resolvedTargetLanguage).toBe('en');
});

it('should handle security check when Translator is a base constructor', async () => {
vi.stubGlobal('Translator', Object);
const { result } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', warmup: false }));

await act(async () => {
await result.current.translate('Hello');
});

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

it('should handle security check when LanguageDetector is a base constructor', async () => {
vi.stubGlobal('LanguageDetector', Array);
const { result } = renderHook(() => useTranslator({ sourceLanguage: 'auto', targetLanguage: 'en', warmup: false }));

await act(async () => {
await result.current.translate('Hello');
});

// Should fallback to 'en' without throwing
expect(result.current.status).toBe('success');
expect(result.current.detectedSourceLanguage).toBeUndefined();
});

it('should handle missing Translator.availability', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TranslatorConstructor = function () {} as any;
TranslatorConstructor.create = vi.fn().mockResolvedValue(mockTranslator);
vi.stubGlobal('Translator', TranslatorConstructor);

const { result } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', warmup: false }));
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.status).toBe('success');
});

it('should handle availability "unavailable"', async () => {
mockAvailability.mockResolvedValue('unavailable');
const { result } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', warmup: false }));
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.status).toBe('error');
expect(result.current.error?.message).toContain('is not available for');
});

it('should handle missing Translator.create', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TranslatorConstructor = function () {} as any;
TranslatorConstructor.availability = vi.fn().mockResolvedValue('readily');
vi.stubGlobal('Translator', TranslatorConstructor);

const { result } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', warmup: false }));
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.status).toBe('error');
expect(result.current.error?.message).toBe('Translator.create is not available');
});

it('should handle missing LanguageDetector.availability', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const LanguageDetectorConstructor = function () {} as any;
LanguageDetectorConstructor.create = vi.fn().mockResolvedValue({
detect: vi.fn().mockResolvedValue([{ detectedLanguage: 'fr', confidence: 1.0 }]),
destroy: vi.fn(),
});
vi.stubGlobal('LanguageDetector', LanguageDetectorConstructor);

const { result } = renderHook(() => useTranslator({ sourceLanguage: 'auto', targetLanguage: 'en', warmup: false }));
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.status).toBe('success');
expect(result.current.detectedSourceLanguage).toBeUndefined(); // Falls back to 'en' because of missing availability check
});

it('should handle missing window.Translator', async () => {
vi.stubGlobal('Translator', undefined);
const { result } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', warmup: false }));
await act(async () => {
await result.current.translate('Hello');
});
expect(result.current.status).toBe('error');
expect(result.current.error?.message).toBe('Translator API not supported in this browser');
});

it('should handle non-Error rejection in translate', async () => {
mockTranslator.translate.mockRejectedValue('String error');
const { result } = renderHook(() => useTranslator({ sourceLanguage: 'en', targetLanguage: 'es', streaming: false, warmup: false }));

await act(async () => {
await result.current.translate('Hello');
});

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

it('should handle warmup with auto source language', async () => {
vi.stubGlobal('navigator', { userActivation: { isActive: true } });
renderHook(() => useTranslator({ sourceLanguage: 'auto', warmup: true }));
// Wait for resolveLanguages and createTranslator to be called
await waitFor(() => expect(mockTranslatorCreate).toHaveBeenCalled());
});

it('should handle warmup error gracefully', async () => {
mockAvailability.mockRejectedValue(new Error('Availability failed'));
renderHook(() => useTranslator({ warmup: true }));
// Warmup error is swallowed but we wait to ensure it finished
await new Promise(resolve => setTimeout(resolve, 50));
});

it('should call abort on unmount even if no translator is active', () => {
const { unmount } = renderHook(() => useTranslator({ warmup: false }));
unmount();
// No errors should occur
});
});
52 changes: 47 additions & 5 deletions lib/hooks/useTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ export type SupportedLanguage =
| 'no' | 'pl' | 'pt' | 'ro' | 'ru' | 'sk' | 'sl' | 'sv' | 'ta' | 'te' | 'th' | 'tr'
| 'uk' | 'vi' | 'zh' | 'zh-Hant';

/**
* List of supported languages for translation.
*/
const SUPPORTED_LANGUAGES: SupportedLanguage[] = [
'ar', 'bg', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr',
'hi', 'hr', 'hu', 'id', 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'mr', 'nl',
'no', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'te', 'th', 'tr',
'uk', 'vi', 'zh', 'zh-Hant',
];

/**
* Configuration options for the Translator hook.
*/
Expand Down Expand Up @@ -173,7 +183,29 @@ export function useTranslator(options: UseTranslatorOptions = {}): UseTranslator
try {
if (typeof window !== 'undefined' && typeof (window as unknown as { LanguageDetector?: unknown }).LanguageDetector === 'function') {
const LanguageDetector = (window as unknown as { LanguageDetector: { availability?: () => Promise<Availability>; create?: (options?: { monitor?: (m: { addEventListener: (event: string, callback: (e: ProgressEvent) => void) => void }) => void }) => Promise<{ detect: (text: string) => Promise<{ detectedLanguage: string; confidence: number }[]>; destroy: () => void }> } }).LanguageDetector;


// Ensure we're not dealing with base constructors
if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
LanguageDetector === (Object as any) ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
LanguageDetector === (Array as any) ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
LanguageDetector === (Function as any)
) {
resolvedSource = 'en';
// Resolve target before returning
if (targetLanguage === 'user') {
const userLang = getUserLanguage() as SupportedLanguage;
resolvedTarget = SUPPORTED_LANGUAGES.includes(userLang) ? userLang : 'en';
setResolvedTargetLanguage(resolvedTarget);
} else {
resolvedTarget = targetLanguage;
setResolvedTargetLanguage(targetLanguage);
}
return { source: resolvedSource, target: resolvedTarget };
}

if (typeof LanguageDetector.availability === 'function') {
const avail = await LanguageDetector.availability();

Expand All @@ -188,8 +220,7 @@ export function useTranslator(options: UseTranslatorOptions = {}): UseTranslator
if (results.length > 0) {
const detected = results[0].detectedLanguage.split('-')[0] as SupportedLanguage;
// Check if detected language is supported
const supportedLanguages: SupportedLanguage[] = ['ar', 'bg', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hi', 'hr', 'hu', 'id', 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'mr', 'nl', 'no', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'te', 'th', 'tr', 'uk', 'vi', 'zh', 'zh-Hant'];
if (supportedLanguages.includes(detected)) {
if (SUPPORTED_LANGUAGES.includes(detected)) {
resolvedSource = detected;
setDetectedSourceLanguage(detected);
} else {
Expand Down Expand Up @@ -218,8 +249,7 @@ export function useTranslator(options: UseTranslatorOptions = {}): UseTranslator
// Resolve target language
if (targetLanguage === 'user') {
const userLang = getUserLanguage() as SupportedLanguage;
const supportedLanguages: SupportedLanguage[] = ['ar', 'bg', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hi', 'hr', 'hu', 'id', 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'mr', 'nl', 'no', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sv', 'ta', 'te', 'th', 'tr', 'uk', 'vi', 'zh', 'zh-Hant'];
if (supportedLanguages.includes(userLang)) {
if (SUPPORTED_LANGUAGES.includes(userLang)) {
resolvedTarget = userLang;
} else {
resolvedTarget = 'en';
Expand Down Expand Up @@ -254,6 +284,18 @@ export function useTranslator(options: UseTranslatorOptions = {}): UseTranslator

const Translator = (window as unknown as { Translator: { availability?: (options: TranslatorAvailabilityOptions) => Promise<Availability>; create?: (options: TranslatorCreateOptions) => Promise<Translator> } }).Translator;

// Ensure we're not dealing with base constructors
if (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Translator === (Object as any) ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Translator === (Array as any) ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Translator === (Function as any)
) {
throw new Error('Translator is not available');
}

// Check availability for the specific language pair
if (typeof Translator.availability === 'function') {
const avail = await Translator.availability({ sourceLanguage: source, targetLanguage: target });
Expand Down
Loading