From 6d3295d114210c974cc4534de9e0b984306f82d9 Mon Sep 17 00:00:00 2001 From: SahilRakhaiya05 Date: Wed, 17 Jun 2026 23:39:51 +0530 Subject: [PATCH 1/2] 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 --- .gitattributes | 1 + src/commands/agent.test.ts | 37 ++++++++++++++++++++++++++++++----- src/lib/agent-targets.test.ts | 2 +- src/lib/bundle.test.ts | 10 +++++----- src/lib/bundle.ts | 13 +++++++++++- src/lib/credentials.test.ts | 14 ++++++++----- test/cli.subprocess.test.ts | 20 +++++++++++++------ test/help.snapshot.test.ts | 3 ++- test/helpers/execNpm.ts | 12 ++++++++++++ 9 files changed, 88 insertions(+), 24 deletions(-) create mode 100644 .gitattributes create mode 100644 test/helpers/execNpm.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index bb558f3..9c9a8f6 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -1,4 +1,10 @@ -import { existsSync, mkdtempSync, readFileSync, symlinkSync } from 'node:fs'; +import { + existsSync, + mkdtempSync, + readFileSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; @@ -13,6 +19,21 @@ import { import type { AgentDeps, AgentFs, InstallResult, ListResult } from './agent.js'; import { AGENTS_MD_CODEX_BUDGET_BYTES, createAgentCommand, runInstall, runList } from './agent.js'; +/** Windows requires Developer Mode or elevation to create symlinks. */ +function canCreateSymlinks(): boolean { + try { + const probeRoot = mkdtempSync(path.join(tmpdir(), 'agent-symlink-probe-')); + const target = path.join(probeRoot, 'target.txt'); + writeFileSync(target, 'probe'); + symlinkSync(target, path.join(probeRoot, 'link.txt'), 'file'); + return true; + } catch { + return false; + } +} + +const symlinkCapable = canCreateSymlinks(); + // --------------------------------------------------------------------------- // In-memory AgentFs backed by a Map // --------------------------------------------------------------------------- @@ -1031,7 +1052,9 @@ describe('runInstall — default AgentFs (real disk)', () => { expect(readFileSync(abs, 'utf8')).toBe(content); }); - it('refuses to write through a symlinked parent dir (real disk) — exit 5', async () => { + it.skipIf(!symlinkCapable)( + 'refuses to write through a symlinked parent dir (real disk) — exit 5', + async () => { const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-parent-')); const outside = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-')); // `.claude` is a real symlink to a directory outside the project root. @@ -1060,9 +1083,12 @@ describe('runInstall — default AgentFs (real disk)', () => { expect((thrown as CLIError).exitCode).toBe(5); // Nothing was created through the symlink, outside --dir. expect(existsSync(path.join(outside, 'skills'))).toBe(false); - }); + }, + ); - it('refuses to overwrite a symlinked target file (real disk) with --force — exit 5', async () => { + it.skipIf(!symlinkCapable)( + 'refuses to overwrite a symlinked target file (real disk) with --force — exit 5', + async () => { const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-target-')); const outsideDir = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-target-')); const { path: relPath } = renderForTarget('claude'); @@ -1097,7 +1123,8 @@ describe('runInstall — default AgentFs (real disk)', () => { expect((thrown as CLIError).exitCode).toBe(5); // The outside file was NOT overwritten (nor clobbered via the .bak path). expect(readFileSync(outsideFile, 'utf8')).toBe('SECRET'); - }); + }, + ); }); // --------------------------------------------------------------------------- diff --git a/src/lib/agent-targets.test.ts b/src/lib/agent-targets.test.ts index 2a2262c..91bf31d 100644 --- a/src/lib/agent-targets.test.ts +++ b/src/lib/agent-targets.test.ts @@ -34,7 +34,7 @@ function parseFrontmatterDescription(content: string): string | undefined { } } if (inFrontmatter && line.startsWith('description: ')) { - return line.slice('description: '.length); + return line.slice('description: '.length).replace(/\r$/, ''); } } return undefined; diff --git a/src/lib/bundle.test.ts b/src/lib/bundle.test.ts index 7319322..0b9a59e 100644 --- a/src/lib/bundle.test.ts +++ b/src/lib/bundle.test.ts @@ -10,7 +10,7 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { applyFailedOnly, @@ -588,13 +588,13 @@ describe('resolveBundleDir', () => { it('resolves a relative path against cwd', () => { const out = resolveBundleDir('./tmp/x'); - expect(out.endsWith('/tmp/x')).toBe(true); - expect(out.startsWith('/')).toBe(true); + expect(out).toBe(resolve(process.cwd(), 'tmp', 'x')); }); it('strips a trailing slash', () => { - const out = resolveBundleDir('/tmp/x/'); - expect(out).toBe('/tmp/x'); + const base = resolve(process.cwd(), 'tmp', 'x'); + const trailing = process.platform === 'win32' ? `${base}\\` : `${base}/`; + expect(resolveBundleDir(trailing)).toBe(base); }); }); diff --git a/src/lib/bundle.ts b/src/lib/bundle.ts index ff64f66..18206ff 100644 --- a/src/lib/bundle.ts +++ b/src/lib/bundle.ts @@ -312,6 +312,17 @@ export function applyFailedOnly(ctx: CliFailureContext): CliFailureContext { * its `.tmp` child — `writeBundle` mkdir's after the integrity check * passes so a forged response never modifies the operator's filesystem. */ +function stripTrailingSeparators(rawPath: string): string { + if (rawPath.length <= 1) return rawPath; + let end = rawPath.length; + while (end > 1 && (rawPath[end - 1] === '/' || rawPath[end - 1] === '\\')) { + // Preserve Windows drive roots (e.g. `C:\`). + if (end === 3 && rawPath[1] === ':' && /[A-Za-z]/.test(rawPath[0]!)) break; + end--; + } + return rawPath.slice(0, end); +} + export function resolveBundleDir(rawPath: string): string { if (typeof rawPath !== 'string' || rawPath.length === 0) { throw ApiError.fromEnvelope({ @@ -324,7 +335,7 @@ export function resolveBundleDir(rawPath: string): string { }, }); } - const trimmed = rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath; + const trimmed = stripTrailingSeparators(rawPath); return isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed); } diff --git a/src/lib/credentials.test.ts b/src/lib/credentials.test.ts index ac038f3..ef751fb 100644 --- a/src/lib/credentials.test.ts +++ b/src/lib/credentials.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { @@ -103,12 +103,16 @@ describe('readCredentialsFile / readProfile', () => { }); }); +const isWin = process.platform === 'win32'; + describe('writeProfile', () => { it('creates the file with mode 0600 and writes the profile', () => { writeProfile(DEFAULT_PROFILE, { apiKey: 'sk-new' }, { path: credentialsPath }); expect(existsSync(credentialsPath)).toBe(true); - const mode = statSync(credentialsPath).mode & 0o777; - expect(mode).toBe(0o600); + if (!isWin) { + const mode = statSync(credentialsPath).mode & 0o777; + expect(mode).toBe(0o600); + } expect(readProfile(DEFAULT_PROFILE, { path: credentialsPath })).toEqual({ apiKey: 'sk-new' }); }); @@ -156,7 +160,7 @@ describe('ensureRestrictiveMode', () => { expect(() => ensureRestrictiveMode(credentialsPath)).not.toThrow(); }); - it('downgrades over-permissive modes', () => { + it.skipIf(isWin)('downgrades over-permissive modes', () => { mkdirSync(tmpRoot, { recursive: true }); writeFileSync(credentialsPath, 'data', { mode: 0o644 }); ensureRestrictiveMode(credentialsPath); @@ -167,7 +171,7 @@ describe('ensureRestrictiveMode', () => { describe('defaultCredentialsPath', () => { it('points at ~/.testsprite/credentials', () => { - expect(defaultCredentialsPath().endsWith('/.testsprite/credentials')).toBe(true); + expect(defaultCredentialsPath()).toBe(join(homedir(), '.testsprite', 'credentials')); }); }); diff --git a/test/cli.subprocess.test.ts b/test/cli.subprocess.test.ts index 95521a3..d56d6a3 100644 --- a/test/cli.subprocess.test.ts +++ b/test/cli.subprocess.test.ts @@ -7,8 +7,9 @@ * and runs `auth whoami` against the mock." */ -import { execFileSync, spawn } from 'node:child_process'; -import { existsSync, mkdtempSync, statSync } from 'node:fs'; +import { spawn } from 'node:child_process'; +import { execNpm } from './helpers/execNpm.js'; +import { existsSync, mkdtempSync, statSync, unlinkSync } from 'node:fs'; import type { IncomingMessage, Server, ServerResponse } from 'node:http'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; @@ -37,7 +38,7 @@ beforeAll(async () => { // existsSync skip we used to do here let `dist` rot under // refactors and gave false-green on `project list` once // already. - execFileSync('npm', ['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' }); + execNpm(['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' }); server = createServer((req: IncomingMessage, res: ServerResponse) => { const url = req.url ?? '/'; if (url.startsWith('/api/cli/v1/projects/')) { @@ -356,13 +357,18 @@ interface SpawnResult { stderr: string; } +function isolatedHomeEnv(): Record { + // Windows `os.homedir()` reads USERPROFILE, not HOME. + return { HOME: tmpHome, USERPROFILE: tmpHome }; +} + function runCli(args: string[], envOverrides: Record = {}): Promise { return new Promise((resolveResult, rejectResult) => { const child = spawn('node', [BIN_PATH, ...args], { cwd: REPO_ROOT, env: { ...process.env, - HOME: tmpHome, + ...isolatedHomeEnv(), TESTSPRITE_API_KEY: undefined, TESTSPRITE_API_URL: undefined, ...envOverrides, @@ -849,7 +855,9 @@ describe('setup --from-env subprocess', () => { expect(result.exitCode).toBe(0); const credentialsPath = join(tmpHome, '.testsprite', 'credentials'); expect(existsSync(credentialsPath)).toBe(true); - expect(statSync(credentialsPath).mode & 0o777).toBe(0o600); + if (process.platform !== 'win32') { + expect(statSync(credentialsPath).mode & 0o777).toBe(0o600); + } }, 30_000); 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', () => { // skipped the prompt. const credPath = join(tmpHome, '.testsprite', 'credentials'); // Make sure any previous test didn't leave one behind. - if (existsSync(credPath)) execFileSync('rm', [credPath]); + if (existsSync(credPath)) unlinkSync(credPath); const result = await runCli(['setup', '--dry-run', '--no-agent', '--output', 'json']); expect(result.exitCode).toBe(0); expect(existsSync(credPath)).toBe(false); diff --git a/test/help.snapshot.test.ts b/test/help.snapshot.test.ts index 2448c91..a616d8d 100644 --- a/test/help.snapshot.test.ts +++ b/test/help.snapshot.test.ts @@ -10,6 +10,7 @@ */ import { execFileSync } from 'node:child_process'; +import { execNpm } from './helpers/execNpm.js'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -46,7 +47,7 @@ const cases: Array<[string, string[]]> = [ describe('--help snapshots', () => { beforeAll(() => { - execFileSync('npm', ['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' }); + execNpm(['run', 'build'], { cwd: REPO_ROOT, stdio: 'pipe' }); }); for (const [name, args] of cases) { diff --git a/test/helpers/execNpm.ts b/test/helpers/execNpm.ts new file mode 100644 index 0000000..7a56f84 --- /dev/null +++ b/test/helpers/execNpm.ts @@ -0,0 +1,12 @@ +import { execFileSync } from 'node:child_process'; + +/** Cross-platform `npm` invocation (Windows needs `shell: true` for `.cmd` shims). */ +export function execNpm( + args: string[], + options: { cwd: string; stdio?: 'pipe' | 'inherit' | 'ignore' }, +): Buffer | string { + return execFileSync('npm', args, { + ...options, + shell: process.platform === 'win32', + }); +} From b508bd68a22633551ff13fef56a14e0934567f41 Mon Sep 17 00:00:00 2001 From: SahilRakhaiya05 Date: Fri, 26 Jun 2026 15:22:22 +0530 Subject: [PATCH 2/2] style: fix Prettier formatting in agent.test.ts --- src/commands/agent.test.ts | 128 ++++++++++++++++++------------------- 1 file changed, 61 insertions(+), 67 deletions(-) diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 9c9a8f6..83c2102 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -1,10 +1,4 @@ -import { - existsSync, - mkdtempSync, - readFileSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; @@ -1055,74 +1049,74 @@ describe('runInstall — default AgentFs (real disk)', () => { it.skipIf(!symlinkCapable)( 'refuses to write through a symlinked parent dir (real disk) — exit 5', async () => { - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-parent-')); - const outside = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-')); - // `.claude` is a real symlink to a directory outside the project root. - symlinkSync(outside, path.join(tmpRoot, '.claude'), 'dir'); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - force: false, - dir: tmpRoot, - }, - { ...deps }, - ); - } catch (err) { - thrown = err; - } + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-parent-')); + const outside = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-')); + // `.claude` is a real symlink to a directory outside the project root. + symlinkSync(outside, path.join(tmpRoot, '.claude'), 'dir'); + const { deps } = makeCapture(); + + let thrown: unknown; + try { + await runInstall( + { + profile: 'default', + output: 'text', + debug: false, + dryRun: false, + target: ['claude'], + force: false, + dir: tmpRoot, + }, + { ...deps }, + ); + } catch (err) { + thrown = err; + } - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - // Nothing was created through the symlink, outside --dir. - expect(existsSync(path.join(outside, 'skills'))).toBe(false); + expect(thrown).toBeInstanceOf(CLIError); + expect((thrown as CLIError).exitCode).toBe(5); + // Nothing was created through the symlink, outside --dir. + expect(existsSync(path.join(outside, 'skills'))).toBe(false); }, ); it.skipIf(!symlinkCapable)( 'refuses to overwrite a symlinked target file (real disk) with --force — exit 5', async () => { - const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-target-')); - const outsideDir = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-target-')); - const { path: relPath } = renderForTarget('claude'); - const abs = path.resolve(tmpRoot, relPath); - const nodeFs = await import('node:fs/promises'); - await nodeFs.mkdir(path.dirname(abs), { recursive: true }); - // SKILL.md is a real symlink to a file outside the project root. - const outsideFile = path.join(outsideDir, 'secret.txt'); - await nodeFs.writeFile(outsideFile, 'SECRET', 'utf8'); - symlinkSync(outsideFile, abs, 'file'); - const { deps } = makeCapture(); - - let thrown: unknown; - try { - await runInstall( - { - profile: 'default', - output: 'text', - debug: false, - dryRun: false, - target: ['claude'], - force: true, - dir: tmpRoot, - }, - { ...deps }, - ); - } catch (err) { - thrown = err; - } + const tmpRoot = mkdtempSync(path.join(tmpdir(), 'agent-test-symlink-target-')); + const outsideDir = mkdtempSync(path.join(tmpdir(), 'agent-test-outside-target-')); + const { path: relPath } = renderForTarget('claude'); + const abs = path.resolve(tmpRoot, relPath); + const nodeFs = await import('node:fs/promises'); + await nodeFs.mkdir(path.dirname(abs), { recursive: true }); + // SKILL.md is a real symlink to a file outside the project root. + const outsideFile = path.join(outsideDir, 'secret.txt'); + await nodeFs.writeFile(outsideFile, 'SECRET', 'utf8'); + symlinkSync(outsideFile, abs, 'file'); + const { deps } = makeCapture(); + + let thrown: unknown; + try { + await runInstall( + { + profile: 'default', + output: 'text', + debug: false, + dryRun: false, + target: ['claude'], + force: true, + dir: tmpRoot, + }, + { ...deps }, + ); + } catch (err) { + thrown = err; + } - expect(thrown).toBeInstanceOf(CLIError); - expect((thrown as CLIError).exitCode).toBe(5); - // The outside file was NOT overwritten (nor clobbered via the .bak path). - expect(readFileSync(outsideFile, 'utf8')).toBe('SECRET'); + expect(thrown).toBeInstanceOf(CLIError); + expect((thrown as CLIError).exitCode).toBe(5); + // The outside file was NOT overwritten (nor clobbered via the .bak path). + expect(readFileSync(outsideFile, 'utf8')).toBe('SECRET'); }, ); });