mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
test: add golden E2E tests for source control workflows (#14260)
* test: add golden E2E tests for source control workflows - Tests core source control interactions: file edit/save, commit staging, and diff viewing - Integrated into CI/CD pipelines for Linux, macOS, and Windows - Includes helper utilities for test setup and worktree management * test(e2e): verify golden commit author and fix test flakiness - Configure git author name/email at worktree level during setup - Verify commits are made with correct author details in assertions - Add explicit timeouts to file visibility waits and git status polling - Fix test ordering to seed edits after source control is open - Simplify git status refresh logic to rely on automatic updates * Add rollback to createGoldenWorktree on setup failure Cleanup callbacks only register after setup succeeds. When a config command fails, the half-built worktree and branch leak into later test runs, causing flakiness. Now we roll back immediately and re-throw the setup error. * test(e2e): match explorer rows after the git status badge appears The golden file-save spec used an exact /^README.md$/ filter. After save, the explorer row text becomes "README.md M", so reopen clicked nothing. * test: strengthen golden worktree setup verification - Track working directory in git call inspection to verify correct execution context - Verify user.name/email config applies to worktree-specific settings, not repo - Add exhaustive setup call sequence assertions to catch setup/rollback leaks
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user