feat(FN-1397): strengthen ProjectOverview tests with sorting and filtering behavior

This commit is contained in:
gsxdsm
2026-04-09 15:03:52 -07:00
parent ebf7e6ec74
commit 5462a3085a
2 changed files with 396 additions and 9 deletions

View File

@@ -10,6 +10,7 @@ import {
_parseClaudePercentLine, _parseClaudePercentLine,
_parseClaudeResetLine, _parseClaudeResetLine,
_parseClaudeResetText, _parseClaudeResetText,
_parseResetTimestamp,
withTimeout, withTimeout,
CLAUDE_FETCH_TIMEOUT_MS, CLAUDE_FETCH_TIMEOUT_MS,
_clearRefreshedToken, _clearRefreshedToken,
@@ -3328,4 +3329,332 @@ describe("usage", () => {
expect(claude.error).toContain("internal server error"); expect(claude.error).toContain("internal server error");
}); });
}); });
describe("_parseResetTimestamp helper", () => {
it("parses ISO string format", () => {
const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000); // 2 hours from now
const result = _parseResetTimestamp(futureDate.toISOString());
expect(result).not.toBeNull();
expect(result!.msLeft).toBeGreaterThan(0);
expect(result!.msLeft).toBeLessThanOrEqual(2 * 60 * 60 * 1000);
});
it("parses Unix timestamp in seconds (10 digits)", () => {
const futureSeconds = Math.floor((Date.now() + 3 * 60 * 60 * 1000) / 1000); // 3 hours from now
const result = _parseResetTimestamp(futureSeconds);
expect(result).not.toBeNull();
expect(result!.msLeft).toBeGreaterThan(0);
expect(result!.msLeft).toBeLessThanOrEqual(3 * 60 * 60 * 1000);
});
it("parses Unix timestamp in milliseconds (13 digits)", () => {
const futureMs = Date.now() + 4 * 60 * 60 * 1000; // 4 hours from now
const result = _parseResetTimestamp(futureMs);
expect(result).not.toBeNull();
expect(result!.msLeft).toBeGreaterThan(0);
expect(result!.msLeft).toBeLessThanOrEqual(4 * 60 * 60 * 1000);
});
it("parses numeric string in seconds", () => {
const futureSeconds = Math.floor((Date.now() + 5 * 60 * 60 * 1000) / 1000); // 5 hours from now
const result = _parseResetTimestamp(String(futureSeconds));
expect(result).not.toBeNull();
expect(result!.msLeft).toBeGreaterThan(0);
expect(result!.msLeft).toBeLessThanOrEqual(5 * 60 * 60 * 1000);
});
it("parses numeric string in milliseconds", () => {
const futureMs = Date.now() + 6 * 60 * 60 * 1000; // 6 hours from now
const result = _parseResetTimestamp(String(futureMs));
expect(result).not.toBeNull();
expect(result!.msLeft).toBeGreaterThan(0);
expect(result!.msLeft).toBeLessThanOrEqual(6 * 60 * 60 * 1000);
});
it("returns null for null value", () => {
expect(_parseResetTimestamp(null)).toBeNull();
});
it("returns null for undefined value", () => {
expect(_parseResetTimestamp(undefined)).toBeNull();
});
it("returns null for empty string", () => {
expect(_parseResetTimestamp("")).toBeNull();
});
it("returns null for invalid date string", () => {
expect(_parseResetTimestamp("invalid-date")).toBeNull();
expect(_parseResetTimestamp("not a timestamp")).toBeNull();
});
it("returns null for past timestamp", () => {
const pastMs = Date.now() - 60 * 60 * 1000; // 1 hour ago
expect(_parseResetTimestamp(pastMs)).toBeNull();
expect(_parseResetTimestamp(pastMs / 1000)).toBeNull(); // seconds format
});
it("returns null for zero timestamp", () => {
expect(_parseResetTimestamp(0)).toBeNull();
});
});
describe("Claude session reset fallback scenarios", () => {
function setupClaudeMocks(options: {
credFileContent?: any;
keychainContent?: any;
}) {
const { credFileContent = null, keychainContent = null } = options;
mockReadFileSync.mockImplementation((filePath: string) => {
if (filePath.includes("claude") && credFileContent !== null) {
return JSON.stringify(credFileContent);
}
throw new Error("File not found");
});
mockExecFileSync.mockImplementation((cmd: string, _args: string[]) => {
if (cmd === "security") {
if (keychainContent !== null) {
return JSON.stringify(keychainContent);
}
throw new Error("Keychain item not found");
}
throw new Error(`Unexpected command: ${cmd}`);
});
}
function setupClaudeApiResponse(mockResponse: any, statusCode = 200) {
const mockReq = {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
mockRequest.mockImplementation((_options: any, callback: any) => {
const mockRes = {
statusCode,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from(JSON.stringify(mockResponse)));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
}
it("applies 5h fallback when resets_at is an invalid string", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
// API returns invalid reset timestamp
setupClaudeApiResponse({
five_hour: {
utilization: 30.0,
resets_at: "invalid-date-string",
},
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
expect(sessionWindow).toBeDefined();
// Should use 5h fallback
expect(sessionWindow.resetMs).toBe(5 * 60 * 60 * 1000);
expect(sessionWindow.resetText).toBe("resets in 5h");
expect(sessionWindow.resetAt).toBeDefined();
});
it("applies 5h fallback when resets_at is a past timestamp", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
// API returns a past reset timestamp (1 hour ago)
const pastReset = new Date(Date.now() - 60 * 60 * 1000).toISOString();
setupClaudeApiResponse({
five_hour: {
utilization: 30.0,
resets_at: pastReset,
},
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
expect(sessionWindow).toBeDefined();
// Should use 5h fallback since the reset time is in the past
expect(sessionWindow.resetMs).toBe(5 * 60 * 60 * 1000);
expect(sessionWindow.resetText).toBe("resets in 5h");
expect(sessionWindow.resetAt).toBeDefined();
});
it("applies 5h fallback when resets_at is empty string", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
// API returns empty string for reset timestamp
setupClaudeApiResponse({
five_hour: {
utilization: 30.0,
resets_at: "",
},
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
expect(sessionWindow).toBeDefined();
// Should use 5h fallback
expect(sessionWindow.resetMs).toBe(5 * 60 * 60 * 1000);
expect(sessionWindow.resetText).toBe("resets in 5h");
});
it("applies 5h fallback when resets_at is null", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
// API returns null for reset timestamp
setupClaudeApiResponse({
five_hour: {
utilization: 30.0,
resets_at: null,
},
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
expect(sessionWindow).toBeDefined();
// Should use 5h fallback
expect(sessionWindow.resetMs).toBe(5 * 60 * 60 * 1000);
expect(sessionWindow.resetText).toBe("resets in 5h");
});
it("parses resets_at as Unix seconds correctly", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
// API returns Unix timestamp in seconds (10 digits)
const futureSeconds = Math.floor((Date.now() + 2 * 60 * 60 * 1000) / 1000);
setupClaudeApiResponse({
five_hour: {
utilization: 30.0,
resets_at: futureSeconds, // Unix seconds
},
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
expect(sessionWindow.resetText).toContain("resets in");
expect(sessionWindow.resetMs).toBeGreaterThan(0);
expect(sessionWindow.resetAt).toBeDefined();
});
it("parses resets_at as Unix milliseconds correctly", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
// API returns Unix timestamp in milliseconds (13 digits)
const futureMs = Date.now() + 3 * 60 * 60 * 1000;
setupClaudeApiResponse({
five_hour: {
utilization: 30.0,
resets_at: futureMs, // Unix milliseconds
},
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
expect(sessionWindow.resetText).toContain("resets in");
expect(sessionWindow.resetMs).toBeGreaterThan(0);
expect(sessionWindow.resetAt).toBeDefined();
});
it("does NOT apply 5h fallback to weekly windows with missing reset", async () => {
setupClaudeMocks({
credFileContent: {
accessToken: "test-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
// Weekly window with missing reset - should NOT apply 5h fallback
setupClaudeApiResponse({
five_hour: {
utilization: 30.0,
},
seven_day: {
utilization: 15.0,
// No resets_at for weekly
},
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
// Session should have fallback
const sessionWindow = claude.windows.find((w) => w.label.includes("Session"))!;
expect(sessionWindow.resetText).toBe("resets in 5h");
expect(sessionWindow.resetMs).toBe(5 * 60 * 60 * 1000);
// Weekly should NOT have fallback (only session window gets fallback)
const weeklyWindow = claude.windows.find((w) => w.label === "Weekly")!;
expect(weeklyWindow.resetText).toBeNull();
expect(weeklyWindow.resetMs).toBeUndefined();
expect(weeklyWindow.resetAt).toBeUndefined();
});
});
}); });

View File

@@ -403,6 +403,51 @@ export function _parseClaudeResetLine(line: string): string | null {
return text || null; return text || null;
} }
/**
* Parse a reset timestamp value from the API into milliseconds until reset.
* Handles multiple formats: ISO strings, Unix timestamps (seconds or milliseconds),
* and numeric strings. Returns null if the value is unparseable or in the past.
*/
export function _parseResetTimestamp(value: unknown): { msLeft: number; resetAt: string } | null {
if (value === null || value === undefined || value === "") {
return null;
}
let timestampMs: number;
// Handle numeric values (Unix timestamps in seconds or milliseconds)
if (typeof value === "number") {
// Detect format: if >= 1e12, assume milliseconds; otherwise seconds
timestampMs = value >= 1e12 ? value : value * 1000;
} else if (typeof value === "string") {
// Try parsing as number first (for numeric strings)
const numericValue = Number(value);
if (!isNaN(numericValue)) {
// Detect format: if >= 1e12, assume milliseconds; otherwise seconds
timestampMs = numericValue >= 1e12 ? numericValue : numericValue * 1000;
} else {
// Try parsing as ISO string
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
return null; // Invalid date string
}
timestampMs = parsed.getTime();
}
} else {
return null;
}
const msLeft = timestampMs - Date.now();
if (msLeft <= 0) {
return null; // Already past
}
return {
msLeft,
resetAt: new Date(timestampMs).toISOString(),
};
}
/** /**
* Parse a reset-time text into an approximate ISO date string. * Parse a reset-time text into an approximate ISO date string.
*/ */
@@ -661,6 +706,16 @@ async function fetchClaudeUsageViaCli(): Promise<ProviderUsage> {
} }
} }
// Fallback for session window: when CLI output doesn't include reset info
// but we have usage percentage data, use the full window duration as a
// best-effort estimate. This enables pace calculation and reset text
// for the session (5h) window even when the CLI output is incomplete.
if (!window.resetText && section.windowMs === 5 * 60 * 60 * 1000) {
window.resetMs = section.windowMs;
window.resetText = "resets in 5h";
window.resetAt = new Date(Date.now() + section.windowMs).toISOString();
}
usage.windows.push(window); usage.windows.push(window);
} }
} }
@@ -852,20 +907,23 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? w.used_percent ?? w.usage_percent ?? 0; const pctUsed: number = w.utilization ?? w.percent_used ?? w.percentUsed ?? w.used_percent ?? w.usage_percent ?? 0;
let resetText: string | null = null; let resetText: string | null = null;
let resetMs: number | undefined; let resetMs: number | undefined;
let resetAt: string | undefined;
let resetAtValue = w.resets_at || w.reset_at || w.resetAt || w.resetsAt || w.reset_time || w.resetsAtTime; const resetAtValue = w.resets_at ?? w.reset_at ?? w.resetAt ?? w.resetsAt ?? w.reset_time ?? w.resetsAtTime;
if (resetAtValue) { const parsedReset = _parseResetTimestamp(resetAtValue);
const msLeft = new Date(resetAtValue).getTime() - Date.now();
resetMs = msLeft > 0 ? msLeft : 0; if (parsedReset) {
resetText = msLeft > 0 ? `resets in ${formatDuration(msLeft)}` : "resetting now"; resetMs = parsedReset.msLeft;
resetAt = parsedReset.resetAt;
resetText = `resets in ${formatDuration(parsedReset.msLeft)}`;
} else if (windowDurationMs === FIVE_HOURS_MS) { } else if (windowDurationMs === FIVE_HOURS_MS) {
// Fallback for session window: when the API doesn't provide reset time, // Fallback for session window: when the API doesn't provide a valid reset time,
// use the full window duration as a best-effort estimate. This enables // use the full window duration as a best-effort estimate. This enables
// pace calculation and reset text for the session (5h) window even when // pace calculation and reset text for the session (5h) window even when
// the API omits resets_at / reset_at / resetAt fields. // the API omits resets_at / reset_at / resetAt fields, or provides invalid values.
resetMs = windowDurationMs; resetMs = windowDurationMs;
resetText = "resets in 5h"; resetText = "resets in 5h";
resetAtValue = new Date(Date.now() + windowDurationMs).toISOString(); resetAt = new Date(Date.now() + windowDurationMs).toISOString();
} }
return { return {
@@ -875,7 +933,7 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
resetText, resetText,
windowDurationMs, windowDurationMs,
resetMs, resetMs,
resetAt: resetAtValue || undefined, resetAt,
}; };
}; };