Skip to content

Commit 18fc2b9

Browse files
fix(test): restore full test suite on Windows
- Strip both slash and backslash trailing separators in resolveBundleDir - Add cross-platform npm helper for subprocess/snapshot builds - Set USERPROFILE alongside HOME in subprocess tests - Replace Unix-only rm with unlinkSync for credential cleanup - Skip symlink and chmod assertions when platform cannot honor them - Normalize CRLF in frontmatter description parsing - Enforce LF line endings via .gitattributes
1 parent 18f6e6e commit 18fc2b9

9 files changed

Lines changed: 88 additions & 23 deletions

File tree

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* text=auto eol=lf

src/commands/agent.test.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { existsSync, mkdtempSync, readFileSync, symlinkSync } from 'node:fs';
1+
import {
2+
existsSync,
3+
mkdtempSync,
4+
readFileSync,
5+
symlinkSync,
6+
writeFileSync,
7+
} from 'node:fs';
28
import { tmpdir } from 'node:os';
39
import path from 'node:path';
410
import { describe, expect, it, vi } from 'vitest';
@@ -13,6 +19,21 @@ import {
1319
import type { AgentDeps, AgentFs, InstallResult, ListResult } from './agent.js';
1420
import { AGENTS_MD_CODEX_BUDGET_BYTES, createAgentCommand, runInstall, runList } from './agent.js';
1521

22+
/** Windows requires Developer Mode or elevation to create symlinks. */
23+
function canCreateSymlinks(): boolean {
24+
try {
25+
const probeRoot = mkdtempSync(path.join(tmpdir(), 'agent-symlink-probe-'));
26+
const target = path.join(probeRoot, 'target.txt');
27+
writeFileSync(target, 'probe');
28+
symlinkSync(target, path.join(probeRoot, 'link.txt'), 'file');
29+
return true;
30+
} catch {
31+
return false;
32+
}
33+
}
34+
35+
const symlinkCapable = canCreateSymlinks();
36+
1637
// ---------------------------------------------------------------------------
1738
// In-memory AgentFs backed by a Map
1839
// ---------------------------------------------------------------------------
@@ -1031,7 +1052,9 @@ describe('runInstall — default AgentFs (real disk)', () => {
10311052
expect(readFileSync(abs, 'utf8')).toBe(content);
10321053
});
10331054

1034-
it('refuses to write through a symlinked parent dir (real disk) — exit 5', async () => {
1055+
it.skipIf(!symlinkCapable)(
1056+
'refuses to write through a symlinked parent dir (real disk) — exit 5',
1057+
async () => {
10351058
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-parent-'));
10361059
const outside = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-'));
10371060
// `.claude` is a real symlink to a directory outside the project root.
@@ -1060,9 +1083,12 @@ describe('runInstall — default AgentFs (real disk)', () => {
10601083
expect((thrown as CLIError).exitCode).toBe(5);
10611084
// Nothing was created through the symlink, outside --dir.
10621085
expect(existsSync(path.join(outside, 'skills'))).toBe(false);
1063-
});
1086+
},
1087+
);
10641088

1065-
it('refuses to overwrite a symlinked target file (real disk) with --force — exit 5', async () => {
1089+
it.skipIf(!symlinkCapable)(
1090+
'refuses to overwrite a symlinked target file (real disk) with --force — exit 5',
1091+
async () => {
10661092
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-target-'));
10671093
const outsideDir = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-target-'));
10681094
const { path: relPath } = renderForTarget('claude');
@@ -1097,7 +1123,8 @@ describe('runInstall — default AgentFs (real disk)', () => {
10971123
expect((thrown as CLIError).exitCode).toBe(5);
10981124
// The outside file was NOT overwritten (nor clobbered via the .bak path).
10991125
expect(readFileSync(outsideFile, 'utf8')).toBe('SECRET');
1100-
});
1126+
},
1127+
);
11011128
});
11021129

11031130
// ---------------------------------------------------------------------------

src/lib/agent-targets.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function parseFrontmatterDescription(content: string): string | undefined {
3434
}
3535
}
3636
if (inFrontmatter && line.startsWith('description: ')) {
37-
return line.slice('description: '.length);
37+
return line.slice('description: '.length).replace(/\r$/, '');
3838
}
3939
}
4040
return undefined;

src/lib/bundle.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* the full http+fetch path is wired against MSW).
99
*/
1010

