fix: isolate attachment submission state

This commit is contained in:
2026-08-24 01:50:04 +08:00
parent bec93d0918
commit 1ca0249e69
8 changed files with 320 additions and 21 deletions

View File

@@ -3,7 +3,7 @@ import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { handleCodingAttachmentRoutes } from '../../electron/api/routes/coding-attachments';
import { getHostApiToken, startHostApiServer } from '../../electron/api/server';
import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store';
@@ -44,6 +44,37 @@ async function startAttachmentServer(maxBytes = 16 * 1024 * 1024) {
};
}
async function startAttachmentFailureServer() {
const attachments = {
put: vi.fn(async () => {
throw new Error('write failed');
}),
read: vi.fn(async () => {
throw new Error('read failed');
}),
};
const context = { codingProducts: { attachments } } as unknown as HostApiContext;
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
void handleCodingAttachmentRoutes(request, response, url, context).then((handled) => {
if (!handled) {
response.statusCode = 404;
response.end();
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Attachment test server failed');
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => await new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((error) => error ? reject(error) : resolve());
}),
};
}
async function startAuthenticatedHostServer() {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-host-attachments-'));
roots.push(root);
@@ -222,7 +253,33 @@ describe('coding attachment routes', () => {
);
expect(response.status).toBe(413);
expect(response.body).toMatchObject({
code: 'CODING_ATTACHMENT_TOO_LARGE',
code: 'CODING_ATTACHMENT_INVALID',
});
} finally {
await server.close();
}
});
it('uses registered errors for unreadable content and upload storage failures', async () => {
const server = await startAttachmentFailureServer();
try {
const content = await fetch(
`${server.baseUrl}/api/coding/attachments/attachment-1/content`,
);
expect(content.status).toBe(404);
await expect(content.json()).resolves.toMatchObject({
code: 'CODING_ATTACHMENT_NOT_FOUND',
});
const upload = await fetch(`${server.baseUrl}/api/coding/attachments`, {
method: 'POST',
headers: { 'Content-Type': 'image/png' },
body: new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
});
expect(upload.status).toBe(500);
await expect(upload.json()).resolves.toMatchObject({
code: 'CODING_STORAGE_WRITE_FAILED',
error: '本地数据写入失败,请检查存储后重试。',
});
} finally {
await server.close();

View File

@@ -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();

View File

@@ -429,6 +429,55 @@ describe('coding Conversation store', () => {
.toMatchObject({ id: 'node-1', status: 'error' });
});
it('keeps attachment preparation failures out of runtime recovery and permits a direct retry', async () => {
const recover = vi.fn(async () => undefined);
const submitPrompt = vi.fn(async (input) => acceptance(
input.conversationId,
input.clientRequestId,
));
const store = createCodingConversationStore({
getSnapshot: vi.fn(),
openEvents: vi.fn(),
submitPrompt,
recover,
createId: ids(),
});
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
store.getState().setDraft('conversation-a', 'Inspect the image');
await expect(store.getState().submitPrompt({
conversationId: 'conversation-a',
mode: 'prompt',
prepareAttachments: async () => {
throw new AppError('VALIDATION', '图片附件无效。', undefined, {
backendCode: 'CODING_ATTACHMENT_INVALID',
});
},
})).rejects.toThrow('图片附件无效。');
expect(store.getState().entriesByConversationId['conversation-a']).toMatchObject({
loadState: 'live',
error: null,
});
expect(store.getState().draftsByConversationId['conversation-a'].text)
.toBe('Inspect the image');
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
.toMatchObject({ status: 'rejected', errorCode: 'CODING_ATTACHMENT_INVALID' });
expect(submitPrompt).not.toHaveBeenCalled();
expect(recover).not.toHaveBeenCalled();
await store.getState().submitPrompt({
conversationId: 'conversation-a',
mode: 'prompt',
prepareAttachments: async () => [{
attachmentId: 'attachment-valid',
mime: 'image/png',
previewUrl: 'blob:valid',
}],
});
expect(submitPrompt).toHaveBeenCalledOnce();
});
it('keeps an uncertain optimistic request recoverable without replaying it on reconnect', async () => {
const source = new FakeEventSource();
const submitPrompt = vi.fn(async () => {