返回 CodeWhale
app.test.mjs
根目录 / crates / tui / src / runtime_web / app.test.mjs
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
357 lines Plain Text