11+
import { resolve } from 'node:path';
1112
import { describe, expect, it } from 'vitest';
1213
import {
1314
applyFailedOnly,
@@ -583,12 +584,12 @@ describe('resolveBundleDir', () => {
583584

584585
it('resolves a relative path against cwd', () => {
585586
const out = resolveBundleDir('./tmp/x');
586-
expect(out.endsWith('/tmp/x')).toBe(true);
587-
expect(out.startsWith('/')).toBe(true);
587+
expect(out).toBe(resolve(process.cwd(), 'tmp', 'x'));
588588
});
589589

590590
it('strips a trailing slash', () => {
591-
const out = resolveBundleDir('/tmp/x/');
592-
expect(out).toBe('/tmp/x');
591+
const base = resolve(process.cwd(), 'tmp', 'x');
592+
const trailing = process.platform === 'win32' ? `${base}\\` : `${base}/`;
593+
expect(resolveBundleDir(trailing)).toBe(base);
593594
});
594595
});

src/lib/bundle.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,17 @@ export function applyFailedOnly(ctx: CliFailureContext): CliFailureContext {
307307
* its `.tmp` child — `writeBundle` mkdir's after the integrity check
308308
* passes so a forged response never modifies the operator's filesystem.
309309
*/
310+
function stripTrailingSeparators(rawPath: string): string {
311+
if (rawPath.length <= 1) return rawPath;
312+
let end = rawPath.length;
313+
while (end > 1 && (rawPath[end - 1] === '/' || rawPath[end - 1] === '\\')) {
314+
// Preserve Windows drive roots (e.g. `C:\`).
315+
if (end === 3 && rawPath[1] === ':' && /[A-Za-z]/.test(rawPath[0]!)) break;
316+
end--;
317+
}
318+
return rawPath.slice(0, end);
319+
}
320+
310321
export function resolveBundleDir(rawPath: string): string {
311322
if (typeof rawPath !== 'string' || rawPath.length === 0) {
312323
throw ApiError.fromEnvelope({
@@ -319,7 +330,7 @@ export function resolveBundleDir(rawPath: string): string {
319330
},
320331
});
321332
}
322-
const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;
333+
const trimmed = stripTrailingSeparators(rawPath);
323334
return isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed);
324335
}
325336

src/lib/credentials.test.ts

Lines changed: 9 additions & 5 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 {
@@ -101,12 +101,16 @@ describe('readCredentialsFile / readProfile', () => {
101101
});
102102
});
103103

