Skip to content

Commit 24d0e79

Browse files
committed
refactor(version-guard): extract to a documented module tested against the real implementation
1 parent 9459275 commit 24d0e79

3 files changed

Lines changed: 86 additions & 46 deletions

File tree

src/index.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
#!/usr/bin/env node
22

3-
// Guard: exit early with a clear message on unsupported Node.js versions.
4-
// This runs before any ESM/modern-syntax imports that would crash with cryptic errors.
5-
const major = Number(process.versions.node.split('.')[0]);
6-
if (major < 20) {
7-
process.stderr.write(
8-
`Error: testsprite requires Node.js >= 20 (found ${process.versions.node}).\nInstall the latest LTS from https://nodejs.org\n`,
9-
);
10-
process.exit(1);
11-
}
12-
133
import { Command, CommanderError } from 'commander';
144
import { createAgentCommand } from './commands/agent.js';
155
import { createAuthCommand } from './commands/auth.js';
@@ -26,6 +16,16 @@ import { Output, isOutputMode } from './lib/output.js';
2616
import { rephraseUnknownOption } from './lib/render-error.js';
2717
import { maybeEmitSkillNudge } from './lib/skill-nudge.js';
2818
import { VERSION } from './version.js';
19+
import { shouldRejectNodeVersion } from './version-guard.js';
20+
21+
// Guard: exit early with a clear message on unsupported Node.js versions,
22+
// rather than failing later with a cryptic ESM/runtime error.
23+
if (shouldRejectNodeVersion(process.versions.node)) {
24+
process.stderr.write(
25+
`Error: testsprite requires Node.js >= 20 (found ${process.versions.node}).\nInstall the latest LTS from https://nodejs.org\n`,
26+
);
27+
process.exit(1);
28+
}
2929

3030
const program = new Command();
3131

