diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abe3a99..209515d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,13 @@ jobs: - name: Build web UI run: bun run build:web + - name: Run tests + run: bun test --timeout 20000 + if: matrix.os == 'windows-latest' + - name: Run tests run: bun test + if: matrix.os != 'windows-latest' - name: Run tests with coverage if: matrix.os == 'ubuntu-latest' diff --git a/src/cli/index.ts b/src/cli/index.ts index d9c7ca4..f2a1881 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -26,6 +26,10 @@ import { import type { RegistrySourceType } from '../types/database'; import type { RegistryCapability } from '../types/registry'; import { checkForUpdates } from './utils/version-check'; +import { + cliSubcommandFromArgv, + shouldSkipVersionCheck, +} from './utils/cli-subcommand'; import { VERSION } from '../version'; import { setFlags, ExitCode, error } from './ui'; @@ -53,8 +57,12 @@ if (process.argv[2] === '__server__') { (async () => { try { // Start version check in the background while the command runs - const isUpgradeCommand = process.argv[2] === 'upgrade'; - const updateCheckPromise = isUpgradeCommand ? Promise.resolve(null) : checkForUpdates(); + const subcommand = cliSubcommandFromArgv(process.argv); + // Hook subprocesses (activity-ingest) must own stdout — only valid JSON for gate events. + const skipVersionCheck = shouldSkipVersionCheck(subcommand); + const updateCheckPromise = skipVersionCheck + ? Promise.resolve(null) + : checkForUpdates(); const program = new Command(); @@ -436,7 +444,7 @@ if (process.argv[2] === '__server__') { // Show update notice after the command completes (if a newer version is available) const updateInfo = await updateCheckPromise; - if (updateInfo?.hasUpdate) { + if (!skipVersionCheck && updateInfo?.hasUpdate) { console.log(`\n A new version of capa is available: ${updateInfo.latestVersion} (current: ${updateInfo.currentVersion})`); console.log(' Run "capa upgrade" to update.\n'); } diff --git a/src/cli/utils/__tests__/cli-subcommand.test.ts b/src/cli/utils/__tests__/cli-subcommand.test.ts new file mode 100644 index 0000000..067af1c --- /dev/null +++ b/src/cli/utils/__tests__/cli-subcommand.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'bun:test'; +import { + cliSubcommandFromArgv, + shouldSkipVersionCheck, +} from '../cli-subcommand'; + +describe('cliSubcommandFromArgv', () => { + it('returns the first non-option token', () => { + expect(cliSubcommandFromArgv(['bun', 'capa', 'activity-ingest'])).toBe( + 'activity-ingest', + ); + expect( + cliSubcommandFromArgv([ + 'bun', + 'capa', + '--no-color', + 'activity-ingest', + '--event', + 'beforeFileRead', + ]), + ).toBe('activity-ingest'); + expect(cliSubcommandFromArgv(['bun', 'capa', '-q', 'upgrade'])).toBe( + 'upgrade', + ); + expect(cliSubcommandFromArgv(['bun', 'capa', 'install'])).toBe('install'); + }); +}); + +describe('shouldSkipVersionCheck', () => { + it('skips for upgrade and activity-ingest only', () => { + expect(shouldSkipVersionCheck('upgrade')).toBe(true); + expect(shouldSkipVersionCheck('activity-ingest')).toBe(true); + expect(shouldSkipVersionCheck('install')).toBe(false); + expect(shouldSkipVersionCheck(undefined)).toBe(false); + }); +}); diff --git a/src/cli/utils/cli-subcommand.ts b/src/cli/utils/cli-subcommand.ts new file mode 100644 index 0000000..63bbe1e --- /dev/null +++ b/src/cli/utils/cli-subcommand.ts @@ -0,0 +1,13 @@ +/** + * Resolve the CLI subcommand from raw argv (after `node` / `bun` and script path). + * Global root flags may precede the command token (`capa --no-color activity-ingest`). + */ +export function cliSubcommandFromArgv(argv: string[]): string | undefined { + const rest = argv.slice(2); + return rest.find((token) => token.length > 0 && !token.startsWith('-')); +} + +/** Subcommands that must not append human-readable text to stdout after they run. */ +export function shouldSkipVersionCheck(subcommand: string | undefined): boolean { + return subcommand === 'upgrade' || subcommand === 'activity-ingest'; +} diff --git a/src/cli/utils/wrap/__tests__/sessions.test.ts b/src/cli/utils/wrap/__tests__/sessions.test.ts index c7fd4a1..1c52a3e 100644 --- a/src/cli/utils/wrap/__tests__/sessions.test.ts +++ b/src/cli/utils/wrap/__tests__/sessions.test.ts @@ -1,6 +1,10 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; import { describe, it, expect } from 'bun:test'; import { findWrapPids, + findWrapPidsForProject, isPidRunning, stopAllWrapSessions, commandLineMatchesProject, @@ -18,6 +22,18 @@ describe('wrap process discovery', () => { expect(pids.includes(process.pid)).toBe(false); }); + it('findWrapPidsForProject skips process scan when project has no wrap workspace', async () => { + const dir = mkdtempSync(join(tmpdir(), 'capa-wrap-scan-skip-')); + try { + const pids = await findWrapPidsForProject(dir); + expect(pids).toEqual([]); + } finally { + try { + rmSync(dir, { recursive: true, force: true }); + } catch {} + } + }); + it('stopAllWrapSessions is a no-op when nothing is wrapping', async () => { // May still find unrelated wraps on the machine; just ensure it resolves. const n = await stopAllWrapSessions(); diff --git a/src/cli/utils/wrap/sessions.ts b/src/cli/utils/wrap/sessions.ts index 481e414..1727ccd 100644 --- a/src/cli/utils/wrap/sessions.ts +++ b/src/cli/utils/wrap/sessions.ts @@ -234,6 +234,12 @@ export async function findWrapPidsForProject(realProjectPath: string): Promise commandLineMatchesProject(p.commandLine, real, workspacePaths)) diff --git a/src/db/__tests__/database.test.ts b/src/db/__tests__/database.test.ts index ef9122a..fde1c56 100644 --- a/src/db/__tests__/database.test.ts +++ b/src/db/__tests__/database.test.ts @@ -4,6 +4,26 @@ import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; +/** Windows CI can briefly keep SQLite files open after close(). */ +function removeTempDirWithRetry(dir: string, attempts = 8): void { + for (let i = 0; i < attempts; i++) { + try { + rmSync(dir, { recursive: true, force: true }); + return; + } catch (error: unknown) { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code: unknown }).code) + : ''; + if (code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY') { + throw error; + } + if (i === attempts - 1) return; + Bun.sleepSync(50 * (i + 1)); + } + } +} + describe('CapaDatabase', () => { let db: CapaDatabase; let tempDir: string; @@ -17,11 +37,7 @@ describe('CapaDatabase', () => { afterEach(() => { db.close(); - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch (error: any) { - if (error?.code !== 'EBUSY') throw error; - } + removeTempDirWithRetry(tempDir); }); describe('Project operations', () => {