diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index fe443880237..205faa49957 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -72,6 +72,7 @@ jobs: fi xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on macOS if: runner.os == 'macOS' @@ -82,6 +83,7 @@ jobs: fi env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on Windows if: runner.os == 'Windows' @@ -93,6 +95,7 @@ jobs: if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts } + pnpm run --if-present test:e2e:source-control-golden - name: Upload Playwright traces if: failure() diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 8e0a6c97812..c881765b340 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -879,12 +879,20 @@ jobs: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden + - name: Run source-control golden on Linux + if: runner.os == 'Linux' + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden + - name: Run terminal rendering golden on macOS if: runner.os == 'macOS' run: | env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden + - name: Run source-control golden on macOS + if: runner.os == 'macOS' + run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden + - name: Run fresh-startup golden on Windows if: runner.os == 'Windows' shell: pwsh @@ -892,6 +900,7 @@ jobs: $env:SKIP_BUILD = '1' $env:ORCA_E2E_FORWARD_APP_LOGS = '1' pnpm run --if-present test:e2e:windows-fresh-startup-golden + pnpm run --if-present test:e2e:source-control-golden - name: Upload Playwright traces if: failure() diff --git a/package.json b/package.json index 6f7be4e6f4b..368e3660de6 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:source-control-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-file-open-edit-save.spec.ts tests/e2e/golden-source-control-commit.spec.ts tests/e2e/golden-source-control-open-diff.spec.ts --grep @golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", diff --git a/tests/e2e/golden-file-open-edit-save.spec.ts b/tests/e2e/golden-file-open-edit-save.spec.ts new file mode 100644 index 00000000000..271025f7aa8 --- /dev/null +++ b/tests/e2e/golden-file-open-edit-save.spec.ts @@ -0,0 +1,68 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + activateGoldenWorktree, + cleanupGoldenWorktree, + createGoldenWorktree +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +const README_PATH = 'README.md' + +test('@golden opens, edits, saves, and reopens a tracked file', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createGoldenWorktree(testRepoPath, 'file-save') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + const sentinel = `Golden file save ${Date.now()}` + const readmePath = path.join(fixture.worktreePath, README_PATH) + + await waitForSessionReady(orcaPage) + await activateGoldenWorktree(orcaPage, testRepoPath, fixture.worktreePath) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('source-control') + state?.setRightSidebarOpen(true) + }) + await orcaPage.getByRole('button', { name: 'Explorer' }).click() + + const explorer = orcaPage.locator('[data-orca-explorer-shell]') + // Why: after save the row's full text is "README.md M" from the git badge. + const readmeRow = explorer.locator('[data-file-explorer-row]').filter({ + has: orcaPage.locator('[data-file-explorer-row-name]').getByText(README_PATH, { exact: true }) + }) + await expect(readmeRow).toBeVisible({ timeout: 10_000 }) + await readmeRow.click() + + await expect(orcaPage.locator('.editor-header-path').first()).toContainText(README_PATH, { + timeout: 20_000 + }) + const editor = orcaPage.locator('.rich-markdown-editor') + await expect(editor).toBeVisible({ timeout: 25_000 }) + await expect(editor).toContainText('Orca E2E Test Repo') + await editor.click() + await orcaPage.keyboard.press('ControlOrMeta+End') + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.type(sentinel) + await orcaPage.keyboard.press('ControlOrMeta+S') + + await expect.poll(() => readFileSync(readmePath, 'utf8'), { timeout: 10_000 }).toContain(sentinel) + const readmeTab = orcaPage.locator('[data-tab-id]').filter({ hasText: README_PATH }).last() + await readmeTab.getByRole('button', { name: 'Close tab' }).click() + await expect( + orcaPage.locator('.editor-header-path').filter({ hasText: README_PATH }) + ).toHaveCount(0) + + await readmeRow.click() + await expect(orcaPage.locator('.rich-markdown-editor')).toContainText(sentinel, { + timeout: 25_000 + }) + await expect(explorer).toBeVisible() + await expect(readmeRow).toBeVisible() + await expect( + orcaPage.getByText(path.basename(testRepoPath), { exact: true }).first() + ).toBeVisible() +}) diff --git a/tests/e2e/golden-source-control-commit.spec.ts b/tests/e2e/golden-source-control-commit.spec.ts new file mode 100644 index 00000000000..70fb7c4e3e1 --- /dev/null +++ b/tests/e2e/golden-source-control-commit.spec.ts @@ -0,0 +1,67 @@ +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + cleanupGoldenWorktree, + createGoldenWorktree, + GOLDEN_CHANGED_PATH, + GOLDEN_GIT_AUTHOR_EMAIL, + GOLDEN_GIT_AUTHOR_NAME, + installPassingNodePreCommitHook, + openGoldenSourceControl, + seedGoldenSourceEdit +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +test('@golden stages and commits a file through Source Control', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createGoldenWorktree(testRepoPath, 'commit') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + seedGoldenSourceEdit(fixture.worktreePath) + const hookMarkerPath = installPassingNodePreCommitHook(fixture) + + await waitForSessionReady(orcaPage) + await openGoldenSourceControl(orcaPage, testRepoPath, fixture) + + const unstagedRow = orcaPage + .locator('[data-testid="source-control-entry"][data-source-control-area="unstaged"]') + .filter({ hasText: path.basename(GOLDEN_CHANGED_PATH) }) + await expect(unstagedRow).toBeVisible() + const stageButton = unstagedRow.getByRole('button', { name: 'Stage' }) + await stageButton.focus() + await stageButton.press('Enter') + await expect(unstagedRow).toHaveCount(0, { timeout: 10_000 }) + + const stagedRow = orcaPage + .locator('[data-testid="source-control-entry"][data-source-control-area="staged"]') + .filter({ hasText: path.basename(GOLDEN_CHANGED_PATH) }) + await expect(stagedRow).toBeVisible({ timeout: 10_000 }) + await orcaPage.getByRole('textbox', { name: 'Commit message' }).fill('test: golden daily loop') + await orcaPage.getByRole('button', { name: 'Commit', exact: true }).click() + + await expect(stagedRow).toHaveCount(0, { timeout: 20_000 }) + await expect + .poll( + () => + execFileSync('git', ['status', '--porcelain'], { + cwd: fixture.worktreePath, + encoding: 'utf8' + }), + { timeout: 20_000, message: 'Golden commit worktree did not become clean' } + ) + .toBe('') + expect( + execFileSync('git', ['show', '-s', '--format=%an%n%ae%n%s', 'HEAD'], { + cwd: fixture.worktreePath, + encoding: 'utf8' + }).trim() + ).toBe(`${GOLDEN_GIT_AUTHOR_NAME}\n${GOLDEN_GIT_AUTHOR_EMAIL}\ntest: golden daily loop`) + await expect.poll(() => existsSync(hookMarkerPath), { timeout: 20_000 }).toBe(true) + await expect( + orcaPage.locator('[data-sonner-toast]').filter({ hasText: /node|command not found|cmd\.exe/i }) + ).toHaveCount(0) +}) diff --git a/tests/e2e/golden-source-control-open-diff.spec.ts b/tests/e2e/golden-source-control-open-diff.spec.ts new file mode 100644 index 00000000000..1c7f2735889 --- /dev/null +++ b/tests/e2e/golden-source-control-open-diff.spec.ts @@ -0,0 +1,52 @@ +import { realpathSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + cleanupGoldenWorktree, + createGoldenWorktree, + GOLDEN_ADDED_LINE, + GOLDEN_CHANGED_PATH, + GOLDEN_REMOVED_LINE, + openGoldenSourceControl, + seedGoldenSourceEdit +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +test('@golden opens an unstaged file diff from Source Control', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createGoldenWorktree(testRepoPath, 'open-diff') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + + await waitForSessionReady(orcaPage) + await openGoldenSourceControl(orcaPage, testRepoPath, fixture) + seedGoldenSourceEdit(fixture.worktreePath) + + const changedFile = orcaPage + .locator('[data-testid="source-control-entry"]') + .filter({ hasText: path.basename(GOLDEN_CHANGED_PATH) }) + await expect(changedFile).toBeVisible({ timeout: 15_000 }) + await changedFile.click() + + await expect(orcaPage.locator('.monaco-diff-editor')).toBeVisible({ timeout: 20_000 }) + await expect( + orcaPage + .locator('.original-in-monaco-diff-editor .view-line') + .filter({ hasText: GOLDEN_REMOVED_LINE }) + ).toBeVisible() + await expect( + orcaPage + .locator('.modified-in-monaco-diff-editor .view-line') + .filter({ hasText: GOLDEN_ADDED_LINE }) + ).toBeVisible() + await expect(orcaPage.locator('.editor-header-path').first()).toHaveAttribute( + 'title', + `${realpathSync(path.join(fixture.worktreePath, GOLDEN_CHANGED_PATH))} (diff)` + ) + + const probe = orcaPage.getByRole('button', { name: /Source Control/ }) + await probe.focus() + await expect(probe).toBeFocused() +}) diff --git a/tests/e2e/helpers/golden-source-control.ts b/tests/e2e/helpers/golden-source-control.ts new file mode 100644 index 00000000000..a0c2cf99300 --- /dev/null +++ b/tests/e2e/helpers/golden-source-control.ts @@ -0,0 +1,183 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' + +export const GOLDEN_CHANGED_PATH = 'src/index.ts' +export const GOLDEN_REMOVED_LINE = 'export const hello = "world"' +export const GOLDEN_ADDED_LINE = 'export const hello = "golden daily loop"' +export const GOLDEN_GIT_AUTHOR_NAME = 'Orca E2E' +export const GOLDEN_GIT_AUTHOR_EMAIL = 'orca-e2e@example.invalid' +const GOLDEN_PRE_COMMIT_MARKER = '.e2e-pre-commit-ran' + +export type GoldenWorktree = { + branchName: string + hooksPath?: string + worktreePath: string +} + +export function createGoldenWorktree(repoPath: string, label: string): GoldenWorktree { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}` + const branchName = `e2e-golden-${label}-${suffix}` + const worktreePath = path.join(os.tmpdir(), branchName) + execFileSync('git', ['worktree', 'add', worktreePath, '-b', branchName], { + cwd: repoPath, + stdio: 'pipe' + }) + const fixture: GoldenWorktree = { branchName, worktreePath } + // Callers only register cleanup once this returns, so roll back here or the + // half-built worktree and branch leak into every later run. + try { + execFileSync('git', ['config', 'extensions.worktreeConfig', 'true'], { + cwd: worktreePath, + stdio: 'pipe' + }) + execFileSync('git', ['config', '--worktree', 'user.name', GOLDEN_GIT_AUTHOR_NAME], { + cwd: worktreePath, + stdio: 'pipe' + }) + execFileSync('git', ['config', '--worktree', 'user.email', GOLDEN_GIT_AUTHOR_EMAIL], { + cwd: worktreePath, + stdio: 'pipe' + }) + } catch (setupError) { + try { + cleanupGoldenWorktree(repoPath, fixture) + } catch { + // Keep the setup failure as the reported cause. + } + throw setupError + } + return fixture +} + +export function cleanupGoldenWorktree(repoPath: string, fixture: GoldenWorktree): void { + try { + execFileSync('git', ['config', '--worktree', '--unset-all', 'core.hooksPath'], { + cwd: fixture.worktreePath, + stdio: 'pipe' + }) + } catch { + // The hook setting may not have been installed before setup failed. + } + if (fixture.hooksPath) { + rmSync(fixture.hooksPath, { recursive: true, force: true }) + } + try { + execFileSync('git', ['worktree', 'remove', '--force', fixture.worktreePath], { + cwd: repoPath, + stdio: 'pipe' + }) + } catch { + rmSync(fixture.worktreePath, { recursive: true, force: true }) + execFileSync('git', ['worktree', 'prune'], { cwd: repoPath, stdio: 'pipe' }) + } + execFileSync('git', ['branch', '-D', fixture.branchName], { cwd: repoPath, stdio: 'pipe' }) +} + +export function seedGoldenSourceEdit(worktreePath: string): void { + const changedPath = path.join(worktreePath, GOLDEN_CHANGED_PATH) + const original = readFileSync(changedPath, 'utf8') + if (!original.includes(GOLDEN_REMOVED_LINE)) { + throw new Error(`Golden source fixture is missing: ${GOLDEN_REMOVED_LINE}`) + } + writeFileSync(changedPath, original.replace(GOLDEN_REMOVED_LINE, GOLDEN_ADDED_LINE)) +} + +export function installPassingNodePreCommitHook(fixture: GoldenWorktree): string { + const hooksPath = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-git-hooks-')) + const hookPath = path.join(hooksPath, 'pre-commit') + const markerPath = path.join(hooksPath, GOLDEN_PRE_COMMIT_MARKER) + fixture.hooksPath = hooksPath + execFileSync('git', ['config', '--worktree', 'core.hooksPath', hooksPath], { + cwd: fixture.worktreePath, + stdio: 'pipe' + }) + writeFileSync( + hookPath, + `#!/bin/sh\nnode -e "const fs = require('node:fs'); const path = require('node:path'); fs.writeFileSync(path.join(path.dirname(process.argv[1]), '${GOLDEN_PRE_COMMIT_MARKER}'), 'ran')" "$0"\n` + ) + chmodSync(hookPath, 0o755) + return markerPath +} + +export async function activateGoldenWorktree( + page: Page, + repoPath: string, + worktreePath: string +): Promise { + await expect + .poll( + () => + page.evaluate( + async ({ registeredRepoPath, targetWorktreePath }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await store.getState().fetchRepos() + const normalize = (value: string): string => { + const normalized = value + .replace(/\\/g, '/') + .replace(/^\/private(?=\/var\/)/, '') + .replace(/\/+$/, '') + return navigator.userAgent.includes('Windows') ? normalized.toLowerCase() : normalized + } + const repo = store + .getState() + .repos.find((entry) => normalize(entry.path) === normalize(registeredRepoPath)) + if (!repo) { + return false + } + await store.getState().fetchWorktrees(repo.id) + const worktree = (store.getState().worktreesByRepo[repo.id] ?? []).find( + (entry) => normalize(entry.path) === normalize(targetWorktreePath) + ) + if (!worktree) { + return false + } + store.getState().setActiveRepo(repo.id) + store.getState().setActiveWorktree(worktree.id) + return true + }, + { registeredRepoPath: repoPath, targetWorktreePath: worktreePath } + ), + { timeout: 10_000, message: `Golden worktree did not load: ${worktreePath}` } + ) + .toBe(true) +} + +export async function openGoldenSourceControl( + page: Page, + repoPath: string, + fixture: GoldenWorktree +): Promise { + await activateGoldenWorktree(page, repoPath, fixture.worktreePath) + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + state.setRightSidebarTab('explorer') + state.setRightSidebarOpen(true) + }) + await page.getByRole('button', { name: /Source Control/ }).click() + await expect(page.getByRole('textbox', { name: 'Commit message' })).toBeVisible({ + timeout: 10_000 + }) + await expect + .poll( + () => + page.evaluate(() => { + const state = window.__store?.getState() + return state?.activeWorktreeId + ? Object.hasOwn(state.gitStatusByWorktree, state.activeWorktreeId) + : false + }), + { timeout: 10_000, message: 'Automatic Git status refresh did not complete' } + ) + .toBe(true) +} diff --git a/tests/e2e/helpers/golden-source-control.unit.test.ts b/tests/e2e/helpers/golden-source-control.unit.test.ts new file mode 100644 index 00000000000..c607c3fa89e --- /dev/null +++ b/tests/e2e/helpers/golden-source-control.unit.test.ts @@ -0,0 +1,117 @@ +import { execFileSync } from 'node:child_process' +import os from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createGoldenWorktree, + GOLDEN_GIT_AUTHOR_EMAIL, + GOLDEN_GIT_AUTHOR_NAME +} from './golden-source-control' + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })) + +const execFileSyncMock = vi.mocked(execFileSync) + +type GitCall = { args: string[]; cwd?: string } + +/** Setup runs in the new worktree while rollback runs in the repo, so cwd is part of the contract. */ +const gitCallsFor = (): GitCall[] => + execFileSyncMock.mock.calls.map(([, args, options]) => ({ + args: (args ?? []) as string[], + cwd: (options as { cwd?: string } | undefined)?.cwd + })) + +/** Args-only view for lookups that locate a call rather than assert its full shape. */ +const gitArgsFor = (): string[][] => gitCallsFor().map((call) => call.args) + +/** Returns the thrown value itself; `toThrow` only compares messages, not identity. */ +const captureThrow = (run: () => void): unknown => { + try { + run() + return undefined + } catch (error) { + return error + } +} + +/** The worktree path and branch name are randomly suffixed, so read them back off the add call. */ +const worktreeAddTargets = (): { branchName: string; worktreePath: string } => { + const addArgs = gitArgsFor().find((args) => args[0] === 'worktree' && args[1] === 'add') + expect(addArgs).toBeDefined() + const [, , worktreePath, , branchName] = addArgs as string[] + return { branchName, worktreePath } +} + +describe('createGoldenWorktree', () => { + beforeEach(() => { + execFileSyncMock.mockReset() + }) + + /** A half-built worktree leaks into later runs unless both the worktree and the branch go away. */ + it('rolls back the worktree and branch when a configuration command fails', () => { + const setupError = new Error('git config --worktree unsupported') + execFileSyncMock.mockImplementation(((_file: string, args: string[]) => { + if (args[0] === 'config' && args[1] === 'extensions.worktreeConfig') { + throw setupError + } + return '' + }) as unknown as typeof execFileSync) + + expect(captureThrow(() => createGoldenWorktree('/repo', 'rollback'))).toBe(setupError) + + const { branchName, worktreePath } = worktreeAddTargets() + expect(branchName).toMatch(/^e2e-golden-rollback-/) + expect(gitCallsFor()).toContainEqual({ + args: ['worktree', 'remove', '--force', worktreePath], + cwd: '/repo' + }) + expect(gitCallsFor()).toContainEqual({ args: ['branch', '-D', branchName], cwd: '/repo' }) + }) + + /** Rollback must still be attempted in full, and its own failure must not mask the setup cause. */ + it('keeps the setup error when rollback itself fails', () => { + const setupError = new Error('git config --worktree unsupported') + execFileSyncMock.mockImplementation(((_file: string, args: string[]) => { + if (args[0] === 'config' && args[1] === 'extensions.worktreeConfig') { + throw setupError + } + if (args[0] === 'branch') { + throw new Error('branch is still checked out') + } + return '' + }) as unknown as typeof execFileSync) + + expect(captureThrow(() => createGoldenWorktree('/repo', 'rollback-fails'))).toBe(setupError) + + const { branchName, worktreePath } = worktreeAddTargets() + expect(branchName).toMatch(/^e2e-golden-rollback-fails-/) + expect(gitCallsFor()).toContainEqual({ + args: ['worktree', 'remove', '--force', worktreePath], + cwd: '/repo' + }) + expect(gitCallsFor()).toContainEqual({ args: ['branch', '-D', branchName], cwd: '/repo' }) + }) + + /** The identity config must land on the worktree, not the repo, or commits pick up the host author. */ + it('returns the fixture when every setup command succeeds', () => { + execFileSyncMock.mockReturnValue('') + + const fixture = createGoldenWorktree('/repo', 'happy') + + expect(fixture.branchName).toMatch(/^e2e-golden-happy-/) + expect(fixture.worktreePath).toBe(path.join(os.tmpdir(), fixture.branchName)) + // Exhaustive: also proves no rollback ran. + expect(gitCallsFor()).toEqual([ + { args: ['worktree', 'add', fixture.worktreePath, '-b', fixture.branchName], cwd: '/repo' }, + { args: ['config', 'extensions.worktreeConfig', 'true'], cwd: fixture.worktreePath }, + { + args: ['config', '--worktree', 'user.name', GOLDEN_GIT_AUTHOR_NAME], + cwd: fixture.worktreePath + }, + { + args: ['config', '--worktree', 'user.email', GOLDEN_GIT_AUTHOR_EMAIL], + cwd: fixture.worktreePath + } + ]) + }) +})