Files
cloudflare__vinext/tests/instrumentation.test.ts
T
Nathan Nguyen e9063411b8 fix: register reportRequestError with ctx.waitUntil on Workers (#493)
On Cloudflare Workers the isolate can terminate after the Response is
sent, killing in-flight error reports (e.g. Sentry HTTP requests).

Fix by having reportRequestError() self-register with
ctx.waitUntil() via getRequestExecutionContext(). This is the same
pattern used by KVCacheHandler, ISR regeneration, and fetch cache
revalidation. On Node.js (dev or vinext start) the context is null
so it's a no-op — fire-and-forget is fine because the process stays
alive.

Since reportRequestError never rejects (internal try/catch), the
redundant .catch() at all 5 call sites is removed.
2026-03-12 08:57:24 +00:00

212 lines
7.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { findInstrumentationFile } from "../packages/vinext/src/server/instrumentation.js";
// The runInstrumentation/reportRequestError describe blocks re-import via
// vi.resetModules() to get fresh module-level state (_onRequestError).
// findInstrumentationFile is a pure function — no reset needed.
describe("findInstrumentationFile", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-instr-"));
});
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("returns the path when a file exists at root", () => {
fs.writeFileSync(path.join(tmpDir, "instrumentation.ts"), "");
const result = findInstrumentationFile(tmpDir);
expect(result).toBe(path.join(tmpDir, "instrumentation.ts"));
});
it("prefers root over src/ directory (priority order)", () => {
// Create both root and src/ variants
fs.writeFileSync(path.join(tmpDir, "instrumentation.ts"), "");
fs.mkdirSync(path.join(tmpDir, "src"));
fs.writeFileSync(path.join(tmpDir, "src", "instrumentation.ts"), "");
const result = findInstrumentationFile(tmpDir);
// Root files come first in INSTRUMENTATION_FILES, so root wins
expect(result).toBe(path.join(tmpDir, "instrumentation.ts"));
});
it("falls back to src/ directory", () => {
fs.mkdirSync(path.join(tmpDir, "src"));
fs.writeFileSync(path.join(tmpDir, "src", "instrumentation.ts"), "");
const result = findInstrumentationFile(tmpDir);
expect(result).toBe(path.join(tmpDir, "src", "instrumentation.ts"));
});
it("returns null when no instrumentation file exists", () => {
const result = findInstrumentationFile(tmpDir);
expect(result).toBeNull();
});
});
describe("runInstrumentation", () => {
let runInstrumentation: typeof import("../packages/vinext/src/server/instrumentation.js").runInstrumentation;
let getOnRequestErrorHandler: typeof import("../packages/vinext/src/server/instrumentation.js").getOnRequestErrorHandler;
beforeEach(async () => {
vi.resetModules();
const mod = await import("../packages/vinext/src/server/instrumentation.js");
runInstrumentation = mod.runInstrumentation;
getOnRequestErrorHandler = mod.getOnRequestErrorHandler;
});
afterEach(() => {
delete globalThis.__VINEXT_onRequestErrorHandler__;
});
it("calls register() when exported", async () => {
const register = vi.fn();
const runner = {
import: vi.fn().mockResolvedValue({ register }),
};
await runInstrumentation(runner, "/fake/instrumentation.ts");
expect(register).toHaveBeenCalledOnce();
});
it("stores onRequestError handler for later retrieval", async () => {
const onRequestError = vi.fn();
const runner = {
import: vi.fn().mockResolvedValue({ onRequestError }),
};
await runInstrumentation(runner, "/fake/instrumentation.ts");
expect(getOnRequestErrorHandler()).toBe(onRequestError);
});
it("handles modules with no register or onRequestError gracefully", async () => {
const runner = {
import: vi.fn().mockResolvedValue({}),
};
// Should not throw
await runInstrumentation(runner, "/fake/instrumentation.ts");
expect(getOnRequestErrorHandler()).toBeNull();
});
it("logs error and continues when import fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const runner = {
import: vi.fn().mockRejectedValue(new Error("Module not found")),
};
// Should not throw
await runInstrumentation(runner, "/fake/instrumentation.ts");
expect(consoleSpy).toHaveBeenCalledWith(
"[vinext] Failed to load instrumentation:",
"Module not found",
);
consoleSpy.mockRestore();
});
});
describe("reportRequestError", () => {
let runInstrumentation: typeof import("../packages/vinext/src/server/instrumentation.js").runInstrumentation;
let reportRequestError: typeof import("../packages/vinext/src/server/instrumentation.js").reportRequestError;
let runWithExecutionContext: typeof import("../packages/vinext/src/shims/request-context.js").runWithExecutionContext;
const sampleRequest = { path: "/test", method: "GET", headers: {} };
const sampleContext = {
routerKind: "App Router" as const,
routePath: "/test",
routeType: "render" as const,
};
beforeEach(async () => {
vi.resetModules();
const mod = await import("../packages/vinext/src/server/instrumentation.js");
runInstrumentation = mod.runInstrumentation;
reportRequestError = mod.reportRequestError;
const ctxMod = await import("../packages/vinext/src/shims/request-context.js");
runWithExecutionContext = ctxMod.runWithExecutionContext;
});
it("calls the registered handler with correct args", async () => {
const onRequestError = vi.fn();
const runner = {
import: vi.fn().mockResolvedValue({ onRequestError }),
};
await runInstrumentation(runner, "/fake/instrumentation.ts");
const error = new Error("boom");
await reportRequestError(error, sampleRequest, sampleContext);
expect(onRequestError).toHaveBeenCalledWith(error, sampleRequest, sampleContext);
});
it("no-ops when no handler is registered", async () => {
// No runInstrumentation called, so _onRequestError is null.
// Should not throw.
await reportRequestError(new Error("boom"), sampleRequest, sampleContext);
});
it("catches and logs errors thrown by the handler", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const onRequestError = vi.fn().mockRejectedValue(new Error("handler broke"));
const runner = {
import: vi.fn().mockResolvedValue({ onRequestError }),
};
await runInstrumentation(runner, "/fake/instrumentation.ts");
await reportRequestError(new Error("boom"), sampleRequest, sampleContext);
expect(consoleSpy).toHaveBeenCalledWith(
"[vinext] onRequestError handler threw:",
"handler broke",
);
consoleSpy.mockRestore();
});
it("registers the report promise with ctx.waitUntil on Workers", async () => {
const onRequestError = vi.fn().mockResolvedValue(undefined);
const runner = {
import: vi.fn().mockResolvedValue({ onRequestError }),
};
await runInstrumentation(runner, "/fake/instrumentation.ts");
const waitUntil = vi.fn();
const ctx = { waitUntil };
await runWithExecutionContext(ctx, () =>
reportRequestError(new Error("boom"), sampleRequest, sampleContext),
);
expect(waitUntil).toHaveBeenCalledOnce();
// The promise passed to waitUntil should resolve (reportRequestError never rejects)
await expect(waitUntil.mock.calls[0][0]).resolves.toBeUndefined();
});
it("does not call waitUntil when no execution context is available", async () => {
const onRequestError = vi.fn().mockResolvedValue(undefined);
const runner = {
import: vi.fn().mockResolvedValue({ onRequestError }),
};
await runInstrumentation(runner, "/fake/instrumentation.ts");
// Call outside runWithExecutionContext — should not throw
await reportRequestError(new Error("boom"), sampleRequest, sampleContext);
expect(onRequestError).toHaveBeenCalledOnce();
});
});