Files
cloudflare__vinext/tests/instrumentation.test.ts
T
James Anderson b0de1af028 fix(cloudflare): support Sentry request errors on Workers (#2425)
* fix(cloudflare): support sentry request errors on workers

* fix(cloudflare): avoid worker conditions in node pages builds

* test(cloudflare): run sentry worker e2es in ci

* test(cloudflare): cover sentry render errors

* test(cloudflare): mirror sentry next setup in fixtures

* update lockfile

* update lockfile

* test(cloudflare): cover sentry client instrumentation
2026-06-30 10:58:04 +01:00

728 lines
27 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as Sentry from "@sentry/nextjs";
import { createServer } from "vite-plus";
import vinext from "../packages/vinext/src/index.js";
import {
findInstrumentationClientFile,
findInstrumentationFile,
} from "../packages/vinext/src/server/instrumentation.js";
import { normalizePathSeparators } from "../packages/vinext/src/utils/path.js";
import { generateInstrumentationClientInjectModule } from "../packages/vinext/src/client/instrumentation-client-inject.js";
import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js";
const RESOLVED_INSTRUMENTATION_CLIENT = "\0private-next-instrumentation-client.mjs";
const ROOT_NODE_MODULES = path.resolve(import.meta.dirname, "..", "node_modules");
type SentryEnvelopeEvent = {
exception?: {
values?: Array<{
value?: string;
}>;
};
contexts?: {
nextjs?: {
request_path?: string;
router_kind?: string;
router_path?: string;
route_type?: string;
};
};
transaction?: string;
};
function getLoadedCode(loaded: unknown): string {
return typeof loaded === "string" ? loaded : ((loaded as { code?: string })?.code ?? "");
}
function parseSentryEnvelopeEvents(envelope: unknown): SentryEnvelopeEvent[] {
if (Array.isArray(envelope)) {
const [, items] = envelope as [unknown, Array<[unknown, SentryEnvelopeEvent]>];
return (items ?? []).map(([, payload]) => payload).filter((payload) => payload.exception);
}
if (typeof envelope !== "string") return [];
const events: SentryEnvelopeEvent[] = [];
for (const line of envelope.split("\n")) {
if (!line.trim().startsWith("{")) continue;
try {
const parsed = JSON.parse(line) as SentryEnvelopeEvent;
if (parsed.exception) events.push(parsed);
} catch {
/* Sentry envelope item headers are JSON too; ignore anything else. */
}
}
return events;
}
function setupInjectProject(options: {
instrumentationClientInject: string[];
injectFiles?: Record<string, string>;
userClientSource?: string;
}): string {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-instr-client-inject-"));
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ name: "test-project", type: "module" }),
);
fs.mkdirSync(path.join(tmpDir, "app"), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, "app", "layout.tsx"),
"export default function Layout({ children }) { return <html><body>{children}</body></html>; }\n",
);
fs.writeFileSync(
path.join(tmpDir, "app", "page.tsx"),
"export default function Page() { return <div>home</div>; }\n",
);
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default { instrumentationClientInject: ${JSON.stringify(options.instrumentationClientInject)} };\n`,
);
for (const [filename, source] of Object.entries(options.injectFiles ?? {})) {
fs.writeFileSync(path.join(tmpDir, filename), source);
}
if (options.userClientSource !== undefined) {
fs.writeFileSync(path.join(tmpDir, "instrumentation-client.js"), options.userClientSource);
}
try {
fs.symlinkSync(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "junction");
} catch {
fs.symlinkSync(ROOT_NODE_MODULES, path.join(tmpDir, "node_modules"), "dir");
}
return tmpDir;
}
type InjectClientContainer = NonNullable<
Awaited<ReturnType<typeof createServer>>["environments"]["client"]
>["pluginContainer"];
async function withInjectClientServer(
options: {
instrumentationClientInject: string[];
injectFiles?: Record<string, string>;
userClientSource?: string;
},
run: (ctx: { tmpDir: string; container: InjectClientContainer }) => Promise<void>,
): Promise<void> {
const tmpDir = setupInjectProject(options);
const testServer = await createServer({
root: tmpDir,
configFile: false,
plugins: [vinext({ appDir: tmpDir })],
server: { port: 0 },
logLevel: "silent",
});
try {
const client = testServer.environments.client;
if (!client) throw new Error("client environment missing");
await run({ tmpDir, container: client.pluginContainer });
} finally {
await testServer.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
// 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(() => {
// Production always passes a forward-slash root (the config hook normalizes
// it), so mirror that here — findInstrumentationFile now returns
// forward-slash paths via path.posix.join.
tmpDir = normalizePathSeparators(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, createValidFileMatcher());
expect(result).toBe(path.posix.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, createValidFileMatcher());
// Root files come first in INSTRUMENTATION_FILES, so root wins
expect(result).toBe(path.posix.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, createValidFileMatcher());
expect(result).toBe(path.posix.join(tmpDir, "src", "instrumentation.ts"));
});
it("returns null when no instrumentation file exists", () => {
const result = findInstrumentationFile(tmpDir, createValidFileMatcher());
expect(result).toBeNull();
});
});
describe("findInstrumentationClientFile", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = normalizePathSeparators(
fs.mkdtempSync(path.join(os.tmpdir(), "vinext-instr-client-")),
);
});
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-client.ts"), "");
const result = findInstrumentationClientFile(tmpDir, createValidFileMatcher());
expect(result).toBe(path.posix.join(tmpDir, "instrumentation-client.ts"));
});
it("prefers root over src/ directory (priority order)", () => {
fs.writeFileSync(path.join(tmpDir, "instrumentation-client.ts"), "");
fs.mkdirSync(path.join(tmpDir, "src"));
fs.writeFileSync(path.join(tmpDir, "src", "instrumentation-client.ts"), "");
const result = findInstrumentationClientFile(tmpDir, createValidFileMatcher());
expect(result).toBe(path.posix.join(tmpDir, "instrumentation-client.ts"));
});
it("falls back to src/ directory", () => {
fs.mkdirSync(path.join(tmpDir, "src"));
fs.writeFileSync(path.join(tmpDir, "src", "instrumentation-client.ts"), "");
const result = findInstrumentationClientFile(tmpDir, createValidFileMatcher());
expect(result).toBe(path.posix.join(tmpDir, "src", "instrumentation-client.ts"));
});
it("returns null when no instrumentation-client file exists", () => {
const result = findInstrumentationClientFile(tmpDir, createValidFileMatcher());
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("ensureInstrumentationRegistered", () => {
let ensureInstrumentationRegistered: typeof import("../packages/vinext/src/server/instrumentation-runtime.js").ensureInstrumentationRegistered;
let reportRequestError: typeof import("../packages/vinext/src/server/instrumentation.js").reportRequestError;
const sampleRequest = { path: "/test", method: "GET", headers: {} };
const sampleContext = {
routerKind: "App Router" as const,
routePath: "/test",
routeType: "render" as const,
};
beforeEach(async () => {
vi.resetModules();
delete globalThis.__VINEXT_onRequestErrorHandler__;
delete process.env.VINEXT_PRERENDER;
const mod = await import("../packages/vinext/src/server/instrumentation-runtime.js");
ensureInstrumentationRegistered = mod.ensureInstrumentationRegistered;
const instMod = await import("../packages/vinext/src/server/instrumentation.js");
reportRequestError = instMod.reportRequestError;
});
afterEach(() => {
delete globalThis.__VINEXT_onRequestErrorHandler__;
delete process.env.VINEXT_PRERENDER;
});
it("calls register() when exported", async () => {
const register = vi.fn();
await ensureInstrumentationRegistered({ register });
expect(register).toHaveBeenCalledOnce();
});
it("wires onRequestError so reportRequestError invokes it", async () => {
const onRequestError = vi.fn();
const error = new Error("boom");
await ensureInstrumentationRegistered({ onRequestError });
await reportRequestError(error, sampleRequest, sampleContext);
expect(onRequestError).toHaveBeenCalledWith(error, sampleRequest, sampleContext);
});
it("is idempotent — register() called only once across concurrent awaits", async () => {
const register = vi.fn();
const mod = { register };
await Promise.all([
ensureInstrumentationRegistered(mod),
ensureInstrumentationRegistered(mod),
ensureInstrumentationRegistered(mod),
]);
expect(register).toHaveBeenCalledOnce();
});
it("is idempotent — sequential awaits do not re-call register()", async () => {
const register = vi.fn();
const mod = { register };
await ensureInstrumentationRegistered(mod);
await ensureInstrumentationRegistered(mod);
expect(register).toHaveBeenCalledOnce();
});
it("no-ops when VINEXT_PRERENDER is set", async () => {
process.env.VINEXT_PRERENDER = "1";
const register = vi.fn();
await ensureInstrumentationRegistered({ register });
expect(register).not.toHaveBeenCalled();
});
it("does not throw when module has no register or onRequestError", async () => {
await ensureInstrumentationRegistered({});
// Should not throw
await reportRequestError(new Error("boom"), sampleRequest, sampleContext);
});
});
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;
});
afterEach(() => {
delete globalThis.__VINEXT_onRequestErrorHandler__;
});
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();
});
it("lets real @sentry/nextjs captureRequestError flush through Workers waitUntil", async () => {
const envelopes: unknown[] = [];
Sentry.init({
dsn: "http://public@sentry.test/42",
defaultIntegrations: false,
tracesSampleRate: 0,
transport: () => ({
send(envelope: unknown) {
envelopes.push(envelope);
return Promise.resolve({});
},
flush() {
return Promise.resolve(true);
},
}),
});
globalThis.__VINEXT_onRequestErrorHandler__ = Sentry.captureRequestError;
const waitUntil = vi.fn();
const ctx = { waitUntil };
const cases = [
{
error: new Error("Server component error"),
request: { path: "/error-server-test", method: "GET", headers: {} },
context: {
routerKind: "App Router" as const,
routePath: "/error-server-test",
routeType: "render" as const,
},
},
{
error: new Error("Pages API error"),
request: { path: "/api/error-route", method: "GET", headers: {} },
context: {
routerKind: "Pages Router" as const,
routePath: "/api/error-route",
routeType: "route" as const,
},
},
];
for (const testCase of cases) {
await runWithExecutionContext(ctx, () =>
reportRequestError(testCase.error, testCase.request, testCase.context),
);
}
expect(waitUntil).toHaveBeenCalledTimes(4);
await Promise.all(waitUntil.mock.calls.map(([promise]) => promise));
const events = envelopes.flatMap(parseSentryEnvelopeEvents);
expect(events.map((event) => event.exception?.values?.[0]?.value)).toEqual([
"Server component error",
"Pages API error",
]);
expect(events.map((event) => event.contexts?.nextjs)).toEqual([
{
request_path: "/error-server-test",
router_kind: "App Router",
router_path: "/error-server-test",
route_type: "render",
},
{
request_path: "/api/error-route",
router_kind: "Pages Router",
router_path: "/api/error-route",
route_type: "route",
},
]);
await (
Sentry as unknown as { default?: { close?: (timeout?: number) => Promise<boolean> } }
).default?.close?.(0);
});
});
describe("Sentry Next.js internal compatibility", () => {
it("aliases Sentry's .js Next async-storage internals to vinext shims", async () => {
await withInjectClientServer({ instrumentationClientInject: [] }, async ({ container }) => {
const sentryAsyncStorageImports = [
"next/dist/client/components/request-async-storage.js",
"next/dist/client/components/request-async-storage.external.js",
"next/dist/server/app-render/work-unit-async-storage.external.js",
"next/dist/client/components/work-unit-async-storage.external.js",
];
for (const id of sentryAsyncStorageImports) {
const resolved = await container.resolveId(id);
expect(normalizePathSeparators(resolved?.id ?? "")).toContain(
"/packages/vinext/src/shims/internal/work-unit-async-storage",
);
}
});
});
});
// Ported from Next.js: packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-instrumentation-client-loader.ts
describe("instrumentationClientInject plugin pipeline", () => {
const INJECT_A = `export function onRouterTransitionStart() {
globalThis.__injectOrder = globalThis.__injectOrder ?? [];
globalThis.__injectOrder.push("a");
}
`;
const INJECT_B = `export function onRouterTransitionStart() {
globalThis.__injectOrder = globalThis.__injectOrder ?? [];
globalThis.__injectOrder.push("b");
}
`;
const USER_CLIENT = `export function onRouterTransitionStart() {
globalThis.__injectOrder = globalThis.__injectOrder ?? [];
globalThis.__injectOrder.push("user");
}
`;
const SIDE_EFFECT_ONLY = "globalThis.__sideEffect = true;\n";
it("does not intercept private-next-instrumentation-client when injects is empty", async () => {
await withInjectClientServer(
{ instrumentationClientInject: [], userClientSource: USER_CLIENT },
async ({ container }) => {
const resolved = await container.resolveId("private-next-instrumentation-client");
expect(resolved).toBeTruthy();
expect(resolved!.id).not.toBe(RESOLVED_INSTRUMENTATION_CLIENT);
expect(resolved!.id.replace(/\\/g, "/")).toContain("instrumentation-client.js");
},
);
});
it("falls back to empty-module fallback when injects is empty and no user file exists", async () => {
await withInjectClientServer({ instrumentationClientInject: [] }, async ({ container }) => {
const resolved = await container.resolveId("private-next-instrumentation-client");
expect(resolved).toBeTruthy();
expect(resolved!.id).not.toBe(RESOLVED_INSTRUMENTATION_CLIENT);
expect(resolved!.id.replace(/\\/g, "/")).toContain("empty-module");
});
});
it("serves and composes the virtual module in inject order", async () => {
await withInjectClientServer(
{
instrumentationClientInject: ["./inject-a.js", "./inject-b.js"],
injectFiles: { "inject-a.js": INJECT_A, "inject-b.js": INJECT_B },
userClientSource: USER_CLIENT,
},
async ({ tmpDir, container }) => {
const resolved = await container.resolveId("private-next-instrumentation-client");
expect(resolved?.id).toBe(RESOLVED_INSTRUMENTATION_CLIENT);
const code = getLoadedCode(await container.load(resolved!.id));
expect(code.indexOf("inject-a.js")).toBeGreaterThanOrEqual(0);
expect(code.lastIndexOf("inject-b.js")).toBeGreaterThan(code.indexOf("inject-a.js"));
expect(code.indexOf("import * as __vinj_2 from")).toBeGreaterThan(
code.lastIndexOf("inject-b.js"),
);
expect(code).toContain("export function onRouterTransitionStart(url, type)");
const entryPath = path.join(tmpDir, ".vinext-composed-instrumentation-client.mjs");
fs.writeFileSync(entryPath, code);
delete (globalThis as { __injectOrder?: string[] }).__injectOrder;
// Node's ESM loader permanently caches imports based on their URL. Since integration
// tests reuse temporary workspace directories, we use a cache-busting query parameter
// to force Node to load the newly generated virtual module instead of a stale cached version.
const mod = (await import(
pathToFileURL(entryPath).href + `?t=${Date.now()}-${Math.random()}`
)) as {
onRouterTransitionStart?: (url: string, type: string) => void;
};
mod.onRouterTransitionStart?.("/x", "push");
expect((globalThis as { __injectOrder?: string[] }).__injectOrder).toEqual([
"a",
"b",
"user",
]);
},
);
});
it("allows side-effect-only inject modules without onRouterTransitionStart", async () => {
await withInjectClientServer(
{
instrumentationClientInject: ["./inject-side.js"],
injectFiles: { "inject-side.js": SIDE_EFFECT_ONLY },
},
async ({ tmpDir, container }) => {
const resolved = await container.resolveId("private-next-instrumentation-client");
const entryPath = path.join(tmpDir, ".vinext-composed-instrumentation-client.mjs");
fs.writeFileSync(entryPath, getLoadedCode(await container.load(resolved!.id)));
delete (globalThis as { __sideEffect?: boolean }).__sideEffect;
// Node's ESM loader permanently caches imports based on their URL. Since integration
// tests reuse temporary workspace directories, we use a cache-busting query parameter
// to force Node to load the newly generated virtual module instead of a stale cached version.
const mod = (await import(
pathToFileURL(entryPath).href + `?t=${Date.now()}-${Math.random()}`
)) as {
onRouterTransitionStart?: (url: string, type: string) => void;
};
expect((globalThis as { __sideEffect?: boolean }).__sideEffect).toBe(true);
expect(() => mod.onRouterTransitionStart?.("/x", "push")).not.toThrow();
},
);
});
});
describe("generateInstrumentationClientInjectModule", () => {
it("returns passthrough when injects is empty (userPath ignored)", () => {
expect(generateInstrumentationClientInjectModule([], null)).toBe("export {};");
expect(
generateInstrumentationClientInjectModule([], "/project/instrumentation-client.ts"),
).toBe("export {};");
});
it("generates a single import for one inject entry", () => {
const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null);
expect(code).toContain('import * as __vinj_0 from "./inject-a.js"');
expect(code).toContain("export function onRouterTransitionStart(url, type)");
expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"');
expect(code).toContain("\n __vinj_0.onRouterTransitionStart(url, type);\n");
});
it("generates imports in config order with user file last", () => {
const code = generateInstrumentationClientInjectModule(
["./inject-a.js", "some-npm-pkg"],
"/project/instrumentation-client.ts",
);
expect(code).toContain('import * as __vinj_0 from "./inject-a.js"');
expect(code).toContain('import * as __vinj_1 from "some-npm-pkg"');
expect(code).toContain('import * as __vinj_2 from "/project/instrumentation-client.ts"');
});
it("falls back to empty-module when user file is absent", () => {
const code = generateInstrumentationClientInjectModule(["./inject-a.js"], null);
expect(code).toContain("import * as __vinj_1 from");
expect(code).toContain("empty-module");
});
it("composes hook calls for every module in array order", () => {
const code = generateInstrumentationClientInjectModule(
["./inject-a.js", "./inject-b.js"],
"/project/instrumentation-client.ts",
);
expect(code).toContain('typeof __vinj_0.onRouterTransitionStart === "function"');
expect(code).toContain("__vinj_0.onRouterTransitionStart(url, type)");
expect(code).toContain('typeof __vinj_1.onRouterTransitionStart === "function"');
expect(code).toContain("__vinj_1.onRouterTransitionStart(url, type)");
expect(code).toContain('typeof __vinj_2.onRouterTransitionStart === "function"');
expect(code).toContain("__vinj_2.onRouterTransitionStart(url, type)");
});
it("escapes special characters in specifier paths", () => {
const code = generateInstrumentationClientInjectModule(['./path/with"quote.js'], null);
expect(code).toContain('from "./path/with\\"quote.js"');
});
});