Skip to content

Commit 5be8ecd

Browse files
committed
test: make Windows CLI tests portable
1 parent 3305dfa commit 5be8ecd

8 files changed

Lines changed: 123 additions & 30 deletions

File tree

CANDIDATES.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Candidate Improvements
2+
3+
## Candidates
4+
5+
1. Picked: make the test suite portable on Windows.
6+
Evidence: `npm test -- src/lib/credentials.test.ts` failed on Windows before the patch because POSIX mode assertions saw `0o666` instead of `0o600`, and the default credentials path assertion assumed `/` separators. The full suite also failed where subprocess tests used `HOME` but not `USERPROFILE`, tried to unset inherited env vars with `undefined`, spawned `npm` directly, and compared POSIX path strings. Fixed in `src/lib/credentials.test.ts:22`, `test/cli.subprocess.test.ts:379`, `test/helpers/npm.ts:3`, `src/lib/skill-nudge.test.ts:11`, `src/lib/agent-targets.test.ts:36`, and `src/lib/bundle.test.ts:594`.
7+
8+
2. `doctor` bypasses the shared `--output` validator.
9+
Evidence: `src/commands/doctor.ts:260` assigns `globals.output ?? 'text'` directly, while the shared validator at `src/lib/output.ts:32` rejects invalid modes with a typed `VALIDATION_ERROR`.
10+
11+
3. Empty `TESTSPRITE_PROFILE` is not normalized like the other env vars.
12+
Evidence: `src/lib/config.ts:20` normalizes empty env values, but `src/lib/config.ts:42` reads `env.TESTSPRITE_PROFILE` raw before `readProfile`; malformed profile names are rejected at `src/lib/credentials.ts:38`.
13+
14+
4. `--password-file` strips leading and trailing spaces from passwords.
15+
Evidence: project create/update both use `readFileSync(...).trim()` at `src/commands/project.ts:205` and `src/commands/project.ts:326`, which changes a password whose value intentionally starts or ends with whitespace.
16+
17+
5. `resolveBundleDir` trims only POSIX trailing slashes.
18+
Evidence: `src/lib/bundle.ts:328` checks only `rawPath.endsWith('/')`; `src/lib/junit-report.ts:201` shows the safer local pattern of accepting both `/` and `\\`.
19+
20+
## Picked Rationale
21+
22+
I picked the Windows test portability issue because it was directly reproducible, blocked the documented `npm test` contributor loop on this workspace, and could be fixed entirely in tests without changing CLI behavior.
23+
24+
## Diff Summary
25+
26+
- Added `test/helpers/npm.ts` so subprocess-style tests run `npm run build` through the current npm entrypoint, with a Windows `.cmd` fallback.
27+
- Isolated subprocess tests from the real Windows user profile by setting both `HOME` and `USERPROFILE`, and by removing inherited TestSprite API env vars case-insensitively.
28+
- Made permission-bit assertions POSIX-only where Node/Windows cannot represent `0o600` reliably.
29+
- Made path and CRLF-sensitive test assertions separator/line-ending neutral.
30+
31+
## Validation
32+
33+
- `npx -y -p node@22 -p npm@10 npm test` - passed, 50 files / 1846 tests.
34+
- `npx -y -p node@22 -p npm@10 npm run lint:fix` - passed.
35+
- `npx -y -p node@22 -p npm@10 npm run typecheck` - passed.
36+
- `npx -y -p node@22 -p npm@10 npm run build` - passed.
37+
38+
## PR Title
39+
40+
test: make Windows CLI test harness portable
41+
42+
## PR Body
43+
44+
Summary:
45+
- make path and CRLF-sensitive tests portable across Windows and POSIX
46+
- isolate subprocess tests from the real Windows user profile and inherited TestSprite env vars
47+
- run subprocess build setup through the active npm entrypoint instead of assuming `npm` is directly spawnable
48+
49+
Tests:
50+
- `npm test`
51+
- `npm run lint:fix`
52+
- `npm run typecheck`
53+
- `npm run build`

