n8n/packages/frontend/@n8n/rest-api-client/src/utils.test.ts
Eugene a7b910f365
fix(editor): Show AI assistant error when stream ends with unparseable content (#32068)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:44:27 +00:00

288 lines
7.4 KiB
TypeScript

import { ResponseError, STREAM_SEPARATOR, streamRequest } from './utils';
describe('streamRequest', () => {
it('should stream data from the API endpoint', async () => {
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPARATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 2 })}${STREAM_SEPARATOR}`));
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 3 })}${STREAM_SEPARATOR}`));
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'browser-id': expect.stringContaining('-'),
},
});
expect(onChunkMock).toHaveBeenCalledTimes(3);
expect(onChunkMock).toHaveBeenNthCalledWith(1, { chunk: 1 });
expect(onChunkMock).toHaveBeenNthCalledWith(2, { chunk: 2 });
expect(onChunkMock).toHaveBeenNthCalledWith(3, { chunk: 3 });
expect(onDoneMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).not.toHaveBeenCalled();
});
it('should stream error response with error data from the API endpoint', async () => {
const testError = { code: 500, message: 'Error happened' };
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(JSON.stringify(testError)));
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'browser-id': expect.stringContaining('-'),
},
});
expect(onChunkMock).not.toHaveBeenCalled();
expect(onDoneMock).not.toHaveBeenCalled();
expect(onErrorMock).toHaveBeenCalledExactlyOnceWith(
new ResponseError(testError.message, { httpStatusCode: 500 }),
);
});
it('should call onError when stream ends immediately with non-ok status and no chunks', async () => {
const mockResponse = new ReadableStream({
start(controller) {
// Empty stream that just closes without sending any chunks
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(onChunkMock).not.toHaveBeenCalled();
expect(onDoneMock).not.toHaveBeenCalled();
expect(onErrorMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledExactlyOnceWith(
new ResponseError('Forbidden', { httpStatusCode: 403 }),
);
});
it('should call onError when an ok stream ends with unparseable leftover content', async () => {
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPARATOR}`));
// Plain-text error body appended to an already-started 200 stream
controller.enqueue(encoder.encode('Something went wrong. Please try again later.'));
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(onChunkMock).toHaveBeenCalledExactlyOnceWith({ chunk: 1 });
expect(onDoneMock).not.toHaveBeenCalled();
expect(onErrorMock).toHaveBeenCalledExactlyOnceWith(
new Error('Something went wrong. Please try again later.'),
);
});
it('should call onError with a generic message when the stream is cut off mid-JSON', async () => {
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPARATOR}`));
// Stream ends abruptly in the middle of a JSON chunk
controller.enqueue(encoder.encode('{"sessionId":"abc","messages":[{"role":"assi'));
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(onChunkMock).toHaveBeenCalledExactlyOnceWith({ chunk: 1 });
expect(onDoneMock).not.toHaveBeenCalled();
expect(onErrorMock).toHaveBeenCalledExactlyOnceWith(new Error('Connection lost'));
});
it('should handle broken stream data', async () => {
const encoder = new TextEncoder();
const mockResponse = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(`${JSON.stringify({ chunk: 1 })}${STREAM_SEPARATOR}{"chunk": `),
);
controller.enqueue(encoder.encode(`2}${STREAM_SEPARATOR}{"ch`));
controller.enqueue(encoder.encode('unk":'));
controller.enqueue(encoder.encode(`3}${STREAM_SEPARATOR}`));
controller.close();
},
});
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: mockResponse,
});
global.fetch = mockFetch;
const onChunkMock = vi.fn();
const onDoneMock = vi.fn();
const onErrorMock = vi.fn();
await streamRequest(
{
baseUrl: 'https://api.example.com',
pushRef: '',
},
'/data',
{ key: 'value' },
onChunkMock,
onDoneMock,
onErrorMock,
);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'browser-id': expect.stringContaining('-'),
},
});
expect(onChunkMock).toHaveBeenCalledTimes(3);
expect(onChunkMock).toHaveBeenNthCalledWith(1, { chunk: 1 });
expect(onChunkMock).toHaveBeenNthCalledWith(2, { chunk: 2 });
expect(onChunkMock).toHaveBeenNthCalledWith(3, { chunk: 3 });
expect(onDoneMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).not.toHaveBeenCalled();
});
});