Gateway: watch API, pairing, notifications, APNs sender, CLI, tests

This commit is contained in:
gib committed 2026-11-01 02:09:18 -05:00
1 parent 972f6e4be5
commit 6ca098b701
12 files changed
+1297 -1

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
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",
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."],
]);
});
});