react-native-ai-hooks 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/CONTRIBUTING.md +122 -0
  3. package/README.md +73 -20
  4. package/docs/ARCHITECTURE.md +301 -0
  5. package/docs/ARCHITECTURE_GUIDE.md +467 -0
  6. package/docs/IMPLEMENTATION_COMPLETE.md +349 -0
  7. package/docs/README.md +17 -0
  8. package/docs/TECHNICAL_SPECIFICATION.md +748 -0
  9. package/example/App.tsx +95 -0
  10. package/example/README.md +27 -0
  11. package/example/index.js +5 -0
  12. package/example/package.json +22 -0
  13. package/example/src/components/ProviderPicker.tsx +62 -0
  14. package/example/src/context/APIKeysContext.tsx +96 -0
  15. package/example/src/screens/ChatScreen.tsx +205 -0
  16. package/example/src/screens/SettingsScreen.tsx +124 -0
  17. package/example/tsconfig.json +7 -0
  18. package/jest.config.cjs +7 -0
  19. package/jest.setup.ts +28 -0
  20. package/package.json +17 -3
  21. package/src/hooks/__tests__/useAIForm.test.ts +345 -0
  22. package/src/hooks/__tests__/useAIStream.test.ts +427 -0
  23. package/src/hooks/useAIChat.ts +111 -51
  24. package/src/hooks/useAICode.ts +8 -0
  25. package/src/hooks/useAIForm.ts +92 -202
  26. package/src/hooks/useAIStream.ts +114 -58
  27. package/src/hooks/useAISummarize.ts +8 -0
  28. package/src/hooks/useAITranslate.ts +9 -0
  29. package/src/hooks/useAIVoice.ts +8 -0
  30. package/src/hooks/useImageAnalysis.ts +134 -79
  31. package/src/index.ts +25 -1
  32. package/src/types/index.ts +178 -4
  33. package/src/utils/__tests__/fetchWithRetry.test.ts +168 -0
  34. package/src/utils/__tests__/providerFactory.test.ts +493 -0
  35. package/src/utils/fetchWithRetry.ts +100 -0
  36. package/src/utils/index.ts +8 -0
  37. package/src/utils/providerFactory.ts +288 -0
