Skip to content

Commit 398503a

Browse files
committed
Add tests for query-api.ts.
1 parent 9fe74f2 commit 398503a

File tree

1 file changed

+378
-0
lines changed

1 file changed

+378
-0
lines changed

src/test/unit/query-api.test.ts

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import {
6+
queryApiWithFallback,
7+
RegularExternalCommunicationDelegate,
8+
} from 'firefox-profiler/utils/query-api';
9+
import type {
10+
ExternalCommunicationCallbacks,
11+
ExternalCommunicationDelegate,
12+
} from 'firefox-profiler/utils/query-api';
13+
import type { BrowserConnection } from 'firefox-profiler/app-logic/browser-connection';
14+
15+
describe('queryApiWithFallback', function () {
16+
function createMockDelegate(
17+
overrides: Partial<ExternalCommunicationDelegate> = {}
18+
): ExternalCommunicationDelegate {
19+
return {
20+
fetchUrlResponse: jest.fn(
21+
overrides.fetchUrlResponse ??
22+
(async () => {
23+
throw new Error('Not implemented');
24+
})
25+
),
26+
queryBrowserSymbolicationApi: jest.fn(
27+
overrides.queryBrowserSymbolicationApi ??
28+
(async () => {
29+
throw new Error('Not implemented');
30+
})
31+
),
32+
};
33+
}
34+
35+
const uppercasingResponseConverter = (json: any) => {
36+
if (typeof json.data !== 'string') {
37+
throw new Error('Invalid response format');
38+
}
39+
return json.data.toUpperCase();
40+
};
41+
42+
it('returns success when browser API succeeds', async function () {
43+
const delegate = createMockDelegate({
44+
queryBrowserSymbolicationApi: async () =>
45+
JSON.stringify({ data: 'hello' }),
46+
});
47+
48+
const result = await queryApiWithFallback(
49+
'/test/v1',
50+
'{"request": "data"}',
51+
null,
52+
delegate,
53+
uppercasingResponseConverter
54+
);
55+
56+
expect(result).toEqual({
57+
type: 'SUCCESS',
58+
convertedResponse: 'HELLO',
59+
});
60+
expect(delegate.queryBrowserSymbolicationApi).toHaveBeenCalledWith(
61+
'/test/v1',
62+
'{"request": "data"}'
63+
);
64+
expect(delegate.fetchUrlResponse).not.toHaveBeenCalled();
65+
});
66+
67+
it('falls back to symbol server when browser API fails', async function () {
68+
const delegate = createMockDelegate({
69+
queryBrowserSymbolicationApi: async () => {
70+
throw new Error('Browser connection failed');
71+
},
72+
fetchUrlResponse: async () =>
73+
new Response(JSON.stringify({ data: 'world' }), { status: 200 }),
74+
});
75+
76+
const result = await queryApiWithFallback(
77+
'/test/v1',
78+
'{"request": "data"}',
79+
'http://localhost:8000',
80+
delegate,
81+
uppercasingResponseConverter
82+
);
83+
84+
expect(result).toEqual({
85+
type: 'SUCCESS',
86+
convertedResponse: 'WORLD',
87+
});
88+
expect(delegate.queryBrowserSymbolicationApi).toHaveBeenCalled();
89+
expect(delegate.fetchUrlResponse).toHaveBeenCalledWith(
90+
'http://localhost:8000/test/v1',
91+
'{"request": "data"}'
92+
);
93+
});
94+
95+
it('returns error when browser API returns error response', async function () {
96+
const delegate = createMockDelegate({
97+
queryBrowserSymbolicationApi: async () =>
98+
JSON.stringify({ error: 'Something went wrong' }),
99+
});
100+
101+
const result = await queryApiWithFallback(
102+
'/test/v1',
103+
'{"request": "data"}',
104+
null,
105+
delegate,
106+
uppercasingResponseConverter
107+
);
108+
109+
expect(result).toEqual({
110+
type: 'ERROR',
111+
errors: [
112+
{
113+
type: 'BROWSER_API_ERROR',
114+
apiErrorMessage: 'Something went wrong',
115+
},
116+
],
117+
});
118+
});
119+
120+
it('returns error when browser API returns malformed JSON', async function () {
121+
const delegate = createMockDelegate({
122+
queryBrowserSymbolicationApi: async () => 'not valid JSON {',
123+
});
124+
125+
const result = await queryApiWithFallback(
126+
'/test/v1',
127+
'{"request": "data"}',
128+
null,
129+
delegate,
130+
uppercasingResponseConverter
131+
);
132+
133+
expect(result).toEqual({
134+
type: 'ERROR',
135+
errors: [
136+
{
137+
type: 'BROWSER_API_MALFORMED_RESPONSE',
138+
errorMessage: expect.stringMatching(/SyntaxError/),
139+
},
140+
],
141+
});
142+
});
143+
144+
it('returns error when uppercasingResponseConverter throws', async function () {
145+
const delegate = createMockDelegate({
146+
queryBrowserSymbolicationApi: async () =>
147+
JSON.stringify({ wrongField: 'data' }),
148+
});
149+
150+
const result = await queryApiWithFallback(
151+
'/test/v1',
152+
'{"request": "data"}',
153+
null,
154+
delegate,
155+
uppercasingResponseConverter
156+
);
157+
158+
expect(result).toEqual({
159+
type: 'ERROR',
160+
errors: [
161+
{
162+
type: 'BROWSER_API_MALFORMED_RESPONSE',
163+
errorMessage: 'Error: Invalid response format',
164+
},
165+
],
166+
});
167+
});
168+
169+
it('collects errors from both browser and symbol server', async function () {
170+
const delegate = createMockDelegate({
171+
queryBrowserSymbolicationApi: async () => {
172+
throw new Error('Browser error');
173+
},
174+
fetchUrlResponse: async () => {
175+
throw new Error('Network error');
176+
},
177+
});
178+
179+
const result = await queryApiWithFallback(
180+
'/test/v1',
181+
'{"request": "data"}',
182+
'http://localhost:8000',
183+
delegate,
184+
uppercasingResponseConverter
185+
);
186+
187+
expect(result).toEqual({
188+
type: 'ERROR',
189+
errors: [
190+
{
191+
type: 'BROWSER_CONNECTION_ERROR',
192+
browserConnectionErrorMessage: 'Error: Browser error',
193+
},
194+
{
195+
type: 'NETWORK_ERROR',
196+
url: 'http://localhost:8000/test/v1',
197+
networkErrorMessage: 'Error: Network error',
198+
},
199+
],
200+
});
201+
});
202+
203+
it('returns error when symbol server API returns error', async function () {
204+
const delegate = createMockDelegate({
205+
queryBrowserSymbolicationApi: async () => {
206+
throw new Error('No browser');
207+
},
208+
fetchUrlResponse: async () =>
209+
new Response(JSON.stringify({ error: 'Server error' }), {
210+
status: 200,
211+
}),
212+
});
213+
214+
const result = await queryApiWithFallback(
215+
'/test/v1',
216+
'{"request": "data"}',
217+
'http://localhost:8000',
218+
delegate,
219+
uppercasingResponseConverter
220+
);
221+
222+
expect(result).toEqual({
223+
type: 'ERROR',
224+
errors: [
225+
{
226+
type: 'BROWSER_CONNECTION_ERROR',
227+
browserConnectionErrorMessage: 'Error: No browser',
228+
},
229+
{
230+
type: 'SYMBOL_SERVER_API_ERROR',
231+
apiErrorMessage: 'Server error',
232+
},
233+
],
234+
});
235+
});
236+
237+
it('does not query symbol server if URL is null', async function () {
238+
const delegate = createMockDelegate({
239+
queryBrowserSymbolicationApi: async () => {
240+
throw new Error('Browser failed');
241+
},
242+
fetchUrlResponse: jest.fn(),
243+
});
244+
245+
const result = await queryApiWithFallback(
246+
'/test/v1',
247+
'{"request": "data"}',
248+
null,
249+
delegate,
250+
uppercasingResponseConverter
251+
);
252+
253+
expect(result.type).toEqual('ERROR');
254+
expect(delegate.fetchUrlResponse).not.toHaveBeenCalled();
255+
});
256+
});
257+
258+
describe('RegularExternalCommunicationDelegate', function () {
259+
function setup(bcOverrides: Partial<BrowserConnection> | null): {
260+
delegate: RegularExternalCommunicationDelegate;
261+
callbacks: ExternalCommunicationCallbacks;
262+
browserConnection: BrowserConnection | null;
263+
} {
264+
const browserConnection: BrowserConnection | null =
265+
bcOverrides !== null
266+
? {
267+
querySymbolicationApi: jest.fn(bcOverrides.querySymbolicationApi),
268+
getProfile: jest.fn(bcOverrides.getProfile),
269+
getExternalMarkers: jest.fn(bcOverrides.getExternalMarkers),
270+
getExternalPowerTracks: jest.fn(bcOverrides.getExternalPowerTracks),
271+
getSymbolTable: jest.fn(bcOverrides.getSymbolTable),
272+
getPageFavicons: jest.fn(bcOverrides.getPageFavicons),
273+
showFunctionInDevtools: jest.fn(bcOverrides.showFunctionInDevtools),
274+
}
275+
: null;
276+
277+
const callbacks = {
278+
onBeginUrlRequest: jest.fn(),
279+
onBeginBrowserConnectionQuery: jest.fn(),
280+
};
281+
282+
const delegate = new RegularExternalCommunicationDelegate(
283+
browserConnection,
284+
callbacks
285+
);
286+
287+
return { delegate, callbacks, browserConnection };
288+
}
289+
290+
describe('fetchUrlResponse', function () {
291+
it('makes POST request with verbatim post data', async function () {
292+
const mockResponse = 'test response';
293+
const postData = '{"key": "value"}';
294+
295+
window.fetchMock
296+
.catch(404)
297+
.postOnce('https://example.com/api', mockResponse);
298+
299+
const { delegate, callbacks } = setup(null);
300+
301+
const response = await delegate.fetchUrlResponse(
302+
'https://example.com/api',
303+
postData
304+
);
305+
expect(await response.text()).toBe(mockResponse);
306+
expect(callbacks.onBeginUrlRequest).toHaveBeenCalledWith(
307+
'https://example.com/api'
308+
);
309+
// Check that postData was passed as-is to fetch
310+
expect(window.fetchMock.callHistory.lastCall()?.options).toEqual(
311+
expect.objectContaining({
312+
body: postData,
313+
})
314+
);
315+
});
316+
317+
it('throws error for non-200 status codes', async function () {
318+
window.fetchMock.getOnce('https://example.com/api', 404);
319+
320+
const { delegate, callbacks } = setup(null);
321+
await expect(
322+
delegate.fetchUrlResponse('https://example.com/api')
323+
).rejects.toThrow(
324+
'The request to https://example.com/api returned HTTP status 404'
325+
);
326+
expect(callbacks.onBeginUrlRequest).toHaveBeenCalled();
327+
});
328+
329+
it('propagates fetch errors', async function () {
330+
window.fetchMock.getOnce('https://example.com/api', {
331+
throws: new Error('Network failure'),
332+
});
333+
const { delegate, callbacks } = setup(null);
334+
await expect(
335+
delegate.fetchUrlResponse('https://example.com/api')
336+
).rejects.toThrow('Network failure');
337+
338+
expect(callbacks.onBeginUrlRequest).toHaveBeenCalled();
339+
});
340+
});
341+
342+
describe('queryBrowserSymbolicationApi', function () {
343+
it('queries browser connection when available', async function () {
344+
const { delegate, callbacks, browserConnection } = setup({
345+
querySymbolicationApi: () => Promise.resolve('{"result": "success"}'),
346+
});
347+
const result = await delegate.queryBrowserSymbolicationApi(
348+
'/api/v1',
349+
'{"request": "data"}'
350+
);
351+
expect(result).toBe('{"result": "success"}');
352+
expect(callbacks.onBeginBrowserConnectionQuery).toHaveBeenCalled();
353+
expect(browserConnection!.querySymbolicationApi).toHaveBeenCalledWith(
354+
'/api/v1',
355+
'{"request": "data"}'
356+
);
357+
});
358+
359+
it('throws error when no browser connection exists', async function () {
360+
const { delegate, callbacks } = setup(null);
361+
await expect(
362+
delegate.queryBrowserSymbolicationApi('/api/v1', '{"request": "data"}')
363+
).rejects.toThrow('No connection to the browser.');
364+
expect(callbacks.onBeginBrowserConnectionQuery).not.toHaveBeenCalled();
365+
});
366+
367+
it('propagates browser connection errors', async function () {
368+
const { delegate, callbacks } = setup({
369+
querySymbolicationApi: () =>
370+
Promise.reject(new Error('Browser API failed')),
371+
});
372+
await expect(
373+
delegate.queryBrowserSymbolicationApi('/api/v1', '{"request": "data"}')
374+
).rejects.toThrow('Browser API failed');
375+
expect(callbacks.onBeginBrowserConnectionQuery).toHaveBeenCalled();
376+
});
377+
});
378+
});

0 commit comments

Comments
 (0)