Fix app E2E GitHub harness

This commit is contained in:
Mohamed Boudra
2026-06-08 23:03:56 +07:00
parent b0954d1616
commit 409d67ef65
2 changed files with 66 additions and 114 deletions
+50 -105
View File
@@ -1,4 +1,5 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process"; import { randomUUID } from "node:crypto";
import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -6,6 +7,8 @@ import path from "node:path";
import net from "node:net"; import net from "node:net";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork"; import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
interface WaitForServerOptions { interface WaitForServerOptions {
@@ -188,7 +191,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null; let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null; let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null; let paseoHome: string | null = null;
let fakeToolBinDir: string | null = null; let fakeEditorBinDir: string | null = null;
let relayProcess: ChildProcess | null = null; let relayProcess: ChildProcess | null = null;
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null { function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
@@ -209,81 +212,24 @@ interface OfferPayload {
relay: { endpoint: string }; relay: { endpoint: string };
} }
async function createFakeToolBin(): Promise<string> { interface DaemonClientConfig {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-")); url: string;
const ghPath = path.join(binDir, "gh"); clientId: string;
await writeFile( clientType: "cli";
ghPath, webSocketFactory: NodeWebSocketFactory;
`#!/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() { interface PairingDaemonClient {
const realGh = findRealGh(); connect(): Promise<void>;
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); } close(): Promise<void>;
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env }); getDaemonPairingOffer(): Promise<{
process.exit(result.status ?? 1); relayEnabled: boolean;
url: string;
}>;
} }
if (args[0] === "auth" && args[1] === "status") { async function createFakeEditorBin(): Promise<string> {
process.exit(0); const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
}
if (args[0] === "pr" && args[1] === "list") {
console.log(JSON.stringify([
{
number: 515,
title: "Review selected start ref",
url: "https://github.com/getpaseo/paseo/pull/515",
state: "OPEN",
body: "Fixture pull request for app e2e.",
labels: [],
baseRefName: "main",
headRefName: "feature/start-from-pr"
}
]));
process.exit(0);
}
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
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") {
console.log("[]");
process.exit(0);
}
forwardToRealGh();
`,
);
await chmod(ghPath, 0o755);
const fakeEditorSource = `#!/usr/bin/env node const fakeEditorSource = `#!/usr/bin/env node
const fs = require("fs"); const fs = require("fs");
@@ -343,29 +289,29 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
return offer as OfferPayload; return offer as OfferPayload;
} }
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload { async function loadPairingOfferFromDaemon(port: number): Promise<OfferPayload> {
const stdout = execFileSync( const DaemonClient = await loadDaemonClientConstructor<DaemonClientConfig, PairingDaemonClient>();
process.execPath, const client = new DaemonClient({
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"], url: `ws://127.0.0.1:${port}/ws`,
{ clientId: `playwright-global-setup-${randomUUID()}`,
cwd: repoRoot, clientType: "cli",
env: { webSocketFactory: createNodeWebSocketFactory(),
...process.env, });
PASEO_HOME: paseoHomePath,
}, await client.connect();
encoding: "utf8", try {
}, const pairing = await client.getDaemonPairingOffer();
); if (!pairing.relayEnabled || !pairing.url) {
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null }; throw new Error("Daemon returned a disabled pairing offer");
if (payload.relayEnabled !== true || typeof payload.url !== "string") { }
throw new Error(`Unexpected daemon pair response: ${stdout}`); return decodeOfferFromFragmentUrl(pairing.url);
} finally {
await client.close().catch(() => {});
} }
return decodeOfferFromFragmentUrl(payload.url);
} }
async function waitForPairingOfferFromCli(args: { async function waitForPairingOfferFromDaemon(args: {
repoRoot: string; port: number;
paseoHome: string;
timeoutMs?: number; timeoutMs?: number;
}): Promise<OfferPayload> { }): Promise<OfferPayload> {
const timeoutMs = args.timeoutMs ?? 15000; const timeoutMs = args.timeoutMs ?? 15000;
@@ -374,7 +320,7 @@ async function waitForPairingOfferFromCli(args: {
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
try { try {
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome); return await loadPairingOfferFromDaemon(args.port);
} catch (error) { } catch (error) {
lastError = error; lastError = error;
await sleep(100); await sleep(100);
@@ -382,7 +328,7 @@ async function waitForPairingOfferFromCli(args: {
} }
throw new Error( throw new Error(
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${ `Timed out waiting for daemon pairing offer: ${
lastError instanceof Error ? lastError.message : String(lastError) lastError instanceof Error ? lastError.message : String(lastError)
}`, }`,
); );
@@ -629,7 +575,7 @@ interface DaemonSpawnArgs {
relayPort: number; relayPort: number;
metroPort: number; metroPort: number;
paseoHome: string; paseoHome: string;
fakeToolBinDir: string; fakeEditorBinDir: string;
editorRecordPath: string; editorRecordPath: string;
dictation: DictationConfig; dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>; buffer: ReturnType<typeof createLineBuffer>;
@@ -644,7 +590,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
cwd: serverDir, cwd: serverDir,
env: { env: {
...process.env, ...process.env,
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`, PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome, PASEO_HOME: args.paseoHome,
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath, PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
PASEO_SERVER_ID: "srv_e2e_test_daemon", PASEO_SERVER_ID: "srv_e2e_test_daemon",
@@ -710,9 +656,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
} else if (paseoHome) { } else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`); console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
} }
if (fakeToolBinDir) { if (fakeEditorBinDir) {
await rm(fakeToolBinDir, { recursive: true, force: true }); await rm(fakeEditorBinDir, { recursive: true, force: true });
fakeToolBinDir = null; fakeEditorBinDir = null;
} }
} }
@@ -727,7 +673,7 @@ export default async function globalSetup() {
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1"; const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"))); paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl"); const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
fakeToolBinDir = await createFakeToolBin(); fakeEditorBinDir = await createFakeEditorBin();
const metroLineBuffer = createLineBuffer(); const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer(); const daemonLineBuffer = createLineBuffer();
@@ -745,7 +691,7 @@ export default async function globalSetup() {
relayPort, relayPort,
metroPort, metroPort,
paseoHome, paseoHome,
fakeToolBinDir, fakeEditorBinDir,
editorRecordPath, editorRecordPath,
dictation, dictation,
buffer: daemonLineBuffer, buffer: daemonLineBuffer,
@@ -765,9 +711,8 @@ export default async function globalSetup() {
}), }),
]); ]);
const offer = await waitForPairingOfferFromCli({ const offer = await waitForPairingOfferFromDaemon({
repoRoot, port,
paseoHome,
}); });
process.env.E2E_DAEMON_PORT = String(port); process.env.E2E_DAEMON_PORT = String(port);
+16 -9
View File
@@ -28,6 +28,7 @@ import {
submitNewWorkspacePrompt, submitNewWorkspacePrompt,
} from "./helpers/new-workspace"; } from "./helpers/new-workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import { createTempGithubRepo, hasGithubAuth } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { import {
expectSidebarWorkspaceSelected, expectSidebarWorkspaceSelected,
@@ -707,10 +708,16 @@ test.describe("New workspace flow", () => {
}); });
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => { test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-"); test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
const ghRepo = await createTempGithubRepo({
category: "new-workspace-pr-ref",
prs: [{ title: "Review selected start ref", state: "open" }],
});
const pr = ghRepo.prs[0]!;
try { try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path); const openedProject = await openProjectViaDaemon(client, pr.localPath);
localWorkspaceIds.add(openedProject.workspaceId); localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page); await gotoAppShell(page);
@@ -720,19 +727,19 @@ test.describe("New workspace flow", () => {
projectDisplayName: openedProject.projectDisplayName, projectDisplayName: openedProject.projectDisplayName,
}); });
await openStartingRefPicker(page); await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, 515); await selectGitHubPrInPicker(page, pr.number);
await expectStartingRefPickerTriggerPr(page, { await expectStartingRefPickerTriggerPr(page, {
number: 515, number: pr.number,
title: "Review selected start ref", title: pr.title,
headRef: "feature/start-from-pr", headRef: pr.branch,
}); });
await expectComposerGithubAttachmentPill(page, { await expectComposerGithubAttachmentPill(page, {
number: 515, number: pr.number,
title: "Review selected start ref", title: pr.title,
}); });
} finally { } finally {
await tempRepo.cleanup(); await ghRepo.cleanup();
} }
}); });
}); });