diff --git a/.prettierignore b/.prettierignore index 32405c6..5380627 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ pnpm-lock.yaml dist/ node_modules/ CHANGELOG.md +CLAUDE.md diff --git a/__tests__/e2e/system-check-failure.e2e.ts b/__tests__/e2e/system-check-failure.e2e.ts index 9defccf..be58b24 100644 --- a/__tests__/e2e/system-check-failure.e2e.ts +++ b/__tests__/e2e/system-check-failure.e2e.ts @@ -1,4 +1,5 @@ import { createSession } from './testing-framework/index.js'; +import { IS_WINDOWS } from './testing-framework/env.js'; import { dirname } from 'node:path'; describe('when system check fails', () => { @@ -18,8 +19,13 @@ describe('when system check fails', () => { expect(session.snapshot()).toMatchSnapshot('system-check-failure'); }); - it('shows error when node is not on PATH', async () => { - // PATH with git but not node — CLI still runs via absolute path + /** + * @todo Make this test work on Windows. It currently relies on `/usr/bin` + * as a PATH that contains git but not node, which has no direct Windows + * equivalent. Constructing one portably is non-trivial because git's + * install location varies across Windows setups. + */ + it.skipIf(IS_WINDOWS)('shows error when node is not on PATH', async () => { using session = createSession({ systemPath: '/usr/bin' }); await session.waitForText('Start setup'); diff --git a/__tests__/e2e/testing-framework/env.ts b/__tests__/e2e/testing-framework/env.ts index 4055bff..3678749 100644 --- a/__tests__/e2e/testing-framework/env.ts +++ b/__tests__/e2e/testing-framework/env.ts @@ -1,5 +1,8 @@ export { AUTH_CALLBACK_PORT } from '@lib/auth.js'; +/** `true` when the test suite is running on Windows. */ +export const IS_WINDOWS = process.platform === 'win32'; + /** * Baseline environment variables injected into every e2e terminal session. * diff --git a/__tests__/e2e/testing-framework/mocks/binaries/fs.ts b/__tests__/e2e/testing-framework/mocks/binaries/fs.ts new file mode 100644 index 0000000..86340dc --- /dev/null +++ b/__tests__/e2e/testing-framework/mocks/binaries/fs.ts @@ -0,0 +1,21 @@ +import { writeFileSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { IS_WINDOWS } from '../../env.js'; + +export const writeMockBinary = IS_WINDOWS ? writeWindowsBinary : writeUnixBinary; + +function writeUnixBinary(dir: string, name: string, script: string): void { + const filePath = join(dir, name); + writeFileSync(filePath, script, 'utf-8'); + chmodSync(filePath, 0o755); +} + +function writeWindowsBinary(dir: string, name: string, script: string): void { + const jsPath = join(dir, `${name}.js`); + writeFileSync(jsPath, script.replace(/^#!.*\n/, ''), 'utf-8'); + writeFileSync(join(dir, `${name}.cmd`), `@node "%~dp0${name}.js" %*\r\n`, 'utf-8'); +} + +export const writeMockOpenStub = IS_WINDOWS + ? (dir: string) => writeFileSync(join(dir, 'open.cmd'), '@exit /b 0\r\n', 'utf-8') + : (dir: string) => writeUnixBinary(dir, 'open', '#!/bin/sh\nexit 0\n'); diff --git a/__tests__/e2e/testing-framework/mocks/binaries/index.ts b/__tests__/e2e/testing-framework/mocks/binaries/index.ts index 0a6a082..34be236 100644 --- a/__tests__/e2e/testing-framework/mocks/binaries/index.ts +++ b/__tests__/e2e/testing-framework/mocks/binaries/index.ts @@ -1,8 +1,9 @@ -import { writeFileSync, mkdirSync, chmodSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { CLAUDE_SCRIPT } from './claude.js'; import { CURSOR_SCRIPT } from './cursor.js'; import { CODEX_SCRIPT } from './codex.js'; +import { writeMockBinary, writeMockOpenStub } from './fs.js'; /** Filename the mock IDE binary writes the chat prompt to. */ export const CHAT_PROMPT_FILE = '.e2e-chat-prompt'; @@ -10,12 +11,6 @@ export const CHAT_PROMPT_FILE = '.e2e-chat-prompt'; /** Filename the mock IDE binary writes the onboarding invocation JSON to. */ export const ONBOARDING_INVOCATION_FILE = '.e2e-onboarding-invocation'; -function writeMockBinary(dir: string, name: string, script: string): void { - const filePath = join(dir, name); - writeFileSync(filePath, script, 'utf-8'); - chmodSync(filePath, 0o755); -} - /** * Creates a directory of executable mock IDE binaries (`claude`, `cursor`, * `codex`, `open`) that the wizard will find on `PATH` during e2e tests. @@ -34,7 +29,8 @@ export function createMockBinDir(dir: string): string { writeMockBinary(binDir, 'claude', CLAUDE_SCRIPT); writeMockBinary(binDir, 'cursor', CURSOR_SCRIPT); writeMockBinary(binDir, 'codex', CODEX_SCRIPT); - writeMockBinary(binDir, 'open', '#!/bin/sh\nexit 0\n'); + + writeMockOpenStub(binDir); return binDir; } diff --git a/__tests__/e2e/testing-framework/session-factory.ts b/__tests__/e2e/testing-framework/session-factory.ts index d2c706f..e91af91 100644 --- a/__tests__/e2e/testing-framework/session-factory.ts +++ b/__tests__/e2e/testing-framework/session-factory.ts @@ -1,6 +1,6 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; import { TerminalSession } from './terminal/index.js'; import { createProjectDir, type ProjectType } from '../../shared/project-scaffold/index.js'; @@ -56,7 +56,7 @@ export function createSession({ const { path: projectDir } = createProjectDir(project); const sessionEnv: Record = { - PATH: `${mockBinDir}:${systemPath ?? process.env.PATH}`, + PATH: `${mockBinDir}${delimiter}${systemPath ?? process.env.PATH}`, ...env, }; diff --git a/__tests__/e2e/testing-framework/terminal/session.ts b/__tests__/e2e/testing-framework/terminal/session.ts index 50d12fa..30dea01 100644 --- a/__tests__/e2e/testing-framework/terminal/session.ts +++ b/__tests__/e2e/testing-framework/terminal/session.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stripAnsi } from './strip-ansi.js'; import { renderScreen, normalizeSnapshot } from './screen-buffer.js'; -import { E2E_BASE_ENV } from '../env.js'; +import { E2E_BASE_ENV, IS_WINDOWS } from '../env.js'; import { resolveKey, type Modifiers } from '../../../shared/key-map.js'; const CLI_PATH = resolve(import.meta.dirname, '../../../../dist/bin/cli.js'); @@ -85,8 +85,15 @@ export class TerminalSession { ...process.env, ...E2E_BASE_ENV, ...env, + HOME: isolatedTmpDir, TMPDIR: isolatedTmpDir, + + ...(IS_WINDOWS && { + USERPROFILE: isolatedTmpDir, + TEMP: isolatedTmpDir, + TMP: isolatedTmpDir, + }), }, }); diff --git a/__tests__/shared/platform.ts b/__tests__/shared/platform.ts new file mode 100644 index 0000000..7dbb967 --- /dev/null +++ b/__tests__/shared/platform.ts @@ -0,0 +1,5 @@ +export const isWindows = process.platform === 'win32'; + +export function perPlatform(options: { windows: T; unix: T }): T { + return isWindows ? options.windows : options.unix; +} diff --git a/__tests__/shared/project-scaffold/index.ts b/__tests__/shared/project-scaffold/index.ts index a170981..4bed870 100644 --- a/__tests__/shared/project-scaffold/index.ts +++ b/__tests__/shared/project-scaffold/index.ts @@ -1,4 +1,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { perPlatform } from '../platform.js'; import { SCAFFOLDS } from './scaffolds.js'; import type { ProjectType } from './types.js'; @@ -9,10 +12,15 @@ export type { ProjectType }; * project scaffold. Supports `Symbol.dispose` for automatic cleanup. * * @remarks - * Uses a hardcoded `/tmp/` prefix instead of `os.tmpdir()`. On macOS - * `tmpdir()` returns `/var/folders/…` which is longer than Linux's `/tmp/`, - * shifting column alignment in the VT100 screen buffer and breaking e2e - * snapshot assertions across platforms. + * Uses `/tmp/` on Unix instead of `os.tmpdir()`. On macOS `tmpdir()` + * returns `/var/folders/…` which is longer than Linux's `/tmp/`, shifting + * column alignment in the VT100 screen buffer and breaking e2e snapshot + * assertions across platforms. On Windows, `/tmp/` doesn't exist so we + * fall back to `os.tmpdir()`. + * + * @todo The Windows `tmpdir()` path is longer than `/tmp/`, producing + * different VT100 column alignment and incompatible e2e snapshots. When + * Windows CI is added, use a fixed-length prefix on all platforms. * * @param type - A named scaffold, or `null` for an empty directory. * @defaultValue `'react'` @@ -26,7 +34,12 @@ export type { ProjectType }; * ``` */ export function createProjectDir(type: ProjectType = 'react') { - const dir = mkdtempSync('/tmp/wizard-test-'); + const prefix = perPlatform({ + windows: join(tmpdir(), 'wizard-test-'), + unix: '/tmp/wizard-test-', + }); + + const dir = mkdtempSync(prefix); SCAFFOLDS[type](dir); return { diff --git a/package.json b/package.json index 7525d4a..2e26108 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "typecheck": "tsc --noEmit", "qa": "pnpm typecheck && pnpm lint && pnpm test", "clear": "./scripts/clean-dev-env.sh", - "prepare": "husky" + "prepare": "git config --local core.symlinks true && husky" }, "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index 0481f0b..393a2b6 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -1,14 +1,15 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; export default defineConfig({ resolve: { alias: { - '@commands': new URL('./src/commands', import.meta.url).pathname, - '@frameworks': new URL('./src/frameworks', import.meta.url).pathname, - '@integrations': new URL('./src/integrations', import.meta.url).pathname, - '@providers': new URL('./src/providers', import.meta.url).pathname, - '@ui': new URL('./src/ui', import.meta.url).pathname, - '@lib': new URL('./src/lib', import.meta.url).pathname, + '@commands': fileURLToPath(new URL('./src/commands', import.meta.url)), + '@frameworks': fileURLToPath(new URL('./src/frameworks', import.meta.url)), + '@integrations': fileURLToPath(new URL('./src/integrations', import.meta.url)), + '@providers': fileURLToPath(new URL('./src/providers', import.meta.url)), + '@ui': fileURLToPath(new URL('./src/ui', import.meta.url)), + '@lib': fileURLToPath(new URL('./src/lib', import.meta.url)), }, }, test: { diff --git a/vitest.config.ts b/vitest.config.ts index 7369bcf..338847d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; const isCI = !!process.env.CI; @@ -5,14 +6,14 @@ const isCI = !!process.env.CI; export default defineConfig({ resolve: { alias: { - '@commands': new URL('./src/commands', import.meta.url).pathname, - '@features': new URL('./src/features', import.meta.url).pathname, - '@frameworks': new URL('./src/frameworks', import.meta.url).pathname, - '@integrations': new URL('./src/integrations', import.meta.url).pathname, - '@providers': new URL('./src/providers', import.meta.url).pathname, - '@shared-kernel': new URL('./src/shared-kernel', import.meta.url).pathname, - '@ui': new URL('./src/ui', import.meta.url).pathname, - '@lib': new URL('./src/lib', import.meta.url).pathname, + '@commands': fileURLToPath(new URL('./src/commands', import.meta.url)), + '@features': fileURLToPath(new URL('./src/features', import.meta.url)), + '@frameworks': fileURLToPath(new URL('./src/frameworks', import.meta.url)), + '@integrations': fileURLToPath(new URL('./src/integrations', import.meta.url)), + '@providers': fileURLToPath(new URL('./src/providers', import.meta.url)), + '@shared-kernel': fileURLToPath(new URL('./src/shared-kernel', import.meta.url)), + '@ui': fileURLToPath(new URL('./src/ui', import.meta.url)), + '@lib': fileURLToPath(new URL('./src/lib', import.meta.url)), }, }, test: {