fix: isolate attachment submission state
This commit is contained in:
@@ -365,6 +365,162 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(maxActiveUploads).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('locks attachment removal while an upload is still pending', async () => {
|
||||
const upload = deferred<{ attachmentId: string; mime: string; byteLength: number }>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.submit.mockImplementation(async (input: {
|
||||
conversationId: string;
|
||||
clientRequestId: string;
|
||||
mode: 'prompt';
|
||||
}) => ({
|
||||
accepted: true,
|
||||
conversationId: input.conversationId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
runId: 'run-1',
|
||||
mode: input.mode,
|
||||
}));
|
||||
attachmentApi.upload.mockReturnValue(upload.promise);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
render(<CodingChatPanel />);
|
||||
await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
|
||||
const file = new File(['image'], 'held.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(await screen.findByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(attachmentApi.upload).toHaveBeenCalledOnce());
|
||||
|
||||
const remove = screen.getByRole('button', { name: '移除图片 held.png' });
|
||||
expect(remove).toBeDisabled();
|
||||
fireEvent.click(remove);
|
||||
expect(screen.getByAltText('held.png')).toBeInTheDocument();
|
||||
|
||||
upload.resolve({ attachmentId: 'attachment-held', mime: 'image/png', byteLength: 5 });
|
||||
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(screen.queryByAltText('held.png')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('keeps next-message images out of a submission while its 202 response is pending', async () => {
|
||||
const acceptanceFlight = deferred<{
|
||||
accepted: true;
|
||||
conversationId: string;
|
||||
clientRequestId: string;
|
||||
runId: string;
|
||||
mode: 'prompt';
|
||||
}>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.submit.mockReturnValue(acceptanceFlight.promise);
|
||||
attachmentApi.upload.mockImplementation(async (file: File) => ({
|
||||
attachmentId: `attachment-${file.name}`,
|
||||
mime: file.type,
|
||||
byteLength: file.size,
|
||||
}));
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
render(<CodingChatPanel />);
|
||||
const textbox = await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
|
||||
const first = new File(['first'], 'first.png', { type: 'image/png' });
|
||||
const next = new File(['next'], 'next.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
|
||||
target: { files: [first] },
|
||||
});
|
||||
fireEvent.click(await screen.findByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
|
||||
expect(screen.getByTestId('coding-file-attachment-input')).toBeDisabled();
|
||||
|
||||
fireEvent.paste(textbox, { clipboardData: { files: [next] } });
|
||||
expect(screen.queryByAltText('next.png')).not.toBeInTheDocument();
|
||||
expect(attachmentApi.upload).toHaveBeenCalledOnce();
|
||||
|
||||
acceptanceFlight.resolve({
|
||||
accepted: true,
|
||||
conversationId: conversation.id,
|
||||
clientRequestId: 'request-1',
|
||||
runId: 'run-1',
|
||||
mode: 'prompt',
|
||||
});
|
||||
await waitFor(() => expect(screen.getByTestId('coding-file-attachment-input')).toBeEnabled());
|
||||
fireEvent.paste(textbox, { clipboardData: { files: [next] } });
|
||||
expect(await screen.findByAltText('next.png')).toBeInTheDocument();
|
||||
expect(attachmentApi.upload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps attachment validation failures local and reuses successful uploads on retry', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
conversationApi.submit.mockImplementation(async (input: {
|
||||
conversationId: string;
|
||||
clientRequestId: string;
|
||||
mode: 'prompt';
|
||||
}) => ({
|
||||
accepted: true,
|
||||
conversationId: input.conversationId,
|
||||
clientRequestId: input.clientRequestId,
|
||||
runId: 'run-1',
|
||||
mode: input.mode,
|
||||
}));
|
||||
attachmentApi.upload.mockImplementation(async (file: File) => {
|
||||
if (file.name === 'invalid.png') throw new Error('图片附件无效。');
|
||||
return {
|
||||
attachmentId: `attachment-${file.name}`,
|
||||
mime: file.type,
|
||||
byteLength: file.size,
|
||||
};
|
||||
});
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
render(<CodingChatPanel />);
|
||||
await screen.findByRole('textbox');
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||
.toBe(conversation.id));
|
||||
|
||||
const valid = new File(['valid'], 'valid.png', { type: 'image/png' });
|
||||
const invalid = new File(['invalid'], 'invalid.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByTestId('coding-file-attachment-input'), {
|
||||
target: { files: [valid, invalid] },
|
||||
});
|
||||
fireEvent.click(await screen.findByRole('button', { name: '发送' }));
|
||||
|
||||
expect(await screen.findByText('图片附件无效。')).toBeInTheDocument();
|
||||
expect(codingConversationStore.getState().entriesByConversationId[conversation.id]?.error)
|
||||
.toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除图片 invalid.png' }));
|
||||
await waitFor(() => expect(screen.queryByText('图片附件无效。')).not.toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledOnce());
|
||||
expect(attachmentApi.upload.mock.calls.filter(([file]) => file.name === 'valid.png'))
|
||||
.toHaveLength(1);
|
||||
expect(attachmentApi.upload).toHaveBeenCalledTimes(2);
|
||||
expect(conversationApi.recover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the 202 acceptance and preserves Enter versus Shift+Enter behavior', async () => {
|
||||
const { CodingComposer } = await import('@/pages/Chat/CodingComposer');
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user