reactotron-react-native
Version:
A development tool to explore, inspect, and diagnose your React Native apps.
246 lines (204 loc) • 9.09 kB
text/typescript
import { FetchInterceptor } from "./fetch-interceptor"
const EXPO_BUILTIN = Symbol.for("expo.builtin")
function makeExpoFetch(impl: (...args: any[]) => Promise<any>) {
const fn: any = (...args: any[]) => impl(...args)
fn[EXPO_BUILTIN] = true
return fn
}
function makeResponse(status: number, headersObj: Record<string, string>) {
return {
status,
headers: {
get: (k: string) => headersObj[k.toLowerCase()] ?? null,
forEach: (cb: (v: string, k: string) => void) =>
Object.entries(headersObj).forEach(([k, v]) => cb(v, k)),
},
clone() {
return this
},
text: () => Promise.resolve(""),
}
}
describe("FetchInterceptor", () => {
const realFetch = globalThis.fetch
afterEach(() => {
FetchInterceptor.disableInterception()
globalThis.fetch = realFetch
})
it("is a no-op when the global fetch is not expo/fetch", () => {
const plain: any = jest.fn()
globalThis.fetch = plain
FetchInterceptor.enableInterception()
expect(FetchInterceptor.isInterceptorEnabled()).toBe(false)
expect(globalThis.fetch).toBe(plain)
})
it("wraps expo/fetch, fires callbacks, and returns the original response untouched", async () => {
const response = makeResponse(201, { "content-type": "application/json" })
const original = makeExpoFetch(() => Promise.resolve(response))
globalThis.fetch = original
const open = jest.fn()
const onResponse = jest.fn()
FetchInterceptor.setOpenCallback(open)
FetchInterceptor.setResponseCallback(onResponse)
FetchInterceptor.enableInterception()
expect(FetchInterceptor.isInterceptorEnabled()).toBe(true)
expect(globalThis.fetch).not.toBe(original)
const result = await (globalThis.fetch as any)("https://example.com/x?a=1", {
method: "post",
headers: { Authorization: "Bearer t" },
body: "hello",
})
// the caller receives the original, untouched response (non-blocking / stream-safe)
expect(result).toBe(response)
expect(open).toHaveBeenCalledTimes(1)
const [method, url, reqHeaders, data, id] = open.mock.calls[0]
expect(method).toBe("POST")
expect(url).toBe("https://example.com/x?a=1")
expect(reqHeaders).toEqual({ Authorization: "Bearer t" })
expect(data).toBe("hello")
expect(onResponse).toHaveBeenCalledTimes(1)
const [rid, status, respHeaders, passedResponse, error] = onResponse.mock.calls[0]
expect(rid).toBe(id)
expect(status).toBe(201)
expect(respHeaders).toEqual({ "content-type": "application/json" })
expect(passedResponse).toBe(response)
expect(error).toBeNull()
})
it("reports rejections with status -1 and a null response", async () => {
const boom = new Error("offline")
globalThis.fetch = makeExpoFetch(() => Promise.reject(boom))
const onResponse = jest.fn()
FetchInterceptor.setResponseCallback(onResponse)
FetchInterceptor.enableInterception()
await expect((globalThis.fetch as any)("https://x.test")).rejects.toBe(boom)
const [, status, headers, response, error] = onResponse.mock.calls[0]
expect(status).toBe(-1)
expect(headers).toBeNull()
expect(response).toBeNull()
expect(error).toBe(boom)
})
it("does not double-wrap an already-wrapped fetch", () => {
globalThis.fetch = makeExpoFetch(() => Promise.resolve(makeResponse(200, {})))
FetchInterceptor.enableInterception()
const wrapped = globalThis.fetch
FetchInterceptor.enableInterception()
expect(globalThis.fetch).toBe(wrapped)
})
it("wraps an explicitly provided fetch even without the expo symbol", async () => {
const response = makeResponse(200, { "content-type": "application/json" })
// no expo.builtin symbol — e.g. expo-router's re-wrapped global fetch
const routerWrapped: any = jest.fn(() => Promise.resolve(response))
globalThis.fetch = routerWrapped
const open = jest.fn()
FetchInterceptor.setOpenCallback(open)
FetchInterceptor.enableInterception(routerWrapped)
expect(FetchInterceptor.isInterceptorEnabled()).toBe(true)
expect(globalThis.fetch).not.toBe(routerWrapped)
// the wrapper must not pretend to be the expo builtin
expect((globalThis.fetch as any)[EXPO_BUILTIN]).toBeUndefined()
const result = await (globalThis.fetch as any)("https://example.com/y")
expect(result).toBe(response)
expect(routerWrapped).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledTimes(1)
expect(open.mock.calls[0][1]).toBe("https://example.com/y")
})
it("does not wrap an explicitly provided fetch twice", () => {
const fn: any = jest.fn()
globalThis.fetch = fn
FetchInterceptor.enableInterception(fn)
const wrapped = globalThis.fetch
FetchInterceptor.enableInterception(fn)
expect(globalThis.fetch).toBe(wrapped)
})
it("restores the original fetch on disable", () => {
const original = makeExpoFetch(() => Promise.resolve(makeResponse(200, {})))
globalThis.fetch = original
FetchInterceptor.enableInterception()
expect(globalThis.fetch).not.toBe(original)
FetchInterceptor.disableInterception()
expect(globalThis.fetch).toBe(original)
expect(FetchInterceptor.isInterceptorEnabled()).toBe(false)
})
it("does not break the caller's fetch when the open callback throws", async () => {
const response = makeResponse(200, {})
globalThis.fetch = makeExpoFetch(() => Promise.resolve(response))
FetchInterceptor.setOpenCallback(() => {
throw new Error("reactotron bug")
})
FetchInterceptor.enableInterception()
const result = await (globalThis.fetch as any)("https://x.test/?q=%E0%A4%A")
expect(result).toBe(response)
})
it("does not reject a successful response when the response callback throws", async () => {
const response = makeResponse(200, {})
globalThis.fetch = makeExpoFetch(() => Promise.resolve(response))
FetchInterceptor.setResponseCallback(() => {
throw new Error("reactotron bug")
})
FetchInterceptor.enableInterception()
const result = await (globalThis.fetch as any)("https://x.test")
expect(result).toBe(response)
})
it("propagates the app's own network error even when the response callback throws", async () => {
const boom = new Error("offline")
globalThis.fetch = makeExpoFetch(() => Promise.reject(boom))
FetchInterceptor.setResponseCallback(() => {
throw new Error("reactotron bug")
})
FetchInterceptor.enableInterception()
await expect((globalThis.fetch as any)("https://x.test")).rejects.toBe(boom)
})
it("leaves the global alone on disable when a third party wrapped fetch after us", async () => {
const response = makeResponse(200, {})
const original = makeExpoFetch(() => Promise.resolve(response))
globalThis.fetch = original
FetchInterceptor.enableInterception()
const ours = globalThis.fetch
// a third party wraps on top of us
const thirdParty: any = (...args: any[]) => (ours as any)(...args)
globalThis.fetch = thirdParty
FetchInterceptor.disableInterception()
// the third party's wrapper must survive, and ours must pass through inert
expect(globalThis.fetch).toBe(thirdParty)
const open = jest.fn()
FetchInterceptor.setOpenCallback(open)
const result = await (globalThis.fetch as any)("https://x.test")
expect(result).toBe(response)
expect(open).not.toHaveBeenCalled()
})
it("does not install an explicit non-global fetch onto the global on disable", () => {
const globalBefore: any = jest.fn()
globalThis.fetch = globalBefore
const explicit: any = jest.fn(() => Promise.resolve(makeResponse(200, {})))
FetchInterceptor.enableInterception(explicit)
expect(globalThis.fetch).not.toBe(globalBefore)
FetchInterceptor.disableInterception()
// restore what was global when we wrapped — never the explicit function
expect(globalThis.fetch).toBe(globalBefore)
})
it("extracts method and url from Request-object input", async () => {
const response = makeResponse(200, {})
globalThis.fetch = makeExpoFetch(() => Promise.resolve(response))
const open = jest.fn()
FetchInterceptor.setOpenCallback(open)
FetchInterceptor.enableInterception()
const request = new Request("https://example.com/req", { method: "PUT" })
await (globalThis.fetch as any)(request)
expect(open).toHaveBeenCalledTimes(1)
const [method, url] = open.mock.calls[0]
expect(method).toBe("PUT")
expect(url).toBe("https://example.com/req")
})
it("extracts the url from URL-object input", async () => {
const response = makeResponse(200, {})
globalThis.fetch = makeExpoFetch(() => Promise.resolve(response))
const open = jest.fn()
FetchInterceptor.setOpenCallback(open)
FetchInterceptor.enableInterception()
await (globalThis.fetch as any)(new URL("https://example.com/from-url"))
expect(open).toHaveBeenCalledTimes(1)
const [method, url] = open.mock.calls[0]
expect(method).toBe("GET")
expect(url).toBe("https://example.com/from-url")
})
})