Skip to content

Commit 47ceb39

Browse files
committed
test(react-native): comprehensive test suite — 155 tests across 10 files
useAttachments unit tests, CopilotChat attachment integration, Popup/Sidebar prop forwarding, headless integration, full pick-to-submit flow.
1 parent 20184e1 commit 47ceb39

12 files changed

Lines changed: 2554 additions & 45 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Stub for expo-document-picker — replaced at runtime by vi.mock() in tests.
2+
// This file exists so Vite's import analysis can resolve the module.
3+
export async function getDocumentAsync(_options?: unknown): Promise<{
4+
canceled: boolean;
5+
assets: Array<{
6+
uri: string;
7+
name: string;
8+
size: number;
9+
mimeType: string;
10+
}>;
11+
}> {
12+
return { canceled: true, assets: [] };
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Stub for expo-file-system — replaced at runtime by vi.mock() in tests.
2+
// This file exists so Vite's import analysis can resolve the module.
3+
export const EncodingType = {
4+
Base64: "base64",
5+
} as const;
6+
7+
export async function readAsStringAsync(
8+
_uri: string,
9+
_options?: unknown,
10+
): Promise<string> {
11+
return "";
12+
}
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
// packages/react-native/src/__tests__/attachments-integration.test.tsx
2+
import React from "react";
3+
import { render, act } from "@testing-library/react";
4+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
5+
6+
// ─── Mocks ────────────────────────────────────────────────────────────────────
7+
8+
const mockGetDocumentAsync = vi.fn();
9+
const mockReadAsStringAsync = vi.fn();
10+
11+
vi.mock("expo-document-picker", () => ({
12+
getDocumentAsync: (...args: any[]) => mockGetDocumentAsync(...args),
13+
}));
14+
15+
vi.mock("expo-file-system", () => ({
16+
readAsStringAsync: (...args: any[]) => mockReadAsStringAsync(...args),
17+
EncodingType: { Base64: "base64" },
18+
}));
19+
20+
vi.mock("react-native", () => {
21+
const _React = require("react");
22+
const View = ({ children, style, testID }: any) =>
23+
_React.createElement("div", { "data-testid": testID, style }, children);
24+
const Text = ({ children, style, testID }: any) =>
25+
_React.createElement("span", { "data-testid": testID, style }, children);
26+
const Pressable = ({ children, onPress, testID, style }: any) =>
27+
_React.createElement(
28+
"div",
29+
{ "data-testid": testID, onClick: onPress, style },
30+
children,
31+
);
32+
return {
33+
View,
34+
Text,
35+
Pressable,
36+
StyleSheet: { create: (s: any) => s, hairlineWidth: 1 },
37+
Platform: { OS: "ios" },
38+
};
39+
});
40+
41+
const hoisted = vi.hoisted(() => {
42+
const _React = require("react");
43+
return {
44+
RealContext: _React.createContext(null),
45+
MockCoreConstructor: vi.fn(),
46+
};
47+
});
48+
49+
let mockAgent: any;
50+
let unsubscribeMock: ReturnType<typeof vi.fn>;
51+
let mockRunAgent: ReturnType<typeof vi.fn>;
52+
53+
function createMockCore() {
54+
return {
55+
subscribe: vi.fn((_sub: any) => ({ unsubscribe: unsubscribeMock })),
56+
subscribeToAgentWithOptions: vi.fn(() => ({ unsubscribe: vi.fn() })),
57+
setRuntimeUrl: vi.fn(),
58+
setRuntimeTransport: vi.fn(),
59+
setHeaders: vi.fn(),
60+
setCredentials: vi.fn(),
61+
setProperties: vi.fn(),
62+
setDebug: vi.fn(),
63+
setDefaultThrottleMs: vi.fn(),
64+
getAgent: vi.fn(() => undefined),
65+
runAgent: (...args: any[]) => mockRunAgent(...args),
66+
runtimeUrl: "https://api.test",
67+
runtimeTransport: "auto",
68+
runtimeConnectionStatus: "Disconnected",
69+
headers: {},
70+
agents: {},
71+
defaultThrottleMs: undefined,
72+
addTool: vi.fn(),
73+
removeTool: vi.fn(),
74+
getTool: vi.fn(() => undefined),
75+
addHookRenderToolCall: vi.fn(),
76+
registerThreadStore: vi.fn(),
77+
unregisterThreadStore: vi.fn(),
78+
intelligence: undefined,
79+
};
80+
}
81+
82+
let mockCoreInstance: ReturnType<typeof createMockCore>;
83+
84+
vi.mock("@copilotkit/react-core/v2/headless", () => {
85+
function CopilotKitCoreReact(this: any, ...args: any[]) {
86+
hoisted.MockCoreConstructor(...args);
87+
const instance = hoisted.MockCoreConstructor.mock.results.at(-1)?.value;
88+
if (instance) Object.assign(this, instance);
89+
}
90+
return {
91+
CopilotKitCoreReact,
92+
useAgent: () => {
93+
const ctx = require("react").useContext(hoisted.RealContext);
94+
if (!ctx) {
95+
throw new Error("useCopilotKit must be used within CopilotKitProvider");
96+
}
97+
return { agent: mockAgent };
98+
},
99+
useFrontendTool: () => {},
100+
useComponent: () => {},
101+
useHumanInTheLoop: () => {},
102+
useInterrupt: () => {},
103+
useSuggestions: () => {},
104+
useConfigureSuggestions: () => {},
105+
useAgentContext: () => {},
106+
useThreads: () => ({
107+
threads: [],
108+
isLoading: false,
109+
error: null,
110+
hasMoreThreads: false,
111+
isFetchingMoreThreads: false,
112+
fetchMoreThreads: () => {},
113+
renameThread: async () => {},
114+
archiveThread: async () => {},
115+
deleteThread: async () => {},
116+
}),
117+
CopilotChatConfigurationProvider: ({ children }: any) => children,
118+
useCopilotChatConfiguration: () => null,
119+
CopilotChatDefaultLabels: {},
120+
};
121+
});
122+
123+
vi.mock("@copilotkit/react-core/v2/context", () => {
124+
const _React = require("react");
125+
return {
126+
CopilotKitContext: hoisted.RealContext,
127+
LicenseContext: _React.createContext({
128+
status: null,
129+
license: null,
130+
checkFeature: () => true,
131+
getLimit: () => null,
132+
}),
133+
useCopilotKit: () => {
134+
const ctx = _React.useContext(hoisted.RealContext);
135+
if (!ctx) {
136+
throw new Error("useCopilotKit must be used within CopilotKitProvider");
137+
}
138+
return { copilotkit: ctx };
139+
},
140+
useLicenseContext: () => ({
141+
status: null,
142+
license: null,
143+
checkFeature: () => true,
144+
getLimit: () => null,
145+
}),
146+
};
147+
});
148+
149+
vi.mock("@copilotkit/shared", () => ({
150+
DEFAULT_AGENT_ID: "default",
151+
randomUUID: () => "test-uuid-" + Math.random().toString(36).slice(2, 8),
152+
getModalityFromMimeType: (mimeType: string) => {
153+
if (mimeType.startsWith("image/")) return "image";
154+
if (mimeType.startsWith("audio/")) return "audio";
155+
if (mimeType.startsWith("video/")) return "video";
156+
return "document";
157+
},
158+
formatFileSize: (bytes: number) => `${bytes} B`,
159+
createLicenseContextValue: () => ({
160+
status: null,
161+
license: null,
162+
checkFeature: () => true,
163+
getLimit: () => null,
164+
}),
165+
}));
166+
167+
import { CopilotKitProvider } from "../CopilotKitProvider";
168+
import { CopilotChat, useCopilotChatContext } from "../CopilotChat";
169+
import type { CopilotChatContextValue } from "../CopilotChat";
170+
171+
// ─── Tests ────────────────────────────────────────────────────────────────────
172+
173+
describe("Attachments integration: full pick -> attach -> submit flow", () => {
174+
beforeEach(() => {
175+
unsubscribeMock = vi.fn();
176+
mockRunAgent = vi.fn().mockResolvedValue(undefined);
177+
mockAgent = {
178+
addMessage: vi.fn(),
179+
isRunning: false,
180+
messages: [],
181+
threadId: undefined,
182+
};
183+
mockCoreInstance = createMockCore();
184+
hoisted.MockCoreConstructor.mockClear();
185+
hoisted.MockCoreConstructor.mockReturnValue(mockCoreInstance);
186+
vi.clearAllMocks();
187+
});
188+
189+
afterEach(() => {
190+
vi.clearAllMocks();
191+
});
192+
193+
it("pick a file, see it in attachments, submit message with attachment data", async () => {
194+
// Setup: DocumentPicker returns a JPEG file
195+
mockGetDocumentAsync.mockResolvedValue({
196+
canceled: false,
197+
assets: [
198+
{
199+
uri: "file:///cache/photo.jpg",
200+
name: "photo.jpg",
201+
size: 4096,
202+
mimeType: "image/jpeg",
203+
},
204+
],
205+
});
206+
mockReadAsStringAsync.mockResolvedValue("aGVsbG8="); // base64 "hello"
207+
208+
let chatCtx: CopilotChatContextValue | null = null;
209+
210+
function Consumer() {
211+
chatCtx = useCopilotChatContext();
212+
return null;
213+
}
214+
215+
render(
216+
<CopilotKitProvider runtimeUrl="https://api.test">
217+
<CopilotChat agentId="test-agent" attachments={{ enabled: true }}>
218+
<Consumer />
219+
</CopilotChat>
220+
</CopilotKitProvider>,
221+
);
222+
223+
// Verify initial state
224+
expect(chatCtx!.attachments).toEqual([]);
225+
expect(chatCtx!.attachmentsEnabled).toBe(true);
226+
227+
// Step 1: Open picker
228+
await act(async () => {
229+
await chatCtx!.openPicker();
230+
});
231+
232+
// Step 2: Verify attachment appeared
233+
expect(chatCtx!.attachments).toHaveLength(1);
234+
expect(chatCtx!.attachments[0]).toMatchObject({
235+
type: "image",
236+
filename: "photo.jpg",
237+
size: 4096,
238+
status: "ready",
239+
source: {
240+
type: "data",
241+
value: "aGVsbG8=",
242+
mimeType: "image/jpeg",
243+
},
244+
});
245+
246+
// Step 3: Submit message with text + attachment
247+
await act(async () => {
248+
await chatCtx!.submitMessage("Check this photo");
249+
});
250+
251+
// Step 4: Verify addMessage was called with InputContent array
252+
expect(mockAgent.addMessage).toHaveBeenCalledTimes(1);
253+
const call = mockAgent.addMessage.mock.calls[0][0];
254+
expect(call.role).toBe("user");
255+
expect(Array.isArray(call.content)).toBe(true);
256+
expect(call.content).toHaveLength(2);
257+
258+
// Text part
259+
expect(call.content[0]).toMatchObject({
260+
type: "text",
261+
text: "Check this photo",
262+
});
263+
264+
// Image part
265+
expect(call.content[1]).toMatchObject({
266+
type: "image",
267+
source: {
268+
type: "data",
269+
value: "aGVsbG8=",
270+
mimeType: "image/jpeg",
271+
},
272+
metadata: {
273+
filename: "photo.jpg",
274+
},
275+
});
276+
277+
// Step 5: Verify attachments queue is now empty (consumed)
278+
expect(chatCtx!.attachments).toHaveLength(0);
279+
});
280+
281+
it("submit without attachments sends plain text", async () => {
282+
let chatCtx: CopilotChatContextValue | null = null;
283+
284+
function Consumer() {
285+
chatCtx = useCopilotChatContext();
286+
return null;
287+
}
288+
289+
render(
290+
<CopilotKitProvider runtimeUrl="https://api.test">
291+
<CopilotChat agentId="test-agent" attachments={{ enabled: true }}>
292+
<Consumer />
293+
</CopilotChat>
294+
</CopilotKitProvider>,
295+
);
296+
297+
await act(async () => {
298+
await chatCtx!.submitMessage("Just text");
299+
});
300+
301+
expect(mockAgent.addMessage).toHaveBeenCalledWith(
302+
expect.objectContaining({
303+
role: "user",
304+
content: "Just text",
305+
}),
306+
);
307+
});
308+
309+
it("canceled picker does not add attachments", async () => {
310+
mockGetDocumentAsync.mockResolvedValue({
311+
canceled: true,
312+
assets: [],
313+
});
314+
315+
let chatCtx: CopilotChatContextValue | null = null;
316+
317+
function Consumer() {
318+
chatCtx = useCopilotChatContext();
319+
return null;
320+
}
321+
322+
render(
323+
<CopilotKitProvider runtimeUrl="https://api.test">
324+
<CopilotChat agentId="test-agent" attachments={{ enabled: true }}>
325+
<Consumer />
326+
</CopilotChat>
327+
</CopilotKitProvider>,
328+
);
329+
330+
await act(async () => {
331+
await chatCtx!.openPicker();
332+
});
333+
334+
expect(chatCtx!.attachments).toHaveLength(0);
335+
});
336+
});

0 commit comments

Comments
 (0)