import { describe, expect, it, vi } from "vitest"; import { isHandoff, resolveStartup, type StartupPorts } from "../lib/startup"; import type { PendingAttachment, Session, SessionSummary } from "../lib/types"; /** * The chat view's startup is the part that kept breaking: three asynchronous * paths (a pushed session, a handed-over capture, an idle resume) that all have * to end in a rendered chat. Two of those bugs shipped, so the decision tree is * tested here rather than discovered from a screenshot of a spinner. */ const NOW = 1_000_000; const HOUR = 3_600_000; function session(id: string, updatedAt: number): Session { return { id, title: id, model: "m", createdAt: updatedAt, updatedAt, turns: [] }; } function summary(id: string, updatedAt: number): SessionSummary { return { id, title: id, model: "m", createdAt: updatedAt, updatedAt, turnCount: 0, preview: "" }; } function attachment(path: string): PendingAttachment { return { path, mime: "image/png", base64: "AAAA" }; } /** Ports that record what was asked for, so "did not resume" is checkable. */ function ports(overrides: Partial = {}) { const loadSession = vi.fn(async (id: string) => session(id, NOW)); const listSessions = vi.fn(async () => [summary("latest", NOW)]); const readAttachment = vi.fn(async (path: string) => attachment(path)); return { loadSession, listSessions, readAttachment, ...overrides, } satisfies StartupPorts; } function input(overrides: Partial[0]> = {}) { return { hasInitialSession: false, now: NOW, ...overrides, }; } describe("isHandoff", () => { it("is true when a file was handed over", () => { expect(isHandoff({ initialAttachmentPath: "/tmp/snip.png" })).toBe(true); }); it("is true when a conversation was named", () => { expect(isHandoff({ initialSessionId: "abc" })).toBe(true); }); it("is false on an ordinary open", () => { expect(isHandoff({})).toBe(false); }); }); describe("resolveStartup", () => { describe("a snip hand-off", () => { it("reads the attachment and restores the named conversation", async () => { const p = ports(); const result = await resolveStartup( input({ initialSessionId: "abc", initialAttachmentPath: "/tmp/snip.png" }), p, ); expect(result.session?.id).toBe("abc"); expect(result.attachment?.path).toBe("/tmp/snip.png"); }); it("does not fall through to the idle resume", async () => { // The bug this file exists for: a hand-off has to finish startup even // though it took the resume path off the table. Guarding the resume with // "and there was no hand-off" while forgetting to clear the readiness flag // left the view on its loading row forever. const p = ports(); await resolveStartup(input({ initialAttachmentPath: "/tmp/snip.png" }), p); expect(p.listSessions).not.toHaveBeenCalled(); }); it("does not attach the most recent conversation's session", async () => { const p = ports(); const result = await resolveStartup(input({ initialAttachmentPath: "/tmp/snip.png" }), p); expect(result.session).toBeUndefined(); }); it("still resolves when the named conversation has been deleted", async () => { // A snip started from a conversation someone has since deleted should mean // a fresh chat with the image, not a spinner or a crash. const p = ports({ loadSession: vi.fn(async () => undefined) }); const result = await resolveStartup( input({ initialSessionId: "gone", initialAttachmentPath: "/tmp/snip.png" }), p, ); expect(result.session).toBeUndefined(); expect(result.attachment?.path).toBe("/tmp/snip.png"); }); it("reports an unreadable attachment without refusing to open", async () => { const p = ports({ readAttachment: vi.fn(async () => { throw new Error("ENOENT: no such file"); }), }); const result = await resolveStartup(input({ initialAttachmentPath: "/tmp/gone.png" }), p); expect(result.attachment).toBeUndefined(); expect(result.attachmentError).toContain("ENOENT"); }); it("survives a storage failure while loading the named conversation", async () => { const p = ports({ loadSession: vi.fn(async () => { throw new Error("storage exploded"); }), }); const result = await resolveStartup(input({ initialSessionId: "abc" }), p); expect(result.session).toBeUndefined(); }); }); describe("an ordinary open", () => { it("resumes the most recent conversation when it is fresh", async () => { const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 60_000)]) }); const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p); expect(result.session?.id).toBe("latest"); expect(p.loadSession).toHaveBeenCalledWith("latest"); }); it("starts fresh when the newest conversation is older than the window", async () => { const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 2 * HOUR)]) }); const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p); expect(result.session).toBeUndefined(); expect(p.loadSession).not.toHaveBeenCalled(); }); it("treats exactly the window as fresh", async () => { const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - HOUR)]) }); const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p); expect(result.session?.id).toBe("latest"); }); it("always resumes when the preference is 'never time out'", async () => { const p = ports({ listSessions: vi.fn(async () => [summary("latest", NOW - 400 * HOUR)]) }); const result = await resolveStartup(input({ resumeTimeoutMs: undefined }), p); expect(result.session?.id).toBe("latest"); }); it("starts fresh when there are no conversations at all", async () => { const p = ports({ listSessions: vi.fn(async () => []) }); const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p); expect(result).toEqual({}); expect(p.loadSession).not.toHaveBeenCalled(); }); it("starts fresh rather than failing when the index cannot be read", async () => { const p = ports({ listSessions: vi.fn(async () => { throw new Error("corrupt index"); }), }); const result = await resolveStartup(input({ resumeTimeoutMs: HOUR }), p); expect(result).toEqual({}); }); it("does not resume when a session was pushed in by hand", async () => { // The sessions command already knows which conversation to show. const p = ports(); await resolveStartup(input({ hasInitialSession: true }), p); expect(p.listSessions).not.toHaveBeenCalled(); expect(p.loadSession).not.toHaveBeenCalled(); }); }); });