1988 lines · 69.7 KB · TypeScript
RawDownload
import { afterEach, describe, expect, it } from "@effect/vitest";import {  CommandId,  EnvironmentId,  MessageId,  ProjectId,  ProviderInstanceId,  ThreadId,} from "@t3tools/contracts";import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => {  let document = JSON.stringify({ schemaVersion: 1, drafts: {} });  let readError: Error | null = null;  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[] = [];  const readImage = vi.fn(async () => "YWJj");   return {    readImage,    blockRead() {      readBarrier = new Promise<void>((resolve) => {        releaseRead = resolve;      });    },    releaseRead() {      releaseRead?.();      releaseRead = null;    },    getDocument() {      return document;    },    setDocument(value: unknown) {      document = JSON.stringify(value);    },    setReadError(error: Error | null) {      readError = error;    },    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() {}       list() {        return [];      }    },    File: class {      exists = true;      parentDirectory = null;       create() {}       moveSync() {}       async text() {        await readBarrier;        if (readError) throw readError;        return document;      }       async base64() {        return readImage();      }       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: { uri: "file:///documents" } },})); vi.mock("../lib/composerImages", async (importOriginal) => ({  ...(await importOriginal<typeof import("../lib/composerImages")>()),  removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,})); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" }));vi.mock("./assets", () => ({ assetEnvironment: {} }));vi.mock("./attachments", () => ({ attachmentEnvironment: {} }));vi.mock("./session", () => ({ environmentSession: {} }));vi.mock("@t3tools/client-runtime/state/runtime", () => ({  createEnvironmentRpcCommand: () => Symbol("rpc-command"),  executeAtomQuery: () => {    throw new Error("Unexpected network query in the inline read test");  },  runAtomCommand: () => {    throw new Error("Unexpected network command in the inline read test");  },  squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,})); vi.mock("../lib/attachmentUpload", () => ({  releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,})); vi.mock("../features/sharing/incoming-share-storage", () => ({  loadIncomingShareDrafts: incomingShareStorageMocks.load,})); import type { DraftComposerAttachment } from "../lib/composerImages";import { appAtomRegistry } from "./atom-registry";import { threadOutboxManager } from "./thread-outbox";import {  appendComposerDraftAttachments,  archiveCloudComposerDrafts,  clearComposerDraftContent,  clearComposerDraftContentState,  clearComposerDraftsEnvironment,  ComposerDraftPersistenceError,  composerDraftsAtom,  composerCloudDraftsAtom,  createNewTaskDraft,  decodePersistedComposerState,  ensureComposerDraftsLoaded,  type ComposerDraft,  findNewTaskDraftKeys,  flushComposerDrafts,  getComposerDraftSnapshot,  mergeComposerDraftContentState,  migrateLegacyNewTaskDraft,  releaseUnusedComposerAttachmentFiles,  removeComposerDraftsForEnvironment,  resetComposerDraftsLoadState,  retainComposerAttachmentFileForPreview,  restoreComposerDraftSnapshotState,  restoreCloudComposerDrafts,  retargetNewTaskDraft,  setComposerDraftText,  setComposerDraftAttachmentUpload,  waitForComposerDraftsLoaded,  setStickyComposerModelSelection,  stickyComposerModelSelectionAtom,  undoComposerDraftMerge,  undoComposerDraftMergeState,} from "./use-composer-drafts"; const DRAFT: ComposerDraft = {  text: "hello",  attachments: [],}; afterEach(() => {  vi.useRealTimers();  resetComposerDraftsLoadState();  composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} });  composerDraftFileMocks.setReadError(null);  composerDraftFileMocks.setWriteError(null);  composerDraftFileMocks.setNextWriteBarrier(null);  composerDraftFileMocks.setOnWrite(null);  composerDraftFileMocks.resetWrites();  composerDraftFileMocks.readImage.mockReset();  composerDraftFileMocks.readImage.mockResolvedValue("YWJj");  appAtomRegistry.set(composerDraftsAtom, {});  appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });  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("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(      decodePersistedComposerState({        schemaVersion: 1,        drafts: {          "environment-1:thread-1": { text: "Review this file", attachments: [file] },        },      }).drafts,    ).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("retains a referenced file-backed image and releases it once unreferenced", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const image = {      id: "image-1",      type: "image" as const,      name: "photo.png",      mimeType: "image/png",      sizeBytes: 3,      fileUri: "file:///documents/t3-composer-attachments/photo.png",      previewUri: "file:///documents/t3-composer-attachments/photo.png",    };    appAtomRegistry.set(composerDraftsAtom, {      "environment-1:thread-1": { text: "look at this", attachments: [image] },    });     await releaseUnusedComposerAttachmentFiles([image]);    expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();     // A queued outbox message must keep the bytes alive after the draft clears.    appAtomRegistry.set(composerDraftsAtom, {});    appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {      "environment-1:thread-1": [        {          environmentId: EnvironmentId.make("environment-1"),          threadId: ThreadId.make("thread-1"),          messageId: MessageId.make("queued-image"),          commandId: CommandId.make("command-image"),          text: "look at this",          attachments: [image],          createdAt: "2026-08-31T12:00:00.000Z",        },      ],    });    await releaseUnusedComposerAttachmentFiles([image]);    expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();     appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});    await releaseUnusedComposerAttachmentFiles([image]);    expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(image.fileUri);  });   it("keeps an image through an inline read after its draft is removed", async () => {    const { prepareTurnAttachments } =      await vi.importActual<typeof import("../lib/attachmentUpload")>("../lib/attachmentUpload");    const image = {      id: "image-reading",      type: "image" as const,      name: "photo.png",      mimeType: "image/png",      sizeBytes: 3,      fileUri: "file:///documents/t3-composer-attachments/photo.png",      previewUri: "file:///documents/t3-composer-attachments/photo.png",    };    const readStarted = Promise.withResolvers<void>();    const read = Promise.withResolvers<void>();    const removed = Promise.withResolvers<void>();    let imageExists = true;    composerDraftFileMocks.readImage.mockImplementation(async () => {      readStarted.resolve();      await read.promise;      if (!imageExists) throw new Error("The image was deleted during its read");      return "YWJj";    });    composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {      imageExists = false;      removed.resolve();    });    appendComposerDraftAttachments("environment-1:thread-1", [image]);    await flushComposerDrafts();    const preparing = prepareTurnAttachments({      environmentId: EnvironmentId.make("environment-1"),      attachments: [image],    });    onTestFinished(async () => {      read.resolve();      await preparing.catch(() => undefined);    });    await readStarted.promise;    clearComposerDraftContent("environment-1:thread-1", { deferAttachmentCleanup: true });    await releaseUnusedComposerAttachmentFiles([image]);    expect(imageExists).toBe(true);     read.resolve();    const prepared = await preparing;    expect(prepared.status).toBe("ready");    if (prepared.status !== "ready") return;    expect(prepared.attachments).toEqual([      {        type: "image",        name: "photo.png",        mimeType: "image/png",        sizeBytes: 3,        dataUrl: "data:image/png;base64,YWJj",      },    ]);    await removed.promise;    expect(imageExists).toBe(false);  });   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("retains offline image bytes and newer edits when an early upload finishes", async () => {    const key = "environment-1:thread-1";    const image = {      id: "photo",      type: "image" as const,      name: "photo.png",      mimeType: "image/png",      sizeBytes: 3,      dataUrl: "data:image/png;base64,YWJj",      previewUri: "file:///photo.png",    };    const second = { ...image, id: "second", name: "second.png" };    const uploaded = {      ...image,      uploadedAttachmentId: "pending-photo",      uploadEnvironmentId: EnvironmentId.make("environment-1"),    };    composerDraftFileMocks.setDocument({ schemaVersion: 1, drafts: {} });    appendComposerDraftAttachments(key, [image]);    setComposerDraftText(key, "Edited while uploading");    appendComposerDraftAttachments(key, [second]);    expect(setComposerDraftAttachmentUpload(key, uploaded)).toBe(true);    await flushComposerDrafts();     appAtomRegistry.set(composerDraftsAtom, {});    resetComposerDraftsLoadState();    await waitForComposerDraftsLoaded();    expect(getComposerDraftSnapshot(key)).toMatchObject({      text: "Edited while uploading",      attachments: [uploaded, second],    });    expect(setComposerDraftAttachmentUpload(key, { ...uploaded, id: "removed-photo" })).toBe(false);    expect(getComposerDraftSnapshot(key).attachments).toHaveLength(2);  });   it("cleans up an unreferenced image upload even when there is no local file URI", async () => {    const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);    onTestFinished(() => outboxLoad.mockRestore());    const environmentId = EnvironmentId.make("environment-1");    await releaseUnusedComposerAttachmentFiles([      {        id: "photo",        type: "image",        name: "photo.png",        mimeType: "image/png",        sizeBytes: 3,        dataUrl: "data:image/png;base64,YWJj",        previewUri: "file:///photo.png",        uploadedAttachmentId: "pending-photo",        uploadEnvironmentId: environmentId,      },    ]);    expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith(environmentId, [      "pending-photo",    ]);    expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();  });   it.each(["file", "image"] as const)(    "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account",    async (type) => {      const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);      onTestFinished(() => load.mockRestore());      await waitForComposerDraftsLoaded();      const environmentId = EnvironmentId.make("cloud-environment");

Lines 1–500 of 1988.