mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* feat(app/e2e): add PR pane E2E spec with fixture-based seeding Adds 7 tests covering open/merged/closed/draft states, check pill counts, activity row count, and the empty-checks graceful render. Fake gh CLI now reads .paseo-e2e-pr.json and .paseo-e2e-timeline.json from the workspace cwd so each test gets isolated fixture data. * refactor(app/e2e): switch PR pane spec to real GitHub fixtures Replace the fixture-file seeding approach with ephemeral real GitHub repos created via the gh CLI. `helpers/github-fixtures.ts` creates a single private repo per test run, pushes one branch per PR scenario, and seeds commit statuses and comments as needed. The fake gh binary now forwards unhandled calls (no fixture file present) to the real gh so the daemon can query live GitHub data. All 7 tests skip gracefully when gh auth is unavailable. * refactor(app/e2e): address PR review feedback on pr-pane spec - helpers/pr-pane: extract assertCheckPill helper so expectPrPaneCheckSummary is a flat 3-call sequence; remove branching on rendering shape - helpers/pr-pane: drop .first() on explorer button and redundant toBeVisible before click; import getStateLabel from @/utils/pr-pane-data instead of duplicating the map - pr-pane.spec.ts: move test.skip and test.setTimeout into beforeEach; replace positional IDX_* constants with workspaceByTitle Map keyed by PR title - helpers/github-fixtures: add IssueSpec/GhIssueFixture and issues[] option; extract seedPr/seedIssue to satisfy complexity limit; make prs/issues optional
245 lines
6.8 KiB
TypeScript
245 lines
6.8 KiB
TypeScript
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<void>;
|
|
}
|
|
|
|
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<GhRepoFixture> {
|
|
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 })),
|
|
]);
|
|
},
|
|
};
|
|
}
|