chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
import {
|
||||
parseNaturalLanguageDuration,
|
||||
safeParseNaturalLanguageDuration,
|
||||
parseNaturalLanguageDurationAgo,
|
||||
safeParseNaturalLanguageDurationAgo,
|
||||
stringifyDuration,
|
||||
} from "../src/v3/isomorphic/duration.js";
|
||||
|
||||
describe("parseNaturalLanguageDuration", () => {
|
||||
let baseTime: Date;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set a fixed base time for consistent testing
|
||||
baseTime = new Date("2024-01-01T12:00:00.000Z");
|
||||
vi.setSystemTime(baseTime);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("valid duration strings", () => {
|
||||
it("parses seconds correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("30s");
|
||||
expect(result).toEqual(new Date("2024-01-01T12:00:30.000Z"));
|
||||
});
|
||||
|
||||
it("parses minutes correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("15m");
|
||||
expect(result).toEqual(new Date("2024-01-01T12:15:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses hours correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("2h");
|
||||
expect(result).toEqual(new Date("2024-01-01T14:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses hours with 'hr' format correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("2hr");
|
||||
expect(result).toEqual(new Date("2024-01-01T14:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses days correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("3d");
|
||||
expect(result).toEqual(new Date("2024-01-04T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses weeks correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("1w");
|
||||
expect(result).toEqual(new Date("2024-01-08T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses combined durations correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("1w2d3h4m5s");
|
||||
expect(result).toEqual(new Date("2024-01-10T15:04:05.000Z"));
|
||||
});
|
||||
|
||||
it("parses combined durations with 'hr' format correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("1w2d3hr4m5s");
|
||||
expect(result).toEqual(new Date("2024-01-10T15:04:05.000Z"));
|
||||
});
|
||||
|
||||
it("parses partial combined durations correctly", () => {
|
||||
const result = parseNaturalLanguageDuration("2d30m");
|
||||
expect(result).toEqual(new Date("2024-01-03T12:30:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses durations with units in any order", () => {
|
||||
const result = parseNaturalLanguageDuration("30s2h1d");
|
||||
expect(result).toEqual(new Date("2024-01-02T14:00:30.000Z"));
|
||||
});
|
||||
|
||||
it("parses durations with 'hr' in any order", () => {
|
||||
const result = parseNaturalLanguageDuration("30s2hr1d");
|
||||
expect(result).toEqual(new Date("2024-01-02T14:00:30.000Z"));
|
||||
});
|
||||
|
||||
it("handles zero values", () => {
|
||||
const result = parseNaturalLanguageDuration("0s");
|
||||
expect(result).toEqual(new Date("2024-01-01T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("handles large numbers", () => {
|
||||
const result = parseNaturalLanguageDuration("100d");
|
||||
expect(result).toEqual(new Date("2024-04-10T12:00:00.000Z"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid duration strings", () => {
|
||||
it("returns undefined for empty string", () => {
|
||||
const result = parseNaturalLanguageDuration("");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for invalid format", () => {
|
||||
const result = parseNaturalLanguageDuration("invalid");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for negative numbers", () => {
|
||||
const result = parseNaturalLanguageDuration("-1h");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for invalid units", () => {
|
||||
const result = parseNaturalLanguageDuration("1x");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for mixed valid/invalid format", () => {
|
||||
const result = parseNaturalLanguageDuration("1h2x");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for decimal numbers", () => {
|
||||
const result = parseNaturalLanguageDuration("1.5h");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for units without numbers", () => {
|
||||
const result = parseNaturalLanguageDuration("h");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeParseNaturalLanguageDuration", () => {
|
||||
let baseTime: Date;
|
||||
|
||||
beforeEach(() => {
|
||||
baseTime = new Date("2024-01-01T12:00:00.000Z");
|
||||
vi.setSystemTime(baseTime);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns the same result as parseNaturalLanguageDuration for valid input", () => {
|
||||
const duration = "1h30m";
|
||||
const result1 = parseNaturalLanguageDuration(duration);
|
||||
const result2 = safeParseNaturalLanguageDuration(duration);
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
it("returns undefined for invalid input without throwing", () => {
|
||||
const result = safeParseNaturalLanguageDuration("invalid");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles exceptions gracefully", () => {
|
||||
// Mock parseNaturalLanguageDuration to throw an error
|
||||
const _originalParse = parseNaturalLanguageDuration;
|
||||
const _mockParse = vi.fn().mockImplementation(() => {
|
||||
throw new Error("Test error");
|
||||
});
|
||||
|
||||
// This test demonstrates the safe wrapper behavior
|
||||
expect(() => safeParseNaturalLanguageDuration("1h")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNaturalLanguageDurationAgo", () => {
|
||||
let baseTime: Date;
|
||||
|
||||
beforeEach(() => {
|
||||
baseTime = new Date("2024-01-01T12:00:00.000Z");
|
||||
vi.setSystemTime(baseTime);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("valid duration strings", () => {
|
||||
it("parses seconds ago correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("30s");
|
||||
expect(result).toEqual(new Date("2024-01-01T11:59:30.000Z"));
|
||||
});
|
||||
|
||||
it("parses minutes ago correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("15m");
|
||||
expect(result).toEqual(new Date("2024-01-01T11:45:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses hours ago correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("2h");
|
||||
expect(result).toEqual(new Date("2024-01-01T10:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses hours ago with 'hr' format correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("2hr");
|
||||
expect(result).toEqual(new Date("2024-01-01T10:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses days ago correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("3d");
|
||||
expect(result).toEqual(new Date("2023-12-29T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses weeks ago correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("1w");
|
||||
expect(result).toEqual(new Date("2023-12-25T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("parses combined durations ago correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("1w2d3h4m5s");
|
||||
expect(result).toEqual(new Date("2023-12-23T08:55:55.000Z"));
|
||||
});
|
||||
|
||||
it("parses combined durations ago with 'hr' format correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("1w2d3hr4m5s");
|
||||
expect(result).toEqual(new Date("2023-12-23T08:55:55.000Z"));
|
||||
});
|
||||
|
||||
it("parses partial combined durations ago correctly", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("2d30m");
|
||||
expect(result).toEqual(new Date("2023-12-30T11:30:00.000Z"));
|
||||
});
|
||||
|
||||
it("handles zero values", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("0s");
|
||||
expect(result).toEqual(new Date("2024-01-01T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("handles large numbers in the past", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("100d");
|
||||
expect(result).toEqual(new Date("2023-09-23T12:00:00.000Z"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid duration strings", () => {
|
||||
it("returns undefined for empty string", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for invalid format", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("invalid");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for negative numbers", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("-1h");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for invalid units", () => {
|
||||
const result = parseNaturalLanguageDurationAgo("1x");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeParseNaturalLanguageDurationAgo", () => {
|
||||
let baseTime: Date;
|
||||
|
||||
beforeEach(() => {
|
||||
baseTime = new Date("2024-01-01T12:00:00.000Z");
|
||||
vi.setSystemTime(baseTime);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns the same result as parseNaturalLanguageDurationAgo for valid input", () => {
|
||||
const duration = "1h30m";
|
||||
const result1 = parseNaturalLanguageDurationAgo(duration);
|
||||
const result2 = safeParseNaturalLanguageDurationAgo(duration);
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
it("returns undefined for invalid input without throwing", () => {
|
||||
const result = safeParseNaturalLanguageDurationAgo("invalid");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles exceptions gracefully", () => {
|
||||
expect(() => safeParseNaturalLanguageDurationAgo("1h")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stringifyDuration", () => {
|
||||
it("returns undefined for zero or negative seconds", () => {
|
||||
expect(stringifyDuration(0)).toBeUndefined();
|
||||
expect(stringifyDuration(-1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("formats seconds correctly", () => {
|
||||
expect(stringifyDuration(30)).toBe("30s");
|
||||
});
|
||||
|
||||
it("formats minutes correctly", () => {
|
||||
expect(stringifyDuration(90)).toBe("1m30s");
|
||||
});
|
||||
|
||||
it("formats hours correctly", () => {
|
||||
expect(stringifyDuration(3661)).toBe("1h1m1s");
|
||||
});
|
||||
|
||||
it("formats days correctly", () => {
|
||||
expect(stringifyDuration(90061)).toBe("1d1h1m1s");
|
||||
});
|
||||
|
||||
it("formats weeks correctly", () => {
|
||||
expect(stringifyDuration(694861)).toBe("1w1d1h1m1s");
|
||||
});
|
||||
|
||||
it("omits zero units", () => {
|
||||
expect(stringifyDuration(3600)).toBe("1h");
|
||||
expect(stringifyDuration(3660)).toBe("1h1m");
|
||||
expect(stringifyDuration(86400)).toBe("1d");
|
||||
});
|
||||
|
||||
it("handles large durations", () => {
|
||||
expect(stringifyDuration(1209600)).toBe("2w"); // 2 weeks
|
||||
});
|
||||
|
||||
it("handles complex durations", () => {
|
||||
expect(stringifyDuration(694861)).toBe("1w1d1h1m1s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("duration function consistency", () => {
|
||||
let baseTime: Date;
|
||||
|
||||
beforeEach(() => {
|
||||
baseTime = new Date("2024-01-01T12:00:00.000Z");
|
||||
vi.setSystemTime(baseTime);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("parseNaturalLanguageDuration and parseNaturalLanguageDurationAgo should be symmetric", () => {
|
||||
const duration = "1h30m15s";
|
||||
const futureDate = parseNaturalLanguageDuration(duration);
|
||||
const pastDate = parseNaturalLanguageDurationAgo(duration);
|
||||
|
||||
expect(futureDate).toBeDefined();
|
||||
expect(pastDate).toBeDefined();
|
||||
|
||||
if (futureDate && pastDate) {
|
||||
const now = new Date();
|
||||
const futureOffset = futureDate.getTime() - now.getTime();
|
||||
const pastOffset = now.getTime() - pastDate.getTime();
|
||||
|
||||
expect(futureOffset).toBe(pastOffset);
|
||||
}
|
||||
});
|
||||
|
||||
it("safe versions should match unsafe versions for valid input", () => {
|
||||
const duration = "2d4h";
|
||||
|
||||
const future1 = parseNaturalLanguageDuration(duration);
|
||||
const future2 = safeParseNaturalLanguageDuration(duration);
|
||||
const past1 = parseNaturalLanguageDurationAgo(duration);
|
||||
const past2 = safeParseNaturalLanguageDurationAgo(duration);
|
||||
|
||||
expect(future1).toEqual(future2);
|
||||
expect(past1).toEqual(past2);
|
||||
});
|
||||
|
||||
it("safe versions should return undefined for invalid input", () => {
|
||||
const invalidDuration = "invalid-duration";
|
||||
|
||||
expect(parseNaturalLanguageDuration(invalidDuration)).toBeUndefined();
|
||||
expect(safeParseNaturalLanguageDuration(invalidDuration)).toBeUndefined();
|
||||
expect(parseNaturalLanguageDurationAgo(invalidDuration)).toBeUndefined();
|
||||
expect(safeParseNaturalLanguageDurationAgo(invalidDuration)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
truncateStack,
|
||||
truncateMessage,
|
||||
parseError,
|
||||
sanitizeError,
|
||||
shouldRetryError,
|
||||
shouldLookupRetrySettings,
|
||||
} from "../src/v3/errors.js";
|
||||
import type { TaskRunError } from "../src/v3/schemas/common.js";
|
||||
|
||||
// Helper: build a fake stack with N frames
|
||||
function buildStack(messageLines: string[], frameCount: number): string {
|
||||
const frames = Array.from(
|
||||
{ length: frameCount },
|
||||
(_, i) => ` at functionName${i} (/path/to/file${i}.ts:${i + 1}:${i + 10})`
|
||||
);
|
||||
return [...messageLines, ...frames].join("\n");
|
||||
}
|
||||
|
||||
describe("truncateStack", () => {
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(truncateStack(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty string", () => {
|
||||
expect(truncateStack("")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves a short stack unchanged", () => {
|
||||
const stack = buildStack(["Error: something broke"], 10);
|
||||
expect(truncateStack(stack)).toBe(stack);
|
||||
});
|
||||
|
||||
it("preserves exactly 50 frames", () => {
|
||||
const stack = buildStack(["Error: at the limit"], 50);
|
||||
const result = truncateStack(stack);
|
||||
expect(result).toBe(stack);
|
||||
expect(result.split("\n").filter((l) => l.trimStart().startsWith("at ")).length).toBe(50);
|
||||
});
|
||||
|
||||
it("truncates to 50 frames when exceeding the limit", () => {
|
||||
const stack = buildStack(["Error: too many frames"], 200);
|
||||
const result = truncateStack(stack);
|
||||
const lines = result.split("\n");
|
||||
|
||||
// Message line + 5 top + 1 omitted notice + 45 bottom = 52 lines
|
||||
expect(lines[0]).toBe("Error: too many frames");
|
||||
expect(lines).toContain(" ... 150 frames omitted ...");
|
||||
|
||||
const frameLines = lines.filter((l) => l.trimStart().startsWith("at "));
|
||||
expect(frameLines.length).toBe(50);
|
||||
|
||||
// First kept frame is frame 0 (top of stack)
|
||||
expect(frameLines[0]).toContain("functionName0");
|
||||
// Last kept frame is the last original frame
|
||||
expect(frameLines[frameLines.length - 1]).toContain("functionName199");
|
||||
});
|
||||
|
||||
it("preserves multi-line error messages before frames", () => {
|
||||
const stack = buildStack(["TypeError: cannot read property", " caused by: something"], 60);
|
||||
const result = truncateStack(stack);
|
||||
const lines = result.split("\n");
|
||||
|
||||
expect(lines[0]).toBe("TypeError: cannot read property");
|
||||
expect(lines[1]).toBe(" caused by: something");
|
||||
expect(lines).toContain(" ... 10 frames omitted ...");
|
||||
});
|
||||
|
||||
it("truncates individual lines longer than 1024 chars", () => {
|
||||
const longFrame = ` at someFn (${"x".repeat(2000)}:1:1)`;
|
||||
const stack = ["Error: long line", longFrame].join("\n");
|
||||
const result = truncateStack(stack);
|
||||
const frameLine = result.split("\n")[1]!;
|
||||
|
||||
expect(frameLine.length).toBeLessThan(1100);
|
||||
expect(frameLine).toContain("...[truncated]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateMessage", () => {
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(truncateMessage(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty string", () => {
|
||||
expect(truncateMessage("")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves a short message", () => {
|
||||
expect(truncateMessage("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
it("truncates messages over 1000 chars", () => {
|
||||
const long = "x".repeat(5000);
|
||||
const result = truncateMessage(long);
|
||||
expect(result.length).toBeLessThan(1100);
|
||||
expect(result).toContain("...[truncated]");
|
||||
});
|
||||
|
||||
it("preserves a message at exactly 1000 chars", () => {
|
||||
const exact = "x".repeat(1000);
|
||||
expect(truncateMessage(exact)).toBe(exact);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseError truncation", () => {
|
||||
it("truncates large stack traces in Error objects", () => {
|
||||
const error = new Error("boom");
|
||||
error.stack = buildStack(["Error: boom"], 200);
|
||||
const parsed = parseError(error);
|
||||
|
||||
expect(parsed.type).toBe("BUILT_IN_ERROR");
|
||||
if (parsed.type === "BUILT_IN_ERROR") {
|
||||
const frameLines = parsed.stackTrace
|
||||
.split("\n")
|
||||
.filter((l) => l.trimStart().startsWith("at "));
|
||||
expect(frameLines.length).toBe(50);
|
||||
expect(parsed.stackTrace).toContain("frames omitted");
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates large error messages", () => {
|
||||
const error = new Error("x".repeat(5000));
|
||||
const parsed = parseError(error);
|
||||
|
||||
if (parsed.type === "BUILT_IN_ERROR") {
|
||||
expect(parsed.message.length).toBeLessThan(1100);
|
||||
expect(parsed.message).toContain("...[truncated]");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeError truncation", () => {
|
||||
it("truncates stack traces during sanitization", () => {
|
||||
const result = sanitizeError({
|
||||
type: "BUILT_IN_ERROR",
|
||||
name: "Error",
|
||||
message: "boom",
|
||||
stackTrace: buildStack(["Error: boom"], 200),
|
||||
});
|
||||
|
||||
if (result.type === "BUILT_IN_ERROR") {
|
||||
const frameLines = result.stackTrace
|
||||
.split("\n")
|
||||
.filter((l) => l.trimStart().startsWith("at "));
|
||||
expect(frameLines.length).toBe(50);
|
||||
}
|
||||
});
|
||||
|
||||
it("strips null bytes and truncates", () => {
|
||||
const result = sanitizeError({
|
||||
type: "BUILT_IN_ERROR",
|
||||
name: "Error\0",
|
||||
message: "hello\0world",
|
||||
stackTrace: "Error: hello\0world\n at fn (/path.ts:1:1)",
|
||||
});
|
||||
|
||||
if (result.type === "BUILT_IN_ERROR") {
|
||||
expect(result.name).toBe("Error");
|
||||
expect(result.message).toBe("helloworld");
|
||||
expect(result.stackTrace).not.toContain("\0");
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates STRING_ERROR raw field", () => {
|
||||
const result = sanitizeError({
|
||||
type: "STRING_ERROR",
|
||||
raw: "x".repeat(5000),
|
||||
});
|
||||
|
||||
if (result.type === "STRING_ERROR") {
|
||||
expect(result.raw.length).toBeLessThan(1100);
|
||||
expect(result.raw).toContain("...[truncated]");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves small CUSTOM_ERROR raw as valid JSON", () => {
|
||||
const originalJson = JSON.stringify({ foo: "bar", nested: { baz: 1 } });
|
||||
const result = sanitizeError({
|
||||
type: "CUSTOM_ERROR",
|
||||
raw: originalJson,
|
||||
});
|
||||
|
||||
if (result.type === "CUSTOM_ERROR") {
|
||||
// Small JSON should pass through unchanged and remain parseable
|
||||
expect(result.raw).toBe(originalJson);
|
||||
expect(() => JSON.parse(result.raw)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps oversized CUSTOM_ERROR raw in a valid JSON envelope", () => {
|
||||
const hugeJson = JSON.stringify({ data: "x".repeat(5000) });
|
||||
const result = sanitizeError({
|
||||
type: "CUSTOM_ERROR",
|
||||
raw: hugeJson,
|
||||
});
|
||||
|
||||
if (result.type === "CUSTOM_ERROR") {
|
||||
// Must remain valid JSON (critical: createErrorTaskError calls JSON.parse on this)
|
||||
expect(() => JSON.parse(result.raw)).not.toThrow();
|
||||
const parsed = JSON.parse(result.raw);
|
||||
expect(parsed.truncated).toBe(true);
|
||||
expect(typeof parsed.preview).toBe("string");
|
||||
expect(parsed.preview.length).toBeLessThanOrEqual(1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeError INTERNAL_ERROR optional fields", () => {
|
||||
it("preserves undefined message (does not convert to empty string)", () => {
|
||||
const result = sanitizeError({
|
||||
type: "INTERNAL_ERROR",
|
||||
code: "SOME_INTERNAL_CODE" as any,
|
||||
// message and stackTrace intentionally undefined
|
||||
});
|
||||
|
||||
if (result.type === "INTERNAL_ERROR") {
|
||||
// Must stay undefined so `error.message ?? fallback` works downstream
|
||||
expect(result.message).toBeUndefined();
|
||||
expect(result.stackTrace).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates INTERNAL_ERROR message when present", () => {
|
||||
const result = sanitizeError({
|
||||
type: "INTERNAL_ERROR",
|
||||
code: "SOME_INTERNAL_CODE" as any,
|
||||
message: "x".repeat(5000),
|
||||
});
|
||||
|
||||
if (result.type === "INTERNAL_ERROR") {
|
||||
expect(result.message).toBeDefined();
|
||||
expect(result.message!.length).toBeLessThan(1100);
|
||||
expect(result.message).toContain("...[truncated]");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateStack message line bounding", () => {
|
||||
it("truncates huge error messages embedded in the stack", () => {
|
||||
// V8 format: "Error: <message>\n at ..."
|
||||
// A huge message on the first line must still be bounded.
|
||||
const hugeMessage = "x".repeat(100_000);
|
||||
const stack = `Error: ${hugeMessage}\n at fn (/path.ts:1:1)`;
|
||||
const result = truncateStack(stack);
|
||||
|
||||
// Total output should be bounded (not 100KB+)
|
||||
expect(result.length).toBeLessThan(5_000);
|
||||
expect(result).toContain("...[truncated]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRetryError + shouldLookupRetrySettings", () => {
|
||||
const internal = (code: string): TaskRunError =>
|
||||
({ type: "INTERNAL_ERROR", code }) as TaskRunError;
|
||||
|
||||
it("retries SIGSEGV (changed from non-retriable) and looks up retry settings", () => {
|
||||
const err = internal("TASK_PROCESS_SIGSEGV");
|
||||
expect(shouldRetryError(err)).toBe(true);
|
||||
expect(shouldLookupRetrySettings(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("retries SIGTERM via the same path", () => {
|
||||
const err = internal("TASK_PROCESS_SIGTERM");
|
||||
expect(shouldRetryError(err)).toBe(true);
|
||||
expect(shouldLookupRetrySettings(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("retries TASK_MIDDLEWARE_ERROR using the task's retry settings", () => {
|
||||
const err = internal("TASK_MIDDLEWARE_ERROR");
|
||||
expect(shouldRetryError(err)).toBe(true);
|
||||
expect(shouldLookupRetrySettings(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("still does not retry SIGKILL timeout", () => {
|
||||
expect(shouldRetryError(internal("TASK_PROCESS_SIGKILL_TIMEOUT"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still does not retry OOM kills (handled by the separate machine-bump path)", () => {
|
||||
expect(shouldRetryError(internal("TASK_PROCESS_OOM_KILLED"))).toBe(false);
|
||||
expect(shouldRetryError(internal("TASK_PROCESS_MAYBE_OOM_KILLED"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,674 @@
|
||||
import { type EventFilter } from "../src/index.js";
|
||||
import { eventFilterMatches } from "../src/eventFilterMatches.js";
|
||||
|
||||
describe("eventFilterMatches", () => {
|
||||
it("should return true if the payload is undefined and the filter is empty", () => {
|
||||
const payload = undefined;
|
||||
const filter: EventFilter = {};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the payload is undefined and the filter isn't empty", () => {
|
||||
const payload = undefined;
|
||||
const filter: EventFilter = {
|
||||
name: ["John"],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when payload matches string filter", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: ["John"],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches boolean filter", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
isAdmin: [false],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches number filter", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
age: [30],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches $startsWith content filter", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $startsWith: "Jo" }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches $endsWith content filter", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $endsWith: "hn" }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches $startsWith and $endsWith content filters", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $startsWith: "Jo" }, { $endsWith: "hn" }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload does not match $anythingBut filter", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $anythingBut: "Jane" }],
|
||||
address: {
|
||||
street: [{ $anythingBut: "456 Elm St" }],
|
||||
},
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload does not match $anythingBut filter with an array", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $anythingBut: ["Jane", "Joe"] }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload does have a key that $exists = true", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $exists: true }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload does NOT have a key that $exists = false", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
foo: [{ $exists: false }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload does match numeric condition", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
score: 100,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
age: [{ $gt: 20 }, { $lt: 40 }],
|
||||
score: [{ $between: [90, 110] }],
|
||||
};
|
||||
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches an includes condition", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
score: 100,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
hobbies: [{ $includes: "reading" }],
|
||||
};
|
||||
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches an not condition", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
score: 100,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
hobbies: [{ $not: "gaming" }],
|
||||
age: [{ $not: 39 }],
|
||||
isAdmin: [{ $not: true }],
|
||||
name: [{ $not: "Test" }],
|
||||
};
|
||||
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when payload not matches an not condition", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
score: 100,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
hobbies: [{ $not: "reading" }],
|
||||
age: [{ $not: 30 }],
|
||||
isAdmin: [{ $not: false }],
|
||||
name: [{ $not: "John" }],
|
||||
};
|
||||
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when payload matches an ignoreCaseEquals condition", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
score: 100,
|
||||
isAdmin: false,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $ignoreCaseEquals: "john" }],
|
||||
};
|
||||
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches an isNull condition", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
score: 100,
|
||||
isAdmin: false,
|
||||
confirmedAt: null,
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
confirmedAt: [{ $isNull: true }],
|
||||
};
|
||||
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when payload matches an isNull condition (flipped)", () => {
|
||||
const payload = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
score: 100,
|
||||
isAdmin: false,
|
||||
confirmedAt: "2020-01-01T00:00:00.000Z",
|
||||
hobbies: ["reading", "swimming"],
|
||||
address: {
|
||||
street: "123 Main St",
|
||||
city: "Anytown",
|
||||
state: "CA",
|
||||
zip: "12345",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
confirmedAt: [{ $isNull: false }],
|
||||
};
|
||||
|
||||
expect(eventFilterMatches(payload, filter)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when payload does not match string filter", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: ["John"],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does not match string filter because it's the wrong type", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
age: ["John"],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does not match boolean filter", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
isAdmin: [false],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does not match number filter", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
age: [30],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does not match $startsWith content filter", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $startsWith: "Jo" }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does not match $endsWith content filter", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $startsWith: "Ja" }, { $endsWith: "hn" }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does match $anythingBut content filters", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $anythingBut: "Jane" }],
|
||||
address: {
|
||||
street: [{ $anythingBut: "456 Elm St" }],
|
||||
},
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does match $anythingBut content filters with an array", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $anythingBut: ["Jane", "John"] }],
|
||||
address: {
|
||||
street: [{ $anythingBut: "456 Elm St" }],
|
||||
},
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does not have a key that $exists = true", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
foo: [{ $exists: true }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when payload does have a key that $exists = false", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
score: 100,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $exists: false }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when the payload does not match the numeric filters", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
score: 100,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "Othertown",
|
||||
state: "NY",
|
||||
zip: "67890",
|
||||
latitude: 37.7749,
|
||||
longitude: 122.4194,
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
age: [{ $gt: 30 }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("Should return false when the payload does not match an includes filter", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
score: 100,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
zip: "67890",
|
||||
latitude: 37.7749,
|
||||
longitude: 122.4194,
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
hobbies: [{ $includes: "swimming" }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("Should return false when the payload does not match any ignoreCaseEquals condition", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
score: 100,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
zip: "67890",
|
||||
latitude: 37.7749,
|
||||
longitude: 122.4194,
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
name: [{ $ignoreCaseEquals: "john" }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when the payload does not match an isNull condition", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
score: 100,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
confirmedAt: "2020-01-01T00:00:00.000Z",
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
zip: "67890",
|
||||
latitude: 37.7749,
|
||||
longitude: 122.4194,
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
confirmedAt: [{ $isNull: true }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when the payload does not match an isNull condition (flipped)", () => {
|
||||
const payload = {
|
||||
name: "Jane",
|
||||
age: 25,
|
||||
score: 100,
|
||||
isAdmin: true,
|
||||
hobbies: ["running", "yoga"],
|
||||
confirmedAt: null,
|
||||
address: {
|
||||
street: "456 Elm St",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
zip: "67890",
|
||||
latitude: 37.7749,
|
||||
longitude: 122.4194,
|
||||
},
|
||||
};
|
||||
const filter: EventFilter = {
|
||||
confirmedAt: [{ $isNull: false }],
|
||||
};
|
||||
expect(eventFilterMatches(payload, filter)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { SpanKind, SpanStatusCode, TraceFlags } from "@opentelemetry/api";
|
||||
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-node";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { ExternalSpanExporterWrapper } from "../src/v3/otel/tracingSDK.js";
|
||||
import { SemanticInternalAttributes } from "../src/v3/semanticInternalAttributes.js";
|
||||
import { traceContext } from "../src/v3/trace-context-api.js";
|
||||
import { StandardTraceContextManager } from "../src/v3/traceContext/manager.js";
|
||||
|
||||
const TRACEPARENT_RUN_A = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1111111111111111-01";
|
||||
const TRACEPARENT_RUN_B = "00-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2222222222222222-01";
|
||||
|
||||
function createAttemptSpan(): ReadableSpan {
|
||||
const spanCtx = {
|
||||
traceId: "cccccccccccccccccccccccccccccccc",
|
||||
spanId: "3333333333333333",
|
||||
traceFlags: TraceFlags.SAMPLED,
|
||||
};
|
||||
return {
|
||||
name: "Attempt 1",
|
||||
kind: SpanKind.CONSUMER,
|
||||
spanContext: () => spanCtx,
|
||||
parentSpanContext: undefined,
|
||||
startTime: [0, 0],
|
||||
endTime: [0, 0],
|
||||
status: { code: SpanStatusCode.UNSET },
|
||||
attributes: { [SemanticInternalAttributes.SPAN_ATTEMPT]: true },
|
||||
links: [],
|
||||
events: [],
|
||||
duration: [0, 0],
|
||||
ended: true,
|
||||
resource: {} as any,
|
||||
instrumentationLibrary: { name: "test" } as any,
|
||||
droppedAttributesCount: 0,
|
||||
droppedEventsCount: 0,
|
||||
droppedLinksCount: 0,
|
||||
} as unknown as ReadableSpan;
|
||||
}
|
||||
|
||||
function makeCapturingExporter(): { exporter: SpanExporter; captured: ReadableSpan[][] } {
|
||||
const captured: ReadableSpan[][] = [];
|
||||
const exporter: SpanExporter = {
|
||||
export: (spans, cb) => {
|
||||
captured.push(spans);
|
||||
cb({ code: 0 } as any);
|
||||
},
|
||||
shutdown: () => Promise.resolve(),
|
||||
forceFlush: () => Promise.resolve(),
|
||||
};
|
||||
return { exporter, captured };
|
||||
}
|
||||
|
||||
describe("ExternalSpanExporterWrapper warm-start regression", () => {
|
||||
let manager: StandardTraceContextManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new StandardTraceContextManager();
|
||||
traceContext.setGlobalManager(manager);
|
||||
});
|
||||
|
||||
it("rewrites attempt spans using the manager's current external context, not the value captured at construction", () => {
|
||||
const { exporter, captured } = makeCapturingExporter();
|
||||
|
||||
manager.traceContext = { external: { traceparent: TRACEPARENT_RUN_A } };
|
||||
|
||||
const wrapper = new ExternalSpanExporterWrapper(exporter, "ffffffffffffffffffffffffffffffff");
|
||||
|
||||
manager.traceContext = { external: { traceparent: TRACEPARENT_RUN_B } };
|
||||
|
||||
wrapper.export([createAttemptSpan()], () => {});
|
||||
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(captured[0]).toHaveLength(1);
|
||||
|
||||
const span = captured[0]![0]!;
|
||||
expect(span.parentSpanContext?.spanId).toBe("2222222222222222");
|
||||
expect(span.parentSpanContext?.spanId).not.toBe("1111111111111111");
|
||||
expect(span.parentSpanContext?.traceId).toBe("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
||||
expect(span.spanContext().traceId).toBe("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Fixture mimicking a task entrypoint file: top-level code calls into the
|
||||
// catalog (the same way `task()` / `schemaTask()` does via
|
||||
// `registerTaskMetadata`).
|
||||
//
|
||||
// Loaded via `await import()` from inside a test that simulates the worker
|
||||
// running a task. The point is to exercise top-level evaluation through Node's
|
||||
// ESM module loader so the module-cache semantics are real.
|
||||
|
||||
const register = globalThis.__catalogRegisterTaskMetadata;
|
||||
if (typeof register === "function") {
|
||||
register({
|
||||
id: "lazy-task",
|
||||
fns: {
|
||||
run: async () => "ok",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const lazyTask = { id: "lazy-task" };
|
||||
@@ -0,0 +1,710 @@
|
||||
import { flattenAttributes, unflattenAttributes } from "../src/v3/utils/flattenAttributes.js";
|
||||
|
||||
describe("flattenAttributes", () => {
|
||||
it("handles number keys correctly", () => {
|
||||
expect(flattenAttributes({ bar: { "25": "foo" } })).toEqual({ "bar.25": "foo" });
|
||||
expect(unflattenAttributes({ "bar.25": "foo" })).toEqual({ bar: { "25": "foo" } });
|
||||
expect(flattenAttributes({ bar: ["foo", "baz"] })).toEqual({
|
||||
"bar.[0]": "foo",
|
||||
"bar.[1]": "baz",
|
||||
});
|
||||
expect(unflattenAttributes({ "bar.[0]": "foo", "bar.[1]": "baz" })).toEqual({
|
||||
bar: ["foo", "baz"],
|
||||
});
|
||||
expect(flattenAttributes({ bar: { 25: "foo" } })).toEqual({ "bar.25": "foo" });
|
||||
expect(unflattenAttributes({ "bar.25": "foo" })).toEqual({ bar: { 25: "foo" } });
|
||||
});
|
||||
|
||||
it("handles null correctly", () => {
|
||||
expect(flattenAttributes(null)).toEqual({ "": "$@null((" });
|
||||
expect(unflattenAttributes({ "": "$@null((" })).toEqual(null);
|
||||
|
||||
expect(flattenAttributes(null, "$output")).toEqual({ $output: "$@null((" });
|
||||
expect(flattenAttributes({ foo: null })).toEqual({ foo: "$@null((" });
|
||||
expect(unflattenAttributes({ foo: "$@null((" })).toEqual({ foo: null });
|
||||
|
||||
expect(flattenAttributes({ foo: [null] })).toEqual({ "foo.[0]": "$@null((" });
|
||||
expect(unflattenAttributes({ "foo.[0]": "$@null((" })).toEqual({ foo: [null] });
|
||||
|
||||
expect(flattenAttributes([null])).toEqual({ "[0]": "$@null((" });
|
||||
expect(unflattenAttributes({ "[0]": "$@null((" })).toEqual([null]);
|
||||
});
|
||||
|
||||
it("flattens string attributes correctly", () => {
|
||||
const result = flattenAttributes("testString");
|
||||
expect(result).toEqual({ "": "testString" });
|
||||
expect(unflattenAttributes(result)).toEqual("testString");
|
||||
});
|
||||
|
||||
it("flattens number attributes correctly", () => {
|
||||
const result = flattenAttributes(12345);
|
||||
expect(result).toEqual({ "": 12345 });
|
||||
expect(unflattenAttributes(result)).toEqual(12345);
|
||||
});
|
||||
|
||||
it("flattens boolean attributes correctly", () => {
|
||||
const result = flattenAttributes(true);
|
||||
expect(result).toEqual({ "": true });
|
||||
expect(unflattenAttributes(result)).toEqual(true);
|
||||
});
|
||||
|
||||
it("flattens boolean attributes correctly", () => {
|
||||
const result = flattenAttributes(true, "$output");
|
||||
expect(result).toEqual({ $output: true });
|
||||
expect(unflattenAttributes(result)).toEqual({ $output: true });
|
||||
});
|
||||
|
||||
it("flattens array attributes correctly", () => {
|
||||
const input = [1, 2, 3];
|
||||
const result = flattenAttributes(input);
|
||||
expect(result).toEqual({ "[0]": 1, "[1]": 2, "[2]": 3 });
|
||||
expect(unflattenAttributes(result)).toEqual(input);
|
||||
});
|
||||
|
||||
it("flattens properties that are undefined correctly", () => {
|
||||
const result = flattenAttributes({ foo: undefined, bar: "baz" });
|
||||
expect(result).toEqual({ bar: "baz" });
|
||||
});
|
||||
|
||||
it("flattens complex objects correctly", () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
value: "test",
|
||||
},
|
||||
array: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
"level1.level2.value": "test",
|
||||
"level1.array.[0]": 1,
|
||||
"level1.array.[1]": 2,
|
||||
"level1.array.[2]": 3,
|
||||
};
|
||||
expect(flattenAttributes(obj)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("applies prefixes correctly", () => {
|
||||
const obj = { key: "value" };
|
||||
const expected = { "prefix.key": "value" };
|
||||
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handles arrays of objects correctly", () => {
|
||||
const obj = {
|
||||
array: [{ key: "value" }, { key: "value" }, { key: "value" }],
|
||||
};
|
||||
const expected = {
|
||||
"array.[0].key": "value",
|
||||
"array.[1].key": "value",
|
||||
"array.[2].key": "value",
|
||||
};
|
||||
expect(flattenAttributes(obj)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handles arrays of objects correctly with prefixing correctly", () => {
|
||||
const obj = {
|
||||
array: [{ key: "value" }, { key: "value" }, { key: "value" }],
|
||||
};
|
||||
const expected = {
|
||||
"prefix.array.[0].key": "value",
|
||||
"prefix.array.[1].key": "value",
|
||||
"prefix.array.[2].key": "value",
|
||||
};
|
||||
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handles objects of objects correctly", () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = { "level1.level2.key": "value" };
|
||||
expect(flattenAttributes(obj)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handles objects of objects correctly with prefixing", () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = { "prefix.level1.level2.key": "value" };
|
||||
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handles retry.byStatus correctly", () => {
|
||||
const obj = {
|
||||
"500": {
|
||||
strategy: "backoff",
|
||||
maxAttempts: 2,
|
||||
factor: 2,
|
||||
minTimeoutInMs: 1_000,
|
||||
maxTimeoutInMs: 30_000,
|
||||
randomize: false,
|
||||
},
|
||||
};
|
||||
|
||||
const expected = {
|
||||
"retry.byStatus.500.strategy": "backoff",
|
||||
"retry.byStatus.500.maxAttempts": 2,
|
||||
"retry.byStatus.500.factor": 2,
|
||||
"retry.byStatus.500.minTimeoutInMs": 1_000,
|
||||
"retry.byStatus.500.maxTimeoutInMs": 30_000,
|
||||
"retry.byStatus.500.randomize": false,
|
||||
};
|
||||
|
||||
expect(flattenAttributes(obj, "retry.byStatus")).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handles circular references correctly", () => {
|
||||
const user = { name: "Alice" };
|
||||
// @ts-expect-error
|
||||
user["blogPosts"] = [{ title: "Post 1", author: user }]; // Circular reference
|
||||
|
||||
const result = flattenAttributes(user);
|
||||
expect(result).toEqual({
|
||||
name: "Alice",
|
||||
"blogPosts.[0].title": "Post 1",
|
||||
"blogPosts.[0].author": "$@circular((",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles nested circular references correctly", () => {
|
||||
const user = { name: "Bob" };
|
||||
// @ts-expect-error
|
||||
user["friends"] = [user]; // Circular reference
|
||||
|
||||
const result = flattenAttributes(user);
|
||||
expect(result).toEqual({
|
||||
name: "Bob",
|
||||
"friends.[0]": "$@circular((",
|
||||
});
|
||||
});
|
||||
|
||||
it("respects maxAttributeCount limit", () => {
|
||||
const obj = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
d: 4,
|
||||
e: 5,
|
||||
};
|
||||
|
||||
const result = flattenAttributes(obj, undefined, 3);
|
||||
expect(Object.keys(result)).toHaveLength(3);
|
||||
expect(result).toEqual({
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects maxAttributeCount limit with nested objects", () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
},
|
||||
level2: {
|
||||
d: 4,
|
||||
e: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const result = flattenAttributes(obj, undefined, 2);
|
||||
expect(Object.keys(result)).toHaveLength(2);
|
||||
expect(result).toEqual({
|
||||
"level1.a": 1,
|
||||
"level1.b": 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects maxAttributeCount limit with arrays", () => {
|
||||
const obj = {
|
||||
array: [1, 2, 3, 4, 5],
|
||||
};
|
||||
|
||||
const result = flattenAttributes(obj, undefined, 3);
|
||||
expect(Object.keys(result)).toHaveLength(3);
|
||||
expect(result).toEqual({
|
||||
"array.[0]": 1,
|
||||
"array.[1]": 2,
|
||||
"array.[2]": 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("works normally when maxAttributeCount is undefined", () => {
|
||||
const obj = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
};
|
||||
|
||||
const result = flattenAttributes(obj);
|
||||
expect(Object.keys(result)).toHaveLength(3);
|
||||
expect(result).toEqual({
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles maxAttributeCount of 0", () => {
|
||||
const obj = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
};
|
||||
|
||||
const result = flattenAttributes(obj, undefined, 0);
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles maxAttributeCount with primitive values", () => {
|
||||
const result1 = flattenAttributes("test", undefined, 1);
|
||||
expect(result1).toEqual({ "": "test" });
|
||||
|
||||
const result2 = flattenAttributes("test", undefined, 0);
|
||||
expect(result2).toEqual({});
|
||||
});
|
||||
|
||||
it("handles Error objects correctly", () => {
|
||||
const error = new Error("Test error message");
|
||||
error.stack = "Error: Test error message\n at test.js:1:1";
|
||||
|
||||
const result = flattenAttributes({ error });
|
||||
expect(result).toEqual({
|
||||
"error.name": "Error",
|
||||
"error.message": "Test error message",
|
||||
"error.stack": "Error: Test error message\n at test.js:1:1",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles Error objects as top-level values", () => {
|
||||
const error = new Error("Top level error");
|
||||
const result = flattenAttributes(error);
|
||||
expect(result["error.name"]).toBe("Error");
|
||||
expect(result["error.message"]).toBe("Top level error");
|
||||
// Stack trace is also included when present
|
||||
expect(result["error.stack"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles function values correctly", () => {
|
||||
function namedFunction() {}
|
||||
const anonymousFunction = function () {};
|
||||
const arrowFunction = () => {};
|
||||
|
||||
const result = flattenAttributes({
|
||||
named: namedFunction,
|
||||
anonymous: anonymousFunction,
|
||||
arrow: arrowFunction,
|
||||
});
|
||||
|
||||
expect(result.named).toBe("[Function: namedFunction]");
|
||||
// Note: function expressions with variable names retain their names
|
||||
expect(result.anonymous).toBe("[Function: anonymousFunction]");
|
||||
// Arrow functions also get their variable names in modern JS
|
||||
expect(result.arrow).toBe("[Function: arrowFunction]");
|
||||
});
|
||||
|
||||
it("handles mixed problematic types", () => {
|
||||
const complexObj = {
|
||||
error: new Error("Mixed error"),
|
||||
func: function testFunc() {},
|
||||
date: new Date("2023-01-01"),
|
||||
normal: "string",
|
||||
number: 42,
|
||||
};
|
||||
|
||||
const result = flattenAttributes(complexObj);
|
||||
|
||||
expect(result["error.name"]).toBe("Error");
|
||||
expect(result["error.message"]).toBe("Mixed error");
|
||||
expect(result["func"]).toBe("[Function: testFunc]");
|
||||
expect(result["date"]).toBe("2023-01-01T00:00:00.000Z");
|
||||
expect(result["normal"]).toBe("string");
|
||||
expect(result["number"]).toBe(42);
|
||||
});
|
||||
|
||||
it("handles bigint and symbol types", () => {
|
||||
const obj = {
|
||||
bigNumber: BigInt(123456789),
|
||||
sym: Symbol("test"),
|
||||
};
|
||||
|
||||
const result = flattenAttributes(obj);
|
||||
expect(result["bigNumber"]).toBe("123456789");
|
||||
expect(result["sym"]).toBe("Symbol(test)");
|
||||
});
|
||||
|
||||
it("handles Set objects correctly", () => {
|
||||
const mySet = new Set([1, "hello", true, { nested: "object" }]);
|
||||
const result = flattenAttributes({ mySet });
|
||||
|
||||
expect(result["mySet.[0]"]).toBe(1);
|
||||
expect(result["mySet.[1]"]).toBe("hello");
|
||||
expect(result["mySet.[2]"]).toBe(true);
|
||||
expect(result["mySet.[3].nested"]).toBe("object");
|
||||
});
|
||||
|
||||
it("handles nested Set objects correctly", () => {
|
||||
const mySet = new Set([1, 2, 3, { nested: "object" }]);
|
||||
const result = flattenAttributes({ mySet });
|
||||
expect(result["mySet.[0]"]).toBe(1);
|
||||
expect(result["mySet.[1]"]).toBe(2);
|
||||
expect(result["mySet.[2]"]).toBe(3);
|
||||
expect(result["mySet.[3].nested"]).toBe("object");
|
||||
});
|
||||
|
||||
it("handles Map objects correctly", () => {
|
||||
const myMap = new Map();
|
||||
myMap.set("key1", "value1");
|
||||
myMap.set("key2", 42);
|
||||
myMap.set(123, "numeric key");
|
||||
|
||||
const result = flattenAttributes({ myMap });
|
||||
|
||||
expect(result["myMap.key1"]).toBe("value1");
|
||||
expect(result["myMap.key2"]).toBe(42);
|
||||
expect(result["myMap.123"]).toBe("numeric key");
|
||||
});
|
||||
|
||||
it("handles nested Map objects correctly", () => {
|
||||
const myMap = new Map();
|
||||
myMap.set("key1", {
|
||||
key2: "value2",
|
||||
key3: 42,
|
||||
});
|
||||
const result = flattenAttributes({ myMap });
|
||||
expect(result["myMap.key1.key2"]).toBe("value2");
|
||||
expect(result["myMap.key1.key3"]).toBe(42);
|
||||
});
|
||||
|
||||
it("handles File objects correctly", () => {
|
||||
if (typeof File !== "undefined") {
|
||||
const file = new File(["content"], "test.txt", {
|
||||
type: "text/plain",
|
||||
lastModified: 1640995200000,
|
||||
});
|
||||
const result = flattenAttributes({ file });
|
||||
|
||||
expect(result["file.name"]).toBe("test.txt");
|
||||
expect(result["file.type"]).toBe("text/plain");
|
||||
expect(result["file.size"]).toBe(7); // "content" is 7 bytes
|
||||
expect(result["file.lastModified"]).toBe(1640995200000);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles ReadableStream objects correctly", () => {
|
||||
if (typeof ReadableStream !== "undefined") {
|
||||
const stream = new ReadableStream();
|
||||
const result = flattenAttributes({ stream });
|
||||
|
||||
expect(result["stream.type"]).toBe("ReadableStream");
|
||||
expect(result["stream.locked"]).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles Promise objects correctly", () => {
|
||||
const resolvedPromise = Promise.resolve("value");
|
||||
const rejectedPromise = Promise.reject(new Error("failed"));
|
||||
const pendingPromise = new Promise(() => {}); // Never resolves
|
||||
|
||||
// Catch the rejection to avoid unhandled promise rejection warnings
|
||||
rejectedPromise.catch(() => {});
|
||||
|
||||
const result = flattenAttributes({
|
||||
resolved: resolvedPromise,
|
||||
rejected: rejectedPromise,
|
||||
pending: pendingPromise,
|
||||
});
|
||||
|
||||
expect(result["resolved"]).toBe("[Promise object]");
|
||||
expect(result["rejected"]).toBe("[Promise object]");
|
||||
expect(result["pending"]).toBe("[Promise object]");
|
||||
});
|
||||
|
||||
it("handles RegExp objects correctly", () => {
|
||||
const regex = /hello.*world/gim;
|
||||
const result = flattenAttributes({ regex });
|
||||
|
||||
expect(result["regex.source"]).toBe("hello.*world");
|
||||
expect(result["regex.flags"]).toBe("gim");
|
||||
});
|
||||
|
||||
it("handles URL objects correctly", () => {
|
||||
if (typeof URL !== "undefined") {
|
||||
const url = new URL("https://example.com:8080/path?query=value#fragment");
|
||||
const result = flattenAttributes({ url });
|
||||
|
||||
expect(result["url.href"]).toBe("https://example.com:8080/path?query=value#fragment");
|
||||
expect(result["url.protocol"]).toBe("https:");
|
||||
expect(result["url.host"]).toBe("example.com:8080");
|
||||
expect(result["url.pathname"]).toBe("/path");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles ArrayBuffer correctly", () => {
|
||||
const buffer = new ArrayBuffer(16);
|
||||
const result = flattenAttributes({ buffer });
|
||||
|
||||
expect(result["buffer.byteLength"]).toBe(16);
|
||||
});
|
||||
|
||||
it("handles TypedArrays correctly", () => {
|
||||
const uint8Array = new Uint8Array([1, 2, 3, 4]);
|
||||
const int32Array = new Int32Array([100, 200, 300]);
|
||||
|
||||
const result = flattenAttributes({
|
||||
uint8: uint8Array,
|
||||
int32: int32Array,
|
||||
});
|
||||
|
||||
expect(result["uint8.constructor"]).toBe("Uint8Array");
|
||||
expect(result["uint8.length"]).toBe(4);
|
||||
expect(result["uint8.byteLength"]).toBe(4);
|
||||
expect(result["uint8.byteOffset"]).toBe(0);
|
||||
|
||||
expect(result["int32.constructor"]).toBe("Int32Array");
|
||||
expect(result["int32.length"]).toBe(3);
|
||||
expect(result["int32.byteLength"]).toBe(12); // 3 * 4 bytes
|
||||
expect(result["int32.byteOffset"]).toBe(0);
|
||||
});
|
||||
|
||||
it("handles complex mixed object with all special types", () => {
|
||||
const complexObj = {
|
||||
error: new Error("Test error"),
|
||||
func: function testFunc() {},
|
||||
date: new Date("2023-01-01"),
|
||||
mySet: new Set([1, 2, 3]),
|
||||
myMap: new Map([["key", "value"]]),
|
||||
regex: /test/gi,
|
||||
bigint: BigInt(999),
|
||||
symbol: Symbol("test"),
|
||||
};
|
||||
|
||||
const result = flattenAttributes(complexObj);
|
||||
|
||||
// Verify we get reasonable representations for all types
|
||||
expect(result["error.name"]).toBe("Error");
|
||||
expect(result["func"]).toBe("[Function: testFunc]");
|
||||
expect(result["date"]).toBe("2023-01-01T00:00:00.000Z");
|
||||
expect(result["regex.source"]).toBe("test");
|
||||
expect(result["bigint"]).toBe("999");
|
||||
expect(typeof result["symbol"]).toBe("string");
|
||||
});
|
||||
|
||||
it("respects maxDepth limit", () => {
|
||||
const obj = {
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: {
|
||||
e: {
|
||||
f: "deep",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// With maxDepth of 3, should not include keys deeper than 3 levels
|
||||
const result = flattenAttributes(obj, undefined, undefined, 3);
|
||||
|
||||
// a.b.c should exist (depth 3)
|
||||
expect(result["a.b.c"]).toBeUndefined(); // c is an object, not a leaf
|
||||
// a.b.c.d should not exist (would require going to depth 4)
|
||||
expect(result["a.b.c.d"]).toBeUndefined();
|
||||
expect(result["a.b.c.d.e.f"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not crash with deeply nested objects", () => {
|
||||
// Create object iteratively with 500 levels of nesting
|
||||
let deepObj: any = { value: "leaf" };
|
||||
for (let i = 0; i < 500; i++) {
|
||||
deepObj = { n: deepObj };
|
||||
}
|
||||
|
||||
// Should complete without stack overflow
|
||||
expect(() => flattenAttributes(deepObj)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not crash with deeply nested arrays", () => {
|
||||
// Create deeply nested array structure iteratively
|
||||
let deepArray: any = ["leaf"];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
deepArray = [deepArray];
|
||||
}
|
||||
|
||||
// Should complete without stack overflow
|
||||
expect(() => flattenAttributes({ arr: deepArray })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unflattenAttributes", () => {
|
||||
it("returns the original object for primitive types", () => {
|
||||
// @ts-expect-error
|
||||
expect(unflattenAttributes("testString")).toEqual("testString");
|
||||
// @ts-expect-error
|
||||
expect(unflattenAttributes(12345)).toEqual(12345);
|
||||
// @ts-expect-error
|
||||
expect(unflattenAttributes(true)).toEqual(true);
|
||||
});
|
||||
|
||||
it("correctly reconstructs an object from flattened attributes", () => {
|
||||
const flattened = {
|
||||
"level1.level2.value": "test",
|
||||
"level1.array.[0]": 1,
|
||||
"level1.array.[1]": 2,
|
||||
"level1.array.[2]": 3,
|
||||
};
|
||||
const expected = {
|
||||
level1: {
|
||||
level2: {
|
||||
value: "test",
|
||||
},
|
||||
array: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
expect(unflattenAttributes(flattened)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("handles complex nested objects with mixed types", () => {
|
||||
const flattened = {
|
||||
"user.details.name": "John Doe",
|
||||
"user.details.age": 30,
|
||||
"user.preferences.colors.[0]": "blue",
|
||||
"user.preferences.colors.[1]": "green",
|
||||
"user.active": true,
|
||||
};
|
||||
const expected = {
|
||||
user: {
|
||||
details: {
|
||||
name: "John Doe",
|
||||
age: 30,
|
||||
},
|
||||
preferences: {
|
||||
colors: ["blue", "green"],
|
||||
},
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
expect(unflattenAttributes(flattened)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("correctly identifies arrays vs objects", () => {
|
||||
const flattened = {
|
||||
"array.[0]": 1,
|
||||
"array.[1]": 2,
|
||||
"object.key": "value",
|
||||
};
|
||||
const expected = {
|
||||
array: [1, 2],
|
||||
object: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
expect(unflattenAttributes(flattened)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("rehydrates circular references correctly", () => {
|
||||
const flattened = {
|
||||
name: "Alice",
|
||||
"blogPosts.[0].title": "Post 1",
|
||||
"blogPosts.[0].author": "$@circular((",
|
||||
};
|
||||
|
||||
const result = unflattenAttributes(flattened);
|
||||
expect(result).toEqual({
|
||||
name: "Alice",
|
||||
blogPosts: [{ title: "Post 1", author: "[Circular Reference]" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("respects maxDepth limit and skips overly deep keys", () => {
|
||||
// Create a flattened object with keys at various depths
|
||||
const flattened = {
|
||||
"a.b.c": "shallow", // depth 3 - should be included
|
||||
"a.b.c.d.e.f.g": "deep", // depth 7 - should be skipped with maxDepth=5
|
||||
"x.y": "also shallow", // depth 2 - should be included
|
||||
};
|
||||
|
||||
const result = unflattenAttributes(flattened, undefined, 5);
|
||||
|
||||
// Shallow keys should be included
|
||||
expect(result).toHaveProperty("a.b.c", "shallow");
|
||||
expect(result).toHaveProperty("x.y", "also shallow");
|
||||
|
||||
// Deep key should be skipped (not create the nested structure)
|
||||
expect((result as any)?.a?.b?.c?.d?.e?.f?.g).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses default maxDepth of 128", () => {
|
||||
// Create a key with 129 parts - should be skipped
|
||||
const deepKey = Array(129).fill("x").join(".");
|
||||
const flattened = {
|
||||
[deepKey]: "too deep",
|
||||
"a.b": "shallow",
|
||||
};
|
||||
|
||||
const result = unflattenAttributes(flattened);
|
||||
|
||||
// Shallow key should work
|
||||
expect(result).toHaveProperty("a.b", "shallow");
|
||||
|
||||
// Deep key should be skipped
|
||||
let current: any = result;
|
||||
for (let i = 0; i < 129 && current; i++) {
|
||||
current = current?.x;
|
||||
}
|
||||
expect(current).toBeUndefined();
|
||||
});
|
||||
|
||||
// Defends against external OTLP producers that emit both a leaf value and a
|
||||
// nested path through the same prefix in one attribute map (e.g. AI SDK
|
||||
// telemetry on certain models). The flattener can't produce these, but the
|
||||
// unflattener used to crash with TypeError when it tried to descend into a
|
||||
// primitive sibling.
|
||||
it("does not throw when a scalar precedes a deeper path at the same prefix", () => {
|
||||
expect(() => unflattenAttributes({ "a.b": "scalar", "a.b.c": "value" })).not.toThrow();
|
||||
expect(unflattenAttributes({ "a.b": "scalar", "a.b.c": "value" })).toEqual({
|
||||
a: { b: { c: "value" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not throw when a deeper path precedes a scalar at the same prefix", () => {
|
||||
expect(() => unflattenAttributes({ "a.b.c": "value", "a.b": "scalar" })).not.toThrow();
|
||||
expect(unflattenAttributes({ "a.b.c": "value", "a.b": "scalar" })).toEqual({
|
||||
a: { b: "scalar" },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an intermediate null sentinel as overwritable when a deeper path follows", () => {
|
||||
expect(() => unflattenAttributes({ "a.b": "$@null((", "a.b.c": "value" })).not.toThrow();
|
||||
expect(unflattenAttributes({ "a.b": "$@null((", "a.b.c": "value" })).toEqual({
|
||||
a: { b: { c: "value" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not throw when a scalar prefix conflicts with a numeric-index path", () => {
|
||||
expect(() => unflattenAttributes({ "a.b": "scalar", "a.b.[0]": "indexed" })).not.toThrow();
|
||||
expect(unflattenAttributes({ "a.b": "scalar", "a.b.[0]": "indexed" })).toEqual({
|
||||
a: { b: ["indexed"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("converts an existing object slot to an array when a numeric-index path follows", () => {
|
||||
expect(() => unflattenAttributes({ "a.b.c": "value", "a.b.[0]": "indexed" })).not.toThrow();
|
||||
expect(unflattenAttributes({ "a.b.c": "value", "a.b.[0]": "indexed" })).toEqual({
|
||||
a: { b: ["indexed"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
import { createTestHttpServer } from "@epic-web/test-server/http";
|
||||
import {
|
||||
replaceSuperJsonPayload,
|
||||
prettyPrintPacket,
|
||||
conditionallyExportPacket,
|
||||
type IOPacket,
|
||||
} from "../src/v3/utils/ioSerialization.js";
|
||||
import { ApiClient } from "../src/v3/apiClient/index.js";
|
||||
import { apiClientManager } from "../src/v3/apiClientManager-api.js";
|
||||
|
||||
describe("ioSerialization", () => {
|
||||
describe("replaceSuperJsonPayload", () => {
|
||||
it("should replace simple JSON payload while preserving SuperJSON metadata", async () => {
|
||||
const originalData = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
date: new Date("2023-01-01"),
|
||||
};
|
||||
|
||||
const superjson = await import("superjson");
|
||||
const originalSerialized = superjson.stringify(originalData);
|
||||
|
||||
const newPayloadJson = JSON.stringify({
|
||||
name: "Jane",
|
||||
surname: "Doe",
|
||||
age: 25,
|
||||
date: "2023-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const result = (await replaceSuperJsonPayload(originalSerialized, newPayloadJson)) as any;
|
||||
|
||||
expect(result.name).toBe("Jane");
|
||||
expect(result.surname).toBe("Doe");
|
||||
expect(result.age).toBe(25);
|
||||
expect(result.date).toBeInstanceOf(Date);
|
||||
expect(result.date.toISOString()).toBe("2023-02-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
// related to issue https://github.com/triggerdotdev/trigger.dev/issues/1968
|
||||
it("should ignore original undefined type metadata for overriden fields", async () => {
|
||||
const originalData = {
|
||||
name: "John",
|
||||
age: 30,
|
||||
date: new Date("2023-01-01"),
|
||||
country: undefined,
|
||||
settings: {
|
||||
theme: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const superjson = await import("superjson");
|
||||
const originalSerialized = superjson.stringify(originalData);
|
||||
|
||||
const newPayloadJson = JSON.stringify({
|
||||
name: "Jane",
|
||||
surname: "Doe",
|
||||
age: 25,
|
||||
date: "2023-02-01T00:00:00.000Z",
|
||||
country: "US",
|
||||
settings: {
|
||||
theme: "dark",
|
||||
},
|
||||
});
|
||||
|
||||
const result = (await replaceSuperJsonPayload(originalSerialized, newPayloadJson)) as any;
|
||||
|
||||
expect(result.name).toBe("Jane");
|
||||
expect(result.surname).toBe("Doe");
|
||||
expect(result.country).toBe("US");
|
||||
expect(result.settings.theme).toBe("dark");
|
||||
expect(result.age).toBe(25);
|
||||
expect(result.date).toBeInstanceOf(Date);
|
||||
expect(result.date.toISOString()).toBe("2023-02-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("should preserve BigInt type metadata", async () => {
|
||||
const originalData = {
|
||||
id: BigInt(123456789),
|
||||
count: 42,
|
||||
};
|
||||
|
||||
const superjson = await import("superjson");
|
||||
const originalSerialized = superjson.stringify(originalData);
|
||||
|
||||
const newPayloadJson = JSON.stringify({
|
||||
id: "987654321",
|
||||
count: 100,
|
||||
});
|
||||
|
||||
const result = (await replaceSuperJsonPayload(originalSerialized, newPayloadJson)) as any;
|
||||
|
||||
expect(result.id).toBe(BigInt(987654321));
|
||||
expect(typeof result.id).toBe("bigint");
|
||||
expect(result.count).toBe(100);
|
||||
});
|
||||
|
||||
it("should preserve nested type metadata", async () => {
|
||||
const originalData = {
|
||||
user: {
|
||||
id: BigInt(123),
|
||||
createdAt: new Date("2023-01-01"),
|
||||
settings: {
|
||||
theme: "dark",
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const superjson = await import("superjson");
|
||||
const originalSerialized = superjson.stringify(originalData);
|
||||
|
||||
const newPayloadJson = JSON.stringify({
|
||||
user: {
|
||||
id: "456",
|
||||
createdAt: "2023-06-01T00:00:00.000Z",
|
||||
settings: {
|
||||
theme: "light",
|
||||
updatedAt: "2023-06-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
version: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const result = (await replaceSuperJsonPayload(originalSerialized, newPayloadJson)) as any;
|
||||
|
||||
expect(result.user.id).toBe(BigInt(456));
|
||||
expect(result.user.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.user.createdAt.toISOString()).toBe("2023-06-01T00:00:00.000Z");
|
||||
expect(result.user.settings.theme).toBe("light");
|
||||
expect(result.user.settings.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result.user.settings.updatedAt.toISOString()).toBe("2023-06-01T00:00:00.000Z");
|
||||
expect(result.metadata.version).toBe(2);
|
||||
});
|
||||
|
||||
it("should preserve Set type metadata", async () => {
|
||||
const originalData = {
|
||||
tags: new Set(["tag1", "tag2"]),
|
||||
name: "test",
|
||||
};
|
||||
|
||||
const superjson = await import("superjson");
|
||||
const originalSerialized = superjson.stringify(originalData);
|
||||
|
||||
const newPayloadJson = JSON.stringify({
|
||||
tags: ["tag3", "tag4", "tag5"],
|
||||
name: "updated",
|
||||
});
|
||||
|
||||
const result = (await replaceSuperJsonPayload(originalSerialized, newPayloadJson)) as any;
|
||||
|
||||
expect(result.tags).toBeInstanceOf(Set);
|
||||
expect(Array.from(result.tags)).toEqual(["tag3", "tag4", "tag5"]);
|
||||
expect(result.name).toBe("updated");
|
||||
});
|
||||
|
||||
it("should preserve Map type metadata", async () => {
|
||||
const originalData = {
|
||||
mapping: new Map([
|
||||
["key1", "value1"],
|
||||
["key2", "value2"],
|
||||
]),
|
||||
name: "test",
|
||||
};
|
||||
|
||||
const superjson = await import("superjson");
|
||||
const originalSerialized = superjson.stringify(originalData);
|
||||
|
||||
const newPayloadJson = JSON.stringify({
|
||||
mapping: [
|
||||
["key3", "value3"],
|
||||
["key4", "value4"],
|
||||
],
|
||||
name: "updated",
|
||||
});
|
||||
|
||||
const result = (await replaceSuperJsonPayload(originalSerialized, newPayloadJson)) as any;
|
||||
|
||||
expect(result.mapping).toBeInstanceOf(Map);
|
||||
expect(result.mapping.get("key3")).toBe("value3");
|
||||
expect(result.mapping.get("key4")).toBe("value4");
|
||||
expect(result.name).toBe("updated");
|
||||
});
|
||||
|
||||
it("should throw error for invalid JSON payload", async () => {
|
||||
const originalData = { name: "test" };
|
||||
|
||||
const superjson = await import("superjson");
|
||||
const originalSerialized = superjson.stringify(originalData);
|
||||
const invalidPayload = "{ invalid json }";
|
||||
|
||||
await expect(replaceSuperJsonPayload(originalSerialized, invalidPayload)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("prettyPrintPacket", () => {
|
||||
it("should return empty string for undefined data", async () => {
|
||||
const result = await prettyPrintPacket(undefined);
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should return string data as-is", async () => {
|
||||
const result = await prettyPrintPacket("Hello, World!");
|
||||
expect(result).toBe("Hello, World!");
|
||||
});
|
||||
|
||||
it("should pretty print JSON data with default options", async () => {
|
||||
const data = { name: "John", age: 30, nested: { value: true } };
|
||||
const result = await prettyPrintPacket(data, "application/json");
|
||||
|
||||
expect(result).toBe(JSON.stringify(data, null, 2));
|
||||
});
|
||||
|
||||
it("should handle JSON data as string", async () => {
|
||||
const data = { name: "John", age: 30 };
|
||||
const jsonString = JSON.stringify(data);
|
||||
const result = await prettyPrintPacket(jsonString, "application/json");
|
||||
|
||||
expect(result).toBe(JSON.stringify(data, null, 2));
|
||||
});
|
||||
|
||||
it("should pretty print SuperJSON data", async () => {
|
||||
const data = {
|
||||
name: "John",
|
||||
date: new Date("2023-01-01"),
|
||||
bigInt: BigInt(123),
|
||||
set: new Set(["a", "b"]),
|
||||
map: new Map([["key", "value"]]),
|
||||
};
|
||||
|
||||
const superjson = await import("superjson");
|
||||
const serialized = superjson.stringify(data);
|
||||
|
||||
const result = await prettyPrintPacket(serialized, "application/super+json");
|
||||
|
||||
// Should deserialize and pretty print the data
|
||||
expect(result).toContain('"name": "John"');
|
||||
expect(result).toContain('"date": "2023-01-01T00:00:00.000Z"');
|
||||
expect(result).toContain('"bigInt": "123"');
|
||||
expect(result).toContain('"set": [\n "a",\n "b"\n ]');
|
||||
expect(result).toContain('"map": {\n "key": "value"\n }');
|
||||
});
|
||||
|
||||
it("should handle circular references", async () => {
|
||||
const data: any = { name: "John" };
|
||||
data.self = data; // Create circular reference
|
||||
|
||||
// Create a SuperJSON serialized version to test the circular reference detection
|
||||
const superjson = await import("superjson");
|
||||
const serialized = superjson.stringify(data);
|
||||
|
||||
const result = await prettyPrintPacket(serialized, "application/super+json");
|
||||
|
||||
expect(result).toContain('"name": "John"');
|
||||
expect(result).toContain('"self": "[Circular]"');
|
||||
});
|
||||
|
||||
it("should handle regular non-circular references", async () => {
|
||||
const person = { name: "John" };
|
||||
|
||||
const data: any = { person1: person, person2: person };
|
||||
|
||||
// Create a SuperJSON serialized version to test the circular reference detection
|
||||
const superjson = await import("superjson");
|
||||
const serialized = superjson.stringify(data);
|
||||
|
||||
const result = await prettyPrintPacket(serialized, "application/super+json");
|
||||
|
||||
expect(result).toContain('"person1": {');
|
||||
expect(result).toContain('"person2": {');
|
||||
});
|
||||
|
||||
it("should filter out specified keys", async () => {
|
||||
const data = { name: "John", password: "secret", age: 30 };
|
||||
const result = await prettyPrintPacket(data, "application/json", {
|
||||
filteredKeys: ["password"],
|
||||
});
|
||||
|
||||
expect(result).toContain('"name": "John"');
|
||||
expect(result).toContain('"age": 30');
|
||||
expect(result).not.toContain('"password"');
|
||||
});
|
||||
|
||||
it("should handle BigInt values", async () => {
|
||||
const data = { id: BigInt(123456789), name: "John" };
|
||||
const result = await prettyPrintPacket(data, "application/json");
|
||||
|
||||
expect(result).toContain('"id": "123456789"');
|
||||
expect(result).toContain('"name": "John"');
|
||||
});
|
||||
|
||||
it("should handle RegExp values", async () => {
|
||||
const data = { pattern: /test/gi, name: "John" };
|
||||
const result = await prettyPrintPacket(data, "application/json");
|
||||
|
||||
expect(result).toContain('"pattern": "/test/gi"');
|
||||
expect(result).toContain('"name": "John"');
|
||||
});
|
||||
|
||||
it("should handle Set values", async () => {
|
||||
const data = { tags: new Set(["tag1", "tag2"]), name: "John" };
|
||||
const result = await prettyPrintPacket(data, "application/json");
|
||||
|
||||
expect(result).toContain('"tags": [\n "tag1",\n "tag2"\n ]');
|
||||
expect(result).toContain('"name": "John"');
|
||||
});
|
||||
|
||||
it("should handle Map values", async () => {
|
||||
const data = { mapping: new Map([["key1", "value1"]]), name: "John" };
|
||||
const result = await prettyPrintPacket(data, "application/json");
|
||||
|
||||
expect(result).toContain('"mapping": {\n "key1": "value1"\n }');
|
||||
expect(result).toContain('"name": "John"');
|
||||
});
|
||||
|
||||
it("should handle complex nested data", async () => {
|
||||
const data = {
|
||||
user: {
|
||||
id: BigInt(123),
|
||||
createdAt: new Date("2023-01-01"),
|
||||
settings: {
|
||||
theme: "dark",
|
||||
tags: new Set(["admin", "user"]),
|
||||
config: new Map([["timeout", "30s"]]),
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
version: 1,
|
||||
pattern: /^test$/,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prettyPrintPacket(data, "application/json");
|
||||
|
||||
expect(result).toContain('"id": "123"');
|
||||
expect(result).toContain('"createdAt": "2023-01-01T00:00:00.000Z"');
|
||||
expect(result).toContain('"theme": "dark"');
|
||||
expect(result).toContain('"tags": [\n "admin",\n "user"\n ]');
|
||||
expect(result).toContain('"config": {\n "timeout": "30s"\n }');
|
||||
expect(result).toContain('"version": 1');
|
||||
expect(result).toContain('"pattern": "/^test$/"');
|
||||
});
|
||||
|
||||
it("should handle data without dataType parameter", async () => {
|
||||
const data = { name: "John", age: 30 };
|
||||
const result = await prettyPrintPacket(data);
|
||||
|
||||
expect(result).toBe(JSON.stringify(data, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditionallyExportPacket", () => {
|
||||
// A payload large enough to exceed OFFLOAD_IO_PACKET_LENGTH_LIMIT (128KB) so it offloads.
|
||||
const largePayload = "x".repeat(200_000);
|
||||
const largePacket: IOPacket = { data: largePayload, dataType: "text/plain" };
|
||||
|
||||
afterEach(() => {
|
||||
// Clear any global client config set during a test.
|
||||
apiClientManager.disable();
|
||||
});
|
||||
|
||||
it("uses the provided client for the upload presign instead of the global client", async () => {
|
||||
const globalPresignRequests: string[] = [];
|
||||
const passedPresignRequests: string[] = [];
|
||||
let uploadedBytes = 0;
|
||||
|
||||
// The global client points here — it must NOT be hit when a client is passed.
|
||||
const globalServer = await createTestHttpServer({
|
||||
defineRoutes(router) {
|
||||
router.put("/api/v2/packets/:filename", async ({ req }) => {
|
||||
globalPresignRequests.push(req.url);
|
||||
return Response.json({ presignedUrl: "http://unused.local/upload" });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// The explicitly passed client points here — it MUST receive the presign + upload.
|
||||
const passedServer = await createTestHttpServer({
|
||||
defineRoutes(router) {
|
||||
router.put("/api/v2/packets/:filename", async ({ req }) => {
|
||||
passedPresignRequests.push(req.url);
|
||||
return Response.json({
|
||||
presignedUrl: `${passedServer.http.url().origin}/upload/payload`,
|
||||
storagePath: "trigger/task/payload.txt",
|
||||
});
|
||||
});
|
||||
router.put("/upload/payload", async ({ req }) => {
|
||||
uploadedBytes = (await req.text()).length;
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Configure the global client to point at the global server.
|
||||
apiClientManager.setGlobalAPIClientConfiguration({
|
||||
baseURL: globalServer.http.url().origin,
|
||||
accessToken: "tr-global",
|
||||
});
|
||||
|
||||
const passedClient = new ApiClient(passedServer.http.url().origin, "tr-passed");
|
||||
|
||||
const result = await conditionallyExportPacket(
|
||||
largePacket,
|
||||
"trigger/task/payload",
|
||||
undefined,
|
||||
passedClient
|
||||
);
|
||||
|
||||
expect(result.dataType).toBe("application/store");
|
||||
expect(result.data).toBe("trigger/task/payload.txt");
|
||||
|
||||
// Upload went through the passed client only.
|
||||
expect(passedPresignRequests).toHaveLength(1);
|
||||
expect(globalPresignRequests).toHaveLength(0);
|
||||
expect(uploadedBytes).toBe(largePayload.length);
|
||||
} finally {
|
||||
await globalServer.close();
|
||||
await passedServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the global client when no client is provided", async () => {
|
||||
const presignRequests: string[] = [];
|
||||
|
||||
const globalServer = await createTestHttpServer({
|
||||
defineRoutes(router) {
|
||||
router.put("/api/v2/packets/:filename", async ({ req }) => {
|
||||
presignRequests.push(req.url);
|
||||
return Response.json({
|
||||
presignedUrl: `${globalServer.http.url().origin}/upload/payload`,
|
||||
storagePath: "trigger/task/payload.txt",
|
||||
});
|
||||
});
|
||||
router.put("/upload/payload", async () => new Response(null, { status: 200 }));
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
apiClientManager.setGlobalAPIClientConfiguration({
|
||||
baseURL: globalServer.http.url().origin,
|
||||
accessToken: "tr-global",
|
||||
});
|
||||
|
||||
const result = await conditionallyExportPacket(largePacket, "trigger/task/payload");
|
||||
|
||||
expect(result.dataType).toBe("application/store");
|
||||
expect(result.data).toBe("trigger/task/payload.txt");
|
||||
expect(presignRequests).toHaveLength(1);
|
||||
} finally {
|
||||
await globalServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the packet unchanged when no client is available", async () => {
|
||||
apiClientManager.disable();
|
||||
|
||||
const result = await conditionallyExportPacket(largePacket, "trigger/task/payload");
|
||||
|
||||
expect(result).toEqual(largePacket);
|
||||
});
|
||||
|
||||
it("does not offload small payloads even with a client", async () => {
|
||||
const passedServer = await createTestHttpServer({
|
||||
defineRoutes(router) {
|
||||
router.put("/api/v2/packets/:filename", async () => {
|
||||
throw new Error("presign should not be called for small payloads");
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const smallPacket: IOPacket = { data: "hello", dataType: "text/plain" };
|
||||
const passedClient = new ApiClient(passedServer.http.url().origin, "tr-passed");
|
||||
|
||||
const result = await conditionallyExportPacket(
|
||||
smallPacket,
|
||||
"trigger/task/payload",
|
||||
undefined,
|
||||
passedClient
|
||||
);
|
||||
|
||||
expect(result).toEqual(smallPacket);
|
||||
} finally {
|
||||
await passedServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { jumpHash } from "../src/v3/serverOnly/index.js";
|
||||
|
||||
describe("jumpHash", () => {
|
||||
it("should hash a string to a number", () => {
|
||||
expect(jumpHash("test", 10)).toBe(5);
|
||||
});
|
||||
|
||||
it("should hash different strings to numbers in range", () => {
|
||||
for (const key of ["a", "b", "c", "test", "trigger", "dev", "123", "!@#"]) {
|
||||
for (const buckets of [1, 2, 5, 10, 100, 1000]) {
|
||||
const result = jumpHash(key, buckets);
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThan(buckets);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should return 0 for any key if buckets is 1", () => {
|
||||
expect(jumpHash("anything", 1)).toBe(0);
|
||||
expect(jumpHash("", 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle empty string key", () => {
|
||||
expect(jumpHash("", 10)).toBeGreaterThanOrEqual(0);
|
||||
expect(jumpHash("", 10)).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it("should distribute keys evenly across buckets", () => {
|
||||
const buckets = 10;
|
||||
const numKeys = 10000;
|
||||
const counts = Array(buckets).fill(0);
|
||||
for (let i = 0; i < numKeys; i++) {
|
||||
const key = `key_${i}`;
|
||||
const bucket = jumpHash(key, buckets);
|
||||
counts[bucket]++;
|
||||
}
|
||||
const avg = numKeys / buckets;
|
||||
// No bucket should have less than half or more than double the average
|
||||
for (const count of counts) {
|
||||
expect(count).toBeGreaterThanOrEqual(avg * 0.5);
|
||||
expect(count).toBeLessThanOrEqual(avg * 2);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have minimal movement when increasing buckets by 1", () => {
|
||||
const numKeys = 1000;
|
||||
const buckets = 50;
|
||||
let moved = 0;
|
||||
for (let i = 0; i < numKeys; i++) {
|
||||
const key = `key_${i}`;
|
||||
const bucket1 = jumpHash(key, buckets);
|
||||
const bucket2 = jumpHash(key, buckets + 1);
|
||||
if (bucket1 !== bucket2) moved++;
|
||||
}
|
||||
// For jump consistent hash, about 1/(buckets+1) of keys should move
|
||||
const expectedMoved = numKeys / (buckets + 1);
|
||||
expect(moved).toBeGreaterThanOrEqual(expectedMoved * 0.5);
|
||||
expect(moved).toBeLessThanOrEqual(expectedMoved * 2);
|
||||
});
|
||||
|
||||
it("should be deterministic for the same key and bucket count", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = `key_${i}`;
|
||||
const buckets = 20;
|
||||
const result1 = jumpHash(key, buckets);
|
||||
const result2 = jumpHash(key, buckets);
|
||||
expect(result1).toBe(result2);
|
||||
}
|
||||
});
|
||||
|
||||
it("should always return a value in [0, buckets-1]", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const key = `key_${i}`;
|
||||
for (let buckets = 1; buckets < 50; buckets++) {
|
||||
const result = jumpHash(key, buckets);
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThan(buckets);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runInMockTaskContext } from "../src/v3/test/index.js";
|
||||
import { inputStreams } from "../src/v3/input-streams-api.js";
|
||||
import { realtimeStreams } from "../src/v3/realtime-streams-api.js";
|
||||
import { locals } from "../src/v3/locals-api.js";
|
||||
import { taskContext } from "../src/v3/task-context-api.js";
|
||||
|
||||
describe("runInMockTaskContext", () => {
|
||||
it("installs a mock TaskRunContext with sensible defaults", async () => {
|
||||
await runInMockTaskContext(async ({ ctx }) => {
|
||||
expect(taskContext.ctx).toBeDefined();
|
||||
expect(taskContext.ctx?.run.id).toBe("run_test");
|
||||
expect(taskContext.ctx?.task.id).toBe("test-task");
|
||||
expect(ctx.run.id).toBe("run_test");
|
||||
});
|
||||
});
|
||||
|
||||
it("applies ctx overrides on top of defaults", async () => {
|
||||
await runInMockTaskContext(
|
||||
async ({ ctx }) => {
|
||||
expect(ctx.run.id).toBe("run_abc");
|
||||
expect(ctx.task.id).toBe("my-chat-agent");
|
||||
// Unspecified fields still use defaults
|
||||
expect(ctx.queue.id).toBe("test-queue-id");
|
||||
},
|
||||
{
|
||||
ctx: {
|
||||
run: { id: "run_abc" },
|
||||
task: { id: "my-chat-agent", filePath: "chat.ts" },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("isolates locals from the surrounding context", async () => {
|
||||
const key = locals.create<{ count: number }>("test.counter");
|
||||
|
||||
await runInMockTaskContext(async ({ locals: inspect }) => {
|
||||
expect(inspect.get(key)).toBeUndefined();
|
||||
locals.set(key, { count: 1 });
|
||||
expect(inspect.get(key)).toEqual({ count: 1 });
|
||||
});
|
||||
|
||||
// After the harness exits, the locals should be gone
|
||||
expect(locals.get(key)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tears down the task context after fn returns", async () => {
|
||||
await runInMockTaskContext(async () => {
|
||||
expect(taskContext.ctx).toBeDefined();
|
||||
});
|
||||
|
||||
expect(taskContext.ctx).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tears down even when fn throws", async () => {
|
||||
await expect(
|
||||
runInMockTaskContext(async () => {
|
||||
throw new Error("boom");
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(taskContext.ctx).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the value returned by fn", async () => {
|
||||
const result = await runInMockTaskContext(async () => "hello");
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
describe("input streams driver", () => {
|
||||
it("resolves inputStreams.once() when test sends data", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const pending = inputStreams.once("chat-messages");
|
||||
setTimeout(() => inputs.send("chat-messages", { hello: "world" }), 0);
|
||||
const result = await pending;
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.output).toEqual({ hello: "world" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("fires inputStreams.on() handlers when test sends data", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const received: unknown[] = [];
|
||||
inputStreams.on("chat-messages", (data) => {
|
||||
received.push(data);
|
||||
});
|
||||
|
||||
await inputs.send("chat-messages", { n: 1 });
|
||||
await inputs.send("chat-messages", { n: 2 });
|
||||
|
||||
expect(received).toEqual([{ n: 1 }, { n: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("fires multiple on() handlers on the same stream", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const a: unknown[] = [];
|
||||
const b: unknown[] = [];
|
||||
inputStreams.on("chat-messages", (data) => a.push(data));
|
||||
inputStreams.on("chat-messages", (data) => b.push(data));
|
||||
|
||||
await inputs.send("chat-messages", "hi");
|
||||
expect(a).toEqual(["hi"]);
|
||||
expect(b).toEqual(["hi"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("off() unsubscribes a handler", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const received: unknown[] = [];
|
||||
const sub = inputStreams.on("chat-messages", (data) => received.push(data));
|
||||
|
||||
await inputs.send("chat-messages", 1);
|
||||
sub.off();
|
||||
await inputs.send("chat-messages", 2);
|
||||
|
||||
expect(received).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
it("times out once() after timeoutMs", async () => {
|
||||
await runInMockTaskContext(async () => {
|
||||
const result = await inputStreams.once("chat-messages", { timeoutMs: 10 });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("peek() returns the latest sent value", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
expect(inputStreams.peek("chat-messages")).toBeUndefined();
|
||||
await inputs.send("chat-messages", { latest: true });
|
||||
expect(inputStreams.peek("chat-messages")).toEqual({ latest: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("close() rejects pending once() waiters with a timeout error", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const pending = inputStreams.once("chat-messages");
|
||||
inputs.close("chat-messages");
|
||||
const result = await pending;
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves multiple concurrent once() waiters from a single send", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
const a = inputStreams.once("chat-messages");
|
||||
const b = inputStreams.once("chat-messages");
|
||||
await inputs.send("chat-messages", "shared");
|
||||
const [ra, rb] = await Promise.all([a, b]);
|
||||
expect(ra.ok && ra.output).toBe("shared");
|
||||
expect(rb.ok && rb.output).toBe("shared");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("realtime streams driver", () => {
|
||||
it("collects chunks from realtimeStreams.append()", async () => {
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
await realtimeStreams.append("chat", "chunk-1" as unknown as BodyInit);
|
||||
await realtimeStreams.append("chat", "chunk-2" as unknown as BodyInit);
|
||||
|
||||
expect(outputs.chunks("chat")).toEqual(["chunk-1", "chunk-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("collects chunks from realtimeStreams.pipe()", async () => {
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
const source = (async function* () {
|
||||
yield "a";
|
||||
yield "b";
|
||||
yield "c";
|
||||
})();
|
||||
|
||||
const instance = realtimeStreams.pipe("chat", source);
|
||||
|
||||
// Drain the returned stream — that's what feeds the buffer
|
||||
for await (const _ of instance.stream) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
expect(outputs.chunks("chat")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("separates chunks by stream id", async () => {
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
await realtimeStreams.append("chat", "a" as unknown as BodyInit);
|
||||
await realtimeStreams.append("stop", "halt" as unknown as BodyInit);
|
||||
|
||||
expect(outputs.chunks("chat")).toEqual(["a"]);
|
||||
expect(outputs.chunks("stop")).toEqual(["halt"]);
|
||||
expect(outputs.all()).toEqual({ chat: ["a"], stop: ["halt"] });
|
||||
});
|
||||
});
|
||||
|
||||
it("clear() empties one stream or all streams", async () => {
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
await realtimeStreams.append("chat", "a" as unknown as BodyInit);
|
||||
await realtimeStreams.append("stop", "halt" as unknown as BodyInit);
|
||||
|
||||
outputs.clear("chat");
|
||||
expect(outputs.chunks("chat")).toEqual([]);
|
||||
expect(outputs.chunks("stop")).toEqual(["halt"]);
|
||||
|
||||
outputs.clear();
|
||||
expect(outputs.chunks("stop")).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("tears down input/output managers so consecutive calls are isolated", async () => {
|
||||
await runInMockTaskContext(async ({ inputs }) => {
|
||||
await inputs.send("chat-messages", "first-run");
|
||||
});
|
||||
|
||||
await runInMockTaskContext(async ({ outputs }) => {
|
||||
expect(outputs.chunks("chat-messages")).toEqual([]);
|
||||
// inputs.peek should NOT see "first-run" from the prior harness
|
||||
expect(inputStreams.peek("chat-messages")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { recordSpanException } from "../src/v3/otel/utils.js";
|
||||
import type { Span } from "@opentelemetry/api";
|
||||
|
||||
function createMockSpan() {
|
||||
return {
|
||||
recordException: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
} as unknown as Span & {
|
||||
recordException: ReturnType<typeof vi.fn>;
|
||||
setStatus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("recordSpanException", () => {
|
||||
it("records Error instances with truncated message and stack", () => {
|
||||
const span = createMockSpan();
|
||||
const error = new Error("x".repeat(5_000));
|
||||
recordSpanException(span, error);
|
||||
|
||||
expect(span.recordException).toHaveBeenCalledTimes(1);
|
||||
const recorded = (span.recordException as any).mock.calls[0][0] as Error;
|
||||
expect(recorded).toBeInstanceOf(Error);
|
||||
expect(recorded.message.length).toBeLessThan(1100);
|
||||
});
|
||||
|
||||
it("records string errors with truncation", () => {
|
||||
const span = createMockSpan();
|
||||
recordSpanException(span, "x".repeat(10_000));
|
||||
|
||||
const recorded = (span.recordException as any).mock.calls[0][0] as string;
|
||||
expect(typeof recorded).toBe("string");
|
||||
expect(recorded.length).toBeLessThan(5_100);
|
||||
expect(recorded).toContain("...[truncated]");
|
||||
});
|
||||
|
||||
it("does not throw on circular references", () => {
|
||||
const span = createMockSpan();
|
||||
const circular: any = { foo: "bar" };
|
||||
circular.self = circular;
|
||||
|
||||
expect(() => recordSpanException(span, circular)).not.toThrow();
|
||||
expect(span.recordException).toHaveBeenCalledTimes(1);
|
||||
expect(span.setStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not throw on BigInt values", () => {
|
||||
const span = createMockSpan();
|
||||
const error = { count: BigInt(123) };
|
||||
|
||||
expect(() => recordSpanException(span, error)).not.toThrow();
|
||||
expect(span.recordException).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles symbol values (JSON.stringify returns undefined)", () => {
|
||||
const span = createMockSpan();
|
||||
const sym = Symbol("test");
|
||||
|
||||
expect(() => recordSpanException(span, sym)).not.toThrow();
|
||||
expect(span.recordException).toHaveBeenCalledTimes(1);
|
||||
const recorded = (span.recordException as any).mock.calls[0][0] as string;
|
||||
expect(typeof recorded).toBe("string");
|
||||
expect(recorded).toContain("Symbol");
|
||||
});
|
||||
|
||||
it("handles function values (JSON.stringify returns undefined)", () => {
|
||||
const span = createMockSpan();
|
||||
const fn = () => "test";
|
||||
|
||||
expect(() => recordSpanException(span, fn)).not.toThrow();
|
||||
expect(span.recordException).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles undefined (JSON.stringify returns undefined)", () => {
|
||||
const span = createMockSpan();
|
||||
|
||||
expect(() => recordSpanException(span, undefined)).not.toThrow();
|
||||
expect(span.recordException).toHaveBeenCalledTimes(1);
|
||||
const recorded = (span.recordException as any).mock.calls[0][0] as string;
|
||||
expect(typeof recorded).toBe("string");
|
||||
});
|
||||
|
||||
it("always calls setStatus ERROR", () => {
|
||||
const span = createMockSpan();
|
||||
recordSpanException(span, new Error("test"));
|
||||
recordSpanException(span, "string");
|
||||
recordSpanException(span, { obj: true });
|
||||
|
||||
expect(span.setStatus).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
NO_FILE_CONTEXT,
|
||||
StandardResourceCatalog,
|
||||
} from "../src/v3/resource-catalog/standardResourceCatalog.js";
|
||||
|
||||
// Regression tests for COULD_NOT_FIND_EXECUTOR on warm worker processes when
|
||||
// a task's `task()` / `schemaTask()` call is evaluated during another task's
|
||||
// execution (e.g. as a side effect of `await import(...)` of a module that
|
||||
// contains a task definition).
|
||||
//
|
||||
// Production throw site:
|
||||
// - managed-run-worker.ts:566 (post-wrap)
|
||||
// - dev-run-worker.ts:578 (post-wrap)
|
||||
// Pre-fix symptom: `resourceCatalog.getTask(execution.task.id)` returned
|
||||
// undefined even after the worker re-imported the task entrypoint.
|
||||
//
|
||||
// Pre-fix mechanism: `registerTaskMetadata` silently returned when
|
||||
// `_currentFileContext` was unset. Any `task()` call firing during a
|
||||
// running task's run() / lifecycle hooks (directly, or transitively via a
|
||||
// dynamic import) hit the silent guard. Node's ESM module cache then
|
||||
// prevented recovery — the worker's setContext + re-import fallback didn't
|
||||
// re-evaluate the module body, so the `task()` call never fired again.
|
||||
//
|
||||
// Post-fix: the runtime workers wrap their `executor.execute(...)` call with
|
||||
// `setCurrentFileContext(NO_FILE_CONTEXT, NO_FILE_CONTEXT)` so any `task()`
|
||||
// call firing during execution registers normally with sentinel file
|
||||
// metadata. The catalog detects the sentinel and emits a one-time warning
|
||||
// per task id to keep the bundle-shape pattern visible. The indexer never
|
||||
// sets this sentinel context — its behavior is unchanged.
|
||||
|
||||
describe("StandardResourceCatalog — runtime registration via sentinel context", () => {
|
||||
afterEach(() => {
|
||||
delete (globalThis as { __catalogRegisterTaskMetadata?: unknown })
|
||||
.__catalogRegisterTaskMetadata;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("silently drops registration when no context is set (indexer's invariant)", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
catalog.registerTaskMetadata({
|
||||
id: "no-context-task",
|
||||
fns: { run: async () => "ok" },
|
||||
});
|
||||
|
||||
expect(catalog.getTask("no-context-task")).toBeUndefined();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(
|
||||
"registers normally and warns once when the sentinel context is set " +
|
||||
"(simulates the worker's executor wrap)",
|
||||
() => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
catalog.setCurrentFileContext(NO_FILE_CONTEXT, NO_FILE_CONTEXT);
|
||||
catalog.registerTaskMetadata({
|
||||
id: "lazy-task",
|
||||
fns: { run: async () => "ok" },
|
||||
});
|
||||
catalog.clearCurrentFileContext();
|
||||
|
||||
const registered = catalog.getTask("lazy-task");
|
||||
expect(registered).toBeDefined();
|
||||
expect(registered?.id).toBe("lazy-task");
|
||||
expect(registered?.filePath).toBe(NO_FILE_CONTEXT);
|
||||
expect(registered?.entryPoint).toBe(NO_FILE_CONTEXT);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[0]).toContain("lazy-task");
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"warm-start path: a task whose top-level definition fires during a " +
|
||||
"dynamic import inside the sentinel wrap remains findable; the " +
|
||||
"worker's setContext + re-import fallback (managed-run-worker.ts:482) " +
|
||||
"is not needed",
|
||||
async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
(globalThis as { __catalogRegisterTaskMetadata?: unknown }).__catalogRegisterTaskMetadata = (
|
||||
task: Parameters<StandardResourceCatalog["registerTaskMetadata"]>[0]
|
||||
) => {
|
||||
catalog.registerTaskMetadata(task);
|
||||
};
|
||||
|
||||
// Simulate the worker wrap: setContext(NO_FILE_CONTEXT) → run user code
|
||||
// (which does a dynamic import) → clearContext.
|
||||
catalog.setCurrentFileContext(NO_FILE_CONTEXT, NO_FILE_CONTEXT);
|
||||
await import("./fixtures/dynamic-task-module.mjs");
|
||||
catalog.clearCurrentFileContext();
|
||||
|
||||
const registered = catalog.getTask("lazy-task");
|
||||
expect(registered).toBeDefined();
|
||||
expect(registered?.filePath).toBe(NO_FILE_CONTEXT);
|
||||
}
|
||||
);
|
||||
|
||||
it("warns at most once per task id under the sentinel context", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
catalog.setCurrentFileContext(NO_FILE_CONTEXT, NO_FILE_CONTEXT);
|
||||
|
||||
const register = (id: string) =>
|
||||
catalog.registerTaskMetadata({
|
||||
id,
|
||||
fns: { run: async () => "ok" },
|
||||
});
|
||||
|
||||
register("task-a");
|
||||
register("task-a");
|
||||
register("task-a");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
register("task-b");
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
|
||||
catalog.clearCurrentFileContext();
|
||||
});
|
||||
|
||||
it("control: real file context registers without firing the sentinel warning", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
(globalThis as { __catalogRegisterTaskMetadata?: unknown }).__catalogRegisterTaskMetadata = (
|
||||
task: Parameters<StandardResourceCatalog["registerTaskMetadata"]>[0]
|
||||
) => {
|
||||
catalog.registerTaskMetadata(task);
|
||||
};
|
||||
|
||||
catalog.setCurrentFileContext("/app/dist/lazy-task.entry.mjs", "src/tasks/lazy-task.ts");
|
||||
await import("./fixtures/dynamic-task-module.mjs?control");
|
||||
catalog.clearCurrentFileContext();
|
||||
|
||||
const task = catalog.getTask("lazy-task");
|
||||
expect(task).toBeDefined();
|
||||
expect(task?.filePath).toBe("/app/dist/lazy-task.entry.mjs");
|
||||
expect(task?.entryPoint).toBe("src/tasks/lazy-task.ts");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("StandardResourceCatalog — duplicate task id collisions", () => {
|
||||
function register(catalog: StandardResourceCatalog, id: string, filePath: string) {
|
||||
catalog.setCurrentFileContext(filePath, filePath);
|
||||
catalog.registerTaskMetadata({ id, fns: { run: async () => "ok" } });
|
||||
catalog.clearCurrentFileContext();
|
||||
}
|
||||
|
||||
it("reports no collisions when every task id is unique", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
register(catalog, "a", "src/a.ts");
|
||||
register(catalog, "b", "src/b.ts");
|
||||
|
||||
expect(catalog.listTaskIdCollisions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("records a collision with both file paths when an id is reused across files", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
register(catalog, "dupe", "src/a.ts");
|
||||
register(catalog, "dupe", "src/b.ts");
|
||||
|
||||
expect(catalog.listTaskIdCollisions()).toEqual([
|
||||
{ id: "dupe", filePaths: ["src/a.ts", "src/b.ts"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects every distinct file path when an id is defined three or more times", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
register(catalog, "dupe", "src/a.ts");
|
||||
register(catalog, "dupe", "src/b.ts");
|
||||
register(catalog, "dupe", "src/c.ts");
|
||||
|
||||
expect(catalog.listTaskIdCollisions()).toEqual([
|
||||
{ id: "dupe", filePaths: ["src/a.ts", "src/b.ts", "src/c.ts"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records two definitions in the same file (e.g. two exports sharing an id)", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
register(catalog, "dupe", "src/dupe.ts");
|
||||
register(catalog, "dupe", "src/dupe.ts");
|
||||
|
||||
expect(catalog.listTaskIdCollisions()).toEqual([
|
||||
{ id: "dupe", filePaths: ["src/dupe.ts", "src/dupe.ts"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
SSEStreamPart,
|
||||
StreamSubscription,
|
||||
StreamSubscriptionFactory,
|
||||
} from "../src/v3/apiClient/runStream.js";
|
||||
import { RunSubscription, SSEStreamSubscription } from "../src/v3/apiClient/runStream.js";
|
||||
import type { SubscribeRunRawShape } from "../src/v3/schemas/api.js";
|
||||
|
||||
// Test implementations
|
||||
// Update TestStreamSubscription to return a ReadableStream
|
||||
class TestStreamSubscription implements StreamSubscription {
|
||||
constructor(private chunks: unknown[]) {}
|
||||
|
||||
async subscribe(): Promise<ReadableStream<SSEStreamPart<unknown>>> {
|
||||
return new ReadableStream({
|
||||
start: async (controller) => {
|
||||
for (let i = 0; i < this.chunks.length; i++) {
|
||||
controller.enqueue({
|
||||
id: `msg-${i}`,
|
||||
chunk: this.chunks[i],
|
||||
timestamp: Date.now() + i,
|
||||
});
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamSubscriptionFactory can remain the same
|
||||
class TestStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
private streams = new Map<string, unknown[]>();
|
||||
|
||||
setStreamChunks(runId: string, streamKey: string, chunks: unknown[]) {
|
||||
this.streams.set(`${runId}:${streamKey}`, chunks);
|
||||
}
|
||||
|
||||
createSubscription(runId: string, streamKey: string): StreamSubscription {
|
||||
const chunks = this.streams.get(`${runId}:${streamKey}`) ?? [];
|
||||
return new TestStreamSubscription(chunks);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the RunShapeProvider implementations and replace with stream creators
|
||||
function createTestShapeStream(
|
||||
shapes: SubscribeRunRawShape[]
|
||||
): ReadableStream<SubscribeRunRawShape> {
|
||||
return new ReadableStream({
|
||||
start: async (controller) => {
|
||||
// Emit all shapes immediately
|
||||
for (const shape of shapes) {
|
||||
controller.enqueue(shape);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createDelayedTestShapeStream(
|
||||
shapes: SubscribeRunRawShape[]
|
||||
): ReadableStream<SubscribeRunRawShape> {
|
||||
return new ReadableStream({
|
||||
start: async (controller) => {
|
||||
// Emit first shape immediately
|
||||
if (shapes.length > 0) {
|
||||
controller.enqueue(shapes[0]);
|
||||
}
|
||||
|
||||
let currentShapeIndex = 1;
|
||||
|
||||
// Emit remaining shapes with delay
|
||||
const interval = setInterval(() => {
|
||||
if (currentShapeIndex >= shapes.length) {
|
||||
clearInterval(interval);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(shapes[currentShapeIndex++]!);
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("RunSubscription", () => {
|
||||
it("should handle basic run subscription", async () => {
|
||||
const shapes = [
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "test-task",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 100,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
realtimeStreams: [],
|
||||
},
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
stopRunShapeStream: () => {},
|
||||
streamFactory: new TestStreamSubscriptionFactory(),
|
||||
closeOnComplete: true,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await convertAsyncIterableToArray(subscription);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
id: "run_123",
|
||||
taskIdentifier: "test-task",
|
||||
status: "COMPLETED",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle payload and outputs", async () => {
|
||||
const shapes: SubscribeRunRawShape[] = [
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "test-task",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 100,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
payload: JSON.stringify({ test: "payload" }),
|
||||
payloadType: "application/json",
|
||||
output: JSON.stringify({ test: "output" }),
|
||||
outputType: "application/json",
|
||||
realtimeStreams: [],
|
||||
},
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
stopRunShapeStream: () => {},
|
||||
streamFactory: new TestStreamSubscriptionFactory(),
|
||||
closeOnComplete: true,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await convertAsyncIterableToArray(subscription);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
id: "run_123",
|
||||
taskIdentifier: "test-task",
|
||||
status: "COMPLETED",
|
||||
payload: { test: "payload" },
|
||||
output: { test: "output" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep stream open when closeOnComplete is false", async () => {
|
||||
const shapes: SubscribeRunRawShape[] = [
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "test-task",
|
||||
status: "EXECUTING",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 100,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
realtimeStreams: [],
|
||||
},
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "test-task",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 200,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
realtimeStreams: [],
|
||||
},
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
runShapeStream: createDelayedTestShapeStream(shapes),
|
||||
stopRunShapeStream: () => {},
|
||||
streamFactory: new TestStreamSubscriptionFactory(),
|
||||
closeOnComplete: false,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
// Collect 2 results
|
||||
const results = await collectNResults(subscription, 2);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]).toMatchObject({
|
||||
id: "run_123",
|
||||
taskIdentifier: "test-task",
|
||||
status: "EXECUTING",
|
||||
});
|
||||
expect(results[1]).toMatchObject({
|
||||
id: "run_123",
|
||||
taskIdentifier: "test-task",
|
||||
status: "COMPLETED",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle stream data", async () => {
|
||||
const streamFactory = new TestStreamSubscriptionFactory();
|
||||
|
||||
// Set up test chunks
|
||||
streamFactory.setStreamChunks("run_123", "openai", [
|
||||
{ id: "chunk1", content: "Hello" },
|
||||
{ id: "chunk2", content: "World" },
|
||||
]);
|
||||
|
||||
const shapes = [
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "openai-streaming",
|
||||
status: "EXECUTING",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 100,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({}),
|
||||
metadataType: "application/json",
|
||||
realtimeStreams: ["openai"],
|
||||
},
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
stopRunShapeStream: () => {},
|
||||
streamFactory,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await collectNResults(
|
||||
subscription.withStreams<{ openai: { id: string; content: string } }>(),
|
||||
3 // 1 run + 2 stream chunks
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results[0]).toMatchObject({
|
||||
type: "run",
|
||||
run: { id: "run_123", taskIdentifier: "openai-streaming", status: "EXECUTING" },
|
||||
});
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "openai",
|
||||
chunk: { id: "chunk1", content: "Hello" },
|
||||
run: { id: "run_123", taskIdentifier: "openai-streaming", status: "EXECUTING" },
|
||||
});
|
||||
expect(results[2]).toMatchObject({
|
||||
type: "openai",
|
||||
chunk: { id: "chunk2", content: "World" },
|
||||
run: { id: "run_123", taskIdentifier: "openai-streaming", status: "EXECUTING" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should only create one stream for multiple runs of the same id", async () => {
|
||||
const streamFactory = new TestStreamSubscriptionFactory();
|
||||
let streamCreationCount = 0;
|
||||
|
||||
// Override createSubscription to count calls
|
||||
const originalCreate = streamFactory.createSubscription.bind(streamFactory);
|
||||
streamFactory.createSubscription = (runId: string, streamKey: string) => {
|
||||
streamCreationCount++;
|
||||
return originalCreate(runId, streamKey);
|
||||
};
|
||||
|
||||
// Set up test chunks
|
||||
streamFactory.setStreamChunks("run_123", "openai", [
|
||||
{ id: "chunk1", content: "Hello" },
|
||||
{ id: "chunk2", content: "World" },
|
||||
]);
|
||||
|
||||
const shapes = [
|
||||
// First run update
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "openai-streaming",
|
||||
status: "EXECUTING",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 100,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({}),
|
||||
metadataType: "application/json",
|
||||
realtimeStreams: ["openai"],
|
||||
},
|
||||
// Second run update with same stream key
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "openai-streaming",
|
||||
status: "EXECUTING",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 200, // Different to show it's a new update
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({}),
|
||||
metadataType: "application/json",
|
||||
realtimeStreams: ["openai"],
|
||||
},
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
stopRunShapeStream: () => {},
|
||||
streamFactory,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await collectNResults(
|
||||
subscription.withStreams<{ openai: { id: string; content: string } }>(),
|
||||
4 // 2 runs + 2 stream chunks
|
||||
);
|
||||
|
||||
// Verify we only created one stream
|
||||
expect(streamCreationCount).toBe(1);
|
||||
|
||||
// Verify we got all the expected events
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results[0]).toMatchObject({
|
||||
type: "run",
|
||||
run: {
|
||||
id: "run_123",
|
||||
taskIdentifier: "openai-streaming",
|
||||
status: "EXECUTING",
|
||||
durationMs: 100,
|
||||
},
|
||||
});
|
||||
expect(results[1]).toMatchObject({
|
||||
type: "openai",
|
||||
chunk: { id: "chunk1", content: "Hello" },
|
||||
run: { id: "run_123", durationMs: 100 },
|
||||
});
|
||||
expect(results[2]).toMatchObject({
|
||||
type: "openai",
|
||||
chunk: { id: "chunk2", content: "World" },
|
||||
run: { id: "run_123", durationMs: 100 },
|
||||
});
|
||||
expect(results[3]).toMatchObject({
|
||||
type: "run",
|
||||
run: {
|
||||
id: "run_123",
|
||||
taskIdentifier: "openai-streaming",
|
||||
status: "EXECUTING",
|
||||
durationMs: 200,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple streams simultaneously", async () => {
|
||||
const streamFactory = new TestStreamSubscriptionFactory();
|
||||
|
||||
// Set up test chunks for two different streams
|
||||
streamFactory.setStreamChunks("run_123", "openai", [
|
||||
{ id: "openai1", content: "Hello" },
|
||||
{ id: "openai2", content: "World" },
|
||||
]);
|
||||
streamFactory.setStreamChunks("run_123", "anthropic", [
|
||||
{ id: "claude1", message: "Hi" },
|
||||
{ id: "claude2", message: "There" },
|
||||
]);
|
||||
|
||||
const shapes = [
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "multi-streaming",
|
||||
status: "EXECUTING",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 100,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({}),
|
||||
metadataType: "application/json",
|
||||
realtimeStreams: ["openai", "anthropic"],
|
||||
},
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
stopRunShapeStream: () => {},
|
||||
streamFactory,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await collectNResults(
|
||||
subscription.withStreams<{
|
||||
openai: { id: string; content: string };
|
||||
anthropic: { id: string; message: string };
|
||||
}>(),
|
||||
5 // 1 run + 2 openai chunks + 2 anthropic chunks
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(5);
|
||||
expect(results[0]).toMatchObject({
|
||||
type: "run",
|
||||
run: { id: "run_123", taskIdentifier: "multi-streaming", status: "EXECUTING" },
|
||||
});
|
||||
|
||||
// Filter and verify openai chunks
|
||||
const openaiChunks = results.filter((r) => r.type === "openai");
|
||||
expect(openaiChunks).toHaveLength(2);
|
||||
expect(openaiChunks[0]).toMatchObject({
|
||||
type: "openai",
|
||||
chunk: { id: "openai1", content: "Hello" },
|
||||
run: { id: "run_123" },
|
||||
});
|
||||
expect(openaiChunks[1]).toMatchObject({
|
||||
type: "openai",
|
||||
chunk: { id: "openai2", content: "World" },
|
||||
run: { id: "run_123" },
|
||||
});
|
||||
|
||||
// Filter and verify anthropic chunks
|
||||
const anthropicChunks = results.filter((r) => r.type === "anthropic");
|
||||
expect(anthropicChunks).toHaveLength(2);
|
||||
expect(anthropicChunks[0]).toMatchObject({
|
||||
type: "anthropic",
|
||||
chunk: { id: "claude1", message: "Hi" },
|
||||
run: { id: "run_123" },
|
||||
});
|
||||
expect(anthropicChunks[1]).toMatchObject({
|
||||
type: "anthropic",
|
||||
chunk: { id: "claude2", message: "There" },
|
||||
run: { id: "run_123" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSEStreamSubscription", () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not retry the initial fetch on 401", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const sub = new SSEStreamSubscription("https://api.test/realtime/v1/streams/run_x/chat", {
|
||||
headers: { Authorization: "Bearer expired" },
|
||||
});
|
||||
|
||||
const stream = await sub.subscribe();
|
||||
const reader = stream.getReader();
|
||||
await expect(reader.read()).rejects.toMatchObject({ status: 401 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry the initial fetch on 403", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 403 }));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const sub = new SSEStreamSubscription("https://api.test/realtime/v1/streams/run_x/chat", {
|
||||
headers: { Authorization: "Bearer denied" },
|
||||
});
|
||||
|
||||
const stream = await sub.subscribe();
|
||||
const reader = stream.getReader();
|
||||
await expect(reader.read()).rejects.toMatchObject({ status: 403 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
export async function convertAsyncIterableToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
||||
const result: T[] = [];
|
||||
for await (const item of iterable) {
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectNResults<T>(
|
||||
iterable: AsyncIterable<T>,
|
||||
count: number,
|
||||
timeoutMs: number = 1000
|
||||
): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
const promise = new Promise<T[]>((resolve) => {
|
||||
(async () => {
|
||||
for await (const result of iterable) {
|
||||
results.push(result);
|
||||
if (results.length === count) {
|
||||
resolve(results);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T[]>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for ${count} results after ${timeoutMs}ms, but only had ${results.length}`
|
||||
)
|
||||
),
|
||||
timeoutMs
|
||||
)
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StandardResourceCatalog } from "../src/v3/resource-catalog/standardResourceCatalog.js";
|
||||
|
||||
describe("StandardResourceCatalog — skills", () => {
|
||||
it("registers and lists a skill manifest", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf-processing", sourcePath: "./skills/pdf-processing" });
|
||||
|
||||
const manifests = catalog.listSkillManifests();
|
||||
expect(manifests).toHaveLength(1);
|
||||
expect(manifests[0]).toMatchObject({
|
||||
id: "pdf-processing",
|
||||
sourcePath: "./skills/pdf-processing",
|
||||
filePath: "trigger/chat.ts",
|
||||
entryPoint: "chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("getSkillManifest returns the registered skill", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
catalog.registerSkillMetadata({ id: "a", sourcePath: "./skills/a" });
|
||||
|
||||
expect(catalog.getSkillManifest("a")?.sourcePath).toBe("./skills/a");
|
||||
expect(catalog.getSkillManifest("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips registration without a file context", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
|
||||
expect(catalog.listSkillManifests()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("warns and ignores when the same id is registered with a different path", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/other-pdf" });
|
||||
|
||||
const manifests = catalog.listSkillManifests();
|
||||
expect(manifests).toHaveLength(1);
|
||||
expect(manifests[0]?.sourcePath).toBe("./skills/pdf");
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("defined twice"));
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("re-registering the same id + path is idempotent", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
|
||||
expect(catalog.listSkillManifests()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("registers multiple distinct skills", () => {
|
||||
const catalog = new StandardResourceCatalog();
|
||||
catalog.setCurrentFileContext("trigger/chat.ts", "chat");
|
||||
|
||||
catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" });
|
||||
catalog.registerSkillMetadata({ id: "researcher", sourcePath: "./skills/researcher" });
|
||||
|
||||
expect(
|
||||
catalog
|
||||
.listSkillManifests()
|
||||
.map((s) => s.id)
|
||||
.sort()
|
||||
).toEqual(["pdf", "researcher"]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,981 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type { Server, IncomingMessage, ServerResponse } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { StreamsWriterV1 } from "../src/v3/realtimeStreams/streamsWriterV1.js";
|
||||
import { ensureReadableStream } from "../src/v3/streams/asyncIterableStream.js";
|
||||
|
||||
type RequestHandler = (req: IncomingMessage, res: ServerResponse) => void;
|
||||
|
||||
describe("StreamsWriterV1", () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let requestHandler: RequestHandler | null = null;
|
||||
let receivedRequests: Array<{
|
||||
method: string;
|
||||
url: string;
|
||||
headers: IncomingMessage["headers"];
|
||||
body: string;
|
||||
}> = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
receivedRequests = [];
|
||||
requestHandler = null;
|
||||
|
||||
// Create test server
|
||||
server = createServer((req, res) => {
|
||||
// Collect request data
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
receivedRequests.push({
|
||||
method: req.method!,
|
||||
url: req.url!,
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks).toString(),
|
||||
});
|
||||
|
||||
// Call custom handler if set
|
||||
if (requestHandler) {
|
||||
requestHandler(req, res);
|
||||
} else {
|
||||
// Default: return 200
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("should successfully stream all chunks to server", async () => {
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0, data: "chunk 0" };
|
||||
yield { chunk: 1, data: "chunk 1" };
|
||||
yield { chunk: 2, data: "chunk 2" };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have received exactly 1 POST request
|
||||
expect(receivedRequests.length).toBe(1);
|
||||
expect(receivedRequests[0]!.method).toBe("POST");
|
||||
expect(receivedRequests[0]!.headers["x-client-id"]).toBeDefined();
|
||||
expect(receivedRequests[0]!.headers["x-resume-from-chunk"]).toBe("0");
|
||||
|
||||
// Verify all chunks were sent
|
||||
const lines = receivedRequests[0]!.body.trim().split("\n");
|
||||
expect(lines.length).toBe(3);
|
||||
expect(JSON.parse(lines[0]!)).toEqual({ chunk: 0, data: "chunk 0" });
|
||||
expect(JSON.parse(lines[1]!)).toEqual({ chunk: 1, data: "chunk 1" });
|
||||
expect(JSON.parse(lines[2]!)).toEqual({ chunk: 2, data: "chunk 2" });
|
||||
});
|
||||
|
||||
it("should use provided clientId instead of generating one", async () => {
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
clientId: "custom-client-123",
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
expect(receivedRequests[0]!.headers["x-client-id"]).toBe("custom-client-123");
|
||||
});
|
||||
|
||||
it("should retry on connection reset and query server for resume point", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
// HEAD request to get last chunk - server has received 1 chunk
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "0" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First POST request - simulate connection reset after receiving some data
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST request - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
yield { chunk: 1 };
|
||||
yield { chunk: 2 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have: 1 POST (failed) + 1 HEAD (query) + 1 POST (retry)
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
const heads = receivedRequests.filter((r) => r.method === "HEAD");
|
||||
|
||||
expect(posts.length).toBe(2); // Original + retry
|
||||
expect(heads.length).toBe(1); // Query for resume point
|
||||
|
||||
// Second POST should resume from chunk 1 (server had chunk 0)
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("1");
|
||||
});
|
||||
|
||||
it("should retry on 503 Service Unavailable", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
// No data received yet
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First request fails with 503
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second request succeeds
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2); // Original + retry
|
||||
});
|
||||
|
||||
it("should retry on request timeout", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First request - don't respond, let it timeout
|
||||
// (timeout is set to 15 minutes in StreamsWriterV1, so we can't actually test this easily)
|
||||
// Instead we'll just delay and then respond
|
||||
setTimeout(() => {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should complete successfully (timeout is very long, won't trigger in test)
|
||||
expect(receivedRequests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle ring buffer correctly on retry", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
// Server received first 2 chunks
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First POST - fail after some data sent
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxBufferSize: 100, // Small buffer for testing
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
|
||||
// First request tried to send chunks 0-4
|
||||
const firstLines = posts[0]!.body.trim().split("\n").filter(Boolean);
|
||||
expect(firstLines.length).toBeGreaterThan(0);
|
||||
|
||||
// Second request resumes from chunk 2 (server had 0-1)
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("2");
|
||||
|
||||
// Second request should send chunks 2, 3, 4 from ring buffer
|
||||
const secondLines = posts[1]!.body.trim().split("\n").filter(Boolean);
|
||||
expect(secondLines.length).toBe(3);
|
||||
expect(JSON.parse(secondLines[0]!).chunk).toBe(2);
|
||||
expect(JSON.parse(secondLines[1]!).chunk).toBe(3);
|
||||
expect(JSON.parse(secondLines[2]!).chunk).toBe(4);
|
||||
});
|
||||
|
||||
it("should fail after max retries exceeded", { timeout: 30000 }, async () => {
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Always fail with retryable error
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxRetries: 3, // Low retry count for faster test
|
||||
});
|
||||
|
||||
await expect(metadataStream.wait()).rejects.toThrow();
|
||||
|
||||
// Should have attempted: 1 initial + 3 retries = 4 POST requests
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(4);
|
||||
});
|
||||
|
||||
it(
|
||||
"should handle HEAD request failures gracefully and resume from 0",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Fail HEAD with 503 (will retry but eventually return -1)
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// First POST - fail with connection reset
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
yield { chunk: 1 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// HEAD should have been attempted (will get 503 responses)
|
||||
const heads = receivedRequests.filter((r) => r.method === "HEAD");
|
||||
expect(heads.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Should have retried POST and resumed from chunk 0 (since HEAD failed with 503s)
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("0");
|
||||
}
|
||||
);
|
||||
|
||||
it("should handle 429 rate limit with retry", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First request - rate limited
|
||||
res.writeHead(429, { "Retry-After": "1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second request - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2); // Original + retry
|
||||
});
|
||||
|
||||
it("should reset retry count after successful response", { timeout: 10000 }, async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// First POST - fail
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST - succeed (retry count should be reset after this)
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have: 1 initial + 1 retry = 2 POST requests
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle large stream with multiple chunks", async () => {
|
||||
const chunkCount = 100;
|
||||
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
expect(receivedRequests.length).toBe(1);
|
||||
const lines = receivedRequests[0]!.body.trim().split("\n");
|
||||
expect(lines.length).toBe(chunkCount);
|
||||
});
|
||||
|
||||
it("should handle retry mid-stream and resume from correct chunk", async () => {
|
||||
let postCount = 0;
|
||||
const totalChunks = 50;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Simulate server received first 20 chunks before connection dropped
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "19" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// First request - fail mid-stream
|
||||
// Give it time to send some data, then kill
|
||||
setTimeout(() => {
|
||||
req.socket.destroy();
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Second request - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
// Small delay to simulate real streaming
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxBufferSize: 100, // Large enough to hold all chunks
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
const heads = receivedRequests.filter((r) => r.method === "HEAD");
|
||||
|
||||
expect(posts.length).toBe(2); // Original + retry
|
||||
expect(heads.length).toBe(1); // Query for resume
|
||||
|
||||
// Second POST should resume from chunk 20 (server had 0-19)
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("20");
|
||||
|
||||
// Verify second request sent chunks 20-49
|
||||
const secondBody = posts[1]!.body.trim().split("\n").filter(Boolean);
|
||||
expect(secondBody.length).toBe(30); // Chunks 20-49
|
||||
|
||||
const firstChunkInRetry = JSON.parse(secondBody[0]!);
|
||||
expect(firstChunkInRetry.chunk).toBe(20);
|
||||
|
||||
const lastChunkInRetry = JSON.parse(secondBody[secondBody.length - 1]!);
|
||||
expect(lastChunkInRetry.chunk).toBe(49);
|
||||
});
|
||||
|
||||
it("should handle multiple retries with exponential backoff", { timeout: 30000 }, async () => {
|
||||
let postCount = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount <= 3) {
|
||||
// Fail first 3 attempts
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fourth attempt succeeds
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
|
||||
expect(posts.length).toBe(4); // 1 initial + 3 retries
|
||||
|
||||
// With exponential backoff (1s, 2s, 4s), should take at least 6 seconds
|
||||
// But jitter and processing means we give it some range
|
||||
expect(elapsed).toBeGreaterThan(5000);
|
||||
});
|
||||
|
||||
it("should handle ring buffer overflow gracefully", async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Server received nothing
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// Let it send some data then fail
|
||||
setTimeout(() => req.socket.destroy(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
// Generate 200 chunks but ring buffer only holds 50
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxBufferSize: 50, // Small buffer - will overflow
|
||||
});
|
||||
|
||||
// Should still complete (may have warnings about missing chunks)
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle consumer reading from stream", async () => {
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0, data: "data 0" };
|
||||
yield { chunk: 1, data: "data 1" };
|
||||
yield { chunk: 2, data: "data 2" };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
// Consumer reads from the stream
|
||||
const consumedChunks: any[] = [];
|
||||
for await (const chunk of metadataStream) {
|
||||
consumedChunks.push(chunk);
|
||||
}
|
||||
|
||||
// Consumer should receive all chunks
|
||||
expect(consumedChunks.length).toBe(3);
|
||||
expect(consumedChunks[0]).toEqual({ chunk: 0, data: "data 0" });
|
||||
expect(consumedChunks[1]).toEqual({ chunk: 1, data: "data 1" });
|
||||
expect(consumedChunks[2]).toEqual({ chunk: 2, data: "data 2" });
|
||||
|
||||
// Server should have received all chunks
|
||||
await metadataStream.wait();
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle non-retryable 4xx errors immediately", async () => {
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "POST") {
|
||||
// 400 Bad Request - not retryable
|
||||
res.writeHead(400);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await expect(metadataStream.wait()).rejects.toThrow("HTTP error! status: 400");
|
||||
|
||||
// Should NOT retry on 400
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(1); // Only initial request, no retries
|
||||
});
|
||||
|
||||
it("should handle 429 rate limit with proper backoff", { timeout: 15000 }, async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount <= 2) {
|
||||
// Rate limited twice
|
||||
res.writeHead(429);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Third attempt succeeds
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(3); // 1 initial + 2 retries
|
||||
});
|
||||
|
||||
it("should handle abort signal during streaming", async () => {
|
||||
const abortController = new AbortController();
|
||||
let requestReceived = false;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestReceived = true;
|
||||
// Don't respond immediately, let abort happen
|
||||
setTimeout(() => {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
yield { chunk: 1 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Abort after a short delay
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
// Should throw due to abort
|
||||
await expect(metadataStream.wait()).rejects.toThrow();
|
||||
|
||||
// Request should have been made before abort
|
||||
expect(requestReceived).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty stream (no chunks)", async () => {
|
||||
// eslint-disable-next-line require-yield
|
||||
async function* generateChunks() {
|
||||
// Yields nothing
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have sent request with empty body
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(1);
|
||||
expect(posts[0]!.body.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("should handle error thrown by source generator", async () => {
|
||||
// Skip this test - source generator errors are properly handled by the stream
|
||||
// but cause unhandled rejection warnings in test environment
|
||||
// In production, these errors would be caught by the task execution layer
|
||||
|
||||
// Test that error propagates correctly by checking stream behavior
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
// Note: Throwing here would test error handling, but causes test infrastructure issues
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Verify normal operation (error test would need different approach)
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle missing X-Last-Chunk-Index header in HEAD response", async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Return success but no chunk index header
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
yield { chunk: 1 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
|
||||
// Should default to resuming from 0 when header is missing
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("0");
|
||||
});
|
||||
|
||||
it(
|
||||
"should handle rapid successive failures with different error types",
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
// Different error types
|
||||
if (postCount === 1) {
|
||||
res.writeHead(503); // Service unavailable
|
||||
res.end();
|
||||
} else if (postCount === 2) {
|
||||
req.socket.destroy(); // Connection reset
|
||||
} else if (postCount === 3) {
|
||||
res.writeHead(502); // Bad gateway
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have retried through all error types
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(4); // 1 initial + 3 retries
|
||||
}
|
||||
);
|
||||
|
||||
it("should handle resume point outside ring buffer window", { timeout: 10000 }, async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Server claims to have chunk 80 (but ring buffer only has last 50)
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "80" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// First POST fails early
|
||||
setTimeout(() => req.socket.destroy(), 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST succeeds
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxBufferSize: 50, // Small buffer
|
||||
});
|
||||
|
||||
// Should complete even though resume point (81) is outside buffer window
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
|
||||
// Should try to resume from chunk 81
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("81");
|
||||
// Will log warnings about missing chunks but should continue with available chunks
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import { assertExhaustive, tryCatch, promiseWithResolvers } from "../src/utils.js";
|
||||
|
||||
describe("assertExhaustive", () => {
|
||||
it("should throw an error when called", () => {
|
||||
expect(() => assertExhaustive("unexpected" as never)).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryCatch", () => {
|
||||
it("should resolve with [null, value] when promise resolves", async () => {
|
||||
const promise = Promise.resolve(42);
|
||||
const result = await tryCatch(promise);
|
||||
expect(result).toEqual([null, 42]);
|
||||
});
|
||||
|
||||
it("should resolve with [error, null] when promise rejects", async () => {
|
||||
const error = new Error("fail");
|
||||
const promise = Promise.reject(error);
|
||||
const result = await tryCatch(promise);
|
||||
expect(result[0]).toBe(error);
|
||||
expect(result[1]).toBeNull();
|
||||
});
|
||||
|
||||
it("should resolve with [error, null] when promise throws non-Error", async () => {
|
||||
const promise = Promise.reject("fail");
|
||||
const result = await tryCatch(promise);
|
||||
expect(result[0]).toBe("fail");
|
||||
expect(result[1]).toBeNull();
|
||||
});
|
||||
|
||||
it("should resolve with [null, undefined] when promise resolves to undefined", async () => {
|
||||
const promise = Promise.resolve(undefined);
|
||||
const result = await tryCatch(promise);
|
||||
expect(result).toEqual([null, undefined]);
|
||||
});
|
||||
|
||||
it("should resolve with [null, value] when promise is already resolved", async () => {
|
||||
const resolved = Promise.resolve("done");
|
||||
const result = await tryCatch(resolved);
|
||||
expect(result).toEqual([null, "done"]);
|
||||
});
|
||||
|
||||
it("should resolve with [null, undefined] when promise is undefined", async () => {
|
||||
const result = await tryCatch(undefined);
|
||||
expect(result).toEqual([null, undefined]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("promiseWithResolvers", () => {
|
||||
it("should return a deferred promise with resolve and reject", async () => {
|
||||
const deferred = promiseWithResolvers<number>();
|
||||
expect(typeof deferred.promise.then).toBe("function");
|
||||
expect(typeof deferred.resolve).toBe("function");
|
||||
expect(typeof deferred.reject).toBe("function");
|
||||
let resolved = false;
|
||||
deferred.promise.then((value: number) => {
|
||||
expect(value).toBe(123);
|
||||
resolved = true;
|
||||
});
|
||||
deferred.resolve(123);
|
||||
await deferred.promise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject the promise when reject is called", async () => {
|
||||
const deferred = promiseWithResolvers<string>();
|
||||
const error = new Error("fail");
|
||||
let caught: Error | null = null;
|
||||
deferred.promise.catch((e: Error) => {
|
||||
caught = e;
|
||||
});
|
||||
deferred.reject(error);
|
||||
await expect(deferred.promise).rejects.toBe(error);
|
||||
expect(caught).toBe(error);
|
||||
});
|
||||
|
||||
it("should allow resolving with undefined", async () => {
|
||||
const deferred = promiseWithResolvers<void>();
|
||||
let resolved = false;
|
||||
deferred.promise.then((value: void) => {
|
||||
expect(value).toBeUndefined();
|
||||
resolved = true;
|
||||
});
|
||||
deferred.resolve();
|
||||
await deferred.promise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user