104+
const isWin = process.platform === 'win32';
105+
104106
describe('writeProfile', () => {
105107
it('creates the file with mode 0600 and writes the profile', () => {
106108
writeProfile(DEFAULT_PROFILE, { apiKey: 'sk-new' }, { path: credentialsPath });
107109
expect(existsSync(credentialsPath)).toBe(true);
108-
const mode = statSync(credentialsPath).mode & 0o777;
109-
expect(mode).toBe(0o600);
110+
if (!isWin) {
111+
const mode = statSync(credentialsPath).mode & 0o777;
112+
expect(mode).toBe(0o600);
113+
}
110114
expect(readProfile(DEFAULT_PROFILE, { path: credentialsPath })).toEqual({ apiKey: 'sk-new' });
111115
});
112116

@@ -154,7 +158,7 @@ describe('ensureRestrictiveMode', () => {
154158
expect(() => ensureRestrictiveMode(credentialsPath)).not.toThrow();
155159
});
156160

157-
it('downgrades over-permissive modes', () => {
161+
it.skipIf(isWin)('downgrades over-permissive modes', () => {
158162
mkdirSync(tmpRoot, { recursive: true });
159163
writeFileSync(credentialsPath, 'data', { mode: 0o644 });
160164
ensureRestrictiveMode(credentialsPath);
@@ -165,6 +169,6 @@ describe('ensureRestrictiveMode', () => {
165169

166170
describe('defaultCredentialsPath', () => {
167171
it('points at ~/.testsprite/credentials', () => {
168-
expect(defaultCredentialsPath().endsWith('/.testsprite/credentials')).toBe(true);
172+
expect(defaultCredentialsPath()).toBe(join(homedir(), '.testsprite', 'credentials'));
169173
});
170174
});

test/cli.subprocess.test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
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 { execNpm } from './helpers/execNpm.js';
12+
import { existsSync, mkdtempSync, statSync, unlinkSync } from 'node:fs';
1213
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
1314
import { createServer } from 'node:http';
1415
import { tmpdir } from 'node:os';
@@ -37,7 +38,7 @@ beforeAll(async () => {
3738
// existsSync skip we used to do here let `dist` rot under
3839
// refactors and gave false-green on `project list` once
3940
// already.
40-
execFileSync('npm', ['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' });
41+
execNpm(['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' });
4142
server = createServer((req: IncomingMessage, res: ServerResponse) => {
4243
const url = req.url ?? '/';
4344
if (url.startsWith('/api/cli/v1/projects/')) {
@@ -356,13 +357,18 @@ interface SpawnResult {
356357
stderr: string;
357358
}
358359

360+
function isolatedHomeEnv(): Record<string, string> {
361+
// Windows `os.homedir()` reads USERPROFILE, not HOME.
362+
return { HOME: tmpHome, USERPROFILE: tmpHome };
363+
}
364+
359365
function runCli(args: string[], envOverrides: Record<string, string> = {}): Promise<SpawnResult> {
360366
return new Promise((resolveResult, rejectResult) => {
361367
const child = spawn('node', [BIN_PATH, ...args], {
362368
cwd: REPO_ROOT,
363369
env: {
364370
...process.env,
365-
HOME: tmpHome,
371+
...isolatedHomeEnv(),
366372
TESTSPRITE_API_KEY: undefined,
367373
TESTSPRITE_API_URL: undefined,
368374
...envOverrides,
@@ -803,7 +809,9 @@ describe('setup --from-env subprocess', () => {
803809
expect(result.exitCode).toBe(0);
804810
const credentialsPath = join(tmpHome, '.testsprite', 'credentials');
805811
expect(existsSync(credentialsPath)).toBe(true);
806-
expect(statSync(credentialsPath).mode & 0o777).toBe(0o600);
812+
if (process.platform !== 'win32') {
813+
expect(statSync(credentialsPath).mode & 0o777).toBe(0o600);
814+
}
807815
}, 30_000);
808816

809817
it('exits 5 with VALIDATION_ERROR when --from-env is set without TESTSPRITE_API_KEY', async () => {
@@ -934,7 +942,7 @@ describe('--dry-run subprocess smoke', () => {
934942
// skipped the prompt.
935943
const credPath = join(tmpHome, '.testsprite', 'credentials');
936944
// Make sure any previous test didn't leave one behind.
937-
if (existsSync(credPath)) execFileSync('rm', [credPath]);
945+
if (existsSync(credPath)) unlinkSync(credPath);
938946
const result = await runCli(['setup', '--dry-run', '--no-agent', '--output', 'json']);
939947
expect(result.exitCode).toBe(0);
940948
expect(existsSync(credPath)).toBe(false);

test/help.snapshot.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import { execFileSync } from 'node:child_process';
13+
import { execNpm } from './helpers/execNpm.js';
1314
import { dirname, join, resolve } from 'node:path';
1415
import { fileURLToPath } from 'node:url';
1516
import { beforeAll, describe, expect, it } from 'vitest';
@@ -46,7 +47,7 @@ const cases: Array<[string, string[]]> = [
4647

4748
describe('--help snapshots', () => {
4849
beforeAll(() => {
49-
execFileSync('npm', ['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' });
50+
execNpm(['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' });
5051
});
5152

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

test/helpers/execNpm.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { execFileSync } from 'node:child_process';
2+
3+
/** Cross-platform `npm` invocation (Windows needs `shell: true` for `.cmd` shims). */
4+
export function execNpm(
5+
args: string[],
6+
options: { cwd: string; stdio?: 'pipe' | 'inherit' | 'ignore' },
7+
): Buffer | string {
8+
return execFileSync('npm', args, {
9+
...options,
10+
shell: process.platform === 'win32',
11+
});
12+
}

0 commit comments

Comments
 (0)