Skip to content

Commit 6d3295d

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 15e95de commit 6d3295d

9 files changed

Lines changed: 88 additions & 24 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 & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import { mkdtempSync } 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,
@@ -588,13 +588,13 @@ describe('resolveBundleDir', () => {
588588

589589
it('resolves a relative path against cwd', () => {
590590
const out = resolveBundleDir('./tmp/x');
591-
expect(out.endsWith('/tmp/x')).toBe(true);
592-
expect(out.startsWith('/')).toBe(true);
591+
expect(out).toBe(resolve(process.cwd(), 'tmp', 'x'));
593592
});
594593

595594
it('strips a trailing slash', () => {
596-
const out = resolveBundleDir('/tmp/x/');
597-
expect(out).toBe('/tmp/x');
595+
const base = resolve(process.cwd(), 'tmp', 'x');
596+
const trailing = process.platform === 'win32' ? `${base}\\` : `${base}/`;
597+
expect(resolveBundleDir(trailing)).toBe(base);
598598
});
599599
});
600600

src/lib/bundle.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,17 @@ export function applyFailedOnly(ctx: CliFailureContext): CliFailureContext {
312312
* its `.tmp` child — `writeBundle` mkdir's after the integrity check
313313
* passes so a forged response never modifies the operator's filesystem.
314314
*/
315+
function stripTrailingSeparators(rawPath: string): string {
316+
if (rawPath.length <= 1) return rawPath;
317+
let end = rawPath.length;
318+
while (end > 1 && (rawPath[end - 1] === '/' || rawPath[end - 1] === '\\')) {
319+
// Preserve Windows drive roots (e.g. `C:\`).
320+
if (end === 3 && rawPath[1] === ':' && /[A-Za-z]/.test(rawPath[0]!)) break;
321+
end--;
322+
}
323+
return rawPath.slice(0, end);
324+
}
325+
315326
export function resolveBundleDir(rawPath: string): string {
316327
if (typeof rawPath !== 'string' || rawPath.length === 0) {
317328
throw ApiError.fromEnvelope({
@@ -324,7 +335,7 @@ export function resolveBundleDir(rawPath: string): string {
324335
},
325336
});
326337
}
327-
const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath;
338+
const trimmed = stripTrailingSeparators(rawPath);
328339
return isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed);
329340
}
330341

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 {
@@ -103,12 +103,16 @@ describe('readCredentialsFile / readProfile', () => {
103103
});
104104
});
105105

106+
const isWin = process.platform === 'win32';
107+
106108
describe('writeProfile', () => {
107109
it('creates the file with mode 0600 and writes the profile', () => {
108110
writeProfile(DEFAULT_PROFILE, { apiKey: 'sk-new' }, { path: credentialsPath });
109111
expect(existsSync(credentialsPath)).toBe(true);
110-
const mode = statSync(credentialsPath).mode & 0o777;
111-
expect(mode).toBe(0o600);
112+
if (!isWin) {
113+
const mode = statSync(credentialsPath).mode & 0o777;
114+
expect(mode).toBe(0o600);
115+
}
112116
expect(readProfile(DEFAULT_PROFILE, { path: credentialsPath })).toEqual({ apiKey: 'sk-new' });
113117
});
114118

@@ -156,7 +160,7 @@ describe('ensureRestrictiveMode', () => {
156160
expect(() => ensureRestrictiveMode(credentialsPath)).not.toThrow();
157161
});
158162

159-
it('downgrades over-permissive modes', () => {
163+
it.skipIf(isWin)('downgrades over-permissive modes', () => {
160164
mkdirSync(tmpRoot, { recursive: true });
161165
writeFileSync(credentialsPath, 'data', { mode: 0o644 });
162166
ensureRestrictiveMode(credentialsPath);
@@ -167,7 +171,7 @@ describe('ensureRestrictiveMode', () => {
167171

168172
describe('defaultCredentialsPath', () => {
169173
it('points at ~/.testsprite/credentials', () => {
170-
expect(defaultCredentialsPath().endsWith('/.testsprite/credentials')).toBe(true);
174+
expect(defaultCredentialsPath()).toBe(join(homedir(), '.testsprite', 'credentials'));
171175
});
172176
});
173177

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,
@@ -849,7 +855,9 @@ describe('setup --from-env subprocess', () => {
849855
expect(result.exitCode).toBe(0);
850856
const credentialsPath = join(tmpHome, '.testsprite', 'credentials');
851857
expect(existsSync(credentialsPath)).toBe(true);
852-
expect(statSync(credentialsPath).mode & 0o777).toBe(0o600);
858+
if (process.platform !== 'win32') {
859+
expect(statSync(credentialsPath).mode & 0o777).toBe(0o600);
860+
}
853861
}, 30_000);
854862

855863
it('exits 5 with VALIDATION_ERROR when --from-env is set without TESTSPRITE_API_KEY', async () => {
@@ -980,7 +988,7 @@ describe('--dry-run subprocess smoke', () => {
980988
// skipped the prompt.
981989
const credPath = join(tmpHome, '.testsprite', 'credentials');
982990
// Make sure any previous test didn't leave one behind.
983-
if (existsSync(credPath)) execFileSync('rm', [credPath]);
991+
if (existsSync(credPath)) unlinkSync(credPath);
984992
const result = await runCli(['setup', '--dry-run', '--no-agent', '--output', 'json']);
985993
expect(result.exitCode).toBe(0);
986994
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)