diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 93e4ae4e..11204859 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,12 +47,18 @@ export type { SetupInstrumentsResponse, } from "./generated/openapi"; 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 new file mode 100644 index 00000000..6e78dc0c --- /dev/null +++ b/packages/core/src/nodeVersion.ts @@ -0,0 +1,77 @@ +/** + * 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.`; +} + +/** + * 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; + +/** + * 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; + warnCi(warning, "Unsupported Node.js version"); +} diff --git a/packages/core/tests/nodeVersion.integ.test.ts b/packages/core/tests/nodeVersion.integ.test.ts new file mode 100644 index 00000000..ced9c352 --- /dev/null +++ b/packages/core/tests/nodeVersion.integ.test.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +export {}; // Make this a module + +const { getUnsupportedNodeVersionWarning, warnCi } = require("..") as { + getUnsupportedNodeVersionWarning: (version: string) => string | null; + warnCi: (message: string, title: string) => void; +}; + +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.`, + ); + }, + ); +}); + +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(); + }); +}); 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);