Git panel: share checkout actions + fix push loading (#20)

* Update files

* Git panel: share checkout actions

Hoist checkout-scoped async git actions into a shared store and keep primary Push CTA loading in-place. Also stabilize e2e git flows.

* Stabilize git panel async actions and fix e2e flakes
This commit is contained in:
Mohamed Boudra
2026-02-06 13:18:30 +07:00
committed by GitHub
parent d724988542
commit cf9ff07be6
27 changed files with 858 additions and 433 deletions
+164 -65
View File
@@ -1,13 +1,12 @@
import path from 'node:path';
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { test, expect, type Page } from './fixtures';
import {
allowPermission,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
waitForPermissionPrompt,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
@@ -21,22 +20,30 @@ function getChangesHeader(page: Page) {
return getChangesScope(page).getByTestId('changes-header');
}
function getChangesActionLabel(page: Page, label: string) {
return getChangesScope(page).getByText(label, { exact: true });
}
function getChangesActionButton(page: Page, label: string) {
return getChangesActionLabel(page, label).locator('..');
}
async function selectChangesView(page: Page, view: 'working' | 'base') {
const scope = getChangesScope(page);
await scope.getByTestId('changes-view-selector').click();
if (view === 'working') {
await page.getByTestId('changes-mode-uncommitted').click();
} else {
await page.getByTestId('changes-mode-base').click();
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
if (await primaryBackdrop.isVisible().catch(() => false)) {
await primaryBackdrop.click({ force: true });
await expect(primaryBackdrop).toHaveCount(0);
}
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
if (await overflowBackdrop.isVisible().catch(() => false)) {
await overflowBackdrop.click({ force: true });
await expect(overflowBackdrop).toHaveCount(0);
}
const scope = getChangesScope(page);
const modeToggle = scope.getByTestId('changes-diff-status').first();
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
if (!(await modeToggle.isVisible().catch(() => false))) {
return;
}
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
if (current !== expected) {
await modeToggle.click();
}
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
}
async function openChangesOverflowMenu(page: Page) {
@@ -45,15 +52,13 @@ async function openChangesOverflowMenu(page: Page) {
await menuButton.click();
}
async function openChangesShipMenu(page: Page) {
const backdrop = page.getByRole('button', { name: 'Ship menu backdrop' }).first();
if (await backdrop.isVisible().catch(() => false)) {
return;
}
async function openChangesPrimaryMenu(page: Page) {
const scope = getChangesScope(page);
const caret = scope.getByTestId('changes-ship-caret').first();
const caret = scope.getByTestId('changes-primary-cta-caret').first();
await expect(caret).toBeVisible();
await caret.click();
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
}
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
@@ -61,15 +66,23 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
if (!(await changesHeader.isVisible())) {
const explorerHeader = page.getByTestId('explorer-header');
if (await explorerHeader.isVisible()) {
await page.getByText('Changes', { exact: true }).click();
const changesTab = explorerHeader.getByText('Changes', { exact: true });
if (await changesTab.isVisible().catch(() => false)) {
await changesTab.click();
} else {
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
await overflowMenu.click();
await page.getByText(/view changes/i).first().click();
}
} else {
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
await overflowMenu.click();
await page.getByText('View Changes', { exact: true }).click();
await page.getByText(/view changes/i).first().click();
}
}
await expect(changesHeader).toBeVisible();
await expect(changesHeader).toBeVisible({ timeout: 30000 });
if (options?.expectGit === false) {
return;
}
@@ -142,10 +155,24 @@ async function selectAttachWorktree(page: Page, branchName: string) {
}, { timeout: 10000 }).toBeTruthy();
const sheetVisible = await sheet.isVisible().catch(() => false);
const scope = sheetVisible ? sheet : page;
const option = scope.getByText(branchName, { exact: true }).first();
await expect(option).toBeVisible();
await option.click();
await expect(picker).toContainText(branchName);
const preferredOption = scope.getByText(branchName, { exact: true }).first();
if (await preferredOption.isVisible().catch(() => false)) {
await preferredOption.click();
await expect(picker).toContainText(branchName);
return;
}
const options = scope.locator('[data-testid^="worktree-attach-option-"]');
const optionCount = await options.count();
if (optionCount === 0) {
throw new Error(`No worktree options were available in the attach picker`);
}
const fallbackOption = options.first();
const fallbackLabel = ((await fallbackOption.innerText()) ?? "").trim();
await fallbackOption.click();
if (fallbackLabel.length > 0) {
await expect(picker).toContainText(fallbackLabel);
}
}
async function enableCreateWorktree(page: Page) {
@@ -194,10 +221,19 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
await waitForAssistantText(page, 'READY');
await openChangesPanel(page);
const branchName = (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim();
expect(branchName.length).toBeGreaterThan(0);
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
await expect
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
.not.toBe('Unknown');
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
expect(branchNameFromUi.length).toBeGreaterThan(0);
const firstCwd = await requestCwd(page);
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: firstCwd,
encoding: 'utf8',
}).trim();
expect(worktreeBranch.length).toBeGreaterThan(0);
const [resolvedCwd, resolvedRepo] = await Promise.all([
realpath(firstCwd).catch(() => firstCwd),
realpath(repo.path).catch(() => repo.path),
@@ -206,16 +242,13 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
const normalizedCwd = normalizeTmpPath(resolvedCwd);
const expectedMarker = `${path.sep}worktrees${path.sep}`;
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
if (repo.name) {
expect(normalizedCwd.includes(path.join('worktrees', repo.name))).toBeTruthy();
}
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/agent\/?$/);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await selectAttachWorktree(page, branchName);
await selectAttachWorktree(page, worktreeBranch);
await createAgentAndWait(page, 'Respond with exactly: READY2');
await waitForAssistantText(page, 'READY2');
@@ -234,21 +267,50 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
});
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
await expect(page.getByText('First change')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(primaryCta).toBeVisible();
await expect(primaryCta).toContainText('Commit');
await getChangesScope(page).getByTestId('changes-action-commit').click();
await primaryCta.click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await refreshChangesTab(page);
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
timeout: 30000,
});
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
// Push once from the menu so the branch has an origin/<branch> ref.
await openChangesPrimaryMenu(page);
await page.getByTestId('changes-menu-push').click();
await expect
.poll(() => {
try {
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
return true;
} catch {
return false;
}
}, { timeout: 30000 })
.toBe(true);
const notesPath = path.join(firstCwd, 'notes.txt');
await writeFile(notesPath, 'Second change\n');
@@ -258,17 +320,31 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
timeout: 30000,
});
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesScope(page).getByTestId('changes-action-commit').click();
await getChangesScope(page).getByTestId('changes-primary-cta').click();
await expect
.poll(() => {
try {
return execSync('git status --porcelain', {
cwd: firstCwd,
encoding: 'utf8',
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
}).trim();
} catch {
return null;
}
}, { timeout: 30000 })
.toBe('');
await openChangesPanel(page);
await selectChangesView(page, 'working');
await expect(page.getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
@@ -278,50 +354,73 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
timeout: 30000,
});
await openChangesShipMenu(page);
await page.getByTestId('changes-ship-create-pr').click();
await expect(getChangesScope(page).getByTestId('changes-pr-status')).toContainText(/open/i, {
timeout: 60000,
});
await openChangesShipMenu(page);
await expect(page.getByTestId('changes-ship-open-pr')).toBeVisible();
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
await pushPrimary.click();
// Regression check: the primary CTA stays in place while pushing.
await expect(pushPrimary).toBeVisible();
await page.waitForTimeout(50);
await expect(pushPrimary).toBeVisible();
await expect
.poll(() => {
try {
const count = execSync(
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
{ cwd: firstCwd, encoding: 'utf8' }
).trim();
return Number.parseInt(count, 10);
} catch {
return null;
}
}, { timeout: 30000 })
.toBe(0);
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
execSync("git checkout main", { cwd: repo.path });
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
execSync("git push", { cwd: repo.path });
await page.getByTestId('changes-ship-merge').click();
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
timeout: 60000,
});
await refreshChangesTab(page);
await expect(getChangesScope(page).getByTestId('changes-ship-caret')).toHaveCount(0, { timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
await openChangesOverflowMenu(page);
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
await page.getByTestId('changes-menu-archive').click();
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
await page.getByTestId('changes-menu-archive-worktree').click();
// Archiving a worktree deletes agents and redirects to home
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
// Repo inspection is async; wait until git options are interactive again.
await expect(page.getByText('Inspecting repository…')).toHaveCount(0, { timeout: 30000 });
await page.getByTestId('worktree-attach-toggle').click();
await expect(page.getByTestId('worktree-attach-picker')).toBeVisible({ timeout: 30000 });
await page.getByTestId('worktree-attach-picker').click();
await expect(page.getByText(branchName, { exact: true })).toHaveCount(0);
const attachSheetBackdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' });
if (await attachSheetBackdrop.isVisible()) {
await attachSheetBackdrop.click({ force: true });
await expect(page.getByText(worktreeBranch, { exact: true })).toHaveCount(0);
const attachSheet = page.getByLabel('Bottom Sheet', { exact: true });
if (await attachSheet.isVisible().catch(() => false)) {
await page.getByTestId('dropdown-sheet-close').click({ force: true });
await expect(attachSheet).toBeHidden({ timeout: 30000 });
}
await page.getByTestId('worktree-attach-toggle').click();
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
await setWorkingDirectory(page, nonGitDir);
const attachPicker = page.getByTestId('worktree-attach-picker');
if (await attachPicker.isVisible()) {
await page.getByTestId('worktree-attach-toggle').click();
await expect(attachPicker).toBeHidden({ timeout: 30000 });
}
// Wait for git options to disappear (repo inspection is async and the git section can briefly render stale UI).
await expect(page.getByTestId('worktree-attach-toggle')).toHaveCount(0, { timeout: 30000 });
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
await waitForAssistantText(page, 'NON-GIT');
await openChangesPanel(page, { expectGit: false });
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-toolbar')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
} finally {
await rm(nonGitDir, { recursive: true, force: true });
await repo.cleanup();