src/version-guard.test.ts

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,51 @@
11
import { describe, expect, it } from 'vitest';
2-
3-
/**
4-
* The version guard logic extracted for testability.
5-
* The real guard lives at the top of src/index.ts as imperative code.
6-
* These tests verify the parsing and threshold logic.
7-
*/
8-
9-
function parseMajorVersion(nodeVersion: string): number {
10-
return Number(nodeVersion.split('.')[0]);
11-
}
12-
13-
function shouldRejectVersion(nodeVersion: string): boolean {
14-
return parseMajorVersion(nodeVersion) < 20;
15-
}
16-
17-
describe('Node.js version guard logic', () => {
18-
it('rejects Node 18.x', () => {
19-
expect(shouldRejectVersion('18.19.1')).toBe(true);
20-
});
21-
22-
it('rejects Node 16.x', () => {
23-
expect(shouldRejectVersion('16.20.2')).toBe(true);
2+
import {
3+
MIN_SUPPORTED_NODE_MAJOR,
4+
parseMajorVersion,
5+
shouldRejectNodeVersion,
6+
} from './version-guard.js';
7+
8+
// These tests exercise the REAL guard functions used by src/index.ts,
9+
// imported here rather than re-declared, so a regression in the source is
10+
// actually caught.
11+
12+
describe('parseMajorVersion', () => {
13+
it('extracts the leading major from a semver string', () => {
14+
expect(parseMajorVersion('20.11.1')).toBe(20);
15+
expect(parseMajorVersion('18.0.0')).toBe(18);
16+
expect(parseMajorVersion('22.3.0')).toBe(22);
2417
});
2518

26-
it('rejects Node 14.x', () => {
27-
expect(shouldRejectVersion('14.21.3')).toBe(true);
19+
it('returns NaN for a non-numeric version string', () => {
20+
expect(Number.isNaN(parseMajorVersion('not-a-version'))).toBe(true);
2821
});
22+
});
2923

30-
it('accepts Node 20.x', () => {
31-
expect(shouldRejectVersion('20.11.0')).toBe(false);
24+
describe('shouldRejectNodeVersion', () => {
25+
it('rejects majors below the supported floor', () => {
26+
expect(shouldRejectNodeVersion('18.19.1')).toBe(true);
27+
expect(shouldRejectNodeVersion('16.20.2')).toBe(true);
28+
expect(shouldRejectNodeVersion('14.21.3')).toBe(true);
3229
});
3330

34-
it('accepts Node 22.x', () => {
35-
expect(shouldRejectVersion('22.1.0')).toBe(false);
31+
it('accepts the supported floor and above', () => {
32+
expect(shouldRejectNodeVersion('20.0.0')).toBe(false);
33+
expect(shouldRejectNodeVersion('20.11.0')).toBe(false);
34+
expect(shouldRejectNodeVersion('21.0.0')).toBe(false);
35+
expect(shouldRejectNodeVersion('22.1.0')).toBe(false);
3636
});
3737

38-
it('accepts Node 21.x', () => {
39-
expect(shouldRejectVersion('21.0.0')).toBe(false);
38+
it(`treats exactly ${MIN_SUPPORTED_NODE_MAJOR} as supported (boundary)`, () => {
39+
expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR}.0.0`)).toBe(false);
40+
expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR - 1}.9.9`)).toBe(true);
4041
});
4142

42-
it('parses major version correctly from semver string', () => {
43-
expect(parseMajorVersion('20.11.1')).toBe(20);
44-
expect(parseMajorVersion('18.0.0')).toBe(18);
45-
expect(parseMajorVersion('22.3.0')).toBe(22);
43+
it('does not reject an unparseable version (guard never blocks on garbage)', () => {
44+
expect(shouldRejectNodeVersion('not-a-version')).toBe(false);
4645
});
4746

4847
it('the running Node satisfies the guard (meta-test)', () => {
49-
// This test is running on Node >= 20, so it must pass the guard.
50-
expect(shouldRejectVersion(process.versions.node)).toBe(false);
48+
// The test suite itself runs on a supported Node, so the guard must pass.
49+
expect(shouldRejectNodeVersion(process.versions.node)).toBe(false);
5150
});
5251
});

src/version-guard.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Node.js runtime version guard.
3+
*
4+
* The CLI targets modern Node (see `engines.node` in package.json). Running on
5+
* an older runtime tends to fail later with a cryptic ESM/syntax error, so the
6+
* entrypoint (`src/index.ts`) uses {@link shouldRejectNodeVersion} to exit early
7+
* with a clear, actionable message instead.
8+
*
9+
* The logic lives here (rather than inline) so it can be unit-tested against the
10+
* real implementation the entrypoint uses — not a copy.
11+
*/
12+
13+
/** Minimum Node.js major version supported by the CLI (matches package.json `engines.node`). */
14+
export const MIN_SUPPORTED_NODE_MAJOR = 20;
15+
16+
/**
17+
* Parse the leading major version number from a Node.js version string.
18+
*
19+
* @param nodeVersion - a dot-separated version string such as `process.versions.node`
20+
* (e.g. `"20.11.1"`). A leading `v` is not expected (Node does not include one here).
21+
* @returns the major version as a number, or `NaN` if the string has no numeric leading segment.
22+
*/
23+
export function parseMajorVersion(nodeVersion: string): number {
24+
return Number(nodeVersion.split('.')[0]);
25+
}
26+
27+
/**
28+
* Decide whether the given Node.js version is too old to run the CLI.
29+
*
30+
* A version is rejected only when its major number is a real value below
31+
* {@link MIN_SUPPORTED_NODE_MAJOR}. An unparseable string yields `NaN`, which is
32+
* treated as "do not reject" so the guard never blocks on a version string it
33+
* cannot understand (the runtime would surface any real incompatibility itself).
34+
*
35+
* @param nodeVersion - a `process.versions.node` style string (e.g. `"18.19.1"`).
36+
* @returns `true` when the runtime is below the supported floor and should be rejected.
37+
*/
38+
export function shouldRejectNodeVersion(nodeVersion: string): boolean {
39+
const major = parseMajorVersion(nodeVersion);
40+
return !Number.isNaN(major) && major < MIN_SUPPORTED_NODE_MAJOR;
41+
}

0 commit comments

Comments
 (0)