@@ -0,0 +1,95 @@
1
+ import React, { useState } from 'react';
2
+ import { SafeAreaView, StatusBar, StyleSheet, Text, View, Pressable } from 'react-native';
3
+
4
+ import { APIKeysProvider } from './src/context/APIKeysContext';
5
+ import { ChatScreen } from './src/screens/ChatScreen';
6
+ import { SettingsScreen } from './src/screens/SettingsScreen';
7
+
8
+ type ScreenName = 'chat' | 'settings';
9
+
10
+ function ExampleAppShell() {
11
+ const [screen, setScreen] = useState<ScreenName>('chat');
12
+
13
+ return (
14
+ <SafeAreaView style={styles.safeArea}>
15
+ <StatusBar barStyle="dark-content" />
16
+
17
+ <View style={styles.topBar}>
18
+ <Text style={styles.brand}>react-native-ai-hooks</Text>
19
+ <View style={styles.segmentRow}>
20
+ <Pressable
21
+ accessibilityRole="button"
22
+ onPress={() => setScreen('chat')}
23
+ style={[styles.segmentButton, screen === 'chat' && styles.segmentButtonActive]}
24
+ >
25
+ <Text style={[styles.segmentText, screen === 'chat' && styles.segmentTextActive]}>Chat</Text>
26
+ </Pressable>
27
+
28
+ <Pressable
29
+ accessibilityRole="button"
30
+ onPress={() => setScreen('settings')}
31
+ style={[styles.segmentButton, screen === 'settings' && styles.segmentButtonActive]}
32
+ >
33
+ <Text style={[styles.segmentText, screen === 'settings' && styles.segmentTextActive]}>Settings</Text>
34
+ </Pressable>
35
+ </View>
36
+ </View>
37
+
38
+ <View style={styles.content}>{screen === 'chat' ? <ChatScreen /> : <SettingsScreen />}</View>
39
+ </SafeAreaView>
40
+ );
41
+ }
42
+
43
+ export default function App() {
44
+ return (
45
+ <APIKeysProvider>
46
+ <ExampleAppShell />
47
+ </APIKeysProvider>
48
+ );
49
+ }
50
+
51
+ const styles = StyleSheet.create({
52
+ safeArea: {
53
+ flex: 1,
54
+ backgroundColor: '#f8fafc',
55
+ },
56
+ topBar: {
57
+ paddingHorizontal: 20,
58
+ paddingTop: 10,
59
+ paddingBottom: 8,
60
+ borderBottomWidth: 1,
61
+ borderBottomColor: '#e2e8f0',
62
+ backgroundColor: '#ffffff',
63
+ },
64
+ brand: {
65
+ fontSize: 18,
66
+ fontWeight: '800',
67
+ color: '#0f172a',
68
+ marginBottom: 10,
69
+ },
70
+ segmentRow: {
71
+ flexDirection: 'row',
72
+ backgroundColor: '#f1f5f9',
73
+ borderRadius: 999,
74
+ padding: 4,
75
+ alignSelf: 'flex-start',
76
+ },
77
+ segmentButton: {
78
+ borderRadius: 999,
79
+ paddingHorizontal: 14,
80
+ paddingVertical: 8,
81
+ },
82
+ segmentButtonActive: {
83
+ backgroundColor: '#0f172a',
84
+ },
85
+ segmentText: {
86
+ color: '#334155',
87
+ fontWeight: '700',
88
+ },
89
+ segmentTextActive: {
90
+ color: '#ffffff',
91
+ },
92
+ content: {
93
+ flex: 1,
94
+ },
95
+ });
@@ -0,0 +1,27 @@
1
+ # Example App
2
+
3
+ This Expo app demonstrates secure API key handling and hook usage.
4
+
5
+ ## Features
6
+
7
+ - Settings screen with local key storage using AsyncStorage
8
+ - Global context for Anthropic/OpenAI/Gemini keys
9
+ - Chat screen using useAIStream
10
+ - Friendly warning when no API key is available
11
+
12
+ ## Run
13
+
14
+ 1. Install dependencies
15
+
16
+ ```bash
17
+ cd example
18
+ npm install
19
+ ```
20
+
21
+ 2. Start app
22
+
23
+ ```bash
24
+ npm run start
25
+ ```
26
+
27
+ 3. Open Settings, save at least one key, return to Chat.
@@ -0,0 +1,5 @@
1
+ import { registerRootComponent } from 'expo';
2
+
3
+ import App from './App';
4
+
5
+ registerRootComponent(App);
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "react-native-ai-hooks-example",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "start": "expo start",
8
+ "android": "expo start --android",
9
+ "ios": "expo start --ios",
10
+ "web": "expo start --web"
11
+ },
12
+ "dependencies": {
13
+ "@react-native-async-storage/async-storage": "^1.24.0",
14
+ "expo": "~52.0.30",
15
+ "react": "18.3.1",
16
+ "react-native": "0.76.6",
17
+ "react-native-ai-hooks": "file:.."
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.7.2"
21
+ }
22
+ }
@@ -0,0 +1,62 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+
4
+ import type { SupportedProvider } from '../context/APIKeysContext';
5
+
6
+ type ProviderPickerProps = {
7
+ value: SupportedProvider;
8
+ onChange: (provider: SupportedProvider) => void;
9
+ };
10
+
11
+ const options: Array<{ label: string; value: SupportedProvider }> = [
12
+ { label: 'Anthropic', value: 'anthropic' },
13
+ { label: 'OpenAI', value: 'openai' },
14
+ { label: 'Gemini', value: 'gemini' },
15
+ ];
16
+
17
+ export function ProviderPicker({ value, onChange }: ProviderPickerProps) {
18
+ return (
19
+ <View style={styles.row}>
20
+ {options.map(option => {
21
+ const active = value === option.value;
22
+ return (
23
+ <Pressable
24
+ accessibilityRole="button"
25
+ key={option.value}
26
+ onPress={() => onChange(option.value)}
27
+ style={[styles.pill, active && styles.pillActive]}
28
+ >
29
+ <Text style={[styles.pillText, active && styles.pillTextActive]}>{option.label}</Text>
30
+ </Pressable>
31
+ );
32
+ })}
33
+ </View>
34
+ );
35
+ }
36
+
37
+ const styles = StyleSheet.create({
38
+ row: {
39
+ flexDirection: 'row',
40
+ gap: 10,
41
+ marginBottom: 8,
42
+ },
43
+ pill: {
44
+ borderWidth: 1,
45
+ borderColor: '#d2dae6',
46
+ backgroundColor: '#ffffff',
47
+ paddingHorizontal: 14,
48
+ paddingVertical: 8,
49
+ borderRadius: 999,
50
+ },
51
+ pillActive: {
52
+ backgroundColor: '#0f172a',
53
+ borderColor: '#0f172a',
54
+ },
55
+ pillText: {
56
+ color: '#334155',
57
+ fontWeight: '600',
58
+ },
59
+ pillTextActive: {
60
+ color: '#ffffff',
61
+ },
62
+ });
@@ -0,0 +1,96 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
3
+
4
+ export type SupportedProvider = 'anthropic' | 'openai' | 'gemini';
5
+
6
+ type APIKeysState = {
7
+ anthropic: string;
8
+ openai: string;
9
+ gemini: string;
10
+ };
11
+
12
+ type APIKeysContextValue = {
13
+ keys: APIKeysState;
14
+ activeProvider: SupportedProvider;
15
+ isReady: boolean;
16
+ setActiveProvider: (provider: SupportedProvider) => void;
17
+ updateKey: (provider: SupportedProvider, value: string) => void;
18
+ saveKeys: () => Promise<void>;
19
+ getActiveKey: () => string;
20
+ };
21
+
22
+ const STORAGE_KEY = 'react_native_ai_hooks_example_api_keys_v1';
23
+
24
+ const defaultKeys: APIKeysState = {
25
+ anthropic: '',
26
+ openai: '',
27
+ gemini: '',
28
+ };
29
+
30
+ const APIKeysContext = createContext<APIKeysContextValue | null>(null);
31
+
32
+ export function APIKeysProvider({ children }: { children: React.ReactNode }) {
33
+ const [keys, setKeys] = useState<APIKeysState>(defaultKeys);
34
+ const [activeProvider, setActiveProvider] = useState<SupportedProvider>('anthropic');
35
+ const [isReady, setIsReady] = useState(false);
36
+
37
+ useEffect(() => {
38
+ const loadKeys = async () => {
39
+ try {
40
+ const raw = await AsyncStorage.getItem(STORAGE_KEY);
41
+ if (raw) {
42
+ const parsed = JSON.parse(raw) as Partial<APIKeysState>;
43
+ setKeys({
44
+ anthropic: parsed.anthropic ?? '',
45
+ openai: parsed.openai ?? '',
46
+ gemini: parsed.gemini ?? '',
47
+ });
48
+ }
49
+ } catch {
50
+ // Keep defaults if storage is unavailable or payload is invalid.
51
+ } finally {
52
+ setIsReady(true);
53
+ }
54
+ };
55
+
56
+ loadKeys().catch(() => setIsReady(true));
57
+ }, []);
58
+
59
+ const updateKey = useCallback((provider: SupportedProvider, value: string) => {
60
+ setKeys(prev => ({
61
+ ...prev,
62
+ [provider]: value.trim(),
63
+ }));
64
+ }, []);
65
+
66
+ const saveKeys = useCallback(async () => {
67
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys));
68
+ }, [keys]);
69
+
70
+ const getActiveKey = useCallback(() => {
71
+ return keys[activeProvider] ?? '';
72
+ }, [activeProvider, keys]);
73
+
74
+ const value = useMemo<APIKeysContextValue>(
75
+ () => ({
76
+ keys,
77
+ activeProvider,
78
+ isReady,
79
+ setActiveProvider,
80
+ updateKey,
81
+ saveKeys,
82
+ getActiveKey,
83
+ }),
84
+ [activeProvider, getActiveKey, isReady, keys, saveKeys, updateKey],
85
+ );
86
+
87
+ return <APIKeysContext.Provider value={value}>{children}</APIKeysContext.Provider>;
88
+ }
89
+
90
+ export function useAPIKeys() {
91
+ const context = useContext(APIKeysContext);
92
+ if (!context) {
93
+ throw new Error('useAPIKeys must be used inside APIKeysProvider');
94
+ }
95
+ return context;
96
+ }
@@ -0,0 +1,205 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ KeyboardAvoidingView,
5
+ Platform,
6
+ Pressable,
7
+ ScrollView,
8
+ StyleSheet,
9
+ Text,
10
+ TextInput,
11
+ View,
12
+ } from 'react-native';
13
+ import { useAIStream } from 'react-native-ai-hooks';
14
+
15
+ import { ProviderPicker } from '../components/ProviderPicker';
16
+ import { useAPIKeys } from '../context/APIKeysContext';
17
+
18
+ const NO_KEY_WARNING = 'Please enter your API key in Settings to start chatting';
19
+
20
+ const modelMap: Record<'anthropic' | 'openai' | 'gemini', string> = {
21
+ anthropic: 'claude-sonnet-4-20250514',
22
+ openai: 'gpt-4o-mini',
23
+ gemini: 'gemini-1.5-flash',
24
+ };
25
+
26
+ export function ChatScreen() {
27
+ const { activeProvider, setActiveProvider, getActiveKey } = useAPIKeys();
28
+ const [prompt, setPrompt] = useState('');
29
+
30
+ const activeApiKey = getActiveKey();
31
+ const hasApiKey = activeApiKey.length > 0;
32
+
33
+ const streamOptions = useMemo(
34
+ () => ({
35
+ apiKey: activeApiKey,
36
+ provider: activeProvider,
37
+ model: modelMap[activeProvider],
38
+ temperature: 0.6,
39
+ maxTokens: 700,
40
+ }),
41
+ [activeApiKey, activeProvider],
42
+ );
43
+
44
+ const { response, isLoading, error, streamResponse, abort, clearResponse } = useAIStream(streamOptions);
45
+
46
+ const onSend = async () => {
47
+ if (!hasApiKey || !prompt.trim()) {
48
+ return;
49
+ }
50
+ await streamResponse(prompt.trim());
51
+ };
52
+
53
+ return (
54
+ <KeyboardAvoidingView
55
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
56
+ style={styles.flex}
57
+ >
58
+ <ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
59
+ <Text style={styles.header}>Stream Chat</Text>
60
+ <Text style={styles.subHeader}>Live token streaming demo using your saved API keys.</Text>
61
+
62
+ <ProviderPicker onChange={setActiveProvider} value={activeProvider} />
63
+
64
+ {!hasApiKey && (
65
+ <View style={styles.warningBox}>
66
+ <Text style={styles.warningText}>{NO_KEY_WARNING}</Text>
67
+ </View>
68
+ )}
69
+
70
+ <View style={styles.outputCard}>
71
+ <Text style={styles.outputLabel}>Assistant Output</Text>
72
+ {isLoading ? <ActivityIndicator color="#0f172a" style={styles.loader} /> : null}
73
+ <Text style={styles.outputText}>{response || 'Start a prompt to see streamed output here.'}</Text>
74
+ {error ? <Text style={styles.errorText}>{error}</Text> : null}
75
+ </View>
76
+
77
+ <TextInput
78
+ multiline
79
+ onChangeText={setPrompt}
80
+ placeholder="Ask anything..."
81
+ placeholderTextColor="#94a3b8"
82
+ style={styles.input}
83
+ value={prompt}
84
+ />
85
+
86
+ <View style={styles.buttonRow}>
87
+ <Pressable
88
+ disabled={!hasApiKey || isLoading || !prompt.trim()}
89
+ onPress={onSend}
90
+ style={[styles.buttonPrimary, (!hasApiKey || isLoading || !prompt.trim()) && styles.buttonDisabled]}
91
+ >
92
+ <Text style={styles.buttonText}>Send</Text>
93
+ </Pressable>
94
+
95
+ <Pressable disabled={!isLoading} onPress={abort} style={[styles.buttonGhost, !isLoading && styles.buttonDisabledGhost]}>
96
+ <Text style={styles.buttonGhostText}>Stop</Text>
97
+ </Pressable>
98
+
99
+ <Pressable onPress={clearResponse} style={styles.buttonGhost}>
100
+ <Text style={styles.buttonGhostText}>Clear</Text>
101
+ </Pressable>
102
+ </View>
103
+ </ScrollView>
104
+ </KeyboardAvoidingView>
105
+ );
106
+ }
107
+
108
+ const styles = StyleSheet.create({
109
+ flex: {
110
+ flex: 1,
111
+ },
112
+ container: {
113
+ padding: 20,
114
+ gap: 14,
115
+ },
116
+ header: {
117
+ fontSize: 30,
118
+ fontWeight: '800',
119
+ color: '#0f172a',
120
+ },
121
+ subHeader: {
122
+ color: '#475569',
123
+ lineHeight: 20,
124
+ marginBottom: 6,
125
+ },
126
+ warningBox: {
127
+ backgroundColor: '#fff7ed',
128
+ borderColor: '#fdba74',
129
+ borderWidth: 1,
130
+ borderRadius: 12,
131
+ padding: 12,
132
+ },
133
+ warningText: {
134
+ color: '#9a3412',
135
+ fontWeight: '600',
136
+ },
137
+ outputCard: {
138
+ backgroundColor: '#ffffff',
139
+ borderWidth: 1,
140
+ borderColor: '#d5dde9',
141
+ borderRadius: 16,
142
+ padding: 14,
143
+ minHeight: 180,
144
+ },
145
+ outputLabel: {
146
+ fontWeight: '700',
147
+ color: '#0f172a',
148
+ marginBottom: 8,
149
+ },
150
+ loader: {
151
+ marginBottom: 8,
152
+ },
153
+ outputText: {
154
+ color: '#111827',
155
+ lineHeight: 22,
156
+ },
157
+ errorText: {
158
+ color: '#b91c1c',
159
+ marginTop: 12,
160
+ fontWeight: '600',
161
+ },
162
+ input: {
163
+ borderWidth: 1,
164
+ borderColor: '#d5dde9',
165
+ borderRadius: 14,
166
+ backgroundColor: '#ffffff',
167
+ paddingHorizontal: 14,
168
+ paddingVertical: 12,
169
+ minHeight: 96,
170
+ textAlignVertical: 'top',
171
+ color: '#111827',
172
+ },
173
+ buttonRow: {
174
+ flexDirection: 'row',
175
+ gap: 10,
176
+ },
177
+ buttonPrimary: {
178
+ backgroundColor: '#0f172a',
179
+ borderRadius: 12,
180
+ paddingHorizontal: 18,
181
+ paddingVertical: 12,
182
+ },
183
+ buttonText: {
184
+ color: '#ffffff',
185
+ fontWeight: '700',
186
+ },
187
+ buttonGhost: {
188
+ borderColor: '#cbd5e1',
189
+ borderWidth: 1,
190
+ borderRadius: 12,
191
+ paddingHorizontal: 16,
192
+ paddingVertical: 12,
193
+ backgroundColor: '#ffffff',
194
+ },
195
+ buttonGhostText: {
196
+ color: '#334155',
197
+ fontWeight: '700',
198
+ },
199
+ buttonDisabled: {
200
+ opacity: 0.45,
201
+ },
202
+ buttonDisabledGhost: {
203
+ opacity: 0.5,
204
+ },
205
+ });
@@ -0,0 +1,124 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Alert, ScrollView, StyleSheet, Text, TextInput, View, Pressable } from 'react-native';
3
+
4
+ import { useAPIKeys } from '../context/APIKeysContext';
5
+
6
+ function getTitle(provider: 'anthropic' | 'openai' | 'gemini') {
7
+ if (provider === 'anthropic') {
8
+ return 'Anthropic API Key';
9
+ }
10
+ if (provider === 'openai') {
11
+ return 'OpenAI API Key';
12
+ }
13
+ return 'Gemini API Key';
14
+ }
15
+
16
+ export function SettingsScreen() {
17
+ const { keys, updateKey, saveKeys } = useAPIKeys();
18
+ const [isSaving, setIsSaving] = useState(false);
19
+
20
+ const providers = useMemo<Array<'anthropic' | 'openai' | 'gemini'>>(
21
+ () => ['anthropic', 'openai', 'gemini'],
22
+ [],
23
+ );
24
+
25
+ const onSave = async () => {
26
+ try {
27
+ setIsSaving(true);
28
+ await saveKeys();
29
+ Alert.alert('Saved', 'Your API keys were saved locally on this device.');
30
+ } catch {
31
+ Alert.alert('Error', 'Could not save keys. Please try again.');
32
+ } finally {
33
+ setIsSaving(false);
34
+ }
35
+ };
36
+
37
+ return (
38
+ <ScrollView contentContainerStyle={styles.container}>
39
+ <Text style={styles.header}>Settings</Text>
40
+ <Text style={styles.subHeader}>
41
+ Add your API keys below. They are stored locally using AsyncStorage.
42
+ </Text>
43
+
44
+ {providers.map(provider => (
45
+ <View style={styles.inputGroup} key={provider}>
46
+ <Text style={styles.label}>{getTitle(provider)}</Text>
47
+ <TextInput
48
+ autoCapitalize="none"
49
+ autoCorrect={false}
50
+ keyboardType="default"
51
+ onChangeText={value => updateKey(provider, value)}
52
+ placeholder={`Enter ${provider} key`}
53
+ placeholderTextColor="#94a3b8"
54
+ style={styles.input}
55
+ value={keys[provider]}
56
+ />
57
+ </View>
58
+ ))}
59
+
60
+ <Pressable onPress={onSave} style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}>
61
+ <Text style={styles.saveButtonText}>{isSaving ? 'Saving...' : 'Save Keys'}</Text>
62
+ </Pressable>
63
+
64
+ <Text style={styles.note}>
65
+ Security tip: For production apps, prefer routing requests through your own backend proxy.
66
+ </Text>
67
+ </ScrollView>
68
+ );
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ container: {
73
+ padding: 20,
74
+ gap: 12,
75
+ },
76
+ header: {
77
+ fontSize: 30,
78
+ fontWeight: '800',
79
+ color: '#0f172a',
80
+ },
81
+ subHeader: {
82
+ color: '#475569',
83
+ lineHeight: 20,
84
+ marginBottom: 12,
85
+ },
86
+ inputGroup: {
87
+ gap: 8,
88
+ },
89
+ label: {
90
+ fontSize: 14,
91
+ color: '#0f172a',
92
+ fontWeight: '700',
93
+ },
94
+ input: {
95
+ borderWidth: 1,
96
+ borderColor: '#d5dde9',
97
+ borderRadius: 14,
98
+ paddingHorizontal: 14,
99
+ paddingVertical: 12,
100
+ color: '#111827',
101
+ backgroundColor: '#ffffff',
102
+ },
103
+ saveButton: {
104
+ backgroundColor: '#0f172a',
105
+ borderRadius: 14,
106
+ alignItems: 'center',
107
+ paddingVertical: 14,
108
+ marginTop: 8,
109
+ },
110
+ saveButtonDisabled: {
111
+ opacity: 0.7,
112
+ },
113
+ saveButtonText: {
114
+ color: '#ffffff',
115
+ fontWeight: '700',
116
+ fontSize: 16,
117
+ },
118
+ note: {
119
+ color: '#64748b',
120
+ marginTop: 10,
121
+ fontSize: 12,
122
+ lineHeight: 18,
123
+ },
124
+ });
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "noUncheckedIndexedAccess": true
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ preset: 'ts-jest',
3
+ testEnvironment: 'node',
4
+ testMatch: ['**/__tests__/**/*.test.ts'],
5
+ setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
6
+ clearMocks: true,
7
+ };
package/jest.setup.ts ADDED
@@ -0,0 +1,28 @@
1
+ const originalConsoleError = console.error;
2
+ let consoleErrorSpy: jest.SpyInstance;
3
+
4
+ beforeAll(() => {
5
+ global.fetch = jest.fn();
6
+
7
+ // Required by React 18+ testing helpers so state updates inside act are tracked correctly.
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
11
+ const firstArg = args[0];
12
+ const message = typeof firstArg === 'string' ? firstArg : '';
13
+
14
+ if (message.includes('react-test-renderer is deprecated')) {
15
+ return;
16
+ }
17
+
18
+ originalConsoleError(...(args as Parameters<typeof console.error>));
19
+ });
20
+ });
21
+
22
+ afterEach(() => {
23
+ jest.clearAllMocks();
24
+ });
25
+
26
+ afterAll(() => {
27
+ consoleErrorSpy?.mockRestore();
28
+ });