返回 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 refusalMessage,
20 resolveApprovalTarget,
21 resolveReplyTarget,
22 sessionTarget,
23 streamCursor,
24 threadTarget,
25 } from "./app.mjs";
26
27 /** Minimal stand-in for the live stream state the real client threads through. */
28 function streamState(threadId, approvalIds = []) {
29 return { threadId, approvals: new Map(approvalIds.map((id) => [id, {}])) };
30 }
31
32 describe("canReply", () => {
33 it("admits only a live thread with an id", () => {
34 expect(canReply(threadTarget("thread-a"))).toBe(true);
35 expect(canReply(sessionTarget("session-a"))).toBe(false);
36 expect(canReply(NO_TARGET)).toBe(false);
37 expect(canReply(threadTarget(""))).toBe(false);
38 expect(canReply(undefined)).toBe(false);
39 });
40 });
41
42 describe("resolveReplyTarget", () => {
43 it("resolves a live thread that the stream is following", () => {
44 const resolved = resolveReplyTarget(threadTarget("thread-a"), streamState("thread-a"));
45 expect(resolved).toEqual({ ok: true, threadId: "thread-a" });
46 });
47
48 it("resolves a live thread before any stream state exists", () => {
49 // A freshly created thread has no stream yet; refusing here would make the
50 // first message of every new thread impossible to send.
51 expect(resolveReplyTarget(threadTarget("thread-a"), null)).toEqual({
52 ok: true,
53 threadId: "thread-a",
54 });
55 expect(resolveReplyTarget(threadTarget("thread-a"), { threadId: "" })).toEqual({
56 ok: true,
57 threadId: "thread-a",
58 });
59 });
60
61 it("refuses a saved session — a recording has no runtime to receive a reply", () => {
62 expect(resolveReplyTarget(sessionTarget("session-a"), streamState("thread-a"))).toEqual({
63 ok: false,
64 reason: "session-not-live",
65 });
66 });
67
68 it("refuses when nothing is selected", () => {
69 expect(resolveReplyTarget(NO_TARGET, streamState("thread-a"))).toEqual({
70 ok: false,
71 reason: "no-target",
72 });
73 expect(resolveReplyTarget(null, streamState("thread-a"))).toEqual({
74 ok: false,
75 reason: "no-target",
76 });
77 expect(resolveReplyTarget(threadTarget(""), streamState("thread-a"))).toEqual({
78 ok: false,
79 reason: "no-target",
80 });
81 });
82
83 it("refuses a stale target: the stream moved on while the user did not", () => {
84 expect(resolveReplyTarget(threadTarget("thread-a"), streamState("thread-b"))).toEqual({
85 ok: false,
86 reason: "stale-target",
87 });
88 });
89
90 it("never returns an id on refusal", () => {
91 for (const [target, state] of [
92 [sessionTarget("session-a"), streamState("thread-a")],
93 [NO_TARGET, streamState("thread-a")],
94 [threadTarget("thread-a"), streamState("thread-b")],
95 ]) {
96 expect(resolveReplyTarget(target, state).threadId).toBeUndefined();
97 }
98 });
99 });
100
101 describe("resolveApprovalTarget", () => {
102 it("resolves an approval the watched thread actually holds", () => {
103 const state = streamState("thread-a", ["approval-1"]);
104 expect(resolveApprovalTarget("approval-1", threadTarget("thread-a"), state)).toEqual({
105 ok: true,
106 threadId: "thread-a",
107 approvalId: "approval-1",
108 });
109 });
110
111 it("inherits every reply refusal — authority cannot outrank the target check", () => {
112 const state = streamState("thread-a", ["approval-1"]);
113 expect(resolveApprovalTarget("approval-1", sessionTarget("session-a"), state).reason).toBe(
114 "session-not-live",
115 );
116 expect(resolveApprovalTarget("approval-1", NO_TARGET, state).reason).toBe("no-target");
117 expect(
118 resolveApprovalTarget("approval-1", threadTarget("thread-b"), state).reason,
119 ).toBe("stale-target");
120 });
121
122 it("refuses an approval that is not in the live approval set", () => {
123 // Already decided, expired, or belonging to a thread we stopped watching.
124 const state = streamState("thread-a", ["approval-1"]);
125 expect(resolveApprovalTarget("approval-2", threadTarget("thread-a"), state)).toEqual({
126 ok: false,
127 reason: "stale-approval",
128 });
129 });
130
131 it("refuses when the stream state is missing or belongs to another thread", () => {
132 expect(resolveApprovalTarget("approval-1", threadTarget("thread-a"), null)).toEqual({
133 ok: false,
134 reason: "stale-target",
135 });
136 // A reply would be allowed here (no stream yet), but an approval must not:
137 // there is no live approval set to check membership against.
138 expect(
139 resolveApprovalTarget("approval-1", threadTarget("thread-a"), { threadId: "" }),
140 ).toEqual({ ok: false, reason: "stale-target" });
141 });
142
143 it("refuses an unidentified approval", () => {
144 const state = streamState("thread-a", ["approval-1"]);
145 expect(resolveApprovalTarget("", threadTarget("thread-a"), state)).toEqual({
146 ok: false,
147 reason: "no-approval",
148 });
149 });
150
151 it("refuses when the thread has no approvals at all", () => {
152 expect(
153 resolveApprovalTarget("approval-1", threadTarget("thread-a"), { threadId: "thread-a" }),
154 ).toEqual({ ok: false, reason: "stale-approval" });
155 });
156
157 it("never returns an approval id on refusal", () => {
158 const state = streamState("thread-a", ["approval-1"]);
159 for (const [id, target] of [
160 ["approval-2", threadTarget("thread-a")],
161 ["approval-1", sessionTarget("session-a")],
162 ["", threadTarget("thread-a")],
163 ]) {
164 expect(resolveApprovalTarget(id, target, state).approvalId).toBeUndefined();
165 }
166 });
167 });
168
169 describe("refusalMessage", () => {
170 it("gives every refusal reason a distinct user-visible sentence", () => {
171 const reasons = ["session-not-live", "stale-target", "stale-approval", "no-approval", "no-target"];
172 const messages = reasons.map(refusalMessage);
173 expect(new Set(messages).size).toBe(reasons.length);
174 for (const message of messages) {
175 // Every refusal must state the outcome, not just the cause: the user
176 // needs to know their message did not go anywhere.
177 expect(message).toContain("nothing was sent");
178 }
179 });
180
181 it("falls back to the select-a-thread message for an unknown reason", () => {
182 expect(refusalMessage("something-new")).toBe(refusalMessage("no-target"));
183 });
184 });
185
186 describe("streamCursor", () => {
187 it("reports the resume sequence and a live label", () => {
188 expect(streamCursor({ latestSeq: 12 })).toEqual({
189 latestSeq: 12,
190 gap: false,
191 connected: true,
192 label: "Live — event #12",
193 });
194 });
195
196 it("names a gap and a disconnect distinctly, both carrying the resume point", () => {
197 expect(streamCursor({ latestSeq: 5 }, { gap: true }).label).toBe(
198 "Gap detected — re-syncing from #5",
199 );
200 expect(streamCursor({ latestSeq: 5 }, { connected: false }).label).toBe(
201 "Reconnecting — resuming from #5",
202 );
203 });
204
205 it("normalizes a missing or nonsense sequence to zero rather than NaN", () => {
206 expect(streamCursor(undefined).latestSeq).toBe(0);
207 expect(streamCursor({ latestSeq: "not a number" }).latestSeq).toBe(0);
208 });
209 });
210
210 lines Plain Text