From 08d7b854a44561f3de5c56df80118fa7afd7c9ed Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 11 Jul 2026 18:39:08 -0700 Subject: [PATCH 1/2] test(cli): clean skills temp directories --- src/entrypoints/cli.skills.test.ts | 178 +++++++++++++++++++++-------- 1 file changed, 133 insertions(+), 45 deletions(-) diff --git a/src/entrypoints/cli.skills.test.ts b/src/entrypoints/cli.skills.test.ts index 78fc9e15fa..c21b923dd3 100644 --- a/src/entrypoints/cli.skills.test.ts +++ b/src/entrypoints/cli.skills.test.ts @@ -2,12 +2,31 @@ import { expect, test } from 'bun:test' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import { join, resolve } from 'path' +import treeKill from 'tree-kill' const repoRoot = resolve(import.meta.dir, '..', '..') const cliEntrypoint = join(repoRoot, 'src', 'entrypoints', 'cli.tsx') +const cliTimeoutMs = 10_000 -async function readStream(stream: ReadableStream): Promise { - return new Response(stream).text() +async function readStream( + stream: ReadableStream, + signal: AbortSignal, +): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let text = '' + const cancel = () => void reader.cancel() + signal.addEventListener('abort', cancel, { once: true }) + try { + while (true) { + const { done, value } = await reader.read() + if (done) return text + decoder.decode() + text += decoder.decode(value, { stream: true }) + } + } finally { + signal.removeEventListener('abort', cancel) + reader.releaseLock() + } } async function runSkillsList(args: string[]): Promise<{ @@ -16,35 +35,100 @@ async function runSkillsList(args: string[]): Promise<{ stdout: string }> { const root = mkdtempSync(join(tmpdir(), 'openclaude-skills-cli-')) - const projectDir = join(root, 'project') - const homeDir = join(root, 'home') - const configDir = join(root, 'config') - mkdirSync(projectDir) - mkdirSync(homeDir) - - const proc = Bun.spawn({ - cmd: [process.execPath, cliEntrypoint, ...args], - cwd: projectDir, - env: { - ...process.env, - CLAUDE_CODE_USE_OPENAI: '1', - OPENAI_BASE_URL: 'https://api.openai.com/v1', - OPENAI_API_KEY: '', - CLAUDE_CONFIG_DIR: configDir, - HOME: homeDir, - OPENCLAUDE_DISABLE_EARLY_INPUT: '1', - }, - stderr: 'pipe', - stdout: 'pipe', - }) - - const [stdout, stderr, exitCode] = await Promise.all([ - readStream(proc.stdout), - readStream(proc.stderr), - proc.exited, - ]) + try { + const projectDir = join(root, 'project') + const homeDir = join(root, 'home') + const configDir = join(root, 'config') + mkdirSync(projectDir) + mkdirSync(homeDir) - return { exitCode, stderr, stdout } + const proc = Bun.spawn({ + cmd: [process.execPath, cliEntrypoint, ...args], + cwd: projectDir, + env: { + ...process.env, + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_API_KEY: '', + CLAUDE_CONFIG_DIR: configDir, + HOME: homeDir, + OPENCLAUDE_DISABLE_EARLY_INPUT: '1', + }, + stderr: 'pipe', + stdout: 'pipe', + }) + + const streamAbort = new AbortController() + let exited = false + const processExit = proc.exited.finally(() => { + exited = true + }) + let termination: Promise | undefined + const terminate = (): Promise => { + if (termination) return termination + streamAbort.abort() + termination = new Promise(resolve => { + let settled = false + const finish = () => { + if (settled) return + settled = true + resolve() + } + const fallback = setTimeout(() => { + try { + proc.kill('SIGKILL') + } catch { + // The process may already have exited while tree-kill was running. + } finally { + finish() + } + }, 500) + treeKill(proc.pid, 'SIGKILL', error => { + clearTimeout(fallback) + if (error && !exited) { + try { + proc.kill('SIGKILL') + } catch { + // The process may have exited between tree-kill and the fallback. + } + } + finish() + }) + }) + return termination + } + let watchdog: ReturnType | undefined + const timeout = new Promise((_, reject) => { + watchdog = setTimeout(() => { + void terminate().finally(() => { + reject(new Error(`skills CLI timed out after ${cliTimeoutMs}ms`)) + }) + }, cliTimeoutMs) + }) + try { + const [stdout, stderr, exitCode] = await Promise.race([ + Promise.all([ + readStream(proc.stdout, streamAbort.signal), + readStream(proc.stderr, streamAbort.signal), + processExit, + ]), + timeout, + ]) + + return { exitCode, stderr, stdout } + } finally { + if (watchdog) clearTimeout(watchdog) + if (!exited) { + await terminate() + await Promise.race([ + processExit.catch(() => undefined), + new Promise(resolve => setTimeout(resolve, 1_000)), + ]) + } + } + } finally { + rmSync(root, { recursive: true, force: true }) + } } test('skills list bypasses provider startup validation', async () => { @@ -98,23 +182,27 @@ test('skills list bypasses provider startup validation after --setting-sources', test('skills list honors --add-dir before provider startup validation', async () => { const addDirRoot = mkdtempSync(join(tmpdir(), 'openclaude-skills-add-dir-')) const skillDir = join(addDirRoot, '.openclaude', 'skills', 'addon') - mkdirSync(skillDir, { recursive: true }) - writeFileSync( - join(skillDir, 'SKILL.md'), - `---\ndescription: Skill loaded from add-dir.\n---\n# Addon\n`, - 'utf8', - ) + try { + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\ndescription: Skill loaded from add-dir.\n---\n# Addon\n`, + 'utf8', + ) - const { exitCode, stderr, stdout } = await runSkillsList([ - '--add-dir', - addDirRoot, - 'skills', - 'list', - ]) + const { exitCode, stderr, stdout } = await runSkillsList([ + '--add-dir', + addDirRoot, + 'skills', + 'list', + ]) - expect(exitCode).toBe(0) - expect(stdout).toContain('addon') - expect(stderr).not.toContain('OPENAI_API_KEY is required') + expect(exitCode).toBe(0) + expect(stdout).toContain('addon') + expect(stderr).not.toContain('OPENAI_API_KEY is required') + } finally { + rmSync(addDirRoot, { recursive: true, force: true }) + } }, 15_000) test('skills list accepts equals-form global flags before provider startup validation', async () => { From d202cc2434f1ee341a9c8e3ba9ae8f80339ac748 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 11 Jul 2026 19:19:11 -0700 Subject: [PATCH 2/2] test(cli): handle stream cancellation errors --- src/entrypoints/cli.skills.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entrypoints/cli.skills.test.ts b/src/entrypoints/cli.skills.test.ts index c21b923dd3..e2517abb43 100644 --- a/src/entrypoints/cli.skills.test.ts +++ b/src/entrypoints/cli.skills.test.ts @@ -15,7 +15,7 @@ async function readStream( const reader = stream.getReader() const decoder = new TextDecoder() let text = '' - const cancel = () => void reader.cancel() + const cancel = () => void reader.cancel().catch(() => {}) signal.addEventListener('abort', cancel, { once: true }) try { while (true) {