mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-15 04:42:45 +00:00
feat(app/e2e): introduce withWorkspace fixture and DSL helpers (#717)
Adds a `withWorkspace` Playwright fixture plus composable helpers (permissions, sidebar, composer, agent-stream, settings) so specs read as user-level intent. Migrates workspace-lifecycle and settings-host-page to the new DSL as proof.
This commit is contained in:
17
packages/app/e2e/helpers/agent-stream.ts
Normal file
17
packages/app/e2e/helpers/agent-stream.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
|
||||
const messages = page.getByTestId("assistant-message");
|
||||
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
|
||||
await expect(target).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
|
||||
}
|
||||
@@ -658,23 +658,6 @@ export const createAgentInRepo = async (
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByTestId("permission-request-question").first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||
await acceptButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
// Wait for the stop button to disappear
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||
|
||||
18
packages/app/e2e/helpers/composer.ts
Normal file
18
packages/app/e2e/helpers/composer.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(text);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
export async function cancelAgent(page: Page): Promise<void> {
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
||||
await stopButton.click();
|
||||
}
|
||||
17
packages/app/e2e/helpers/permissions.ts
Normal file
17
packages/app/e2e/helpers/permissions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function allowPermission(page: Page): Promise<void> {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5_000 });
|
||||
await acceptButton.click();
|
||||
}
|
||||
|
||||
export async function denyPermission(page: Page): Promise<void> {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5_000 });
|
||||
await denyButton.click();
|
||||
}
|
||||
52
packages/app/e2e/helpers/settings.ts
Normal file
52
packages/app/e2e/helpers/settings.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
diagnostics: "Diagnostics",
|
||||
about: "About",
|
||||
} as const;
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
if (section === "projects") {
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
return;
|
||||
}
|
||||
|
||||
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||
}
|
||||
|
||||
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function selectHostConnectionType(
|
||||
page: Page,
|
||||
type: "direct" | "relay",
|
||||
): Promise<void> {
|
||||
const label = type === "direct" ? "Direct connection" : "Paste pairing link";
|
||||
await page.getByRole("button", { name: label }).click();
|
||||
}
|
||||
|
||||
export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
21
packages/app/e2e/helpers/sidebar.ts
Normal file
21
packages/app/e2e/helpers/sidebar.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
function requireServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
105
packages/app/e2e/helpers/with-workspace.ts
Normal file
105
packages/app/e2e/helpers/with-workspace.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./workspace-setup";
|
||||
|
||||
export interface CreatedWorkspace {
|
||||
workspaceId: string;
|
||||
repoPath: string;
|
||||
navigateTo(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WithWorkspaceOptions {
|
||||
worktree?: boolean;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export type WithWorkspace = (options?: WithWorkspaceOptions) => Promise<CreatedWorkspace>;
|
||||
|
||||
interface WorktreeRecord {
|
||||
repoPath: string;
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export interface WithWorkspaceHandle {
|
||||
withWorkspace: WithWorkspace;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
let client: WorkspaceSetupDaemonClient | null = null;
|
||||
const repos: Array<{ cleanup: () => Promise<void> }> = [];
|
||||
const worktrees: WorktreeRecord[] = [];
|
||||
|
||||
const withWorkspace: WithWorkspace = async (options) => {
|
||||
if (!client) {
|
||||
client = await connectWorkspaceSetupClient();
|
||||
}
|
||||
const prefix = options?.prefix ?? (options?.worktree ? "wt-" : "ws-");
|
||||
const repo = await createTempGitRepo(prefix);
|
||||
repos.push(repo);
|
||||
|
||||
let workspacePath = repo.path;
|
||||
if (options?.worktree) {
|
||||
const tempRoot = await realpath("/tmp");
|
||||
workspacePath = path.join(
|
||||
tempRoot,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `paseo-wt-${Date.now()}`;
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(workspacePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{ cwd: repo.path, stdio: "ignore" },
|
||||
);
|
||||
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
||||
// Register the parent project so the sidebar lists it before we navigate.
|
||||
await client.openProject(repo.path);
|
||||
}
|
||||
|
||||
const opened = await client.openProject(workspacePath);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
|
||||
}
|
||||
const workspaceId = opened.workspace.id;
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
repoPath: workspacePath,
|
||||
navigateTo: async () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await selectWorkspaceInSidebar(page, workspaceId);
|
||||
await waitForTabBar(page);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
withWorkspace,
|
||||
cleanup: async () => {
|
||||
for (const { repoPath, worktreePath } of worktrees) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so the original test failure is preserved.
|
||||
}
|
||||
}
|
||||
for (const repo of repos) {
|
||||
await repo.cleanup();
|
||||
}
|
||||
if (client) {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user