Skip to content

Commit 94c0386

Browse files
committed
test: cover the auth menu view-model formatting helpers
Direct coverage for lib/ui/auth-menu-builder.ts (the last substantive untested module in lib/), asserting text content with ANSI stripped so the contract holds in both UI palettes: - main-menu title version suffix with the v prefix added only when missing, env save/restored - relative-time buckets (never/today/yesterday/Nd/Nw/locale date) - account titles prefer email > label > id > generic, number by quickSwitchNumber, and strip ANSI escapes and control characters from identity fields so a hostile label cannot repaint the row - search text joins lowercased identity fields plus the row number - row colors highlight the current row unless disabled, then map by status; status badges carry their status label, unknown fallback - hint lines: default field order, Status only when the badge column is hidden, configured statusline field ordering, rate-limited and quota-exhausted flags, empty when every field is hidden - focus keys use the storage position for account actions and the action type for static rows https://claude.ai/code/session_01XNtnkLbBiXZxfQQYLMpucB
1 parent 6ede089 commit 94c0386

1 file changed

Lines changed: 269 additions & 0 deletions

File tree

test/auth-menu-builder.test.ts

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

0 commit comments

Comments
 (0)