Files

210 lines
8.4 KiB
TypeScript
Raw Permalink Normal View History

/**
* Tests for usage-limit pause detection (auto-resume on usage limit).
*
* Message corpus mirrors real Claude Code output observed across
* 1.0.x–2.1.x (GitHub issues + official error docs). Time expectations are
* computed with the same local-Date APIs the implementation uses, so the
* tests are timezone-independent.
*/
import { describe, it, expect } from 'vitest';
import { detectUsageLimitPause } from '../src/usage-limit-patterns.js';
/** Fixed "now" for deterministic tests: a real timestamp, any value works. */
const NOW = new Date(2026, 5, 10, 14, 0, 0).getTime(); // local Jun 10 2026, 2:00pm
/** Expected epoch ms for the next local occurrence of hour:minute after NOW. */
function nextLocal(hour: number, minute = 0, from = NOW): number {
const d = new Date(from);
let ts = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hour, minute).getTime();
if (ts <= from) ts += 24 * 60 * 60 * 1000;
return ts;
}
describe('detectUsageLimitPause', () => {
describe('era 2 footer forms (v1.0.109+, ∙ separator, no timezone)', () => {
it.each([
['5-hour limit reached ∙ resets 8pm', 20, 0],
['Session limit reached ∙ resets 8pm', 20, 0],
['Weekly limit reached ∙ resets 6pm', 18, 0],
['5-hour limit reached ∙ resets 10:30pm', 22, 30],
['5-hour limit reached ∙ resets 3am', 3, 0],
['5-hour limit reached ∙ resets 12am', 0, 0],
['5-hour limit reached ∙ resets 12pm', 12, 0],
])('parses "%s"', (msg, hour, minute) => {
const det = detectUsageLimitPause(msg, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(nextLocal(hour, minute));
});
it('rolls over to tomorrow when the time already passed today', () => {
// NOW is 2:00pm local; "resets 9am" must be tomorrow 9am
const det = detectUsageLimitPause('5-hour limit reached ∙ resets 9am', NOW);
expect(det!.resetAt).toBe(nextLocal(9, 0));
expect(det!.resetAt).toBeGreaterThan(NOW);
});
});
describe('era 3 forms (v2.0.55+, · separator, IANA timezone, action hints)', () => {
it('parses with timezone and /upgrade suffix', () => {
const det = detectUsageLimitPause(
'5-hour limit reached · resets 3pm (Europe/Stockholm) · /upgrade to Max 20x or turn on /extra-usage',
NOW
);
expect(det).not.toBeNull();
// 3pm in Stockholm (UTC+2 in June) = 13:00 UTC
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(13);
expect(det!.resetAt).toBeGreaterThan(NOW);
});
it('parses generic "Limit reached" with minutes and timezone', () => {
const det = detectUsageLimitPause(
'Limit reached · resets 11:30am (Asia/Calcutta) · /upgrade to Max or turn on /extra-usage',
NOW
);
expect(det).not.toBeNull();
// 11:30am IST (UTC+5:30) = 06:00 UTC
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(6);
expect(d.getUTCMinutes()).toBe(0);
});
it('falls back to local time for unresolvable timezone (Etc/Unknown)', () => {
const det = detectUsageLimitPause('Limit reached · resets 5pm (Etc/Unknown)', NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(nextLocal(17, 0));
});
it('parses weekly date form "Dec 2, 7pm"', () => {
const det = detectUsageLimitPause('Weekly limit reached · resets Dec 2, 7pm (Europe/Moscow) ·', NOW);
// Dec 2 is >8 days from Jun 10 → implausible horizon → rejected
expect(det).toBeNull();
});
it('parses near-future date form within the horizon', () => {
const det = detectUsageLimitPause('Weekly limit reached ∙ resets Jun 15, 1pm', NOW);
expect(det).not.toBeNull();
const expected = new Date(2026, 5, 15, 13, 0).getTime();
expect(det!.resetAt).toBe(expected);
});
it('parses malformed separator-less render', () => {
const det = detectUsageLimitPause('Limit reached resets 1:30pm (Asia/Calcutta) ·', NOW);
expect(det).not.toBeNull();
});
});
describe('era 4 forms (v2.1.x, "You\'ve hit your limit")', () => {
it.each([
["You've hit your limit · resets 1:40pm (UTC)", 13, 40],
["You've hit your session limit · resets 3:45pm", 15, 45],
["You've hit your Opus limit · resets 3:45pm", 15, 45],
])('parses "%s"', (msg) => {
const det = detectUsageLimitPause(msg, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBeGreaterThan(NOW);
});
it('parses day-of-week form "resets Mon 12:00am"', () => {
const det = detectUsageLimitPause("You've hit your weekly limit · resets Mon 12:00am", NOW);
expect(det).not.toBeNull();
const d = new Date(det!.resetAt);
expect(d.getDay()).toBe(1); // Monday
expect(d.getHours()).toBe(0);
expect(det!.resetAt).toBeGreaterThan(NOW);
expect(det!.resetAt).toBeLessThanOrEqual(NOW + 8 * 24 * 60 * 60 * 1000);
});
it('parses "May 5 at 9pm" date-with-at form (next-year rollover)', () => {
// NOW is Jun 10 2026 — "May 5" is >2 days past → next year → beyond horizon → null
const det = detectUsageLimitPause("You've hit your limit · resets May 5 at 9pm (UTC)", NOW);
expect(det).toBeNull();
});
it('parses "Jun 12 at 9pm" date-with-at form within horizon', () => {
const det = detectUsageLimitPause("You've hit your limit · resets Jun 12 at 9pm", NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(new Date(2026, 5, 12, 21, 0).getTime());
});
it('parses extra-usage form "You\'re out of extra usage"', () => {
const det = detectUsageLimitPause("You're out of extra usage · resets 1pm (UTC)", NOW);
expect(det).not.toBeNull();
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(13);
});
});
describe('era 1 inline form (v1.0.x)', () => {
it('parses "Your limit will reset at 2pm (America/New_York)"', () => {
const det = detectUsageLimitPause(
'Claude usage limit reached. Your limit will reset at 2pm (America/New_York)',
NOW
);
expect(det).not.toBeNull();
// 2pm EDT (UTC-4 in June) = 18:00 UTC
expect(new Date(det!.resetAt).getUTCHours()).toBe(18);
});
});
describe('raw API epoch form', () => {
it('parses "Claude AI usage limit reached|<epoch>"', () => {
const epoch = Math.floor(NOW / 1000) + 3600; // resets in 1h
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(epoch * 1000);
});
it('returns past epoch as-is (stale message → caller retries soon)', () => {
const epoch = Math.floor(NOW / 1000) - 600;
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBeLessThan(NOW);
});
});
describe('transcript and multi-line contexts', () => {
it('detects the ⎿-prefixed transcript echo', () => {
const det = detectUsageLimitPause(
"⎿ You've hit your limit · resets 6pm (Asia/Bangkok)\n /upgrade to increase your usage limit.",
NOW
);
expect(det).not.toBeNull();
});
it('returns the LAST parseable occurrence in a chunk', () => {
const chunk = [
'5-hour limit reached ∙ resets 3pm',
'some scrollback text',
'5-hour limit reached ∙ resets 5pm',
].join('\n');
const det = detectUsageLimitPause(chunk, NOW);
expect(det!.resetAt).toBe(nextLocal(17, 0));
});
});
describe('false-positive guards', () => {
it.each([
'the rate limit reached in tests was 30/min',
'limit reached',
'Usage limit reached', // bare banner: no reset time → not actionable
'we reset at dawn',
'resets 3pm', // time without a limit phrase
'The speed limit reached 120 km/h before it resets',
'5-hour limit reached ∙ resets 25pm', // invalid hour
'limit reached ∙ resets 99:99pm',
])('ignores "%s"', (msg) => {
expect(detectUsageLimitPause(msg, NOW)).toBeNull();
});
it('ignores empty and irrelevant data', () => {
expect(detectUsageLimitPause('', NOW)).toBeNull();
expect(detectUsageLimitPause('compiling project...', NOW)).toBeNull();
});
it('requires the reset time within the window after the phrase', () => {
const farApart = 'limit reached' + ' x'.repeat(200) + ' resets 3pm';
expect(detectUsageLimitPause(farApart, NOW)).toBeNull();
});
});
});