Files
wristcode/gateway/test/notify.test.ts
T
gib edc4b88990
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
2026-11-02 14:45:34 -05:00

179 lines
6.6 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { approvalBody, 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://t3-mac.example.com",
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,
});
});
});
describe("approvalBody", () => {
const approval = (kind: string, files: Array<{ name: string; additions: number | null; deletions: number | null }>) => ({
id: "r-1",
kind,
prompt: "Edit: deploy/compose.yml",
reason: null,
options: [],
questions: [],
files: files.map((file) => ({ ...file, path: `deploy/${file.name}`, preview: [] })),
});
it("names the file a file approval would edit, with counts only when known", () => {
expect(approvalBody(approval("file-change", [{ name: "compose.yml", additions: 9, deletions: 6 }]))).toBe(
"Wants to edit compose.yml +9 \u22126",
);
expect(approvalBody(approval("file-change", [{ name: "compose.yml", additions: null, deletions: null }]))).toBe(
"Wants to edit compose.yml",
);
});
it("counts several files and lists their names", () => {
const files = ["compose.yml", "Containerfile", "health.ts"].map((name) => ({ name, additions: null, deletions: null }));
expect(approvalBody(approval("file-change", files))).toBe("Wants to edit 3 files: compose.yml, Containerfile, health.ts");
});
it("falls back to the prompt without files", () => {
expect(approvalBody(approval("file-change", []))).toBe("Wants to run: Edit: deploy/compose.yml");
expect(approvalBody(null)).toBe("Needs your approval.");
});
});