mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-24 08:17:47 +00:00
Uploads had no timeout, so a stalled XHR could leave a chip on "uploading" forever, and a queued send waited with it. Network failures also set no errorMessage, so the failed chip showed no reason. guardUploadStall aborts an upload once it goes 120 s without progress. It restarts on every upload progress event and again when the body finishes sending. A large file on a slow link keeps going; only a silent stall is cut off. That is why it is not xhr.timeout, which caps the whole request against a 100 MB file limit. onerror, onabort and ontimeout now mark the chip failed with "Upload failed. The file couldn't be read or the connection dropped." (single and multi-file paths). A failed chip is dropped at send, so a queued question always goes out in the end. The 5-minute processing cap is unchanged.
266 lines
7.7 KiB
TypeScript
266 lines
7.7 KiB
TypeScript
import { configureStore } from '@reduxjs/toolkit';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { Provider } from 'react-redux';
|
|
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({ t: (key: string) => key }),
|
|
}));
|
|
// The upload modal pulls in the whole ingest UI; the composer never opens it here.
|
|
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
|
|
|
import notificationsReducer from '../notifications/notificationsSlice';
|
|
import { prefSlice } from '../preferences/preferenceSlice';
|
|
import type { RootState } from '../store';
|
|
import uploadReducer, {
|
|
addAttachment,
|
|
selectCompletedAttachments,
|
|
type Attachment,
|
|
} from '../upload/uploadSlice';
|
|
import MessageInput from './MessageInput';
|
|
import { UPLOAD_STALL_TIMEOUT_MS } from './message-input/uploadStallGuard';
|
|
|
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
|
|
const makeStore = () =>
|
|
configureStore({
|
|
reducer: {
|
|
preference: prefSlice.reducer,
|
|
upload: uploadReducer,
|
|
notifications: notificationsReducer,
|
|
},
|
|
});
|
|
|
|
type TestStore = ReturnType<typeof makeStore>;
|
|
|
|
const att = (over: Partial<Attachment> = {}): Attachment => ({
|
|
id: 'ok-1',
|
|
fileName: 'notes.pdf',
|
|
progress: 100,
|
|
status: 'completed',
|
|
taskId: 't1',
|
|
...over,
|
|
});
|
|
|
|
/** Just enough of XMLHttpRequest for the composer's upload paths. */
|
|
class FakeXHR extends EventTarget {
|
|
static instances: FakeXHR[] = [];
|
|
|
|
upload = new EventTarget();
|
|
status = 0;
|
|
responseText = '';
|
|
timeout = 0;
|
|
onload: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
onabort: (() => void) | null = null;
|
|
ontimeout: (() => void) | null = null;
|
|
|
|
open() {}
|
|
setRequestHeader() {}
|
|
send() {
|
|
FakeXHR.instances.push(this);
|
|
}
|
|
|
|
private finish(handler: (() => void) | null, type: string) {
|
|
handler?.();
|
|
this.dispatchEvent(new Event(type));
|
|
this.dispatchEvent(new Event('loadend'));
|
|
}
|
|
|
|
/** What Android Chrome reported: no response, status 0. */
|
|
failNetwork() {
|
|
this.status = 0;
|
|
this.finish(this.onerror, 'error');
|
|
}
|
|
|
|
abort() {
|
|
this.finish(this.onabort, 'abort');
|
|
}
|
|
}
|
|
|
|
describe('MessageInput send with a failed attachment', () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let store: TestStore;
|
|
let onSubmit: ReturnType<typeof vi.fn<(text: string) => void>>;
|
|
const realXHR = globalThis.XMLHttpRequest;
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
FakeXHR.instances = [];
|
|
globalThis.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest;
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
store = makeStore();
|
|
onSubmit = vi.fn<(text: string) => void>();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
container.remove();
|
|
globalThis.XMLHttpRequest = realXHR;
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const render = async (loading = false) => {
|
|
await act(async () => {
|
|
root.render(
|
|
<Provider store={store}>
|
|
<MessageInput
|
|
onSubmit={onSubmit}
|
|
loading={loading}
|
|
showSourceButton={false}
|
|
showToolButton={false}
|
|
autoFocus={false}
|
|
/>
|
|
</Provider>,
|
|
);
|
|
});
|
|
};
|
|
|
|
const textarea = () =>
|
|
container.querySelector<HTMLTextAreaElement>('#message-input')!;
|
|
|
|
const type = async (text: string) => {
|
|
const el = textarea();
|
|
await act(async () => {
|
|
const setter = Object.getOwnPropertyDescriptor(
|
|
HTMLTextAreaElement.prototype,
|
|
'value',
|
|
)!.set!;
|
|
setter.call(el, text);
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
};
|
|
|
|
const pressEnter = async () => {
|
|
await act(async () => {
|
|
textarea().dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
|
);
|
|
});
|
|
};
|
|
|
|
const attachFile = async (name: string) => {
|
|
const input = container.querySelector<HTMLInputElement>(
|
|
'label input[type="file"]',
|
|
)!;
|
|
const file = new File(['%PDF-1.4'], name, { type: 'application/pdf' });
|
|
Object.defineProperty(input, 'files', {
|
|
value: [file],
|
|
configurable: true,
|
|
});
|
|
const sent = FakeXHR.instances.length;
|
|
await act(async () => {
|
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
});
|
|
// partitionAttachmentFiles sniffs the file asynchronously.
|
|
await act(async () => {
|
|
await vi.waitFor(() =>
|
|
expect(FakeXHR.instances.length).toBeGreaterThan(sent),
|
|
);
|
|
});
|
|
};
|
|
|
|
it('sends the question and drops the failed file', async () => {
|
|
store.dispatch(addAttachment(att()));
|
|
store.dispatch(
|
|
addAttachment(
|
|
att({ id: 'bad-1', fileName: 'broken.pdf', status: 'failed' }),
|
|
),
|
|
);
|
|
let idsAtSubmit: string[] = [];
|
|
onSubmit.mockImplementation(() => {
|
|
idsAtSubmit = selectCompletedAttachments(
|
|
store.getState() as unknown as RootState,
|
|
).map((a) => a.id);
|
|
});
|
|
await render();
|
|
|
|
await type('What does the contract say?');
|
|
await pressEnter();
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith('What does the contract say?');
|
|
expect(idsAtSubmit).toEqual(['ok-1']);
|
|
expect(store.getState().upload.attachments.map((a) => a.id)).toEqual([
|
|
'ok-1',
|
|
]);
|
|
expect(textarea().value).toBe('');
|
|
});
|
|
|
|
it('sends when the only attachment failed on the network', async () => {
|
|
await render();
|
|
await attachFile('scan.pdf');
|
|
expect(FakeXHR.instances).toHaveLength(1);
|
|
|
|
await act(async () => FakeXHR.instances[0].failNetwork());
|
|
const [chip] = store.getState().upload.attachments;
|
|
expect(chip.status).toBe('failed');
|
|
expect(chip.errorMessage).toBe('conversation.attachments.uploadFailed');
|
|
|
|
await type('Summarise this');
|
|
await pressEnter();
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith('Summarise this');
|
|
expect(store.getState().upload.attachments).toEqual([]);
|
|
});
|
|
|
|
it('shows no send-blocked alert after a failed send attempt', async () => {
|
|
store.dispatch(addAttachment(att({ id: 'bad-1', status: 'failed' })));
|
|
await render();
|
|
|
|
await type('hello');
|
|
await pressEnter();
|
|
|
|
expect(container.querySelector('[role="alert"]')).toBeNull();
|
|
});
|
|
|
|
it('fails a stalled upload and then flushes the queued send', async () => {
|
|
vi.useFakeTimers();
|
|
await render();
|
|
await attachFile('huge.pdf');
|
|
expect(store.getState().upload.attachments[0].status).toBe('uploading');
|
|
|
|
await type('Is it done?');
|
|
await pressEnter();
|
|
// Pending: the send is queued, not dropped.
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(UPLOAD_STALL_TIMEOUT_MS);
|
|
});
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith('Is it done?');
|
|
expect(store.getState().upload.attachments).toEqual([]);
|
|
});
|
|
|
|
it('keeps a queued question while another answer is streaming', async () => {
|
|
store.dispatch(addAttachment(att({ status: 'processing', progress: 30 })));
|
|
await render();
|
|
await type('Queued question');
|
|
await pressEnter();
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
|
|
// A retry starts streaming, then the file settles.
|
|
await render(true);
|
|
await act(async () => {
|
|
store.dispatch({
|
|
type: 'upload/updateAttachment',
|
|
payload: { id: 'ok-1', updates: { status: 'completed' } },
|
|
});
|
|
});
|
|
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
expect(textarea().value).toBe('Queued question');
|
|
|
|
// The stream ends: the held send goes out.
|
|
await render(false);
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith('Queued question');
|
|
});
|
|
});
|