mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-15 04:42:45 +00:00
441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
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 {
|
|
createAgent,
|
|
ensureHostSelected,
|
|
gotoHome,
|
|
setWorkingDirectory,
|
|
} from './helpers/app';
|
|
import { createTempGitRepo } from './helpers/workspace';
|
|
|
|
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
|
|
|
function escapeRegex(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function getChangesScope(page: Page) {
|
|
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
|
}
|
|
|
|
function getChangesHeader(page: Page) {
|
|
return getChangesScope(page).getByTestId('changes-header');
|
|
}
|
|
|
|
async function ensureExplorerTabsVisible(page: Page) {
|
|
const changesTab = page.getByTestId('explorer-tab-changes').first();
|
|
if (await changesTab.isVisible().catch(() => false)) {
|
|
return;
|
|
}
|
|
|
|
const toggle = page
|
|
.getByRole('button', { name: /open explorer|close explorer|toggle explorer/i })
|
|
.first();
|
|
await expect(toggle).toBeVisible({ timeout: 10000 });
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
if (await changesTab.isVisible().catch(() => false)) {
|
|
return;
|
|
}
|
|
await toggle.click();
|
|
await page.waitForTimeout(200);
|
|
}
|
|
await expect(changesTab).toBeVisible({ timeout: 30000 });
|
|
}
|
|
|
|
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
|
// 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 diffModeBackdrop = page.getByTestId('changes-diff-status-menu-backdrop');
|
|
if (await diffModeBackdrop.isVisible().catch(() => false)) {
|
|
await diffModeBackdrop.click({ force: true });
|
|
await expect(diffModeBackdrop).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();
|
|
const menu = page.getByTestId('changes-diff-status-menu');
|
|
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
const optionTestId =
|
|
view === 'working' ? 'changes-diff-mode-uncommitted' : 'changes-diff-mode-committed';
|
|
await page.getByTestId(optionTestId).click({ force: true });
|
|
}
|
|
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
|
}
|
|
|
|
async function openChangesPrimaryMenu(page: Page) {
|
|
const scope = getChangesScope(page);
|
|
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 }) {
|
|
await ensureExplorerTabsVisible(page);
|
|
const changesHeader = getChangesHeader(page);
|
|
if (!(await changesHeader.isVisible())) {
|
|
await page.getByTestId('explorer-tab-changes').first().click();
|
|
}
|
|
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
|
if (options?.expectGit === false) {
|
|
return;
|
|
}
|
|
const changesScope = getChangesScope(page);
|
|
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
|
|
timeout: 30000,
|
|
});
|
|
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
|
|
timeout: 30000,
|
|
});
|
|
}
|
|
|
|
async function waitForAgentTurnToSettle(page: Page, timeout = 90000) {
|
|
const stopButton = page.getByRole('button', { name: /stop agent|stop/i }).first();
|
|
if (!(await stopButton.isVisible().catch(() => false))) {
|
|
return;
|
|
}
|
|
await expect(stopButton).not.toBeVisible({ timeout });
|
|
}
|
|
|
|
async function createAgentAndWait(page: Page, message: string) {
|
|
await createAgent(page, message);
|
|
}
|
|
|
|
async function selectAttachWorktree(page: Page, branchName: string) {
|
|
const trigger = page.getByTestId('worktree-select-trigger').first();
|
|
await expect(trigger).toBeVisible({ timeout: 30000 });
|
|
await trigger.click();
|
|
|
|
const menu = page.getByTestId('combobox-desktop-container').first();
|
|
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
const searchInput = page.getByRole('textbox', { name: /search worktrees/i }).first();
|
|
if (await searchInput.isVisible().catch(() => false)) {
|
|
await searchInput.fill(branchName);
|
|
}
|
|
|
|
const preferredOption = menu
|
|
.getByText(new RegExp(`^${escapeRegex(branchName)}$`, 'i'))
|
|
.first();
|
|
await expect(preferredOption).toBeVisible({ timeout: 10000 });
|
|
await preferredOption.click({ force: true });
|
|
await expect(menu).toHaveCount(0);
|
|
await expect(trigger).toContainText(branchName, { timeout: 30000 });
|
|
}
|
|
|
|
async function enableCreateWorktree(page: Page) {
|
|
const trigger = page.getByTestId('worktree-select-trigger').first();
|
|
await expect(trigger).toBeVisible({ timeout: 30000 });
|
|
|
|
const currentValue = ((await trigger.innerText().catch(() => '')) ?? '').trim();
|
|
if (/Create new worktree/i.test(currentValue)) {
|
|
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await trigger.click();
|
|
const menu = page.getByTestId('combobox-desktop-container').first();
|
|
await expect(menu).toBeVisible({ timeout: 10000 });
|
|
const createOption = menu.getByText('Create new worktree', { exact: true }).first();
|
|
await expect(createOption).toBeVisible({ timeout: 10000 });
|
|
await createOption.click({ force: true });
|
|
await expect(menu).toHaveCount(0);
|
|
await expect(trigger).toContainText('Create new worktree', { timeout: 30000 });
|
|
await expect(page.getByTestId('worktree-base-branch-trigger')).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
}
|
|
|
|
async function refreshUncommittedMode(page: Page) {
|
|
await selectChangesView(page, 'base');
|
|
await selectChangesView(page, 'working');
|
|
}
|
|
|
|
async function refreshChangesTab(page: Page) {
|
|
await ensureExplorerTabsVisible(page);
|
|
await page.getByTestId('explorer-tab-files').first().click();
|
|
await page.getByTestId('explorer-tab-changes').first().click();
|
|
}
|
|
|
|
function normalizeTmpPath(value: string) {
|
|
if (value.startsWith('/var/')) {
|
|
return `/private${value}`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
type GitWorktreeEntry = {
|
|
worktreePath: string;
|
|
branchRef: string | null;
|
|
};
|
|
|
|
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
|
|
const blocks = raw
|
|
.split(/\n\s*\n/g)
|
|
.map((block) => block.trim())
|
|
.filter((block) => block.length > 0);
|
|
|
|
const entries: GitWorktreeEntry[] = [];
|
|
for (const block of blocks) {
|
|
const worktreeMatch = block.match(/^worktree (.+)$/m);
|
|
if (!worktreeMatch) {
|
|
continue;
|
|
}
|
|
const branchMatch = block.match(/^branch (.+)$/m);
|
|
entries.push({
|
|
worktreePath: worktreeMatch[1].trim(),
|
|
branchRef: branchMatch ? branchMatch[1].trim() : null,
|
|
});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
async function waitForCreatedWorktree(repoPath: string, timeoutMs = 30000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
const normalizedRepoPath = normalizeTmpPath(repoPath);
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const output = execSync('git worktree list --porcelain', {
|
|
cwd: repoPath,
|
|
encoding: 'utf8',
|
|
});
|
|
const entries = parseGitWorktreeList(output);
|
|
const candidate = entries.find((entry) => {
|
|
const normalizedWorktreePath = normalizeTmpPath(entry.worktreePath);
|
|
if (normalizedWorktreePath === normalizedRepoPath) {
|
|
return false;
|
|
}
|
|
if (!entry.branchRef) {
|
|
return false;
|
|
}
|
|
return !/\/main$/i.test(entry.branchRef);
|
|
});
|
|
|
|
if (candidate) {
|
|
const branchName = candidate.branchRef?.split('/').filter(Boolean).pop();
|
|
if (branchName) {
|
|
return {
|
|
worktreePath: candidate.worktreePath,
|
|
branchName,
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore transient git worktree read errors while polling.
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for a non-main worktree under ${repoPath}`);
|
|
}
|
|
|
|
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
|
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
|
|
|
try {
|
|
await gotoHome(page);
|
|
await setWorkingDirectory(page, repo.path);
|
|
await ensureHostSelected(page);
|
|
|
|
await enableCreateWorktree(page);
|
|
await createAgentAndWait(page, 'Respond with exactly: READY');
|
|
await waitForAgentTurnToSettle(page);
|
|
|
|
await openChangesPanel(page);
|
|
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 { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
|
repo.path
|
|
);
|
|
expect(worktreeBranch.length).toBeGreaterThan(0);
|
|
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
|
realpath(firstCwd).catch(() => firstCwd),
|
|
realpath(repo.path).catch(() => repo.path),
|
|
]);
|
|
const normalizedRepo = normalizeTmpPath(resolvedRepo);
|
|
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
|
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
|
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
|
|
|
await page.getByTestId('sidebar-new-agent').click();
|
|
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
|
|
|
await setWorkingDirectory(page, repo.path);
|
|
await ensureHostSelected(page);
|
|
await selectAttachWorktree(page, worktreeBranch);
|
|
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
|
await waitForAgentTurnToSettle(page);
|
|
await openChangesPanel(page);
|
|
|
|
const readmePath = path.join(firstCwd, 'README.md');
|
|
await appendFile(readmePath, '\nFirst change\n');
|
|
|
|
await refreshUncommittedMode(page);
|
|
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
|
await expect(page.getByText('First change')).toBeVisible();
|
|
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
|
await expect(primaryCta).toBeVisible();
|
|
await expect(primaryCta).toContainText('Commit');
|
|
|
|
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 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({
|
|
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');
|
|
|
|
await refreshUncommittedMode(page);
|
|
await refreshChangesTab(page);
|
|
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
|
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-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(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({
|
|
timeout: 30000,
|
|
});
|
|
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
|
|
// 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 selectChangesView(page, 'base');
|
|
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
|
'Committed',
|
|
{ timeout: 30000 }
|
|
);
|
|
await refreshChangesTab(page);
|
|
|
|
// Post-ship UI behavior is implementation-dependent (archive can be promoted into
|
|
// primary flow or hidden behind menu variants), so continue from a fresh draft.
|
|
await page.getByTestId('sidebar-new-agent').click();
|
|
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(?:\?|$)/, { timeout: 30000 });
|
|
|
|
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
|
} finally {
|
|
await rm(nonGitDir, { recursive: true, force: true });
|
|
await repo.cleanup();
|
|
}
|
|
});
|