test: harden daemon lifecycle and e2e reliability across app/cli/server
This commit is contained in:
@@ -14,8 +14,6 @@ test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await page.getByTestId("agent-menu-details").click();
|
||||
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
|
||||
@@ -4,20 +4,16 @@ import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const marker = 'TIMELINE_HYDRATION_OK';
|
||||
const prompt = `Respond with exactly: ${marker}`;
|
||||
const prompt = 'Respond with exactly: TIMELINE_HYDRATION_OK';
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
const assistantMessage = page
|
||||
.getByTestId('assistant-message')
|
||||
.filter({ hasText: marker })
|
||||
.first();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
@@ -25,9 +21,6 @@ test('agent timeline hydrates after reload via fetch_agent_timeline_request', as
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
createAgent,
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
@@ -12,6 +13,10 @@ 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();
|
||||
}
|
||||
@@ -20,6 +25,26 @@ 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');
|
||||
@@ -32,6 +57,11 @@ async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
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();
|
||||
@@ -42,16 +72,15 @@ async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
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 openChangesOverflowMenu(page: Page) {
|
||||
const menuButton = getChangesScope(page).locator('[data-testid="changes-overflow-menu"]:visible').first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
}
|
||||
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
@@ -62,25 +91,10 @@ async function openChangesPrimaryMenu(page: Page) {
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesHeader(page);
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
const explorerHeader = page.getByTestId('explorer-header');
|
||||
if (await explorerHeader.isVisible()) {
|
||||
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/i).first().click();
|
||||
}
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
@@ -95,98 +109,62 @@ async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
});
|
||||
}
|
||||
|
||||
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 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) {
|
||||
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('/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();
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
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();
|
||||
const trigger = page.getByTestId('worktree-select-trigger').first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
await trigger.click();
|
||||
|
||||
// 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 preferredOption = scope.getByText(branchName, { exact: true }).first();
|
||||
if (await preferredOption.isVisible().catch(() => false)) {
|
||||
await preferredOption.click();
|
||||
await expect(picker).toContainText(branchName);
|
||||
return;
|
||||
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 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);
|
||||
}
|
||||
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 createToggle = page.getByTestId('worktree-create-toggle');
|
||||
const willCreateLabel = page.getByText(/Will create:/);
|
||||
if (await willCreateLabel.isVisible()) {
|
||||
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;
|
||||
}
|
||||
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 });
|
||||
|
||||
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) {
|
||||
@@ -195,9 +173,9 @@ async function refreshUncommittedMode(page: Page) {
|
||||
}
|
||||
|
||||
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();
|
||||
await ensureExplorerTabsVisible(page);
|
||||
await page.getByTestId('explorer-tab-files').first().click();
|
||||
await page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
|
||||
function normalizeTmpPath(value: string) {
|
||||
@@ -207,6 +185,72 @@ function normalizeTmpPath(value: string) {
|
||||
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-'));
|
||||
@@ -218,7 +262,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
|
||||
await enableCreateWorktree(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await waitForAssistantText(page, 'READY');
|
||||
await waitForAgentTurnToSettle(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
@@ -228,11 +272,9 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
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();
|
||||
const { worktreePath: firstCwd, branchName: worktreeBranch } = await waitForCreatedWorktree(
|
||||
repo.path
|
||||
);
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
@@ -244,19 +286,14 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
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 waitForAssistantText(page, 'READY2');
|
||||
|
||||
const secondCwd = await requestCwd(page);
|
||||
expect(secondCwd).toBe(firstCwd);
|
||||
|
||||
await sendPrompt(page, "Respond with exactly: OK");
|
||||
await waitForAssistantText(page, "OK");
|
||||
await waitForAgentTurnToSettle(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
@@ -384,43 +421,18 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-diff-status')).toContainText(
|
||||
'Committed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
await openChangesOverflowMenu(page);
|
||||
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(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 });
|
||||
// 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 setWorkingDirectory(page, nonGitDir);
|
||||
// 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-primary-cta')).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
|
||||
@@ -2,6 +2,18 @@ import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
function parseAgentUrl(url: string): { serverId: string; agentId: string } {
|
||||
const parsed = new URL(url);
|
||||
const match = parsed.pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`);
|
||||
}
|
||||
return {
|
||||
serverId: decodeURIComponent(match[1]),
|
||||
agentId: decodeURIComponent(match[2]),
|
||||
};
|
||||
}
|
||||
|
||||
test('create agent in a temp repo', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
@@ -15,14 +27,18 @@ test('create agent in a temp repo', async ({ page }) => {
|
||||
// Verify user message is shown in the stream
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
|
||||
// Verify we used a fast model (do not fall back to a default like Sonnet).
|
||||
// Verify we used the seeded fast model (do not fall back to other defaults).
|
||||
await page.getByTestId('agent-overflow-menu').click();
|
||||
await expect(page.getByText('Model', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('agent-overflow-content').getByText(/haiku/i)).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('agent-overflow-content').getByText(/gpt-5\.1-codex-mini/i)
|
||||
).toBeVisible();
|
||||
|
||||
// Wait for agent response containing "Hello" within an assistant message
|
||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 30000 });
|
||||
// Verify the created agent's title reflects the response.
|
||||
const { serverId, agentId } = parseAgentUrl(page.url());
|
||||
const agentRow = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
|
||||
await expect(agentRow).not.toContainText(/new agent/i, { timeout: 30000 });
|
||||
await expect(agentRow).toContainText(/hello|greet|response/i, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -17,15 +17,19 @@ test('deleting an agent persists after reload', async ({ page }) => {
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent\/[^/?#]+(?:\?|$)/, {
|
||||
timeout: 30000,
|
||||
});
|
||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
||||
|
||||
const match = page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
const match =
|
||||
page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/) ??
|
||||
page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /agent/:serverId/:agentId URL, got ${page.url()}`);
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
@@ -36,11 +40,16 @@ test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
|
||||
// Web UX: hover shows a quick-archive icon. First click enters confirm state; second click archives.
|
||||
await agentRow.hover();
|
||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||
await quickArchive.click({ force: true });
|
||||
const confirmArchive = page
|
||||
.getByTestId(`agent-archive-confirm-${serverId}-${agentId}`)
|
||||
.first();
|
||||
await expect(confirmArchive).toBeVisible({ timeout: 10000 });
|
||||
await confirmArchive.click({ force: true });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
@@ -5,10 +5,14 @@ import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function addFakeMicrophone(page: Page) {
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.webm');
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.wav');
|
||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
||||
const mimeType = 'audio/webm;codecs=opus';
|
||||
const mimeType = 'audio/wav';
|
||||
|
||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
||||
const mic = {
|
||||
@@ -39,6 +43,24 @@ async function addFakeMicrophone(page: Page) {
|
||||
};
|
||||
};
|
||||
|
||||
const AudioContextCtor =
|
||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
||||
if (AudioContextCtor?.prototype?.createMediaStreamSource) {
|
||||
const nativeCreateMediaStreamSource =
|
||||
AudioContextCtor.prototype.createMediaStreamSource;
|
||||
AudioContextCtor.prototype.createMediaStreamSource =
|
||||
function patchedCreateMediaStreamSource(stream: unknown) {
|
||||
const isFakeStream =
|
||||
!!stream &&
|
||||
typeof (stream as { getTracks?: unknown }).getTracks === 'function' &&
|
||||
typeof (stream as { id?: unknown }).id === 'undefined';
|
||||
if (isFakeStream) {
|
||||
throw new Error('Force recorder fallback for fake microphone stream');
|
||||
}
|
||||
return nativeCreateMediaStreamSource.call(this, stream);
|
||||
};
|
||||
}
|
||||
|
||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
@@ -142,6 +164,9 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -199,7 +224,14 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
||||
const match = page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]!);
|
||||
const agentId = decodeURIComponent(match[2]!);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
@@ -211,16 +243,19 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||
await expect(newAgentButton).toBeVisible();
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
|
||||
await page.waitForTimeout(10_000);
|
||||
|
||||
const agentEntry = page.getByText(repo.path).first();
|
||||
const agentEntry = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
|
||||
await expect(agentEntry).toBeVisible();
|
||||
await agentEntry.click();
|
||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/h/${escapeRegex(serverId)}/agent/${escapeRegex(agentId)}(?:\\?|$)`)
|
||||
);
|
||||
|
||||
await expect(page.getByText(/voice note/i)).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
|
||||
await expect(page.getByTestId('agent-chat-scroll').getByText(/this is a voice note\./i)).toHaveCount(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ test("draft enables explorer after selecting a working directory", async ({ page
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
|
||||
|
||||
@@ -77,10 +77,10 @@ test.beforeEach(async ({ page }) => {
|
||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
||||
serverId: testDaemon.serverId,
|
||||
// Keep e2e fast/cheap by default.
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
@@ -10,18 +10,31 @@ function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
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 openChangesPanel(page: Page) {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
||||
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 page.getByTestId('explorer-tab-changes').first().click();
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
}
|
||||
@@ -31,22 +44,29 @@ async function refreshUncommittedMode(page: Page) {
|
||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
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 currentLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const firstTarget = currentLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(firstTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
||||
|
||||
const nextLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByTestId('changes-diff-status-menu')).toBeVisible({ timeout: 10000 });
|
||||
const secondTarget = nextLabel === 'Uncommitted' ? 'changes-diff-mode-committed' : 'changes-diff-mode-uncommitted';
|
||||
await page.getByTestId(secondTarget).click({ force: true });
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
||||
}
|
||||
|
||||
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();
|
||||
await createAgent(page, message);
|
||||
}
|
||||
|
||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
||||
|
||||
+428
-158
@@ -7,6 +7,14 @@ import net from 'node:net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
type WaitForServerOptions = {
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
label: string;
|
||||
childProcess?: ChildProcess | null;
|
||||
getRecentOutput?: () => string;
|
||||
};
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -22,23 +30,157 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, timeout = 15000): Promise<void> {
|
||||
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
|
||||
const lines: string[] = [];
|
||||
return {
|
||||
add(line: string) {
|
||||
lines.push(line);
|
||||
if (lines.length > maxLines) {
|
||||
lines.shift();
|
||||
}
|
||||
},
|
||||
dump() {
|
||||
return lines.join('\n');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||
if (!getRecentOutput) {
|
||||
return '';
|
||||
}
|
||||
const output = getRecentOutput().trim();
|
||||
if (!output) {
|
||||
return '';
|
||||
}
|
||||
return `\nRecent output:\n${output}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
const {
|
||||
host = '127.0.0.1',
|
||||
timeoutMs = 15000,
|
||||
label,
|
||||
childProcess,
|
||||
getRecentOutput,
|
||||
} = options;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
let lastConnectionError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (childProcess && childProcess.exitCode !== null) {
|
||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : '';
|
||||
throw new Error(
|
||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, 'localhost', () => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
lastConnectionError = error;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
|
||||
|
||||
const reason =
|
||||
lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : '';
|
||||
throw new Error(
|
||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseRelayStartupFailure(line: string): string | null {
|
||||
const clean = stripAnsi(line);
|
||||
if (/Address already in use/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
if (/failed: ::bind\(/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
if (/Fatal uncaught/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return 'empty response body';
|
||||
}
|
||||
if (trimmed.length <= 240) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, 240)}…`;
|
||||
}
|
||||
|
||||
async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean> {
|
||||
const key = apiKey?.trim();
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/models?limit=1', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
const body = await response.text();
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe request failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
@@ -81,165 +223,293 @@ export default async function globalSetup() {
|
||||
}
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const relayPort = await getAvailablePort();
|
||||
let relayPort = 0;
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
||||
let relayLineBuffer = createLineBuffer();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
}
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.error(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForServer(relayPort, 30000);
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
const appDir = path.resolve(__dirname, '..');
|
||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Don't auto-open browser
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[metro] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||
const tsxBin = execSync('which tsx').toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString('utf8');
|
||||
const lines = stdoutBuffer.split('\n');
|
||||
stdoutBuffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes('pairing_offer')) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[daemon] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port),
|
||||
waitForServer(metroPort, 120000), // Metro can take longer to start
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
||||
|
||||
return async () => {
|
||||
if (daemonProcess) {
|
||||
daemonProcess.kill('SIGTERM');
|
||||
daemonProcess = null;
|
||||
}
|
||||
if (metroProcess) {
|
||||
metroProcess.kill('SIGTERM');
|
||||
metroProcess = null;
|
||||
}
|
||||
if (relayProcess) {
|
||||
relayProcess.kill('SIGTERM');
|
||||
relayProcess = null;
|
||||
}
|
||||
const cleanup = async () => {
|
||||
await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]);
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
if (paseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
}
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
};
|
||||
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech');
|
||||
const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? 'openai' : 'local';
|
||||
|
||||
if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) {
|
||||
throw new Error(
|
||||
'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' +
|
||||
'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.'
|
||||
);
|
||||
}
|
||||
|
||||
const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}`
|
||||
);
|
||||
|
||||
try {
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let relayStarted = false;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
relayPort = await getAvailablePort();
|
||||
relayLineBuffer = createLineBuffer();
|
||||
let relayStartupFailureLine: string | null = null;
|
||||
let relayReadyForSelectedPort = false;
|
||||
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
}
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stdout] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
if (failure) {
|
||||
relayStartupFailureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
relayReadyForSelectedPort = true;
|
||||
}
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stderr] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
if (failure) {
|
||||
relayStartupFailureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
relayReadyForSelectedPort = true;
|
||||
}
|
||||
console.error(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(relayPort, {
|
||||
label: 'Relay dev server',
|
||||
timeoutMs: 30000,
|
||||
childProcess: relayProcess,
|
||||
getRecentOutput: relayLineBuffer.dump,
|
||||
});
|
||||
|
||||
const readyDeadline = Date.now() + 5000;
|
||||
while (
|
||||
!relayReadyForSelectedPort &&
|
||||
relayStartupFailureLine === null &&
|
||||
relayProcess?.exitCode === null &&
|
||||
relayProcess?.signalCode === null &&
|
||||
Date.now() < readyDeadline
|
||||
) {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (relayStartupFailureLine) {
|
||||
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
|
||||
}
|
||||
if (!relayReadyForSelectedPort) {
|
||||
throw new Error(
|
||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||
relayLineBuffer.dump
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
||||
relayLineBuffer.dump
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
relayStarted = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastRelayStartupError = error;
|
||||
await stopProcess(relayProcess);
|
||||
relayProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!relayStarted) {
|
||||
const message =
|
||||
lastRelayStartupError instanceof Error
|
||||
? lastRelayStartupError.message
|
||||
: String(lastRelayStartupError);
|
||||
throw new Error(
|
||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
const appDir = path.resolve(__dirname, '..');
|
||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Don't auto-open browser
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stdout] ${line}`);
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||
const tsxBin = execSync('which tsx').toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
|
||||
PASEO_DICTATION_ENABLED: '1',
|
||||
PASEO_VOICE_MODE_ENABLED: '1',
|
||||
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
|
||||
PASEO_VOICE_STT_PROVIDER: 'openai',
|
||||
PASEO_VOICE_TTS_PROVIDER: 'openai',
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString('utf8');
|
||||
const lines = stdoutBuffer.split('\n');
|
||||
stdoutBuffer = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes('pairing_offer')) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[daemon] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: 'Paseo daemon',
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: 'Metro web server',
|
||||
timeoutMs: 120000, // Metro can take longer to start
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
||||
|
||||
return async () => {
|
||||
await cleanup();
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+149
-20
@@ -71,10 +71,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
'@paseo:create-agent-preferences',
|
||||
JSON.stringify({
|
||||
serverId: expectedServerId,
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -183,7 +183,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first();
|
||||
const closeBottomSheet = async () => {
|
||||
const bottomSheetBackdrop = page
|
||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
||||
@@ -362,16 +362,83 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
export const createAgent = async (page: Page, message: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await preferFastThinkingOption(page);
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
|
||||
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
// Router updates can be same-document transitions; assert URL state instead of waiting for a commit event.
|
||||
await expect(page).toHaveURL(/\/agent\//, { timeout: 30000 });
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
const providerTrigger = page.getByTestId('draft-provider-select').first();
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
|
||||
if (!/codex/i.test(providerText)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingTrigger = page.getByTestId('agent-thinking-selector').first();
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await thinkingTrigger.click();
|
||||
const menu = page.getByTestId('agent-thinking-menu').first();
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredLabels = ['low', 'minimal', 'off', 'medium'];
|
||||
let selected = false;
|
||||
for (const label of preferredLabels) {
|
||||
const option = menu
|
||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') })
|
||||
.first();
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true });
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
const options = menu.getByRole('button');
|
||||
const count = await options.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const option = options.nth(index);
|
||||
const label = ((await option.innerText().catch(() => '')) ?? '').trim();
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
if (label.toLowerCase() === currentThinkingLabel) {
|
||||
continue;
|
||||
}
|
||||
await option.click({ force: true });
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(menu).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
directory: string;
|
||||
provider?: string;
|
||||
@@ -381,42 +448,104 @@ export interface AgentConfig {
|
||||
}
|
||||
|
||||
export const selectProvider = async (page: Page, provider: string) => {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
const normalizedProvider = provider.trim();
|
||||
if (!normalizedProvider) {
|
||||
throw new Error('Provider must be a non-empty string.');
|
||||
}
|
||||
|
||||
const option = page.getByText(provider, { exact: true }).first();
|
||||
const providerTrigger = page.getByTestId('draft-provider-select').first();
|
||||
if (
|
||||
await providerTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
await providerTrigger.click();
|
||||
} else {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
}
|
||||
|
||||
const dialog = page.getByRole('dialog').last();
|
||||
const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(normalizedProvider);
|
||||
}
|
||||
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i'))
|
||||
.first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
};
|
||||
|
||||
export const selectModel = async (page: Page, model: string) => {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
const normalizedModel = model.trim();
|
||||
if (!normalizedModel) {
|
||||
throw new Error('Model must be a non-empty string.');
|
||||
}
|
||||
|
||||
const modelTrigger = page.getByTestId('draft-model-select').first();
|
||||
if (
|
||||
await modelTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to search/filter models
|
||||
await searchInput.fill(model);
|
||||
await searchInput.fill(normalizedModel);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(model)}$`, 'i'))
|
||||
const exactOption = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i'))
|
||||
.first();
|
||||
await expect(option).toBeVisible({ timeout: 30000 });
|
||||
await option.click({ force: true });
|
||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||
if (exactVisible) {
|
||||
await exactOption.click({ force: true });
|
||||
} else {
|
||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||
// select the first filtered result using keyboard confirm.
|
||||
await searchInput.press('Enter');
|
||||
}
|
||||
|
||||
// Wait for dropdown to close
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
}
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
export const selectMode = async (page: Page, mode: string) => {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
const modeTrigger = page.getByTestId('draft-mode-select').first();
|
||||
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||
await modeTrigger.click();
|
||||
} else {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the mode dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
||||
|
||||
@@ -12,7 +12,9 @@ export const createTempGitRepo = async (
|
||||
prefix = 'paseo-e2e-',
|
||||
options?: { withRemote?: boolean }
|
||||
): Promise<TempRepo> => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp';
|
||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const withRemote = options?.withRemote ?? false;
|
||||
|
||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
||||
|
||||
@@ -38,5 +38,7 @@ test('no hosts shows welcome; direct connection adds host and lands on agent cre
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,11 +78,21 @@ test('host removal removes the host from UI and persists after reload', async ({
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
|
||||
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
|
||||
const hostSettingsButton = page.getByTestId(`daemon-card-settings-${extraDaemon.serverId}`).first();
|
||||
await expect(hostSettingsButton).toBeVisible({ timeout: 10000 });
|
||||
await hostSettingsButton.click();
|
||||
|
||||
const hostDetailModal = page.getByTestId('host-detail-modal');
|
||||
await expect(hostDetailModal).toBeVisible({ timeout: 10000 });
|
||||
await hostDetailModal.getByText('Advanced', { exact: true }).click();
|
||||
await page.getByText('Remove host', { exact: true }).last().click();
|
||||
|
||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||
await page.getByTestId('remove-host-confirm').click();
|
||||
|
||||
await expect(page.getByTestId(`daemon-card-${extraDaemon.serverId}`)).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
await page.waitForFunction(
|
||||
(serverId) => {
|
||||
|
||||
@@ -41,10 +41,10 @@ test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
};
|
||||
const createAgentPreferences = {
|
||||
serverId: testDaemon.serverId,
|
||||
provider: 'claude',
|
||||
provider: 'codex',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
codex: { model: 'gpt-5.1-codex-mini', thinkingOptionId: 'low' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
@@ -109,7 +109,6 @@ test('new agent auto-selects first online host when no preference is stored', as
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Host should be auto-selected (no manual selection required).
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
|
||||
@@ -17,8 +17,28 @@ test('manual host add accepts host:port only and persists a direct connection',
|
||||
});
|
||||
await page.goto('/settings');
|
||||
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
if (await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'welcome';
|
||||
}
|
||||
if (await page.getByText('+ Add connection', { exact: true }).isVisible().catch(() => false)) {
|
||||
return 'settings';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
.not.toBe('');
|
||||
|
||||
const isWelcome = await page.getByText('Welcome to Paseo', { exact: true }).isVisible().catch(() => false);
|
||||
if (isWelcome) {
|
||||
await page.getByText('Direct connection', { exact: true }).first().click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('host:6767');
|
||||
await expect(input).toBeVisible();
|
||||
@@ -27,11 +47,18 @@ test('manual host add accepts host:port only and persists a direct connection',
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
await expect(nameModal).toBeVisible({ timeout: 15000 });
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
if (await nameModal.isVisible().catch(() => false)) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible({ timeout: 10000 });
|
||||
await settingsButton.click();
|
||||
await expect(page.locator(`[data-testid="daemon-card-${serverId}"]:visible`).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
({ port, serverId }) => {
|
||||
|
||||
@@ -10,6 +10,15 @@ function encodeBase64Url(input: string): string {
|
||||
}
|
||||
|
||||
test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', async ({ page }) => {
|
||||
const relayPort = process.env.E2E_RELAY_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPublicKeyB64 = process.env.E2E_RELAY_DAEMON_PUBLIC_KEY;
|
||||
if (!relayPort || !serverId || !daemonPublicKeyB64) {
|
||||
throw new Error(
|
||||
'E2E_RELAY_PORT, E2E_SERVER_ID, or E2E_RELAY_DAEMON_PUBLIC_KEY is not set (expected from globalSetup).'
|
||||
);
|
||||
}
|
||||
|
||||
// Override the default fixture seeding for this test.
|
||||
await page.goto('/settings');
|
||||
await page.evaluate(() => {
|
||||
@@ -18,27 +27,39 @@ test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host',
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
await page.reload();
|
||||
await page.goto('/');
|
||||
|
||||
const relayEndpoint = `127.0.0.1:${relayPort}`;
|
||||
|
||||
const offer = {
|
||||
v: 2 as const,
|
||||
serverId: 'e2e-server-123',
|
||||
daemonPublicKeyB64: Buffer.from('e2e-public-key', 'utf8').toString('base64'),
|
||||
relay: { endpoint: 'relay.local:443' },
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint },
|
||||
};
|
||||
|
||||
const offerUrl = `https://app.paseo.sh/#offer=${encodeBase64Url(JSON.stringify(offer))}`;
|
||||
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
const welcomeTitle = page.getByText('Welcome to Paseo', { exact: true });
|
||||
if (await welcomeTitle.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('welcome-paste-pairing-link').click();
|
||||
} else {
|
||||
await page.getByText('+ Add connection', { exact: true }).click();
|
||||
await page.getByText('Paste pairing link', { exact: true }).click();
|
||||
}
|
||||
|
||||
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(offerUrl);
|
||||
|
||||
await page.getByText('Pair', { exact: true }).click();
|
||||
await page.getByTestId('pair-link-submit').click();
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
const nameHostModal = page.getByTestId('name-host-modal');
|
||||
if (await nameHostModal.isVisible().catch(() => false)) {
|
||||
await nameHostModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(
|
||||
({ expected }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentFinishUI,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
@@ -31,7 +32,7 @@ test.describe('permission prompts', () => {
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
model: 'haiku',
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
@@ -71,7 +72,7 @@ test.describe('permission prompts', () => {
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
model: 'haiku',
|
||||
provider: 'claude',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
@@ -79,10 +80,8 @@ test.describe('permission prompts', () => {
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
|
||||
await denyPermission(page);
|
||||
|
||||
await expect(page.getByText(/denied by the user|permission\/authorization check/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await waitForAgentFinishUI(page, 30000);
|
||||
await expect(page.getByTestId('permission-request-question')).toHaveCount(0);
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
|
||||
|
||||
@@ -13,10 +13,26 @@ test('sidebar New Agent opens a fresh create screen', async ({ page }) => {
|
||||
await createAgent(page, 'Agent A: respond with exactly A');
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
|
||||
// Click sidebar New Agent and assert it does not carry over agent settings via URL.
|
||||
// Click sidebar New Agent and assert it re-opens the host draft route while
|
||||
// preserving working directory context from the selected agent.
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
||||
const searchWorkingDir = await page.evaluate(() => {
|
||||
try {
|
||||
return new URL(window.location.href).searchParams.get('workingDir');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const normalizedCandidates = new Set<string>([repoA.path]);
|
||||
if (repoA.path.startsWith('/var/')) {
|
||||
normalizedCandidates.add(`/private${repoA.path}`);
|
||||
}
|
||||
if (repoA.path.startsWith('/private/var/')) {
|
||||
normalizedCandidates.add(repoA.path.replace(/^\/private/, ''));
|
||||
}
|
||||
expect(searchWorkingDir).not.toBeNull();
|
||||
expect(normalizedCandidates.has(searchWorkingDir ?? '')).toBe(true);
|
||||
} finally {
|
||||
await repoA.cleanup();
|
||||
}
|
||||
|
||||
@@ -26,26 +26,13 @@ test("project filter dropdown never appears visibly at 0,0 on open", async ({ pa
|
||||
});
|
||||
};
|
||||
|
||||
const getContainer = (node: HTMLElement): HTMLElement | null => {
|
||||
let current: HTMLElement | null = node;
|
||||
while (current && current !== document.body) {
|
||||
const style = getComputedStyle(current);
|
||||
if (style.position === "absolute" && style.backgroundColor !== "rgba(0, 0, 0, 0)") {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryResolveTarget = (root: HTMLElement) => {
|
||||
const stack = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
|
||||
for (const element of stack) {
|
||||
if (!element.textContent?.includes("No projects")) continue;
|
||||
const container = getContainer(element);
|
||||
if (!container) continue;
|
||||
target = container;
|
||||
return true;
|
||||
if (element.dataset?.testid === "combobox-desktop-container") {
|
||||
target = element;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,21 @@ import {
|
||||
} from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
return page.locator(`[data-testid="${testId}"]:visible`).first();
|
||||
}
|
||||
|
||||
function visibleTestIdPrefix(page: Page, prefix: string) {
|
||||
return page.locator(`[data-testid^="${prefix}"]:visible`);
|
||||
}
|
||||
|
||||
let terminalMarkerCounter = 0;
|
||||
|
||||
function shortTerminalMarker(prefix: string): string {
|
||||
terminalMarkerCounter += 1;
|
||||
return `${prefix.slice(0, 1)}${terminalMarkerCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
|
||||
const pathname = (() => {
|
||||
try {
|
||||
@@ -60,7 +75,7 @@ async function openNewAgentDraft(page: Page): Promise<void> {
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('[data-testid="working-directory-select"]:visible').first()
|
||||
).toBeVisible({
|
||||
@@ -68,38 +83,42 @@ async function openNewAgentDraft(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
let header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
if (!(await header.isVisible().catch(() => false))) {
|
||||
const toggle = page.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
});
|
||||
if (await toggle.first().isVisible().catch(() => false)) {
|
||||
await toggle.first().click();
|
||||
}
|
||||
async function ensureExplorerTabsVisible(page: Page): Promise<void> {
|
||||
const filesTab = visibleTestId(page, "explorer-tab-files");
|
||||
if (await filesTab.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await expect(header).toBeVisible({ timeout: 30000 });
|
||||
const toggle = page
|
||||
.getByRole("button", { name: /open explorer|close explorer|toggle explorer/i })
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 10000 });
|
||||
await toggle.click();
|
||||
await expect(filesTab).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
|
||||
async function openTerminalsPanel(page: Page): Promise<void> {
|
||||
await ensureExplorerTabsVisible(page);
|
||||
|
||||
const terminalsTab = visibleTestId(page, "explorer-tab-terminals");
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
|
||||
await expect(page.getByTestId("terminals-header").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "terminals-header")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "terminal-surface")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFilesPanel(page: Page): Promise<void> {
|
||||
const filesTab = page.getByTestId("explorer-tab-files").first();
|
||||
await ensureExplorerTabsVisible(page);
|
||||
const filesTab = visibleTestId(page, "explorer-tab-files");
|
||||
await expect(filesTab).toBeVisible({ timeout: 30000 });
|
||||
await filesTab.click();
|
||||
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "files-pane-header")).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
@@ -124,7 +143,7 @@ async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
|
||||
|
||||
|
||||
async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
const tabs = page.locator('[data-testid^="terminal-tab-"]');
|
||||
const tabs = visibleTestIdPrefix(page, "terminal-tab-");
|
||||
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
|
||||
await expect
|
||||
.poll(async () => await tabs.count(), { timeout: 30000 })
|
||||
@@ -133,7 +152,7 @@ async function selectNewestTerminalTab(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
|
||||
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
|
||||
const firstTab = visibleTestIdPrefix(page, "terminal-tab-").first();
|
||||
await expect(firstTab).toBeVisible({ timeout: 30000 });
|
||||
const value = await firstTab.getAttribute("data-testid");
|
||||
if (!value) {
|
||||
@@ -143,7 +162,7 @@ async function getFirstTerminalTabTestId(page: Page): Promise<string> {
|
||||
}
|
||||
|
||||
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
@@ -158,7 +177,7 @@ async function runTerminalCommandWithPreEnterEcho(
|
||||
command: string,
|
||||
expectedText: string
|
||||
): Promise<void> {
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type(command, { delay: 1 });
|
||||
@@ -218,11 +237,11 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
||||
await createAgent(page, "Reply with exactly: terminal smoke");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const preEnterEchoMarker = `typed-echo-${Date.now()}`;
|
||||
const preEnterEchoMarker = shortTerminalMarker("typed");
|
||||
await runTerminalCommandWithPreEnterEcho(
|
||||
page,
|
||||
`echo ${preEnterEchoMarker}`,
|
||||
@@ -240,7 +259,7 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
|
||||
const markerOne = `terminal-smoke-one-${Date.now()}`;
|
||||
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
|
||||
|
||||
await page.getByTestId("terminals-create-button").first().click();
|
||||
await visibleTestId(page, "terminals-create-button").click();
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
const markerTwo = `terminal-smoke-two-${Date.now()}`;
|
||||
@@ -272,7 +291,7 @@ test("terminal reattaches cleanly after heavy output and tab switches", async ({
|
||||
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
|
||||
await expect(page.locator('[data-testid="terminal-attach-loading"]:visible')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
@@ -295,7 +314,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -303,7 +322,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
const markerAfterEnters = `echo-visible-${Date.now()}`;
|
||||
const markerAfterEnters = shortTerminalMarker("visible");
|
||||
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
|
||||
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
|
||||
timeout: 30000,
|
||||
@@ -319,7 +338,7 @@ test("terminal keeps prompt echo visible after enter and backspace churn", async
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
const markerAfterBackspace = `echo-backspace-${Date.now()}`;
|
||||
const markerAfterBackspace = shortTerminalMarker("backspace");
|
||||
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(markerAfterBackspace, {
|
||||
@@ -341,7 +360,7 @@ test("terminal remains interactive after alternate-screen enter/exit", async ({
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -375,7 +394,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||
|
||||
const exitedTabTestId = await getFirstTerminalTabTestId(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
await page.keyboard.type("exit", { delay: 1 });
|
||||
@@ -385,7 +404,7 @@ test("terminal tab is removed when shell exits", async ({ page }) => {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
|
||||
await expect(visibleTestIdPrefix(page, "terminal-tab-").first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
const nextTabTestId = await getFirstTerminalTabTestId(page);
|
||||
@@ -408,7 +427,7 @@ test("closing terminal with running command asks for confirmation", async ({ pag
|
||||
|
||||
const tabTestId = await getFirstTerminalTabTestId(page);
|
||||
const terminalId = tabTestId.replace("terminal-tab-", "");
|
||||
const tab = page.getByTestId(tabTestId).first();
|
||||
const tab = visibleTestId(page, tabTestId);
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const runningMarker = `terminal-close-running-${Date.now()}`;
|
||||
@@ -425,7 +444,7 @@ test("closing terminal with running command asks for confirmation", async ({ pag
|
||||
await dialog.dismiss();
|
||||
}
|
||||
);
|
||||
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
|
||||
await visibleTestId(page, `terminal-close-${terminalId}`).click();
|
||||
await dialogPromise;
|
||||
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
@@ -447,7 +466,7 @@ test("confirming terminal close with running command removes the tab", async ({
|
||||
|
||||
const tabTestId = await getFirstTerminalTabTestId(page);
|
||||
const terminalId = tabTestId.replace("terminal-tab-", "");
|
||||
const tab = page.getByTestId(tabTestId).first();
|
||||
const tab = visibleTestId(page, tabTestId);
|
||||
await expect(tab).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const runningMarker = `terminal-close-running-accept-${Date.now()}`;
|
||||
@@ -464,7 +483,7 @@ test("confirming terminal close with running command removes the tab", async ({
|
||||
await dialog.accept();
|
||||
}
|
||||
);
|
||||
await page.getByTestId(`terminal-close-${terminalId}`).first().click();
|
||||
await visibleTestId(page, `terminal-close-${terminalId}`).click();
|
||||
await dialogPromise;
|
||||
|
||||
await expect(page.getByTestId(tabTestId)).toHaveCount(0, {
|
||||
@@ -497,7 +516,7 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
|
||||
await openAgentFromSidebar(page, first.serverId, first.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await page.getByTestId("terminals-create-button").first().click();
|
||||
await visibleTestId(page, "terminals-create-button").click();
|
||||
await selectNewestTerminalTab(page);
|
||||
|
||||
await openAgentFromSidebar(page, second.serverId, second.agentId);
|
||||
@@ -510,7 +529,7 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
|
||||
await openAgentFromSidebar(page, first.serverId, first.agentId);
|
||||
await openTerminalsPanel(page);
|
||||
await selectNewestTerminalTab(page);
|
||||
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, {
|
||||
await expect(visibleTestId(page, "terminal-surface")).toContainText(sharedMarker, {
|
||||
timeout: 30000,
|
||||
});
|
||||
} finally {
|
||||
@@ -529,7 +548,7 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -546,7 +565,9 @@ test("terminal captures escape and ctrl+c key input", async ({ page }) => {
|
||||
await page.keyboard.press("Control+B");
|
||||
await expect(surface).toContainText("^B", { timeout: 30000 });
|
||||
|
||||
const marker = `terminal-key-capture-${Date.now()}`;
|
||||
// Clear any line-editor residue before validating the next shell command.
|
||||
await page.keyboard.press("Enter");
|
||||
const marker = shortTerminalMarker("key-capture");
|
||||
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(surface).toContainText(marker, { timeout: 30000 });
|
||||
@@ -565,7 +586,7 @@ test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) =>
|
||||
await createAgent(page, "Terminal Cmd+B");
|
||||
|
||||
await openTerminalsPanel(page);
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await expect(surface).toBeVisible({ timeout: 30000 });
|
||||
await surface.click({ force: true });
|
||||
|
||||
@@ -594,17 +615,28 @@ async function getTerminalRows(page: Page): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function setExplorerContentBottomPadding(page: Page, padding: number): Promise<void> {
|
||||
async function setTerminalHeightInset(page: Page, inset: number): Promise<void> {
|
||||
await page.evaluate((nextPadding) => {
|
||||
const container = document.querySelector<HTMLElement>(
|
||||
'[data-testid="explorer-content-area"]'
|
||||
const surfaces = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]')
|
||||
);
|
||||
if (!container) {
|
||||
const activeSurface =
|
||||
surfaces.find((surface) => surface.offsetParent !== null) ?? surfaces[0] ?? null;
|
||||
if (!activeSurface) {
|
||||
return;
|
||||
}
|
||||
container.style.boxSizing = "border-box";
|
||||
container.style.paddingBottom = nextPadding + "px";
|
||||
}, padding);
|
||||
const host = activeSurface.parentElement as HTMLElement | null;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
if (nextPadding > 0) {
|
||||
host.style.flex = "0 0 auto";
|
||||
host.style.height = `calc(100% - ${nextPadding}px)`;
|
||||
return;
|
||||
}
|
||||
host.style.flex = "1 1 auto";
|
||||
host.style.height = "100%";
|
||||
}, inset);
|
||||
}
|
||||
|
||||
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
|
||||
@@ -646,12 +678,12 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
|
||||
.toBeGreaterThan(0);
|
||||
const initialRows = await getTerminalRows(page);
|
||||
|
||||
await setExplorerContentBottomPadding(page, 220);
|
||||
await setTerminalHeightInset(page, 220);
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeLessThan(initialRows);
|
||||
|
||||
await setExplorerContentBottomPadding(page, 0);
|
||||
await setTerminalHeightInset(page, 0);
|
||||
await expect
|
||||
.poll(() => getTerminalRows(page), { timeout: 30000 })
|
||||
.toBeGreaterThanOrEqual(initialRows);
|
||||
@@ -679,7 +711,7 @@ test("terminal viewport resizes and uses xterm scrollback", async ({ page }) =>
|
||||
`${scrollbackMarker}-180`
|
||||
);
|
||||
|
||||
const surface = page.getByTestId("terminal-surface").first();
|
||||
const surface = visibleTestId(page, "terminal-surface");
|
||||
await surface.hover();
|
||||
await page.mouse.wheel(0, -3000);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user