Files
wristcode/gateway/test/notify.test.ts
T
2026-11-02 13:39:49 -05:00

148 lines
5.3 KiB
TypeScript

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> = {}): 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,
});
});
});