///
/** @jest-environment node */
import { FetchResponse } from '../FetchResponse';
globalThis.ReadableStream = require('node:stream/web').ReadableStream;
globalThis.TextDecoder = require('node:util').TextDecoder;
globalThis.TextEncoder = require('node:util').TextEncoder;
jest.mock('../ExpoFetchModule', () => {
const { TextEncoder, TextDecoder } = require('node:util');
const helloWorld = new TextEncoder().encode('hello world');
class StubNativeResponse {
private listeners = new Map void>>();
private _bodyUsed = false;
// Getters on the prototype, like the real native binding, so super.x works.
get _rawHeaders(): [string, string][] {
return [['content-type', 'text/plain']];
}
get status(): number {
return 200;
}
get statusText(): string {
return 'OK';
}
get url(): string {
return 'https://example.test/';
}
get redirected(): boolean {
return false;
}
get bodyUsed(): boolean {
return this._bodyUsed;
}
addListener(event: string, listener: (...args: any[]) => void) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
}
removeListener(event: string, listener: (...args: any[]) => void) {
this.listeners.get(event)?.delete(listener);
}
removeAllListeners(event: string) {
this.listeners.delete(event);
}
emit(event: string, ...args: any[]) {
const listeners = this.listeners.get(event);
if (!listeners) {
return;
}
for (const listener of listeners) {
listener(...args);
}
}
async arrayBuffer(): Promise {
this._bodyUsed = true;
return helloWorld.buffer.slice(
helloWorld.byteOffset,
helloWorld.byteOffset + helloWorld.byteLength
) as ArrayBuffer;
}
async text(): Promise {
this._bodyUsed = true;
return new TextDecoder().decode(helloWorld);
}
async startStreaming(): Promise {
return helloWorld;
}
cancelStreaming() {}
}
class StubNativeRequest {}
return {
ExpoFetchModule: {
NativeRequest: StubNativeRequest,
NativeResponse: StubNativeResponse,
},
};
});
function makeResponse(): FetchResponse {
return new FetchResponse(() => {});
}
describe('FetchResponse', () => {
it('identifies as a standard Response via Symbol.toStringTag', () => {
expect(Object.prototype.toString.call(FetchResponse.prototype)).toBe('[object Response]');
});
it('does not throw when native emits didComplete after the stream was canceled', async () => {
// Repros expo/expo#34804: native can deliver didComplete after the JS
// consumer has already canceled the stream. Before the fix this would
// call controller.close() on an already-closed controller and throw
// "The stream is not in a state that permits close" out of the event
// listener, surfacing as a fatal unhandled error.
const response = makeResponse();
const body = response.body!;
const reader = body.getReader();
await reader.cancel('consumer canceled');
expect(() => (response as any).emit('didComplete')).not.toThrow();
});
it('does not throw when native emits didFailWithError after the stream was canceled', async () => {
const response = makeResponse();
const body = response.body!;
const reader = body.getReader();
await reader.cancel('consumer canceled');
expect(() => (response as any).emit('didFailWithError', 'late error')).not.toThrow();
});
it('does not throw when native emits didComplete twice', async () => {
const response = makeResponse();
const body = response.body!;
const reader = body.getReader();
const readPromise = reader.read();
(response as any).emit('didComplete');
expect(() => (response as any).emit('didComplete')).not.toThrow();
await readPromise;
});
describe('clone()', () => {
it('returns a Response that exposes the same metadata', () => {
const response = makeResponse();
const cloned = response.clone();
expect(cloned.status).toBe(response.status);
expect(cloned.statusText).toBe(response.statusText);
expect(cloned.url).toBe(response.url);
expect(cloned.redirected).toBe(response.redirected);
expect(cloned.ok).toBe(response.ok);
expect(cloned.type).toBe('default');
expect(cloned.headers.get('content-type')).toBe('text/plain');
expect(Object.prototype.toString.call(cloned)).toBe('[object Response]');
});
it('lets the original and the clone read the body independently', async () => {
const response = makeResponse();
const cloned = response.clone();
const [originalBytes, clonedBytes] = await Promise.all([
response.arrayBuffer(),
cloned.arrayBuffer(),
]);
expect(originalBytes.byteLength).toBe(11);
expect(clonedBytes.byteLength).toBe(11);
expect(response.bodyUsed).toBe(true);
expect(cloned.bodyUsed).toBe(true);
});
it('supports cloning the clone', async () => {
const response = makeResponse();
const cloned = response.clone();
const reCloned = cloned.clone();
const bytes = await reCloned.arrayBuffer();
expect(bytes.byteLength).toBe(11);
});
it('does not flip bodyUsed on siblings when a second clone is read', async () => {
const response = makeResponse();
const second = response.clone();
const third = response.clone();
await third.json().catch(() => {});
expect(response.bodyUsed).toBe(false);
expect(second.bodyUsed).toBe(false);
expect(third.bodyUsed).toBe(true);
});
it('lets the original be read after being cloned twice', async () => {
const response = makeResponse();
response.clone();
response.clone();
expect((await response.arrayBuffer()).byteLength).toBe(11);
});
it('throws a TypeError if the body has already been read', async () => {
const response = makeResponse();
await response.arrayBuffer();
expect(() => response.clone()).toThrow(TypeError);
});
it('throws a TypeError if the body stream is locked', () => {
const response = makeResponse();
response.body!.getReader();
expect(() => response.clone()).toThrow(TypeError);
});
it('throws a TypeError if the body has been partially read and released', async () => {
const response = makeResponse();
const reader = response.body!.getReader();
await reader.read();
reader.releaseLock();
expect(() => response.clone()).toThrow(TypeError);
});
it('keeps the original readable after the clone body is cancelled', async () => {
const response = makeResponse();
const cloned = response.clone();
await cloned.body!.cancel();
expect((await response.arrayBuffer()).byteLength).toBe(11);
});
it('keeps the clone readable after the original body is cancelled', async () => {
const response = makeResponse();
const cloned = response.clone();
await response.body!.cancel();
expect((await cloned.arrayBuffer()).byteLength).toBe(11);
});
it('reads the body of a clone via text()', async () => {
const response = makeResponse();
const cloned = response.clone();
expect(await cloned.text()).toBe('hello world');
});
it('reads the body of a clone via blob()', async () => {
const response = makeResponse();
const cloned = response.clone();
const blob = await cloned.blob();
expect(blob.size).toBe(11);
});
it('routes json() through the cloned body', async () => {
const response = makeResponse();
const cloned = response.clone();
await expect(cloned.json()).rejects.toThrow(SyntaxError);
});
it('routes formData() through the cloned body', async () => {
const response = makeResponse();
const cloned = response.clone();
const formData = await cloned.formData();
expect(formData.get('hello world')).toBe('');
});
});
describe('body methods', () => {
it('rejects a second arrayBuffer() call with TypeError', async () => {
const response = makeResponse();
await response.arrayBuffer();
await expect(response.arrayBuffer()).rejects.toThrow(TypeError);
});
it('rejects a second text() call with TypeError', async () => {
const response = makeResponse();
await response.arrayBuffer();
await expect(response.text()).rejects.toThrow(TypeError);
});
it('rejects text() when the body stream is locked', async () => {
const response = makeResponse();
response.body!.getReader();
await expect(response.text()).rejects.toThrow(TypeError);
});
it('rejects arrayBuffer() when the body stream is locked', async () => {
const response = makeResponse();
response.body!.getReader();
await expect(response.arrayBuffer()).rejects.toThrow(TypeError);
});
it('rejects body methods on a clone after its body has been read', async () => {
const response = makeResponse();
const cloned = response.clone();
await cloned.arrayBuffer();
await expect(cloned.text()).rejects.toThrow(TypeError);
});
it('flips bodyUsed on a clone after reading its body stream directly', async () => {
const response = makeResponse();
const cloned = response.clone();
const reader = cloned.body!.getReader();
while (true) {
const { done } = await reader.read();
if (done) break;
}
reader.releaseLock();
expect(cloned.bodyUsed).toBe(true);
});
it('does not flip bodyUsed on the original when only the clone is read', async () => {
const response = makeResponse();
const cloned = response.clone();
await cloned.arrayBuffer();
expect(response.bodyUsed).toBe(false);
});
it('lets both tee() branches read the body and flips bodyUsed', async () => {
const response = makeResponse();
const [branchA, branchB] = response.body!.tee();
const drain = async (stream: ReadableStream>) => {
const reader = stream.getReader();
let length = 0;
while (true) {
const { done, value } = await reader.read();
if (!done) {
length += value.byteLength;
} else {
break;
}
}
return length;
};
const [aLength, bLength] = await Promise.all([drain(branchA), drain(branchB)]);
expect(aLength).toBe(11);
expect(bLength).toBe(11);
expect(response.bodyUsed).toBe(true);
});
});
});