@ggui-ai/mcp-apps-react-native 0.0.1-placeholder → 0.10.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.
@@ -0,0 +1,502 @@
1
+ /**
2
+ * useInvoke — React Native hook for the streamable invoke protocol (v1).
3
+ *
4
+ * Near-exact port of the web hook (`@ggui-ai/mcp-apps-react/src/invoke/useInvoke.ts`).
5
+ * Public API is identical — consumers should be able to migrate between web
6
+ * and RN by swapping imports only.
7
+ *
8
+ * ## Platform notes
9
+ *
10
+ * 1. **Fetch streaming.** React Native's `fetch` historically returned a null
11
+ * `response.body`: the runtime buffered the entire response before
12
+ * resolving. RN added an opt-in streaming mode — you must pass
13
+ * `reactNative: { textStreaming: true }` on `fetch()` init to get a real
14
+ * `ReadableStream`. This option is an RN-only extension to `RequestInit`
15
+ * and is ignored on web (so the same code path works under Expo Web).
16
+ *
17
+ * Tested on Expo SDK 54 / RN 0.81 (Hermes). On older RN (<0.75) the
18
+ * streaming path may fall through to the non-streaming fallback below.
19
+ *
20
+ * 2. **Non-streaming fallback.** If `response.body` is null (RN version
21
+ * without streaming support, Flipper interfering, etc.) the hook falls
22
+ * back to `response.text()` + single-shot SSE parse. The agent's turn
23
+ * completes atomically — no delta rendering, but correctness preserved.
24
+ * Consumers see content_block_start → content_block_delta → stop in one
25
+ * tick. Good enough for tiny turns; a noticeable UX regression for long
26
+ * ones. Document this to users targeting old RN.
27
+ *
28
+ * 3. **TextDecoder.** Ships natively in Hermes on RN 0.74+ (SDK 50+).
29
+ * If targeting older RN, polyfill it in the app entry file.
30
+ *
31
+ * 4. **AbortController.** Available in RN 0.60+ (all supported versions).
32
+ *
33
+ * 5. **crypto.randomUUID.** NOT available in Hermes. We degrade to
34
+ * `Math.random()` — ids are internal/ephemeral, not cryptographic.
35
+ *
36
+ * ## Runtime caveats (untested by the porting session)
37
+ *
38
+ * - Android pre-RN-0.75 may need the `react-native-fetch-api` polyfill to
39
+ * expose `response.body`. If a real device shows the non-streaming
40
+ * fallback always firing, that's the likely culprit.
41
+ * - iOS + Flipper has been known to intercept fetch and drop streaming.
42
+ * Disable Flipper network inspector when testing streaming locally.
43
+ * - Expo Web uses the browser's native fetch — behaves identically to the
44
+ * web hook.
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * const { messages, send, isStreaming, error } = useInvoke();
49
+ * <Pressable onPress={() => send('hi')}>
50
+ * <Text>Send</Text>
51
+ * </Pressable>
52
+ * ```
53
+ */
54
+ import { useCallback, useRef, useState } from 'react';
55
+ import type {
56
+ ContentBlock,
57
+ InvokeErrorCode,
58
+ InvokeTurn,
59
+ ToolUseBlock,
60
+ } from '@ggui-ai/protocol';
61
+ import { useGguiContext } from '../components/GguiProvider';
62
+ import { parseSseStream } from './sse-parse';
63
+
64
+ const PROTOCOL_VERSION = '1';
65
+
66
+ /**
67
+ * RN-specific extension to `RequestInit`. React Native's fetch accepts
68
+ * `reactNative.textStreaming = true` to opt into a real streaming response
69
+ * body. The option is silently ignored on other platforms (web fetch just
70
+ * sees an unknown property), so the cast-free type below is safe across
71
+ * Expo Web + iOS + Android.
72
+ */
73
+ type RNRequestInit = RequestInit & {
74
+ reactNative?: { textStreaming?: boolean };
75
+ };
76
+
77
+ export interface UseInvokeOptions {
78
+ /** Override `appConfig.endpointUrl`. */
79
+ endpointUrl?: string;
80
+ /**
81
+ * Continue an existing conversation. Absent → new session each call.
82
+ * Forwarded to the agent as the `X-Ggui-Host-Session-Id` header — this
83
+ * is the conversation envelope identity (the chat thread), distinct
84
+ * from any per-render `sessionId` carried on `_meta["ai.ggui/render"]`.
85
+ */
86
+ hostSessionId?: string;
87
+ /** End-user JWT for authenticated apps. */
88
+ bearerToken?: string;
89
+ /**
90
+ * Seed the conversation on mount. Useful when reopening a persistent
91
+ * thread — the seed counts toward `history` on the next `send()` so the
92
+ * agent keeps context. Captured by `useState` once at mount; changing
93
+ * this prop later does NOT re-seed. The caller must gate hook mount
94
+ * until the seed is ready (ChatThreadProvider handles this in Chunk 2).
95
+ */
96
+ initialMessages?: ConversationMessage[];
97
+ /**
98
+ * Fired for every `tool_use` content block the agent emits. Protocol v1.1
99
+ * emits two kinds of tool_use that clients care about:
100
+ * - `ggui_render` / `ggui_update` / `ggui_handshake` — paired by
101
+ * `tool_use_id` with an inline `tool_result` block on the same
102
+ * assistant turn. Consumers watch for the pair and mount their
103
+ * renderer (e.g. `<McpAppIframe>` on the web SDK) using the
104
+ * bootstrap metadata off the paired tool_result.
105
+ * - `ggui_render_blueprint` — a pure client tool (no server result to
106
+ * pair with); the consumer resolves the blueprint name locally.
107
+ */
108
+ onToolUse?: (block: ToolUseBlock) => void;
109
+ /** Fired on terminal error frames or transport failures. */
110
+ onError?: (err: InvokeError) => void;
111
+ /**
112
+ * Dev-mode bridge routing — when set, `send()` POSTs to
113
+ * `{gatewayUrl}/{appId}` instead of `{endpointUrl}/invoke`. The pod
114
+ * routes by `appId`. Mirrors the web hook; see that JSDoc for the
115
+ * full description. When set, `endpointUrl` is optional — the gateway
116
+ * is the transport.
117
+ */
118
+ devBridge?: {
119
+ /** Base URL of the bridge gateway (trailing slash optional). */
120
+ gatewayUrl: string;
121
+ };
122
+ /**
123
+ * Fires once per `send()` with the streaming mode that was used.
124
+ * Device testers on Android use this to confirm `response.body`
125
+ * actually returns a `ReadableStream` (mode `'streaming'`) vs
126
+ * silently degrading to the buffered fallback (mode `'buffered'`).
127
+ * Web hooks don't need this — ReadableStream is reliable there.
128
+ */
129
+ onStreamMode?: (mode: 'streaming' | 'buffered') => void;
130
+ }
131
+
132
+ export interface InvokeError {
133
+ code: InvokeErrorCode | 'transport_error';
134
+ message: string;
135
+ retryAfterMs?: number;
136
+ }
137
+
138
+ export interface ConversationMessage {
139
+ /** Stable id — for assistants this is the agent's `message.id`. */
140
+ id: string;
141
+ role: 'user' | 'assistant';
142
+ /** Accumulated content blocks. Assistant blocks fill in over time. */
143
+ content: ContentBlock[];
144
+ /** True while assistant blocks are still receiving deltas. */
145
+ isStreaming: boolean;
146
+ }
147
+
148
+ export interface UseInvokeReturn {
149
+ messages: ConversationMessage[];
150
+ /**
151
+ * Send a user message; resolves when the assistant turn completes (or errors).
152
+ * Accepts an optional `clientMessageId` so callers with durable storage can
153
+ * own the user-message id and achieve outbox idempotency across retries.
154
+ */
155
+ send: (message: string, opts?: { clientMessageId?: string }) => Promise<void>;
156
+ /** True between `send()` start and turn completion. */
157
+ isStreaming: boolean;
158
+ /** Most recent error; reset on next `send()`. */
159
+ error: InvokeError | null;
160
+ /** Abort the in-flight turn. Closes the fetch + drops streaming flags. */
161
+ abort: () => void;
162
+ /** Reset the local conversation. Does NOT affect server-side state. */
163
+ reset: () => void;
164
+ }
165
+
166
+ export function useInvoke(options: UseInvokeOptions = {}): UseInvokeReturn {
167
+ const ctx = useGguiContext();
168
+ const endpointUrl = options.endpointUrl ?? ctx.appConfig?.endpointUrl;
169
+
170
+ const [messages, setMessages] = useState<ConversationMessage[]>(
171
+ options.initialMessages ?? [],
172
+ );
173
+ const [isStreaming, setIsStreaming] = useState(false);
174
+ const [error, setError] = useState<InvokeError | null>(null);
175
+ const abortRef = useRef<AbortController | null>(null);
176
+ // Always-current snapshot for building `history` without re-creating `send`.
177
+ const messagesRef = useRef<ConversationMessage[]>(messages);
178
+ messagesRef.current = messages;
179
+ // Tracks the hostSessionId the agent surfaces via `tool_result` on turn 1
180
+ // so subsequent `send()` calls can carry `X-Ggui-Host-Session-Id` —
181
+ // without this the agent mints a new session per POST and turn-2 render
182
+ // events never reach the already-mounted render host. Mirrors the web
183
+ // hook's fix. Names the conversation envelope (the chat thread),
184
+ // distinct from any per-render `sessionId`.
185
+ const hostSessionIdRef = useRef<string | null>(options.hostSessionId ?? null);
186
+
187
+ const send = useCallback(
188
+ async (message: string, opts?: { clientMessageId?: string }): Promise<void> => {
189
+ // devBridge short-circuits the prod endpoint — gateway is the transport.
190
+ if (!endpointUrl && !options.devBridge) {
191
+ const err: InvokeError = {
192
+ code: 'invalid_request',
193
+ message: 'Cannot invoke: neither app.endpointUrl nor devBridge is configured.',
194
+ };
195
+ setError(err);
196
+ options.onError?.(err);
197
+ return;
198
+ }
199
+ if (abortRef.current) {
200
+ // Refuse overlapping sends — caller decides whether to abort first.
201
+ return;
202
+ }
203
+
204
+ setError(null);
205
+ const controller = new AbortController();
206
+ abortRef.current = controller;
207
+
208
+ // Append user message + placeholder assistant message in one update.
209
+ // `clientMessageId` lets durable callers own the user id so retries
210
+ // dedupe at the outbox level instead of creating duplicates.
211
+ const userId = opts?.clientMessageId ?? `user_${cryptoRandom()}`;
212
+ const assistantId = `asst_${cryptoRandom()}`;
213
+ setMessages((prev) => [
214
+ ...prev,
215
+ { id: userId, role: 'user', content: [{ type: 'text', text: message }], isStreaming: false },
216
+ { id: assistantId, role: 'assistant', content: [], isStreaming: true },
217
+ ]);
218
+ setIsStreaming(true);
219
+
220
+ // Build history from the snapshot BEFORE this turn (excludes the user
221
+ // message we just appended, since `message` carries it explicitly).
222
+ const history = toInvokeHistory(messagesRef.current);
223
+
224
+ // Track the assistant message id across the turn — message_start may
225
+ // rename it from `assistantId` to the agent-supplied `message.id`,
226
+ // and the catch/finally block needs to find it whichever id it has.
227
+ let assistantMsgId = assistantId;
228
+
229
+ try {
230
+ const headers: Record<string, string> = {
231
+ 'Content-Type': 'application/json',
232
+ 'Accept': 'text/event-stream',
233
+ 'X-Ggui-Protocol-Version': PROTOCOL_VERSION,
234
+ 'X-Ggui-App-Id': ctx.appId,
235
+ };
236
+ // options.hostSessionId wins for explicit-resume callers; otherwise
237
+ // fall back to the hostSessionId surfaced on a prior turn's
238
+ // tool_result.
239
+ const effectiveHostSessionId = options.hostSessionId ?? hostSessionIdRef.current;
240
+ if (effectiveHostSessionId) headers['X-Ggui-Host-Session-Id'] = effectiveHostSessionId;
241
+ if (options.bearerToken) headers['Authorization'] = `Bearer ${options.bearerToken}`;
242
+
243
+ const init: RNRequestInit = {
244
+ method: 'POST',
245
+ headers,
246
+ body: JSON.stringify({ message, history }),
247
+ signal: controller.signal,
248
+ // RN-only: opt into real streaming body. Silently ignored on web.
249
+ reactNative: { textStreaming: true },
250
+ };
251
+
252
+ // Dev-mode bridge: gateway receives `{gatewayUrl}/{appId}` POSTs and
253
+ // streams the SSE response from the local `ggui dev` CLI (routes by appId).
254
+ const targetUrl = options.devBridge
255
+ ? `${options.devBridge.gatewayUrl.replace(/\/$/, '')}/${encodeURIComponent(ctx.appId)}`
256
+ : `${(endpointUrl as string).replace(/\/$/, '')}/invoke`;
257
+ const response = await fetch(targetUrl, init);
258
+
259
+ if (!response.ok) {
260
+ let payload: { error?: { code?: InvokeErrorCode; message?: string; retryAfterMs?: number } } = {};
261
+ try {
262
+ payload = (await response.json()) as typeof payload;
263
+ } catch {
264
+ // non-JSON error body
265
+ }
266
+ const err: InvokeError = {
267
+ code: payload.error?.code ?? 'transport_error',
268
+ message: payload.error?.message ?? `HTTP ${response.status}`,
269
+ ...(payload.error?.retryAfterMs !== undefined ? { retryAfterMs: payload.error.retryAfterMs } : {}),
270
+ };
271
+ throw err;
272
+ }
273
+
274
+ // Stream events. If response.body is null (older RN, Flipper, no
275
+ // streaming support) fall back to buffered text parse — same frames,
276
+ // all arrive at once. Correctness preserved, streaming UX lost.
277
+ // Fire `onStreamMode` so device testers can verify which path the
278
+ // platform actually took (the buffered path is indistinguishable
279
+ // from a fast stream without explicit signalling).
280
+ const usingStreaming = response.body != null;
281
+ options.onStreamMode?.(usingStreaming ? 'streaming' : 'buffered');
282
+ const events = usingStreaming
283
+ ? parseSseStream(response.body as unknown as ReadableStream<Uint8Array>, controller.signal)
284
+ : bufferedFallback(await response.text());
285
+
286
+ for await (const event of events) {
287
+ if (controller.signal.aborted) break;
288
+ if (event.type === 'message_start') {
289
+ // Adopt the agent's message id so client + server line up.
290
+ const newId = event.message.id;
291
+ setMessages((prev) =>
292
+ prev.map((m) => (m.id === assistantId ? { ...m, id: newId } : m)),
293
+ );
294
+ assistantMsgId = newId;
295
+ continue;
296
+ }
297
+ if (event.type === 'content_block_start') {
298
+ const block = event.content_block;
299
+ setMessages((prev) =>
300
+ mutateAssistant(prev, assistantMsgId, (m) => ({
301
+ ...m,
302
+ content: setBlockAt(m.content, event.index, block),
303
+ })),
304
+ );
305
+ if (block.type === 'tool_use') {
306
+ options.onToolUse?.(block);
307
+ }
308
+ // Snap hostSessionId off the first tool_result that surfaces
309
+ // one — agent-side tools like `ggui_render` / `ggui_handshake`
310
+ // inline their result on the same assistant turn with a
311
+ // sessionId payload (the conversation envelope identity, not
312
+ // a per-render id). Subsequent sends reuse this so the server
313
+ // threads user messages to the same session instead of minting
314
+ // a new one per POST.
315
+ if (block.type === 'tool_result' && !hostSessionIdRef.current) {
316
+ const maybe = extractHostSessionIdFromContent(block.content);
317
+ if (maybe) hostSessionIdRef.current = maybe;
318
+ }
319
+ continue;
320
+ }
321
+ if (event.type === 'content_block_delta') {
322
+ const { index, delta } = event;
323
+ setMessages((prev) =>
324
+ mutateAssistant(prev, assistantMsgId, (m) => ({
325
+ ...m,
326
+ content: applyDelta(m.content, index, delta),
327
+ })),
328
+ );
329
+ continue;
330
+ }
331
+ if (event.type === 'error') {
332
+ const err: InvokeError = {
333
+ code: event.error.code,
334
+ message: event.error.message,
335
+ ...(event.error.retryAfterMs !== undefined ? { retryAfterMs: event.error.retryAfterMs } : {}),
336
+ };
337
+ throw err;
338
+ }
339
+ // content_block_stop, message_delta, ping, message_stop — no UI change.
340
+ }
341
+
342
+ // Stream ended cleanly.
343
+ setMessages((prev) =>
344
+ mutateAssistant(prev, assistantMsgId, (m) => ({ ...m, isStreaming: false })),
345
+ );
346
+ } catch (raw) {
347
+ const err: InvokeError = isInvokeError(raw)
348
+ ? raw
349
+ : makeTransportError(raw instanceof Error ? raw.message : 'Invoke failed');
350
+ setError(err);
351
+ options.onError?.(err);
352
+ // Mark assistant message done (even if empty) so UI doesn't spin.
353
+ setMessages((prev) =>
354
+ mutateAssistant(prev, assistantMsgId, (m) => ({ ...m, isStreaming: false })),
355
+ );
356
+ } finally {
357
+ abortRef.current = null;
358
+ setIsStreaming(false);
359
+ }
360
+ },
361
+ [endpointUrl, ctx.appId, options],
362
+ );
363
+
364
+ const abort = useCallback(() => {
365
+ abortRef.current?.abort();
366
+ abortRef.current = null;
367
+ setIsStreaming(false);
368
+ }, []);
369
+
370
+ const reset = useCallback(() => {
371
+ abortRef.current?.abort();
372
+ abortRef.current = null;
373
+ setMessages([]);
374
+ setError(null);
375
+ setIsStreaming(false);
376
+ // Clear the derived hostSessionId — reset() implies a fresh conversation.
377
+ hostSessionIdRef.current = options.hostSessionId ?? null;
378
+ }, [options.hostSessionId]);
379
+
380
+ return { messages, send, isStreaming, error, abort, reset };
381
+ }
382
+
383
+ // ── Helpers ───────────────────────────────────────────────────────────
384
+
385
+ function cryptoRandom(): string {
386
+ // Hermes ships `crypto.getRandomValues` but NOT `crypto.randomUUID` as of
387
+ // RN 0.81. We only need a short unique id for client-side message keying,
388
+ // so Math.random is acceptable (not cryptographic).
389
+ const g = globalThis as { crypto?: { randomUUID?: () => string } };
390
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID().slice(0, 8);
391
+ return Math.random().toString(36).slice(2, 10);
392
+ }
393
+
394
+ function isInvokeError(value: unknown): value is InvokeError {
395
+ return (
396
+ typeof value === 'object' &&
397
+ value !== null &&
398
+ 'code' in value &&
399
+ typeof (value as { code: unknown }).code === 'string'
400
+ );
401
+ }
402
+
403
+ function makeTransportError(message: string): InvokeError {
404
+ return { code: 'transport_error', message };
405
+ }
406
+
407
+ /**
408
+ * Pull a `hostSessionId` string (the conversation envelope id) out of a
409
+ * tool_result's content payload if one is present. Tolerant of arbitrary
410
+ * nested shapes — agents may put the id directly on the result or under
411
+ * a wrapper like `{ result: { hostSessionId } }`. Wire field and
412
+ * SDK-side name are both `hostSessionId`.
413
+ */
414
+ function extractHostSessionIdFromContent(content: unknown): string | null {
415
+ if (typeof content !== 'object' || content === null) return null;
416
+ const record = content as Record<string, unknown>;
417
+ if (typeof record.hostSessionId === 'string') return record.hostSessionId;
418
+ // One level of nesting — common when tools wrap their output in `{ result }`.
419
+ for (const value of Object.values(record)) {
420
+ if (typeof value === 'object' && value !== null) {
421
+ const inner = value as Record<string, unknown>;
422
+ if (typeof inner.hostSessionId === 'string') return inner.hostSessionId;
423
+ }
424
+ }
425
+ return null;
426
+ }
427
+
428
+ function mutateAssistant(
429
+ messages: ConversationMessage[],
430
+ id: string,
431
+ fn: (m: ConversationMessage) => ConversationMessage,
432
+ ): ConversationMessage[] {
433
+ return messages.map((m) => (m.id === id && m.role === 'assistant' ? fn(m) : m));
434
+ }
435
+
436
+ function setBlockAt(blocks: ContentBlock[], index: number, block: ContentBlock): ContentBlock[] {
437
+ const next = blocks.slice();
438
+ // Pad with text placeholders if the agent skipped indices (defensive).
439
+ while (next.length < index) next.push({ type: 'text', text: '' });
440
+ next[index] = block;
441
+ return next;
442
+ }
443
+
444
+ function applyDelta(
445
+ blocks: ContentBlock[],
446
+ index: number,
447
+ delta: { type: 'text_delta'; text: string } | { type: 'input_json_delta'; partial_json: string },
448
+ ): ContentBlock[] {
449
+ const target = blocks[index];
450
+ if (!target) return blocks;
451
+ if (delta.type === 'text_delta' && target.type === 'text') {
452
+ const next = blocks.slice();
453
+ next[index] = { ...target, text: target.text + delta.text };
454
+ return next;
455
+ }
456
+ // input_json_delta accumulation isn't surfaced to UI in v1 — agents that
457
+ // need this can opt in via a future hook. Drop silently.
458
+ return blocks;
459
+ }
460
+
461
+ /**
462
+ * Project the local conversation into the wire `InvokeTurn[]` shape the
463
+ * server expects on `history`. Strip in-flight assistant messages — they
464
+ * have no final stop_reason.
465
+ */
466
+ function toInvokeHistory(messages: ConversationMessage[]): InvokeTurn[] {
467
+ return messages
468
+ .filter((m) => !(m.role === 'assistant' && m.isStreaming))
469
+ .map((m) => ({ role: m.role, content: m.content }));
470
+ }
471
+
472
+ /**
473
+ * Fallback when `response.body` is null (no streaming support in this RN
474
+ * environment). Parses the entire buffered SSE payload and yields events
475
+ * one-by-one from memory. Consumers still see normal event-by-event state
476
+ * updates — just all in the same tick instead of interleaved with network.
477
+ *
478
+ * Splits frames on the SSE separator (`\n\n`), strips the `data: ` prefix,
479
+ * JSON-parses + validates each frame via the same schema the streaming path
480
+ * uses. Silently drops malformed frames — same policy as `parseSseStream`.
481
+ */
482
+ async function* bufferedFallback(
483
+ payload: string,
484
+ ): AsyncGenerator<import('@ggui-ai/protocol').InvokeEvent> {
485
+ const { invokeEventSchema } = await import('@ggui-ai/protocol');
486
+ const FRAME_SEP = '\n\n';
487
+ const frames = payload.split(FRAME_SEP);
488
+ for (const frame of frames) {
489
+ const dataIdx = frame.indexOf('data: ');
490
+ if (dataIdx === -1) continue;
491
+ const json = frame.slice(dataIdx + 'data: '.length).trim();
492
+ if (!json) continue;
493
+ let parsed: unknown;
494
+ try {
495
+ parsed = JSON.parse(json);
496
+ } catch {
497
+ continue;
498
+ }
499
+ const result = invokeEventSchema.safeParse(parsed);
500
+ if (result.success) yield result.data;
501
+ }
502
+ }
@@ -0,0 +1,85 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any -- global native-module mocks (react-native / WebView) for plain-Node test runs; test-only, never ships */
2
+ /**
3
+ * Global test setup for @ggui-ai/mcp-apps-react-native
4
+ *
5
+ * Mocks react-native and WebView so tests can run in a plain Node
6
+ * environment.
7
+ */
8
+ import { vi } from 'vitest';
9
+ import React from 'react';
10
+
11
+ // --- react-native mock ---
12
+
13
+ const appStateListeners: ((state: string) => void)[] = [];
14
+
15
+ vi.mock('react-native', async () => {
16
+ const R = (await vi.importActual<typeof import('react')>('react'));
17
+
18
+ return {
19
+ AppState: {
20
+ currentState: 'active',
21
+ addEventListener: vi.fn((_event: string, handler: (state: string) => void) => {
22
+ appStateListeners.push(handler);
23
+ return { remove: () => { const i = appStateListeners.indexOf(handler); if (i >= 0) appStateListeners.splice(i, 1); } };
24
+ }),
25
+ },
26
+ Platform: { OS: 'ios', Version: '17.0', select: (obj: Record<string, unknown>) => obj.ios ?? obj.default },
27
+ PixelRatio: { get: () => 3 },
28
+ Dimensions: {
29
+ get: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
30
+ addEventListener: vi.fn((_event: string, _handler: () => void) => ({ remove: vi.fn() })),
31
+ },
32
+ Easing: {
33
+ bezier: (x1: number, y1: number, x2: number, y2: number) => ({ _bezier: [x1, y1, x2, y2] }),
34
+ linear: { _type: 'linear' },
35
+ },
36
+ Animated: {
37
+ Value: class { constructor(public _value: number) {} },
38
+ timing: vi.fn(() => ({ start: vi.fn() })),
39
+ spring: vi.fn(() => ({ start: vi.fn() })),
40
+ },
41
+ AccessibilityInfo: {
42
+ isReduceMotionEnabled: vi.fn(async () => false),
43
+ addEventListener: vi.fn((_event: string, _handler: (enabled: boolean) => void) => ({
44
+ remove: vi.fn(),
45
+ })),
46
+ },
47
+ Linking: {
48
+ openURL: vi.fn(async (_url: string) => undefined),
49
+ canOpenURL: vi.fn(async (_url: string) => true),
50
+ addEventListener: vi.fn((_event: string, _handler: (state: unknown) => void) => ({
51
+ remove: vi.fn(),
52
+ })),
53
+ },
54
+ StyleSheet: { create: <T extends Record<string, unknown>>(styles: T): T => styles },
55
+ View: R.forwardRef(({ children, ...props }: any, ref: any) => R.createElement('View', { ...props, ref }, children)),
56
+ Text: ({ children, ...props }: any) => R.createElement('Text', props, children),
57
+ TextInput: (props: any) => R.createElement('TextInput', props),
58
+ Image: (props: any) => R.createElement('Image', props),
59
+ ScrollView: ({ children, ...props }: any) => R.createElement('ScrollView', props, children),
60
+ Switch: (props: any) => R.createElement('Switch', props),
61
+ Pressable: ({ children, ...props }: any) => R.createElement('Pressable', props, typeof children === 'function' ? children({ pressed: false }) : children),
62
+ ActivityIndicator: (props: any) => R.createElement('ActivityIndicator', props),
63
+ useColorScheme: () => 'light',
64
+ };
65
+ });
66
+
67
+ // --- react-native-webview mock ---
68
+
69
+ vi.mock('react-native-webview', async () => {
70
+ const R = (await vi.importActual<typeof import('react')>('react'));
71
+ return {
72
+ default: R.forwardRef((props: any, ref: any) => R.createElement('WebView', { ...props, ref })),
73
+ WebView: R.forwardRef((props: any, ref: any) => R.createElement('WebView', { ...props, ref })),
74
+ };
75
+ });
76
+
77
+ // Suppress unused import warning — React is needed for JSX in vi.mock factories
78
+ void React;
79
+
80
+ // Helpers to simulate state changes in tests
81
+ export function simulateAppStateChange(state: string): void {
82
+ for (const listener of appStateListeners) {
83
+ listener(state);
84
+ }
85
+ }