311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
isRateLimitMessage,
|
|
parseRateLimitResetTime,
|
|
} from '../../../src/shared/utils/rateLimitDetector';
|
|
|
|
// Helper: every production rate-limit message starts with this substring.
|
|
// Prefix test inputs so they clear the parser's rate-limit-context gate.
|
|
const RL = "You've hit your limit. ";
|
|
const MODEL_COOLDOWN_API_ERROR =
|
|
'API Error: 429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_seconds":41,"reset_time":"40s"}}';
|
|
const MODEL_COOLDOWN_NO_SECONDS_API_ERROR =
|
|
'API Error: 429 {"error":{"code":"model_cooldown","message":"All credentials for model claude-opus-4-6 are cooling down via provider claude","model":"claude-opus-4-6","provider":"claude","reset_time":"40s"}}';
|
|
|
|
describe('isRateLimitMessage', () => {
|
|
it('detects the canonical substring', () => {
|
|
expect(isRateLimitMessage("You've hit your limit")).toBe(true);
|
|
expect(
|
|
isRateLimitMessage("You've hit your limit. Your limit will reset at 3pm (PST).")
|
|
).toBe(true);
|
|
});
|
|
|
|
it('returns false for unrelated text', () => {
|
|
expect(isRateLimitMessage('All good here')).toBe(false);
|
|
expect(isRateLimitMessage('hit the limit')).toBe(false); // missing "You've"
|
|
expect(isRateLimitMessage('')).toBe(false);
|
|
});
|
|
|
|
it('detects structured model_cooldown API errors as rate limits', () => {
|
|
expect(isRateLimitMessage(MODEL_COOLDOWN_API_ERROR)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('parseRateLimitResetTime', () => {
|
|
// ---------------------------------------------------------------------
|
|
// Rate-limit context gate
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('returns null for text that is not a rate-limit message', () => {
|
|
// Even if the text contains a parseable "reset at X" clause, the parser
|
|
// must refuse to interpret it when the rate-limit context is absent.
|
|
// Protects against false positives like "reset at 3pm (PST)" appearing
|
|
// in unrelated prose.
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(
|
|
parseRateLimitResetTime('Please reset your expectations at 3pm (PST).', now)
|
|
).toBeNull();
|
|
expect(parseRateLimitResetTime('Resets in 2 hours.', now)).toBeNull();
|
|
});
|
|
|
|
it('parses model_cooldown reset_seconds from structured API errors', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(MODEL_COOLDOWN_API_ERROR, now);
|
|
expect(result?.toISOString()).toBe('2026-04-17T12:00:41.000Z');
|
|
});
|
|
|
|
it('falls back to structured reset_time when reset_seconds is missing', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(MODEL_COOLDOWN_NO_SECONDS_API_ERROR, now);
|
|
expect(result?.toISOString()).toBe('2026-04-17T12:00:40.000Z');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Relative durations
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('parses "resets in N hours"', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets in 2 hours.`, now);
|
|
expect(result?.toISOString()).toBe('2026-04-17T14:00:00.000Z');
|
|
});
|
|
|
|
it('parses "resets in N minutes"', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Will reset in 45 minutes.`, now);
|
|
expect(result?.toISOString()).toBe('2026-04-17T12:45:00.000Z');
|
|
});
|
|
|
|
it('parses "resets in N seconds"', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets in 90 seconds.`, now);
|
|
expect(result?.toISOString()).toBe('2026-04-17T12:01:30.000Z');
|
|
});
|
|
|
|
it('parses "hrs" and "mins" abbreviations', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Resets in 3 hrs.`, now)?.toISOString()
|
|
).toBe('2026-04-17T15:00:00.000Z');
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Resets in 15 mins.`, now)?.toISOString()
|
|
).toBe('2026-04-17T12:15:00.000Z');
|
|
});
|
|
|
|
it('parses bare "h" / "m" / "s" single-letter units', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(parseRateLimitResetTime(`${RL}Resets in 2 h.`, now)?.toISOString()).toBe(
|
|
'2026-04-17T14:00:00.000Z'
|
|
);
|
|
expect(parseRateLimitResetTime(`${RL}Resets in 30 m.`, now)?.toISOString()).toBe(
|
|
'2026-04-17T12:30:00.000Z'
|
|
);
|
|
expect(parseRateLimitResetTime(`${RL}Resets in 45 s.`, now)?.toISOString()).toBe(
|
|
'2026-04-17T12:00:45.000Z'
|
|
);
|
|
});
|
|
|
|
it('parses "resets in about 30 minutes" with filler words', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(
|
|
`${RL}Your limit will reset in about 30 minutes.`,
|
|
now
|
|
);
|
|
expect(result?.toISOString()).toBe('2026-04-17T12:30:00.000Z');
|
|
});
|
|
|
|
it('parses "around" and "~" filler variants', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Your limit will reset in around 30 minutes.`, now)?.toISOString()
|
|
).toBe('2026-04-17T12:30:00.000Z');
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Your limit will reset in ~ 45 seconds.`, now)?.toISOString()
|
|
).toBe('2026-04-17T12:00:45.000Z');
|
|
});
|
|
|
|
it('parses fractional hours', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets in 1.5 hours.`, now);
|
|
expect(result?.toISOString()).toBe('2026-04-17T13:30:00.000Z');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Absolute clock times with timezone
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('parses "resets at 3pm (PST)"', () => {
|
|
// 3pm PST = 23:00 UTC (PST = UTC-8)
|
|
const now = new Date('2026-04-17T12:00:00Z'); // earlier than 23:00 UTC
|
|
const result = parseRateLimitResetTime(
|
|
`${RL}Your limit will reset at 3pm (PST).`,
|
|
now
|
|
);
|
|
expect(result?.toISOString()).toBe('2026-04-17T23:00:00.000Z');
|
|
});
|
|
|
|
it('parses "resets at 3:30 pm (PST)"', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(
|
|
`${RL}Your limit will reset at 3:30 pm (PST).`,
|
|
now
|
|
);
|
|
expect(result?.toISOString()).toBe('2026-04-17T23:30:00.000Z');
|
|
});
|
|
|
|
it('parses 24-hour time with UTC', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(
|
|
`${RL}Your limit will reset at 15:30 UTC.`,
|
|
now
|
|
);
|
|
expect(result?.toISOString()).toBe('2026-04-17T15:30:00.000Z');
|
|
});
|
|
|
|
it('parses bare timezone abbreviation without parentheses', () => {
|
|
// Regex group 5 path: "3pm PST" (no parens) should parse same as "(PST)".
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(
|
|
`${RL}Your limit will reset at 3pm PST.`,
|
|
now
|
|
);
|
|
expect(result?.toISOString()).toBe('2026-04-17T23:00:00.000Z');
|
|
});
|
|
|
|
it('parses non-PST North American timezones', () => {
|
|
// Cover each zone in the whitelist — regression guard against map typos.
|
|
const now = new Date('2026-04-17T02:00:00Z');
|
|
// 3am EST = UTC-5 → 08:00 UTC
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Resets at 3am (EST).`, now)?.toISOString()
|
|
).toBe('2026-04-17T08:00:00.000Z');
|
|
// 3am EDT = UTC-4 → 07:00 UTC
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Resets at 3am (EDT).`, now)?.toISOString()
|
|
).toBe('2026-04-17T07:00:00.000Z');
|
|
// 3am CST = UTC-6 → 09:00 UTC
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Resets at 3am (CST).`, now)?.toISOString()
|
|
).toBe('2026-04-17T09:00:00.000Z');
|
|
// 3am MDT = UTC-6 → 09:00 UTC
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Resets at 3am (MDT).`, now)?.toISOString()
|
|
).toBe('2026-04-17T09:00:00.000Z');
|
|
});
|
|
|
|
it('rolls forward to tomorrow when the time has already passed today', () => {
|
|
// 3pm PST = 23:00 UTC; if "now" is 23:30 UTC, the parsed 23:00 should
|
|
// roll to tomorrow rather than return a time in the past.
|
|
const now = new Date('2026-04-17T23:30:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets at 3pm (PST).`, now);
|
|
expect(result?.toISOString()).toBe('2026-04-18T23:00:00.000Z');
|
|
});
|
|
|
|
it('does NOT roll forward for near-present timestamps (within the 1-minute tolerance)', () => {
|
|
// Parsed time is 20s in the past (stale message / clock skew). A full
|
|
// 24h rollover here would trip the scheduler's 12h ceiling and silently
|
|
// drop auto-resume. Instead, the parser returns the near-past time and
|
|
// lets the scheduler's buffer + Math.max(0, ...) clamp take over.
|
|
const now = new Date('2026-04-17T23:00:20Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets at 3pm (PST).`, now);
|
|
// 3pm PST = 23:00 UTC (today) — stays in the past, not rolled.
|
|
expect(result?.toISOString()).toBe('2026-04-17T23:00:00.000Z');
|
|
});
|
|
|
|
it('resolves the zone-local calendar date when UTC and zone disagree on the day', () => {
|
|
// now = 2026-04-18T01:00:00Z which is still 2026-04-17 17:00 PST.
|
|
// "8pm (PST)" on that PST day = 2026-04-17T20:00 PST = 2026-04-18T04:00Z.
|
|
// A naive UTC-anchored build would emit 2026-04-19T04:00Z (24h off).
|
|
const now = new Date('2026-04-18T01:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets at 8pm (PST).`, now);
|
|
expect(result?.toISOString()).toBe('2026-04-18T04:00:00.000Z');
|
|
});
|
|
|
|
it('handles the mirror case for positive offsets crossing the UTC day', () => {
|
|
// 02:00 UTC today is already in the past vs 23:00 UTC → roll to tomorrow.
|
|
const now = new Date('2026-04-17T23:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets at 02:00 UTC.`, now);
|
|
expect(result?.toISOString()).toBe('2026-04-18T02:00:00.000Z');
|
|
});
|
|
|
|
it('handles 12am (midnight) correctly', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets at 12am UTC.`, now);
|
|
// Same day midnight is already in the past relative to noon; rolls to next day.
|
|
expect(result?.toISOString()).toBe('2026-04-18T00:00:00.000Z');
|
|
});
|
|
|
|
it('handles 12pm (noon) correctly', () => {
|
|
const now = new Date('2026-04-17T06:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Resets at 12pm UTC.`, now);
|
|
expect(result?.toISOString()).toBe('2026-04-17T12:00:00.000Z');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Day-shift qualifiers — should bail out rather than guess today/tomorrow
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('returns null when the reset is qualified with "next week"', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Reset at 3pm (PST) next week.`, now)
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns null when the reset is qualified with "tomorrow"', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Reset at 9am UTC tomorrow.`, now)
|
|
).toBeNull();
|
|
});
|
|
|
|
it('returns null when the reset is qualified with a day of week', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Reset at 3pm (PST) on Tuesday.`, now)
|
|
).toBeNull();
|
|
expect(
|
|
parseRateLimitResetTime(`${RL}Reset at 9am UTC on Mon.`, now)
|
|
).toBeNull();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Unparseable / ambiguous cases
|
|
// ---------------------------------------------------------------------
|
|
|
|
it('returns null when no reset time is present', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(parseRateLimitResetTime("You've hit your limit.", now)).toBeNull();
|
|
expect(parseRateLimitResetTime('', now)).toBeNull();
|
|
});
|
|
|
|
it('returns null for unknown parenthesized timezone abbreviations', () => {
|
|
// Parenthesized TZ is authoritative — unknown means "sender meant a
|
|
// specific zone we don't model"; bail out rather than guess.
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(parseRateLimitResetTime(`${RL}Resets at 3pm (CEST).`, now)).toBeNull();
|
|
});
|
|
|
|
it('falls back to local time when a trailing word looks like a TZ but is not one', () => {
|
|
// "3pm today" used to capture "TODAY" as an unknown TZ and suppress
|
|
// the whole message. Now the parser ignores the bare token and treats
|
|
// "3pm" as user-local. Assert a parse happens (non-null result) rather
|
|
// than pinning the UTC value, since local time depends on the runner.
|
|
const now = new Date('2026-04-17T06:00:00Z');
|
|
const result = parseRateLimitResetTime(`${RL}Reset at 3pm today.`, now);
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
it('returns null for invalid clock values', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
expect(parseRateLimitResetTime(`${RL}Resets at 25:00 UTC.`, now)).toBeNull();
|
|
expect(parseRateLimitResetTime(`${RL}Resets at 10:99 UTC.`, now)).toBeNull();
|
|
});
|
|
|
|
it('returns null for negative relative durations', () => {
|
|
const now = new Date('2026-04-17T12:00:00Z');
|
|
// Regex requires \d+ so "-2" won't match; we'd get null anyway, but verify.
|
|
expect(parseRateLimitResetTime(`${RL}Resets in -2 hours.`, now)).toBeNull();
|
|
});
|
|
});
|