Files
PaperClipAI/tests/runner-e2e/github-review-browser.ts
T
a0e5166e97 Fix GitHub journey registration diagnostics and native retry cleanup (#17513)
## Thinking Path

> - Paperclip manages work performed by AI agents.
> - The Product E2E harness verifies a GitHub review-bot journey.
> - GitHub can return a registration page without the expected creation
control.
> - The journey could then wait for the full login allowance without
useful evidence.
> - A failed native run can also retain a retry after its terminal
status is visible.
> - This change records a safe registration boundary and uses the normal
Stop route for owned retries.
> - Operators can diagnose the setup failure and confirm cleanup without
changing the behavioral oracle.

## Linked Issues or Issue Description

Refs: #17457.

**What happened?**

The GitHub review journey waited at App registration with no useful
handoff evidence. Its cleanup also skipped failed native runs that still
owned a durable retry.

**Expected behavior**

The journey should report a bounded registration failure or
reauthentication boundary. Cleanup should Stop an owned native retry and
require its audited acknowledgement.

**Steps to reproduce**

1. Run the configured GitHub review journey.
2. Return a GitHub registration page without the expected creation
button, or fail a native review run while its coordinator can retry.
3. Inspect the result and cleanup evidence.

**Paperclip version or commit**

Source built from current master. This extends the driver merged in
#17457.

## What Changed

- Bound the registration render wait to 30 seconds. Keep the separate
attended confirmation allowance.
- Retain GitHub origin, path, and control-presence flags. Retain
headings, button labels, and alerts only on known registration error
pages without authentication fields.
- Exclude query parameters, entered values, page bodies, grant payloads,
and authentication content from that diagnostic.
- Add an optional private authentication hook for ordinary GitHub access
reconfirmation. Detect passkey-first sudo at the manifest URL without
retaining its authentication content.
- Stop owned failed native retries through the existing run-only
cancellation route. Verify company, agent, task, and audit
acknowledgement.
- Use native Stop correlation only for native runs. Queued legacy or
unresolved runs use the ordinary cancel request. Stop scheduled retries
and wait for them to leave the live state set.
- Keep pending accounting debt intact. Add regression coverage and
journey documentation.

## Verification

- Focused journey and report suite: 58 tests passed.
- Runner E2E TypeScript check passed.
- `git diff --check` passed. Credential-value scan passed.
- Live fixture reproduced the missing creation control at the manifest
handoff. The new diagnostic retained its path and control-presence
flags. No model work started. Draft cleanup passed.
- All 58 current-head checks are terminal and green. Greptile scored the
exact head 5/5, with no unresolved review threads.

## Risks

GitHub pages that take more than 30 seconds to render the creation
control now fail early. Native cleanup fails closed if the server does
not provide the expected Stop acknowledgement. The live setup-to-Done
behavioral journey is still unqualified. These changes do not repair a
missing provider usage receipt or weaken its accounting gate.

## Model Used

OpenAI Codex, based on GPT-6. Exact runtime model identifier and
context-window size are not exposed in this session. Used for code
execution, browser tooling, and regression analysis.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Paperclip <paperclip@paperclip.ing>
2026-10-24 21:39:21 -07:00

230 lines
15 KiB
TypeScript

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);
}
export interface GitHubRegistrationBoundary {
origin: string; pathname: string; passwordRequired: boolean; oneTimeCodeRequired: boolean;
reauthenticationRequired: boolean; createControlVisible: boolean;
controls?: { headings: string[]; buttons: string[]; alerts: string[] };
}
/** Keep only navigation and control-presence evidence. Authentication fields,
* page content, query parameters, and grant payloads must stay out of reports. */
export async function waitForGitHubRegistrationControl(page: Page, owner: string, timeoutMs: number,
record: (boundary: GitHubRegistrationBoundary) => Promise<void> = async () => {}, reauthenticate?: () => Promise<void>) {
const control = page.getByRole("button", { name: new RegExp(`^Create GitHub App(?: for ${owner})?$`) });
const observe = async (includeErrorControls = false) => {
const url = new URL(page.url());
if (url.origin !== "https://github.com") throw new ConnectionFailure("github_registration_origin_mismatch");
const boundary: GitHubRegistrationBoundary = { origin: url.origin, pathname: url.pathname,
passwordRequired: await page.locator('input[type="password"]:visible').count() > 0,
oneTimeCodeRequired: await page.locator('input[autocomplete="one-time-code"]:visible').count() > 0,
reauthenticationRequired: await page.getByRole("heading", { name: /^Confirm access$/i }).isVisible()
|| await page.getByRole("button", { name: /^Use (?:your password|passkey)$/i }).isVisible(),
createControlVisible: await control.isVisible() };
boundary.reauthenticationRequired ||= boundary.passwordRequired || boundary.oneTimeCodeRequired
|| /\/(?:login|sessions)(?:\/|$)/.test(boundary.pathname);
if (includeErrorControls && !boundary.reauthenticationRequired
&& ["/settings/apps/new", "/settings/apps/manifest"].includes(url.pathname)) {
boundary.controls = { headings: await page.getByRole("heading").allTextContents(),
buttons: await page.getByRole("button").allTextContents(), alerts: await page.getByRole("alert").allTextContents() };
}
await record(boundary);
return boundary;
};
await observe();
let authenticationAttempted = false;
for (;;) {
try { await control.waitFor({ timeout: Math.min(timeoutMs, 30_000) }); break; }
catch (error) {
if (page.isClosed()) throw error;
const boundary = await observe(true);
if (boundary.reauthenticationRequired) {
if (reauthenticate && !authenticationAttempted) { authenticationAttempted = true; await reauthenticate(); continue; }
throw new ConnectionBlock("awaiting_user", "github_registration_reauthentication_required");
}
throw new ConnectionFailure("github_registration_create_control_missing");
}
}
await observe();
return control;
}
/** 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<boolean>, 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<string>; signal: AbortSignal;
checkpoint(id: string): Promise<void>;
}) {
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 });
}