mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
fix: 删除 issues 测试用例导致真提交了
This commit is contained in:
@@ -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<symbol, unknown>)[
|
|
||||||
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<symbol, unknown> & ((...a: unknown[]) => unknown)
|
|
||||||
;(wrappedIssueGhExecFile as Record<symbol, unknown>)[
|
|
||||||
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<string, unknown>
|
|
||||||
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<string, unknown>
|
|
||||||
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<CallFn> {
|
|
||||||
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<void> {
|
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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<symbol, unknown>)[
|
|
||||||
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<symbol, unknown> & ((...a: unknown[]) => unknown)
|
|
||||||
;(wrappedIssueTemplateExecFile as Record<symbol, unknown>)[
|
|
||||||
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<string, unknown>
|
|
||||||
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<string, unknown>
|
|
||||||
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<CallFn> {
|
|
||||||
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, string>): 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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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<CallFn> {
|
|
||||||
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<void> {
|
|
||||||
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<string, unknown>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user