1403 lines · 46.7 KB · TypeScript
RawDownload
import { afterEach, describe, expect, it } from "@effect/vitest";import {  CommandId,  EnvironmentId,  MessageId,  ProviderInstanceId,  ThreadId,} from "@t3tools/contracts";import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => {  let document = "";  let writeError: Error | null = null;  let releaseRead: (() => void) | null = null;  let readBarrier = Promise.resolve();  let nextWriteBarrier: Promise<void> | null = null;  let onWrite: (() => void) | null = null;  const writes: string[] = [];   return {    blockRead() {      readBarrier = new Promise<void>((resolve) => {        releaseRead = resolve;      });    },    releaseRead() {      releaseRead?.();      releaseRead = null;    },    getDocument() {      return document;    },    setDocument(value: unknown) {      document = JSON.stringify(value);    },    setWriteError(error: Error | null) {      writeError = error;    },    setNextWriteBarrier(barrier: Promise<void> | null) {      nextWriteBarrier = barrier;    },    setOnWrite(callback: (() => void) | null) {      onWrite = callback;    },    getWrites(): ReadonlyArray<string> {      return writes;    },    resetWrites() {      writes.length = 0;    },    Directory: class {      create() {}    },    File: class {      exists = true;      parentDirectory = null;       create() {}       moveSync() {}       async text() {        await readBarrier;        return document;      }       write(value: string) {        if (writeError) {          throw writeError;        }        if (nextWriteBarrier) {          const barrier = nextWriteBarrier;          nextWriteBarrier = null;          return barrier.then(() => {            document = value;            writes.push(value);            onWrite?.();          });        }        document = value;        writes.push(value);        onWrite?.();      }    },  };}); const composerAttachmentCleanupMocks = vi.hoisted(() => ({  remove: vi.fn(async () => undefined),  releaseUploads: vi.fn(async () => undefined),})); const incomingShareStorageMocks = vi.hoisted(() => ({  load: vi.fn<typeof import("../features/sharing/incoming-share-storage").loadIncomingShareDrafts>(    async () => [],  ),})); vi.mock("expo-file-system", () => ({  Directory: composerDraftFileMocks.Directory,  File: composerDraftFileMocks.File,  Paths: { document: "/documents" },})); vi.mock("../lib/composerImages", () => ({  removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,})); vi.mock("../lib/attachmentUpload", () => ({  releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,})); vi.mock("../features/sharing/incoming-share-storage", () => ({  loadIncomingShareDrafts: incomingShareStorageMocks.load,})); import { appAtomRegistry } from "./atom-registry";import { threadOutboxManager } from "./thread-outbox";import {  appendComposerDraftAttachments,  clearComposerDraftContentState,  clearComposerDraftsEnvironment,  ComposerDraftPersistenceError,  composerDraftsAtom,  copyComposerDraftContentIfEmpty,  copyComposerDraftContentState,  decodePersistedComposerState,  decodePersistedComposerDrafts,  ensureComposerDraftsLoaded,  type ComposerDraft,  flushComposerDrafts,  getComposerDraftSnapshot,  mergeComposerDraftContentState,  releaseUnusedComposerAttachmentFiles,  removeComposerDraftsForEnvironment,  resetComposerDraftsLoadState,  retainComposerAttachmentFileForPreview,  restoreComposerDraftSnapshotState,  setComposerDraftText,  setStickyComposerModelSelection,  stickyComposerModelSelectionAtom,  undoComposerDraftMerge,  undoComposerDraftMergeState,} from "./use-composer-drafts"; const DRAFT: ComposerDraft = {  text: "hello",  attachments: [],}; afterEach(() => {  vi.useRealTimers();  resetComposerDraftsLoadState();  composerDraftFileMocks.setDocument("");  composerDraftFileMocks.setWriteError(null);  composerDraftFileMocks.setNextWriteBarrier(null);  composerDraftFileMocks.setOnWrite(null);  composerDraftFileMocks.resetWrites();  appAtomRegistry.set(composerDraftsAtom, {});  appAtomRegistry.set(stickyComposerModelSelectionAtom, null);  appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});  composerAttachmentCleanupMocks.remove.mockClear();  composerAttachmentCleanupMocks.releaseUploads.mockReset();  composerAttachmentCleanupMocks.releaseUploads.mockResolvedValue(undefined);  incomingShareStorageMocks.load.mockReset();  incomingShareStorageMocks.load.mockResolvedValue([]);}); describe("mobile composer drafts", () => {  // Hydration is one-shot per module instance and the attachment sweep now  // triggers it too, so this test must observe it before any sweep test runs.  it("waits for persisted drafts before copying content between projects", async () => {    const sourceKey = "new-task:environment-1:project-1";    const targetKey = "new-task:environment-1:project-2";    const unrelatedKey = "environment-1:thread-1";    const source = { text: "Current task", attachments: [] } satisfies ComposerDraft;    const target = { text: "Persisted target", attachments: [] } satisfies ComposerDraft;    const unrelated = { text: "Keep me", attachments: [] } satisfies ComposerDraft;     composerDraftFileMocks.setDocument({      schemaVersion: 1,      drafts: {        [targetKey]: target,        [unrelatedKey]: unrelated,      },    });    composerDraftFileMocks.blockRead();    appAtomRegistry.set(composerDraftsAtom, { [sourceKey]: source });     const copy = copyComposerDraftContentIfEmpty(sourceKey, targetKey);    expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ [sourceKey]: source });     composerDraftFileMocks.releaseRead();    await copy;     expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({      [sourceKey]: source,      [targetKey]: target,      [unrelatedKey]: unrelated,    });  });   it("hydrates generic file attachments from their saved local paths", () => {    const file = {      id: "file-1",      type: "file" as const,      name: "report.pdf",      mimeType: "application/pdf",      sizeBytes: 42,      fileUri: "file:///documents/report.pdf",    };     expect(      decodePersistedComposerDrafts({        schemaVersion: 1,        drafts: {          "environment-1:thread-1": { text: "Review this file", attachments: [file] },        },      }),    ).toEqual({      "environment-1:thread-1": { text: "Review this file", attachments: [file] },    });  });   it("releases videos rejected by the live draft limit and keeps accepted files", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const cleanup = Promise.withResolvers<void>();    composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {      cleanup.resolve();    });    const makeAttachment = (id: string) => ({      id,      type: "file" as const,      name: `${id}.mov`,      mimeType: "video/quicktime",      sizeBytes: 42,      fileUri: `file:///documents/t3-composer-attachments/${id}.mov`,    });    const draftKey = "new-task:environment-1:project-cap";    const existing = Array.from({ length: 7 }, (_, index) => makeAttachment(`held-${index}`));    appAtomRegistry.set(composerDraftsAtom, {      [draftKey]: { text: "send this", attachments: existing },    });     const rejected = appendComposerDraftAttachments(draftKey, [      makeAttachment("incoming-1"),      makeAttachment("incoming-2"),    ]);     expect(rejected).toBe(1);    const draft = appAtomRegistry.get(composerDraftsAtom)[draftKey];    expect(draft?.attachments).toHaveLength(8);    expect(draft?.attachments.at(-1)?.id).toBe("incoming-1");    await cleanup.promise;    expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(      makeAttachment("incoming-2").fileUri,    );     // Restore paths bypass the cap so a failed send never drops its files.    const overflowRejected = appendComposerDraftAttachments(      draftKey,      [makeAttachment("restored-1")],      { allowOverflow: true },    );    expect(overflowRejected).toBe(0);    expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(9);  });   it("keeps shared attachment files until every draft releases them", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const file = {      id: "file-1",      type: "file" as const,      name: "report.pdf",      mimeType: "application/pdf",      sizeBytes: 42,      fileUri: "file:///documents/t3-composer-attachments/report.pdf",    };    appAtomRegistry.set(composerDraftsAtom, {      source: { text: "First draft", attachments: [file] },      copied: { text: "Second draft", attachments: [file] },    });     await releaseUnusedComposerAttachmentFiles([file]);    expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();     appAtomRegistry.set(composerDraftsAtom, {      copied: { text: "Second draft", attachments: [file] },    });    await releaseUnusedComposerAttachmentFiles([file]);    expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();     appAtomRegistry.set(composerDraftsAtom, {});    await releaseUnusedComposerAttachmentFiles([file]);    expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);  });   it("keeps a failed-send draft's pending upload for retry", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const file = {      id: "file-failed-send",      type: "file" as const,      name: "report.pdf",      mimeType: "application/pdf",      sizeBytes: 42,      fileUri: "file:///documents/t3-composer-attachments/failed-send.pdf",      uploadedAttachmentId: "pending-failed-send",      uploadEnvironmentId: EnvironmentId.make("environment-1"),    };    appAtomRegistry.set(composerDraftsAtom, {      "environment-1:thread-1": { text: "Retry this send", attachments: [file] },    });     await releaseUnusedComposerAttachmentFiles([file]);     expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();    expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();  });   it("keeps a removed file until both playback and a share copy finish", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4";    const file = {      id: "file-preview",      type: "file" as const,      name: "recording.mp4",      mimeType: "video/mp4",      sizeBytes: 42,      fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,    };    const currentFile = {      ...file,      fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`,    };    const releasePlayback = retainComposerAttachmentFileForPreview(file);    const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile);    onTestFinished(releasePlayback);    onTestFinished(releaseShareCopy);     await releaseUnusedComposerAttachmentFiles([currentFile]);    expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();     releasePlayback();    releasePlayback();    await releaseUnusedComposerAttachmentFiles([file]);    expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();     const deleted = Promise.withResolvers<void>();    composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {      deleted.resolve();      return undefined;    });    releaseShareCopy();    await deleted.promise;     expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]);  });   it("preserves a preview opened while cleanup is checking the incoming inbox", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const file = {      id: "file-opening-preview",      type: "file" as const,      name: "recording.mp4",      mimeType: "video/mp4",      sizeBytes: 42,      fileUri: "file:///documents/t3-composer-attachments/recording.mp4",    };    const ownershipReadStarted = Promise.withResolvers<void>();    const ownershipRead = Promise.withResolvers<[]>();    incomingShareStorageMocks.load.mockImplementationOnce(() => {      ownershipReadStarted.resolve();      return ownershipRead.promise;    });     const cleanup = releaseUnusedComposerAttachmentFiles([file]);    await ownershipReadStarted.promise;    const release = retainComposerAttachmentFileForPreview(file);    onTestFinished(release);    ownershipRead.resolve([]);    await cleanup;    expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();     const deleted = Promise.withResolvers<void>();    composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {      deleted.resolve();      return undefined;    });    release();    await deleted.promise;    expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[file.fileUri]]);  });   it("removes an unreferenced local file and its pending upload", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const environmentId = EnvironmentId.make("environment-1");    const file = {      id: "file-discarded",      type: "file" as const,      name: "report.pdf",      mimeType: "application/pdf",      sizeBytes: 42,      fileUri: "file:///documents/t3-composer-attachments/discarded.pdf",      uploadedAttachmentId: "pending-discarded",      uploadEnvironmentId: environmentId,    };     await releaseUnusedComposerAttachmentFiles([file]);     expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);    expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [      "pending-discarded",    ]);  });   it("keeps a pending upload referenced through another local file", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const environmentId = EnvironmentId.make("environment-1");    const discarded = {      id: "file-discarded-copy",      type: "file" as const,      name: "report.pdf",      mimeType: "application/pdf",      sizeBytes: 42,      fileUri: "file:///documents/t3-composer-attachments/discarded-copy.pdf",      uploadedAttachmentId: "pending-shared",      uploadEnvironmentId: environmentId,    };    const retained = {      ...discarded,      id: "file-retained-copy",      fileUri: "file:///documents/t3-composer-attachments/retained-copy.pdf",    };    appAtomRegistry.set(composerDraftsAtom, {      "environment-1:thread-1": { text: "Keep this copy", attachments: [retained] },    });     await releaseUnusedComposerAttachmentFiles([discarded]);     expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(discarded.fileUri);    expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();  });   it("completes local cleanup when pending upload deletion fails", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const warning = vi.spyOn(console, "warn").mockImplementation(() => undefined);    onTestFinished(() => warning.mockRestore());    composerAttachmentCleanupMocks.releaseUploads.mockRejectedValueOnce(      new Error("environment disconnected"),    );    const file = {      id: "file-delete-failed",      type: "file" as const,      name: "report.pdf",      mimeType: "application/pdf",      sizeBytes: 42,      fileUri: "file:///documents/t3-composer-attachments/delete-failed.pdf",      uploadedAttachmentId: "pending-delete-failed",      uploadEnvironmentId: EnvironmentId.make("environment-1"),    };     await expect(releaseUnusedComposerAttachmentFiles([file])).resolves.toBeUndefined();     expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);    expect(warning).toHaveBeenCalledWith(      "[composer-attachments] could not remove pending upload",      expect.objectContaining({ attachmentId: "pending-delete-failed" }),    );  });   it("keeps local attachment files while an outbox message still needs them", async () => {    const file = {      id: "file-queued",      type: "file" as const,      name: "report.pdf",      mimeType: "application/pdf",      sizeBytes: 42,      fileUri: "file:///documents/t3-composer-attachments/report.pdf",    };    appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {      "environment-1:thread-1": [        {          environmentId: EnvironmentId.make("environment-1"),          threadId: ThreadId.make("thread-1"),          messageId: MessageId.make("message-1"),          commandId: CommandId.make("command-1"),          text: "Review the report",          attachments: [file],          createdAt: "2026-08-24T12:00:00.000Z",        },      ],    });

Lines 1–500 of 1403.