Skip to content

Commit fa8894b

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 e53257d commit fa8894b

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';
@@ -17,6 +23,21 @@ import {
1723
import type { AgentDeps, AgentFs, InstallResult, ListResult } from './agent.js';
1824
import { AGENTS_MD_CODEX_BUDGET_BYTES, createAgentCommand, runInstall, runList } from './agent.js';
1925

26+
/** Windows requires Developer Mode or elevation to create symlinks. */
27+
function canCreateSymlinks(): boolean {
28+
try {
29+
const probeRoot = mkdtempSync(path.join(tmpdir(), 'agent-symlink-probe-'));
30+
const target = path.join(probeRoot, 'target.txt');
31+
writeFileSync(target, 'probe');
32+
symlinkSync(target, path.join(probeRoot, 'link.txt'), 'file');
33+
return true;
34+
} catch {
35+
return false;
36+
}
37+
}
38+
39+
const symlinkCapable = canCreateSymlinks();
40+
2041
// ---------------------------------------------------------------------------
2142
// In-memory AgentFs backed by a Map
2243
// ---------------------------------------------------------------------------
@@ -1085,7 +1106,9 @@ describe('runInstall — default AgentFs (real disk)', () => {
10851106
expect(readFileSync(abs, 'utf8')).toBe(content);
10861107
});
10871108

1088-
it('refuses to write through a symlinked parent dir (real disk) — exit 5', async () => {
1109+
it.skipIf(!symlinkCapable)(
1110+
'refuses to write through a symlinked parent dir (real disk) — exit 5',
1111+
async () => {
10891112
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-parent-'));
10901113
const outside = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-'));
10911114
// `.claude` is a real symlink to a directory outside the project root.
@@ -1115,9 +1138,12 @@ describe('runInstall — default AgentFs (real disk)', () => {
11151138
expect((thrown as CLIError).exitCode).toBe(5);
11161139
// Nothing was created through the symlink, outside --dir.
11171140
expect(existsSync(path.join(outside, 'skills'))).toBe(false);
1118-
});
1141+
},
1142+
);
11191143

1120-
it('refuses to overwrite a symlinked target file (real disk) with --force — exit 5', async () => {
1144+
it.skipIf(!symlinkCapable)(
1145+
'refuses to overwrite a symlinked target file (real disk) with --force — exit 5',
1146+
async () => {
11211147
const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-target-'));
11221148
const outsideDir = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-target-'));
11231149
const { path: relPath } = renderForTarget('claude', 'testsprite-verify');
@@ -1153,7 +1179,8 @@ describe('runInstall — default AgentFs (real disk)', () => {
11531179
expect((thrown as CLIError).exitCode).toBe(5);
11541180
// The outside file was NOT overwritten (nor clobbered via the .bak path).
11551181
expect(readFileSync(outsideFile, 'utf8')).toBe('SECRET');
1156-
});
1182+
},
1183+
);
11571184
});
11581185

11591186
// ---------------------------------------------------------------------------

src/lib/agent-targets.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ function parseFrontmatterDescription(content: string): string | undefined {
4141
}
4242
}
4343
if (inFrontmatter && line.startsWith('description: ')) {
44-
return line.slice('description: '.length);
44+
return line.slice('description: '.length).replace(/\r$/, '');
4545
}
4646
}
4747
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 { existsSync, 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,
@@ -592,13 +592,13 @@ describe('resolveBundleDir', () => {
592592

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

599598
it('strips a trailing slash', () => {
600-
const out = resolveBundleDir('/tmp/x/');
601-
expect(out).toBe('/tmp/x');
599+
const base = resolve(process.cwd(), 'tmp', 'x');
600+
const trailing = process.platform === 'win32' ? `${base}\\` : `${base}/`;
601+
expect(resolveBundleDir(trailing)).toBe(base);
602602
});
603603
});
604604

src/lib/bundle.ts

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

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 {
@@ -135,12 +135,16 @@ describe('readCredentialsFile / readProfile', () => {
135135
});
136136
});
137137

138+
const isWin = process.platform === 'win32';
139+
138140
describe('writeProfile', () => {
139141
it('creates the file with mode 0600 and writes the profile', () => {
140142
writeProfile(DEFAULT_PROFILE, { apiKey: 'sk-new' }, { path: credentialsPath });
141143
expect(existsSync(credentialsPath)).toBe(true);
142-
const mode = statSync(credentialsPath).mode & 0o777;
143-
expect(mode).toBe(0o600);
144+
if (!isWin) {
145+
const mode = statSync(credentialsPath).mode & 0o777;
146+
expect(mode).toBe(0o600);
147+
}
144148
expect(readProfile(DEFAULT_PROFILE, { path: credentialsPath })).toEqual({ apiKey: 'sk-new' });
145149
});
146150

@@ -188,7 +192,7 @@ describe('ensureRestrictiveMode', () => {
188192
expect(() => ensureRestrictiveMode(credentialsPath)).not.toThrow();
189193
});
190194

191-
it('downgrades over-permissive modes', () => {
195+
it.skipIf(isWin)('downgrades over-permissive modes', () => {
192196
mkdirSync(tmpRoot, { recursive: true });
193197
writeFileSync(credentialsPath, 'data', { mode: 0o644 });
194198
ensureRestrictiveMode(credentialsPath);
@@ -199,7 +203,7 @@ describe('ensureRestrictiveMode', () => {
199203

200204
describe('defaultCredentialsPath', () => {
201205
it('points at ~/.testsprite/credentials', () => {
202-
expect(defaultCredentialsPath().endsWith('/.testsprite/credentials')).toBe(true);
206+
expect(defaultCredentialsPath()).toBe(join(homedir(), '.testsprite', 'credentials'));
203207
});
204208
});
205209

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/')) {
@@ -358,13 +359,18 @@ interface SpawnResult {
358359
stderr: string;
359360
}
360361

362+
function isolatedHomeEnv(): Record<string, string> {
363+
// Windows `os.homedir()` reads USERPROFILE, not HOME.
364+
return { HOME: tmpHome, USERPROFILE: tmpHome };
365+
}
366+
361367
function runCli(args: string[], envOverrides: Record<string, string> = {}): Promise<SpawnResult> {
362368
return new Promise((resolveResult, rejectResult) => {
363369
const child = spawn('node', [BIN_PATH, ...args], {
364370
cwd: REPO_ROOT,
365371
env: {
366372
...process.env,
367-
HOME: tmpHome,
373+
...isolatedHomeEnv(),
368374
TESTSPRITE_API_KEY: undefined,
369375
TESTSPRITE_API_URL: undefined,
370376
...envOverrides,
@@ -897,7 +903,9 @@ describe('setup --from-env subprocess', () => {
897903
expect(result.exitCode).toBe(0);
898904
const credentialsPath = join(tmpHome, '.testsprite', 'credentials');
899905
expect(existsSync(credentialsPath)).toBe(true);
900-
expect(statSync(credentialsPath).mode & 0o777).toBe(0o600);
906+
if (process.platform !== 'win32') {
907+
expect(statSync(credentialsPath).mode & 0o777).toBe(0o600);
908+
}
901909
}, 30_000);
902910

903911
it('exits 5 with VALIDATION_ERROR when --from-env is set without TESTSPRITE_API_KEY', async () => {
@@ -1044,7 +1052,7 @@ describe('--dry-run subprocess smoke', () => {
10441052
// skipped the prompt.
10451053
const credPath = join(tmpHome, '.testsprite', 'credentials');
10461054
// Make sure any previous test didn't leave one behind.
1047-
if (existsSync(credPath)) execFileSync('rm', [credPath]);
1055+
if (existsSync(credPath)) unlinkSync(credPath);
10481056
const result = await runCli(['setup', '--dry-run', '--no-agent', '--output', 'json']);
10491057
expect(result.exitCode).toBe(0);
10501058
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)