Skip to content

Commit

Permalink
refactor: XRequest to support custom protocols (#293)
Browse files Browse the repository at this point in the history
* refactor: XRequest to support custom protocols.

* docs: add demo

* test: update snapshots

* chore: update bun.lockb

* test: refactor the test cases for XRequest

* refactor: improve the Content-Type matching

* test: add test for same instance for the same baseURL or fetch

* test: add test for XFetch

* chore: prettier code

* test: add test for XRequest 1.0.2

* docs: refactor ts

* test: mod x-request test file name

* test: refactor test case and supplemental coverage

* test: add test case for TransformStream error
  • Loading branch information
YumoImer authored Dec 4, 2024
1 parent 9ff72b9 commit 7d3d6a1
Show file tree
Hide file tree
Showing 10 changed files with 419 additions and 140 deletions.
Binary file modified bun.lockb
Binary file not shown.
104 changes: 87 additions & 17 deletions components/x-request/__tests__/__snapshots__/demo.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
<div
class="ant-splitter ant-splitter-horizontal"
class="ant-space ant-space-horizontal ant-space-align-start"
style="column-gap:16px;row-gap:16px"
>
<div
class="ant-splitter-panel"
style="flex-basis:auto;flex-grow:1"
class="ant-space-item"
>
<button
class="ant-btn ant-btn-primary ant-btn-color-primary ant-btn-variant-solid"
Expand All @@ -18,23 +18,10 @@ exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
</button>
</div>
<div
aria-valuemax="0"
aria-valuemin="0"
aria-valuenow="50"
class="ant-splitter-bar"
role="separator"
>
<div
class="ant-splitter-bar-dragger"
/>
</div>
<div
class="ant-splitter-panel"
style="flex-basis:auto;flex-grow:1"
class="ant-space-item"
>
<div
class="ant-thought-chain ant-thought-chain-middle"
style="margin-left:16px"
>
<div
class="ant-thought-chain-item"
Expand Down Expand Up @@ -148,3 +135,86 @@ exports[`renders components/x-request/demo/basic.tsx correctly 1`] = `
</div>
</div>
`;

exports[`renders components/x-request/demo/custom-transformer.tsx correctly 1`] = `
<div
class="ant-space ant-space-horizontal ant-space-align-start"
style="column-gap:16px;row-gap:16px"
>
<div
class="ant-space-item"
>
<button
class="ant-btn ant-btn-primary ant-btn-color-primary ant-btn-variant-solid"
type="button"
>
<span>
Request - https://api.example.host/chat
</span>
</button>
</div>
<div
class="ant-space-item"
>
<div
class="ant-thought-chain ant-thought-chain-middle"
>
<div
class="ant-thought-chain-item"
>
<div
class="ant-thought-chain-item-header"
>
<span
class="ant-avatar ant-avatar-circle ant-avatar-icon ant-thought-chain-item-icon"
>
<span
aria-label="tags"
class="anticon anticon-tags"
role="img"
>
<svg
aria-hidden="true"
data-icon="tags"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M483.2 790.3L861.4 412c1.7-1.7 2.5-4 2.3-6.3l-25.5-301.4c-.7-7.8-6.8-13.9-14.6-14.6L522.2 64.3c-2.3-.2-4.7.6-6.3 2.3L137.7 444.8a8.03 8.03 0 000 11.3l334.2 334.2c3.1 3.2 8.2 3.2 11.3 0zm62.6-651.7l224.6 19 19 224.6L477.5 694 233.9 450.5l311.9-311.9zm60.16 186.23a48 48 0 1067.88-67.89 48 48 0 10-67.88 67.89zM889.7 539.8l-39.6-39.5a8.03 8.03 0 00-11.3 0l-362 361.3-237.6-237a8.03 8.03 0 00-11.3 0l-39.6 39.5a8.03 8.03 0 000 11.3l243.2 242.8 39.6 39.5c3.1 3.1 8.2 3.1 11.3 0l407.3-406.6c3.1-3.1 3.1-8.2 0-11.3z"
/>
</svg>
</span>
</span>
<div
class="ant-thought-chain-item-header-box"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-ellipsis-single-line ant-thought-chain-item-title"
>
<strong>
Mock Custom Protocol - Log
</strong>
</span>
</div>
</div>
<div
class="ant-thought-chain-item-content"
>
<div
class="ant-thought-chain-item-content-box"
>
<pre
style="overflow:scroll"
>
<code />
</pre>
</div>
</div>
</div>
</div>
</div>
</div>
`;
177 changes: 111 additions & 66 deletions components/x-request/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
import XRequest from '../index';
import xFetch from '../x-fetch';

import type { SSEOutput } from '../../x-stream';
import type { XRequestCallbacks, XRequestOptions } from '../index';

jest.mock('../x-fetch', () => jest.fn());

const sseChunks = ['event:message\ndata:{"id":"0","content":"He"}\n\n'];
const SSE_SEPARATOR = '\n\n';

const ND_JSON_SEPARATOR = '\n';

const sseEvent: SSEOutput = { event: 'message', data: '{"id":"0","content":"He"}' };

const sseData = `${Object.keys(sseEvent)
.map((key) => `${key}:${sseEvent[key as keyof SSEOutput]}`)
.join(ND_JSON_SEPARATOR)}${SSE_SEPARATOR}`;

const ndJsonData = `${JSON.stringify(sseEvent)}${ND_JSON_SEPARATOR}${JSON.stringify({ ...sseEvent, event: 'delta' })}`;

const options: XRequestOptions = {
baseURL: 'https://api.example.com/v1/chat',
model: 'gpt-3.5-turbo',
dangerouslyApiKey: 'dangerouslyApiKey',
};

function mockReadableStream() {
const params = { messages: [{ role: 'user', content: 'Hello' }] };

function mockSSEReadableStream() {
return new ReadableStream({
async start(controller) {
for (const chunk of sseChunks) {
for (const chunk of sseData.split(SSE_SEPARATOR)) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
}

function mockNdJsonReadableStream() {
return new ReadableStream({
async start(controller) {
for (const chunk of ndJsonData.split(ND_JSON_SEPARATOR)) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
Expand All @@ -17,109 +49,122 @@ function mockReadableStream() {
}

describe('XRequest Class', () => {
const baseURL = 'https://api.example.com/v1/chat';
const model = 'gpt-3.5-turbo';
const callbacks: XRequestCallbacks<any> = {
onSuccess: jest.fn(),
onError: jest.fn(),
onUpdate: jest.fn(),
};

const mockedXFetch = xFetch as jest.Mock;

let request: ReturnType<typeof XRequest>;

beforeEach(() => {
jest.clearAllMocks();
request = XRequest(options);
});

test('should initialize with valid options', () => {
const request = XRequest({ baseURL, model });

expect(request.baseURL).toBe(baseURL);
expect(request.model).toBe(model);
expect(request.baseURL).toBe(options.baseURL);
expect(request.model).toBe(options.model);
});

test('should throw error on invalid baseURL', () => {
expect(() => XRequest({ baseURL: '', model })).toThrow('The baseURL is not valid!');
expect(() => XRequest({ baseURL: '' })).toThrow('The baseURL is not valid!');
});

test('should create request and handle successful JSON response', async () => {
const onSuccess = jest.fn();
const onError = jest.fn();
const onUpdate = jest.fn();

const params = { messages: [{ role: 'user', content: 'Hello' }] };

const mockedXFetch = xFetch as jest.Mock;

mockedXFetch.mockResolvedValueOnce({
ok: true,
status: 200,
headers: {
get: jest.fn().mockReturnValue('application/json'),
get: jest.fn().mockReturnValue('application/json; charset=utf-8'),
},
json: jest.fn().mockResolvedValueOnce({ response: 'Hi there!' }),
json: jest.fn().mockResolvedValueOnce(params),
});

const request = XRequest({ baseURL, model });
await request.create(params, { onSuccess, onError, onUpdate });

expect(onSuccess).toHaveBeenCalledWith([{ response: 'Hi there!' }]);
expect(onError).not.toHaveBeenCalled();
expect(onUpdate).toHaveBeenCalled();
await request.create(params, callbacks);
expect(callbacks.onSuccess).toHaveBeenCalledWith([params]);
expect(callbacks.onError).not.toHaveBeenCalled();
expect(callbacks.onUpdate).toHaveBeenCalledWith(params);
});

test('should create request and handle streaming response', async () => {
const onSuccess = jest.fn();
const onError = jest.fn();
const onUpdate = jest.fn();
const params = { messages: [{ role: 'user', content: 'Hello' }] };

const mockedXFetch = xFetch as jest.Mock;
mockedXFetch.mockResolvedValueOnce({
headers: {
get: jest.fn().mockReturnValue('text/event-stream'),
},
body: mockReadableStream(),
body: mockSSEReadableStream(),
});
await request.create(params, callbacks);
expect(callbacks.onSuccess).toHaveBeenCalledWith([sseEvent]);
expect(callbacks.onError).not.toHaveBeenCalled();
expect(callbacks.onUpdate).toHaveBeenCalledWith(sseEvent);
});

const request = XRequest({ baseURL, model });
await request.create(params, { onSuccess, onError, onUpdate });

const sseEvent = { event: 'message', data: '{"id":"0","content":"He"}' };
test('should create request and handle custom response, e.g. application/x-ndjson', async () => {
mockedXFetch.mockResolvedValueOnce({
headers: {
get: jest.fn().mockReturnValue('application/x-ndjson'),
},
body: mockNdJsonReadableStream(),
});
await request.create(params, callbacks, new TransformStream());
expect(callbacks.onSuccess).toHaveBeenCalledWith([
ndJsonData.split(ND_JSON_SEPARATOR)[0],
ndJsonData.split(ND_JSON_SEPARATOR)[1],
]);
expect(callbacks.onError).not.toHaveBeenCalled();
expect(callbacks.onUpdate).toHaveBeenCalledWith(ndJsonData.split(ND_JSON_SEPARATOR)[0]);
expect(callbacks.onUpdate).toHaveBeenCalledWith(ndJsonData.split(ND_JSON_SEPARATOR)[1]);
});

expect(onSuccess).toHaveBeenCalledWith([sseEvent]);
expect(onError).not.toHaveBeenCalled();
expect(onUpdate).toHaveBeenCalledWith(sseEvent);
test('should reuse the same instance for the same baseURL or fetch', () => {
const request1 = XRequest(options);
const request2 = XRequest(options);
expect(request1).toBe(request2);
const request3 = XRequest({ fetch: mockedXFetch, baseURL: options.baseURL });
const request4 = XRequest({ fetch: mockedXFetch, baseURL: options.baseURL });
expect(request3).toBe(request4);
});

test('should handle error response', async () => {
const onSuccess = jest.fn();
const onError = jest.fn();
const onUpdate = jest.fn();
const params = { messages: [{ role: 'user', content: 'Hello' }] };

const mockedXFetch = xFetch as jest.Mock;
mockedXFetch.mockRejectedValueOnce(new Error('Fetch failed'));

const request = XRequest({ baseURL, model });
await request.create(params, { onSuccess, onError, onUpdate }).catch(() => {});

expect(onSuccess).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(new Error('Fetch failed'));
await request.create(params, callbacks).catch(() => {});
expect(callbacks.onSuccess).not.toHaveBeenCalled();
expect(callbacks.onError).toHaveBeenCalledWith(new Error('Fetch failed'));
});

test('should throw error for unsupported content type', async () => {
const onSuccess = jest.fn();
const onError = jest.fn();
const onUpdate = jest.fn();
const params = { messages: [{ role: 'user', content: 'Hello' }] };

const mockedXFetch = xFetch as jest.Mock;
const contentType = 'text/plain';
mockedXFetch.mockResolvedValueOnce({
headers: {
get: jest.fn().mockReturnValue('text/plain'),
get: jest.fn().mockReturnValue(contentType),
},
});
await request.create(params, callbacks).catch(() => {});
expect(callbacks.onSuccess).not.toHaveBeenCalled();
expect(callbacks.onError).toHaveBeenCalledWith(
new Error(`The response content-type: ${contentType} is not support!`),
);
});

const request = XRequest({ baseURL, model });
await request.create(params, { onSuccess, onError, onUpdate }).catch(() => {});
test('should handle TransformStream errors', async () => {
const errorTransform = new TransformStream({
transform() {
throw new Error('Transform error');
},
});

expect(onSuccess).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalledWith(
new Error('The response content-type: text/plain is not support!'),
);
mockedXFetch.mockResolvedValueOnce({
headers: {
get: jest.fn().mockReturnValue('application/x-ndjson'),
},
body: mockNdJsonReadableStream(),
});

await request.create(params, callbacks, errorTransform).catch(() => {});
expect(callbacks.onError).toHaveBeenCalledWith(new Error('Transform error'));
expect(callbacks.onSuccess).not.toHaveBeenCalled();
expect(callbacks.onUpdate).not.toHaveBeenCalled();
});
});
6 changes: 6 additions & 0 deletions components/x-request/__tests__/x-fetch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ describe('xFetch', () => {
expect(await response.text()).toBe('{"data": "modified"}');
});

it('should throw an error while options.onResponse not return a Response instance', async () => {
await expect(
xFetch(baseURL, { middlewares: { onResponse: () => new Date() as any } }),
).rejects.toThrow('The options.onResponse must return a Response instance!');
});

it('should throw an error on non-200 status', async () => {
(global.fetch as jest.Mock).mockResolvedValue(new Response(null, { status: 404 }));

Expand Down
Loading

0 comments on commit 7d3d6a1

Please sign in to comment.