diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index ad420db51..b36c57d2e 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -215,8 +215,28 @@ async function createFakeGhBin(): Promise { await writeFile( ghPath, `#!/usr/bin/env node +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); const args = process.argv.slice(2); +function findRealGh() { + const fakeBinDir = __dirname; + for (const dir of (process.env.PATH || "").split(path.delimiter)) { + if (dir === fakeBinDir) continue; + const candidate = path.join(dir, "gh"); + try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {} + } + return null; +} + +function forwardToRealGh() { + const realGh = findRealGh(); + if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); } + const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env }); + process.exit(result.status ?? 1); +} + if (args[0] === "auth" && args[1] === "status") { process.exit(0); } @@ -238,8 +258,21 @@ if (args[0] === "pr" && args[1] === "list") { } if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) { - console.error("no pull requests found for branch"); - process.exit(1); + const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json"); + if (fs.existsSync(fixture)) { + console.log(fs.readFileSync(fixture, "utf8")); + process.exit(0); + } + forwardToRealGh(); +} + +if (args[0] === "api" && args[1] === "graphql") { + const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json"); + if (fs.existsSync(fixture)) { + console.log(fs.readFileSync(fixture, "utf8")); + process.exit(0); + } + forwardToRealGh(); } if (args[0] === "issue" && args[1] === "list") { @@ -247,8 +280,7 @@ if (args[0] === "issue" && args[1] === "list") { process.exit(0); } -console.error("Unsupported fake gh invocation: " + args.join(" ")); -process.exit(1); +forwardToRealGh(); `, ); await chmod(ghPath, 0o755); diff --git a/packages/app/e2e/helpers/github-fixtures.ts b/packages/app/e2e/helpers/github-fixtures.ts new file mode 100644 index 000000000..7e0b36860 --- /dev/null +++ b/packages/app/e2e/helpers/github-fixtures.ts @@ -0,0 +1,244 @@ +import { execFileSync, execSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export function hasGithubAuth(): boolean { + try { + execSync("gh auth status", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export interface CheckSpec { + context: string; + state: "success" | "failure" | "pending"; +} + +export interface PrSpec { + title: string; + state: "open" | "merged" | "closed" | "draft"; + checks?: CheckSpec[]; + commentCount?: number; +} + +export interface IssueSpec { + title: string; + body?: string; + labels?: string[]; + state?: "open" | "closed"; +} + +export interface GhPrFixture { + number: number; + title: string; + url: string; + branch: string; + localPath: string; +} + +export interface GhIssueFixture { + number: number; + title: string; + url: string; +} + +export interface GhRepoFixture { + owner: string; + name: string; + fullName: string; + prs: GhPrFixture[]; + issues: GhIssueFixture[]; + cleanup(): Promise; +} + +function gh(args: string[], opts?: { cwd?: string }): string { + return execFileSync("gh", args, { + cwd: opts?.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function git(args: string[], cwd: string): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +async function seedPr(args: { + spec: PrSpec; + branch: string; + index: number; + basePath: string; + authedUrl: string; + fullName: string; + repoName: string; +}): Promise<{ fixture: GhPrFixture; localPath: string }> { + const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args; + + const createArgs = [ + "pr", + "create", + "--title", + spec.title, + "--base", + "main", + "--head", + branch, + "--body", + "", + ]; + if (spec.state === "draft") createArgs.push("--draft"); + + const prUrl = gh(createArgs, { cwd: basePath }); + const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10); + + if (spec.checks && spec.checks.length > 0) { + const sha = git(["rev-parse", branch], basePath); + for (const check of spec.checks) { + gh([ + "api", + `repos/${fullName}/statuses/${sha}`, + "--method", + "POST", + "-f", + `state=${check.state}`, + "-f", + `context=${check.context}`, + "-f", + `target_url=https://example.com/${encodeURIComponent(check.context)}`, + ]); + } + } + + for (let j = 0; j < (spec.commentCount ?? 0); j++) { + gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath }); + } + + if (spec.state === "merged") { + gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath }); + } else if (spec.state === "closed") { + gh(["pr", "close", String(prNumber)], { cwd: basePath }); + } + + const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`)); + git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath); + // Clean remote URL (no embedded token) so gh can parse owner/repo + git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath); + git(["config", "user.email", "e2e@paseo.test"], localPath); + git(["config", "user.name", "Paseo E2E"], localPath); + git(["config", "commit.gpgsign", "false"], localPath); + + return { + fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath }, + localPath, + }; +} + +function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture { + const { spec, basePath } = args; + const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""]; + for (const label of spec.labels ?? []) { + createArgs.push("--label", label); + } + const issueUrl = gh(createArgs, { cwd: basePath }); + const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10); + if (spec.state === "closed") { + gh(["issue", "close", String(issueNumber)], { cwd: basePath }); + } + return { number: issueNumber, title: spec.title, url: issueUrl }; +} + +export async function createTempGithubRepo(options: { + prefix?: string; + prs?: PrSpec[]; + issues?: IssueSpec[]; +}): Promise { + const { prefix = "paseo-e2e-", prs = [], issues = [] } = options; + const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const repoName = `${prefix}${uniqueSuffix}`; + + // Bootstrap local git repo + const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`)); + git(["init", "-b", "main"], basePath); + git(["config", "user.email", "e2e@paseo.test"], basePath); + git(["config", "user.name", "Paseo E2E"], basePath); + git(["config", "commit.gpgsign", "false"], basePath); + await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n"); + git(["add", "README.md"], basePath); + git(["commit", "-m", "Initial commit"], basePath); + + // Create GitHub repo and push initial commit + gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]); + + const owner = gh(["api", "user", "--jq", ".login"]); + const fullName = `${owner}/${repoName}`; + const token = gh(["auth", "token"]); + const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`; + + // Switch remote to authed URL for subsequent pushes + git(["remote", "set-url", "origin", authedUrl], basePath); + + // Create a branch + commit for each PR spec + const branches: string[] = []; + for (let i = 0; i < prs.length; i++) { + const branch = `pr-branch-${i + 1}`; + branches.push(branch); + git(["checkout", "-b", branch], basePath); + await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`); + git(["add", `pr-${i + 1}.txt`], basePath); + git(["commit", "-m", `Add PR ${i + 1}`], basePath); + git(["checkout", "main"], basePath); + } + + if (branches.length > 0) { + git(["push", "origin", ...branches], basePath); + } + + // Create PRs, seed checks/comments, apply state changes, clone workspaces + const prFixtures: GhPrFixture[] = []; + const localPaths: string[] = []; + + for (let i = 0; i < prs.length; i++) { + const { fixture, localPath } = await seedPr({ + spec: prs[i], + branch: branches[i], + index: i, + basePath, + authedUrl, + fullName, + repoName, + }); + localPaths.push(localPath); + prFixtures.push(fixture); + } + + // Create issues + const issueFixtures: GhIssueFixture[] = []; + for (const spec of issues) { + issueFixtures.push(seedIssue({ spec, basePath })); + } + + return { + owner, + name: repoName, + fullName, + prs: prFixtures, + issues: issueFixtures, + cleanup: async () => { + try { + gh(["repo", "delete", fullName, "--yes"]); + } catch { + // Best-effort cleanup + } + await Promise.all([ + rm(basePath, { recursive: true, force: true }), + ...localPaths.map((p) => rm(p, { recursive: true, force: true })), + ]); + }, + }; +} diff --git a/packages/app/e2e/helpers/pr-pane.ts b/packages/app/e2e/helpers/pr-pane.ts new file mode 100644 index 000000000..924f12208 --- /dev/null +++ b/packages/app/e2e/helpers/pr-pane.ts @@ -0,0 +1,42 @@ +import { expect, type Page } from "@playwright/test"; +import { getStateLabel } from "@/utils/pr-pane-data"; + +export async function openPrPane(page: Page): Promise { + await page.getByRole("button", { name: "Open explorer" }).click(); + await page.getByTestId("explorer-tab-pr").click(); + await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 }); +} + +export async function expectPrPaneTitle(page: Page, title: string): Promise { + await expect(page.getByTestId("pr-pane-title")).toContainText(title, { timeout: 15_000 }); +} + +export async function expectPrPaneState( + page: Page, + state: "open" | "merged" | "closed" | "draft", +): Promise { + await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), { + timeout: 15_000, + }); +} + +async function assertCheckPill(page: Page, testId: string, count: number): Promise { + const locator = page.getByTestId(testId); + await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 }); + if (count > 0) { + await expect(locator).toContainText(String(count)); + } +} + +export async function expectPrPaneCheckSummary( + page: Page, + counts: { passed: number; failed: number; pending: number }, +): Promise { + await assertCheckPill(page, "pr-pane-check-passed", counts.passed); + await assertCheckPill(page, "pr-pane-check-failed", counts.failed); + await assertCheckPill(page, "pr-pane-check-pending", counts.pending); +} + +export async function expectPrPaneActivityCount(page: Page, count: number): Promise { + await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 }); +} diff --git a/packages/app/e2e/pr-pane.spec.ts b/packages/app/e2e/pr-pane.spec.ts new file mode 100644 index 000000000..b0106f18e --- /dev/null +++ b/packages/app/e2e/pr-pane.spec.ts @@ -0,0 +1,124 @@ +import { test } from "./fixtures"; +import { + openPrPane, + expectPrPaneTitle, + expectPrPaneState, + expectPrPaneCheckSummary, + expectPrPaneActivityCount, +} from "./helpers/pr-pane"; +import { gotoWorkspace } from "./helpers/launcher"; +import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures"; +import { + connectWorkspaceSetupClient, + type WorkspaceSetupDaemonClient, +} from "./helpers/workspace-setup"; + +const GITHUB_AUTH = hasGithubAuth(); + +test.describe("PR pane", () => { + test.describe.configure({ retries: 1 }); + + let seedClient: WorkspaceSetupDaemonClient; + let repoFixture: GhRepoFixture; + const workspaceByTitle = new Map(); + + test.beforeAll(async () => { + if (!GITHUB_AUTH) return; + + seedClient = await connectWorkspaceSetupClient(); + + repoFixture = await createTempGithubRepo({ + prefix: "paseo-e2e-pr-", + prs: [ + { title: "Review selected start ref", state: "open" }, + { title: "Merged feature branch", state: "merged" }, + { title: "Closed without merge", state: "closed" }, + { title: "Work in progress", state: "draft" }, + { + title: "PR with mixed checks", + state: "open", + checks: [ + { context: "build-1", state: "success" }, + { context: "build-2", state: "success" }, + { context: "deploy", state: "failure" }, + { context: "security", state: "pending" }, + ], + }, + { title: "PR with reviews", state: "open", commentCount: 3 }, + { title: "PR with no checks", state: "open" }, + ], + }); + + for (const pr of repoFixture.prs) { + const result = await seedClient.openProject(pr.localPath); + if (!result.workspace) { + throw new Error(result.error ?? `Failed to open project ${pr.localPath}`); + } + workspaceByTitle.set(pr.title, result.workspace.id); + } + }); + + test.afterAll(async () => { + await repoFixture?.cleanup().catch(() => undefined); + await seedClient?.close().catch(() => undefined); + }); + + test.beforeEach(async () => { + test.skip(!GITHUB_AUTH, "Requires GitHub authentication (gh auth login)"); + test.setTimeout(60_000); + }); + + test("renders an open PR with title, state, and repo line", async ({ page }) => { + await gotoWorkspace(page, workspaceByTitle.get("Review selected start ref")!); + await openPrPane(page); + + await expectPrPaneTitle(page, "Review selected start ref"); + await expectPrPaneState(page, "open"); + }); + + test("renders merged state label and icon", async ({ page }) => { + await gotoWorkspace(page, workspaceByTitle.get("Merged feature branch")!); + await openPrPane(page); + + await expectPrPaneState(page, "merged"); + await expectPrPaneTitle(page, "Merged feature branch"); + }); + + test("renders closed state label and icon", async ({ page }) => { + await gotoWorkspace(page, workspaceByTitle.get("Closed without merge")!); + await openPrPane(page); + + await expectPrPaneState(page, "closed"); + await expectPrPaneTitle(page, "Closed without merge"); + }); + + test("renders draft state label and icon", async ({ page }) => { + await gotoWorkspace(page, workspaceByTitle.get("Work in progress")!); + await openPrPane(page); + + await expectPrPaneState(page, "draft"); + await expectPrPaneTitle(page, "Work in progress"); + }); + + test("renders check pills with correct passed/failed/pending counts", async ({ page }) => { + await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!); + await openPrPane(page); + + await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 }); + }); + + test("renders activity rows with correct count", async ({ page }) => { + await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!); + await openPrPane(page); + + await expectPrPaneActivityCount(page, 3); + }); + + test("renders gracefully with zero checks", async ({ page }) => { + await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!); + await openPrPane(page); + + await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 }); + await expectPrPaneTitle(page, "PR with no checks"); + }); +}); diff --git a/packages/app/src/components/pr-pane.tsx b/packages/app/src/components/pr-pane.tsx index 4c7bdacc3..98269490f 100644 --- a/packages/app/src/components/pr-pane.tsx +++ b/packages/app/src/components/pr-pane.tsx @@ -91,15 +91,17 @@ export function PrPane({ data }: { data: PrPaneData }) { ); return ( - + {({ hovered }) => ( <> - {stateLabel} + + {stateLabel} + - + {data.title} {hovered ? ( @@ -124,12 +126,19 @@ export function PrPane({ data }: { data: PrPaneData }) { count={passed} color={theme.colors.statusSuccess} icon={checkSuccessIcon} + testID="pr-pane-check-passed" + /> + - } @@ -211,15 +220,17 @@ function SummaryPill({ count, color, icon, + testID, }: { count: number; color: string; icon: React.ReactNode; + testID?: string; }) { const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]); if (count === 0) return null; return ( - + {icon} {count} @@ -264,7 +275,7 @@ function ActivityRow({ item }: { item: PrPaneActivity }) { [item.avatarColor], ); return ( - + {item.author.slice(0, 1).toUpperCase()}