Skip to content

Commit 71a8df4

Browse files
committed
Merge pull request #573 from ndycode/claude/audit-54-auth-menu-builder-tests
test: cover the auth menu view-model formatting helpers
2 parents 43f0dcc + a9c60c0 commit 71a8df4

1 file changed

Lines changed: 315 additions & 0 deletions

File tree

test/auth-menu-builder.test.ts

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
accountRowColor,
4+
accountSearchText,
5+
accountTitle,
6+
authMenuFocusKey,
7+
currentMarkerLabel,
8+
formatAccountHint,
9+
formatDate,
10+
formatRelativeTime,
11+
mainMenuTitleWithVersion,
12+
statusBadge,
13+
type AccountInfo,
14+
type AuthMenuAction,
15+
} from "../lib/ui/auth-menu-builder.js";
16+
import {
17+
getUiRuntimeOptions,
18+
resetUiRuntimeOptions,
19+
setUiRuntimeOptions,
20+
} from "../lib/ui/runtime.js";
21+
import { UI_COPY } from "../lib/ui/ui-copy.js";
22+
23+
const NOW = Date.now();
24+
const DAY_MS = 86_400_000;
25+
26+
// All formatting helpers may paint with ANSI in either UI mode; the contract
27+
// under test is the text content, not the palette.
28+
function stripAnsi(value: string): string {
29+
30+
return value.replace(/\x1b\[[0-9;]*m/g, "");
31+
}
32+
33+
function account(overrides: Partial<AccountInfo> = {}): AccountInfo {
34+
return { index: 0, ...overrides };
35+
}
36+
37+
const originalVersionEnv = process.env.CODEX_MULTI_AUTH_CLI_VERSION;
38+
39+
beforeEach(() => {
40+
delete process.env.CODEX_MULTI_AUTH_CLI_VERSION;
41+
});
42+
43+
afterEach(() => {
44+
if (originalVersionEnv === undefined) {
45+
delete process.env.CODEX_MULTI_AUTH_CLI_VERSION;
46+
} else {
47+
process.env.CODEX_MULTI_AUTH_CLI_VERSION = originalVersionEnv;
48+
}
49+
});
50+
51+
describe("mainMenuTitleWithVersion", () => {
52+
it("returns the bare title when no CLI version is exported", () => {
53+
expect(mainMenuTitleWithVersion()).toBe(UI_COPY.mainMenu.title);
54+
});
55+
56+
it("appends the version, prefixing a v only when missing", () => {
57+
process.env.CODEX_MULTI_AUTH_CLI_VERSION = "1.2.3";
58+
expect(mainMenuTitleWithVersion()).toBe(
59+
`${UI_COPY.mainMenu.title} (v1.2.3)`,
60+
);
61+
62+
process.env.CODEX_MULTI_AUTH_CLI_VERSION = "v2.0.0";
63+
expect(mainMenuTitleWithVersion()).toBe(
64+
`${UI_COPY.mainMenu.title} (v2.0.0)`,
65+
);
66+
});
67+
});
68+
69+
describe("formatRelativeTime", () => {
70+
it.each([
71+
["never", undefined],
72+
["today", NOW - 1_000],
73+
["yesterday", NOW - DAY_MS - 1_000],
74+
["3d ago", NOW - 3 * DAY_MS - 1_000],
75+
["2w ago", NOW - 15 * DAY_MS],
76+
] as const)("renders %s", (expected, timestamp) => {
77+
expect(formatRelativeTime(timestamp)).toBe(expected);
78+
});
79+
80+
it("falls back to a locale date beyond a month", () => {
81+
const old = NOW - 60 * DAY_MS;
82+
expect(formatRelativeTime(old)).toBe(new Date(old).toLocaleDateString());
83+
});
84+
});
85+
86+
describe("accountTitle", () => {
87+
it("prefers email, then label, then id, then a generic name", () => {
88+
expect(accountTitle(account({ email: "a@example.com" }))).toBe(
89+
"1. a@example.com",
90+
);
91+
expect(accountTitle(account({ accountLabel: "Team A" }))).toBe(
92+
"1. Team A",
93+
);
94+
expect(accountTitle(account({ accountId: "acc_1" }))).toBe("1. acc_1");
95+
expect(accountTitle(account())).toBe("1. Account 1");
96+
});
97+
98+
it("numbers rows by quickSwitchNumber when present", () => {
99+
expect(
100+
accountTitle(account({ index: 4, quickSwitchNumber: 2, email: "a@b.c" })),
101+
).toBe("2. a@b.c");
102+
});
103+
104+
it("strips ANSI escapes and control characters from every identity field", () => {
105+
// Hostile identity values must not be able to repaint the menu row,
106+
// regardless of which field carries them.
107+
expect(
108+
accountTitle(account({ accountLabel: "\x1b[31mEvil\x1b[0m Label" })),
109+
).toBe("1. Evil Label");
110+
expect(
111+
accountTitle(account({ email: "\x1b[2Jevil@example.com" })),
112+
).toBe("1. evil@example.com");
113+
expect(accountTitle(account({ accountId: "acc\x1b[31m_red" }))).toBe(
114+
"1. acc_red",
115+
);
116+
});
117+
});
118+
119+
describe("accountSearchText", () => {
120+
it("joins the lowercased identity fields and the row number", () => {
121+
expect(
122+
accountSearchText(
123+
account({
124+
index: 1,
125+
email: "User@Example.com",
126+
accountLabel: "Team A",
127+
accountId: "ACC_9",
128+
}),
129+
),
130+
).toBe("user@example.com team a acc_9 2");
131+
});
132+
});
133+
134+
describe("accountRowColor", () => {
135+
it("highlights the current row green unless highlighting is disabled", () => {
136+
expect(
137+
accountRowColor(account({ isCurrentAccount: true, status: "disabled" })),
138+
).toBe("green");
139+
expect(
140+
accountRowColor(
141+
account({
142+
isCurrentAccount: true,
143+
highlightCurrentRow: false,
144+
status: "disabled",
145+
}),
146+
),
147+
).toBe("red");
148+
});
149+
150+
it.each([
151+
["green", "ok"],
152+
["yellow", "rate-limited"],
153+
["yellow", "cooldown"],
154+
["red", "flagged"],
155+
["yellow", undefined],
156+
] as const)("maps to %s for status %s", (expected, status) => {
157+
expect(accountRowColor(account({ status }))).toBe(expected);
158+
});
159+
});
160+
161+
describe("statusBadge", () => {
162+
it.each([
163+
"active",
164+
"ok",
165+
"quota-exhausted",
166+
"rate-limited",
167+
"cooldown",
168+
"flagged",
169+
"disabled",
170+
"error",
171+
] as const)("labels the %s badge with its status", (status) => {
172+
expect(stripAnsi(statusBadge(status))).toContain(status);
173+
});
174+
175+
it("labels missing statuses unknown", () => {
176+
expect(stripAnsi(statusBadge(undefined))).toContain("unknown");
177+
});
178+
});
179+
180+
describe("formatDate", () => {
181+
it("renders unknown for missing timestamps and a locale date otherwise", () => {
182+
expect(formatDate(undefined)).toBe("unknown");
183+
const ts = NOW - 10 * DAY_MS;
184+
expect(formatDate(ts)).toBe(new Date(ts).toLocaleDateString());
185+
});
186+
});
187+
188+
describe("currentMarkerLabel", () => {
189+
it("humanizes in-use and passes other markers through", () => {
190+
expect(currentMarkerLabel("in-use")).toBe("in use");
191+
expect(currentMarkerLabel("current")).toBe("current");
192+
expect(currentMarkerLabel("selected")).toBe("selected");
193+
});
194+
});
195+
196+
describe("formatAccountHint", () => {
197+
const ui = getUiRuntimeOptions();
198+
199+
it("renders last-used and quota limits in the default field order", () => {
200+
const hint = stripAnsi(
201+
formatAccountHint(
202+
account({
203+
lastUsed: NOW - 1_000,
204+
quota5hLeftPercent: 50,
205+
quota5hResetAtMs: NOW + 30_000,
206+
}),
207+
ui,
208+
),
209+
);
210+
211+
expect(hint).toMatch(/^Last used: today \| Limits: 5h /);
212+
expect(hint).toContain("50%");
213+
expect(hint).toContain("reset ");
214+
});
215+
216+
it("includes the status text only when the badge column is hidden", () => {
217+
const visibleBadge = stripAnsi(
218+
formatAccountHint(account({ status: "ok", lastUsed: NOW }), ui),
219+
);
220+
expect(visibleBadge).not.toContain("Status:");
221+
222+
const hiddenBadge = stripAnsi(
223+
formatAccountHint(
224+
account({ status: "ok", lastUsed: NOW, showStatusBadge: false }),
225+
ui,
226+
),
227+
);
228+
expect(hiddenBadge).toContain("Status: ok");
229+
});
230+
231+
it("orders the parts by the configured statusline fields", () => {
232+
const hint = stripAnsi(
233+
formatAccountHint(
234+
account({
235+
status: "ok",
236+
lastUsed: NOW,
237+
showStatusBadge: false,
238+
statuslineFields: ["status", "last-used"],
239+
}),
240+
ui,
241+
),
242+
);
243+
244+
expect(hint.indexOf("Status:")).toBeLessThan(hint.indexOf("Last used:"));
245+
});
246+
247+
it("flags rate-limited and exhausted accounts in the limits segment", () => {
248+
const hint = stripAnsi(
249+
formatAccountHint(
250+
account({
251+
lastUsed: NOW,
252+
quotaRateLimited: true,
253+
quotaExhausted: true,
254+
}),
255+
ui,
256+
),
257+
);
258+
259+
expect(hint).toContain("rate-limited");
260+
expect(hint).toContain("quota-exhausted");
261+
});
262+
263+
it("returns an empty string when every field is hidden", () => {
264+
expect(
265+
formatAccountHint(account({ showLastUsed: false }), ui),
266+
).toBe("");
267+
});
268+
});
269+
270+
describe("legacy (v1) palette rendering", () => {
271+
it("renders badges and hints with the same text content when v2 is off", () => {
272+
setUiRuntimeOptions({ v2Enabled: false });
273+
try {
274+
expect(stripAnsi(statusBadge("rate-limited"))).toContain(
275+
"rate-limited",
276+
);
277+
expect(stripAnsi(statusBadge(undefined))).toContain("unknown");
278+
279+
const hint = stripAnsi(
280+
formatAccountHint(
281+
account({
282+
lastUsed: NOW - 1_000,
283+
quota5hLeftPercent: 50,
284+
quota5hResetAtMs: NOW + 30_000,
285+
}),
286+
getUiRuntimeOptions(),
287+
),
288+
);
289+
expect(hint).toMatch(/^Last used: today \| Limits: 5h /);
290+
expect(hint).toContain("50%");
291+
} finally {
292+
resetUiRuntimeOptions();
293+
}
294+
});
295+
});
296+
297+
describe("authMenuFocusKey", () => {
298+
it("keys account actions by their storage position", () => {
299+
const row = account({ index: 2, sourceIndex: 5 });
300+
const action: AuthMenuAction = { type: "select-account", account: row };
301+
expect(authMenuFocusKey(action)).toBe("account:5");
302+
// Without a sourceIndex the display index is the identity.
303+
expect(
304+
authMenuFocusKey({
305+
type: "delete-account",
306+
account: account({ index: 2 }),
307+
}),
308+
).toBe("account:2");
309+
});
310+
311+
it("keys static actions by their type", () => {
312+
expect(authMenuFocusKey({ type: "add" })).toBe("action:add");
313+
expect(authMenuFocusKey({ type: "cancel" })).toBe("action:cancel");
314+
});
315+
});

0 commit comments

Comments
 (0)