diff --git a/.github/workflows/visual-capture-fallback.yml b/.github/workflows/visual-capture-fallback.yml new file mode 100644 index 0000000000..987d32b030 --- /dev/null +++ b/.github/workflows/visual-capture-fallback.yml @@ -0,0 +1,174 @@ +name: Gittensory Visual Capture Fallback + +# GitHub-Actions build-and-serve FALLBACK for a repo with no CI-produced preview deploy (#4112, part of the +# #3607 visual-capture convergence epic). gittensory's preview-url.ts discovery chain (Deployments API -> +# commit-check scan -> bot PR-comment scan) only ever finds a preview that SOME OTHER CI already produced; +# this workflow is what lets a self-hosted repo with none of that still get an automated "after" screenshot, +# by having Actions itself build, serve, and capture the PR's own code. +# +# Mirrors this repo's OWN ui-preview.yml fork-safety discipline: `contents: read` only, NO secrets anywhere in +# this job. It is triggered ONLY by `workflow_dispatch`, and ONLY gittensory's own backend dispatches it +# (src/review/visual/actions-fallback.ts, dispatchVisualCaptureFallback) -- always against `ref: +# `. A `workflow_dispatch` call always runs the DISPATCHED ref's copy of this file, so a +# contributor can never smuggle a modified workflow definition through their own PR branch (unlike a +# `pull_request` trigger, which would run the version committed on the PR branch itself). +# +# Security boundary: the untrusted PR code is checked out and BUILT here, but its network reach never leaves +# this job's own ephemeral runner -- it is served on 127.0.0.1 and captured by this SAME job's own preinstalled +# headless Chrome, so no externally-reachable endpoint is ever created for it. This is deliberately NOT a +# custom sandbox (Firecracker/gVisor/etc.): GitHub Actions already gives free, ephemeral, isolated compute for +# untrusted PR code on public repos -- exactly the trust boundary this repo's own CI (ci.yml, ui-preview.yml) +# already relies on -- so building a bespoke one here would just re-solve a problem GitHub already solves. +# Every `${{ inputs.* }}` value is threaded through `env:` rather than interpolated straight into a `run:` +# script -- standard GitHub Actions practice to keep an input's literal text out of the shell-parsed script, +# regardless of how trusted its source is. +# +# Handoff: this job uploads its captured PNGs as a GitHub Actions artifact (`gittensory-visual-fallback`) and +# stops -- it never talks to gittensory directly and holds no credential to do so. On completion, GitHub +# delivers a `workflow_run` webhook; gittensory's backend then lists + downloads that run's artifact using its +# OWN, already-trusted GitHub App installation token -- never a token that passed through this job. +# +# Setup (self-hosted repos only -- NOT needed for gittensory-ui / metagraphed, which already have their own +# preview-deploy pipeline): copy this file, unmodified, into the target repo's `.github/workflows/` at this +# EXACT path and name (`visual-capture-fallback.yml` / "Gittensory Visual Capture Fallback") -- gittensory's +# dispatch call and workflow_run listener both key off this fixed name. Then set `review.visual.actions_fallback: +# true` in that repo's `.gittensory.yml` (see .gittensory.yml.example) to opt in; it activates ONLY when the +# existing discovery chain finds no preview at all, so a repo with its own CI-produced preview is unaffected. + +on: + workflow_dispatch: + inputs: + pr_number: + description: "The PR number this capture is for." + required: true + type: string + head_sha: + description: "The exact PR head commit to check out and build." + required: true + type: string + routes: + description: 'JSON array of route paths to capture, e.g. ["/","/pricing"].' + required: false + type: string + default: '["/"]' + build_cmd: + description: "Shell command that builds the site. Edit this default for your own repo." + required: false + type: string + default: "npm ci && npm run build" + dist_dir: + description: "Directory the build writes its static output to." + required: false + type: string + default: "dist" + serve_port: + description: "Local port to serve the built output on." + required: false + type: string + default: "4173" + +# GitHub renders run-name from these inputs and surfaces the result as workflow_run.display_title in the +# completion webhook -- a workflow_dispatch run carries no natural PR association otherwise. See +# actions-fallback.ts's parseFallbackRunCorrelation, which reads this EXACT "pr= sha=" shape back out. +run-name: "gittensory-visual-fallback pr=${{ inputs.pr_number }} sha=${{ inputs.head_sha }}" + +permissions: + contents: read + +concurrency: + group: visual-capture-fallback-${{ inputs.head_sha }} + cancel-in-progress: true + +jobs: + capture: + name: Build, serve, and capture PR routes + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + GITTENSORY_DIST_DIR: ${{ inputs.dist_dir }} + GITTENSORY_SERVE_PORT: ${{ inputs.serve_port }} + GITTENSORY_ROUTES_JSON: ${{ inputs.routes }} + steps: + - name: Checkout PR head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ inputs.head_sha }} + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + + - name: Build + env: + GITTENSORY_BUILD_CMD: ${{ inputs.build_cmd }} + run: bash -euo pipefail -c "$GITTENSORY_BUILD_CMD" + + - name: Serve built output on localhost + run: | + set -euo pipefail + npx --yes serve@14 -l "tcp://127.0.0.1:${GITTENSORY_SERVE_PORT}" "$GITTENSORY_DIST_DIR" \ + > serve.log 2>&1 & + echo $! > serve.pid + for _ in $(seq 1 30); do + if curl --silent --fail --output /dev/null "http://127.0.0.1:${GITTENSORY_SERVE_PORT}/"; then + echo "Local server is up." + exit 0 + fi + sleep 1 + done + echo "::error::Local static server never became ready:" + cat serve.log || true + exit 1 + + - name: Slugify routes + run: | + set -euo pipefail + # Mirrors slugifyRoutePath in src/review/visual/actions-fallback.ts EXACTLY -- both sides must + # independently compute the same filename for the same route, or the download side can't find it. + cat <<'JS' > "$RUNNER_TEMP/slugify-routes.mjs" + const routes = JSON.parse(process.env.GITTENSORY_ROUTES_JSON); + const slugify = (path) => { + const trimmed = path.replace(/^\/+|\/+$/g, ""); + if (trimmed === "") return "root"; + return trimmed + .toLowerCase() + .replace(/[^a-z0-9/]+/g, "-") + .replace(/\/+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + }; + const pairs = routes.map((r) => [r, slugify(r)].join("\t")).join("\n"); + require("node:fs").writeFileSync(process.env.GITHUB_WORKSPACE + "/routes.tsv", pairs + "\n"); + JS + node "$RUNNER_TEMP/slugify-routes.mjs" + + - name: Capture each route (desktop + mobile) with the runner's own headless Chrome + run: | + set -euo pipefail + mkdir -p shots + while IFS=$'\t' read -r route slug; do + [ -z "$route" ] && continue + google-chrome-stable --headless=new --no-sandbox --disable-gpu --hide-scrollbars \ + --window-size=1440,900 --screenshot="shots/${slug}--desktop.png" \ + "http://127.0.0.1:${GITTENSORY_SERVE_PORT}${route}" + google-chrome-stable --headless=new --no-sandbox --disable-gpu --hide-scrollbars \ + --window-size=390,844 --screenshot="shots/${slug}--mobile.png" \ + "http://127.0.0.1:${GITTENSORY_SERVE_PORT}${route}" + done < routes.tsv + ls -la shots/ + + - name: Stop local server + if: always() + run: kill "$(cat serve.pid)" 2>/dev/null || true + + # gittensory's backend downloads this by name via its OWN installation token (never a token from this + # job) once the `workflow_run` completion webhook arrives -- see fetchFallbackArtifactShots. + - name: Upload captured screenshots + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: gittensory-visual-fallback + path: shots/*.png + if-no-files-found: error + retention-days: 1 diff --git a/.gittensory.yml.example b/.gittensory.yml.example index fb0f77eceb..f0bec75a1a 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -985,6 +985,14 @@ settings: # # repo back in at a layer where a broader default disabled it -- it does NOT bypass the env-var gate # # itself, so the env vars remain the outer infra-availability switch. # enabled: true +# # When true, and ONLY when the discovery above finds no preview at all for a PR, dispatch +# # .github/workflows/visual-capture-fallback.yml -- a fork-safe GitHub Actions job (contents: read, no +# # secrets) that builds, serves, and screenshots the PR's own code, and use its captured PNGs as the +# # "after" shot instead. Requires that workflow file to be present in this repo (copy it from +# # JSONbored/gittensory unmodified -- see the workflow's own header for setup). Bool. Default: false (no +# # dispatch, byte-identical to today). Not needed for gittensory-ui / metagraphed, which already have +# # their own preview-deploy pipeline. (#4112, part of the #3607 visual-capture convergence epic) +# actions_fallback: false # # Maintainer overrides for the public review-panel CONTENT (not what gittensory measures). The # # Gittensor attribution + register link is always appended to the footer regardless; maintainer text # # failing the public-safe filter is dropped, never published. diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index da753318a4..84ec82f502 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -998,6 +998,14 @@ settings: # # repo back in at a layer where a broader default disabled it -- it does NOT bypass the env-var gate # # itself, so the env vars remain the outer infra-availability switch. # enabled: true +# # When true, and ONLY when the discovery above finds no preview at all for a PR, dispatch +# # .github/workflows/visual-capture-fallback.yml -- a fork-safe GitHub Actions job (contents: read, no +# # secrets) that builds, serves, and screenshots the PR's own code, and use its captured PNGs as the +# # "after" shot instead. Requires that workflow file to be present in this repo (copy it from +# # JSONbored/gittensory unmodified -- see the workflow's own header for setup). Bool. Default: false (no +# # dispatch, byte-identical to today). Not needed for gittensory-ui / metagraphed, which already have +# # their own preview-deploy pipeline. (#4112, part of the #3607 visual-capture convergence epic) +# actions_fallback: false # # Maintainer overrides for the public review-panel CONTENT (not what gittensory measures). The # # Gittensor attribution + register link is always appended to the footer regardless; maintainer text # # failing the public-safe filter is dropped, never published. diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index 13988bb4f9..783295258b 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -671,6 +671,15 @@ export type VisualConfig = { * app-specific (there is no universal convention), so it is opaque, bounded, public-safe text, same shape * as `review.ai_model`'s free-text fields. */ themeStorageKey: string | null; + /** `review.visual.actions_fallback` (#4112): when true, and ONLY when the existing GitHub-native discovery + * chain (Deployments API / commit checks / cloudflare-bot PR comment / an explicit `preview.url_template`) + * finds no preview at all for this PR, dispatch `.github/workflows/visual-capture-fallback.yml` -- a + * fork-safe GitHub Actions job that builds, serves, and screenshots the PR's own code with zero secrets -- + * and use its captured PNGs as the "after" shot instead. false (default) ⇒ byte-identical to today (no + * dispatch, no change to the discovery order). Requires the target repo to have that workflow file present + * (see the workflow's own header comment for setup); a repo without it just never gets a fallback run, same + * as leaving this unset. (#3607 visual-capture convergence epic) */ + actionsFallback: boolean; }; /** A `prefers-color-scheme` value the capture pipeline can emulate before rendering (#3678). */ @@ -710,6 +719,7 @@ export const EMPTY_VISUAL_CONFIG: VisualConfig = { gif: false, enabled: null, themeStorageKey: null, + actionsFallback: false, }; /** One `review.path_instructions[]` entry: a manifest path glob + the public-safe instructions to apply when a @@ -2120,6 +2130,7 @@ function overlayVisualConfig(base: VisualConfig, override: VisualConfig): Visual gif: override.gif ? override.gif : base.gif, enabled: pickOverlayNullable(override.enabled, base.enabled), themeStorageKey: pickOverlayNullable(override.themeStorageKey, base.themeStorageKey), + actionsFallback: override.actionsFallback ? override.actionsFallback : base.actionsFallback, }; } @@ -2356,7 +2367,8 @@ function visualConfigPresent(config: VisualConfig): boolean { config.themes.length > 0 || config.gif || config.enabled !== null || - config.themeStorageKey !== null + config.themeStorageKey !== null || + config.actionsFallback ); } @@ -2440,8 +2452,9 @@ function parseVisualConfig(value: JsonValue | undefined, warnings: string[]): Vi const gif = normalizeOptionalBoolean(record.gif, "review.visual.gif", warnings) === true; const enabled = normalizeOptionalBoolean(record.enabled, "review.visual.enabled", warnings); const themeStorageKey = parsePublicSafeText(record.theme_storage_key, "review.visual.theme_storage_key", warnings); + const actionsFallback = normalizeOptionalBoolean(record.actions_fallback, "review.visual.actions_fallback", warnings) === true; - return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled, themeStorageKey }; + return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled, themeStorageKey, actionsFallback }; } function parseAutoReviewTitleKeywords(value: JsonValue | undefined, warnings: string[]): string[] { @@ -2758,6 +2771,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue if (review.visual.gif) visual.gif = true; if (review.visual.enabled !== null) visual.enabled = review.visual.enabled; if (review.visual.themeStorageKey !== null) visual.theme_storage_key = review.visual.themeStorageKey; + if (review.visual.actionsFallback) visual.actions_fallback = true; out.visual = visual; } if (review.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = review.linkedIssueSatisfaction; diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 43ab15e1ae..5abb419a11 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -359,7 +359,14 @@ import { randomUUID } from "node:crypto"; import { isRetryableJobError, RetryableJobError } from "./retryable"; import { screenshotsAllowed } from "../review/visual-wire"; import { isVisualPath } from "../review/visual/paths"; -import { buildCapture, hasSuccessfulBotCapture, type CaptureRoute } from "../review/visual/capture"; +import { buildCapture, hasSuccessfulBotCapture, resolveVisualRoutes, type CaptureRoute } from "../review/visual/capture"; +import { + fallbackShotFileName, + fallbackShotR2Key, + fetchFallbackArtifactShots, + FALLBACK_WORKFLOW_NAME, + parseFallbackRunCorrelation, +} from "../review/visual/actions-fallback"; import { incr } from "../selfhost/metrics"; import { renderReviewingPlaceholder, @@ -494,6 +501,7 @@ import { } from "../review/feature-activation"; import { deploymentStatusToPreview, + parseRepo, type DeploymentStatusPayload, } from "../review/visual/preview-url"; import { resolveHardGuardrailGlobs } from "../review/guardrail-config"; @@ -4528,6 +4536,91 @@ async function maybeCaptureOnDeploymentStatus( return true; } +/** + * Store the captured PNGs from a completed actions_fallback run into R2 under buildCapture's own lookup keys + * (#4112) — resolveVisualRoutes MUST be recomputed the exact same way buildCapture derives it, since the run + * carries only filenames (viewport-tagged, per fallbackShotFileName), not the original route paths. Fail-safe + * throughout: any missing token/config/route match just skips that shot (or all of them), never throws. + */ +async function storeVisualCaptureFallbackShots( + env: Env, + repoFullName: string, + installationId: number, + runId: number, + prNumber: number, + headSha: string, + rateLimitAdmissionKey: GitHubRateLimitAdmissionKey, +): Promise { + if (!env.REVIEW_AUDIT) return; + const token = await createInstallationToken(env, installationId).catch(() => undefined); + if (!token) return; + const shots = await fetchFallbackArtifactShots({ token, repo: parseRepo(repoFullName), runId, rateLimitAdmissionKey }); + if (shots.length === 0) return; + const byFileName = new Map(shots.map((shot) => [shot.fileName, shot.png])); + + // Recompute the SAME route list buildCapture would derive for this PR right now — the artifact carries only + // viewport-tagged filenames (fallbackShotFileName), not the original route paths, so both sides must agree + // independently on which routes those filenames correspond to. + const [visualConfig, storedFiles] = await Promise.all([ + resolveVisualCaptureConfig(env, repoFullName), + listPullRequestFiles(env, repoFullName, prNumber), + ]); + const visualFiles = storedFiles.map((file) => file.path).filter(isVisualPath); + for (const path of resolveVisualRoutes(visualFiles, visualConfig.routes)) { + for (const viewportName of ["desktop", "mobile"] as const) { + const png = byFileName.get(fallbackShotFileName(path, viewportName)); + if (!png) continue; + const key = await fallbackShotR2Key(headSha, path, viewportName); + await env.REVIEW_AUDIT.put(key, png, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined); + } + } +} + +/** + * workflow_run (completed) from THIS module's own .github/workflows/visual-capture-fallback.yml (#4112) → + * store its captured PNGs in R2, then re-review so a fresh buildCapture pass picks them up as the "after" + * shot. The run carries no natural PR link (it's workflow_dispatch, not pull_request) — parseFallbackRunCorrelation + * recovers {prNumber, headSha} from the run's own display_title, which dispatchVisualCaptureFallback set via + * the workflow's `run-name:`. Gated on the run's OWN name + trigger type so an unrelated workflow_run (this + * repo's ui-preview.yml, a target repo's other CI, etc.) is never mistaken for this fallback's completion. + */ +async function maybeCaptureOnActionsFallbackWorkflowRun( + env: Env, + deliveryId: string, + eventName: string, + payload: GitHubWebhookPayload, +): Promise { + if (eventName !== "workflow_run") return false; + const repoFullName = payload.repository?.full_name; + const installationId = getInstallationId(payload); + if (!repoFullName || !installationId) return false; + const run = ( + payload as unknown as { + workflow_run?: { id?: number; name?: string; event?: string; conclusion?: string; display_title?: string }; + } + ).workflow_run; + if (run?.name !== FALLBACK_WORKFLOW_NAME || run?.event !== "workflow_dispatch") return false; + + if (run.conclusion === "success" && run.id && isConvergenceRepoAllowed(env, repoFullName)) { + const correlation = parseFallbackRunCorrelation(run.display_title); + if (correlation) { + const admissionKey = githubRateLimitAdmissionKeyForInstallation(installationId); + await storeVisualCaptureFallbackShots(env, repoFullName, installationId, run.id, correlation.prNumber, correlation.headSha, admissionKey); + await reReviewStoredPullRequest(env, deliveryId, installationId, repoFullName, correlation.prNumber); + } + } + await recordWebhookEvent(env, { + deliveryId, + eventName, + action: payload.action, + installationId, + repositoryFullName: repoFullName, + payloadHash: "processed", + status: "processed", + }); + return true; +} + async function repairDataFidelity( env: Env, requestedBy: "schedule" | "api" | "test", @@ -5700,6 +5793,10 @@ async function processGitHubWebhook( // red). Without this a PR that goes green/red AFTER its open-time review is never re-evaluated. if (await maybeReReviewOnCiCompletion(env, deliveryId, eventName, payload)) return; + // actions_fallback's own workflow_run completion (#4112) — checked BEFORE the legacy status/workflow_run + // handler below, which otherwise unconditionally consumes EVERY workflow_run event first. + if (await maybeCaptureOnActionsFallbackWorkflowRun(env, deliveryId, eventName, payload)) + return; // Legacy status/workflow_run CI signals aren't re-review triggers (see the function's own doc comment), but // must still invalidate the durable CI-state cache so a tracked PR's next reader doesn't see a stale // pre-transition aggregate for the rest of the cache TTL. @@ -10255,6 +10352,9 @@ async function maybePublishPrPublicSurface( ...(pr.headSha ? { headSha: pr.headSha } : {}), ...(pr.headRef ? { headRef: pr.headRef } : {}), previewFromChecks: true, + // Pins the actions_fallback dispatch (#4112) to a trusted ref -- see buildCapture. Absent (no + // stored default branch yet) ⇒ that dispatch just never fires, same as leaving it unconfigured. + ...(repo?.defaultBranch ? { defaultBranchRef: repo.defaultBranch } : {}), }; // review.visual.enabled (#4083): a config-as-code override layered on top of the screenshotsAllowed // env-var gate above, not a replacement for it. Unset/true ⇒ defer to that gate's decision (buildCapture diff --git a/src/review/visual/actions-fallback.ts b/src/review/visual/actions-fallback.ts new file mode 100644 index 0000000000..9681344e56 --- /dev/null +++ b/src/review/visual/actions-fallback.ts @@ -0,0 +1,405 @@ +// GitHub-Actions build-and-serve FALLBACK for a repo with no CI-produced preview deploy (#4112, part of the +// #3607 visual-capture convergence epic). +// +// preview-url.ts's discovery chain (Deployments API -> commit-check scan -> bot PR-comment scan) only ever +// finds a preview that SOME OTHER CI already produced. This module is the trusted half of a fork-safe, +// two-sided pipeline whose untrusted half is .github/workflows/visual-capture-fallback.yml: +// 1. gittensory DISPATCHES that workflow (`workflow_dispatch`, always resolved against the repo's default +// branch) with the PR number + head SHA as inputs. A `workflow_dispatch` call always runs the DISPATCHED +// ref's copy of the workflow file, so a contributor can never smuggle a modified workflow definition +// through their own PR branch -- unlike a `pull_request`-triggered workflow, which runs the version +// committed on the PR branch itself. +// 2. The dispatched job (contents: read, NO secrets -- see the workflow file's own header) checks out that +// exact commit, builds the repo, serves the build on localhost INSIDE its own ephemeral runner, captures +// each configured route with the runner's own preinstalled headless Chrome, and uploads the PNGs as a +// GitHub Actions artifact. It never holds a credential of any kind, and it never needs one: the untrusted +// code's network reach never leaves the runner's own localhost, so GitHub's stock per-job isolation is +// already the full sandbox this needs -- no bespoke Firecracker/gVisor sandbox to build or maintain. +// 3. On completion, GitHub delivers a `workflow_run` webhook. The caller (queue processor) uses gittensory's +// OWN, already-trusted installation token -- NEVER a token that passed through step 2's untrusted job -- +// to list and download that run's artifact via `fetchFallbackArtifactShots` below. +// +// The artifact's real download location is a short-lived, per-run SIGNED url GitHub hands back at request +// time (an *.actions.githubusercontent.com / *.blob.core.windows.net host today), not a fixed one -- unlike +// every other fetch in this codebase, which only ever talks to api.github.com or a *.workers.dev/*.pages.dev +// preview host. isGithubArtifactStorageUrl is the SSRF allowlist extension this genuinely new source needs: +// isSafeHttpUrl's general public-https safety, PLUS a closed host-suffix allowlist (mirrors preview-url.ts's +// own PREVIEW_HOST_SUFFIXES pattern), so a malformed or unexpected API response can never make gittensory's +// backend fetch an attacker-influenced or internal address. +// +// A `workflow_dispatch` run carries no natural PR association (unlike a `pull_request`-triggered run), so the +// dispatched workflow's `run-name:` embeds `pr= sha=` -- GitHub renders `run-name` from the +// dispatch inputs and surfaces the result as `workflow_run.display_title` in the completion webhook. +// parseFallbackRunCorrelation reads it back; a run whose title doesn't match this exact shape is ignored +// (fail-safe -- never guesses a PR from an unrelated run). +import { timeoutFetch, type GitHubRateLimitAdmissionKey } from "../../github/client"; +import { sha256Hex } from "../../utils/crypto"; +import { isSafeHttpUrl } from "../content-lane/safe-url"; +import type { GitHubRepo } from "./preview-url"; + +const DEFAULT_TIMEOUT_MS = 20_000; +const API_VERSION = "2022-11-28"; + +/** The workflow file this module dispatches and whose completions it listens for. */ +export const FALLBACK_WORKFLOW_FILE = "visual-capture-fallback.yml"; +/** The workflow's declared `name:` -- cross-checked against `workflow_run.name` before acting on a completion. */ +export const FALLBACK_WORKFLOW_NAME = "Gittensory Visual Capture Fallback"; +/** The artifact name the dispatched workflow uploads its captured PNGs under. */ +export const FALLBACK_ARTIFACT_NAME = "gittensory-visual-fallback"; + +// --------------------------------------------------------------------------------------------------------- +// SSRF allowlist extension: the artifact-download redirect target. +// --------------------------------------------------------------------------------------------------------- + +/** Hosts GitHub's Actions artifact-download redirect resolves to. Closed allowlist, mirrors preview-url.ts's + * own PREVIEW_HOST_SUFFIXES pattern -- a public, non-attacker-controllable set of GitHub/Azure-owned hosts. */ +const GITHUB_ARTIFACT_HOST_SUFFIXES = [".actions.githubusercontent.com", ".blob.core.windows.net"] as const; + +/** True for an https URL on the GitHub Actions artifact-storage allowlist. Layers isSafeHttpUrl's general + * public/non-private-host safety UNDER the closed suffix allowlist -- both must hold. Used to validate the + * redirect `Location` the artifact-zip endpoint returns before this backend ever fetches it. */ +export function isGithubArtifactStorageUrl(raw: string): boolean { + if (!isSafeHttpUrl(raw)) return false; + let url: URL; + try { + url = new URL(raw); + } catch { + // Unreachable via this public entry point: isSafeHttpUrl above already parsed `raw` with `new URL()` and + // only returned true because that parse succeeded -- `new URL()` is deterministic, so the identical call + // here can never throw. Retained (mirrors safe-url.ts's own defense-in-depth style) rather than trusting + // that invariant silently. + /* v8 ignore next -- @preserve unreachable, see comment above */ + return false; + } + const host = url.hostname.toLowerCase(); + return GITHUB_ARTIFACT_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix)); +} + +// --------------------------------------------------------------------------------------------------------- +// Dispatch: gittensory -> GitHub (workflow_dispatch), pinned to the default branch. +// --------------------------------------------------------------------------------------------------------- + +/** Dispatch the fallback capture workflow for one PR. `ref` MUST be the repo's default branch (never the PR's + * own branch/SHA) -- that pinning is what makes a contributor's own workflow-file edits inert. Returns false + * (never throws) on any failure so a capture attempt can't sink a review; the caller degrades to "no preview + * yet" exactly like every other discovery source in this pipeline. */ +export async function dispatchVisualCaptureFallback(params: { + token: string; + repo: GitHubRepo; + ref: string; + prNumber: number; + headSha: string; + routes: readonly string[]; + rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined; +}): Promise { + const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`; + try { + const headers = new Headers(); + headers.set("accept", "application/vnd.github+json"); + headers.set("content-type", "application/json"); + headers.set("user-agent", "gittensory/0.1"); + headers.set("x-github-api-version", API_VERSION); + headers.set("authorization", `Bearer ${params.token}`); + const response = await timeoutFetch(`${base}/actions/workflows/${FALLBACK_WORKFLOW_FILE}/dispatches`, { + method: "POST", + headers, + body: JSON.stringify({ + ref: params.ref, + inputs: { + pr_number: String(params.prNumber), + head_sha: params.headSha, + routes: JSON.stringify([...params.routes]), + }, + }), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + githubRateLimitAdmission: params.rateLimitAdmissionKey !== undefined, + ...(params.rateLimitAdmissionKey ? { githubRateLimitAdmissionKey: params.rateLimitAdmissionKey } : {}), + }); + if (!response.ok) { + console.log(JSON.stringify({ event: "visual_fallback_dispatch_rejected", repo: `${params.repo.owner}/${params.repo.repo}`, pr: params.prNumber, status: response.status })); + } + return response.ok; + } catch (error) { + console.log(JSON.stringify({ event: "visual_fallback_dispatch_error", repo: `${params.repo.owner}/${params.repo.repo}`, pr: params.prNumber, message: String(error).slice(0, 200) })); + return false; + } +} + +// --------------------------------------------------------------------------------------------------------- +// Correlation: recover {prNumber, headSha} from a completed workflow_run's display_title. +// --------------------------------------------------------------------------------------------------------- + +const RUN_NAME_PATTERN = /gittensory-visual-fallback pr=(\d+) sha=([0-9a-f]{40})/i; + +/** Parse the `pr= sha=` correlation this module's own `run-name:` embeds (see the workflow file) + * back out of a completed run's `display_title`. Returns null (fail-safe, never guesses) for anything that + * doesn't match this exact shape -- an unrelated workflow_run, a hand-triggered dispatch, or a malformed + * title all degrade to "ignore this run" rather than acting on an unverified correlation. */ +export function parseFallbackRunCorrelation(displayTitle: string | undefined | null): { prNumber: number; headSha: string } | null { + if (!displayTitle) return null; + const match = RUN_NAME_PATTERN.exec(displayTitle); + if (!match) return null; + const prNumber = Number(match[1]); + if (!Number.isFinite(prNumber) || prNumber <= 0) return null; + return { prNumber, headSha: (match[2] as string).toLowerCase() }; +} + +/** True when a fallback run for this EXACT (prNumber, headSha) is already queued or in progress -- checked + * by buildCapture before dispatching, so the existing recapture-poll retry (every 90s, up to 5 attempts, + * see PREVIEW_POLL_SECONDS/MAX_PREVIEW_POLLS in processors.ts) doesn't repeatedly re-dispatch while waiting + * for the SAME run's workflow_run completion. That matters because the workflow's own `concurrency: group: + * visual-capture-fallback-${{ inputs.head_sha }}` + `cancel-in-progress: true` means a second dispatch for + * the same head SHA CANCELS the first -- without this check, a poll firing before a slow build finishes + * would cancel-and-restart it every 90s and the fallback could never complete. Queries GitHub's own run + * list rather than persisting new dispatch-tracking state, mirroring this pipeline's existing + * live-query-don't-persist pattern (getLatestDeploymentStatus, findPreviewUrlFromChecks). Fails OPEN (false, + * "nothing in flight") on any error -- a transient list-runs failure should still let the existing + * concurrency group be the backstop dedup, not silently stop the fallback from ever being tried. */ +export async function hasInFlightFallbackDispatch(params: { + token: string; + repo: GitHubRepo; + prNumber: number; + headSha: string; + rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined; +}): Promise { + const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`; + try { + const response = await timeoutFetch(`${base}/actions/workflows/${FALLBACK_WORKFLOW_FILE}/runs?event=workflow_dispatch&per_page=20`, { + headers: githubApiHeaders(params.token), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + githubRateLimitAdmission: params.rateLimitAdmissionKey !== undefined, + ...(params.rateLimitAdmissionKey ? { githubRateLimitAdmissionKey: params.rateLimitAdmissionKey } : {}), + }); + if (!response.ok) return false; + const payload = (await response.json().catch(() => null)) as { workflow_runs?: Array<{ status?: string; display_title?: string }> } | null; + const headSha = params.headSha.toLowerCase(); + return (payload?.workflow_runs ?? []).some((run) => { + if (run.status !== "queued" && run.status !== "in_progress") return false; + const correlation = parseFallbackRunCorrelation(run.display_title); + return correlation !== null && correlation.prNumber === params.prNumber && correlation.headSha === headSha; + }); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------------------------------------- +// Minimal ZIP reader -- just enough to read a GitHub Actions artifact (STORED / DEFLATE entries only). +// --------------------------------------------------------------------------------------------------------- + +export type ZipEntry = { name: string; data: Uint8Array }; + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_DIR_SIGNATURE = 0x02014b50; +const LOCAL_FILE_SIGNATURE = 0x04034b50; +const EOCD_MIN_SIZE = 22; +const MAX_ZIP_COMMENT_BYTES = 65535; +const CENTRAL_DIR_HEADER_SIZE = 46; +const LOCAL_HEADER_SIZE = 30; +// GitHub Actions artifacts hold at most a handful of files here (one per route x viewport); bound the walk +// regardless of what a hostile/corrupt central directory claims, so a crafted entryCount can't spin forever. +const MAX_ZIP_ENTRIES = 64; + +async function inflateRawRaw(compressed: Uint8Array): Promise { + try { + // The cast only narrows the TYPE for the UI workspace's stricter DOM-lib BodyInit/BlobPart, which excludes + // SharedArrayBuffer from ArrayBufferLike -- `compressed` is always a view over a plain (never shared) + // ArrayBuffer here (subarray of bytes ultimately sourced from Response#arrayBuffer()), mirrors shot.ts's + // own `png as Uint8Array` cast for the identical reason. + const stream = new Blob([compressed as Uint8Array]).stream().pipeThrough(new DecompressionStream("deflate-raw")); + const buf = await new Response(stream).arrayBuffer(); + return new Uint8Array(buf); + } catch { + return null; + } +} + +/** Read every file entry out of a well-formed ZIP archive (method 0 = stored, or 8 = raw DEFLATE -- the only + * two GitHub Actions' own artifact uploader produces). Anything else -- a truncated buffer, a bad signature, + * an unsupported compression method, an offset past the buffer end -- degrades that ONE entry (or the whole + * read) to being skipped/empty rather than throwing; this parses a REMOTE, only-indirectly-trusted byte + * stream (the fork-built artifact), so every read here is bounds-checked before use. */ +export async function parseZipEntries(bytes: Uint8Array): Promise { + try { + if (bytes.byteLength < EOCD_MIN_SIZE) return []; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const searchStart = Math.max(0, bytes.byteLength - EOCD_MIN_SIZE - MAX_ZIP_COMMENT_BYTES); + let eocdOffset = -1; + for (let i = bytes.byteLength - EOCD_MIN_SIZE; i >= searchStart; i--) { + if (view.getUint32(i, true) === EOCD_SIGNATURE) { + eocdOffset = i; + break; + } + } + if (eocdOffset === -1) return []; + + const entryCount = Math.min(view.getUint16(eocdOffset + 10, true), MAX_ZIP_ENTRIES); + let centralDirOffset = view.getUint32(eocdOffset + 16, true); + const entries: ZipEntry[] = []; + const decoder = new TextDecoder(); + + for (let i = 0; i < entryCount; i++) { + if (centralDirOffset < 0 || centralDirOffset + CENTRAL_DIR_HEADER_SIZE > bytes.byteLength) break; + if (view.getUint32(centralDirOffset, true) !== CENTRAL_DIR_SIGNATURE) break; + const method = view.getUint16(centralDirOffset + 10, true); + const compressedSize = view.getUint32(centralDirOffset + 20, true); + const nameLen = view.getUint16(centralDirOffset + 28, true); + const extraLen = view.getUint16(centralDirOffset + 30, true); + const commentLen = view.getUint16(centralDirOffset + 32, true); + const localHeaderOffset = view.getUint32(centralDirOffset + 42, true); + const nameStart = centralDirOffset + CENTRAL_DIR_HEADER_SIZE; + const nameEnd = nameStart + nameLen; + const nextCentralDirOffset = nameEnd + extraLen + commentLen; + if (nameEnd > bytes.byteLength) break; + const name = decoder.decode(bytes.subarray(nameStart, nameEnd)); + + if ( + localHeaderOffset >= 0 && + localHeaderOffset + LOCAL_HEADER_SIZE <= bytes.byteLength && + view.getUint32(localHeaderOffset, true) === LOCAL_FILE_SIGNATURE + ) { + const localNameLen = view.getUint16(localHeaderOffset + 26, true); + const localExtraLen = view.getUint16(localHeaderOffset + 28, true); + const dataOffset = localHeaderOffset + LOCAL_HEADER_SIZE + localNameLen + localExtraLen; + const dataEnd = dataOffset + compressedSize; + if (dataOffset >= 0 && dataEnd <= bytes.byteLength) { + const compressed = bytes.subarray(dataOffset, dataEnd); + const data = method === 0 ? new Uint8Array(compressed) : method === 8 ? await inflateRawRaw(compressed) : null; + if (data) entries.push({ name, data }); + } + } + centralDirOffset = nextCentralDirOffset; + } + return entries; + } catch { + // Every offset this loop reads is bounds-checked against bytes.byteLength before use, so this is a + // defense-in-depth backstop against a read this function doesn't already know how to reject cleanly -- + // not a path a crafted or truncated buffer is expected to reach through the checks above. + /* v8 ignore next -- @preserve defense-in-depth backstop, see comment above */ + return []; + } +} + +// --------------------------------------------------------------------------------------------------------- +// Fetch: list the completed run's artifacts, resolve + validate its download location, extract PNGs. +// --------------------------------------------------------------------------------------------------------- + +export type FallbackShot = { fileName: string; png: Uint8Array }; + +// Bounds a hostile/oversized artifact -- MAX_CONFIGURED_ROUTES (5, capture.ts) x 2 viewports x 2 themes, +// rounded up, and a generous per-artifact byte cap (well above what ~20 full-page PNGs need in practice). +const MAX_FALLBACK_SHOTS = 24; +const MAX_ARTIFACT_BYTES = 60 * 1024 * 1024; + +function githubApiHeaders(token: string): Headers { + const headers = new Headers(); + headers.set("accept", "application/vnd.github+json"); + headers.set("user-agent", "gittensory/0.1"); + headers.set("x-github-api-version", API_VERSION); + headers.set("authorization", `Bearer ${token}`); + return headers; +} + +/** List + download the named artifact from a completed workflow run, returning its extracted `.png` entries. + * Every step degrades to `[]` on failure (missing/expired artifact, oversized artifact, a download-redirect + * target outside isGithubArtifactStorageUrl, a network error, a malformed zip) -- callers treat an empty + * result exactly like "no fallback capture yet", never a crash. */ +export async function fetchFallbackArtifactShots(params: { + token: string; + repo: GitHubRepo; + runId: number; + rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined; +}): Promise { + const base = `https://api.github.com/repos/${params.repo.owner}/${params.repo.repo}`; + const repoLabel = `${params.repo.owner}/${params.repo.repo}`; + try { + const listResponse = await timeoutFetch(`${base}/actions/runs/${params.runId}/artifacts?per_page=100`, { + headers: githubApiHeaders(params.token), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + githubRateLimitAdmission: params.rateLimitAdmissionKey !== undefined, + ...(params.rateLimitAdmissionKey ? { githubRateLimitAdmissionKey: params.rateLimitAdmissionKey } : {}), + }); + if (!listResponse.ok) return []; + const listPayload = (await listResponse.json().catch(() => null)) as { + artifacts?: Array<{ id: number; name: string; expired?: boolean; size_in_bytes?: number }>; + } | null; + const artifact = listPayload?.artifacts?.find((a) => a.name === FALLBACK_ARTIFACT_NAME && a.expired !== true); + if (!artifact) return []; + if (typeof artifact.size_in_bytes === "number" && artifact.size_in_bytes > MAX_ARTIFACT_BYTES) { + console.log(JSON.stringify({ event: "visual_fallback_artifact_too_large", repo: repoLabel, runId: params.runId, bytes: artifact.size_in_bytes })); + return []; + } + + // Probe the download endpoint WITHOUT following its redirect -- the target is a short-lived, per-run + // signed url on a different host, and its safety must be validated before this backend ever fetches it. + const zipResponse = await fetch(`${base}/actions/artifacts/${artifact.id}/zip`, { + headers: githubApiHeaders(params.token), + redirect: "manual", + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + const location = zipResponse.headers.get("location"); + if (!location || !isGithubArtifactStorageUrl(location)) { + console.log(JSON.stringify({ event: "visual_fallback_artifact_url_rejected", repo: repoLabel, runId: params.runId })); + return []; + } + // Fetch the validated, presigned blob URL directly -- never forward the GitHub token to this third-party host. + const blobResponse = await fetch(location, { signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS) }); + if (!blobResponse.ok) return []; + const buffer = await blobResponse.arrayBuffer(); + if (buffer.byteLength > MAX_ARTIFACT_BYTES) return []; + const entries = await parseZipEntries(new Uint8Array(buffer)); + + const shots: FallbackShot[] = []; + for (const entry of entries) { + if (shots.length >= MAX_FALLBACK_SHOTS) break; + if (!entry.name.toLowerCase().endsWith(".png")) continue; + shots.push({ fileName: entry.name, png: entry.data }); + } + return shots; + } catch (error) { + console.log(JSON.stringify({ event: "visual_fallback_artifact_fetch_error", repo: repoLabel, runId: params.runId, message: String(error).slice(0, 200) })); + return []; + } +} + +// --------------------------------------------------------------------------------------------------------- +// Route <-> artifact filename naming (must match the bash slugify in visual-capture-fallback.yml exactly). +// --------------------------------------------------------------------------------------------------------- + +/** Slugify a route path into the filename-safe token the workflow uses for its screenshot names + * (`--desktop.png` / `--mobile.png`). "/" -> "root"; "/app/analytics" -> "app-analytics". Pure + * and deterministic so the workflow (bash) and this reader (TypeScript) independently compute the same + * name for the same route -- see the workflow file's own "Slugify routes" step, which implements the + * identical algorithm in bash. */ +export function slugifyRoutePath(path: string): string { + const trimmed = path.replace(/^\/+|\/+$/g, ""); + if (trimmed === "") return "root"; + return trimmed + .toLowerCase() + .replace(/[^a-z0-9/]+/g, "-") + .replace(/\/+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** Build the expected artifact filename for one route + viewport -- the inverse of what the workflow writes, + * used by the caller to look up a specific route/viewport's shot in fetchFallbackArtifactShots' output. */ +export function fallbackShotFileName(path: string, viewport: "desktop" | "mobile"): string { + return `${slugifyRoutePath(path)}--${viewport}.png`; +} + +// --------------------------------------------------------------------------------------------------------- +// R2 storage key -- shared between the workflow_run webhook handler (writer) and buildCapture (reader), so +// both independently derive the SAME key for the same (headSha, path, viewport) without a preview URL to +// fingerprint against (capturePage's own key scheme needs a real "page" url; a fallback shot has none). +// --------------------------------------------------------------------------------------------------------- + +const FALLBACK_SHOT_NAMESPACE = "gittensory/shots/actions-fallback/"; + +/** The R2 key a fallback-captured shot is stored/read under for one PR head + route + viewport. Pure content + * address (no preview URL involved) -- deterministic so the write side (webhook handler) and the read side + * (buildCapture) always agree without any shared in-memory state. */ +export async function fallbackShotR2Key(headSha: string, path: string, viewport: "desktop" | "mobile"): Promise { + const fingerprint = await sha256Hex(`${headSha}:actions-fallback:${viewport}:${path}`); + return `${FALLBACK_SHOT_NAMESPACE}${fingerprint.slice(0, 40)}.png`; +} diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts index 9b0e8c6016..18940f3b98 100644 --- a/src/review/visual/capture.ts +++ b/src/review/visual/capture.ts @@ -12,6 +12,7 @@ // default TanStack route convention; those hooks can return if a per-repo visual config is added. import { sha256Hex } from "../../utils/crypto"; import type { GitHubRateLimitAdmissionKey } from "../../github/client"; +import { dispatchVisualCaptureFallback, fallbackShotR2Key, hasInFlightFallbackDispatch } from "./actions-fallback"; import { findPreviewUrlFromChecks, findPreviewUrlFromPrComments, @@ -99,6 +100,10 @@ export interface CaptureTarget { previewFailed?: boolean | undefined; /** Whether to scan commit checks / the cloudflare-bot PR comment for the preview URL (Workers Builds). */ previewFromChecks?: boolean | undefined; + /** The repo's default branch -- REQUIRED to dispatch the actions_fallback workflow (#4112) against a + * trusted ref rather than the PR's own branch. Absent ⇒ the fallback is never dispatched (fail-safe: no + * ref to pin to means no dispatch, not a guess at "main"). */ + defaultBranchRef?: string | undefined; } function joinUrl(base: string, path: string): string { @@ -238,6 +243,28 @@ async function capturePage( return { url: onDemand }; } +/** Resolve the "after" shot when there is no real preview page to render (#4112): if `review.visual. + * actions_fallback` is enabled AND the workflow_run webhook handler has already stored a fallback-captured + * PNG in R2 for this exact head + route + viewport (fallbackShotR2Key), return its shot URL; otherwise fall + * back to the ordinary loading/failed placeholder. This never fetches or dispatches anything itself — a + * cache miss here just means the fallback hasn't landed yet (or was never enabled), degrading exactly like + * "no preview yet" does everywhere else in this pipeline. */ +async function resolveFallbackAfterShot( + env: Env, + target: CaptureTarget, + path: string, + viewportName: "desktop" | "mobile", + actionsFallbackEnabled: boolean, + placeholder: string | undefined, +): Promise<{ url?: string | undefined; png?: Uint8Array | undefined }> { + if (!actionsFallbackEnabled || !env.REVIEW_AUDIT || !target.headSha) return { url: placeholder }; + const key = await fallbackShotR2Key(target.headSha, path, viewportName); + const cached = await env.REVIEW_AUDIT.get(key).catch(() => null); + if (!cached) return { url: placeholder }; + const shotBase = env.PUBLIC_API_ORIGIN; + return { url: shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : placeholder }; +} + /** Upload a computed diff-overlay PNG to the same store `capturePage` uses, returning its shot URL — or * undefined when there's no diff image (unchanged/new/removed/no-diff-provider), storage is unavailable, or * the upload fails. Mirrors capturePage's own key/URL scheme so the diff shares its caching story. */ @@ -314,6 +341,9 @@ export type VisualCaptureConfig = { * `CaptureShotOptions.theme` doc for the verified finding this fixes. null/undefined (default) ⇒ no * localStorage write, byte-identical to today. Only takes effect when `themes` is also configured. */ themeStorageKey?: string | null | undefined; + /** `review.visual.actions_fallback` (#4112): dispatch the GitHub-Actions build-and-serve fallback when NO + * preview at all was found for this PR. false/absent (default) ⇒ byte-identical to today. */ + actionsFallback?: boolean | null | undefined; }; /** @@ -365,6 +395,36 @@ export async function buildCapture(env: Env, token: string, target: CaptureTarge } } + // Fallback (#4112): the discovery chain above found NOTHING at all for this repo (no preview URL, not + // failed, and no real build already in flight) -- if review.visual.actions_fallback is enabled, dispatch + // .github/workflows/visual-capture-fallback.yml against the repo's own default branch and mark + // previewPending so the EXISTING recapture-poll mechanism (processors.ts) retries this same buildCapture + // call later, by which point the workflow_run webhook handler (running independently) has stored the + // fallback's captured PNGs in R2 for resolveFallbackAfterShot below to find. Requires headSha + a resolved + // default branch to pin the dispatch to a trusted ref; either missing ⇒ no dispatch (fail-safe). + const actionsFallbackEnabled = visualConfig?.actionsFallback === true; + const routes = resolveVisualRoutes(visualFiles, visualConfig?.routes); + if (!previewBase && !previewFailed && !previewPending && actionsFallbackEnabled && target.headSha && target.defaultBranchRef) { + // Never re-dispatch onto an already in-flight run (#4112 review fix): the workflow's own `concurrency: + // cancel-in-progress: true` group would CANCEL that run the instant a second dispatch for the same head + // SHA lands, so a recapture-poll retry (every 90s -- see PREVIEW_POLL_SECONDS in processors.ts) firing + // before a slower build finishes could cancel-and-restart it forever and never complete. See + // hasInFlightFallbackDispatch's own doc comment for the full rationale. + const alreadyInFlight = await hasInFlightFallbackDispatch({ token, repo, prNumber: target.prNumber, headSha: target.headSha, rateLimitAdmissionKey }); + const dispatched = + alreadyInFlight || + (await dispatchVisualCaptureFallback({ + token, + repo, + ref: target.defaultBranchRef, + prNumber: target.prNumber, + headSha: target.headSha, + routes, + rateLimitAdmissionKey, + })); + if (dispatched) previewPending = true; + } + // With no real "after" shot, the cell shows a placeholder (same aspect ratio as a real shot): a spinner // while the preview is still building, or a static "deploy failed" card once it won't come. const shotBase = env.PUBLIC_API_ORIGIN; @@ -381,7 +441,6 @@ export async function buildCapture(env: Env, token: string, target: CaptureTarge // heaviest capture mode (up to 6 extra renders per side), and doubling it for mobile is a narrower-scope // call deferred to a follow-up rather than shipped speculatively (matches #3674's hosted-diff deferral). const gifWanted = visualConfig?.gif === true && isScrollGifAvailable(); - const routes = resolveVisualRoutes(visualFiles, visualConfig?.routes); // #3678: an explicit, non-empty theme list captures the SAME routes once per theme, each tagged on its // CaptureRoute entry. [undefined] (the default, absent config) renders the single un-emulated default — // capturePage/captureShot already treat an undefined theme as "no emulation call at all", so this one @@ -400,8 +459,12 @@ export async function buildCapture(env: Env, token: string, target: CaptureTarge const [beforeShot, beforeMobileShot, afterShot, afterMobileShot] = await Promise.all([ capturePage(env, target, beforePage, "before", "desktop", DESKTOP_VIEWPORT, diffAvailable, theme, themeStorageKey), capturePage(env, target, beforePage, "before", "mobile", MOBILE_VIEWPORT, diffAvailable, theme, themeStorageKey), - afterPage ? capturePage(env, target, afterPage, "after", "desktop", DESKTOP_VIEWPORT, diffAvailable, theme, themeStorageKey) : Promise.resolve<{ url?: string | undefined; png?: Uint8Array | undefined }>({ url: afterPlaceholder }), - afterPage ? capturePage(env, target, afterPage, "after", "mobile", MOBILE_VIEWPORT, diffAvailable, theme, themeStorageKey) : Promise.resolve<{ url?: string | undefined; png?: Uint8Array | undefined }>({ url: afterPlaceholder }), + afterPage + ? capturePage(env, target, afterPage, "after", "desktop", DESKTOP_VIEWPORT, diffAvailable, theme, themeStorageKey) + : resolveFallbackAfterShot(env, target, path, "desktop", actionsFallbackEnabled, afterPlaceholder), + afterPage + ? capturePage(env, target, afterPage, "after", "mobile", MOBILE_VIEWPORT, diffAvailable, theme, themeStorageKey) + : resolveFallbackAfterShot(env, target, path, "mobile", actionsFallbackEnabled, afterPlaceholder), ]); // A diff needs BOTH sides' real bytes — a placeholder/dash slot (no preview yet, auth-walled, render // failure) has no `png`, so compareCapturedScreenshots degrades to null exactly like a missing shot does. diff --git a/test/unit/actions-fallback-webhook.test.ts b/test/unit/actions-fallback-webhook.test.ts new file mode 100644 index 0000000000..21a73daf90 --- /dev/null +++ b/test/unit/actions-fallback-webhook.test.ts @@ -0,0 +1,555 @@ +import { deflateRawSync } from "node:zlib"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getPullRequest, + upsertInstallation, + upsertPullRequestFile, + upsertPullRequestFromGitHub, + upsertRepositoryFromGitHub, + upsertRepositorySettings, +} from "../../src/db/repositories"; +import { clearInstallationTokenCacheForTest } from "../../src/github/app"; +import { clearGitHubResponseCacheForTest } from "../../src/github/client"; +import { fallbackShotR2Key, FALLBACK_ARTIFACT_NAME } from "../../src/review/visual/actions-fallback"; +import { processJob } from "../../src/queue/processors"; +import { createTestEnv } from "../helpers/d1"; + +// Mirrors test/unit/queue.test.ts's own generatePrivateKeyPem helper -- createInstallationToken mints a real +// JWT, so the default createTestEnv placeholder key ("test-private-key") won't do for any test that reaches it. +async function generatePrivateKeyPem(): Promise { + const key = (await crypto.subtle.generateKey( + { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const exported = await crypto.subtle.exportKey("pkcs8", key.privateKey); + const base64 = Buffer.from(exported as ArrayBuffer).toString("base64").replace(/(.{64})/g, "$1\n"); + return `-----BEGIN PRIVATE KEY-----\n${base64}\n-----END PRIVATE KEY-----`; +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +/** Minimal single-entry-per-file ZIP builder (mirrors test/unit/actions-fallback.test.ts's own fixture, kept + * file-local rather than shared since it's a small, self-contained test fixture, not production code). */ +function buildZip(files: Array<{ name: string; data: Uint8Array }>): Uint8Array { + const localParts: Uint8Array[] = []; + const centralParts: Uint8Array[] = []; + let offset = 0; + const encoder = new TextEncoder(); + for (const file of files) { + const nameBytes = encoder.encode(file.name); + const compressed = new Uint8Array(deflateRawSync(Buffer.from(file.data))); + const localHeader = new DataView(new ArrayBuffer(30)); + localHeader.setUint32(0, 0x04034b50, true); + localHeader.setUint16(8, 8, true); + localHeader.setUint32(18, compressed.length, true); + localHeader.setUint32(22, file.data.length, true); + localHeader.setUint16(26, nameBytes.length, true); + const localEntry = concatBytes([new Uint8Array(localHeader.buffer), nameBytes, compressed]); + localParts.push(localEntry); + + const centralHeader = new DataView(new ArrayBuffer(46)); + centralHeader.setUint32(0, 0x02014b50, true); + centralHeader.setUint16(10, 8, true); + centralHeader.setUint32(20, compressed.length, true); + centralHeader.setUint32(24, file.data.length, true); + centralHeader.setUint16(28, nameBytes.length, true); + centralHeader.setUint32(42, offset, true); + centralParts.push(concatBytes([new Uint8Array(centralHeader.buffer), nameBytes])); + + offset += localEntry.length; + } + const centralDirOffset = offset; + const centralDirBytes = concatBytes(centralParts); + const eocd = new DataView(new ArrayBuffer(22)); + eocd.setUint32(0, 0x06054b50, true); + eocd.setUint16(8, files.length, true); + eocd.setUint16(10, files.length, true); + eocd.setUint32(12, centralDirBytes.length, true); + eocd.setUint32(16, centralDirOffset, true); + return concatBytes([...localParts, centralDirBytes, new Uint8Array(eocd.buffer)]); +} + +function memoryReviewAudit(): R2Bucket { + const store = new Map(); + return { + async get(key: string) { + const bytes = store.get(key); + return bytes ? ({ body: new Response(bytes).body } as unknown as R2ObjectBody) : null; + }, + async put(key: string, value: unknown) { + const bytes = new Uint8Array(await new Response(value as BodyInit).arrayBuffer()); + store.set(key, bytes); + return { key } as unknown as R2Object; + }, + } as unknown as R2Bucket; +} + +async function seedRepoAndPr(env: ReturnType, headSha: string): Promise { + await upsertInstallation(env, { + action: "created", + installation: { id: 9101, account: { login: "owner", id: 1, type: "Organization" }, target_type: "Organization", repository_selection: "selected", permissions: {}, events: [] }, + }); + await upsertRepositoryFromGitHub(env, { name: "fallback-repo", full_name: "owner/fallback-repo", private: false, owner: { login: "owner" }, default_branch: "main" }, 9101); + await upsertRepositorySettings(env, { + repoFullName: "owner/fallback-repo", + autonomy: { merge: "observe", update_branch: "observe" }, + aiReviewMode: "off", + gatePack: "oss-anti-slop", + gateCheckMode: "off", + checkRunMode: "off", + commentMode: "off", + publicSurface: "off", + }); + await upsertPullRequestFromGitHub(env, "owner/fallback-repo", { + number: 55, + title: "Add a pricing page", + state: "open", + user: { login: "contributor" }, + head: { sha: headSha }, + base: { ref: "main" }, + labels: [], + body: "Closes #1", + }); +} + +function baseFetchStub(overrides: Record Response | Promise> = {}) { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + for (const [pattern, handler] of Object.entries(overrides)) { + if (url.includes(pattern)) return handler(); + } + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (/\/pulls\/55(?:\?|$)/.test(url) && method === "GET") { + return Response.json({ number: 55, title: "Add a pricing page", state: "open", user: { login: "contributor" }, head: { sha: "cafebabecafebabecafebabecafebabecafebabe" }, mergeable_state: "clean", labels: [], body: "Closes #1" }); + } + if (url.includes("/pulls/55/files")) return Response.json([]); + return Response.json({}); + }; +} + +afterEach(() => { + clearGitHubResponseCacheForTest(); + clearInstallationTokenCacheForTest(); + vi.unstubAllGlobals(); +}); + +describe("workflow_run webhook -> actions_fallback storage (#4112)", () => { + it("stores the fallback's captured PNGs in R2 and re-reviews the correlated PR", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + + const zip = buildZip([ + { name: "root--desktop.png", data: new TextEncoder().encode("desktop-bytes") }, + { name: "root--mobile.png", data: new TextEncoder().encode("mobile-bytes") }, + ]); + + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/501/artifacts": () => Response.json({ artifacts: [{ id: 9, name: FALLBACK_ARTIFACT_NAME, expired: false }] }), + "/actions/artifacts/9/zip": () => new Response(null, { status: 302, headers: { location: "https://pipelines.actions.githubusercontent.com/blob.zip" } }), + "pipelines.actions.githubusercontent.com": () => new Response(zip.buffer as ArrayBuffer, { status: 200 }), + }), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-501", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { + id: 501, + name: "Gittensory Visual Capture Fallback", + event: "workflow_dispatch", + conclusion: "success", + display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe", + }, + }, + } as never); + + const desktopKey = await fallbackShotR2Key("cafebabecafebabecafebabecafebabecafebabe", "/", "desktop"); + const mobileKey = await fallbackShotR2Key("cafebabecafebabecafebabecafebabecafebabe", "/", "mobile"); + const desktopObj = await env.REVIEW_AUDIT!.get(desktopKey); + const mobileObj = await env.REVIEW_AUDIT!.get(mobileKey); + expect(desktopObj).not.toBeNull(); + expect(mobileObj).not.toBeNull(); + expect(new TextDecoder().decode(await new Response(desktopObj!.body).arrayBuffer())).toBe("desktop-bytes"); + + // The PR row still exists + is untouched in state -- the re-review ran without throwing. + expect(await getPullRequest(env, "owner/fallback-repo", 55)).toMatchObject({ state: "open" }); + }); + + it("stores partial shots when only some route/viewport combinations are present in the artifact", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + + // Only the desktop shot is present in the artifact -- the mobile one for the same route must be silently + // skipped (no crash), while desktop still lands in R2. + const zip = buildZip([{ name: "root--desktop.png", data: new TextEncoder().encode("desktop-only") }]); + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/510/artifacts": () => Response.json({ artifacts: [{ id: 10, name: FALLBACK_ARTIFACT_NAME }] }), + "/actions/artifacts/10/zip": () => new Response(null, { status: 302, headers: { location: "https://pipelines.actions.githubusercontent.com/blob.zip" } }), + "pipelines.actions.githubusercontent.com": () => new Response(zip.buffer as ArrayBuffer, { status: 200 }), + }), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-510", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 510, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never); + + const desktopKey = await fallbackShotR2Key("cafebabecafebabecafebabecafebabecafebabe", "/", "desktop"); + const mobileKey = await fallbackShotR2Key("cafebabecafebabecafebabecafebabecafebabe", "/", "mobile"); + expect(await env.REVIEW_AUDIT!.get(desktopKey)).not.toBeNull(); + expect(await env.REVIEW_AUDIT!.get(mobileKey)).toBeNull(); + }); + + it("derives the route from the PR's own stored changed files, not just the default '/'", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + await upsertPullRequestFile(env, { + repoFullName: "owner/fallback-repo", + pullNumber: 55, + path: "apps/gittensory-ui/src/routes/app.index.tsx", + status: "modified", + additions: 1, + deletions: 0, + changes: 1, + payload: { patch: "@@\n+export default function App() { return null; }" }, + }); + + const zip = buildZip([{ name: "app--desktop.png", data: new TextEncoder().encode("app-desktop") }]); + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/520/artifacts": () => Response.json({ artifacts: [{ id: 20, name: FALLBACK_ARTIFACT_NAME }] }), + "/actions/artifacts/20/zip": () => new Response(null, { status: 302, headers: { location: "https://pipelines.actions.githubusercontent.com/blob.zip" } }), + "pipelines.actions.githubusercontent.com": () => new Response(zip.buffer as ArrayBuffer, { status: 200 }), + }), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-520", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 520, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never); + + const appDesktopKey = await fallbackShotR2Key("cafebabecafebabecafebabecafebabecafebabe", "/app", "desktop"); + const rootDesktopKey = await fallbackShotR2Key("cafebabecafebabecafebabecafebabecafebabe", "/", "desktop"); + expect(await env.REVIEW_AUDIT!.get(appDesktopKey)).not.toBeNull(); + expect(await env.REVIEW_AUDIT!.get(rootDesktopKey)).toBeNull(); + }); + + it("stores nothing (never throws) when the run's artifact list comes back empty", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/511/artifacts": () => Response.json({ artifacts: [] }), + }), + ); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-511", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 511, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never), + ).resolves.toBeUndefined(); + + const desktopKey = await fallbackShotR2Key("cafebabecafebabecafebabecafebabecafebabe", "/", "desktop"); + expect(await env.REVIEW_AUDIT!.get(desktopKey)).toBeNull(); + }); + + it("stores nothing (never throws) when REVIEW_AUDIT isn't configured", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo" }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + let artifactsListCalled = false; + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/": () => { + artifactsListCalled = true; + return Response.json({ artifacts: [] }); + }, + }), + ); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-512", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 512, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never), + ).resolves.toBeUndefined(); + // Never even attempts to list artifacts without a place to store them. + expect(artifactsListCalled).toBe(false); + }); + + it("stores nothing (never throws) when minting the installation token fails", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/access_tokens": () => new Response("forbidden", { status: 403 }), + }), + ); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-513", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 513, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never), + ).resolves.toBeUndefined(); + + const desktopKey = await fallbackShotR2Key("cafebabecafebabecafebabecafebabecafebabe", "/", "desktop"); + expect(await env.REVIEW_AUDIT!.get(desktopKey)).toBeNull(); + }); + + it("still returns the stored shot even when persisting it to R2 fails (fire-and-forget put, mirrors capture.ts's own pattern)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo" }); + const failingAudit: R2Bucket = { + async get() { + return null; + }, + async put() { + throw new Error("simulated R2 write failure"); + }, + } as unknown as R2Bucket; + env.REVIEW_AUDIT = failingAudit; + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + const zip = buildZip([{ name: "root--desktop.png", data: new TextEncoder().encode("x") }]); + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/514/artifacts": () => Response.json({ artifacts: [{ id: 14, name: FALLBACK_ARTIFACT_NAME }] }), + "/actions/artifacts/14/zip": () => new Response(null, { status: 302, headers: { location: "https://pipelines.actions.githubusercontent.com/blob.zip" } }), + "pipelines.actions.githubusercontent.com": () => new Response(zip.buffer as ArrayBuffer, { status: 200 }), + }), + ); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-514", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 514, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never), + ).resolves.toBeUndefined(); + }); + + it("ignores a workflow_run whose name doesn't match this module's own workflow", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + let artifactsListCalled = false; + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/": () => { + artifactsListCalled = true; + return Response.json({ artifacts: [] }); + }, + }), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "unrelated-run", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 502, name: "CI", event: "workflow_dispatch", conclusion: "success", display_title: "CI" }, + }, + } as never); + + expect(artifactsListCalled).toBe(false); + }); + + it("ignores a matching-name run that was NOT triggered by workflow_dispatch", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + let artifactsListCalled = false; + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/": () => { + artifactsListCalled = true; + return Response.json({ artifacts: [] }); + }, + }), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "wrong-trigger", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 503, name: "Gittensory Visual Capture Fallback", event: "pull_request", conclusion: "success", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never); + + expect(artifactsListCalled).toBe(false); + }); + + it("records the webhook and does nothing further when the matching run FAILED", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + let artifactsListCalled = false; + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/": () => { + artifactsListCalled = true; + return Response.json({ artifacts: [] }); + }, + }), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "failed-run", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 504, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "failure", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never); + + expect(artifactsListCalled).toBe(false); + }); + + it("does nothing when the run's display_title doesn't correlate to a PR (never guesses)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + let artifactsListCalled = false; + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/": () => { + artifactsListCalled = true; + return Response.json({ artifacts: [] }); + }, + }), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "no-correlation", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 505, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success", display_title: "manually triggered" }, + }, + } as never); + + expect(artifactsListCalled).toBe(false); + }); + + it("does nothing when the repo isn't on the convergence allowlist", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), REVIEW_AUDIT: memoryReviewAudit() }); + await seedRepoAndPr(env, "cafebabecafebabecafebabecafebabecafebabe"); + let artifactsListCalled = false; + vi.stubGlobal( + "fetch", + baseFetchStub({ + "/actions/runs/": () => { + artifactsListCalled = true; + return Response.json({ artifacts: [] }); + }, + }), + ); + + await processJob(env, { + type: "github-webhook", + deliveryId: "not-allowlisted", + eventName: "workflow_run", + payload: { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9101 }, + workflow_run: { id: 506, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success", display_title: "gittensory-visual-fallback pr=55 sha=cafebabecafebabecafebabecafebabecafebabe" }, + }, + } as never); + + expect(artifactsListCalled).toBe(false); + }); + + it("does not process a workflow_run event with no repository/installation on the payload", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + vi.stubGlobal("fetch", async () => new Response("unexpected", { status: 500 })); + + await expect( + processJob(env, { + type: "github-webhook", + deliveryId: "no-repo", + eventName: "workflow_run", + payload: { action: "completed", workflow_run: { id: 507, name: "Gittensory Visual Capture Fallback", event: "workflow_dispatch", conclusion: "success" } }, + } as never), + ).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/actions-fallback.test.ts b/test/unit/actions-fallback.test.ts new file mode 100644 index 0000000000..ce70be9e6c --- /dev/null +++ b/test/unit/actions-fallback.test.ts @@ -0,0 +1,557 @@ +import { deflateRawSync } from "node:zlib"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearGitHubResponseCacheForTest } from "../../src/github/client"; +import { + dispatchVisualCaptureFallback, + fallbackShotFileName, + fallbackShotR2Key, + fetchFallbackArtifactShots, + FALLBACK_ARTIFACT_NAME, + hasInFlightFallbackDispatch, + isGithubArtifactStorageUrl, + parseFallbackRunCorrelation, + parseZipEntries, + slugifyRoutePath, +} from "../../src/review/visual/actions-fallback"; + +afterEach(() => { + clearGitHubResponseCacheForTest(); + vi.unstubAllGlobals(); +}); + +// --------------------------------------------------------------------------------------------------------- +// Minimal ZIP fixture builder -- constructs a real, spec-compliant archive for the reader tests below (a +// central directory + one local header per entry + an EOCD record), so parseZipEntries is exercised against +// actual zip bytes rather than a hand-approximated shape. +// --------------------------------------------------------------------------------------------------------- + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function buildZip(files: Array<{ name: string; data: Uint8Array; method: 0 | 8 }>): Uint8Array { + const localParts: Uint8Array[] = []; + const centralParts: Uint8Array[] = []; + let offset = 0; + const encoder = new TextEncoder(); + for (const file of files) { + const nameBytes = encoder.encode(file.name); + const compressed = file.method === 8 ? new Uint8Array(deflateRawSync(Buffer.from(file.data))) : file.data; + + const localHeader = new DataView(new ArrayBuffer(30)); + localHeader.setUint32(0, 0x04034b50, true); + localHeader.setUint16(8, file.method, true); + localHeader.setUint32(18, compressed.length, true); + localHeader.setUint32(22, file.data.length, true); + localHeader.setUint16(26, nameBytes.length, true); + const localEntry = concatBytes([new Uint8Array(localHeader.buffer), nameBytes, compressed]); + localParts.push(localEntry); + + const centralHeader = new DataView(new ArrayBuffer(46)); + centralHeader.setUint32(0, 0x02014b50, true); + centralHeader.setUint16(10, file.method, true); + centralHeader.setUint32(20, compressed.length, true); + centralHeader.setUint32(24, file.data.length, true); + centralHeader.setUint16(28, nameBytes.length, true); + centralHeader.setUint32(42, offset, true); + centralParts.push(concatBytes([new Uint8Array(centralHeader.buffer), nameBytes])); + + offset += localEntry.length; + } + const centralDirOffset = offset; + const centralDirBytes = concatBytes(centralParts); + const eocd = new DataView(new ArrayBuffer(22)); + eocd.setUint32(0, 0x06054b50, true); + eocd.setUint16(8, files.length, true); + eocd.setUint16(10, files.length, true); + eocd.setUint32(12, centralDirBytes.length, true); + eocd.setUint32(16, centralDirOffset, true); + return concatBytes([...localParts, centralDirBytes, new Uint8Array(eocd.buffer)]); +} + +// --------------------------------------------------------------------------------------------------------- + +describe("isGithubArtifactStorageUrl (SSRF allowlist extension)", () => { + it("allows an actions.githubusercontent.com download url", () => { + expect(isGithubArtifactStorageUrl("https://pipelines.actions.githubusercontent.com/abc123")).toBe(true); + }); + + it("allows a blob.core.windows.net download url", () => { + expect(isGithubArtifactStorageUrl("https://productionresultssa12.blob.core.windows.net/artifacts/x.zip")).toBe(true); + }); + + it("rejects a non-https url", () => { + expect(isGithubArtifactStorageUrl("http://pipelines.actions.githubusercontent.com/abc123")).toBe(false); + }); + + it("rejects a private/loopback host even with the right-looking path", () => { + expect(isGithubArtifactStorageUrl("https://127.0.0.1/blob.core.windows.net")).toBe(false); + }); + + it("rejects an unrelated public host", () => { + expect(isGithubArtifactStorageUrl("https://evil.example.com/actions.githubusercontent.com")).toBe(false); + }); + + it("rejects an unparseable url", () => { + expect(isGithubArtifactStorageUrl("not-a-url")).toBe(false); + }); +}); + +describe("parseFallbackRunCorrelation", () => { + it("parses a matching run-name display_title", () => { + expect(parseFallbackRunCorrelation("gittensory-visual-fallback pr=42 sha=0123456789abcdef0123456789abcdef01234567")).toEqual({ + prNumber: 42, + headSha: "0123456789abcdef0123456789abcdef01234567", + }); + }); + + it("lowercases an upper-case sha", () => { + expect(parseFallbackRunCorrelation("gittensory-visual-fallback pr=1 sha=ABCDEF0123456789ABCDEF0123456789ABCDEF01")?.headSha).toBe( + "abcdef0123456789abcdef0123456789abcdef01", + ); + }); + + it("returns null for an unrelated title", () => { + expect(parseFallbackRunCorrelation("Some other workflow run")).toBeNull(); + }); + + it("returns null for a null/undefined title", () => { + expect(parseFallbackRunCorrelation(undefined)).toBeNull(); + expect(parseFallbackRunCorrelation(null)).toBeNull(); + }); + + it("returns null for an empty-string title", () => { + expect(parseFallbackRunCorrelation("")).toBeNull(); + }); + + it("returns null when the pr number is not a positive integer", () => { + expect(parseFallbackRunCorrelation("gittensory-visual-fallback pr=0 sha=0123456789abcdef0123456789abcdef01234567")).toBeNull(); + }); +}); + +describe("slugifyRoutePath / fallbackShotFileName", () => { + it("slugifies the root route to 'root'", () => { + expect(slugifyRoutePath("/")).toBe("root"); + }); + + it("slugifies a nested route", () => { + expect(slugifyRoutePath("/app/analytics")).toBe("app-analytics"); + }); + + it("collapses non-alphanumeric runs and strips leading/trailing dashes", () => { + expect(slugifyRoutePath("/Pricing & Plans/")).toBe("pricing-plans"); + }); + + it("builds the expected artifact filename per viewport", () => { + expect(fallbackShotFileName("/", "desktop")).toBe("root--desktop.png"); + expect(fallbackShotFileName("/app/analytics", "mobile")).toBe("app-analytics--mobile.png"); + }); +}); + +describe("fallbackShotR2Key", () => { + it("is deterministic for the same (headSha, path, viewport)", async () => { + const a = await fallbackShotR2Key("deadbeef", "/pricing", "desktop"); + const b = await fallbackShotR2Key("deadbeef", "/pricing", "desktop"); + expect(a).toBe(b); + expect(a.startsWith("gittensory/shots/actions-fallback/")).toBe(true); + expect(a.endsWith(".png")).toBe(true); + }); + + it("differs across headSha, path, and viewport", async () => { + const base = await fallbackShotR2Key("deadbeef", "/pricing", "desktop"); + expect(await fallbackShotR2Key("cafebabe", "/pricing", "desktop")).not.toBe(base); + expect(await fallbackShotR2Key("deadbeef", "/docs", "desktop")).not.toBe(base); + expect(await fallbackShotR2Key("deadbeef", "/pricing", "mobile")).not.toBe(base); + }); +}); + +describe("parseZipEntries", () => { + it("extracts a STORED (uncompressed) entry", async () => { + const data = new TextEncoder().encode("stored-bytes"); + const zip = buildZip([{ name: "root--desktop.png", data, method: 0 }]); + const entries = await parseZipEntries(zip); + expect(entries).toHaveLength(1); + expect(entries[0]?.name).toBe("root--desktop.png"); + expect(new TextDecoder().decode(entries[0]?.data)).toBe("stored-bytes"); + }); + + it("extracts a DEFLATE-compressed entry", async () => { + const data = new TextEncoder().encode("deflate-me-please".repeat(20)); + const zip = buildZip([{ name: "root--mobile.png", data, method: 8 }]); + const entries = await parseZipEntries(zip); + expect(entries).toHaveLength(1); + expect(new TextDecoder().decode(entries[0]?.data)).toBe(new TextDecoder().decode(data)); + }); + + it("extracts multiple entries in order", async () => { + const zip = buildZip([ + { name: "a.png", data: new TextEncoder().encode("A"), method: 0 }, + { name: "b.png", data: new TextEncoder().encode("BB"), method: 8 }, + ]); + const entries = await parseZipEntries(zip); + expect(entries.map((e) => e.name)).toEqual(["a.png", "b.png"]); + }); + + it("returns [] for a buffer too small to contain an EOCD record", async () => { + expect(await parseZipEntries(new Uint8Array(4))).toEqual([]); + }); + + it("returns [] when no EOCD signature is present", async () => { + expect(await parseZipEntries(new Uint8Array(64))).toEqual([]); + }); + + // The next several tests patch specific fields of a real, valid single-entry zip to exercise each of + // parseZipEntries' bounds/signature guards individually -- every one degrades to "stop reading" rather + // than throwing, since this parses a REMOTE, only-indirectly-trusted byte stream. + function singleEntryZipOffsets(zip: Uint8Array, nameLen: number, dataLen: number) { + const localEntryLen = 30 + nameLen + dataLen; + const centralDirStart = localEntryLen; + const centralDirLen = 46 + nameLen; + const eocdStart = centralDirStart + centralDirLen; + return { view: new DataView(zip.buffer, zip.byteOffset, zip.byteLength), centralDirStart, eocdStart }; + } + + it("stops reading once a corrupted entry count walks the central directory past the buffer end", async () => { + const zip = buildZip([{ name: "a.png", data: new TextEncoder().encode("A"), method: 0 }]); + const { view, eocdStart } = singleEntryZipOffsets(zip, "a.png".length, 1); + view.setUint16(eocdStart + 10, 2, true); // claim 2 entries when only 1 exists + const entries = await parseZipEntries(zip); + expect(entries).toEqual([{ name: "a.png", data: new TextEncoder().encode("A") }]); + }); + + it("stops reading when the central directory offset does not point at a central-directory signature", async () => { + const zip = buildZip([{ name: "a.png", data: new TextEncoder().encode("A"), method: 0 }]); + const { view, eocdStart } = singleEntryZipOffsets(zip, "a.png".length, 1); + view.setUint32(eocdStart + 16, 0, true); // point at the local-file-header region instead + expect(await parseZipEntries(zip)).toEqual([]); + }); + + it("stops reading when a central-directory entry's name would run past the buffer end", async () => { + const zip = buildZip([{ name: "a.png", data: new TextEncoder().encode("A"), method: 0 }]); + const { view, centralDirStart } = singleEntryZipOffsets(zip, "a.png".length, 1); + view.setUint16(centralDirStart + 28, 60000, true); // nameLen far beyond the buffer + expect(await parseZipEntries(zip)).toEqual([]); + }); + + it("skips an entry whose local-header offset does not point at a local-file signature", async () => { + const zip = buildZip([{ name: "a.png", data: new TextEncoder().encode("A"), method: 0 }]); + const { view, centralDirStart } = singleEntryZipOffsets(zip, "a.png".length, 1); + view.setUint32(centralDirStart + 42, centralDirStart, true); // points at the central-dir signature instead + expect(await parseZipEntries(zip)).toEqual([]); + }); + + it("skips an entry whose declared compressed size would run past the buffer end", async () => { + const zip = buildZip([{ name: "a.png", data: new TextEncoder().encode("A"), method: 0 }]); + const { view, centralDirStart } = singleEntryZipOffsets(zip, "a.png".length, 1); + view.setUint32(centralDirStart + 20, 999_999, true); // compressedSize far beyond the buffer + expect(await parseZipEntries(zip)).toEqual([]); + }); + + it("skips (does not throw on) an entry whose declared DEFLATE bytes are not valid deflate data", async () => { + // Build a normal method=8 entry, then corrupt its compressed payload so DecompressionStream rejects it -- + // the entry must be silently dropped, not surface as a thrown error. + const zip = buildZip([{ name: "corrupt.png", data: new TextEncoder().encode("hello-world-hello-world"), method: 8 }]); + const localHeaderView = new DataView(zip.buffer, zip.byteOffset, zip.byteLength); + const nameLen = localHeaderView.getUint16(26, true); + const compressedSize = localHeaderView.getUint32(18, true); + const dataOffset = 30 + nameLen; + // Flip only the compressed payload bytes (never the trailing central directory / EOCD) -- garbage input + // DecompressionStream("deflate-raw") cannot parse. + for (let i = dataOffset; i < dataOffset + compressedSize; i++) zip[i] = 0xff; + const entries = await parseZipEntries(zip); + expect(entries).toEqual([]); + }); + + it("returns [] for an unsupported compression method", async () => { + // method 99 doesn't exist in the zip spec; parseZipEntries must skip it rather than throw. + const zip = buildZip([{ name: "x.png", data: new TextEncoder().encode("x"), method: 0 }]); + // Corrupt the central directory's compression-method field (offset 10 within the central header, which + // starts right after the local entry). + const localEntryLen = 30 + "x.png".length + 1; + const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength); + view.setUint16(localEntryLen + 10, 99, true); + const entries = await parseZipEntries(zip); + expect(entries).toEqual([]); + }); +}); + +describe("dispatchVisualCaptureFallback", () => { + it("returns true on a successful (204) dispatch", async () => { + let capturedUrl = ""; + let capturedBody: unknown; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(input); + capturedBody = init?.body ? JSON.parse(String(init.body)) : undefined; + return new Response(null, { status: 204 }); + }); + const ok = await dispatchVisualCaptureFallback({ + token: "tok", + repo: { owner: "acme", repo: "widgets" }, + ref: "main", + prNumber: 7, + headSha: "deadbeef", + routes: ["/", "/pricing"], + }); + expect(ok).toBe(true); + expect(capturedUrl).toBe("https://api.github.com/repos/acme/widgets/actions/workflows/visual-capture-fallback.yml/dispatches"); + expect(capturedBody).toEqual({ + ref: "main", + inputs: { pr_number: "7", head_sha: "deadbeef", routes: JSON.stringify(["/", "/pricing"]) }, + }); + }); + + it("dispatches successfully when a rateLimitAdmissionKey is supplied", async () => { + vi.stubGlobal("fetch", async () => new Response(null, { status: 204 })); + const ok = await dispatchVisualCaptureFallback({ + token: "tok", + repo: { owner: "acme", repo: "widgets" }, + ref: "main", + prNumber: 7, + headSha: "deadbeef", + routes: ["/"], + rateLimitAdmissionKey: "installation:1", + }); + expect(ok).toBe(true); + }); + + it("returns false on a non-2xx response", async () => { + vi.stubGlobal("fetch", async () => new Response("nope", { status: 422 })); + const ok = await dispatchVisualCaptureFallback({ + token: "tok", + repo: { owner: "acme", repo: "widgets" }, + ref: "main", + prNumber: 7, + headSha: "deadbeef", + routes: ["/"], + }); + expect(ok).toBe(false); + }); + + it("returns false (never throws) on a network failure", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const ok = await dispatchVisualCaptureFallback({ + token: "tok", + repo: { owner: "acme", repo: "widgets" }, + ref: "main", + prNumber: 7, + headSha: "deadbeef", + routes: ["/"], + }); + expect(ok).toBe(false); + }); +}); + +describe("hasInFlightFallbackDispatch (#4112 review fix -- avoid cancel-in-progress re-dispatch)", () => { + const HEAD_SHA = "cafebabecafebabecafebabecafebabecafebabe"; + + function runsResponse(runs: Array<{ status?: string; display_title?: string }>): Response { + return Response.json({ workflow_runs: runs }); + } + + it("true when a QUEUED run matches this exact pr+headSha", async () => { + vi.stubGlobal("fetch", async () => runsResponse([{ status: "queued", display_title: `gittensory-visual-fallback pr=7 sha=${HEAD_SHA}` }])); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA }); + expect(inFlight).toBe(true); + }); + + it("true when an IN_PROGRESS run matches (case-insensitive headSha)", async () => { + vi.stubGlobal("fetch", async () => runsResponse([{ status: "in_progress", display_title: `gittensory-visual-fallback pr=7 sha=${HEAD_SHA}` }])); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA.toUpperCase() }); + expect(inFlight).toBe(true); + }); + + it("false for an empty run list", async () => { + vi.stubGlobal("fetch", async () => runsResponse([])); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA }); + expect(inFlight).toBe(false); + }); + + it("false when the matching run has already COMPLETED (not queued/in_progress)", async () => { + vi.stubGlobal("fetch", async () => runsResponse([{ status: "completed", display_title: `gittensory-visual-fallback pr=7 sha=${HEAD_SHA}` }])); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA }); + expect(inFlight).toBe(false); + }); + + it("false when an in-progress run exists for a DIFFERENT PR", async () => { + vi.stubGlobal("fetch", async () => runsResponse([{ status: "in_progress", display_title: `gittensory-visual-fallback pr=99 sha=${HEAD_SHA}` }])); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA }); + expect(inFlight).toBe(false); + }); + + it("false when an in-progress run exists for the same PR but a DIFFERENT headSha (new push)", async () => { + vi.stubGlobal("fetch", async () => runsResponse([{ status: "in_progress", display_title: `gittensory-visual-fallback pr=7 sha=${"f".repeat(40)}` }])); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA }); + expect(inFlight).toBe(false); + }); + + it("false when the run's display_title doesn't match the expected correlation shape at all", async () => { + vi.stubGlobal("fetch", async () => runsResponse([{ status: "in_progress", display_title: "Manually triggered run" }])); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA }); + expect(inFlight).toBe(false); + }); + + it("false on a non-ok response", async () => { + vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 })); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA }); + expect(inFlight).toBe(false); + }); + + it("false (never throws) on a network failure", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const inFlight = await hasInFlightFallbackDispatch({ token: "tok", repo: { owner: "acme", repo: "widgets" }, prNumber: 7, headSha: HEAD_SHA }); + expect(inFlight).toBe(false); + }); + + it("true when a rateLimitAdmissionKey is supplied and a match is found", async () => { + vi.stubGlobal("fetch", async () => runsResponse([{ status: "queued", display_title: `gittensory-visual-fallback pr=7 sha=${HEAD_SHA}` }])); + const inFlight = await hasInFlightFallbackDispatch({ + token: "tok", + repo: { owner: "acme", repo: "widgets" }, + prNumber: 7, + headSha: HEAD_SHA, + rateLimitAdmissionKey: "installation:1", + }); + expect(inFlight).toBe(true); + }); +}); + +describe("fetchFallbackArtifactShots", () => { + function stubSequence(handlers: Array<(input: RequestInfo | URL, init?: RequestInit) => Response | Promise>): void { + let call = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const handler = handlers[Math.min(call, handlers.length - 1)]; + call += 1; + return handler ? handler(input, init) : new Response("unexpected", { status: 500 }); + }); + } + + it("lists, downloads, validates, and extracts PNG shots end to end", async () => { + const zip = buildZip([ + { name: "root--desktop.png", data: new TextEncoder().encode("desktop-bytes"), method: 0 }, + { name: "root--mobile.png", data: new TextEncoder().encode("mobile-bytes"), method: 8 }, + { name: "manifest.json", data: new TextEncoder().encode("{}"), method: 0 }, + ]); + stubSequence([ + () => Response.json({ artifacts: [{ id: 99, name: FALLBACK_ARTIFACT_NAME, expired: false, size_in_bytes: 1000 }] }), + () => new Response(null, { status: 302, headers: { location: "https://productionresultssa1.blob.core.windows.net/x.zip" } }), + () => new Response(zip.buffer as ArrayBuffer, { status: 200 }), + ]); + + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 555 }); + expect(shots.map((s) => s.fileName).sort()).toEqual(["root--desktop.png", "root--mobile.png"]); + }); + + it("passes a rateLimitAdmissionKey through to the list-artifacts read without changing the result", async () => { + stubSequence([() => Response.json({ artifacts: [{ id: 1, name: "some-other-artifact" }] })]); + const shots = await fetchFallbackArtifactShots({ + token: "tok", + repo: { owner: "acme", repo: "widgets" }, + runId: 1, + rateLimitAdmissionKey: "installation:1", + }); + expect(shots).toEqual([]); + }); + + it("returns [] when the run has no matching artifact", async () => { + stubSequence([() => Response.json({ artifacts: [{ id: 1, name: "some-other-artifact" }] })]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("returns [] when the artifact is expired", async () => { + stubSequence([() => Response.json({ artifacts: [{ id: 1, name: FALLBACK_ARTIFACT_NAME, expired: true }] })]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("returns [] when the reported artifact size exceeds the cap", async () => { + stubSequence([() => Response.json({ artifacts: [{ id: 1, name: FALLBACK_ARTIFACT_NAME, size_in_bytes: 999_999_999 }] })]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("returns [] when the list-artifacts response body is not valid JSON", async () => { + stubSequence([() => new Response("not-json{{{", { status: 200 })]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("returns [] when the list-artifacts call itself fails", async () => { + stubSequence([() => new Response("nope", { status: 500 })]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("rejects a download redirect that does not point at an allowlisted artifact-storage host", async () => { + stubSequence([ + () => Response.json({ artifacts: [{ id: 1, name: FALLBACK_ARTIFACT_NAME }] }), + () => new Response(null, { status: 302, headers: { location: "https://evil.example.com/steal.zip" } }), + ]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("returns [] when the redirect carries no location header", async () => { + stubSequence([ + () => Response.json({ artifacts: [{ id: 1, name: FALLBACK_ARTIFACT_NAME }] }), + () => new Response(null, { status: 302 }), + ]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("returns [] when the validated blob fetch itself fails", async () => { + stubSequence([ + () => Response.json({ artifacts: [{ id: 1, name: FALLBACK_ARTIFACT_NAME }] }), + () => new Response(null, { status: 302, headers: { location: "https://pipelines.actions.githubusercontent.com/x.zip" } }), + () => new Response("nope", { status: 500 }), + ]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("returns [] when the downloaded blob exceeds the byte cap", async () => { + stubSequence([ + () => Response.json({ artifacts: [{ id: 1, name: FALLBACK_ARTIFACT_NAME }] }), + () => new Response(null, { status: 302, headers: { location: "https://pipelines.actions.githubusercontent.com/x.zip" } }), + () => new Response(new Uint8Array(61 * 1024 * 1024), { status: 200 }), + ]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("returns [] (never throws) on a network failure", async () => { + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots).toEqual([]); + }); + + it("caps the number of returned shots even when the artifact holds more PNGs than the limit", async () => { + const files = Array.from({ length: 30 }, (_, i) => ({ + name: `route-${i}--desktop.png`, + data: new TextEncoder().encode(`shot-${i}`), + method: 0 as const, + })); + const zip = buildZip(files); + stubSequence([ + () => Response.json({ artifacts: [{ id: 1, name: FALLBACK_ARTIFACT_NAME }] }), + () => new Response(null, { status: 302, headers: { location: "https://pipelines.actions.githubusercontent.com/x.zip" } }), + () => new Response(zip.buffer as ArrayBuffer, { status: 200 }), + ]); + const shots = await fetchFallbackArtifactShots({ token: "tok", repo: { owner: "acme", repo: "widgets" }, runId: 1 }); + expect(shots.length).toBeLessThanOrEqual(24); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 7c50ca6056..3a0509993c 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3727,6 +3727,7 @@ describe("review.visual (#3609 preview.url_template / #3610 routes)", () => { gif: false, enabled: null, themeStorageKey: null, + actionsFallback: false, }); expect(m.review.present).toBe(true); expect(parseFocusManifest({ review: reviewConfigToJson(m.review) }).review.visual).toEqual(m.review.visual); @@ -3822,7 +3823,7 @@ describe("review.visual (#3609 preview.url_template / #3610 routes)", () => { it("resolveReviewVisualConfig: null manifest yields empty defaults; a set manifest passes through", () => { expect(resolveReviewVisualConfig(null)).toEqual({ ...EMPTY_VISUAL_CONFIG }); const manifest = parseFocusManifest({ review: { visual: { routes: { paths: ["/app"] } } } }); - expect(resolveReviewVisualConfig(manifest)).toEqual({ preview: { urlTemplate: null }, routes: { paths: ["/app"], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null }); + expect(resolveReviewVisualConfig(manifest)).toEqual({ preview: { urlTemplate: null }, routes: { paths: ["/app"], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null, actionsFallback: false }); }); }); @@ -3907,7 +3908,7 @@ describe("review.visual.gif (#3612 scroll-through GIF capture)", () => { it("composes with themes — both configured independently and both round-trip", () => { const m = parseFocusManifest({ review: { visual: { gif: true, themes: ["dark"] } } }); - expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: true, enabled: null, themeStorageKey: null }); + expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: true, enabled: null, themeStorageKey: null, actionsFallback: false }); expect(reviewConfigToJson(m.review)).toEqual({ visual: { themes: ["dark"], gif: true } }); }); @@ -4010,7 +4011,7 @@ describe("review.visual.theme_storage_key (#4109 localStorage theme-forcing fall it("composes with themes — both configured independently and both round-trip", () => { const m = parseFocusManifest({ review: { visual: { themes: ["dark"], theme_storage_key: "theme" } } }); - expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: false, enabled: null, themeStorageKey: "theme" }); + expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: false, enabled: null, themeStorageKey: "theme", actionsFallback: false }); expect(reviewConfigToJson(m.review)).toEqual({ visual: { themes: ["dark"], theme_storage_key: "theme" } }); }); @@ -4033,6 +4034,61 @@ describe("review.visual.theme_storage_key (#4109 localStorage theme-forcing fall }); }); +describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve fallback)", () => { + it("parses actions_fallback: true, marks present, and round-trips", () => { + const m = parseFocusManifest({ review: { visual: { actions_fallback: true } } }); + expect(m.review.visual.actionsFallback).toBe(true); + expect(m.review.present).toBe(true); + expect(reviewConfigToJson(m.review)).toEqual({ visual: { actions_fallback: true } }); + }); + + it("absent actions_fallback defaults to false and does not mark review present on its own", () => { + expect(parseFocusManifest({}).review.visual.actionsFallback).toBe(false); + expect(parseFocusManifest({ review: { visual: {} } }).review.present).toBe(false); + }); + + it("actions_fallback: false does not mark review present, so the whole review block round-trips to null", () => { + const m = parseFocusManifest({ review: { visual: { actions_fallback: false } } }); + expect(m.review.visual.actionsFallback).toBe(false); + expect(reviewConfigToJson(m.review)).toBeNull(); + }); + + it("warns and defaults to false when actions_fallback is not a boolean", () => { + const bad = parseFocusManifest({ review: { visual: { actions_fallback: "yes" } } }); + expect(bad.review.visual.actionsFallback).toBe(false); + expect(bad.warnings.some((w) => /review\.visual\.actions_fallback.*must be a boolean/.test(w))).toBe(true); + }); + + it("marks present via actions_fallback alone (preview + routes + themes + gif + enabled all empty)", () => { + const m = parseFocusManifest({ review: { visual: { actions_fallback: true } } }); + expect(m.review.present).toBe(true); + }); + + it("composes with gif — both configured independently and both round-trip", () => { + const m = parseFocusManifest({ review: { visual: { actions_fallback: true, gif: true } } }); + expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: true, enabled: null, themeStorageKey: null, actionsFallback: true }); + expect(reviewConfigToJson(m.review)).toEqual({ visual: { gif: true, actions_fallback: true } }); + }); + + it("resolveReviewVisualConfig passes a configured actions_fallback: true through", () => { + const manifest = parseFocusManifest({ review: { visual: { actions_fallback: true } } }); + expect(resolveReviewVisualConfig(manifest).actionsFallback).toBe(true); + }); + + it("overlay: a per-repo actions_fallback: true wins over a global-default false", () => { + const globalDefault = parseReviewConfigMapping({ visual: { actions_fallback: false } }, []); + const perRepo = parseReviewConfigMapping({ visual: { actions_fallback: true } }, []); + expect(overlayReviewConfig(globalDefault, perRepo).visual.actionsFallback).toBe(true); + }); + + it("overlay: an unset per-repo actions_fallback falls back to the global-default true", () => { + const globalDefault = parseReviewConfigMapping({ visual: { actions_fallback: true } }, []); + const perRepo = parseReviewConfigMapping({ visual: { routes: { paths: ["/app"] } } }, []); + expect(overlayReviewConfig(globalDefault, perRepo).visual.actionsFallback).toBe(true); + expect(overlayReviewConfig(globalDefault, perRepo).visual.routes.paths).toEqual(["/app"]); + }); +}); + describe("review.pre_merge_checks (#review-pre-merge-checks)", () => { it("parses checks (name + assertions + when_paths + enforce), marks present, and round-trips", () => { const m = parseFocusManifest({ diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 3865077d0d..787fff5c79 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1138,7 +1138,7 @@ describe("signal coverage edge cases", () => { collisions: buildCollisionReport(directRepo.fullName, [], [currentPr]), preflight: buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix isolated issue", body: "Fixes #99", linkedIssues: [99] }, directRepo, [], [currentPr]), settings: gateSettings, - review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false }, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], skipLabels: [], skipDocsOnly: null, maxAddedLines: 0, maxFiles: 0, baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false }, enrichmentAnalyzers: {}, profile: null, tone: null, securityFocus: null, inlineComments: null, fixHandoff: null, autoMergeSummary: null, suggestions: null, changedFilesSummary: null, effortScore: null, impactMap: null, cultureProfile: null, selftune: null, reviewMemory: null, findingCategories: null, inlineCommentsPerCategory: null, minFindingSeverity: null, maxFindings: { blockers: null, nits: null }, commentVerbosity: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], skipLabels: [], skipDocsOnly: null, maxAddedLines: 0, maxFiles: 0, baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null, actionsFallback: false }, linkedIssueSatisfaction: null, sharedConfigSource: null }, aiReview: { notes: "The change is focused.\n\n**Nits (2)**\n- Add a test for the edge case.\n- Keep the validator helper scoped." }, }); expect(customizedComment).toContain("Reviewed by the Acme maintainer bot."); // custom footer lead diff --git a/test/unit/visual-capture.test.ts b/test/unit/visual-capture.test.ts index be1c4475bb..14f7ba42f7 100644 --- a/test/unit/visual-capture.test.ts +++ b/test/unit/visual-capture.test.ts @@ -4,6 +4,7 @@ import { githubRateLimitAdmissionKeyForInstallation, latestGitHubRestRateLimitObservation, } from "../../src/github/client"; +import { fallbackShotR2Key } from "../../src/review/visual/actions-fallback"; import { buildCapture, hasSuccessfulBotCapture, mapFilesToRoutes, resolvePreviewUrlTemplate, resolveVisualRoutes } from "../../src/review/visual/capture"; import type { CaptureRoute } from "../../src/review/visual/capture"; import * as pixelDiffModule from "../../src/review/visual/pixel-diff"; @@ -1245,3 +1246,314 @@ describe("hasSuccessfulBotCapture (#4110)", () => { expect(hasSuccessfulBotCapture(routes)).toBe(false); }); }); + +describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve fallback)", () => { + function stubNoPreviewFound(extra?: (url: string, init?: RequestInit) => Response | null): (input: RequestInfo | URL, init?: RequestInit) => Promise { + return async (input, init) => { + const url = input.toString(); + const custom = extra?.(url, init); + if (custom) return custom; + if (url.includes("/deployments?")) return Response.json([]); + if (url.includes("/status")) return Response.json({ statuses: [] }); + if (url.includes("/check-runs")) return Response.json({ check_runs: [] }); + if (url.includes("/comments")) return Response.json([]); + return new Response("not found", { status: 404 }); + }; + } + + it("dispatches the fallback workflow when no preview is found anywhere, pinned to the default branch, and marks the capture pending", async () => { + const dispatchBodies: string[] = []; + vi.stubGlobal( + "fetch", + stubNoPreviewFound((url, init) => { + if (!url.includes("/actions/workflows/visual-capture-fallback.yml/dispatches")) return null; + dispatchBodies.push(String(init?.body)); + return new Response(null, { status: 204 }); + }), + ); + + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 20, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(dispatchBodies).toHaveLength(1); + expect(JSON.parse(dispatchBodies[0] as string)).toEqual({ + ref: "main", + inputs: { pr_number: "20", head_sha: "cafebabe", routes: JSON.stringify(["/app"]) }, + }); + expect(result.previewPending).toBe(true); + }); + + it("skips dispatching a NEW run when one is already queued/in-progress for this exact pr+headSha, but still marks the capture pending (#4112 review fix)", async () => { + let dispatchCalled = false; + vi.stubGlobal( + "fetch", + stubNoPreviewFound((url) => { + if (url.includes("/actions/workflows/visual-capture-fallback.yml/runs")) { + return Response.json({ workflow_runs: [{ status: "in_progress", display_title: "gittensory-visual-fallback pr=20 sha=cafebabecafebabecafebabecafebabecafebabe" }] }); + } + if (url.includes("/dispatches")) { + dispatchCalled = true; + return new Response(null, { status: 204 }); + } + return null; + }), + ); + + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 20, headSha: "cafebabecafebabecafebabecafebabecafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(dispatchCalled).toBe(false); + expect(result.previewPending).toBe(true); + }); + + it("dispatches a NEW run when the only in-flight run found is for a DIFFERENT headSha (a later push)", async () => { + let dispatchCalled = false; + vi.stubGlobal( + "fetch", + stubNoPreviewFound((url) => { + if (url.includes("/actions/workflows/visual-capture-fallback.yml/runs")) { + return Response.json({ workflow_runs: [{ status: "in_progress", display_title: "gittensory-visual-fallback pr=20 sha=ffffffffffffffffffffffffffffffffffffffff" }] }); + } + if (url.includes("/dispatches")) { + dispatchCalled = true; + return new Response(null, { status: 204 }); + } + return null; + }), + ); + + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 20, headSha: "cafebabecafebabecafebabecafebabecafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(dispatchCalled).toBe(true); + expect(result.previewPending).toBe(true); + }); + + it("leaves the capture non-pending when the dispatch call itself fails", async () => { + vi.stubGlobal( + "fetch", + stubNoPreviewFound((url) => (url.includes("/dispatches") ? new Response("nope", { status: 422 }) : null)), + ); + + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 21, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(result.previewPending).toBe(false); + }); + + it("never dispatches when actions_fallback is not configured (byte-identical to pre-#4112)", async () => { + let dispatchCalled = false; + vi.stubGlobal( + "fetch", + stubNoPreviewFound((url) => { + if (url.includes("/dispatches")) dispatchCalled = true; + return null; + }), + ); + + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 22, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + + expect(dispatchCalled).toBe(false); + expect(result.previewPending).toBe(false); + }); + + it("never dispatches without a headSha to pin the build to", async () => { + let dispatchCalled = false; + vi.stubGlobal( + "fetch", + stubNoPreviewFound((url) => { + if (url.includes("/dispatches")) dispatchCalled = true; + return null; + }), + ); + + await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 23, previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(dispatchCalled).toBe(false); + }); + + it("never dispatches without a resolved default branch to pin the dispatch to", async () => { + let dispatchCalled = false; + vi.stubGlobal( + "fetch", + stubNoPreviewFound((url) => { + if (url.includes("/dispatches")) dispatchCalled = true; + return null; + }), + ); + + await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 24, headSha: "cafebabe", previewFromChecks: true }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(dispatchCalled).toBe(false); + }); + + it("never dispatches when the preview deploy already FAILED (a real terminal state, not a gap to fill)", async () => { + let dispatchCalled = false; + vi.stubGlobal( + "fetch", + stubNoPreviewFound((url) => { + if (url.includes("/dispatches")) dispatchCalled = true; + return null; + }), + ); + + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 25, headSha: "cafebabe", previewFailed: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(dispatchCalled).toBe(false); + expect(result.routes[0]?.afterUrl).toContain("placeholder=failed"); + }); + + it("never dispatches when a real preview build is already pending (buildState 'building')", async () => { + let dispatchCalled = false; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/dispatches")) dispatchCalled = true; + if (url.includes("/deployments?")) return Response.json([]); + if (url.includes("/status")) return Response.json({ statuses: [] }); + if (url.includes("/check-runs")) { + return Response.json({ check_runs: [{ name: "Cloudflare Workers Builds", status: "in_progress" }] }); + } + if (url.includes("/comments")) return Response.json([]); + return new Response("not found", { status: 404 }); + }); + + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 26, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(dispatchCalled).toBe(false); + expect(result.previewPending).toBe(true); + }); + + it("uses an already-stored fallback shot from R2 as the after URL, without any preview discovery URL", async () => { + vi.stubGlobal("fetch", stubNoPreviewFound()); + const env = createTestEnv({ + PUBLIC_API_ORIGIN: "https://worker.example", + PUBLIC_SITE_ORIGIN: "https://prod.example.com", + REVIEW_AUDIT: memoryReviewAudit(), + }); + const key = await fallbackShotR2Key("cafebabe", "/app", "desktop"); + await env.REVIEW_AUDIT!.put(key, new Uint8Array([1, 2, 3])); + + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 27, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(result.routes[0]?.afterUrl).toBe(`https://worker.example/gittensory/shot?key=${encodeURIComponent(key)}`); + }); + + it("falls back to the loading placeholder when actions_fallback is enabled but no shot has landed in R2 yet", async () => { + vi.stubGlobal("fetch", stubNoPreviewFound()); + const env = createTestEnv({ + PUBLIC_API_ORIGIN: "https://worker.example", + PUBLIC_SITE_ORIGIN: "https://prod.example.com", + REVIEW_AUDIT: memoryReviewAudit(), + }); + + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 28, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(result.routes[0]?.afterUrl).toContain("placeholder=loading"); + }); + + it("falls back to the loading placeholder (never throws) when the R2 read for a fallback shot itself throws", async () => { + vi.stubGlobal("fetch", stubNoPreviewFound()); + const env = createTestEnv({ + PUBLIC_API_ORIGIN: "https://worker.example", + PUBLIC_SITE_ORIGIN: "https://prod.example.com", + REVIEW_AUDIT: memoryReviewAudit({ failGet: true }), + }); + + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 30, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(result.routes[0]?.afterUrl).toContain("placeholder=loading"); + }); + + it("falls back to the loading placeholder (never throws) when actions_fallback is enabled but REVIEW_AUDIT isn't configured", async () => { + vi.stubGlobal("fetch", stubNoPreviewFound()); + + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://prod.example.com" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 29, headSha: "cafebabe", previewFromChecks: true, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + + expect(result.routes[0]?.afterUrl).toContain("placeholder=loading"); + }); +}); diff --git a/test/unit/visual-config-wiring.test.ts b/test/unit/visual-config-wiring.test.ts index 0497cb40af..d9991259f9 100644 --- a/test/unit/visual-config-wiring.test.ts +++ b/test/unit/visual-config-wiring.test.ts @@ -22,6 +22,7 @@ describe("review.visual wiring (#3609 / #3610)", () => { gif: false, enabled: null, themeStorageKey: null, + actionsFallback: false, }); expect(loadSpy).toHaveBeenCalledWith(expect.anything(), "acme/widgets"); loadSpy.mockRestore(); @@ -45,4 +46,11 @@ describe("review.visual wiring (#3609 / #3610)", () => { await expect(resolveVisualCaptureConfig({} as Env, "acme/widgets")).resolves.toEqual({ ...EMPTY_VISUAL_CONFIG, enabled: false }); loadSpy.mockRestore(); }); + + it("resolves a configured actions_fallback: true from the repo's focus manifest (#4112)", async () => { + const manifest = parseFocusManifest({ review: { visual: { actions_fallback: true } } }); + const loadSpy = vi.spyOn(focusManifestLoader, "loadRepoFocusManifest").mockResolvedValue(manifest); + await expect(resolveVisualCaptureConfig({} as Env, "acme/widgets")).resolves.toEqual({ ...EMPTY_VISUAL_CONFIG, actionsFallback: true }); + loadSpy.mockRestore(); + }); });