import { expect, type Page } from "@playwright/test"; import { ConnectionBlock, ConnectionFailure } from "./connection-config.js"; import { connectionDelay } from "./connection-target.js"; import type { GitHubReviewJourneyConfig } from "./github-review-config.js"; export async function enterGitHubJourneyCompany(page: Page, origin: string, company: { issuePrefix: string }) { if (!/^[A-Za-z0-9]+$/.test(company.issuePrefix)) throw new ConnectionFailure("invalid_journey_company_prefix"); const pathname = `/${company.issuePrefix}/dashboard`; await page.goto(`${origin}${pathname}`); await page.waitForURL(url => url.origin === origin && url.pathname === pathname); } /** Capture measured pages only, excluding authentication and unrelated GitHub data. */ export function isGitHubJourneyEvidencePage(value: string, targetOrigin: string, repository: string, appSlug?: string) { const url = new URL(value); if ([...url.searchParams.keys()].some(key => /token|secret|code|state|password/i.test(key))) return false; if (url.origin === targetOrigin) return !/connect|login|auth/.test(url.pathname); if (url.origin !== "https://github.com") return false; const prefix = `/${repository}/`; if (url.pathname.startsWith(prefix) && /^(?:pull\/\d+(?:\/(?:files|commits|checks))?|runs\/\d+|actions\/runs\/\d+(?:\/job\/\d+)?)\/?$/.test(url.pathname.slice(prefix.length))) return true; return Boolean(appSlug && (url.pathname === `/apps/${appSlug}/installations/new` || url.pathname === `/apps/${appSlug}/installations/new/permissions`)); } export async function awaitJourneyCondition(check: () => Promise, timeoutMs: number, code: string, signal: AbortSignal, boundary: "awaiting_user" | "failed" = "awaiting_user") { const end = Date.now() + timeoutMs; while (Date.now() < end) { if (signal.aborted) throw new ConnectionBlock("blocked_target", "journey_interrupted"); if (await check()) return; await connectionDelay(750); } if (boundary === "failed") throw new ConnectionFailure(code); throw new ConnectionBlock("awaiting_user", code); } export async function observeGitHubJourneyAccount(page: Page) { const menu = page.getByRole("button", { name: /Open user navigation menu|View profile and more/i }); const dialog = page.getByRole("dialog", { name: "User navigation", exact: true }); await menu.click(); try { await dialog.waitFor({ timeout: 3_000 }); } catch (error) { // GitHub can replace its server-rendered header during hydration. Retry // once only when the observed control still explicitly says Open. if (!await menu.isVisible() || await dialog.isVisible()) throw error; await menu.click(); await dialog.waitFor(); } const profile = dialog.getByRole("link", { name: "Profile", exact: true }); const href = await profile.getAttribute("href"); const url = new URL(href ?? "", "https://github.com"); const account = url.origin === "https://github.com" ? /^\/([A-Za-z0-9-]+)\/?$/.exec(url.pathname)?.[1] : undefined; if (!account) throw new ConnectionFailure("github_profile_identity_missing"); await page.keyboard.press("Escape"); return account; } /** Prepare one repository grant; submitting Install remains a separate action. */ export async function selectGitHubJourneyRepository(page: Page, repository: string) { const selectedOnly = page.getByRole("radio", { name: /Only select repositories/i }); await selectedOnly.check(); await page.getByRole("button", { name: /Select repositories/i }).click(); await page.getByRole("textbox", { name: "Search for a repository", exact: true }).fill(repository.split("/")[1]!); const name = new RegExp(`^${repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|$)`); // GitHub nests the selectable repository item inside a containing menu item. await page.getByRole("menuitem", { name }).last().click(); await page.keyboard.press("Escape"); await expect(selectedOnly).toBeChecked(); await expect(page.getByRole("radio", { name: /^All repositories/ })).not.toBeChecked(); await expect(page.getByRole("main")).toContainText("Selected 1 repository."); await expect(page.getByRole("button", { name: `Remove ${repository.split("/")[1]}`, exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Install", exact: true })).toBeEnabled(); } /** Login remains ordinary browser UI. Passkeys are never exported or emulated. */ export async function ensureGitHubJourneyLogin(input: { page: Page; config: GitHubReviewJourneyConfig; username: string; password?: () => Promise; signal: AbortSignal; checkpoint(id: string): Promise; }) { const { page, config } = input; const signedIn = () => page.getByRole("button", { name: /Open user navigation menu|View profile and more/i }).isVisible(); if (!await signedIn()) { const loginLink = page.getByRole("link", { name: /^Sign in$/ }); if (await loginLink.isVisible()) await loginLink.click(); if (config.github.login === "password") { await page.getByLabel("Username or email address", { exact: true }).fill(input.username); await page.getByLabel("Password", { exact: true }).fill(await input.password!()); await page.getByRole("button", { name: "Sign in", exact: true }).click(); } await input.checkpoint("awaiting_github_sign_in"); console.log("[github-review-journey] Complete Ralph QA GitHub sign-in/passkey in the dedicated QA browser. No credential needs to be pasted into chat."); await awaitJourneyCondition(signedIn, config.browser.loginTimeoutMs, "github_login_required", input.signal); } // The login identifier can be an email address; verify the GitHub handle. if ((await observeGitHubJourneyAccount(page)).toLowerCase() !== config.github.account.toLowerCase()) throw new ConnectionBlock("blocked_target", "github_account_mismatch"); } /** Explicit one-time bootstrap. Later attempts reuse this private repository. */ export async function createPrivateJourneyRepository(page: Page, repository: string) { const [owner, name] = repository.split("/"); await page.getByRole("button", { name: /Create new/i }).click(); await page.getByRole("menuitem", { name: /New repository/i }).click(); const ownerButton = page.getByRole("button", { name: /Owner|Choose an owner/i }); await expect(ownerButton).toHaveText(owner!); await page.getByRole("textbox", { name: /Repository name/i }).fill(name!); await expect(page.getByText(`${name} is available.`, { exact: true })).toBeVisible(); const privateChoice = page.getByRole("radio", { name: /Private/ }); if (await privateChoice.isVisible()) await privateChoice.check(); else { await page.getByRole("button", { name: /Choose visibility|Public/i }).click(); await page.getByRole("menuitemradio", { name: /Private/ }).click(); } await expect(page.getByRole("button", { name: "Choose visibility: Private", exact: true })).toBeVisible(); const readme = page.getByRole("button", { name: "Add README", exact: true }); if (await readme.getAttribute("aria-pressed") !== "true") await readme.click(); await expect(readme).toHaveAttribute("aria-pressed", "true"); await page.getByRole("button", { name: "Create repository", exact: true }).click(); await page.waitForURL(url => url.origin === "https://github.com" && url.pathname === `/${repository}`); await expect(page.getByRole("link", { name: name!, exact: true }).first()).toBeVisible(); await expect(page.getByText("Private", { exact: true }).first()).toBeVisible(); } export async function createGitHubJourneyPullRequest(input: { page: Page; branch: string; title: string; contents: string }) { const { page } = input; // The initial file list is server-rendered before GitHub's controls hydrate. // Its loaded commit receipt provides a visible readiness signal. await expect(page.getByRole("link", { name: /^Commit [a-f0-9]{7,40}$/ })).toBeVisible(); await page.getByRole("button", { name: "Add file", exact: true }).click(); await page.getByRole("menuitem", { name: "Create new file", exact: true }).click(); await page.getByRole("textbox", { name: /Name your file|File name/i }).fill("cart-total.js"); await page.getByRole("textbox", { name: /Editing cart-total.js file contents|File contents/i }).fill(input.contents); await page.getByRole("button", { name: /Commit changes/i }).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("textbox", { name: /Commit message/i }).fill("Add isolated cart precision review fixture"); await dialog.getByRole("radio", { name: /Create a new branch/i }).check(); const branch = dialog.getByRole("textbox", { name: "PR target branch", exact: true }); await branch.fill(input.branch); await expect(branch).toHaveValue(input.branch); await dialog.getByRole("button", { name: /Propose changes|Commit changes/i }).click(); await page.getByRole("textbox", { name: /Add a title/i }).fill(input.title); await page.getByRole("textbox", { name: /Add a description|Comment|Description/i }).fill("Disposable automated review fixture. This money-total helper promises exact cent totals. Review only; do not modify or merge the branch. No manual bot mention is used."); await page.getByRole("button", { name: "Create pull request", exact: true }).click(); await page.waitForURL(url => url.origin === "https://github.com" && /\/pull\/\d+$/.test(url.pathname)); const match = /\/pull\/(\d+)$/.exec(new URL(page.url()).pathname); if (!match) throw new ConnectionFailure("created_pr_url_missing"); return Number(match[1]); } export async function observeGitHubHead(page: Page) { const pull = new URL(page.url()); const repository = /^\/([^/]+\/[^/]+)\/pull\/\d+$/.exec(pull.pathname)?.[1]; if (pull.origin !== "https://github.com" || !repository) throw new ConnectionFailure("github_pr_evidence_missing"); const commits = page.getByRole("link", { name: /^Commits(?:\s*\(?\d+\)?)?$/ }); await expect(commits).toBeVisible(); const commitsUrl = new URL(await commits.getAttribute("href") ?? "", pull.origin); if (commitsUrl.origin !== pull.origin || commitsUrl.pathname !== `${pull.pathname}/commits`) throw new ConnectionFailure("github_commits_navigation_missing"); // Follow the rendered navigation link. A click immediately after PR creation // can be swallowed when GitHub replaces its server-rendered navigation. await page.goto(commitsUrl.href); const commit = page.getByRole("link", { name: /^[a-f0-9]{7,40}$/ }).last(); await expect(commit).toBeVisible(); const url = new URL(await commit.getAttribute("href") ?? "", pull.origin); const sha = /\/(?:commits?|changes)\/([a-f0-9]{40})$/.exec(url.pathname)?.[1]; if (url.origin !== pull.origin || !sha || ![`/${repository}/commit/${sha}`, `${pull.pathname}/commits/${sha}`, `${pull.pathname}/changes/${sha}`].includes(url.pathname)) throw new ConnectionFailure("github_head_evidence_missing"); const conversation = page.getByRole("link", { name: /^Conversation(?:\s*\(?\d+\)?)?$/ }); await expect(conversation).toBeVisible(); const conversationUrl = new URL(await conversation.getAttribute("href") ?? "", pull.origin); if (conversationUrl.origin !== pull.origin || conversationUrl.pathname !== pull.pathname) throw new ConnectionFailure("github_conversation_navigation_missing"); await page.goto(conversationUrl.href); return sha; } export async function closeOwnedGitHubPullRequest(page: Page) { const closed = page.getByText("Closed", { exact: true }).first(); if (!await closed.isVisible()) { await expect(page.getByRole("button", { name: /^(?:Merge status|Status)$/ })).toBeEnabled(); await page.getByRole("button", { name: "Close pull request", exact: true }).click(); } await expect(closed).toBeVisible({ timeout: 30_000 }); const restored = page.getByRole("button", { name: "Restore branch", exact: true }); if (!await restored.isVisible()) await page.getByRole("button", { name: "Delete branch", exact: true }).click(); await expect(restored).toBeVisible({ timeout: 30_000 }); }