| 1 | // Automated coverage for the dashboard's target-resolution rail (#4397). |
| 2 | // |
| 3 | // These functions are the only thing standing between "the user clicked a row" |
| 4 | // and "a reply or an approval was POSTed somewhere". Every branch here is a |
| 5 | // fail-closed decision, so every branch is asserted: a saved session is never |
| 6 | // replied to, a stale selection never sends, and an approval that is not in the |
| 7 | // live thread's own approval set is never answered. |
| 8 | // |
| 9 | // Run by `npm test` in `web/` — see `web/vitest.config.ts`, whose `include` |
| 10 | // reaches this file deliberately so the rail cannot regress without CI saying |
| 11 | // so. The module is import-safe outside a browser: `app.mjs` only calls |
| 12 | // `startBrowserClient()` when `document` exists. |
| 13 | |
| 14 | import { describe, expect, it } from "vitest"; |
| 15 | |
| 16 | import { |
| 17 | NO_TARGET, |
| 18 | canReply, |
| 19 | collectProviderModelPages, |
| 20 | refusalMessage, |
| 21 | receiptPresentation, |
| 22 | resolveApprovalTarget, |
| 23 | resolveReplyTarget, |
| 24 | sessionTarget, |
| 25 | streamCursor, |
| 26 | threadTarget, |
| 27 | workflowReceiptPresentation, |
| 28 | } from "./app.mjs"; |
| 29 | |
| 30 | describe("collectProviderModelPages", () => { |
| 31 | it("loads a 600-model catalog through every opaque page", async () => { |
| 32 | const all = Array.from({ length: 600 }, (_, index) => ({ |
| 33 | id: `openrouter/model-${String(index).padStart(3, "0")}`, |
| 34 | image_input: "unknown", |
| 35 | })); |
| 36 | const cursors = new Map([ |
| 37 | ["", { start: 0, nextCursor: "page-2" }], |
| 38 | ["page-2", { start: 250, nextCursor: "page-3" }], |
| 39 | ["page-3", { start: 500, nextCursor: "" }], |
| 40 | ]); |
| 41 | const paths = []; |
| 42 | const models = await collectProviderModelPages("openrouter", async (path) => { |
| 43 | paths.push(path); |
| 44 | const url = new URL(path, "http://runtime.local"); |
| 45 | const cursor = url.searchParams.get("cursor") || ""; |
| 46 | const page = cursors.get(cursor); |
| 47 | expect(url.searchParams.get("limit")).toBe("250"); |
| 48 | expect(page).toBeDefined(); |
| 49 | return { |
| 50 | provider: "openrouter", |
| 51 | models: all.slice(page.start, page.start + 250), |
| 52 | total: all.length, |
| 53 | ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}), |
| 54 | }; |
| 55 | }); |
| 56 | |
| 57 | expect(models).toEqual(all); |
| 58 | expect(paths).toHaveLength(3); |
| 59 | }); |
| 60 | |
| 61 | it("rejects truncated pages, malformed rows and changing totals", async () => { |
| 62 | for (const response of [ |
| 63 | { provider: "openrouter", models: [{ id: "one" }], total: 2 }, |
| 64 | { provider: "openrouter", models: null, total: 0 }, |
| 65 | { provider: "another-provider", models: [], total: 0 }, |
| 66 | ]) { |
| 67 | await expect(collectProviderModelPages("openrouter", async () => response)).rejects.toThrow(); |
| 68 | } |
| 69 | let count = 0; |
| 70 | await expect(collectProviderModelPages("openrouter", async () => ({ |
| 71 | provider: "openrouter", |
| 72 | models: [{ id: String(count) }], |
| 73 | total: ++count === 1 ? 2 : 3, |
| 74 | nextCursor: "next", |
| 75 | }))).rejects.toThrow("catalog changed"); |
| 76 | }); |
| 77 | |
| 78 | it("rejects a repeated or non-progressing cursor", async () => { |
| 79 | await expect( |
| 80 | collectProviderModelPages("openrouter", async () => ({ |
| 81 | provider: "openrouter", |
| 82 | models: [{ id: "model-a", image_input: "unknown" }], |
| 83 | total: 2, |
| 84 | nextCursor: "same-page", |
| 85 | })), |
| 86 | ).rejects.toThrow("non-progressing model cursor"); |
| 87 | }); |
| 88 | }); |
| 89 | |
| 90 | describe("receiptPresentation", () => { |
| 91 | it("keeps a failed MCP transport compact while preserving the raw receipt", () => { |
| 92 | const raw = "Failed to connect MCP server 'github': Stdio transport closed MCP server stderr (last 1 line): Docker is not running"; |
| 93 | expect(receiptPresentation({ kind: "status", status: "completed", summary: raw })).toEqual({ |
| 94 | label: "MCP · Unavailable", |
| 95 | summary: "github could not connect", |
| 96 | raw, |
| 97 | failed: true, |
| 98 | }); |
| 99 | }); |
| 100 | |
| 101 | it("does not rewrite ordinary receipts", () => { |
| 102 | expect(receiptPresentation({ kind: "tool_result", status: "completed", summary: "3 tests passed" })).toEqual({ |
| 103 | label: "Tool Result · Completed", |
| 104 | summary: "3 tests passed", |
| 105 | raw: "3 tests passed", |
| 106 | failed: false, |
| 107 | }); |
| 108 | }); |
| 109 | }); |
| 110 | |
| 111 | describe("workflowReceiptPresentation", () => { |
| 112 | it("summarises a single rejected dispatch and keeps the raw receipt", () => { |
| 113 | const raw = '{"status":"degraded","dispatch_failure_count":1}'; |
| 114 | expect( |
| 115 | workflowReceiptPresentation( |
| 116 | { summary: "workflow: check", status: "completed" }, |
| 117 | raw, |
| 118 | "---" |
| 119 | ), |
| 120 | ).toEqual({ |
| 121 | label: "Workflow · Needs attention", |
| 122 | summary: "1 task dispatch was rejected", |
| 123 | raw: "---", |
| 124 | failed: true, |
| 125 | }); |
| 126 | }); |
| 127 | |
| 128 | it("summarises multiple rejected dispatches in the plural", () => { |
| 129 | const raw = '{"status":"degraded","dispatch_failure_count":3}'; |
| 130 | const result = workflowReceiptPresentation( |
| 131 | { summary: "workflow: check", status: "completed" }, |
| 132 | raw, |
| 133 | null |
| 134 | ); |
| 135 | expect(result.summary).toBe("3 task dispatches were rejected"); |
| 136 | expect(result.failed).toBe(true); |
| 137 | }); |
| 138 | |
| 139 | it("keeps a failed workflow without a dispatch count honest", () => { |
| 140 | const result = workflowReceiptPresentation( |
| 141 | { summary: "workflow: gate", status: "completed" }, |
| 142 | '{"status":"failed"}', |
| 143 | null |
| 144 | ); |
| 145 | expect(result).toEqual({ |
| 146 | label: "Workflow · Failed", |
| 147 | summary: "The workflow did not complete", |
| 148 | raw: null, |
| 149 | failed: true, |
| 150 | }); |
| 151 | }); |
| 152 | |
| 153 | it("labels degraded completions without rejected dispatches as needs attention", () => { |
| 154 | const result = workflowReceiptPresentation( |
| 155 | { summary: "workflow: check", status: "completed" }, |
| 156 | '{"status":"degraded"}', |
| 157 | null |
| 158 | ); |
| 159 | expect(result.label).toBe("Workflow · Needs attention"); |
| 160 | expect(result.summary).toBe("The workflow completed with degraded results"); |
| 161 | }); |
| 162 | |
| 163 | it("returns null for non-workflow receipts", () => { |
| 164 | expect( |
| 165 | workflowReceiptPresentation( |
| 166 | { summary: "3 tests passed", status: "completed" }, |
| 167 | "3 tests passed", |
| 168 | null |
| 169 | ) |
| 170 | ).toBeNull(); |
| 171 | }); |
| 172 | }); |
| 173 | |
| 174 | /** Minimal stand-in for the live stream state the real client threads through. */ |
| 175 | function streamState(threadId, approvalIds = []) { |
| 176 | return { threadId, approvals: new Map(approvalIds.map((id) => [id, {}])) }; |
| 177 | } |
| 178 | |
| 179 | describe("canReply", () => { |
| 180 | it("admits only a live thread with an id", () => { |
| 181 | expect(canReply(threadTarget("thread-a"))).toBe(true); |
| 182 | expect(canReply(sessionTarget("session-a"))).toBe(false); |
| 183 | expect(canReply(NO_TARGET)).toBe(false); |
| 184 | expect(canReply(threadTarget(""))).toBe(false); |
| 185 | expect(canReply(undefined)).toBe(false); |
| 186 | }); |
| 187 | }); |
| 188 | |
| 189 | describe("resolveReplyTarget", () => { |
| 190 | it("resolves a live thread that the stream is following", () => { |
| 191 | const resolved = resolveReplyTarget(threadTarget("thread-a"), streamState("thread-a")); |
| 192 | expect(resolved).toEqual({ ok: true, threadId: "thread-a" }); |
| 193 | }); |
| 194 | |
| 195 | it("resolves a live thread before any stream state exists", () => { |
| 196 | // A freshly created thread has no stream yet; refusing here would make the |
| 197 | // first message of every new thread impossible to send. |
| 198 | expect(resolveReplyTarget(threadTarget("thread-a"), null)).toEqual({ |
| 199 | ok: true, |
| 200 | threadId: "thread-a", |
| 201 | }); |
| 202 | expect(resolveReplyTarget(threadTarget("thread-a"), { threadId: "" })).toEqual({ |
| 203 | ok: true, |
| 204 | threadId: "thread-a", |
| 205 | }); |
| 206 | }); |
| 207 | |
| 208 | it("refuses a saved session — a recording has no runtime to receive a reply", () => { |
| 209 | expect(resolveReplyTarget(sessionTarget("session-a"), streamState("thread-a"))).toEqual({ |
| 210 | ok: false, |
| 211 | reason: "session-not-live", |
| 212 | }); |
| 213 | }); |
| 214 | |
| 215 | it("refuses when nothing is selected", () => { |
| 216 | expect(resolveReplyTarget(NO_TARGET, streamState("thread-a"))).toEqual({ |
| 217 | ok: false, |
| 218 | reason: "no-target", |
| 219 | }); |
| 220 | expect(resolveReplyTarget(null, streamState("thread-a"))).toEqual({ |
| 221 | ok: false, |
| 222 | reason: "no-target", |
| 223 | }); |
| 224 | expect(resolveReplyTarget(threadTarget(""), streamState("thread-a"))).toEqual({ |
| 225 | ok: false, |
| 226 | reason: "no-target", |
| 227 | }); |
| 228 | }); |
| 229 | |
| 230 | it("refuses a stale target: the stream moved on while the user did not", () => { |
| 231 | expect(resolveReplyTarget(threadTarget("thread-a"), streamState("thread-b"))).toEqual({ |
| 232 | ok: false, |
| 233 | reason: "stale-target", |
| 234 | }); |
| 235 | }); |
| 236 | |
| 237 | it("never returns an id on refusal", () => { |
| 238 | for (const [target, state] of [ |
| 239 | [sessionTarget("session-a"), streamState("thread-a")], |
| 240 | [NO_TARGET, streamState("thread-a")], |
| 241 | [threadTarget("thread-a"), streamState("thread-b")], |
| 242 | ]) { |
| 243 | expect(resolveReplyTarget(target, state).threadId).toBeUndefined(); |
| 244 | } |
| 245 | }); |
| 246 | }); |
| 247 | |
| 248 | describe("resolveApprovalTarget", () => { |
| 249 | it("resolves an approval the watched thread actually holds", () => { |
| 250 | const state = streamState("thread-a", ["approval-1"]); |
| 251 | expect(resolveApprovalTarget("approval-1", threadTarget("thread-a"), state)).toEqual({ |
| 252 | ok: true, |
| 253 | threadId: "thread-a", |
| 254 | approvalId: "approval-1", |
| 255 | }); |
| 256 | }); |
| 257 | |
| 258 | it("inherits every reply refusal — authority cannot outrank the target check", () => { |
| 259 | const state = streamState("thread-a", ["approval-1"]); |
| 260 | expect(resolveApprovalTarget("approval-1", sessionTarget("session-a"), state).reason).toBe( |
| 261 | "session-not-live", |
| 262 | ); |
| 263 | expect(resolveApprovalTarget("approval-1", NO_TARGET, state).reason).toBe("no-target"); |
| 264 | expect( |
| 265 | resolveApprovalTarget("approval-1", threadTarget("thread-b"), state).reason, |
| 266 | ).toBe("stale-target"); |
| 267 | }); |
| 268 | |
| 269 | it("refuses an approval that is not in the live approval set", () => { |
| 270 | // Already decided, expired, or belonging to a thread we stopped watching. |
| 271 | const state = streamState("thread-a", ["approval-1"]); |
| 272 | expect(resolveApprovalTarget("approval-2", threadTarget("thread-a"), state)).toEqual({ |
| 273 | ok: false, |
| 274 | reason: "stale-approval", |
| 275 | }); |
| 276 | }); |
| 277 | |
| 278 | it("refuses when the stream state is missing or belongs to another thread", () => { |
| 279 | expect(resolveApprovalTarget("approval-1", threadTarget("thread-a"), null)).toEqual({ |
| 280 | ok: false, |
| 281 | reason: "stale-target", |
| 282 | }); |
| 283 | // A reply would be allowed here (no stream yet), but an approval must not: |
| 284 | // there is no live approval set to check membership against. |
| 285 | expect( |
| 286 | resolveApprovalTarget("approval-1", threadTarget("thread-a"), { threadId: "" }), |
| 287 | ).toEqual({ ok: false, reason: "stale-target" }); |
| 288 | }); |
| 289 | |
| 290 | it("refuses an unidentified approval", () => { |
| 291 | const state = streamState("thread-a", ["approval-1"]); |
| 292 | expect(resolveApprovalTarget("", threadTarget("thread-a"), state)).toEqual({ |
| 293 | ok: false, |
| 294 | reason: "no-approval", |
| 295 | }); |
| 296 | }); |
| 297 | |
| 298 | it("refuses when the thread has no approvals at all", () => { |
| 299 | expect( |
| 300 | resolveApprovalTarget("approval-1", threadTarget("thread-a"), { threadId: "thread-a" }), |
| 301 | ).toEqual({ ok: false, reason: "stale-approval" }); |
| 302 | }); |
| 303 | |
| 304 | it("never returns an approval id on refusal", () => { |
| 305 | const state = streamState("thread-a", ["approval-1"]); |
| 306 | for (const [id, target] of [ |
| 307 | ["approval-2", threadTarget("thread-a")], |
| 308 | ["approval-1", sessionTarget("session-a")], |
| 309 | ["", threadTarget("thread-a")], |
| 310 | ]) { |
| 311 | expect(resolveApprovalTarget(id, target, state).approvalId).toBeUndefined(); |
| 312 | } |
| 313 | }); |
| 314 | }); |
| 315 | |
| 316 | describe("refusalMessage", () => { |
| 317 | it("gives every refusal reason a distinct user-visible sentence", () => { |
| 318 | const reasons = ["session-not-live", "stale-target", "stale-approval", "no-approval", "no-target"]; |
| 319 | const messages = reasons.map(refusalMessage); |
| 320 | expect(new Set(messages).size).toBe(reasons.length); |
| 321 | for (const message of messages) { |
| 322 | // Every refusal must state the outcome, not just the cause: the user |
| 323 | // needs to know their message did not go anywhere. |
| 324 | expect(message).toContain("nothing was sent"); |
| 325 | } |
| 326 | }); |
| 327 | |
| 328 | it("falls back to the select-a-thread message for an unknown reason", () => { |
| 329 | expect(refusalMessage("something-new")).toBe(refusalMessage("no-target")); |
| 330 | }); |
| 331 | }); |
| 332 | |
| 333 | describe("streamCursor", () => { |
| 334 | it("reports the resume sequence and a live label", () => { |
| 335 | expect(streamCursor({ latestSeq: 12 })).toEqual({ |
| 336 | latestSeq: 12, |
| 337 | gap: false, |
| 338 | connected: true, |
| 339 | label: "Live — event #12", |
| 340 | }); |
| 341 | }); |
| 342 | |
| 343 | it("names a gap and a disconnect distinctly, both carrying the resume point", () => { |
| 344 | expect(streamCursor({ latestSeq: 5 }, { gap: true }).label).toBe( |
| 345 | "Gap detected — re-syncing from #5", |
| 346 | ); |
| 347 | expect(streamCursor({ latestSeq: 5 }, { connected: false }).label).toBe( |
| 348 | "Reconnecting — resuming from #5", |
| 349 | ); |
| 350 | }); |
| 351 | |
| 352 | it("normalizes a missing or nonsense sequence to zero rather than NaN", () => { |
| 353 | expect(streamCursor(undefined).latestSeq).toBe(0); |
| 354 | expect(streamCursor({ latestSeq: "not a number" }).latestSeq).toBe(0); |
| 355 | }); |
| 356 | }); |
| 357 |