mirror of https://github.com/openclaw/openclaw.git
test: harden infra formatter and retry coverage
This commit is contained in:
parent
4aec20d365
commit
2d32cf2839
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { formatUtcTimestamp, formatZonedTimestamp, resolveTimezone } from "./format-datetime.js";
|
||||
import {
|
||||
formatDurationCompact,
|
||||
|
|
@ -188,6 +188,15 @@ describe("format-relative", () => {
|
|||
});
|
||||
|
||||
describe("formatRelativeTimestamp", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-02-10T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns fallback for invalid timestamp input", () => {
|
||||
for (const value of [null, undefined]) {
|
||||
expect(formatRelativeTimestamp(value)).toBe("n/a");
|
||||
|
|
@ -197,21 +206,39 @@ describe("format-relative", () => {
|
|||
|
||||
it.each([
|
||||
{ offsetMs: -10000, expected: "just now" },
|
||||
{ offsetMs: -30000, expected: "just now" },
|
||||
{ offsetMs: -300000, expected: "5m ago" },
|
||||
{ offsetMs: -7200000, expected: "2h ago" },
|
||||
{ offsetMs: -(47 * 3600000), expected: "47h ago" },
|
||||
{ offsetMs: -(48 * 3600000), expected: "2d ago" },
|
||||
{ offsetMs: 30000, expected: "in <1m" },
|
||||
{ offsetMs: 300000, expected: "in 5m" },
|
||||
{ offsetMs: 7200000, expected: "in 2h" },
|
||||
])("formats relative timestamp for offset $offsetMs", ({ offsetMs, expected }) => {
|
||||
const now = Date.now();
|
||||
expect(formatRelativeTimestamp(now + offsetMs)).toBe(expected);
|
||||
expect(formatRelativeTimestamp(Date.now() + offsetMs)).toBe(expected);
|
||||
});
|
||||
|
||||
it("falls back to date for old timestamps when enabled", () => {
|
||||
const oldDate = Date.now() - 30 * 24 * 3600000; // 30 days ago
|
||||
const result = formatRelativeTimestamp(oldDate, { dateFallback: true });
|
||||
// Should be a short date like "Jan 9" not "30d ago"
|
||||
expect(result).toMatch(/[A-Z][a-z]{2} \d{1,2}/);
|
||||
it.each([
|
||||
{
|
||||
name: "keeps 7-day-old timestamps relative",
|
||||
offsetMs: -7 * 24 * 3600000,
|
||||
options: { dateFallback: true, timezone: "UTC" },
|
||||
expected: "7d ago",
|
||||
},
|
||||
{
|
||||
name: "falls back to a short date once the timestamp is older than 7 days",
|
||||
offsetMs: -8 * 24 * 3600000,
|
||||
options: { dateFallback: true, timezone: "UTC" },
|
||||
expected: "Feb 2",
|
||||
},
|
||||
{
|
||||
name: "keeps relative output when date fallback is disabled",
|
||||
offsetMs: -8 * 24 * 3600000,
|
||||
options: { timezone: "UTC" },
|
||||
expected: "8d ago",
|
||||
},
|
||||
])("$name", ({ offsetMs, options, expected }) => {
|
||||
expect(formatRelativeTimestamp(Date.now() + offsetMs, options)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,48 +1,154 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTelegramRetryRunner } from "./retry-policy.js";
|
||||
|
||||
const ZERO_DELAY_RETRY = { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 };
|
||||
|
||||
describe("createTelegramRetryRunner", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("strictShouldRetry", () => {
|
||||
it("without strictShouldRetry: ECONNRESET is retried via regex fallback even when predicate returns false", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }));
|
||||
const runner = createTelegramRetryRunner({
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
shouldRetry: () => false, // predicate says no
|
||||
// strictShouldRetry not set — regex fallback still applies
|
||||
});
|
||||
await expect(runner(fn, "test")).rejects.toThrow("ECONNRESET");
|
||||
// Regex matches "reset" so it retried despite shouldRetry returning false
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
name: "falls back to regex matching when strictShouldRetry is disabled",
|
||||
runnerOptions: {
|
||||
retry: { ...ZERO_DELAY_RETRY, attempts: 2 },
|
||||
shouldRetry: () => false,
|
||||
},
|
||||
fnSteps: [
|
||||
{
|
||||
type: "reject" as const,
|
||||
value: Object.assign(new Error("read ECONNRESET"), {
|
||||
code: "ECONNRESET",
|
||||
}),
|
||||
},
|
||||
],
|
||||
expectedCalls: 2,
|
||||
expectedError: "ECONNRESET",
|
||||
},
|
||||
{
|
||||
name: "suppresses regex fallback when strictShouldRetry is enabled",
|
||||
runnerOptions: {
|
||||
retry: { ...ZERO_DELAY_RETRY, attempts: 2 },
|
||||
shouldRetry: () => false,
|
||||
strictShouldRetry: true,
|
||||
},
|
||||
fnSteps: [
|
||||
{
|
||||
type: "reject" as const,
|
||||
value: Object.assign(new Error("read ECONNRESET"), {
|
||||
code: "ECONNRESET",
|
||||
}),
|
||||
},
|
||||
],
|
||||
expectedCalls: 1,
|
||||
expectedError: "ECONNRESET",
|
||||
},
|
||||
{
|
||||
name: "still retries when the strict predicate returns true",
|
||||
runnerOptions: {
|
||||
retry: { ...ZERO_DELAY_RETRY, attempts: 2 },
|
||||
shouldRetry: (err: unknown) => (err as { code?: string }).code === "ECONNREFUSED",
|
||||
strictShouldRetry: true,
|
||||
},
|
||||
fnSteps: [
|
||||
{
|
||||
type: "reject" as const,
|
||||
value: Object.assign(new Error("ECONNREFUSED"), {
|
||||
code: "ECONNREFUSED",
|
||||
}),
|
||||
},
|
||||
{ type: "resolve" as const, value: "ok" },
|
||||
],
|
||||
expectedCalls: 2,
|
||||
expectedValue: "ok",
|
||||
},
|
||||
{
|
||||
name: "does not retry unrelated errors when neither predicate nor regex match",
|
||||
runnerOptions: {
|
||||
retry: { ...ZERO_DELAY_RETRY, attempts: 2 },
|
||||
},
|
||||
fnSteps: [
|
||||
{
|
||||
type: "reject" as const,
|
||||
value: Object.assign(new Error("permission denied"), {
|
||||
code: "EACCES",
|
||||
}),
|
||||
},
|
||||
],
|
||||
expectedCalls: 1,
|
||||
expectedError: "permission denied",
|
||||
},
|
||||
{
|
||||
name: "keeps retrying retriable errors until attempts are exhausted",
|
||||
runnerOptions: {
|
||||
retry: ZERO_DELAY_RETRY,
|
||||
},
|
||||
fnSteps: [
|
||||
{
|
||||
type: "reject" as const,
|
||||
value: Object.assign(new Error("connection timeout"), {
|
||||
code: "ETIMEDOUT",
|
||||
}),
|
||||
},
|
||||
],
|
||||
expectedCalls: 3,
|
||||
expectedError: "connection timeout",
|
||||
},
|
||||
])("$name", async ({ runnerOptions, fnSteps, expectedCalls, expectedValue, expectedError }) => {
|
||||
vi.useFakeTimers();
|
||||
const runner = createTelegramRetryRunner(runnerOptions);
|
||||
const fn = vi.fn();
|
||||
const allRejects = fnSteps.length > 0 && fnSteps.every((step) => step.type === "reject");
|
||||
if (allRejects) {
|
||||
fn.mockRejectedValue(fnSteps[0]?.value);
|
||||
}
|
||||
for (const [index, step] of fnSteps.entries()) {
|
||||
if (allRejects && index > 0) {
|
||||
break;
|
||||
}
|
||||
if (step.type === "reject") {
|
||||
fn.mockRejectedValueOnce(step.value);
|
||||
} else {
|
||||
fn.mockResolvedValueOnce(step.value);
|
||||
}
|
||||
}
|
||||
|
||||
it("with strictShouldRetry=true: ECONNRESET is NOT retried when predicate returns false", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }));
|
||||
const runner = createTelegramRetryRunner({
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
shouldRetry: () => false,
|
||||
strictShouldRetry: true, // predicate is authoritative
|
||||
});
|
||||
await expect(runner(fn, "test")).rejects.toThrow("ECONNRESET");
|
||||
// No retry — predicate returned false and regex fallback was suppressed
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const promise = runner(fn, "test");
|
||||
const assertion = expectedError
|
||||
? expect(promise).rejects.toThrow(expectedError)
|
||||
: expect(promise).resolves.toBe(expectedValue);
|
||||
|
||||
it("with strictShouldRetry=true: ECONNREFUSED is still retried when predicate returns true", async () => {
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" }))
|
||||
.mockResolvedValue("ok");
|
||||
const runner = createTelegramRetryRunner({
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
shouldRetry: (err) => (err as { code?: string }).code === "ECONNREFUSED",
|
||||
strictShouldRetry: true,
|
||||
});
|
||||
await expect(runner(fn, "test")).resolves.toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
await vi.runAllTimersAsync();
|
||||
await assertion;
|
||||
expect(fn).toHaveBeenCalledTimes(expectedCalls);
|
||||
});
|
||||
});
|
||||
|
||||
it("honors nested retry_after hints before retrying", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const runner = createTelegramRetryRunner({
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1_000, jitter: 0 },
|
||||
});
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({
|
||||
message: "429 Too Many Requests",
|
||||
response: { parameters: { retry_after: 1 } },
|
||||
})
|
||||
.mockResolvedValue("ok");
|
||||
|
||||
const promise = runner(fn, "test");
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(promise).resolves.toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue