Files
orca/tests/e2e/source-control-commit-message-ai.spec.ts
T
Jinjing 0d9eeed7a9 fix(source-control-ai): resolve {linkedIssue} adversarial re-review findings
Addresses all 8 findings from the {linkedIssue} code re-review
(2 minor, 6 nit, 0 critical, 0 major); none deferred.

Behavior:
- Revert the variableOverrides parameter on planSourceControlTextGeneration.
  Its result is a Save/Generate gate, not a preview, and the recipe it
  validates is saved repo- or globally scoped -- so rendering it against the
  active workspace disabled both buttons with "Command input is empty." for a
  {linkedIssue}-only template on any unlinked workspace, blocking a global
  settings write. Validation is synthetic again; chip previews are unchanged.
- Make the chip hover card additive instead of either/or. A supplied preview
  now appends a "This workspace" sample below the description and Example
  rather than replacing them, so the GitLab-empty and dangling `Fixes #`
  warning survives on the two dialogs where recipes are actually authored.
  basePrompt keeps its preview-only shape, where the preview is the content.

Structure:
- Drop the registry re-export from source-control-ai-actions.ts and move the
  last two consumers onto source-control-ai-action-variables, so one import
  path per symbol keeps a grep of the registry's consumers complete.
- Split the registry/helper suites into source-control-ai-action-variables.test.ts
  so each test file mirrors its module.

Tests:
- Cover the Save/Generate gate at the canRunGeneration level for a bare
  {linkedIssue} recipe on linked and unlinked workspaces, with a negative
  control proving the buttons can still be disabled.
- Cover the chip hover card directly; the dialog tests mock it away.
- Guard the PR mismatched-id test with toHaveLength(1) so it cannot pass
  vacuously on an unrelated early return.
- Add an unlinked-workspace e2e case (saw-issue:empty), which is what
  distinguishes a real resolver from one that always returns a number.
  Spec now runs green: 3 passed.
- Rename the dialog test that claimed a synthetic-fallback assertion it did
  not make, and route its renders through one shared helper.

Docs are worktree-local (.gitignore:84 ignores docs/**): the design doc's
plan-preview and chip-surface claims, the manual QA rows, and both reviews'
statements about pre-existing PR-handler tests are corrected there.
2026-07-25 14:06:19 -07:00

170 lines
6.3 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { openSourceControlForWorktree } from './helpers/worktree-registration'
function createWorktreeWithStagedChange(repoPath: string): {
branchName: string
worktreePath: string
} {
const branchName = `e2e-ai-commit-${Date.now()}-${Math.random().toString(16).slice(2)}`
const worktreePath = path.join(os.tmpdir(), branchName)
execFileSync('git', ['worktree', 'add', worktreePath, '-b', branchName], {
cwd: repoPath,
stdio: 'pipe'
})
writeFileSync(
path.join(worktreePath, 'README.md'),
'# AI Commit Message E2E\n\nGenerated flow.\n'
)
execFileSync('git', ['add', 'README.md'], { cwd: worktreePath, stdio: 'pipe' })
return { branchName, worktreePath }
}
function cleanupWorktree(repoPath: string, worktreePath: string, branchName: string): void {
try {
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
cwd: repoPath,
stdio: 'pipe'
})
} catch {
rmSync(worktreePath, { recursive: true, force: true })
}
try {
execFileSync('git', ['branch', '-D', branchName], { cwd: repoPath, stdio: 'pipe' })
} catch {
// The branch is already gone when git prunes it with the worktree.
}
}
/**
* Echoes back whichever issue number reached the prompt, so the assertion covers the
* whole chain (renderer → IPC → worktree meta → template render → agent stdin) rather
* than any single hop.
*/
function writeLinkedIssueEchoGenerator(scriptPath: string): void {
writeFileSync(
scriptPath,
[
'const chunks = []',
"process.stdin.on('data', (chunk) => chunks.push(chunk))",
"process.stdin.on('end', () => {",
" const prompt = Buffer.concat(chunks).toString('utf8')",
' const match = prompt.match(/ORCA_E2E_ISSUE=(\\d*)/)',
" process.stdout.write(`saw-issue:${match ? match[1] || 'empty' : 'missing'}`)",
'})'
].join('\n')
)
}
test.describe('Source Control AI commit messages', () => {
// Why: the unlinked case is what separates a real resolver from one that always returns a
// number — `saw-issue:empty` proves the token expanded to nothing rather than staying literal.
for (const { label, linkedIssue, expected } of [
{ label: 'substitutes the workspace-linked issue into', linkedIssue: 4242, expected: '4242' },
{
label: 'expands the issue token to nothing for an unlinked workspace in',
linkedIssue: null,
expected: 'empty'
}
]) {
test(`${label} the commit-message recipe`, async ({ orcaPage, testRepoPath }) => {
const { branchName, worktreePath } = createWorktreeWithStagedChange(testRepoPath)
const generatorPath = path.join(os.tmpdir(), `${branchName}-linked-issue-generator.cjs`)
writeLinkedIssueEchoGenerator(generatorPath)
try {
await waitForSessionReady(orcaPage)
await openSourceControlForWorktree(orcaPage, testRepoPath, worktreePath)
await orcaPage.evaluate(
async ({ generatorPath, linkedIssue }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const worktreeId = store.getState().activeWorktreeId
if (!worktreeId) {
throw new Error('No worktree was active after opening Source Control')
}
await window.api.worktrees.updateMeta({ worktreeId, updates: { linkedIssue } })
const customAgentCommand = `node ${JSON.stringify(generatorPath)}`
await store.getState().updateSettings({
activeRuntimeEnvironmentId: null,
sourceControlAi: {
enabled: true,
agentId: 'custom' as const,
selectedModelByAgent: {},
selectedThinkingByModel: {},
customAgentCommand,
instructionsByOperation: {},
actions: {
commitMessage: {
agentId: 'custom' as const,
commandInputTemplate: 'ORCA_E2E_ISSUE={linkedIssue}\n\n{basePrompt}'
}
}
}
})
},
{ generatorPath, linkedIssue }
)
const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' })
await expect(textarea).toBeVisible({ timeout: 10_000 })
const generate = orcaPage.getByRole('button', { name: 'Generate commit message with AI' })
await expect(generate).toBeEnabled()
await generate.click()
await expect(textarea).toHaveValue(`saw-issue:${expected}`, { timeout: 15_000 })
} finally {
rmSync(generatorPath, { force: true })
cleanupWorktree(testRepoPath, worktreePath, branchName)
}
})
}
test('generates a commit message from staged changes through the Source Control UI', async ({
orcaPage,
testRepoPath
}) => {
const { branchName, worktreePath } = createWorktreeWithStagedChange(testRepoPath)
const agentCommand =
'node -e "setTimeout(() => process.stdout.write(\'Add generated E2E message\'), 250)"'
try {
await waitForSessionReady(orcaPage)
await openSourceControlForWorktree(orcaPage, testRepoPath, worktreePath, {
commitMessageAi: {
enabled: true,
agentId: 'custom',
selectedModelByAgent: {},
selectedThinkingByModel: {},
customPrompt: '',
customAgentCommand: agentCommand
}
})
const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' })
await expect(textarea).toBeVisible({ timeout: 10_000 })
await expect(textarea).toHaveValue('')
const generate = orcaPage.getByRole('button', { name: 'Generate commit message with AI' })
await expect(generate).toBeVisible()
await expect(generate).toBeEnabled()
await generate.click()
await expect(
orcaPage.getByRole('button', { name: 'Stop generating commit message' })
).toBeVisible()
await expect(textarea).toHaveValue('Add generated E2E message', { timeout: 10_000 })
} finally {
cleanupWorktree(testRepoPath, worktreePath, branchName)
}
})
})