mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
zustand's JSON storage serialises the whole partialized slice and writes it to AsyncStorage on every set(), including ones that only flip `loading`. Opening one unread message rewrote the mail cache about five times. `createPersistStorage` now coalesces writes per store: - a slice whose fields are the same references as the last one is dropped without serialising; the email and contacts slices are built through `memoizeSlice`, so a set() that only touches UI flags hands over the same slice - other changes wait up to 1 s and only the newest is serialised, and JSON identical to the stored row, or to the row just hydrated from, isn't written again - pending writes are flushed when the app leaves the foreground and when an account is signed out - the account registry is still written straight away, since the headless push task reads it from AsyncStorage - "Refresh cached data" removes the rows through the stores, so their storage forgets the old JSON and writes the re-fetched data back
169 lines
6.9 KiB
TypeScript
169 lines
6.9 KiB
TypeScript
// Vitest setup. Two pieces of plumbing the unit tests need:
|
|
//
|
|
// 1. AsyncStorage: the Zustand `persist` middleware used by several stores
|
|
// reaches in during `set()` calls, which on a `node` environment crashes
|
|
// inside the RN AsyncStorage shim (no `window` global). Swap in an
|
|
// in-memory implementation.
|
|
// 2. react-native: the bundled entry point is Flow-typed and rolldown can't
|
|
// parse it. Stub the few surfaces used at module-load time so any store
|
|
// that transitively imports `react-native` (via push / client-cert
|
|
// bridges) loads cleanly. Tests that actually exercise native modules
|
|
// re-mock the relevant module locally.
|
|
|
|
import { vi } from 'vitest';
|
|
|
|
const memory = new Map<string, string>();
|
|
|
|
vi.mock('@react-native-async-storage/async-storage', () => ({
|
|
default: {
|
|
getItem: vi.fn(async (key: string) => memory.get(key) ?? null),
|
|
setItem: vi.fn(async (key: string, value: string) => {
|
|
memory.set(key, value);
|
|
}),
|
|
removeItem: vi.fn(async (key: string) => {
|
|
memory.delete(key);
|
|
}),
|
|
clear: vi.fn(async () => {
|
|
memory.clear();
|
|
}),
|
|
getAllKeys: vi.fn(async () => Array.from(memory.keys())),
|
|
multiGet: vi.fn(async (keys: string[]) =>
|
|
keys.map((k) => [k, memory.get(k) ?? null] as [string, string | null]),
|
|
),
|
|
multiSet: vi.fn(async (pairs: [string, string][]) => {
|
|
for (const [k, v] of pairs) memory.set(k, v);
|
|
}),
|
|
multiRemove: vi.fn(async (keys: string[]) => {
|
|
for (const k of keys) memory.delete(k);
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('react-native', () => {
|
|
class NativeEventEmitter {
|
|
addListener() {
|
|
return { remove: () => undefined };
|
|
}
|
|
removeAllListeners() {}
|
|
}
|
|
return {
|
|
Platform: { OS: 'android', Version: 33, select: <T,>(spec: { default?: T; android?: T; ios?: T }) => spec.android ?? spec.default ?? spec.ios },
|
|
NativeModules: {},
|
|
NativeEventEmitter,
|
|
PermissionsAndroid: {
|
|
RESULTS: { GRANTED: 'granted', DENIED: 'denied', NEVER_ASK_AGAIN: 'never_ask_again' },
|
|
request: async () => 'granted',
|
|
},
|
|
Linking: { openURL: async () => undefined },
|
|
Appearance: { getColorScheme: () => 'dark', addChangeListener: () => ({ remove: () => undefined }) },
|
|
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => undefined }) },
|
|
};
|
|
});
|
|
|
|
vi.mock('expo-secure-store', () => ({
|
|
setItemAsync: vi.fn(async () => undefined),
|
|
getItemAsync: vi.fn(async () => null),
|
|
deleteItemAsync: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
// expo-web-browser transitively pulls in expo-modules-core, which evaluates
|
|
// RN-only globals at module load. The unit tests don't exercise the real
|
|
// browser handoff flow, so stubbing the surface is enough.
|
|
vi.mock('expo-web-browser', () => ({
|
|
openAuthSessionAsync: vi.fn(async () => ({ type: 'cancel' as const })),
|
|
maybeCompleteAuthSession: vi.fn(),
|
|
}));
|
|
|
|
// @react-native-community/netinfo ships Flow-typed source that rolldown
|
|
// cannot parse. The outbox/network stores import it at module load, so any
|
|
// suite that reaches them (auth-store -> email-store -> outbox-store) needs
|
|
// this mock. Reports "online" by default.
|
|
vi.mock('@react-native-community/netinfo', () => ({
|
|
default: {
|
|
addEventListener: vi.fn(() => () => undefined),
|
|
fetch: vi.fn(async () => ({ isConnected: true, isInternetReachable: true })),
|
|
},
|
|
}));
|
|
|
|
// expo-crypto is a native module; lib/random.ts falls back to node's
|
|
// webcrypto when the import fails, so just make the import resolvable.
|
|
vi.mock('expo-crypto', () => ({
|
|
getRandomValues: <T extends ArrayBufferView>(a: T): T => {
|
|
const bytes = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
|
|
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
return a;
|
|
},
|
|
randomUUID: () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
|
const r = (Math.random() * 16) | 0;
|
|
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
|
return v.toString(16);
|
|
}),
|
|
}));
|
|
|
|
// React Native injects `__DEV__`; expo-modules-core reads it at import time.
|
|
(globalThis as { __DEV__?: boolean }).__DEV__ = false;
|
|
|
|
// Expo native modules reached transitively from the stores (file system,
|
|
// haptics, clipboard, notifications, sharing, pickers). Their JS entry points
|
|
// pull in expo-modules-core, which needs the RN runtime. Tests that exercise
|
|
// one of them re-mock it locally with real behaviour.
|
|
vi.mock('expo-file-system', () => ({
|
|
File: class { constructor(public uri: string) {} async bytes() { return new Uint8Array(); } },
|
|
Paths: { cache: { uri: 'file:///cache/' }, document: { uri: 'file:///documents/' } },
|
|
Directory: class { constructor(public uri: string) {} },
|
|
}));
|
|
vi.mock('expo-file-system/legacy', () => ({
|
|
cacheDirectory: 'file:///cache/',
|
|
documentDirectory: 'file:///documents/',
|
|
createUploadTask: vi.fn(),
|
|
uploadAsync: vi.fn(async () => ({ status: 200, body: '{}' })),
|
|
getInfoAsync: vi.fn(async () => ({ exists: false })),
|
|
deleteAsync: vi.fn(async () => undefined),
|
|
readDirectoryAsync: vi.fn(async () => []),
|
|
writeAsStringAsync: vi.fn(async () => undefined),
|
|
readAsStringAsync: vi.fn(async () => ''),
|
|
FileSystemUploadType: { BINARY_CONTENT: 0 },
|
|
}));
|
|
vi.mock('expo-haptics', () => ({
|
|
impactAsync: vi.fn(async () => undefined),
|
|
notificationAsync: vi.fn(async () => undefined),
|
|
selectionAsync: vi.fn(async () => undefined),
|
|
ImpactFeedbackStyle: { Light: 'light', Medium: 'medium', Heavy: 'heavy' },
|
|
NotificationFeedbackType: { Success: 'success', Warning: 'warning', Error: 'error' },
|
|
}));
|
|
vi.mock('expo-clipboard', () => ({
|
|
setStringAsync: vi.fn(async () => true),
|
|
getStringAsync: vi.fn(async () => ''),
|
|
}));
|
|
vi.mock('expo-notifications', () => ({
|
|
scheduleNotificationAsync: vi.fn(async () => 'id'),
|
|
cancelScheduledNotificationAsync: vi.fn(async () => undefined),
|
|
cancelAllScheduledNotificationsAsync: vi.fn(async () => undefined),
|
|
getAllScheduledNotificationsAsync: vi.fn(async () => []),
|
|
requestPermissionsAsync: vi.fn(async () => ({ granted: true })),
|
|
getPermissionsAsync: vi.fn(async () => ({ granted: true })),
|
|
setNotificationHandler: vi.fn(),
|
|
setNotificationChannelAsync: vi.fn(async () => undefined),
|
|
AndroidImportance: { DEFAULT: 3, HIGH: 4 },
|
|
SchedulableTriggerInputTypes: { DATE: 'date' },
|
|
}));
|
|
vi.mock('expo-sharing', () => ({
|
|
isAvailableAsync: vi.fn(async () => false),
|
|
shareAsync: vi.fn(async () => undefined),
|
|
}));
|
|
vi.mock('expo-document-picker', () => ({
|
|
getDocumentAsync: vi.fn(async () => ({ canceled: true, assets: null })),
|
|
}));
|
|
vi.mock('expo-image-manipulator', () => ({
|
|
manipulateAsync: vi.fn(async (uri: string) => ({ uri, width: 0, height: 0 })),
|
|
SaveFormat: { JPEG: 'jpeg', PNG: 'png' },
|
|
}));
|
|
vi.mock('expo-intent-launcher', () => ({
|
|
startActivityAsync: vi.fn(async () => ({ resultCode: 0 })),
|
|
ActivityAction: { VIEW: 'android.intent.action.VIEW' },
|
|
}));
|
|
vi.mock('expo-localization', () => ({
|
|
getLocales: () => [{ languageCode: 'en', regionCode: 'US', languageTag: 'en-US', textDirection: 'ltr' }],
|
|
getCalendars: () => [{ timeZone: 'UTC', firstWeekday: 1 }],
|
|
}));
|