src/lib/agent-targets.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import {
3636
function parseFrontmatterDescription(content: string): string | undefined {
3737
const lines = content.split('\n');
3838
let inFrontmatter = false;
39-
for (const line of lines) {
39+
for (const rawLine of lines) {
40+
const line = rawLine.replace(/\r$/, '');
4041
if (line.trim() === '---') {
4142
if (!inFrontmatter) {
4243
inFrontmatter = true;

src/lib/bundle.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
1212
import { tmpdir } from 'node:os';
13-
import { join } from 'node:path';
13+
import { join, resolve } from 'node:path';
1414
import { describe, expect, it } from 'vitest';
1515
import {
1616
applyFailedOnly,
@@ -593,8 +593,7 @@ describe('resolveBundleDir', () => {
593593

594594
it('resolves a relative path against cwd', () => {
595595
const out = resolveBundleDir('./tmp/x');
596-
expect(out.endsWith('/tmp/x')).toBe(true);
597-
expect(out.startsWith('/')).toBe(true);
596+
expect(out).toBe(resolve('./tmp/x'));
598597
});
599598

600599
it('strips a trailing slash', () => {

src/lib/credentials.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { mkdtempSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2-
import { tmpdir } from 'node:os';
2+
import { homedir, tmpdir } from 'node:os';
33
import { join } from 'node:path';
44
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
55
import {
@@ -19,6 +19,11 @@ import { ApiError } from './errors.js';
1919
let tmpRoot: string;
2020
let credentialsPath: string;
2121

22+
function expectPosixMode(path: string, expected: number): void {
23+
if (process.platform === 'win32') return;
24+
expect(statSync(path).mode & 0o777).toBe(expected);
25+
}
26+
2227
beforeEach(() => {
2328
tmpRoot = mkdtempSync(join(tmpdir(), 'testsprite-creds-'));
2429
credentialsPath = join(tmpRoot, 'credentials');
@@ -139,8 +144,7 @@ describe('writeProfile', () => {
139144
it('creates the file with mode 0600 and writes the profile', () => {
140145
writeProfile(DEFAULT_PROFILE, { apiKey: 'sk-new' }, { path: credentialsPath });
141146
expect(existsSync(credentialsPath)).toBe(true);
142-
const mode = statSync(credentialsPath).mode & 0o777;
143-
expect(mode).toBe(0o600);
147+
expectPosixMode(credentialsPath, 0o600);
144148
expect(readProfile(DEFAULT_PROFILE, { path: credentialsPath })).toEqual({ apiKey: 'sk-new' });
145149
});
146150

@@ -192,14 +196,13 @@ describe('ensureRestrictiveMode', () => {
192196
mkdirSync(tmpRoot, { recursive: true });
193197
writeFileSync(credentialsPath, 'data', { mode: 0o644 });
194198
ensureRestrictiveMode(credentialsPath);
195-
const mode = statSync(credentialsPath).mode & 0o777;
196-
expect(mode).toBe(0o600);
199+
expectPosixMode(credentialsPath, 0o600);
197200
});
198201
});
199202

200203
describe('defaultCredentialsPath', () => {
201204
it('points at ~/.testsprite/credentials', () => {
202-
expect(defaultCredentialsPath().endsWith('/.testsprite/credentials')).toBe(true);
205+
expect(defaultCredentialsPath()).toBe(join(homedir(), '.testsprite', 'credentials'));
203206
});
204207
});
205208

src/lib/skill-nudge.test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,36 @@ import {
99
type SkillNudgeContext,
1010
} from './skill-nudge.js';
1111

12+
function posixPath(path: string): string {
13+
return path.replace(/\\/g, '/');
14+
}
15+
1216
// ---------------------------------------------------------------------------
1317
// isVerifySkillInstalled
1418
// ---------------------------------------------------------------------------
1519

1620
describe('isVerifySkillInstalled', () => {
1721
it('true when the claude own-file SKILL.md exists', () => {
18-
const existsSync = (p: string) => p.endsWith('.claude/skills/testsprite-verify/SKILL.md');
22+
const existsSync = (p: string) =>
23+
posixPath(p).endsWith('.claude/skills/testsprite-verify/SKILL.md');
1924
expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(true);
2025
});
2126

2227
it('true for the cursor .mdc landing file', () => {
23-
const existsSync = (p: string) => p.endsWith('.cursor/rules/testsprite-verify.mdc');
28+
const existsSync = (p: string) =>
29+
posixPath(p).endsWith('.cursor/rules/testsprite-verify.mdc');
2430
expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(true);
2531
});
2632

2733
it('true for the cline landing file', () => {
28-
const existsSync = (p: string) => p.endsWith('.clinerules/testsprite-verify.md');
34+
const existsSync = (p: string) =>
35+
posixPath(p).endsWith('.clinerules/testsprite-verify.md');
2936
expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(true);
3037
});
3138

3239
it('true for the antigravity landing file', () => {
33-
const existsSync = (p: string) => p.endsWith('.agents/skills/testsprite-verify/SKILL.md');
40+
const existsSync = (p: string) =>
41+
posixPath(p).endsWith('.agents/skills/testsprite-verify/SKILL.md');
3442
expect(isVerifySkillInstalled('/proj', { existsSync })).toBe(true);
3543
});
3644

@@ -73,7 +81,7 @@ describe('isVerifySkillInstalled', () => {
7381
return false;
7482
},
7583
});
76-
expect(seen.every(p => p.startsWith('/some/proj'))).toBe(true);
84+
expect(seen.every(p => posixPath(p).startsWith('/some/proj'))).toBe(true);
7785
// One probe per target landing path.
7886
expect(seen).toHaveLength(Object.keys(TARGETS).length);
7987
});
@@ -198,6 +206,6 @@ describe('maybeEmitSkillNudge', () => {
198206
});
199207
maybeEmitSkillNudge(ctx);
200208
expect(probed.length).toBeGreaterThan(0);
201-
expect(probed.every(p => p.startsWith('/work/here'))).toBe(true);
209+
expect(probed.every(p => posixPath(p).startsWith('/work/here'))).toBe(true);
202210
});
203211
});

test/cli.subprocess.test.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@
77
* and runs `auth whoami` against the mock."
88
*/
99

10-
import { execFileSync, spawn } from 'node:child_process';
11-
import { existsSync, mkdtempSync, statSync } from 'node:fs';
10+
import { spawn } from 'node:child_process';
11+
import { existsSync, mkdtempSync, renameSync, statSync } from 'node:fs';
1212
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
1313
import { createServer } from 'node:http';
1414
import { tmpdir } from 'node:os';
1515
import { dirname, join, resolve } from 'node:path';
1616
import { fileURLToPath } from 'node:url';
1717
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
18+
import { runNpmScript } from './helpers/npm.js';
1819

1920
const __dirname = dirname(fileURLToPath(import.meta.url));
2021
const REPO_ROOT = resolve(__dirname, '..');
@@ -31,13 +32,18 @@ let server: Server;
3132
let baseUrl: string;
3233
let tmpHome: string;
3334

35+
function expectPosixMode(path: string, expected: number): void {
36+
if (process.platform === 'win32') return;
37+
expect(statSync(path).mode & 0o777).toBe(expected);
38+
}
39+
3440
beforeAll(async () => {
3541
// Always rebuild — `npm run build` is fast and a stale `dist/index.js`
3642
// would silently mask ESM/import regressions in this suite. The
3743
// existsSync skip we used to do here let `dist` rot under
3844
// refactors and gave false-green on `project list` once
3945
// already.
40-
execFileSync('npm', ['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' });
46+
runNpmScript('build', REPO_ROOT);
4147
server = createServer((req: IncomingMessage, res: ServerResponse) => {
4248
const url = req.url ?? '/';
4349
if (url.startsWith('/api/cli/v1/projects/')) {
@@ -349,7 +355,7 @@ beforeAll(async () => {
349355
}, 60_000);
350356

351357
afterAll(async () => {
352-
await new Promise<void>(resolveClose => server.close(() => resolveClose()));
358+
if (server) await new Promise<void>(resolveClose => server.close(() => resolveClose()));
353359
});
354360

355361
interface SpawnResult {
@@ -362,13 +368,7 @@ function runCli(args: string[], envOverrides: Record<string, string> = {}): Prom
362368
return new Promise((resolveResult, rejectResult) => {
363369
const child = spawn('node', [BIN_PATH, ...args], {
364370
cwd: REPO_ROOT,
365-
env: {
366-
...process.env,
367-
HOME: tmpHome,
368-
TESTSPRITE_API_KEY: undefined,
369-
TESTSPRITE_API_URL: undefined,
370-
...envOverrides,
371-
} as NodeJS.ProcessEnv,
371+
env: childEnv(envOverrides),
372372
});
373373
let stdout = '';
374374
let stderr = '';
@@ -379,6 +379,21 @@ function runCli(args: string[], envOverrides: Record<string, string> = {}): Prom
379379
});
380380
}
381381

382+
function childEnv(envOverrides: Record<string, string>): NodeJS.ProcessEnv {
383+
const env = { ...process.env };
384+
for (const key of Object.keys(env)) {
385+
if (key.toUpperCase() === 'TESTSPRITE_API_KEY' || key.toUpperCase() === 'TESTSPRITE_API_URL') {
386+
delete env[key];
387+
}
388+
}
389+
return {
390+
...env,
391+
HOME: tmpHome,
392+
USERPROFILE: tmpHome,
393+
...envOverrides,
394+
};
395+
}
396+
382397
describe('auth status subprocess (+ deprecated whoami alias)', () => {
383398
it('prints JSON me and exits 0 against the local server', async () => {
384399
const result = await runCli(['auth', 'status', '--output', 'json'], {
@@ -897,7 +912,7 @@ describe('setup --from-env subprocess', () => {
897912
expect(result.exitCode).toBe(0);
898913
const credentialsPath = join(tmpHome, '.testsprite', 'credentials');
899914
expect(existsSync(credentialsPath)).toBe(true);
900-
expect(statSync(credentialsPath).mode & 0o777).toBe(0o600);
915+
expectPosixMode(credentialsPath, 0o600);
901916
}, 30_000);
902917

903918
it('exits 5 with VALIDATION_ERROR when --from-env is set without TESTSPRITE_API_KEY', async () => {
@@ -1044,7 +1059,7 @@ describe('--dry-run subprocess smoke', () => {
10441059
// skipped the prompt.
10451060
const credPath = join(tmpHome, '.testsprite', 'credentials');
10461061
// Make sure any previous test didn't leave one behind.
1047-
if (existsSync(credPath)) execFileSync('rm', [credPath]);
1062+
if (existsSync(credPath)) renameSync(credPath, `${credPath}.bak-${process.pid}-${Date.now()}`);
10481063
const result = await runCli(['setup', '--dry-run', '--no-agent', '--output', 'json']);
10491064
expect(result.exitCode).toBe(0);
10501065
expect(existsSync(credPath)).toBe(false);

test/help.snapshot.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process';
1313
import { dirname, join, resolve } from 'node:path';
1414
import { fileURLToPath } from 'node:url';
1515
import { beforeAll, describe, expect, it } from 'vitest';
16+
import { runNpmScript } from './helpers/npm.js';
1617

1718
const __dirname = dirname(fileURLToPath(import.meta.url));
1819
const REPO_ROOT = resolve(__dirname, '..');
@@ -47,7 +48,7 @@ const cases: Array<[string, string[]]> = [
4748

4849
describe('--help snapshots', () => {
4950
beforeAll(() => {
50-
execFileSync('npm', ['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' });
51+
runNpmScript('build', REPO_ROOT);
5152
});
5253

5354
for (const [name, args] of cases) {

test/helpers/npm.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { execFileSync } from 'node:child_process';
2+
3+
export function runNpmScript(script: string, cwd: string): void {
4+
const npmExecPath = process.env.npm_execpath;
5+
if (npmExecPath) {
6+
execFileSync(process.execPath, [npmExecPath, 'run', script], { cwd, stdio: 'pipe' });
7+
return;
8+
}
9+
execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', script], {
10+
cwd,
11+
stdio: 'pipe',
12+
});
13+
}

0 commit comments

Comments
 (0)