diff --git a/src/core/analytics/analyticsUtils.test.ts b/src/core/analytics/analyticsUtils.test.ts new file mode 100755 index 00000000..399a6455 --- /dev/null +++ b/src/core/analytics/analyticsUtils.test.ts @@ -0,0 +1,237 @@ +import test from "ava"; +import { sendVersionMismatchAlert, sendUnpatchedDependencyAlert } from "./analyticsUtils"; +import { TuskDriftCore, TuskDriftMode } from "../TuskDrift"; + +type MockCore = { + getMode: () => TuskDriftMode; + getProtobufCommunicator: () => unknown; +}; + +/** + * Temporarily replace TuskDriftCore.getInstance with a stub. + * Restores the original in a finally block. + */ +function withMockedInstance(mock: MockCore, fn: () => void): void { + const originalGetInstance = (TuskDriftCore as any).getInstance; + (TuskDriftCore as any).getInstance = () => mock; + try { + fn(); + } finally { + (TuskDriftCore as any).getInstance = originalGetInstance; + } +} + +// ---- sendVersionMismatchAlert ---- + +test.serial("sendVersionMismatchAlert: does not send when mode is RECORD", (t) => { + let sendCalled = false; + withMockedInstance( + { + getMode: () => TuskDriftMode.RECORD, + getProtobufCommunicator: () => ({ + sendInstrumentationVersionMismatchAlert: () => { + sendCalled = true; + }, + }), + }, + () => { + sendVersionMismatchAlert({ + moduleName: "my-module", + foundVersion: "1.0.0", + supportedVersions: ["1.0.0"], + }); + }, + ); + t.false(sendCalled); +}); + +test.serial("sendVersionMismatchAlert: does not send when mode is DISABLED", (t) => { + let sendCalled = false; + withMockedInstance( + { + getMode: () => TuskDriftMode.DISABLED, + getProtobufCommunicator: () => ({ + sendInstrumentationVersionMismatchAlert: () => { + sendCalled = true; + }, + }), + }, + () => { + sendVersionMismatchAlert({ + moduleName: "my-module", + foundVersion: "1.0.0", + supportedVersions: ["1.0.0"], + }); + }, + ); + t.false(sendCalled); +}); + +test.serial("sendVersionMismatchAlert: does not send when protobufComm is null in REPLAY mode", (t) => { + let sendCalled = false; + withMockedInstance( + { + getMode: () => TuskDriftMode.REPLAY, + getProtobufCommunicator: () => null, + }, + () => { + sendVersionMismatchAlert({ + moduleName: "my-module", + foundVersion: "1.0.0", + supportedVersions: ["1.0.0"], + }); + }, + ); + t.false(sendCalled); +}); + +test.serial("sendVersionMismatchAlert: sends alert in REPLAY mode with protobufComm", (t) => { + t.plan(3); + withMockedInstance( + { + getMode: () => TuskDriftMode.REPLAY, + getProtobufCommunicator: () => ({ + sendInstrumentationVersionMismatchAlert: (data: { + moduleName: string; + requestedVersion: string | undefined; + supportedVersions: string[]; + }) => { + t.is(data.moduleName, "express"); + t.is(data.requestedVersion, "4.18.0"); + t.deepEqual(data.supportedVersions, ["4.17.0", "4.18.0"]); + }, + }), + }, + () => { + sendVersionMismatchAlert({ + moduleName: "express", + foundVersion: "4.18.0", + supportedVersions: ["4.17.0", "4.18.0"], + }); + }, + ); +}); + +test.serial("sendVersionMismatchAlert: passes undefined foundVersion as requestedVersion", (t) => { + t.plan(1); + withMockedInstance( + { + getMode: () => TuskDriftMode.REPLAY, + getProtobufCommunicator: () => ({ + sendInstrumentationVersionMismatchAlert: (data: { requestedVersion: string | undefined }) => { + t.is(data.requestedVersion, undefined); + }, + }), + }, + () => { + sendVersionMismatchAlert({ + moduleName: "my-module", + foundVersion: undefined, + supportedVersions: ["1.0.0"], + }); + }, + ); +}); + +test.serial("sendVersionMismatchAlert: handles exception without throwing", (t) => { + withMockedInstance( + { + getMode: () => { + throw new Error("simulated error"); + }, + getProtobufCommunicator: () => null, + }, + () => { + t.notThrows(() => { + sendVersionMismatchAlert({ + moduleName: "my-module", + foundVersion: "1.0.0", + supportedVersions: [], + }); + }); + }, + ); +}); + +// ---- sendUnpatchedDependencyAlert ---- + +test.serial("sendUnpatchedDependencyAlert: does nothing when protobufComm is null", (t) => { + let sendCalled = false; + withMockedInstance( + { + getMode: () => TuskDriftMode.RECORD, + getProtobufCommunicator: () => null, + }, + () => { + sendUnpatchedDependencyAlert({ + traceTestServerSpanId: "span-123", + stackTrace: "Error: some error", + }); + }, + ); + t.false(sendCalled); +}); + +test.serial("sendUnpatchedDependencyAlert: does nothing when stackTrace is undefined", (t) => { + let sendCalled = false; + withMockedInstance( + { + getMode: () => TuskDriftMode.RECORD, + getProtobufCommunicator: () => ({ + sendUnpatchedDependencyAlert: () => { + sendCalled = true; + }, + }), + }, + () => { + sendUnpatchedDependencyAlert({ + traceTestServerSpanId: "span-123", + // stackTrace intentionally omitted + }); + }, + ); + t.false(sendCalled); +}); + +test.serial("sendUnpatchedDependencyAlert: sends alert when protobufComm and stackTrace are present", (t) => { + t.plan(2); + withMockedInstance( + { + getMode: () => TuskDriftMode.RECORD, + getProtobufCommunicator: () => ({ + sendUnpatchedDependencyAlert: (data: { + traceTestServerSpanId: string; + stackTrace: string; + }) => { + t.is(data.traceTestServerSpanId, "span-789"); + t.is(data.stackTrace, "Error: test\n at foo.ts:1:1"); + }, + }), + }, + () => { + sendUnpatchedDependencyAlert({ + traceTestServerSpanId: "span-789", + stackTrace: "Error: test\n at foo.ts:1:1", + }); + }, + ); +}); + +test.serial("sendUnpatchedDependencyAlert: handles exception without throwing", (t) => { + withMockedInstance( + { + getMode: () => TuskDriftMode.RECORD, + getProtobufCommunicator: () => { + throw new Error("simulated error"); + }, + }, + () => { + t.notThrows(() => { + sendUnpatchedDependencyAlert({ + traceTestServerSpanId: "span-123", + stackTrace: "Error: test", + }); + }); + }, + ); +}); diff --git a/src/core/esmLoader.test.ts b/src/core/esmLoader.test.ts new file mode 100755 index 00000000..6648a5e5 --- /dev/null +++ b/src/core/esmLoader.test.ts @@ -0,0 +1,168 @@ +import test from "ava"; +import { initializeEsmLoader, _esmLoaderDeps } from "./esmLoader"; + +/** + * Whether the current Node version supports module.register(). + * Mirrors the private supportsModuleRegister() function in esmLoader.ts. + */ +function supportsModuleRegister(): boolean { + const major = parseInt(process.versions.node.split(".")[0]!, 10); + const minor = parseInt(process.versions.node.split(".")[1]!, 10); + return major >= 21 || (major === 20 && minor >= 6) || (major === 18 && minor >= 19); +} + +// ---- initializeEsmLoader ---- + +test.serial("initializeEsmLoader: is a no-op in CJS environment (default test env)", (t) => { + // Tests run as CJS so _esmLoaderDeps.isCommonJS() returns true by default — + // the function exits immediately without setting the registration flag. + const prevFlag = (globalThis as any).__tuskDriftEsmLoaderRegistered; + initializeEsmLoader(); + t.is((globalThis as any).__tuskDriftEsmLoaderRegistered, prevFlag); +}); + +test.serial("initializeEsmLoader: skips registration if already registered", (t) => { + if (!supportsModuleRegister()) { + t.pass(); + return; + } + + const originalIsCommonJS = _esmLoaderDeps.isCommonJS; + _esmLoaderDeps.isCommonJS = () => false; + + const prevFlag = (globalThis as any).__tuskDriftEsmLoaderRegistered; + (globalThis as any).__tuskDriftEsmLoaderRegistered = true; + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const iitm = require("import-in-the-middle") as { + createAddHookMessageChannel: () => { addHookMessagePort: unknown }; + }; + const originalCreateChannel = iitm.createAddHookMessageChannel; + let channelCreated = false; + iitm.createAddHookMessageChannel = () => { + channelCreated = true; + return { addHookMessagePort: {} }; + }; + + try { + initializeEsmLoader(); + t.false(channelCreated); + } finally { + _esmLoaderDeps.isCommonJS = originalIsCommonJS; + iitm.createAddHookMessageChannel = originalCreateChannel; + (globalThis as any).__tuskDriftEsmLoaderRegistered = prevFlag; + } +}); + +test.serial("initializeEsmLoader: registers ESM loader hooks when not in CJS and not yet registered", (t) => { + if (!supportsModuleRegister()) { + t.pass(); + return; + } + + const originalIsCommonJS = _esmLoaderDeps.isCommonJS; + _esmLoaderDeps.isCommonJS = () => false; + + const prevFlag = (globalThis as any).__tuskDriftEsmLoaderRegistered; + delete (globalThis as any).__tuskDriftEsmLoaderRegistered; + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const iitm = require("import-in-the-middle") as { + createAddHookMessageChannel: () => { addHookMessagePort: unknown }; + }; + const originalCreateChannel = iitm.createAddHookMessageChannel; + const mockPort = {}; + let channelCreated = false; + iitm.createAddHookMessageChannel = () => { + channelCreated = true; + return { addHookMessagePort: mockPort }; + }; + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const moduleModule = require("module") as { register?: (...args: unknown[]) => void }; + const originalRegister = moduleModule.register; + let registerCalled = false; + let firstRegisterArg: unknown; + moduleModule.register = (...args: unknown[]) => { + registerCalled = true; + firstRegisterArg = args[0]; + }; + + try { + initializeEsmLoader(); + + t.true(channelCreated); + t.true(registerCalled); + t.is(firstRegisterArg, "import-in-the-middle/hook.mjs"); + t.true((globalThis as any).__tuskDriftEsmLoaderRegistered); + } finally { + _esmLoaderDeps.isCommonJS = originalIsCommonJS; + iitm.createAddHookMessageChannel = originalCreateChannel; + moduleModule.register = originalRegister; + (globalThis as any).__tuskDriftEsmLoaderRegistered = prevFlag; + } +}); + +test.serial("initializeEsmLoader: warns and returns early when Node version does not support module.register", (t) => { + const originalIsCommonJS = _esmLoaderDeps.isCommonJS; + const originalNodeMajor = _esmLoaderDeps.nodeMajor; + const originalNodeMinor = _esmLoaderDeps.nodeMinor; + + _esmLoaderDeps.isCommonJS = () => false; + // Node 17 falls through all three conditions in supportsModuleRegister, + // covering lines 12 and 13, and returning false → exercises the warn path (lines 40-46). + _esmLoaderDeps.nodeMajor = 17; + _esmLoaderDeps.nodeMinor = 0; + + const prevFlag = (globalThis as any).__tuskDriftEsmLoaderRegistered; + delete (globalThis as any).__tuskDriftEsmLoaderRegistered; + + try { + t.notThrows(() => { + initializeEsmLoader(); + }); + // Returned early before setting the registration flag. + t.falsy((globalThis as any).__tuskDriftEsmLoaderRegistered); + } finally { + _esmLoaderDeps.isCommonJS = originalIsCommonJS; + _esmLoaderDeps.nodeMajor = originalNodeMajor; + _esmLoaderDeps.nodeMinor = originalNodeMinor; + (globalThis as any).__tuskDriftEsmLoaderRegistered = prevFlag; + } +}); + +test.serial("initializeEsmLoader: handles createAddHookMessageChannel error gracefully", (t) => { + if (!supportsModuleRegister()) { + t.pass(); + return; + } + + const originalIsCommonJS = _esmLoaderDeps.isCommonJS; + _esmLoaderDeps.isCommonJS = () => false; + + const prevFlag = (globalThis as any).__tuskDriftEsmLoaderRegistered; + delete (globalThis as any).__tuskDriftEsmLoaderRegistered; + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const iitm = require("import-in-the-middle") as { + createAddHookMessageChannel: () => { addHookMessagePort: unknown }; + }; + const originalCreateChannel = iitm.createAddHookMessageChannel; + iitm.createAddHookMessageChannel = () => { + throw new Error("channel creation failed"); + }; + + try { + // The try/catch inside initializeEsmLoader must swallow the error. + t.notThrows(() => { + initializeEsmLoader(); + }); + + // Flag is set to true before the try/catch block, so it stays true. + t.true((globalThis as any).__tuskDriftEsmLoaderRegistered); + } finally { + _esmLoaderDeps.isCommonJS = originalIsCommonJS; + iitm.createAddHookMessageChannel = originalCreateChannel; + (globalThis as any).__tuskDriftEsmLoaderRegistered = prevFlag; + } +}); diff --git a/src/core/esmLoader.ts b/src/core/esmLoader.ts index d5597410..018bdf0d 100644 --- a/src/core/esmLoader.ts +++ b/src/core/esmLoader.ts @@ -6,11 +6,22 @@ import { isCommonJS } from "./utils/runtimeDetectionUtils"; const NODE_MAJOR = parseInt(process.versions.node.split(".")[0]!, 10); const NODE_MINOR = parseInt(process.versions.node.split(".")[1]!, 10); +/** + * Mutable dependency container — exposed so tests can swap out isCommonJS + * without patching non-configurable module exports. + * @internal + */ +export const _esmLoaderDeps = { + isCommonJS, + nodeMajor: NODE_MAJOR, + nodeMinor: NODE_MINOR, +}; + function supportsModuleRegister(): boolean { return ( - NODE_MAJOR >= 21 || - (NODE_MAJOR === 20 && NODE_MINOR >= 6) || - (NODE_MAJOR === 18 && NODE_MINOR >= 19) + _esmLoaderDeps.nodeMajor >= 21 || + (_esmLoaderDeps.nodeMajor === 20 && _esmLoaderDeps.nodeMinor >= 6) || + (_esmLoaderDeps.nodeMajor === 18 && _esmLoaderDeps.nodeMinor >= 19) ); } @@ -25,7 +36,7 @@ function supportsModuleRegister(): boolean { * https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options */ export function initializeEsmLoader(): void { - if (isCommonJS()) { + if (_esmLoaderDeps.isCommonJS()) { return; } @@ -38,9 +49,11 @@ export function initializeEsmLoader(): void { return; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((globalThis as any).__tuskDriftEsmLoaderRegistered) { return; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__tuskDriftEsmLoaderRegistered = true; try {