import { describe, expect, it } from "bun:test"; import { Notifier, type Push } from "../src/notify.ts"; import type { T3Host } from "../src/t3/host.ts"; import type { ThreadShell } from "../src/t3/types.ts"; import { SummaryCache } from "../src/views.ts"; const host = { id: "host-1", label: "Mac mini", projects: new Map([["project-1", { id: "project-1", title: "wristcode" }]]), bounded: () => Promise.reject(new Error("offline")), } as unknown as T3Host; const shell = (overrides: Partial = {}): ThreadShell => ({ id: "thread-1", projectId: "project-1", title: "Watch app scaffold", createdAt: "2026-11-01T09:00:00.000Z", updatedAt: "2026-11-01T09:00:00.000Z", status: "running", activeRunId: "run-1", pendingRuntimeRequest: null, modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5" }, lineage: { relationshipToParent: null }, ...overrides, }); const request = (id: string) => ({ id, kind: "command", createdAt: "2026-11-01T09:00:00.000Z" }); function setup() { const pushes: Push[] = []; const notifier = new Notifier(async (push) => void pushes.push(push), new SummaryCache()); let previous: ThreadShell | undefined; const change = (next: ThreadShell | undefined, initial = false) => { notifier.onThreadChange({ host, previous, next, initial }); previous = next; }; // Pushes are sent after a summary lookup, which fails fast here. const settle = () => Bun.sleep(5); return { pushes, change, settle, notifier }; } describe("Notifier", () => { it("stays quiet while loading the first snapshot", async () => { const { pushes, change, settle } = setup(); change(shell({ status: "completed" }), true); change(shell({ status: "completed" })); await settle(); expect(pushes).toEqual([]); }); it("notifies once when a running thread finishes", async () => { const { pushes, change, settle } = setup(); change(shell(), true); change(shell({ status: "completed" })); change(shell({ status: "completed", updatedAt: "2026-11-01T09:01:00.000Z" })); await settle(); expect(pushes.map((push) => [push.category, push.subtitle, push.data.threadId])).toEqual([ ["finished", "Mac mini ยท wristcode", "thread-1"], ]); }); it("notifies for each new approval request, with its request id", async () => { const { pushes, change, settle } = setup(); change(shell(), true); change(shell({ pendingRuntimeRequest: request("r1") })); change(shell({ pendingRuntimeRequest: request("r2") })); await settle(); expect(pushes.map((push) => [push.category, push.data.requestId])).toEqual([ ["approval", "r1"], ["approval", "r2"], ]); }); it("does not announce a turn the user stopped", async () => { const { pushes, change, settle } = setup(); change(shell(), true); change(shell({ status: "completed", latestRunStopRequested: true })); await settle(); expect(pushes).toEqual([]); }); it("reports failures with the server's error", async () => { const { pushes, change, settle } = setup(); change(shell(), true); change(shell({ status: "failed", lastError: "Provider quota exceeded" })); await settle(); expect(pushes.map((push) => [push.category, push.body])).toEqual([["failed", "Provider quota exceeded"]]); }); it("warns about a token that expires within a week, once a day", async () => { const { pushes, notifier } = setup(); const row = { id: "host-1", label: "Mac mini", url: "https://t3mac.gbrown.org", alt_urls: null, environment_id: "host-1", token: "t", token_expires_at: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(), created_at: "", }; notifier.checkTokenExpiry([row]); notifier.checkTokenExpiry([row]); expect(pushes.map((push) => [push.category, push.body])).toEqual([ ["host", "The gateway's T3 token expires in 3 days. Pair Mac mini again."], ]); }); }); describe("question pushes", () => { it("carry a lone single-choice question's options for the notification's buttons", async () => { const asking = shell({ pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: "2026-11-01T09:00:00.000Z" } }); const question = { id: "base", header: "Image", question: "Which base image?", options: [ { label: "bun:1-alpine", description: "" }, { label: "bun:1-debian", description: "", value: "debian" }, ], allowCustomAnswer: true, }; const reading = { ...host, bounded: () => Promise.resolve({ snapshotSequence: 1, projection: { thread: asking, messages: [], turnItems: [{ type: "user_input_request", requestId: "q1", questions: [question] }] }, }), } as unknown as T3Host; const pushes: Push[] = []; const notifier = new Notifier(async (push) => void pushes.push(push), new SummaryCache()); notifier.onThreadChange({ host: reading, previous: undefined, next: shell(), initial: true }); notifier.onThreadChange({ host: reading, previous: shell(), next: asking, initial: false }); await Bun.sleep(5); expect(pushes[0]?.data.question).toEqual({ id: "base", options: [ { label: "bun:1-alpine", value: "bun:1-alpine" }, { label: "bun:1-debian", value: "debian" }, ], allowCustomAnswer: true, }); }); });