import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { composerAttachmentUploadBlockReason, composerAttachmentUploadKey, composerDraftEnvironmentId, createComposerAttachmentUploadQueue, type ComposerAttachmentUploadRequest, type ComposerAttachmentUploadState, } from "./composerAttachmentUploadQueue"; const environmentId = EnvironmentId.make("environment-1"); function request(id: string, environment = environmentId): ComposerAttachmentUploadRequest { return { environmentId: environment, attachment: { id, type: "file", name: `${id}.pdf`, mimeType: "application/pdf", sizeBytes: 42, fileUri: `file:///documents/${id}.pdf`, }, }; } describe("composer attachment upload queue", () => { it("bounds concurrency, deduplicates updates, and drains all attachments", async () => { const gates = new Map>>(); const fourthStarted = Promise.withResolvers(); const firstThreeStarted = Promise.withResolvers(); let active = 0; let maximum = 0; const upload = vi.fn(async (input: ComposerAttachmentUploadRequest) => { active += 1; maximum = Math.max(maximum, active); const gate = Promise.withResolvers(); gates.set(input.attachment.id, gate); if (gates.size === 3) firstThreeStarted.resolve(); if (gates.size === 4) fourthStarted.resolve(); try { return await gate.promise; } finally { active -= 1; } }); const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); const requests = [request("one"), request("two"), request("three"), request("four")]; queue.sync(requests); queue.sync(requests); await firstThreeStarted.promise; expect(upload).toHaveBeenCalledTimes(3); gates.get("one")!.resolve(true); await fourthStarted.promise; for (const gate of gates.values()) gate.resolve(true); await queue.settled(); queue.sync(requests); await queue.settled(); expect(maximum).toBe(3); expect(upload).toHaveBeenCalledTimes(4); queue.dispose(); }); it("cancels on disconnect and resumes from the same local draft on reconnect", async () => { const started = Promise.withResolvers(); let states: Readonly> = {}; let signal: AbortSignal | undefined; const upload = vi.fn( async (_request: ComposerAttachmentUploadRequest, currentSignal: AbortSignal) => { signal = currentSignal; started.resolve(); return new Promise((resolve) => currentSignal.addEventListener("abort", () => resolve(false), { once: true }), ); }, ); const queue = createComposerAttachmentUploadQueue({ upload, onChange: (next) => { states = next; }, }); const local = request("offline-draft"); queue.sync([local]); await started.promise; queue.sync([]); await queue.settled(); expect(signal?.aborted).toBe(true); expect(states).toEqual({}); upload.mockResolvedValueOnce(true); queue.sync([local]); await queue.settled(); expect(upload.mock.calls[1]?.[0]).toBe(local); expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ status: "ready", }); expect(local.attachment).toMatchObject({ fileUri: "file:///documents/offline-draft.pdf" }); queue.dispose(); }); it("ignores a late completion after removal or environment switch", async () => { const gate = Promise.withResolvers(); const started = Promise.withResolvers(); let states: Readonly> = {}; const upload = vi.fn(async () => { started.resolve(); return gate.promise; }); const queue = createComposerAttachmentUploadQueue({ upload, onChange: (next) => { states = next; }, }); queue.sync([request("photo")]); await started.promise; upload.mockResolvedValueOnce(true); const other = EnvironmentId.make("environment-2"); queue.sync([request("photo", other)]); gate.resolve(true); await queue.settled(); expect(states).toEqual({ [composerAttachmentUploadKey(other, "photo")]: { status: "ready" } }); queue.sync([]); expect(states).toEqual({}); queue.dispose(); }); it("restarts a re-added attachment after its aborted transfer finishes settling", async () => { const firstStarted = Promise.withResolvers(); const firstSettled = Promise.withResolvers(); const secondStarted = Promise.withResolvers(); const secondSettled = Promise.withResolvers(); let states: Readonly> = {}; let firstSignal: AbortSignal | undefined; const upload = vi.fn(async (_request: ComposerAttachmentUploadRequest, signal: AbortSignal) => { if (!firstSignal) { firstSignal = signal; firstStarted.resolve(); return firstSettled.promise; } secondStarted.resolve(); return secondSettled.promise; }); const queue = createComposerAttachmentUploadQueue({ upload, onChange: (next) => { states = next; }, }); const local = request("re-added"); queue.sync([local]); await firstStarted.promise; queue.sync([]); queue.sync([local]); expect(firstSignal?.aborted).toBe(true); expect(upload).toHaveBeenCalledOnce(); firstSettled.resolve(false); await secondStarted.promise; expect(upload).toHaveBeenCalledTimes(2); secondSettled.resolve(true); await queue.settled(); expect(states[composerAttachmentUploadKey(environmentId, local.attachment.id)]).toEqual({ status: "ready", }); queue.dispose(); }); it("keeps failures stable until retry and reports bounded progress", async () => { let states: Readonly> = {}; const progress: number[] = []; const upload = vi.fn( async ( _request: ComposerAttachmentUploadRequest, _signal: AbortSignal, report: (value: number) => void, ): Promise => { report(0.12); report(0.13); report(1.1); throw new Error("Server unavailable"); }, ); const queue = createComposerAttachmentUploadQueue({ upload, onChange: (next) => { states = next; const state = next[composerAttachmentUploadKey(environmentId, "file")]; if (state?.status === "uploading") progress.push(state.progress); }, }); queue.sync([request("file")]); await queue.settled(); queue.sync([request("file")]); expect(upload).toHaveBeenCalledOnce(); expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "failed", reason: "Server unavailable", }); expect(progress).toEqual([0, 0.1, 1]); upload.mockImplementationOnce(async () => true); queue.retry(environmentId, "file"); await queue.settled(); expect(states[composerAttachmentUploadKey(environmentId, "file")]).toEqual({ status: "ready" }); queue.dispose(); }); it("does not spin when an upload's draft was abandoned before persistence", async () => { const upload = vi.fn(async () => false); const queue = createComposerAttachmentUploadQueue({ upload, onChange: () => {} }); queue.sync([request("discarded")]); await queue.settled(); expect(upload).toHaveBeenCalledOnce(); queue.dispose(); }); }); describe("draft upload scope and offline submission", () => { it("resolves thread, new-task, and queued-task drafts without crossing environments", () => { expect(composerDraftEnvironmentId("environment-1:thread", [])).toBe(environmentId); expect(composerDraftEnvironmentId("new-task:environment-1:project", [])).toBe(environmentId); expect( composerDraftEnvironmentId("pending-task:message", [{ messageId: "message", environmentId }]), ).toBe(environmentId); expect(composerDraftEnvironmentId("pending-task:missing", [])).toBeNull(); const colonEnvironment = EnvironmentId.make("a:vcs-status:b"); expect(composerDraftEnvironmentId(`${colonEnvironment}:thread`, [])).toBe(colonEnvironment); expect(composerDraftEnvironmentId(`new-task:${colonEnvironment}:project`, [])).toBe( colonEnvironment, ); }); it("allows offline queuing while a connected composer waits for upload or retry", () => { const key = composerAttachmentUploadKey(environmentId, "file"); const input = { environmentId, attachments: [request("file").attachment], connected: true, serverConfig: { environment: { capabilities: { attachmentUploads: true, fileAttachments: { maxUploadBytes: 1024 } }, }, }, states: {}, }; expect(composerAttachmentUploadBlockReason(input)).toBe("Attachment still uploading"); expect(composerAttachmentUploadBlockReason({ ...input, connected: false })).toBeNull(); expect( composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "failed", reason: "Offline" } }, }), ).toBe("Retry or remove the failed attachment"); expect( composerAttachmentUploadBlockReason({ ...input, states: { [key]: { status: "ready" } } }), ).toBeNull(); }); });