Files
sim/packages/utils/src/formatting.test.ts
T
Waleed 9d34fbea1b refactor(utils): consolidate duplicated helpers onto @sim/utils (#5509)
* refactor(utils): consolidate duplicated helpers onto @sim/utils

Replaces ~90 hand-rolled reimplementations of error-message extraction,
postgres error-code checks, sleep, Math.random, retry/backoff, object
filtering/omission, noop, string truncation, date/time formatting, email
normalization, and plain-object type guards with the shared @sim/utils
exports. Wires check:utils into CI (test-build.yml) so these patterns
don't regress.

* fix(test): mock @sim/utils/random instead of Math.random in schedule-execute tests

The schedules/execute route previously used Math.random() for jitter delay;
this consolidation PR switched it to randomInt() from @sim/utils/random,
which is backed by crypto.getRandomValues() rather than Math.random(). The
route.test.ts spies on Math.random() no longer had any effect, so jitter
became real random delay instead of the deterministic 0ms the tests expect,
causing intermittent 10s timeouts in CI.

* fix(retry): preserve uncapped Retry-After comparison in tools/index.ts

parseRetryAfter() caps its return value at 30s by default. tools/index.ts
compares the parsed Retry-After against a caller-configured maxDelayMs to
decide whether to skip a retry entirely -- capping before that comparison
silently defeats the skip check whenever maxDelayMs is configured above
30s, since a Retry-After between 30s and maxDelayMs would incorrectly look
"within limits" and get retried instead of skipped (caught by Cursor
Bugbot). Added an optional maxMs param (default unchanged) so tools/index.ts
can request the raw, uncapped value for its own comparison while
backoffWithJitter still clamps the actual sleep duration to maxDelayMs.
Added a regression test covering maxDelayMs > 30s.

* fix(utils): fall back to Intl-resolved abbreviation for unmapped timezones

getTimezoneAbbreviation only covered 9 hardcoded IANA zones and returned
the raw IANA string for everything else, degrading schedule descriptions
for zones like Europe/Berlin or America/Toronto (caught by Greptile). The
deleted local implementation in schedules/utils.ts resolved any valid IANA
timezone generically via Intl.DateTimeFormat's short timeZoneName. Restore
that as a fallback so only genuinely invalid timezone strings return
themselves unchanged.
2026-07-08 11:26:23 -07:00

185 lines
5.3 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
formatAbsoluteDate,
formatCompactTimestamp,
formatDate,
formatDateTime,
formatDuration,
formatRelativeTime,
formatTime,
formatTimeWithSeconds,
getTimezoneAbbreviation,
} from './formatting.js'
describe('getTimezoneAbbreviation', () => {
it('returns UTC for UTC timezone', () => {
expect(getTimezoneAbbreviation('UTC')).toBe('UTC')
})
it('returns JST for Tokyo (no DST)', () => {
expect(getTimezoneAbbreviation('Asia/Tokyo', new Date('2023-01-15'))).toBe('JST')
expect(getTimezoneAbbreviation('Asia/Tokyo', new Date('2023-07-15'))).toBe('JST')
})
it('returns the timezone string for unknown timezones', () => {
expect(getTimezoneAbbreviation('Unknown/Zone')).toBe('Unknown/Zone')
})
it('resolves a valid IANA timezone outside the hardcoded map via Intl instead of the raw string', () => {
const result = getTimezoneAbbreviation('Europe/Berlin', new Date('2023-01-15'))
expect(result).not.toBe('Europe/Berlin')
})
it('returns PST or PDT for Los Angeles', () => {
const result = getTimezoneAbbreviation('America/Los_Angeles', new Date('2023-01-15'))
expect(['PST', 'PDT']).toContain(result)
})
})
describe('formatDateTime', () => {
it('formats a date with time', () => {
const date = new Date('2023-05-15T14:30:00')
const result = formatDateTime(date)
expect(result).toMatch(/May 15, 2023/)
})
it('appends timezone abbreviation when timezone is provided', () => {
const date = new Date('2023-05-15T14:30:00Z')
const result = formatDateTime(date, 'UTC')
expect(result).toContain('UTC')
})
})
describe('formatDate', () => {
it('formats a date without time', () => {
const date = new Date('2023-05-15T14:30:00')
const result = formatDate(date)
expect(result).toMatch(/May 15, 2023/)
expect(result).not.toMatch(/14:30/)
})
})
describe('formatAbsoluteDate', () => {
it('formats an ISO date string', () => {
const result = formatAbsoluteDate('2023-05-15T14:30:00Z')
expect(result).toMatch(/May/)
expect(result).toMatch(/2023/)
})
})
describe('formatTime', () => {
it('formats time only', () => {
const date = new Date('2023-05-15T14:30:00')
const result = formatTime(date)
expect(result).toMatch(/2:30 PM|14:30/)
})
})
describe('formatTimeWithSeconds', () => {
it('formats time with seconds and timezone', () => {
const date = new Date('2023-05-15T14:30:45')
const result = formatTimeWithSeconds(date)
expect(result).toMatch(/2:30:45 PM|14:30:45/)
})
it('omits timezone when includeTimezone is false', () => {
const date = new Date('2023-05-15T14:30:45')
const withTz = formatTimeWithSeconds(date, true)
const withoutTz = formatTimeWithSeconds(date, false)
expect(withoutTz.length).toBeLessThanOrEqual(withTz.length)
})
})
describe('formatCompactTimestamp', () => {
it('produces MM-DD HH:mm pattern', () => {
const result = formatCompactTimestamp('2023-05-15T14:30:00')
expect(result).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}$/)
})
it('returns a formatted string even for invalid dates (no throw)', () => {
const result = formatCompactTimestamp('not-a-date')
expect(typeof result).toBe('string')
})
})
describe('formatDuration', () => {
it('returns "0ms" for 0', () => {
expect(formatDuration(0)).toBe('0ms')
})
it('formats milliseconds', () => {
expect(formatDuration(500)).toBe('500ms')
})
it('formats seconds', () => {
expect(formatDuration(5000)).toBe('5s')
})
it('formats minutes and seconds', () => {
expect(formatDuration(65000)).toBe('1m 5s')
})
it('formats hours and minutes', () => {
expect(formatDuration(3725000)).toBe('1h 2m')
})
it('returns null for null', () => {
expect(formatDuration(null)).toBeNull()
})
it('returns null for undefined', () => {
expect(formatDuration(undefined)).toBeNull()
})
it('parses string durations', () => {
expect(formatDuration('500ms')).toBe('500ms')
})
it('returns em dash for NaN', () => {
expect(formatDuration(Number.NaN)).toBe('\u2014')
})
it('returns em dash for Infinity', () => {
expect(formatDuration(Number.POSITIVE_INFINITY)).toBe('\u2014')
})
it('supports precision option for seconds', () => {
expect(formatDuration(1500, { precision: 1 })).toBe('1.5s')
expect(formatDuration(5000, { precision: 1 })).toBe('5s')
})
it('formats sub-millisecond durations', () => {
expect(formatDuration(0.5)).toBe('0.50ms')
expect(formatDuration(0.001)).toBe('0ms')
})
it('returns original string for non-numeric strings', () => {
expect(formatDuration('not-a-number')).toBe('not-a-number')
})
})
describe('formatRelativeTime', () => {
it('returns "just now" for recent dates', () => {
const now = new Date()
expect(formatRelativeTime(now.toISOString())).toBe('just now')
})
it('returns minutes ago', () => {
const date = new Date(Date.now() - 5 * 60 * 1000)
expect(formatRelativeTime(date.toISOString())).toBe('5m ago')
})
it('returns hours ago', () => {
const date = new Date(Date.now() - 3 * 60 * 60 * 1000)
expect(formatRelativeTime(date.toISOString())).toBe('3h ago')
})
it('returns days ago', () => {
const date = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000)
expect(formatRelativeTime(date.toISOString())).toBe('2d ago')
})
})