369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import path from 'node:path';
|
|
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
|
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';
|
|
|
|
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
|
|
|
function getChangesScope(page: Page) {
|
|
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
async function openChangesOverflowMenu(page: Page) {
|
|
const menuButton = getChangesScope(page).locator('[data-testid="changes-overflow-menu"]:visible').first();
|
|
await expect(menuButton).toBeVisible();
|
|
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;
|
|
}
|
|
const scope = getChangesScope(page);
|
|
const caret = scope.getByTestId('changes-ship-caret').first();
|
|
await expect(caret).toBeVisible();
|
|
await caret.click();
|
|
}
|
|
|
|
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
|
const changesHeader = getChangesHeader(page);
|
|
if (!(await changesHeader.isVisible())) {
|
|
const explorerHeader = page.getByTestId('explorer-header');
|
|
if (await explorerHeader.isVisible()) {
|
|
await page.getByText('Changes', { exact: true }).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 expect(changesHeader).toBeVisible();
|
|
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 sendPrompt(page: Page, prompt: string) {
|
|
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
await expect(input).toBeEditable();
|
|
await input.fill(prompt);
|
|
await input.press('Enter');
|
|
}
|
|
|
|
async function waitForAssistantText(page: Page, text: string) {
|
|
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: text }).last();
|
|
await expect(assistantMessage).toBeVisible({ timeout: 60000 });
|
|
return assistantMessage;
|
|
}
|
|
|
|
async function waitForAssistantTextWithPermissions(
|
|
page: Page,
|
|
text: string,
|
|
timeoutMs = 60000
|
|
) {
|
|
const start = Date.now();
|
|
const assistantMessage = page
|
|
.getByTestId('assistant-message')
|
|
.filter({ hasText: text })
|
|
.last();
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (await assistantMessage.isVisible()) {
|
|
return assistantMessage;
|
|
}
|
|
const allowButton = page.getByText('Allow', { exact: true }).first();
|
|
if (await allowButton.isVisible()) {
|
|
try {
|
|
await allowButton.click({ force: true, timeout: 1000 });
|
|
} catch {
|
|
// Button can detach during animation; retry on next loop.
|
|
}
|
|
continue;
|
|
}
|
|
await page.waitForTimeout(500);
|
|
}
|
|
throw new Error(`Timed out waiting for assistant text: ${text}`);
|
|
}
|
|
|
|
async function createAgentAndWait(page: Page, message: string) {
|
|
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
|
await expect(input).toBeEditable();
|
|
await input.fill(message);
|
|
await input.press('Enter');
|
|
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
|
|
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
|
}
|
|
|
|
async function requestCwd(page: Page) {
|
|
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
|
|
const message = await waitForAssistantText(page, 'CWD:');
|
|
// The assistant streams tokens; make sure we capture the full path (not a partial prefix).
|
|
await expect.poll(async () => (await message.textContent()) ?? '', { timeout: 60000 }).toContain('.paseo/worktrees/');
|
|
const content = (await message.textContent()) ?? '';
|
|
const match = content.match(/CWD:\s*(\S+)/);
|
|
if (!match) {
|
|
throw new Error(`Expected agent to respond with "CWD: <path>", got: ${content}`);
|
|
}
|
|
return match[1].trim();
|
|
}
|
|
|
|
async function selectAttachWorktree(page: Page, branchName: string) {
|
|
await page.getByTestId('worktree-attach-toggle').click();
|
|
const picker = page.getByTestId('worktree-attach-picker');
|
|
await expect(picker).toBeVisible();
|
|
|
|
// Wait a bit for the worktree list to load
|
|
await page.waitForTimeout(1000);
|
|
|
|
await picker.click();
|
|
|
|
// Wait a bit for animation
|
|
await page.waitForTimeout(500);
|
|
|
|
const sheet = page.getByLabel('Bottom Sheet', { exact: true });
|
|
const backdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }).first();
|
|
|
|
await expect.poll(async () => {
|
|
const sheetVisible = await sheet.isVisible().catch(() => false);
|
|
const backdropVisible = await backdrop.isVisible().catch(() => false);
|
|
// Also check if branch name is visible directly
|
|
const branchVisible = await page.getByText(branchName, { exact: true }).first().isVisible().catch(() => false);
|
|
return sheetVisible || backdropVisible || branchVisible;
|
|
}, { 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);
|
|
}
|
|
|
|
async function enableCreateWorktree(page: Page) {
|
|
const createToggle = page.getByTestId('worktree-create-toggle');
|
|
const willCreateLabel = page.getByText(/Will create:/);
|
|
if (await willCreateLabel.isVisible()) {
|
|
return;
|
|
}
|
|
const readyLabel = page.getByText(
|
|
/Run isolated from|Run in an isolated directory/
|
|
);
|
|
await expect(readyLabel).toBeVisible({ timeout: 30000 });
|
|
await createToggle.click({ force: true });
|
|
await expect(willCreateLabel).toBeVisible({ timeout: 30000 });
|
|
}
|
|
|
|
async function refreshUncommittedMode(page: Page) {
|
|
await selectChangesView(page, 'base');
|
|
await selectChangesView(page, 'working');
|
|
}
|
|
|
|
async function refreshChangesTab(page: Page) {
|
|
const header = page.locator('[data-testid="explorer-header"]:visible').first();
|
|
await header.getByText('Files', { exact: true }).first().click();
|
|
await header.getByText('Changes', { exact: true }).first().click();
|
|
}
|
|
|
|
function normalizeTmpPath(value: string) {
|
|
if (value.startsWith('/var/')) {
|
|
return `/private${value}`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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 waitForAssistantText(page, 'READY');
|
|
|
|
await openChangesPanel(page);
|
|
const branchName = (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim();
|
|
expect(branchName.length).toBeGreaterThan(0);
|
|
|
|
const firstCwd = await requestCwd(page);
|
|
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}.paseo${path.sep}worktrees${path.sep}`;
|
|
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
|
if (repo.name) {
|
|
const expectedProjectRoot = path.join('.paseo', 'worktrees', repo.name);
|
|
expect(normalizedCwd.includes(expectedProjectRoot)).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 createAgentAndWait(page, 'Respond with exactly: READY2');
|
|
await waitForAssistantText(page, 'READY2');
|
|
|
|
const secondCwd = await requestCwd(page);
|
|
expect(secondCwd).toBe(firstCwd);
|
|
|
|
await sendPrompt(
|
|
page,
|
|
'Only call MCP tools set_title("E2E Ship Loop") and set_branch("feat/e2e-ship-loop"). Do not run bash or other tools. Then respond with exactly: OK'
|
|
);
|
|
await waitForAssistantTextWithPermissions(page, 'OK', 60000);
|
|
await expect(page.getByText('E2E Ship Loop', { exact: true }).first()).toBeVisible();
|
|
|
|
await openChangesPanel(page);
|
|
await expect.poll(
|
|
async () => (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim(),
|
|
{ timeout: 60000 }
|
|
).toBe('feat/e2e-ship-loop');
|
|
|
|
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();
|
|
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
|
|
|
|
await getChangesScope(page).getByTestId('changes-action-commit').click();
|
|
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 selectChangesView(page, 'base');
|
|
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
|
|
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-action-commit')).toBeVisible();
|
|
|
|
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 selectChangesView(page, 'working');
|
|
await expect(page.getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
|
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
|
|
|
|
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,
|
|
});
|
|
|
|
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();
|
|
|
|
await page.getByTestId('changes-ship-merge').click();
|
|
await selectChangesView(page, 'base');
|
|
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
|
|
timeout: 60000,
|
|
});
|
|
await refreshChangesTab(page);
|
|
await expect(getChangesScope(page).getByTestId('changes-ship-caret')).toHaveCount(0, { timeout: 30000 });
|
|
|
|
await openChangesOverflowMenu(page);
|
|
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
|
|
await page.getByTestId('changes-menu-archive').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);
|
|
await page.getByTestId('worktree-attach-toggle').click();
|
|
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 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 });
|
|
}
|
|
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);
|
|
} finally {
|
|
await rm(nonGitDir, { recursive: true, force: true });
|
|
await repo.cleanup();
|
|
}
|
|
});
|