diff --git a/src/commands/issue/__tests__/issue-gh.test.ts b/src/commands/issue/__tests__/issue-gh.test.ts deleted file mode 100644 index 12887b717..000000000 --- a/src/commands/issue/__tests__/issue-gh.test.ts +++ /dev/null @@ -1,571 +0,0 @@ -/** - * Coverage tests for issue/index.ts gh-CLI paths. - * - * issue/index.ts uses `import * as childProcess from 'node:child_process'` - * with lazy promisify, so mock.module('node:child_process') is effective. - */ -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - mock, - test, -} from 'bun:test' -import { promisify } from 'node:util' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -// ── Mock control state ── -let _execFileSyncImpl: (cmd: string, args: string[], opts?: unknown) => Buffer = - () => Buffer.from('') - -let _execFileImpl: ( - cmd: string, - args: string[], - opts: unknown, - cb: (err: Error | null, stdout: string, stderr: string) => void, -) => void = (_cmd, _args, _opts, cb) => cb(null, '', '') - -const execFileSyncMockCore = ( - cmd: string, - args: string[], - opts?: unknown, -): Buffer => _execFileSyncImpl(cmd, args, opts) - -const execFileMockCore = ( - cmd: string, - args: string[], - opts: unknown, - cb: (err: Error | null, stdout: string, stderr: string) => void, -) => _execFileImpl(cmd, args, opts, cb) - -;(execFileMockCore as unknown as Record)[ - promisify.custom as symbol -] = ( - cmd: string, - args: string[], - opts: unknown, -): Promise<{ stdout: string; stderr: string }> => - new Promise((resolve, reject) => - _execFileImpl(cmd, args, opts, (err, stdout, stderr) => { - if (err) reject(err) - else resolve({ stdout, stderr }) - }), - ) - -// Spread real child_process + flag-gated stub (see share-gh.test.ts for the -// promisify.custom rationale). -let useIssueGhCpStubs = false -const wrappedIssueGhExecFile = ((...args: unknown[]) => - useIssueGhCpStubs - ? (execFileMockCore as (...a: unknown[]) => unknown)(...args) - : // eslint-disable-next-line @typescript-eslint/no-require-imports - (require('node:child_process').execFile as (...a: unknown[]) => unknown)( - ...args, - )) as unknown as Record & ((...a: unknown[]) => unknown) -;(wrappedIssueGhExecFile as Record)[ - promisify.custom as symbol -] = ( - cmd: string, - args: string[], - opts: unknown, -): Promise<{ stdout: string; stderr: string }> => { - if (useIssueGhCpStubs) { - return new Promise((resolve, reject) => - _execFileImpl(cmd, args, opts, (err, stdout, stderr) => - err ? reject(err) : resolve({ stdout, stderr }), - ), - ) - } - // eslint-disable-next-line @typescript-eslint/no-require-imports - const real = require('node:child_process') as Record - return promisify(real.execFile as never)(cmd, args, opts) as Promise<{ - stdout: string - stderr: string - }> -} -mock.module('node:child_process', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const real = require('node:child_process') as Record - return { - ...real, - default: real, - execFile: wrappedIssueGhExecFile as typeof real.execFile, - execFileSync: ((...args: unknown[]) => - useIssueGhCpStubs - ? (execFileSyncMockCore as (...a: unknown[]) => unknown)(...args) - : (real.execFileSync as (...a: unknown[]) => unknown)( - ...args, - )) as typeof real.execFileSync, - } -}) - -mock.module('bun:bundle', () => ({ - feature: (_name: string) => true, -})) - -mock.module('src/services/analytics/index.js', () => ({ - logEvent: () => {}, - stripProtoFields: (v: unknown) => v, -})) - -// ── State ── -let tmpDir: string -let claudeDir: string - -beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'issue-gh-test-')) - claudeDir = join(tmpDir, '.claude') - mkdirSync(claudeDir, { recursive: true }) - process.env.CLAUDE_CONFIG_DIR = claudeDir - // Default: git remote fails (no GitHub remote), gh not available - _execFileSyncImpl = (_cmd, _args, _opts) => { - throw new Error('ENOENT: command not found') - } - _execFileImpl = (_cmd, _args, _opts, cb) => - cb(new Error('ENOENT: command not found'), '', '') -}) - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - delete process.env.CLAUDE_CONFIG_DIR -}) - -// ── Helpers ── -type CallFn = (args: string) => Promise<{ type: string; value: string }> - -async function getCallFn(): Promise { - const mod = await import('../index.js') - const loaded = await ( - mod.default as unknown as { load: () => Promise<{ call: CallFn }> } - ).load() - return loaded.call.bind(loaded) as CallFn -} - -async function writeSessionLog(entries?: string[]): Promise { - const { sanitizePath } = await import('../../../utils/path.js') - const { getSessionId, getOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - const sessionId = getSessionId() - const cwd = getOriginalCwd() - const encoded = sanitizePath(cwd) - const dir = join(claudeDir, 'projects', encoded) - mkdirSync(dir, { recursive: true }) - const content = entries ?? [ - JSON.stringify({ role: 'user', content: 'Fix the login bug' }), - JSON.stringify({ - role: 'assistant', - content: [{ type: 'text', text: 'I will investigate' }], - }), - ] - writeFileSync(join(dir, `${sessionId}.jsonl`), content.join('\n') + '\n') -} - -// Create a .github/ISSUE_TEMPLATE dir in tmpDir -function createIssueTemplate( - content = '## Bug Report\n\nDescribe the bug.', -): string { - const templateDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE') - mkdirSync(templateDir, { recursive: true }) - writeFileSync(join(templateDir, 'bug_report.md'), content) - return templateDir -} - -// ── Sequence helpers ── -type SeqBehavior = - | { type: 'sync-ok'; stdout: string } - | { type: 'sync-fail'; msg: string } - | { type: 'async-ok'; stdout: string } - | { type: 'async-fail'; msg: string } - -/** - * Sets sync/async behavior based on command name. - * syncBehavior controls execFileSync (git, gh --version sync-check). - * asyncBehaviors controls sequential async calls. - */ -function setupMocks(opts: { - gitRemoteUrl?: string | null // null = git fails, string = succeeds with that URL - ghCliAvailable?: boolean // whether gh --version sync call succeeds - asyncSequence?: Array< - { ok: true; stdout: string } | { ok: false; msg: string } - > -}): void { - const { gitRemoteUrl, ghCliAvailable = false, asyncSequence = [] } = opts - - _execFileSyncImpl = (cmd, _args, _opts) => { - if (cmd === 'git') { - if (gitRemoteUrl !== null && gitRemoteUrl !== undefined) { - return Buffer.from(gitRemoteUrl + '\n') - } - throw new Error('ENOENT: git not found or no remote') - } - if (cmd === 'gh') { - if (ghCliAvailable) { - return Buffer.from('gh version 2.0.0') - } - throw new Error('ENOENT: gh not found') - } - throw new Error(`Unexpected sync command: ${cmd}`) - } - - let asyncCallCount = 0 - _execFileImpl = (_cmd, _args, _opts, cb) => { - const b = asyncSequence[asyncCallCount] ?? { - ok: false, - msg: 'unexpected async call', - } - asyncCallCount++ - if (b.ok) cb(null, b.stdout, '') - else cb(new Error(b.msg), '', b.msg) - } -} - -// Activate child_process stubs only for this suite. -beforeAll(() => { - useIssueGhCpStubs = true -}) -afterAll(() => { - useIssueGhCpStubs = false -}) - -describe('issue command — tryDetectGitRemoteUrl catch path', () => { - test('git fails → tryDetectGitRemoteUrl returns null → no remote detected', async () => { - setupMocks({ gitRemoteUrl: null, ghCliAvailable: false }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - // No remote + no gh → fallback URL path - expect(result.value).toContain('GitHub') - }) -}) - -describe('issue command — ghCliAvailable paths', () => { - test('gh not available → falls back to browser URL (with GitHub remote)', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: false, - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('github.com/owner/repo') - expect(result.value).toContain('Install') - }) - - test('gh not available + no remote → shows no GitHub remote message', async () => { - setupMocks({ gitRemoteUrl: null, ghCliAvailable: false }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('GitHub') - }) - - test('gh available + no remote → falls back to browser (no URL)', async () => { - setupMocks({ - gitRemoteUrl: null, - ghCliAvailable: true, - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('GitHub') - }) -}) - -describe('issue command — parseOwnerRepo null path', () => { - test('non-GitHub remote → parseOwnerRepo returns null → no gh URL', async () => { - setupMocks({ - gitRemoteUrl: 'https://gitlab.com/owner/repo.git', - ghCliAvailable: true, - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - }) -}) - -describe('issue command — repoHasIssuesEnabled paths', () => { - test('gh available + GitHub remote → issues enabled (true) → creates issue', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, // gh api repos → has_issues = true - { ok: true, stdout: 'https://github.com/owner/repo/issues/42' }, // gh issue create - ], - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - expect(result.value).toContain('Fix login bug') - expect(result.value).toContain('https://github.com/owner/repo/issues/42') - }) - - test('gh available + GitHub remote → issues disabled (false) → discussions fallback', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'false\n' }, // gh api repos → has_issues = false - ], - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('Issues are disabled') - expect(result.value).toContain('discussions') - }) - - test('gh available + GitHub remote → repoHasIssuesEnabled returns null (unexpected output)', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'null\n' }, // unexpected .has_issues value → null - { ok: true, stdout: 'https://github.com/owner/repo/issues/99' }, // issue create - ], - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - // null → proceeds to create issue - expect(result.value).toContain('Issue created') - }) - - test('gh available + GitHub remote → repoHasIssuesEnabled throws → returns null → creates issue', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: false, msg: 'network error' }, // gh api fails → catch → null - { ok: true, stdout: 'https://github.com/owner/repo/issues/101' }, // issue create - ], - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) - - test('gh available + GitHub remote + issue create fails → error message', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, // has_issues = true - { ok: false, msg: 'gh auth error' }, // issue create fails - ], - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('Failed to create issue') - expect(result.value).toContain('gh auth error') - }) - - test('gh available + GitHub remote + labels and assignees → issue created with labels', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/repo/issues/50' }, - ], - }) - const call = await getCallFn() - const result = await call('--label bug --assignee alice Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - expect(result.value).toContain('Labels: bug') - expect(result.value).toContain('Assignees: alice') - }) -}) - -describe('issue command — detectIssueTemplate paths', () => { - test('no .github/ISSUE_TEMPLATE → no template used', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/repo/issues/1' }, - ], - }) - process.env.INIT_CWD = tmpDir - // Ensure no ISSUE_TEMPLATE exists - const call = await getCallFn() - const result = await call('Test no template') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) - - test('.github/ISSUE_TEMPLATE with md file → template included in body', async () => { - createIssueTemplate('---\nname: Bug Report\n---\n## Describe the bug') - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/repo/issues/2' }, - ], - }) - // Override getOriginalCwd to return tmpDir by setting env - // detectIssueTemplate uses `cwd = getOriginalCwd()` from state - // which returns the real process cwd. We create template relative to real cwd - // This test just verifies the path doesn't crash. - const call = await getCallFn() - const result = await call('Test with template') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - }) - - test('.github/ISSUE_TEMPLATE with only yml files → no md template', async () => { - const templateDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE') - mkdirSync(templateDir, { recursive: true }) - writeFileSync(join(templateDir, 'bug.yml'), 'name: Bug\ndescription: A bug') - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/repo/issues/3' }, - ], - }) - const call = await getCallFn() - const result = await call('Test yml template') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - }) -}) - -describe('issue command — getTranscriptSummary paths', () => { - test('session log exists + projectDir=null → reads from standard path', async () => { - await writeSessionLog() - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/repo/issues/4' }, - ], - }) - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) - - test('session log with tool_result errors → errors included in summary', async () => { - await writeSessionLog([ - JSON.stringify({ - role: 'user', - content: [ - { - type: 'tool_result', - tool_use_id: 'tu1', - is_error: true, - content: 'Command failed with exit code 1', - }, - ], - }), - JSON.stringify({ role: 'user', content: 'help me' }), - JSON.stringify({ role: 'assistant', content: 'let me look' }), - ]) - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/repo/issues/5' }, - ], - }) - const call = await getCallFn() - const result = await call('Fix crash') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) - - test('session log with array content user message', async () => { - await writeSessionLog([ - JSON.stringify({ - role: 'user', - content: [{ type: 'text', text: 'What is the issue?' }], - }), - ]) - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/repo/issues/6' }, - ], - }) - const call = await getCallFn() - const result = await call('Test array content') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) - - test('no session log → getTranscriptSummary returns no session log found', async () => { - // No log written → summary says "(no session log found)" - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/repo/issues/7' }, - ], - }) - const call = await getCallFn() - const result = await call('Fix issue no log') - expect(result.type).toBe('text') - // Either creates issue successfully or fails, but passes the code paths - expect(typeof result.value).toBe('string') - }) -}) - -describe('issue command — SSH GitHub remote', () => { - test('SSH remote parsed correctly → issue created', async () => { - setupMocks({ - gitRemoteUrl: 'git@github.com:owner/myrepo.git', - ghCliAvailable: true, - asyncSequence: [ - { ok: true, stdout: 'true\n' }, - { ok: true, stdout: 'https://github.com/owner/myrepo/issues/8' }, - ], - }) - const call = await getCallFn() - const result = await call('Fix SSH issue') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) -}) - -describe('issue command — no title with remote present', () => { - test('no title + GitHub remote + gh available → usage with repo info and gh message', async () => { - setupMocks({ - gitRemoteUrl: 'https://github.com/owner/repo.git', - ghCliAvailable: true, - }) - const call = await getCallFn() - const result = await call('') - expect(result.type).toBe('text') - expect(result.value).toContain('Usage') - expect(result.value).toContain('owner/repo') - }) - - test('no title + no remote + gh not available → usage with no repo info', async () => { - setupMocks({ gitRemoteUrl: null, ghCliAvailable: false }) - const call = await getCallFn() - const result = await call('') - expect(result.type).toBe('text') - expect(result.value).toContain('Usage') - }) -}) diff --git a/src/commands/issue/__tests__/issue-template.test.ts b/src/commands/issue/__tests__/issue-template.test.ts deleted file mode 100644 index f4db48fc4..000000000 --- a/src/commands/issue/__tests__/issue-template.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Coverage tests for detectIssueTemplate paths. - * - * detectIssueTemplate uses getOriginalCwd() to find .github/ISSUE_TEMPLATE. - * These tests create the template directory in the REAL project CWD and clean - * up after each test. - * - * IMPORTANT: No state mock is used — this avoids global mock contamination. - */ -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - mock, - test, -} from 'bun:test' -import { promisify } from 'node:util' -import { - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -// ── child_process mock ── -let _execFileSyncImplT: ( - cmd: string, - args: string[], - opts?: unknown, -) => Buffer = () => Buffer.from('') -let _execFileImplT: ( - cmd: string, - args: string[], - opts: unknown, - cb: (err: Error | null, stdout: string, stderr: string) => void, -) => void = (_cmd, _args, _opts, cb) => cb(null, '', '') - -const execFileSyncMockT = ( - cmd: string, - args: string[], - opts?: unknown, -): Buffer => _execFileSyncImplT(cmd, args, opts) -const execFileMockT = ( - cmd: string, - args: string[], - opts: unknown, - cb: (err: Error | null, stdout: string, stderr: string) => void, -) => _execFileImplT(cmd, args, opts, cb) - -;(execFileMockT as unknown as Record)[ - promisify.custom as symbol -] = ( - cmd: string, - args: string[], - opts: unknown, -): Promise<{ stdout: string; stderr: string }> => - new Promise((resolve, reject) => - _execFileImplT(cmd, args, opts, (err, stdout, stderr) => { - if (err) reject(err) - else resolve({ stdout, stderr }) - }), - ) - -// Spread real child_process + flag-gated stub (see share-gh.test.ts for the -// promisify.custom rationale). -let useIssueTemplateCpStubs = false -const wrappedIssueTemplateExecFile = ((...args: unknown[]) => - useIssueTemplateCpStubs - ? (execFileMockT as (...a: unknown[]) => unknown)(...args) - : // eslint-disable-next-line @typescript-eslint/no-require-imports - (require('node:child_process').execFile as (...a: unknown[]) => unknown)( - ...args, - )) as unknown as Record & ((...a: unknown[]) => unknown) -;(wrappedIssueTemplateExecFile as Record)[ - promisify.custom as symbol -] = ( - cmd: string, - args: string[], - opts: unknown, -): Promise<{ stdout: string; stderr: string }> => { - if (useIssueTemplateCpStubs) { - return new Promise((resolve, reject) => - _execFileImplT(cmd, args, opts, (err, stdout, stderr) => - err ? reject(err) : resolve({ stdout, stderr }), - ), - ) - } - // eslint-disable-next-line @typescript-eslint/no-require-imports - const real = require('node:child_process') as Record - return promisify(real.execFile as never)(cmd, args, opts) as Promise<{ - stdout: string - stderr: string - }> -} -mock.module('node:child_process', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const real = require('node:child_process') as Record - return { - ...real, - default: real, - execFile: wrappedIssueTemplateExecFile as typeof real.execFile, - execFileSync: ((...args: unknown[]) => - useIssueTemplateCpStubs - ? (execFileSyncMockT as (...a: unknown[]) => unknown)(...args) - : (real.execFileSync as (...a: unknown[]) => unknown)( - ...args, - )) as typeof real.execFileSync, - } -}) - -mock.module('bun:bundle', () => ({ - feature: (_name: string) => true, -})) - -mock.module('src/services/analytics/index.js', () => ({ - logEvent: () => {}, - stripProtoFields: (v: unknown) => v, -})) - -// Re-mock bootstrap/state.js so getOriginalCwd points at the real process -// cwd regardless of any prior test file's static state mock (e.g. -// launchAutofixPr.test.ts pinning '/mock/cwd'). Without this override, in -// the full suite detectIssueTemplate would see '/mock/cwd' and skip the -// template loading body (lines 114-129). -import { stateMock as _baseStateMockT } from '../../../../tests/mocks/state' -let _dynamicCwdT: string = process.cwd() -mock.module('src/bootstrap/state.js', () => ({ - ..._baseStateMockT(), - getSessionId: () => 'issue-tpl-session-id', - getSessionProjectDir: () => null, - getOriginalCwd: () => _dynamicCwdT, - setOriginalCwd: (c: string) => { - _dynamicCwdT = c - }, -})) - -// ── State ── -let tmpDir: string -let claudeDir: string - -// The real CWD where the issue command will look for .github/ISSUE_TEMPLATE -// We determine this at import time (stable throughout test run) -const realCwd = process.cwd() -// We track whether we created the template dir so we can clean it up -let createdTemplatePath: string | null = null - -beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'issue-tpl-test-')) - claudeDir = join(tmpDir, '.claude') - mkdirSync(claudeDir, { recursive: true }) - process.env.CLAUDE_CONFIG_DIR = claudeDir - createdTemplatePath = null - - // Default: git → GitHub remote, gh → available, async → issues true + create OK - let n = 0 - _execFileSyncImplT = (cmd, _args, _opts) => { - if (cmd === 'git') return Buffer.from('https://github.com/owner/repo.git\n') - if (cmd === 'gh') return Buffer.from('gh version 2.0.0') - return Buffer.from('') - } - _execFileImplT = (_cmd, _args, _opts, cb) => { - n++ - if (n === 1) cb(null, 'true\n', '') - else cb(null, 'https://github.com/owner/repo/issues/20', '') - } -}) - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - delete process.env.CLAUDE_CONFIG_DIR - // Clean up any template dir we created in the real CWD - if (createdTemplatePath && existsSync(createdTemplatePath)) { - rmSync(createdTemplatePath, { recursive: true, force: true }) - } - createdTemplatePath = null -}) - -// ── Helpers ── -type CallFn = (args: string) => Promise<{ type: string; value: string }> - -async function getCallFn(): Promise { - const mod = await import('../index.js') - const loaded = await ( - mod.default as unknown as { load: () => Promise<{ call: CallFn }> } - ).load() - return loaded.call.bind(loaded) as CallFn -} - -/** - * Creates .github/ISSUE_TEMPLATE in the REAL CWD. - * Registers for cleanup in afterEach. - */ -function createTemplateInCwd(files: Record): string { - const templateDir = join(realCwd, '.github', 'ISSUE_TEMPLATE') - mkdirSync(templateDir, { recursive: true }) - for (const [name, content] of Object.entries(files)) { - writeFileSync(join(templateDir, name), content) - } - // Track the ISSUE_TEMPLATE dir for cleanup — never delete the whole .github/ - // as it may contain workflows, settings, or other project config. - createdTemplatePath = templateDir - return templateDir -} - -// Activate child_process stubs only for this suite. -beforeAll(() => { - useIssueTemplateCpStubs = true -}) -afterAll(() => { - useIssueTemplateCpStubs = false -}) - -describe('issue command — detectIssueTemplate template paths', () => { - test('md template with front-matter → front-matter stripped', async () => { - createTemplateInCwd({ - 'bug.md': - '---\nname: Bug Report\nabout: A bug\n---\n## Describe the bug\n\nDetails.', - }) - const call = await getCallFn() - const result = await call('Fix bug with template') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) - - test('md template without front-matter → content returned as-is', async () => { - createTemplateInCwd({ - 'feature.md': '## Feature Request\n\nDescribe the feature.', - }) - const call = await getCallFn() - const result = await call('Add feature') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) - - test('yml file only → mdFile not found → no template (null)', async () => { - createTemplateInCwd({ - 'bug.yml': 'name: Bug\ndescription: Describe the bug.', - }) - const call = await getCallFn() - const result = await call('Fix yml-only template issue') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) - - test('md template stripped to empty → null (stripped || null)', async () => { - // Front-matter only, empty body after stripping - createTemplateInCwd({ - 'empty.md': '---\nname: Empty\nabout: empty\n---', - }) - const call = await getCallFn() - const result = await call('Empty template test') - expect(result.type).toBe('text') - expect(result.value).toContain('Issue created') - }) -}) diff --git a/src/commands/issue/__tests__/issue.test.ts b/src/commands/issue/__tests__/issue.test.ts deleted file mode 100644 index 56a76c8aa..000000000 --- a/src/commands/issue/__tests__/issue.test.ts +++ /dev/null @@ -1,611 +0,0 @@ -/** - * Tests for issue/index.ts - * - * NOTE: issue/index.ts calls execFileSync at module-function level (not top-level). - * The child_process functions are imported by reference and cannot be reliably - * mocked after module load with Bun's mock.module. Tests here cover what's - * testable without child_process control: parseIssueArgs, metadata, and - * environment-agnostic paths. - */ -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - mock, - test, -} from 'bun:test' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { randomUUID } from 'node:crypto' - -mock.module('bun:bundle', () => ({ - feature: (_name: string) => true, -})) - -mock.module('src/services/analytics/index.js', () => ({ - logEvent: () => {}, - logEventAsync: () => Promise.resolve(), - stripProtoFields: (v: unknown) => v, - _resetForTesting: () => {}, - attachAnalyticsSink: () => {}, -})) - -// Re-mock bootstrap/state.js with a dynamic getOriginalCwd / setOriginalCwd -// pair so this suite can drive cwd values regardless of any earlier test -// file's static mock (e.g. launchAutofixPr.test.ts which sets a fixed -// '/mock/cwd'). We start from the shared stateMock helper, then override -// the four exports issue/index.ts cares about with closure-driven impls. -// -// Bun's mock.module is global / last-write-wins. After this suite finishes -// we set `useIssueDynamicState=false` so launchAutofixPr's tests (which run -// in the same process) see the values their suite originally expected. -import { stateMock } from '../../../../tests/mocks/state' -let _dynamicCwd = process.cwd() -let _dynamicSessionId = `issue-test-${randomUUID()}` -// Default OFF — autofix-pr/__tests__/launchAutofixPr.test.ts runs FIRST in -// the combined suite (alphabetical: 'autofix-pr' < 'issue') and expects -// '/mock/cwd'. Issue's beforeAll switches this on, afterAll switches off. -let useIssueDynamicState = false -// Default OFF — the long-body draft-save test below flips this on for its -// body (so execFile/execFileSync return ENOENT + a fake GitHub remote URL) -// then flips off in finally. Without the flag the child_process stub leaked -// process-globally into every later test file via Bun's mock.module cache. -let useIssueLongBodyCpStubs = false -mock.module('src/bootstrap/state.js', () => ({ - ...stateMock(), - getSessionId: () => - useIssueDynamicState ? _dynamicSessionId : 'parent-session-id', - getParentSessionId: () => undefined, - getCwdState: () => (useIssueDynamicState ? _dynamicCwd : '/mock/cwd'), - getSessionProjectDir: () => null, - getOriginalCwd: () => (useIssueDynamicState ? _dynamicCwd : '/mock/cwd'), - getProjectRoot: () => (useIssueDynamicState ? _dynamicCwd : '/mock/project'), - setCwdState: (c: string) => { - if (useIssueDynamicState) _dynamicCwd = c - }, - setOriginalCwd: (c: string) => { - if (useIssueDynamicState) _dynamicCwd = c - }, - setLastAPIRequestMessages: () => {}, - getIsNonInteractiveSession: () => false, - addSlowOperation: () => {}, -})) - -// ── State ── -let tmpDir: string -let claudeDir: string -// Snapshot HOME so per-test mutations (lines below set process.env.HOME = -// tmpDir for child-process branches) can be restored. Otherwise the leaked -// /tmp/issue-test-XXX HOME pollutes downstream tests like -// src/services/langfuse/__tests__/langfuse.test.ts whose sanitize logic -// substitutes the current process.env.HOME. -const _originalHomeForIssueSuite = process.env.HOME - -// Mock envUtils to read CLAUDE_CONFIG_DIR from process.env dynamically so -// other test files (cacheStats, SessionMemory/prompts) that mock with static -// paths don't pollute this test in the full suite. Reading process.env at -// call time lets each test drive its own dir. -mock.module('src/utils/envUtils.js', () => ({ - getClaudeConfigHomeDir: () => - process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`, - isEnvTruthy: (v: unknown) => Boolean(v), - getTeamsDir: () => - join(process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`, 'teams'), - hasNodeOption: () => false, - isEnvDefinedFalsy: () => false, - isBareMode: () => false, - parseEnvVars: (s: string) => s, - getAWSRegion: () => 'us-east-1', - getDefaultVertexRegion: () => 'us-central1', - shouldMaintainProjectWorkingDir: () => false, -})) - -// Activate dynamic state mode for this suite only. -beforeAll(() => { - useIssueDynamicState = true -}) - -beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), 'issue-test-')) - claudeDir = join(tmpDir, '.claude') - mkdirSync(claudeDir, { recursive: true }) - process.env.CLAUDE_CONFIG_DIR = claudeDir - // Reset dynamic cwd to a per-test deterministic default (the tmpDir). - // Tests that need a different cwd call the mocked setOriginalCwd. - _dynamicCwd = tmpDir - _dynamicSessionId = `issue-test-${randomUUID()}` -}) - -afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - delete process.env.CLAUDE_CONFIG_DIR - // Restore HOME — individual tests may have set it to tmpDir. - if (_originalHomeForIssueSuite === undefined) { - delete process.env.HOME - } else { - process.env.HOME = _originalHomeForIssueSuite - } -}) - -// After this suite finishes, switch off our dynamic mode so any subsequent -// test file (e.g. launchAutofixPr.test.ts) that imports bootstrap/state.js -// gets the static values its suite expects. Bun's mock.module is global and -// our mock won the registration race; this flag flips behavior post-suite. -afterAll(() => { - useIssueDynamicState = false -}) - -// ── Helpers ── -type CallFn = ( - args: string, - ctx?: never, -) => Promise<{ type: string; value: string }> - -async function getCallFn(): Promise { - const mod = await import('../index.js') - const loaded = await ( - mod.default as unknown as { load: () => Promise<{ call: CallFn }> } - ).load() - return loaded.call.bind(loaded) as CallFn -} - -async function writeSessionLog(entries?: string[]): Promise { - const { sanitizePath } = await import('../../../utils/path.js') - const { getSessionId, getOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - const sessionId = getSessionId() - const cwd = getOriginalCwd() - const encoded = sanitizePath(cwd) - const dir = join(claudeDir, 'projects', encoded) - mkdirSync(dir, { recursive: true }) - const content = entries ?? [ - JSON.stringify({ role: 'user', content: 'Fix the login bug' }), - JSON.stringify({ - role: 'assistant', - content: [{ type: 'text', text: 'I will investigate' }], - }), - ] - writeFileSync(join(dir, `${sessionId}.jsonl`), content.join('\n') + '\n') -} - -describe('issue command — metadata', () => { - test('command has correct name and type', async () => { - const mod = await import('../index.js') - const cmd = mod.default - expect(cmd.name).toBe('issue') - expect(cmd.type).toBe('local') - expect( - (cmd as unknown as { supportsNonInteractive: boolean }) - .supportsNonInteractive, - ).toBe(true) - }) - - test('isEnabled returns true', async () => { - const mod = await import('../index.js') - expect(mod.default.isEnabled?.()).toBe(true) - }) -}) - -describe('issue command — parseIssueArgs', () => { - test('--label without value → parse error message', async () => { - const call = await getCallFn() - const result = await call('--label') - expect(result.type).toBe('text') - expect(result.value).toContain('--label requires a value') - }) - - test('--label with empty next flag → parse error', async () => { - const call = await getCallFn() - const result = await call('--label --public') - expect(result.type).toBe('text') - expect(result.value).toContain('--label requires a value') - }) - - test('--assignee without value → parse error message', async () => { - const call = await getCallFn() - const result = await call('--assignee') - expect(result.type).toBe('text') - expect(result.value).toContain('--assignee requires a value') - }) - - test('-l without value → parse error', async () => { - const call = await getCallFn() - const result = await call('-l') - expect(result.type).toBe('text') - expect(result.value).toContain('--label requires a value') - }) - - test('-a without value → parse error', async () => { - const call = await getCallFn() - const result = await call('-a') - expect(result.type).toBe('text') - expect(result.value).toContain('--assignee requires a value') - }) - - test('unknown flag → parse error', async () => { - const call = await getCallFn() - const result = await call('--unknown Fix bug') - expect(result.type).toBe('text') - expect(result.value).toContain('Unknown flag') - }) -}) - -describe('issue command — no title', () => { - test('empty args → usage hint', async () => { - const call = await getCallFn() - const result = await call('') - expect(result.type).toBe('text') - expect(result.value).toContain('Usage') - }) - - test('whitespace-only args → usage hint', async () => { - const call = await getCallFn() - const result = await call(' ') - expect(result.type).toBe('text') - expect(result.value).toContain('Usage') - }) -}) - -describe('issue command — with title', () => { - test('title only → returns some text result', async () => { - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - expect(result.value.length).toBeGreaterThan(0) - }) - - test('title with --label → returns some text result', async () => { - const call = await getCallFn() - const result = await call('--label bug Fix login bug') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - expect(result.value.length).toBeGreaterThan(0) - }) - - test('title with --assignee → returns some text result', async () => { - const call = await getCallFn() - const result = await call('--assignee alice Fix login bug') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - expect(result.value.length).toBeGreaterThan(0) - }) - - test('title with both --label and --assignee → returns some text result', async () => { - const call = await getCallFn() - const result = await call('--label bug --assignee alice Fix login bug') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - expect(result.value.length).toBeGreaterThan(0) - }) - - test('title with log file present → exercises transcript summary paths', async () => { - await writeSessionLog() - const call = await getCallFn() - const result = await call('Fix login bug') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - expect(result.value.length).toBeGreaterThan(0) - }) - - test('transcript with array content → covers array branch in getTranscriptSummary', async () => { - await writeSessionLog([ - JSON.stringify({ - role: 'user', - content: [{ type: 'text', text: 'What is the issue?' }], - }), - // tool_result with is_error → covers error collection - JSON.stringify({ - role: 'user', - content: [ - { - type: 'tool_result', - tool_use_id: 'tu1', - is_error: true, - content: 'Command failed', - }, - ], - }), - // malformed line - 'NOT_JSON{{{', - ]) - const call = await getCallFn() - const result = await call('Test issue') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - }) - - test('transcript with only system entries → no conversation content', async () => { - await writeSessionLog([ - JSON.stringify({ role: 'system', content: 'system prompt' }), - ]) - const call = await getCallFn() - const result = await call('Test issue empty summary') - expect(result.type).toBe('text') - expect(typeof result.value).toBe('string') - }) - - // ── H5 regression: browser fallback URL body must be ≤ 4096 chars before encode ── - test('H5: URL-encoded body is capped at 4096 chars when session summary is very long', async () => { - // Write a log with a very long user message to ensure summary exceeds 4096 chars - const longText = 'A'.repeat(6000) - await writeSessionLog([ - JSON.stringify({ role: 'user', content: longText }), - JSON.stringify({ - role: 'assistant', - content: [{ type: 'text', text: longText }], - }), - ]) - const call = await getCallFn() - // No gh, no remote → falls into browser fallback path - const result = await call('Some Long Issue Title') - expect(result.type).toBe('text') - if (result.type === 'text') { - // Extract the URL from the output (if present) - const urlMatch = result.value.match(/https?:\/\/\S+/) - if (urlMatch) { - // The URL must be ≤ ~8KB after encoding. Check the body= parameter specifically. - const bodyParam = urlMatch[0].match(/[?&]body=([^&]*)/) - if (bodyParam) { - // decoded body text must be ≤ 4096 chars (plus truncation suffix) - const decoded = decodeURIComponent(bodyParam[1]) - expect(decoded.length).toBeLessThanOrEqual(4096 + 60) // 60 for truncation suffix - } - } - } - }) - - test('long body session log does not crash', async () => { - // Long session log content exercises the body-formatting branches. - const longText = 'x'.repeat(4500) - const entries: string[] = [] - for (let i = 0; i < 50; i++) { - entries.push(JSON.stringify({ role: 'user', content: longText })) - entries.push( - JSON.stringify({ - role: 'assistant', - content: [{ type: 'text', text: longText }], - }), - ) - } - await writeSessionLog(entries) - process.env.HOME = tmpDir - const call = await getCallFn() - const result = await call('Long body issue') - expect(result.type).toBe('text') - }) - - test('handles unreadable session log gracefully', async () => { - // Write a corrupt log file that triggers parse errors but exists - const { sanitizePath } = await import('../../../utils/path.js') - const { getSessionId, getOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - const sessionId = getSessionId() - const cwd = getOriginalCwd() - const encoded = sanitizePath(cwd) - const dir = join(claudeDir, 'projects', encoded) - mkdirSync(dir, { recursive: true }) - // Empty / whitespace-only file: should not crash, will produce empty session text - writeFileSync(join(dir, `${sessionId}.jsonl`), '') - const call = await getCallFn() - const result = await call('Issue from empty session') - expect(result.type).toBe('text') - }) - - test('template directory unreadable returns null template (graceful)', async () => { - // Create issue-templates directory with no .md files (only a non-readable subfile name) - const templatesDir = join(claudeDir, 'issue-templates') - mkdirSync(templatesDir, { recursive: true }) - writeFileSync(join(templatesDir, 'README.txt'), 'not a markdown template') - await writeSessionLog() - const call = await getCallFn() - // Should still succeed without template — template loading is best-effort - const result = await call('Issue without templates') - expect(result.type).toBe('text') - }) - - test('session log read failure caught (path is a directory)', async () => { - const { sanitizePath } = await import('../../../utils/path.js') - const { getSessionId, getOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - const sessionId = getSessionId() - const cwd = getOriginalCwd() - const encoded = sanitizePath(cwd) - const dir = join(claudeDir, 'projects', encoded) - mkdirSync(dir, { recursive: true }) - // Create a directory at the log path so readFileSync throws EISDIR. - mkdirSync(join(dir, `${sessionId}.jsonl`), { recursive: true }) - const call = await getCallFn() - const result = await call('Issue with broken log') - expect(result.type).toBe('text') - if (result.type === 'text') { - // Should still produce output even when session log is unreadable - expect(result.value.length).toBeGreaterThan(0) - } - }) - - test('detectIssueTemplate picks up first .md template from .github/ISSUE_TEMPLATE', async () => { - // Issue command uses getOriginalCwd() (NOT process.cwd) — override via - // setOriginalCwd. Restore after to avoid polluting other tests. - const { getOriginalCwd, setOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE') - mkdirSync(githubDir, { recursive: true }) - writeFileSync( - join(githubDir, 'bug.md'), - '---\nname: Bug\nabout: Bug report\n---\n## Steps to reproduce\n\nSteps...\n', - ) - writeFileSync( - join(githubDir, 'config.yml'), - 'blank_issues_enabled: false\n', - ) - await writeSessionLog() - const origCwd = getOriginalCwd() - try { - setOriginalCwd(tmpDir) - const call = await getCallFn() - const result = await call('Issue with bug template') - expect(result.type).toBe('text') - } finally { - setOriginalCwd(origCwd) - } - }) - - test('detectIssueTemplate returns null when only non-md templates present', async () => { - const { getOriginalCwd, setOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE') - mkdirSync(githubDir, { recursive: true }) - writeFileSync(join(githubDir, 'bug.yml'), 'name: Bug') - await writeSessionLog() - const origCwd = getOriginalCwd() - try { - setOriginalCwd(tmpDir) - const call = await getCallFn() - const result = await call('Issue YAML-only template') - expect(result.type).toBe('text') - } finally { - setOriginalCwd(origCwd) - } - }) - - test('detectIssueTemplate returns null when ISSUE_TEMPLATE is empty', async () => { - const { getOriginalCwd, setOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - const githubDir = join(tmpDir, '.github', 'ISSUE_TEMPLATE') - mkdirSync(githubDir, { recursive: true }) - await writeSessionLog() - const origCwd = getOriginalCwd() - try { - setOriginalCwd(tmpDir) - const call = await getCallFn() - const result = await call('Issue empty template dir') - expect(result.type).toBe('text') - } finally { - setOriginalCwd(origCwd) - } - }) - - test('detectIssueTemplate readdir failure is caught (catch branch)', async () => { - const { getOriginalCwd, setOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - // Create the ISSUE_TEMPLATE path as a regular file (not a directory) so - // existsSync returns true but readdirSync throws ENOTDIR. - const githubDir = join(tmpDir, '.github') - mkdirSync(githubDir, { recursive: true }) - writeFileSync(join(githubDir, 'ISSUE_TEMPLATE'), 'not-a-directory') - await writeSessionLog() - const origCwd = getOriginalCwd() - try { - setOriginalCwd(tmpDir) - const call = await getCallFn() - const result = await call('Issue with broken template path') - expect(result.type).toBe('text') - } finally { - setOriginalCwd(origCwd) - } - }) - - test('long body triggers truncation + draft save', async () => { - const { getOriginalCwd, setOriginalCwd } = await import( - '../../../bootstrap/state.js' - ) - // getTranscriptSummary clips each user/assistant text to 200 chars and - // joins only the last 10 entries, so it can never organically exceed - // ~2.7 KB. To exercise the >4096-char branch (lines 362-375), we - // temporarily neutralise Array.prototype.slice for the `slice(-N)` - // pattern (negative-only first arg, no second arg). String.slice and - // positive Array.slice keep working, and we restore the original in - // finally so no state leaks across tests. - const longText = 'x'.repeat(200) - const entries: string[] = [] - for (let i = 0; i < 100; i++) { - entries.push(JSON.stringify({ role: 'user', content: longText })) - entries.push( - JSON.stringify({ - role: 'assistant', - content: [{ type: 'text', text: longText }], - }), - ) - } - await writeSessionLog(entries) - process.env.HOME = tmpDir - const origCwd = getOriginalCwd() - const origSlice = Array.prototype.slice - // Force the fallback URL branch with a *parsed* GitHub remote so the - // draft-path output (lines 392-393) is reached: git remote returns a - // GitHub URL but `gh --version` fails so hasGh is false. - // - // Spread+flag pattern: the previous bare `mock.module(...)` here leaked - // a stub child_process to every later test file in the same `bun test` - // run (mock.module is process-global, last-write-wins). Now we register - // a flag-gated mock that delegates to real child_process by default, and - // only flips on for THIS test's body. - mock.module('node:child_process', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const real = require('node:child_process') as Record - return { - ...real, - default: real, - execFile: ((...args: unknown[]) => { - if (useIssueLongBodyCpStubs) { - const cb = args[3] as - | ((e: Error | null, s: string, e2: string) => void) - | undefined - if (cb) cb(new Error('ENOENT'), '', '') - return - } - return (real.execFile as (...a: unknown[]) => unknown)(...args) - }) as typeof real.execFile, - execFileSync: ((...args: unknown[]) => { - if (useIssueLongBodyCpStubs) { - const cmd = args[0] as string - if (cmd === 'git') - return Buffer.from('https://github.com/owner/repo.git\n') - throw new Error('ENOENT') - } - return (real.execFileSync as (...a: unknown[]) => unknown)(...args) - }) as typeof real.execFileSync, - } - }) - useIssueLongBodyCpStubs = true - Array.prototype.slice = function ( - this: unknown[], - start?: number, - end?: number, - ): unknown[] { - // For `summaryParts.slice(-10)` and `errors.slice(-3)` (negative - // start, no end) return the full array so summaryParts.length - // determines the body size. - if (typeof start === 'number' && start < 0 && end === undefined) { - return Array.from(this) - } - return origSlice.call(this, start, end) as unknown[] - } as typeof Array.prototype.slice - try { - setOriginalCwd(tmpDir) - const call = await getCallFn() - const result = await call('Long body for draft save') - expect(result.type).toBe('text') - if (result.type === 'text') { - // Draft path is reported when body > 4096 chars (line 393 branch). - expect(result.value).toContain('Full issue body saved to') - } - } finally { - Array.prototype.slice = origSlice - setOriginalCwd(origCwd) - useIssueLongBodyCpStubs = false - } - }) -})