From 5afca1a461931c097555055379dcaa77ecff4da6 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 27 Aug 2026 14:32:24 +0200 Subject: [PATCH 1/2] feat(core): warn when running on an untested Node.js version The plugins ship ABI-pinned prebuilds for Node.js 22 and 24 only, so any other major silently fails to load the native addon with no hint that the runtime version is the cause. Emit the warning before the native-binding check in setupCore, so the version is still printed on the majors where the addon cannot load and setupCore throws. The playwright plugin gets its own call because it registers the integration itself and bypasses setupCore. COD-3399 --- packages/core/src/index.ts | 8 +++++ packages/core/src/nodeVersion.ts | 35 +++++++++++++++++++ packages/core/tests/nodeVersion.integ.test.ts | 21 +++++++++++ packages/playwright-plugin/src/index.ts | 3 ++ 4 files changed, 67 insertions(+) create mode 100644 packages/core/src/nodeVersion.ts create mode 100644 packages/core/tests/nodeVersion.integ.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 93e4ae4e..3bc76a7a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ import { checkV8Flags } from "./introspection"; import { MongoMeasurement } from "./mongoMeasurement"; import native_core from "./native_core"; +import { warnOnUnsupportedNodeVersion } from "./nodeVersion"; import { getCodspeedRunnerMode } from "./runnerMode"; declare const __VERSION__: string; @@ -12,6 +13,8 @@ export const isBound = native_core.isBound; export const mongoMeasurement = new MongoMeasurement(); export const setupCore = () => { + warnOnUnsupportedNodeVersion(); + if (!native_core.isBound) { throw new Error( "Native core module is not bound, CodSpeed integration will not work properly", @@ -44,6 +47,11 @@ export type { SetupInstrumentsResponse, } from "./generated/openapi"; export { getV8Flags, tryIntrospect } from "./introspection"; +export { + getUnsupportedNodeVersionWarning, + SUPPORTED_NODE_MAJORS, + warnOnUnsupportedNodeVersion, +} from "./nodeVersion"; export { optimizeFunction, optimizeFunctionSync } from "./optimization"; export { wrapWithRootFrame, wrapWithRootFrameSync } from "./rootFrame"; export * from "./utils"; diff --git a/packages/core/src/nodeVersion.ts b/packages/core/src/nodeVersion.ts new file mode 100644 index 00000000..d6c9a3c5 --- /dev/null +++ b/packages/core/src/nodeVersion.ts @@ -0,0 +1,35 @@ +/** + * Majors the native addon ships prebuilds for, as listed in the + * `build-native-addon` targets in package.json. Prebuilds are matched on the + * exact ABI version, so on any other major the addon only loads when it has + * been compiled from source locally. + */ +export const SUPPORTED_NODE_MAJORS = [22, 24]; + +export function getUnsupportedNodeVersionWarning( + version: string, +): string | null { + const major = parseInt(version.split(".")[0], 10); + if (SUPPORTED_NODE_MAJORS.includes(major)) { + return null; + } + return `[CodSpeed] Node.js v${version} is not supported: CodSpeed is tested on Node.js ${SUPPORTED_NODE_MAJORS.join(", ")}. Support for other versions is experimental and measurements may be unstable.`; +} + +let hasWarned = false; + +/** + * Integrations call their setup path per suite or per worker, so the warning is + * latched to once per process. + */ +export function warnOnUnsupportedNodeVersion(): void { + if (hasWarned) { + return; + } + const warning = getUnsupportedNodeVersionWarning(process.versions.node); + if (warning === null) { + return; + } + hasWarned = true; + console.warn(warning); +} diff --git a/packages/core/tests/nodeVersion.integ.test.ts b/packages/core/tests/nodeVersion.integ.test.ts new file mode 100644 index 00000000..40778110 --- /dev/null +++ b/packages/core/tests/nodeVersion.integ.test.ts @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +export {}; // Make this a module + +const { getUnsupportedNodeVersionWarning } = require("..") as { + getUnsupportedNodeVersionWarning: (version: string) => string | null; +}; + +describe("getUnsupportedNodeVersionWarning", () => { + it.each(["22.22.2", "24.19.0"])("should not warn on Node %s", (version) => { + expect(getUnsupportedNodeVersionWarning(version)).toBeNull(); + }); + + it.each(["20.5.1", "23.11.0", "26.0.0"])( + "should warn on Node %s", + (version) => { + expect(getUnsupportedNodeVersionWarning(version)).toBe( + `[CodSpeed] Node.js v${version} is not supported: CodSpeed is tested on Node.js 22, 24. Support for other versions is experimental and measurements may be unstable.`, + ); + }, + ); +}); diff --git a/packages/playwright-plugin/src/index.ts b/packages/playwright-plugin/src/index.ts index 9b504b65..499c3091 100644 --- a/packages/playwright-plugin/src/index.ts +++ b/packages/playwright-plugin/src/index.ts @@ -5,6 +5,7 @@ import { MARKER_TYPE_BENCHMARK_END, MARKER_TYPE_BENCHMARK_START, msToS, + warnOnUnsupportedNodeVersion, writeWalltimeResults, type Benchmark, type BenchmarkStats, @@ -101,6 +102,8 @@ function ensureIntegrationSetup(): void { if (integrationInitialized) return; integrationInitialized = true; + warnOnUnsupportedNodeVersion(); + InstrumentHooks.setIntegration("node-custom", __VERSION__); InstrumentHooks.setEnvironment("nodejs", "version", process.versions.node); InstrumentHooks.setEnvironment("nodejs", "v8", process.versions.v8); From 6e7804146ee880cf6d7495513e83295bd675d787 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 27 Aug 2026 15:44:16 +0200 Subject: [PATCH 2/2] feat(core): surface the unsupported-version warning in CI console.warn only reaches the raw job log, where it is buried in the test runner output and easy to miss. Route it through warnCi, which uses the surface each provider offers: a workflow-command annotation on GitHub Actions, so it shows up in the checks and merge request UI, and yellow on GitLab CI, mirroring how the runner renders its own warnings there. COD-3399 --- packages/core/src/index.ts | 5 +- packages/core/src/nodeVersion.ts | 44 +++++++++++++- packages/core/tests/nodeVersion.integ.test.ts | 57 ++++++++++++++++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3bc76a7a..11204859 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,14 +50,15 @@ export { getV8Flags, tryIntrospect } from "./introspection"; export { getUnsupportedNodeVersionWarning, SUPPORTED_NODE_MAJORS, + warnCi, warnOnUnsupportedNodeVersion, } from "./nodeVersion"; export { optimizeFunction, optimizeFunctionSync } from "./optimization"; export { wrapWithRootFrame, wrapWithRootFrameSync } from "./rootFrame"; +export { getCodspeedRunnerMode, getInstrumentMode } from "./runnerMode"; +export type { InstrumentMode } from "./runnerMode"; export * from "./utils"; export * from "./walltime"; -export type { InstrumentMode } from "./runnerMode"; -export { getCodspeedRunnerMode, getInstrumentMode } from "./runnerMode"; export const InstrumentHooks = native_core.InstrumentHooks; // Marker type constants, sourced from the native addon (which reads them from diff --git a/packages/core/src/nodeVersion.ts b/packages/core/src/nodeVersion.ts index d6c9a3c5..6e78dc0c 100644 --- a/packages/core/src/nodeVersion.ts +++ b/packages/core/src/nodeVersion.ts @@ -16,6 +16,48 @@ export function getUnsupportedNodeVersionWarning( return `[CodSpeed] Node.js v${version} is not supported: CodSpeed is tested on Node.js ${SUPPORTED_NODE_MAJORS.join(", ")}. Support for other versions is experimental and measurements may be unstable.`; } +/** + * Percent-encode the characters GitHub Actions treats as workflow-command + * syntax, so a message cannot terminate or extend the command it is part of. + * + * See https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions + */ +function escapeWorkflowCommandData(value: string): string { + return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +/** + * Properties are parsed out of the command header, where `:` ends the property + * list and `,` separates properties, so both need encoding on top of the data + * escaping. + */ +function escapeWorkflowCommandProperty(value: string): string { + return escapeWorkflowCommandData(value) + .replace(/:/g, "%3A") + .replace(/,/g, "%2C"); +} + +/** + * On GitHub Actions the warning becomes an annotation, which is shown outside + * the job log. Workflow commands must start a line, hence the direct stdout + * write rather than a prefixed log call. + */ +export function warnCi(message: string, title: string): void { + if (process.env.GITHUB_ACTIONS === "true") { + process.stdout.write( + `::warning title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(message)}\n`, + ); + return; + } + if (process.env.GITLAB_CI === "true") { + // GitLab CI has no annotation mechanism, so colour the line to make it + // stand out in the job log. + console.warn(`\x1b[33m${message}\x1b[0m`); + return; + } + console.warn(message); +} + let hasWarned = false; /** @@ -31,5 +73,5 @@ export function warnOnUnsupportedNodeVersion(): void { return; } hasWarned = true; - console.warn(warning); + warnCi(warning, "Unsupported Node.js version"); } diff --git a/packages/core/tests/nodeVersion.integ.test.ts b/packages/core/tests/nodeVersion.integ.test.ts index 40778110..ced9c352 100644 --- a/packages/core/tests/nodeVersion.integ.test.ts +++ b/packages/core/tests/nodeVersion.integ.test.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-require-imports */ export {}; // Make this a module -const { getUnsupportedNodeVersionWarning } = require("..") as { +const { getUnsupportedNodeVersionWarning, warnCi } = require("..") as { getUnsupportedNodeVersionWarning: (version: string) => string | null; + warnCi: (message: string, title: string) => void; }; describe("getUnsupportedNodeVersionWarning", () => { @@ -19,3 +20,57 @@ describe("getUnsupportedNodeVersionWarning", () => { }, ); }); + +describe("warnCi", () => { + const originalEnv = { + GITHUB_ACTIONS: process.env.GITHUB_ACTIONS, + GITLAB_CI: process.env.GITLAB_CI, + }; + let write: jest.SpyInstance; + let warn: jest.SpyInstance; + + beforeEach(() => { + delete process.env.GITHUB_ACTIONS; + delete process.env.GITLAB_CI; + write = jest.spyOn(process.stdout, "write").mockReturnValue(true); + warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + afterEach(() => { + for (const [name, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + jest.restoreAllMocks(); + }); + + it("should emit an annotation on GitHub Actions", () => { + process.env.GITHUB_ACTIONS = "true"; + + warnCi("a 100% clear\nwarning", "A title: with, separators"); + + expect(write).toHaveBeenCalledWith( + "::warning title=A title%3A with%2C separators::a 100%25 clear%0Awarning\n", + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it("should colour the warning on GitLab CI", () => { + process.env.GITLAB_CI = "true"; + + warnCi("a warning", "A title"); + + expect(warn).toHaveBeenCalledWith("\x1b[33ma warning\x1b[0m"); + expect(write).not.toHaveBeenCalled(); + }); + + it("should log the warning as is outside of CI", () => { + warnCi("a warning", "A title"); + + expect(warn).toHaveBeenCalledWith("a warning"); + expect(write).not.toHaveBeenCalled(); + }); +});