-
Notifications
You must be signed in to change notification settings - Fork 114
Recovered: test: make the Windows CLI test harness portable (#207 by @Yazan-O) #250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # Candidate Improvements | ||
|
|
||
| ## Candidates | ||
|
|
||
| 1. Picked: make the test suite portable on Windows. | ||
| 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`. | ||
|
|
||
| 2. `doctor` bypasses the shared `--output` validator. | ||
| 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`. | ||
|
|
||
| 3. Empty `TESTSPRITE_PROFILE` is not normalized like the other env vars. | ||
| 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`. | ||
|
|
||
| 4. `--password-file` strips leading and trailing spaces from passwords. | ||
| 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. | ||
|
|
||
| 5. `resolveBundleDir` trims only POSIX trailing slashes. | ||
| 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 `\\`. | ||
|
|
||
| ## Picked Rationale | ||
|
|
||
| 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. | ||
|
|
||
| ## Diff Summary | ||
|
|
||
| - Added `test/helpers/npm.ts` so subprocess-style tests run `npm run build` through the current npm entrypoint, with a Windows `.cmd` fallback. | ||
| - 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. | ||
| - Made permission-bit assertions POSIX-only where Node/Windows cannot represent `0o600` reliably. | ||
| - Made path and CRLF-sensitive test assertions separator/line-ending neutral. | ||
|
|
||
| ## Validation | ||
|
|
||
| - `npx -y -p node@22 -p npm@10 npm test` - passed, 50 files / 1846 tests. | ||
| - `npx -y -p node@22 -p npm@10 npm run lint:fix` - passed. | ||
| - `npx -y -p node@22 -p npm@10 npm run typecheck` - passed. | ||
| - `npx -y -p node@22 -p npm@10 npm run build` - passed. | ||
|
|
||
| ## PR Title | ||
|
|
||
| test: make Windows CLI test harness portable | ||
|
|
||
| ## PR Body | ||
|
|
||
| Summary: | ||
| - make path and CRLF-sensitive tests portable across Windows and POSIX | ||
| - isolate subprocess tests from the real Windows user profile and inherited TestSprite env vars | ||
| - run subprocess build setup through the active npm entrypoint instead of assuming `npm` is directly spawnable | ||
|
|
||
| Tests: | ||
| - `npm test` | ||
| - `npm run lint:fix` | ||
| - `npm run typecheck` | ||
| - `npm run build` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,14 +7,15 @@ | |
| * 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 { existsSync, mkdtempSync, renameSync, statSync } from 'node:fs'; | ||
| import type { IncomingMessage, Server, ServerResponse } from 'node:http'; | ||
| import { createServer } from 'node:http'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { dirname, join, resolve } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | ||
| import { runNpmScript } from './helpers/npm.js'; | ||
|
|
||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
| const REPO_ROOT = resolve(__dirname, '..'); | ||
|
|
@@ -31,13 +32,18 @@ let server: Server; | |
| let baseUrl: string; | ||
| let tmpHome: string; | ||
|
|
||
| function expectPosixMode(path: string, expected: number): void { | ||
| if (process.platform === 'win32') return; | ||
| expect(statSync(path).mode & 0o777).toBe(expected); | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| // Always rebuild — `npm run build` is fast and a stale `dist/index.js` | ||
| // would silently mask ESM/import regressions in this suite. The | ||
| // 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' }); | ||
| runNpmScript('build', REPO_ROOT); | ||
| server = createServer((req: IncomingMessage, res: ServerResponse) => { | ||
| const url = req.url ?? '/'; | ||
| if (url.startsWith('/api/cli/v1/projects/')) { | ||
|
|
@@ -349,7 +355,7 @@ beforeAll(async () => { | |
| }, 60_000); | ||
|
|
||
| afterAll(async () => { | ||
| await new Promise<void>(resolveClose => server.close(() => resolveClose())); | ||
| if (server) await new Promise<void>(resolveClose => server.close(() => resolveClose())); | ||
| }); | ||
|
|
||
| interface SpawnResult { | ||
|
|
@@ -362,13 +368,7 @@ function runCli(args: string[], envOverrides: Record<string, string> = {}): Prom | |
| return new Promise((resolveResult, rejectResult) => { | ||
| const child = spawn('node', [BIN_PATH, ...args], { | ||
| cwd: REPO_ROOT, | ||
| env: { | ||
| ...process.env, | ||
| HOME: tmpHome, | ||
| TESTSPRITE_API_KEY: undefined, | ||
| TESTSPRITE_API_URL: undefined, | ||
| ...envOverrides, | ||
| } as NodeJS.ProcessEnv, | ||
| env: childEnv(envOverrides), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Scrub every inherited This loop removes only const env = { ...process.env };
for (const key of Object.keys(env)) {
- if (key.toUpperCase() === 'TESTSPRITE_API_KEY' || key.toUpperCase() === 'TESTSPRITE_API_URL') {
+ if (key.toUpperCase().startsWith('TESTSPRITE_')) {
delete env[key];
}
}As per path instructions, subprocess tests must remove inherited Also applies to: 382-388 🤖 Prompt for AI AgentsSource: Path instructions |
||
| }); | ||
| let stdout = ''; | ||
| let stderr = ''; | ||
|
|
@@ -379,6 +379,21 @@ function runCli(args: string[], envOverrides: Record<string, string> = {}): Prom | |
| }); | ||
| } | ||
|
|
||
| function childEnv(envOverrides: Record<string, string>): NodeJS.ProcessEnv { | ||
| const env = { ...process.env }; | ||
| for (const key of Object.keys(env)) { | ||
| if (key.toUpperCase() === 'TESTSPRITE_API_KEY' || key.toUpperCase() === 'TESTSPRITE_API_URL') { | ||
| delete env[key]; | ||
| } | ||
| } | ||
| return { | ||
| ...env, | ||
| HOME: tmpHome, | ||
| USERPROFILE: tmpHome, | ||
| ...envOverrides, | ||
| }; | ||
| } | ||
|
|
||
| describe('auth status subprocess (+ deprecated whoami alias)', () => { | ||
| it('prints JSON me and exits 0 against the local server', async () => { | ||
| const result = await runCli(['auth', 'status', '--output', 'json'], { | ||
|
|
@@ -897,7 +912,7 @@ 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); | ||
| expectPosixMode(credentialsPath, 0o600); | ||
| }, 30_000); | ||
|
|
||
| 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', () => { | |
| // 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)) renameSync(credPath, `${credPath}.bak-${process.pid}-${Date.now()}`); | ||
| const result = await runCli(['setup', '--dry-run', '--no-agent', '--output', 'json']); | ||
| expect(result.exitCode).toBe(0); | ||
| expect(existsSync(credPath)).toBe(false); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { execFileSync } from 'node:child_process'; | ||
|
|
||
| export function runNpmScript(script: string, cwd: string): void { | ||
| const npmExecPath = process.env.npm_execpath; | ||
| if (npmExecPath) { | ||
| execFileSync(process.execPath, [npmExecPath, 'run', script], { cwd, stdio: 'pipe' }); | ||
| return; | ||
| } | ||
| execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', script], { | ||
| cwd, | ||
| stdio: 'pipe', | ||
| }); | ||
|
Comment on lines
+9
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
node <<'NODE'
const { execFileSync } = require('node:child_process');
if (process.platform === 'win32') {
execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'npm.cmd', '--version'], {
stdio: 'inherit',
});
}
NODERepository: TestSprite/testsprite-cli Length of output: 163 Use
🧰 Tools🪛 ast-grep (0.44.1)[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec. (detect-child-process-typescript) 🤖 Prompt for AI AgentsSource: MCP tools |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add a deterministic CRLF regression case.
The parser change is correct, but the current fixture-based coverage may use LF files and never prove CRLF handling. Add an assertion using input such as
---\r\ndescription: ...\r\n---\r\n.As per path instructions, tests must cover new behavior deterministically and offline.
🤖 Prompt for AI Agents
Source: Path instructions