kimi-code/apps/kimi-web/test/attachment-upload.test.ts
qer be7c9916b0
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
fix(web): refill attachments when editing a queued or undone message (#1357)
* fix(web): refill attachments when editing a queued or undone message

Queued prompts that carry images/video are no longer remove-only: clicking one loads its text and attachments back into the composer. Undo ("edit & resend") now restores the message's attachments too, not just its text. useAttachmentUpload gains loadAttachments, which reuses the existing fileIds (no re-upload) and fetches authenticated blob URLs for protected getFileUrl previews so the refilled thumbnails don't 401.

* fix(web): forward attachment refills through ChatDock

When the normal chat dock is mounted (non-empty conversation), bindChatDock receives the ChatDock instance, but ChatDock only exposed loadForEdit/focus — so the new loadAttachmentsForEdit fell back to a no-op and editing a queued media prompt or undoing a media message still dropped the attachments. ChatDock now forwards loadAttachmentsForEdit to the underlying Composer.

* fix(web): replace composer attachments when refilling edits

loadForEdit(text) overwrites the composer text, but loadAttachments appended to any unsent draft attachments, so a later submit sent the stale draft files together with the edited message's files. Make loadAttachments replace the current session's attachments (revoking their object URLs) so an edit/undo replaces the whole composer, mirroring loadForEdit.

* fix(web): clear stale attachments on text-only edits

When the composer already has attachments loaded (for example after editing a queued media prompt) and the user then edits a text-only queued prompt or undoes a text-only message, loadComposerForEdit skipped loadAttachmentsForEdit (the only path that clears the strip) because the new attachment list was empty/undefined, so the next submit would send the stale media with the new text. Always call loadAttachmentsForEdit(attachments ?? []) so text-only edits replace the strip with an empty set.

* fix(web): make fileId-less refilled media resendable

When editing a user turn whose media was base64-inlined by the server (no fileId), loadAttachments used to add a non-uploading chip with no fileId, which handleSubmit silently drops on resend (and an image-only edit would not submit at all). Re-upload the data URL to obtain a fileId so the attachment is actually resendable; when re-upload is unavailable, skip the chip instead of showing a misleading ready attachment. Also factor a patchAttachment helper.

* chore: prefix web changeset entry

The gen-changesets rules require web-app changelog entries to start with 'web: ' so the synced release notes classify web UI changes correctly.

* fix(web): don't dequeue a prompt when the composer is hidden

When a queued media item is clicked while the dock is showing a pending question or approval, ChatDock has no nested Composer (only rendered in its v-else), so loadComposerForEdit no-ops — but handleEditQueued still dequeued the item, losing it. Make ChatDock.loadForEdit report whether the nested composer is present, have loadComposerForEdit return success, and only dequeue when the load actually succeeds.

* fix(web): preserve URL-backed media when refilling composer

When an undone turn contains media with source.kind === 'url' (a URL but no fileId), loadAttachments used to fall through and drop it. Re-upload the URL (data: or http(s):) to obtain a fileId so URL-backed media is preserved on edit/resend; if the URL can't be fetched (CORS / non-2xx) the chip is dropped instead of shown as a misleading ready attachment.
2026-07-04 23:32:32 +08:00

218 lines
9.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { useAttachmentUpload, type Attachment } from '../src/composables/useAttachmentUpload';
// The composable registers its paste listener and cleanup via onMounted /
// onUnmounted. Outside a component (unit test) there is no active instance, so
// Vue would warn; stub the two hooks since these tests don't exercise the
// lifecycle itself.
vi.mock('vue', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue')>();
return { ...actual, onMounted: vi.fn(), onUnmounted: vi.fn() };
});
type UploadImage = (
file: Blob,
name?: string,
) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
function setup(uploadImage?: UploadImage, sessionId: string | null = 'test-session') {
return useAttachmentUpload({ uploadImage: () => uploadImage, sessionId: () => sessionId ?? undefined });
}
function imageFile(name: string): File {
return { name, type: 'image/png' } as unknown as File;
}
function inputEvent(files: File[]): Event {
return { target: { files, value: 'x' } } as unknown as Event;
}
describe('useAttachmentUpload', () => {
let createObjectURL: ReturnType<typeof vi.fn>;
let revokeObjectURL: ReturnType<typeof vi.fn>;
beforeEach(() => {
createObjectURL = vi.fn().mockReturnValue('blob:mock-url');
revokeObjectURL = vi.fn();
(globalThis.URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL;
(globalThis.URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL;
});
afterEach(() => {
vi.restoreAllMocks();
});
it('adds an uploading attachment via the file input', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f1', name: 'a.png', mediaType: 'image/png' });
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
expect(att.attachments.value).toHaveLength(1);
expect(att.attachments.value[0]).toMatchObject({ name: 'a.png', kind: 'image', uploading: true });
expect(createObjectURL).toHaveBeenCalledOnce();
});
it('ignores non-media files', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([{ name: 'a.txt', type: 'text/plain' } as unknown as File]));
expect(att.attachments.value).toHaveLength(0);
});
it('is a no-op when uploadImage is not provided', () => {
const att = setup(undefined);
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
expect(att.attachments.value).toHaveLength(0);
});
it('removeAttachment drops the entry and revokes its object URL', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
const localId = att.attachments.value[0].localId;
att.removeAttachment(localId);
expect(att.attachments.value).toHaveLength(0);
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
it('removeAttachment also closes the preview when it shows the removed entry', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
const added = att.attachments.value[0];
att.openAttachmentPreview(added);
expect(att.previewAttachment.value).not.toBeNull();
att.removeAttachment(added.localId);
expect(att.previewAttachment.value).toBeNull();
});
it('openAttachmentPreview / closeAttachmentPreview toggle the preview', () => {
const att = setup(undefined);
const item: Attachment = { localId: 'x', name: 'a.png', kind: 'image', previewUrl: 'blob:x', uploading: false };
att.openAttachmentPreview(item);
expect(att.previewAttachment.value?.localId).toBe('x');
att.closeAttachmentPreview();
expect(att.previewAttachment.value).toBeNull();
});
it('clearAfterSubmit revokes every object URL and empties the list', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('a.png'), imageFile('b.png')]));
expect(att.attachments.value).toHaveLength(2);
att.clearAfterSubmit();
expect(att.attachments.value).toHaveLength(0);
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
});
it('loadAttachments refills an already-uploaded attachment without re-uploading', () => {
const att = setup(undefined);
att.loadAttachments([
{ fileId: 'f_existing', kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' },
]);
expect(att.attachments.value).toHaveLength(1);
expect(att.attachments.value[0]).toMatchObject({
fileId: 'f_existing',
kind: 'image',
name: 'a.png',
uploading: false,
previewUrl: 'data:image/png;base64,AAAA',
});
});
it('loadAttachments replaces any unsent draft attachments instead of appending', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('draft.png')]));
expect(att.attachments.value).toHaveLength(1);
att.loadAttachments([
{ fileId: 'f_existing', kind: 'image', url: 'data:image/png;base64,AAAA', name: 'refill.png' },
]);
expect(att.attachments.value).toHaveLength(1);
expect(att.attachments.value[0].name).toBe('refill.png');
});
it('loadAttachments with an empty list clears the attachment strip', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
att.handleFileInputChange(inputEvent([imageFile('draft.png')]));
expect(att.attachments.value).toHaveLength(1);
att.loadAttachments([]);
expect(att.attachments.value).toHaveLength(0);
});
it('loadAttachments re-uploads a fileId-less data URL so it becomes resendable', async () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f_new', name: 'a.png', mediaType: 'image/png' });
const att = setup(uploadImage);
const blob = new Blob(['x'], { type: 'image/png' });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }));
att.loadAttachments([{ kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' }]);
expect(att.attachments.value).toHaveLength(1);
expect(att.attachments.value[0].uploading).toBe(true);
// Flush the fetch → blob → upload promise chain so the re-upload resolves.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(att.attachments.value[0].uploading).toBe(false);
expect(att.attachments.value[0].fileId).toBe('f_new');
expect(uploadImage).toHaveBeenCalledOnce();
});
it('loadAttachments skips a fileId-less data URL when re-upload is unavailable', () => {
const att = setup(undefined);
att.loadAttachments([{ kind: 'image', url: 'data:image/png;base64,AAAA', name: 'a.png' }]);
expect(att.attachments.value).toHaveLength(0);
});
it('loadAttachments re-uploads a fileId-less http URL so it becomes resendable', async () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f_http', name: 'x.png', mediaType: 'image/png' });
const att = setup(uploadImage);
const blob = new Blob(['x'], { type: 'image/png' });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) }));
att.loadAttachments([{ kind: 'image', url: 'https://example.test/x.png', name: 'x.png' }]);
expect(att.attachments.value).toHaveLength(1);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(att.attachments.value[0].fileId).toBe('f_http');
});
it('loadAttachments drops a fileId-less URL whose fetch fails', async () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue({ fileId: 'f_x', name: 'x.png', mediaType: 'image/png' });
const att = setup(uploadImage);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 }));
att.loadAttachments([{ kind: 'image', url: 'https://example.test/protected.png', name: 'protected.png' }]);
expect(att.attachments.value).toHaveLength(1);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(att.attachments.value).toHaveLength(0);
});
it('isolates attachments between sessions', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const sessionId = ref<string | undefined>('sess-a');
const att = useAttachmentUpload({ uploadImage: () => uploadImage, sessionId: () => sessionId.value });
att.handleFileInputChange(inputEvent([imageFile('a.png')]));
expect(att.attachments.value).toHaveLength(1);
// Switch to session B — A's attachment must not show up here.
sessionId.value = 'sess-b';
expect(att.attachments.value).toHaveLength(0);
att.handleFileInputChange(inputEvent([imageFile('b.png')]));
expect(att.attachments.value).toHaveLength(1);
// Switch back to A — its attachment is still there.
sessionId.value = 'sess-a';
expect(att.attachments.value).toHaveLength(1);
expect(att.attachments.value[0].name).toBe('a.png');
// B's attachment is gone from A's view.
expect(att.attachments.value.map((a) => a.name)).not.toContain('b.png');
});
});