From dddff881e327bb46a862568a41b050c5e7bd1f42 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 8 Jul 2026 05:09:54 -0700 Subject: [PATCH] feat(review): upload visual-capture screenshots to a public R2 bucket (#4184) For a self-host instance that stays deliberately private (Tailscale-only, no public HTTP surface), PUBLIC_API_ORIGIN can never be fetched by GitHub, so every visual-capture screenshot embedded in a PR comment renders as a broken image. Adds an optional, config-driven upload path: when R2_PUBLIC_ACCOUNT_ID/BUCKET/ACCESS_KEY_ID/SECRET_ACCESS_KEY/BASE_URL are all set, every captured PNG/GIF (before/after shots, diff overlays, scroll GIFs, and the actions-fallback workflow's own captures) is also uploaded to a dedicated public R2 bucket via a hand-rolled SigV4 signer (no new dependency), and its direct URL used instead of the private origin's. Absent config is byte-identical to today. --- .env.example | 13 + .../src/lib/selfhost-env-reference.ts | 25 ++ src/env.d.ts | 9 + src/queue/processors.ts | 6 + src/review/visual/capture.ts | 37 ++- src/selfhost/r2-public-upload.ts | 170 ++++++++++++ test/unit/queue.test.ts | 117 ++++++++ test/unit/r2-public-upload.test.ts | 163 +++++++++++ test/unit/visual-capture.test.ts | 252 ++++++++++++++++++ 9 files changed, 788 insertions(+), 4 deletions(-) create mode 100644 src/selfhost/r2-public-upload.ts create mode 100644 test/unit/r2-public-upload.test.ts diff --git a/.env.example b/.env.example index 3ce1260f97..548bf8e584 100644 --- a/.env.example +++ b/.env.example @@ -182,6 +182,19 @@ GITTENSORY_REVIEW_DRAFT=false # # bare Tailscale MagicDNS `*.ts.net` address) once you've confirmed # # it's genuinely public (Funnel enabled, a reverse proxy in front of # # it, etc.). Default false (warning shown). + +# --- Public screenshot bucket (JSONbored/gittensory#4184; optional) --- +# For an instance that stays deliberately private (e.g. Tailscale-only, no public HTTP surface at all): +# uploads visual-capture screenshots to a dedicated PUBLIC Cloudflare R2 bucket and links directly to it, +# instead of requiring PUBLIC_API_ORIGIN itself to be publicly reachable. All five required together; any +# one missing means not configured (captures keep using PUBLIC_API_ORIGIN, exactly as before this feature). +# R2_PUBLIC_ACCOUNT_ID= # your Cloudflare account ID +# R2_PUBLIC_BUCKET= # a bucket dedicated to this -- never reuse one holding other +# # audit/private data, since every object in it is world-readable +# R2_PUBLIC_ACCESS_KEY_ID= # from R2 -> Manage API Tokens -> Create -> "Object Read & Write", +# R2_PUBLIC_SECRET_ACCESS_KEY= # scoped to ONLY the bucket above -- shown once, save it then +# R2_PUBLIC_BASE_URL= # the bucket's public base URL: its r2.dev dev URL (R2 -> bucket +# # -> Settings -> Public access -> Allow Access), or a custom domain # SELFHOST_SETUP_TOKEN=change-this-long-random-value # REQUIRED to unlock the first-run /setup wizard. Without it # # /setup returns 400; with it, enter the token in the browser form # # or send an x-setup-token / Bearer header. Never put this token in diff --git a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts index 482340970b..45667d1c9b 100644 --- a/apps/gittensory-ui/src/lib/selfhost-env-reference.ts +++ b/apps/gittensory-ui/src/lib/selfhost-env-reference.ts @@ -361,6 +361,26 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [ name: "QUEUE_STARTUP_JITTER_MIN_JOBS", firstReference: "src/selfhost/queue-common.ts", }, + { + name: "R2_PUBLIC_ACCESS_KEY_ID", + firstReference: "src/selfhost/r2-public-upload.ts", + }, + { + name: "R2_PUBLIC_ACCOUNT_ID", + firstReference: "src/selfhost/r2-public-upload.ts", + }, + { + name: "R2_PUBLIC_BASE_URL", + firstReference: "src/selfhost/r2-public-upload.ts", + }, + { + name: "R2_PUBLIC_BUCKET", + firstReference: "src/selfhost/r2-public-upload.ts", + }, + { + name: "R2_PUBLIC_SECRET_ACCESS_KEY", + firstReference: "src/selfhost/r2-public-upload.ts", + }, { name: "REDIS_URL", firstReference: "src/selfhost/preflight.ts", @@ -503,6 +523,11 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [ "| `QUEUE_CONCURRENCY` | `src/selfhost/pg-queue.ts` |", "| `QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS` | `src/selfhost/queue-common.ts` |", "| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts` |", + "| `R2_PUBLIC_ACCESS_KEY_ID` | `src/selfhost/r2-public-upload.ts` |", + "| `R2_PUBLIC_ACCOUNT_ID` | `src/selfhost/r2-public-upload.ts` |", + "| `R2_PUBLIC_BASE_URL` | `src/selfhost/r2-public-upload.ts` |", + "| `R2_PUBLIC_BUCKET` | `src/selfhost/r2-public-upload.ts` |", + "| `R2_PUBLIC_SECRET_ACCESS_KEY` | `src/selfhost/r2-public-upload.ts` |", "| `REDIS_URL` | `src/selfhost/preflight.ts` |", "| `REVIEW_AUDIT_DIR` | `src/server.ts` |", "| `SELFHOST_BUNDLE_ALL` | `scripts/build-selfhost.mjs` |", diff --git a/src/env.d.ts b/src/env.d.ts index 2781a810cc..97ef233cd5 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -45,6 +45,15 @@ declare global { * Deliberately NOT declared in this chunk; the review path keeps its current concurrency behavior. */ PUBLIC_API_ORIGIN?: string; PUBLIC_SITE_ORIGIN?: string; + /** JSONbored/gittensory#4184: upload visual-capture screenshots to a dedicated public R2 bucket and link + * directly to it, for a self-host instance whose PUBLIC_API_ORIGIN stays deliberately private (e.g. + * Tailscale-only). All five required together (see resolveR2PublicUploadConfig) -- any one missing means + * not configured, not an error. */ + R2_PUBLIC_ACCOUNT_ID?: string; + R2_PUBLIC_BUCKET?: string; + R2_PUBLIC_ACCESS_KEY_ID?: string; + R2_PUBLIC_SECRET_ACCESS_KEY?: string; + R2_PUBLIC_BASE_URL?: string; AI_SUMMARIES_ENABLED?: string; AI_PUBLIC_COMMENTS_ENABLED?: string; /** Model id for a genuine Cloudflare Workers AI binding only — no live deployment (hosted or self-host) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 1e66396fb1..85df6d926e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -381,6 +381,7 @@ import { FALLBACK_WORKFLOW_NAME, parseFallbackRunCorrelation, } from "../review/visual/actions-fallback"; +import { resolveR2PublicUploadConfig, uploadToPublicR2Bucket } from "../selfhost/r2-public-upload"; import { buildVisualRegressionFindings, buildVisualVisionUserPrompt, @@ -4534,6 +4535,11 @@ async function storeVisualCaptureFallbackShots( if (!png) continue; const key = await fallbackShotR2Key(headSha, path, viewportName); await env.REVIEW_AUDIT.put(key, png, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined); + // Mirror to the public bucket too (#4184) so capture.ts's resolveFallbackAfterShot -- which trusts any + // key found in REVIEW_AUDIT to already be mirrored -- doesn't link to an object that was never actually + // uploaded there. + const r2Public = resolveR2PublicUploadConfig(env); + if (r2Public) await uploadToPublicR2Bucket(r2Public, key, png, "image/png"); } } } diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts index a8d7910678..3ae48f8d83 100644 --- a/src/review/visual/capture.ts +++ b/src/review/visual/capture.ts @@ -6,6 +6,14 @@ // (env.REVIEW_AUDIT), and embedded as /gittensory/shot?key= so GitHub's image // proxy fetches a fast static object instead of waiting on a live browser render. // +// JSONbored/gittensory#4184: on self-host, env.REVIEW_AUDIT is often a local-filesystem store (see +// blob-store.ts) fronted by an instance that stays deliberately PRIVATE (e.g. Tailscale-only, no public HTTP +// surface at all) — PUBLIC_API_ORIGIN in that case is unfetchable by GitHub, so the embedded URL renders as a +// broken image. When r2-public-upload.ts's config is present, every fresh render is ALSO uploaded to a +// dedicated, deliberately public R2 bucket and its direct URL used instead, so the private instance itself +// never has to answer a single public request. Config absent (default) ⇒ byte-identical to the R2/local-fs +// behavior described above. +// // PORTED from reviewbot's src/agents/gittensory/capture.ts (mapFilesToRoutes / routeForFile / capturePage / // buildCapture), adapted to gittensory bindings + origins. The agent-config-driven route rules, authed-route // preview session, and explicit-route override are intentionally dropped here — gittensory's UI uses the @@ -24,6 +32,7 @@ import { import { captureScrollFrames, captureShot, DESKTOP_VIEWPORT, MOBILE_VIEWPORT, type ShotTheme, type Viewport } from "./shot"; import { compareCapturedScreenshots, isVisualDiffAvailable, type VisualDiffOutcome } from "./pixel-diff"; import { encodeScrollGif, isScrollGifAvailable } from "./scroll-gif"; +import { publicUrlForKey, resolveR2PublicUploadConfig, uploadToPublicR2Bucket } from "../../selfhost/r2-public-upload"; const NAMESPACE = "gittensory"; const DEFAULT_ROUTES = ["/"]; @@ -242,9 +251,15 @@ async function capturePage( `${target.headSha ?? target.prNumber}:${slot}:${viewportName}:${page}${theme ? `:${theme}` : ""}${theme && themeStorageKey ? `:${themeStorageKey}` : ""}`, ); const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}.png`; - const url = shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : onDemand; + const localUrl = shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : onDemand; + const r2Public = resolveR2PublicUploadConfig(env); const cached = await env.REVIEW_AUDIT.get(key).catch(() => null); if (cached) { + // A public bucket is trusted to already mirror any key this instance ever caches locally, since the + // two are written together below on every fresh render going forward -- see r2-public-upload.ts's + // module doc for the one-time transition caveat this accepts for cache entries written before the + // bucket was configured. + const url = r2Public ? publicUrlForKey(r2Public, key) : localUrl; if (!includeBytes) return { url }; const bytes = await new Response(cached.body).arrayBuffer().then((buf) => new Uint8Array(buf)).catch(() => undefined); return { url, ...(bytes ? { png: bytes } : {}) }; @@ -257,6 +272,7 @@ async function capturePage( } if (png) { await env.REVIEW_AUDIT.put(key, png, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined); + const url = r2Public ? (await uploadToPublicR2Bucket(r2Public, key, png, "image/png")) ?? localUrl : localUrl; return { url, ...(includeBytes ? { png } : {}) }; } } @@ -281,6 +297,8 @@ async function resolveFallbackAfterShot( const key = await fallbackShotR2Key(target.headSha, path, viewportName); const cached = await env.REVIEW_AUDIT.get(key).catch(() => null); if (!cached) return { url: placeholder }; + const r2Public = resolveR2PublicUploadConfig(env); + if (r2Public) return { url: publicUrlForKey(r2Public, key) }; const shotBase = env.PUBLIC_API_ORIGIN; return { url: shotBase ? `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}` : placeholder }; } @@ -302,6 +320,11 @@ async function uploadDiffImage( const fingerprint = await sha256Hex(`${target.headSha ?? target.prNumber}:diff:${viewportName}:${path}${theme ? `:${theme}` : ""}`); const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}-diff.png`; await env.REVIEW_AUDIT.put(key, diff.diffImagePng, { httpMetadata: { contentType: "image/png" } }).catch(() => undefined); + const r2Public = resolveR2PublicUploadConfig(env); + if (r2Public) { + const publicUrl = await uploadToPublicR2Bucket(r2Public, key, diff.diffImagePng, "image/png"); + if (publicUrl) return publicUrl; + } return `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}`; } @@ -334,9 +357,11 @@ async function captureScrollGif( `${target.headSha ?? target.prNumber}:scrollgif:${slot}:${viewportName}:${page}${theme ? `:${theme}` : ""}${theme && themeStorageKey ? `:${themeStorageKey}` : ""}`, ); const key = `${NAMESPACE}/shots/${fingerprint.slice(0, 40)}.gif`; - const url = `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}`; + const localUrl = `${shotBase}/${NAMESPACE}/shot?key=${encodeURIComponent(key)}`; + const r2Public = resolveR2PublicUploadConfig(env); const cached = await env.REVIEW_AUDIT.get(key).catch(() => null); - if (cached) return url; + // Same "already mirrored" trust as capturePage's own cache-hit branch — see that function's comment. + if (cached) return r2Public ? publicUrlForKey(r2Public, key) : localUrl; const { frames, authWalled } = await captureScrollFrames(env, page, viewport, theme ? { theme, ...(themeStorageKey ? { themeStorageKey } : {}) } : {}).catch(() => ({ frames: [] as Uint8Array[], authWalled: false })); if (authWalled || frames.length === 0) return undefined; const gifBytes = await encodeScrollGif( @@ -345,7 +370,11 @@ async function captureScrollGif( ); if (!gifBytes) return undefined; await env.REVIEW_AUDIT.put(key, gifBytes, { httpMetadata: { contentType: "image/gif" } }).catch(() => undefined); - return url; + if (r2Public) { + const publicUrl = await uploadToPublicR2Bucket(r2Public, key, gifBytes, "image/gif"); + if (publicUrl) return publicUrl; + } + return localUrl; } /** Per-repo `review.visual` config, as resolved by the caller from the manifest (#3609 / #3610 / #3678 / diff --git a/src/selfhost/r2-public-upload.ts b/src/selfhost/r2-public-upload.ts new file mode 100644 index 0000000000..3cd4e04e2a --- /dev/null +++ b/src/selfhost/r2-public-upload.ts @@ -0,0 +1,170 @@ +// Self-host public screenshot bucket (JSONbored/gittensory#4184 follow-up). Self-host's REVIEW_AUDIT is a +// LOCAL-FILESYSTEM store (blob-store.ts) served through this instance's own /gittensory/shot route -- fine +// for a self-host box that stays fully private (Tailscale-only, no public HTTP surface by design), but that +// means the URL embedded in a public PR comment is never fetchable by GitHub. Rather than requiring the whole +// instance to expose a public origin, upload the SAME captured PNG a second time to a dedicated, deliberately +// PUBLIC Cloudflare R2 bucket and link directly to ITS public URL instead -- the private instance itself never +// needs to answer a single public request. +// +// Self-host only (Node's node:crypto for SigV4; the Worker bundle never imports this). No new dependency: R2's +// S3-compatible API needs AWS SigV4 request signing, implemented here directly rather than pulling in the full +// @aws-sdk/client-s3 (its transitive dependency weight buys nothing over a few dozen lines of HMAC chaining for +// the ONE operation this needs -- a single-object PUT, no multipart, no listing, no other S3 verb). +import { createHash, createHmac } from "node:crypto"; + +const SERVICE = "s3"; +const REGION = "auto"; +const ALGORITHM = "AWS4-HMAC-SHA256"; + +export type R2PublicUploadConfig = { + accountId: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + /** The bucket's public base URL (its r2.dev dev URL, or a custom domain) -- no trailing slash. */ + publicBaseUrl: string; +}; + +function hex(input: Buffer): string { + return input.toString("hex"); +} + +function sha256HexBytes(bytes: Uint8Array | string): string { + return hex(createHash("sha256").update(bytes).digest()); +} + +function hmac(key: Buffer | string, data: string): Buffer { + return createHmac("sha256", key).update(data).digest(); +} + +/** URI-encode one path segment the way SigV4's canonical URI requires (RFC 3986 unreserved chars kept + * literal; every other byte percent-encoded, uppercase hex) -- stricter than `encodeURIComponent`, which + * leaves `!'()*` unescaped. */ +function encodeUriSegment(segment: string): string { + return encodeURIComponent(segment).replace( + /[!'()*]/g, + (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +function canonicalUri(bucket: string, key: string): string { + const segments = `${bucket}/${key}`.split("/").map(encodeUriSegment); + return `/${segments.join("/")}`; +} + +function amzTimestamps(now: Date): { amzDate: string; dateStamp: string } { + const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); // -> YYYYMMDDTHHMMSSZ + return { amzDate: iso, dateStamp: iso.slice(0, 8) }; +} + +function signingKey(secretAccessKey: string, dateStamp: string): Buffer { + const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp); + const kRegion = hmac(kDate, REGION); + const kService = hmac(kRegion, SERVICE); + return hmac(kService, "aws4_request"); +} + +export type R2SigV4Request = { + method: "PUT"; + host: string; + uri: string; + amzDate: string; + dateStamp: string; + contentType: string; + payloadHash: string; + accessKeyId: string; + secretAccessKey: string; +}; + +/** Build the SigV4 Authorization header value for one PUT request. Pure -- every timestamp/hash is a + * parameter, never computed internally, so this is fully deterministic and unit-testable without touching + * the network or the system clock. */ +export function signR2PutRequest(req: R2SigV4Request): string { + const canonicalHeaders = + `content-type:${req.contentType}\n` + + `host:${req.host}\n` + + `x-amz-content-sha256:${req.payloadHash}\n` + + `x-amz-date:${req.amzDate}\n`; + const signedHeaders = "content-type;host;x-amz-content-sha256;x-amz-date"; + const canonicalRequest = [req.method, req.uri, "", canonicalHeaders, signedHeaders, req.payloadHash].join("\n"); + + const credentialScope = `${req.dateStamp}/${REGION}/${SERVICE}/aws4_request`; + const stringToSign = [ALGORITHM, req.amzDate, credentialScope, sha256HexBytes(canonicalRequest)].join("\n"); + + const signature = hex(hmac(signingKey(req.secretAccessKey, req.dateStamp), stringToSign)); + return `${ALGORITHM} Credential=${req.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; +} + +/** The public URL a key would have IF it's already in the bucket -- no network call, just string-building. + * Used both as `uploadToPublicR2Bucket`'s own success return value and by a caller that already knows (by + * its own convention) that a given key was uploaded on a previous call and wants to reconstruct the same + * URL without paying for a redundant upload. */ +export function publicUrlForKey(config: R2PublicUploadConfig, key: string): string { + return `${config.publicBaseUrl.replace(/\/+$/, "")}/${key}`; +} + +/** Upload `bytes` to the configured public R2 bucket under `key` and return its public URL, or undefined on + * any failure (missing config, network error, non-2xx response) -- mirrors capture.ts's own "never throw, + * degrade to no URL" convention so a bucket outage can never sink a review. */ +export async function uploadToPublicR2Bucket( + config: R2PublicUploadConfig | undefined, + key: string, + bytes: Uint8Array, + contentType: string, +): Promise { + if (!config) return undefined; + try { + const host = `${config.accountId}.r2.cloudflarestorage.com`; + const uri = canonicalUri(config.bucket, key); + const { amzDate, dateStamp } = amzTimestamps(new Date()); + const payloadHash = sha256HexBytes(bytes); + const authorization = signR2PutRequest({ + method: "PUT", + host, + uri, + amzDate, + dateStamp, + contentType, + payloadHash, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }); + const response = await fetch(`https://${host}${uri}`, { + method: "PUT", + headers: { + "content-type": contentType, + "x-amz-content-sha256": payloadHash, + "x-amz-date": amzDate, + authorization, + }, + body: bytes, + }); + if (!response.ok) return undefined; + return publicUrlForKey(config, key); + } catch { + return undefined; + } +} + +/** Resolve the public-bucket config from env, or undefined when any piece is missing -- absent config is the + * legitimate "not opted in" case (captures keep using the private-instance-served URL, exactly as before + * this feature), not an error. */ +export function resolveR2PublicUploadConfig(env: { + R2_PUBLIC_ACCOUNT_ID?: string | undefined; + R2_PUBLIC_BUCKET?: string | undefined; + R2_PUBLIC_ACCESS_KEY_ID?: string | undefined; + R2_PUBLIC_SECRET_ACCESS_KEY?: string | undefined; + R2_PUBLIC_BASE_URL?: string | undefined; +}): R2PublicUploadConfig | undefined { + const { R2_PUBLIC_ACCOUNT_ID, R2_PUBLIC_BUCKET, R2_PUBLIC_ACCESS_KEY_ID, R2_PUBLIC_SECRET_ACCESS_KEY, R2_PUBLIC_BASE_URL } = env; + if (!R2_PUBLIC_ACCOUNT_ID || !R2_PUBLIC_BUCKET || !R2_PUBLIC_ACCESS_KEY_ID || !R2_PUBLIC_SECRET_ACCESS_KEY || !R2_PUBLIC_BASE_URL) { + return undefined; + } + return { + accountId: R2_PUBLIC_ACCOUNT_ID, + bucket: R2_PUBLIC_BUCKET, + accessKeyId: R2_PUBLIC_ACCESS_KEY_ID, + secretAccessKey: R2_PUBLIC_SECRET_ACCESS_KEY, + publicBaseUrl: R2_PUBLIC_BASE_URL, + }; +} diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index e78d446cf9..c13a70363c 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -7,6 +7,7 @@ import * as rateLimitModule from "../../src/github/rate-limit"; import * as repositoriesModule from "../../src/db/repositories"; import * as reviewEffortModule from "../../src/review/review-effort"; import * as repositorySettingsModule from "../../src/settings/repository-settings"; +import * as actionsFallbackModule from "../../src/review/visual/actions-fallback"; import * as sentryModule from "../../src/selfhost/sentry"; import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics"; import { jobCoalesceKey } from "../../src/selfhost/queue-common"; @@ -29829,3 +29830,119 @@ describe("auto-action convergence: end-to-end plan+execute for the general heuri expect(closeAudit?.n).toBe(0); }); }); + +describe("storeVisualCaptureFallbackShots (#4184 public bucket mirror)", () => { + 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; + } + + const R2_PUBLIC_ENV = { + R2_PUBLIC_ACCOUNT_ID: "acct123", + R2_PUBLIC_BUCKET: "gittensory-visual-capture-public", + R2_PUBLIC_ACCESS_KEY_ID: "ak", + R2_PUBLIC_SECRET_ACCESS_KEY: "sk", + R2_PUBLIC_BASE_URL: "https://pub-example.r2.dev", + }; + + async function seedFallbackRepoAndPr(env: ReturnType, headSha: string): Promise { + await upsertInstallation(env, { action: "created", installation: { id: 9001, 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" } }, 9001); + await upsertRepositorySettings(env, { repoFullName: "owner/fallback-repo", gatePack: "oss-anti-slop", gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); + await upsertPullRequestFromGitHub(env, "owner/fallback-repo", { number: 12, title: "Fallback shot test", state: "open", user: { login: "contributor" }, head: { sha: headSha }, base: { ref: "main" }, labels: [], body: "Closes #1" }); + } + + function fallbackWorkflowRunPayload(headSha: string, prNumber: number): unknown { + return { + action: "completed", + repository: { name: "fallback-repo", full_name: "owner/fallback-repo", owner: { login: "owner" } }, + installation: { id: 9001 }, + workflow_run: { + id: 555, + name: "Gittensory Visual Capture Fallback", + event: "workflow_dispatch", + conclusion: "success", + display_title: `gittensory-visual-fallback pr=${prNumber} sha=${headSha}`, + }, + }; + } + + function stubFallbackFetch(): (input: RequestInfo | URL, init?: RequestInit) => Promise { + return async (input) => { + const url = String(input); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/pulls/12/files")) return Response.json([]); // no gittensory-ui route file -> falls to DEFAULT_ROUTES ["/"] + if (url.includes("r2.cloudflarestorage.com")) return new Response(null, { status: 200 }); + return new Response("not found", { status: 404 }); // manifest load, re-review kickoff, etc. -- all fail-safe to defaults + }; + } + + it("mirrors a stored fallback shot to the public bucket when configured", async () => { + const headSha = "b".repeat(40); + const env = createTestEnv({ ...R2_PUBLIC_ENV, GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedFallbackRepoAndPr(env, headSha); + const fetchSpy = vi.fn(stubFallbackFetch()); + vi.stubGlobal("fetch", fetchSpy); + const shotsSpy = vi.spyOn(actionsFallbackModule, "fetchFallbackArtifactShots").mockResolvedValue([ + { fileName: actionsFallbackModule.fallbackShotFileName("/", "desktop"), png: new Uint8Array([1, 2, 3]) }, + { fileName: actionsFallbackModule.fallbackShotFileName("/", "mobile"), png: new Uint8Array([4, 5, 6]) }, + ]); + try { + await processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-completed", + eventName: "workflow_run", + payload: fallbackWorkflowRunPayload(headSha, 12), + } as never); + + const desktopKey = await actionsFallbackModule.fallbackShotR2Key(headSha, "/", "desktop"); + expect(await env.REVIEW_AUDIT!.get(desktopKey)).not.toBeNull(); // local mirror still happens exactly as before this feature + const mobileKey = await actionsFallbackModule.fallbackShotR2Key(headSha, "/", "mobile"); + expect(await env.REVIEW_AUDIT!.get(mobileKey)).not.toBeNull(); + + // Confirms uploadToPublicR2Bucket was actually invoked for BOTH shots, not just resolveR2PublicUploadConfig + // resolving truthy and stopping there. + const r2PutCalls = fetchSpy.mock.calls.filter(([input]) => String(input).includes("r2.cloudflarestorage.com")); + expect(r2PutCalls.length).toBe(2); + } finally { + shotsSpy.mockRestore(); + } + }); + + it("never attempts a public-bucket upload when the public bucket isn't configured (byte-identical to before this feature)", async () => { + const headSha = "c".repeat(40); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), GITTENSORY_REVIEW_REPOS: "owner/fallback-repo", REVIEW_AUDIT: memoryReviewAudit() }); + await seedFallbackRepoAndPr(env, headSha); + const fetchSpy = vi.fn(stubFallbackFetch()); + vi.stubGlobal("fetch", fetchSpy); + const shotsSpy = vi.spyOn(actionsFallbackModule, "fetchFallbackArtifactShots").mockResolvedValue([ + { fileName: actionsFallbackModule.fallbackShotFileName("/", "desktop"), png: new Uint8Array([1, 2, 3]) }, + ]); + try { + await processJob(env, { + type: "github-webhook", + deliveryId: "fallback-run-completed-no-public-bucket", + eventName: "workflow_run", + payload: fallbackWorkflowRunPayload(headSha, 12), + } as never); + + // Proves the code path actually ran (not a vacuous pass from an early-return elsewhere) before trusting + // the negative assertion below. + const desktopKey = await actionsFallbackModule.fallbackShotR2Key(headSha, "/", "desktop"); + expect(await env.REVIEW_AUDIT!.get(desktopKey)).not.toBeNull(); + expect(fetchSpy.mock.calls.some(([input]) => String(input).includes("r2.cloudflarestorage.com"))).toBe(false); + } finally { + shotsSpy.mockRestore(); + } + }); +}); diff --git a/test/unit/r2-public-upload.test.ts b/test/unit/r2-public-upload.test.ts new file mode 100644 index 0000000000..9c1c141fa4 --- /dev/null +++ b/test/unit/r2-public-upload.test.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { resolveR2PublicUploadConfig, signR2PutRequest, uploadToPublicR2Bucket } from "../../src/selfhost/r2-public-upload"; + +const FAKE_CONFIG = { + accountId: "abc123def456", + bucket: "gittensory-visual-capture-public", + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + publicBaseUrl: "https://pub-example.r2.dev", +}; + +describe("signR2PutRequest (#4184 public screenshot bucket)", () => { + it("regression: matches an independently Python-computed SigV4 signature for a fixed test vector", () => { + // Cross-checked against Python's hmac/hashlib computing the identical canonical-request/signing-key + // chain by hand, for the SAME inputs -- an independent implementation agreeing on the exact signature + // is much stronger evidence of correctness than this test suite alone could ever provide. + const payloadHash = createHash("sha256").update("test-png-bytes").digest("hex"); + const result = signR2PutRequest({ + method: "PUT", + host: "abc123def456.r2.cloudflarestorage.com", + uri: "/gittensory-visual-capture-public/gittensory/shots/deadbeef.png", + amzDate: "20260708T113000Z", + dateStamp: "20260708", + contentType: "image/png", + payloadHash, + accessKeyId: FAKE_CONFIG.accessKeyId, + secretAccessKey: FAKE_CONFIG.secretAccessKey, + }); + expect(result).toBe( + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260708/auto/s3/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date, Signature=c600d5c7b73c198bdf627a1e4ad1079627c35653e54ee829ffbf1b4752be067c", + ); + }); + + it("is a pure function of its inputs: identical inputs always produce identical output", () => { + const req = { + method: "PUT" as const, + host: "h.r2.cloudflarestorage.com", + uri: "/bucket/key.png", + amzDate: "20260101T000000Z", + dateStamp: "20260101", + contentType: "image/png", + payloadHash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + accessKeyId: "ak", + secretAccessKey: "sk", + }; + expect(signR2PutRequest(req)).toBe(signR2PutRequest({ ...req })); + }); + + it("a different secret key produces a different signature (the signing key actually depends on it)", () => { + const req = { + method: "PUT" as const, + host: "h.r2.cloudflarestorage.com", + uri: "/bucket/key.png", + amzDate: "20260101T000000Z", + dateStamp: "20260101", + contentType: "image/png", + payloadHash: "abc", + accessKeyId: "ak", + secretAccessKey: "sk-one", + }; + expect(signR2PutRequest(req)).not.toBe(signR2PutRequest({ ...req, secretAccessKey: "sk-two" })); + }); +}); + +describe("uploadToPublicR2Bucket (#4184)", () => { + it("returns undefined immediately (no fetch) when config is undefined", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const url = await uploadToPublicR2Bucket(undefined, "some/key.png", new Uint8Array([1, 2, 3]), "image/png"); + expect(url).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("returns the public URL (base + key) on a successful PUT", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true }), + ); + const url = await uploadToPublicR2Bucket(FAKE_CONFIG, "gittensory/shots/abc123.png", new Uint8Array([1, 2, 3]), "image/png"); + expect(url).toBe("https://pub-example.r2.dev/gittensory/shots/abc123.png"); + vi.unstubAllGlobals(); + }); + + it("strips a trailing slash from publicBaseUrl before joining the key", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true })); + const url = await uploadToPublicR2Bucket( + { ...FAKE_CONFIG, publicBaseUrl: "https://pub-example.r2.dev/" }, + "k.png", + new Uint8Array(), + "image/png", + ); + expect(url).toBe("https://pub-example.r2.dev/k.png"); + vi.unstubAllGlobals(); + }); + + it("percent-encodes SigV4's extra reserved chars (!'()*) in the request URI, which encodeURIComponent alone leaves untouched", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchSpy); + await uploadToPublicR2Bucket(FAKE_CONFIG, "test(1)!'*.png", new Uint8Array(), "image/png"); + const [url] = fetchSpy.mock.calls[0] as [string]; + expect(url).toBe("https://abc123def456.r2.cloudflarestorage.com/gittensory-visual-capture-public/test%281%29%21%27%2A.png"); + vi.unstubAllGlobals(); + }); + + it("returns undefined when the PUT responds non-ok", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403 })); + const url = await uploadToPublicR2Bucket(FAKE_CONFIG, "k.png", new Uint8Array(), "image/png"); + expect(url).toBeUndefined(); + vi.unstubAllGlobals(); + }); + + it("returns undefined (never throws) when fetch itself rejects", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); + const url = await uploadToPublicR2Bucket(FAKE_CONFIG, "k.png", new Uint8Array(), "image/png"); + expect(url).toBeUndefined(); + vi.unstubAllGlobals(); + }); + + it("sends a real Authorization header built from the configured credentials", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchSpy); + await uploadToPublicR2Bucket(FAKE_CONFIG, "k.png", new Uint8Array([9]), "image/png"); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit & { headers: Record }]; + expect(url).toBe("https://abc123def456.r2.cloudflarestorage.com/gittensory-visual-capture-public/k.png"); + expect(init.method).toBe("PUT"); + expect(init.headers.authorization).toContain(`Credential=${FAKE_CONFIG.accessKeyId}/`); + expect(init.headers["content-type"]).toBe("image/png"); + vi.unstubAllGlobals(); + }); +}); + +describe("resolveR2PublicUploadConfig (#4184)", () => { + const FULL_ENV = { + R2_PUBLIC_ACCOUNT_ID: "acct", + R2_PUBLIC_BUCKET: "bucket", + R2_PUBLIC_ACCESS_KEY_ID: "ak", + R2_PUBLIC_SECRET_ACCESS_KEY: "sk", + R2_PUBLIC_BASE_URL: "https://pub.r2.dev", + }; + + it("resolves a full config object when every var is set", () => { + expect(resolveR2PublicUploadConfig(FULL_ENV)).toEqual({ + accountId: "acct", + bucket: "bucket", + accessKeyId: "ak", + secretAccessKey: "sk", + publicBaseUrl: "https://pub.r2.dev", + }); + }); + + it("returns undefined (not a partial config) when any single var is missing -- one absent field for each", () => { + for (const key of Object.keys(FULL_ENV) as (keyof typeof FULL_ENV)[]) { + const partial = { ...FULL_ENV, [key]: undefined }; + expect(resolveR2PublicUploadConfig(partial)).toBeUndefined(); + } + }); + + it("returns undefined when nothing at all is configured -- the legitimate not-opted-in default", () => { + expect(resolveR2PublicUploadConfig({})).toBeUndefined(); + }); +}); diff --git a/test/unit/visual-capture.test.ts b/test/unit/visual-capture.test.ts index 2d72479208..4659381577 100644 --- a/test/unit/visual-capture.test.ts +++ b/test/unit/visual-capture.test.ts @@ -1554,6 +1554,258 @@ describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve f expect(result.routes[0]?.afterUrl).toContain("placeholder=loading"); }); + + it("regression: shows the requires-authentication placeholder for a sign-in-walled route (pre-existing gap, no prior test covered this)", async () => { + const captureShotSpy = vi.spyOn(shotModule, "captureShot").mockResolvedValue({ png: null, authWalled: true }); + try { + 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: 44, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + expect(result.routes[0]?.beforeUrl).toContain("placeholder=auth"); + } finally { + captureShotSpy.mockRestore(); + } + }); +}); + +describe("public R2 bucket screenshot upload (#4184)", () => { + const R2_PUBLIC_ENV = { + PUBLIC_API_ORIGIN: "https://edge.internal.example", // stays private -- the whole point of this feature + PUBLIC_SITE_ORIGIN: "https://prod.example.com", + R2_PUBLIC_ACCOUNT_ID: "acct123", + R2_PUBLIC_BUCKET: "gittensory-visual-capture-public", + R2_PUBLIC_ACCESS_KEY_ID: "ak", + R2_PUBLIC_SECRET_ACCESS_KEY: "sk", + R2_PUBLIC_BASE_URL: "https://pub-example.r2.dev", + }; + + function stubR2Fetch(ok: boolean): void { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("r2.cloudflarestorage.com")) return new Response(null, { status: ok ? 200 : 500 }); + // A test that omits previewUrl also triggers buildCapture's own deployment-discovery lookup — an empty + // result degrades it to "no preview found" exactly like preview-url.test.ts's own stubs, not a stray call. + return new Response("not found", { status: 404 }); + })); + } + + it("uploads a fresh render to the public bucket and returns its direct URL instead of the private origin's", async () => { + stubR2Fetch(true); + const captureShotSpy = vi.spyOn(shotModule, "captureShot").mockResolvedValue({ png: new Uint8Array([1, 2, 3]), authWalled: false }); + try { + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: memoryReviewAudit() }); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 100, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + expect(result.routes[0]?.beforeUrl).toMatch(/^https:\/\/pub-example\.r2\.dev\//); + expect(result.routes[0]?.beforeUrl).not.toContain("edge.internal.example"); + } finally { + captureShotSpy.mockRestore(); + } + }); + + it("falls back to the private-origin URL when the public bucket upload itself fails", async () => { + stubR2Fetch(false); + const captureShotSpy = vi.spyOn(shotModule, "captureShot").mockResolvedValue({ png: new Uint8Array([1, 2, 3]), authWalled: false }); + try { + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: memoryReviewAudit() }); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 101, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + expect(result.routes[0]?.beforeUrl).toContain("edge.internal.example"); + } finally { + captureShotSpy.mockRestore(); + } + }); + + it("regression: a cache hit with no PUBLIC_API_ORIGIN and no public bucket falls back to the raw page URL (localUrl's shotBase-less side)", async () => { + const key = await shotKey(109, "before", "desktop", "https://prod.example.com/app"); + const seeded = memoryReviewAudit(); + await seeded.put(key, new Uint8Array([9, 9, 9])); + const env = createTestEnv({ PUBLIC_SITE_ORIGIN: "https://prod.example.com", REVIEW_AUDIT: seeded }); + delete (env as Partial).PUBLIC_API_ORIGIN; + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 109, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + expect(result.routes[0]?.beforeUrl).toBe("https://prod.example.com/app"); + }); + + it("trusts a cache hit as already-mirrored and returns the public URL with no upload call at all", async () => { + const fetchSpy = vi.fn(async () => { throw new Error("must not be called on a cache hit"); }); + vi.stubGlobal("fetch", fetchSpy); + const key = await shotKey(102, "before", "desktop", "https://prod.example.com/app"); + const seeded = memoryReviewAudit(); + await seeded.put(key, new Uint8Array([9, 9, 9])); + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: seeded }); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 102, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + expect(result.routes[0]?.beforeUrl).toBe(`https://pub-example.r2.dev/${key}`); + }); + + it("resolveFallbackAfterShot: returns the public bucket URL for an actions-fallback shot too, not just capturePage's own renders", async () => { + stubR2Fetch(true); + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: memoryReviewAudit() }); + const headSha = "a".repeat(40); + await markFallbackDispatched(env, headSha); + const key = await fallbackShotR2Key(headSha, "/app", "desktop"); + await env.REVIEW_AUDIT!.put(key, new Uint8Array([4, 5, 6])); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 103, headSha, defaultBranchRef: "main" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { actionsFallback: true }, + ); + expect(result.routes[0]?.afterUrl).toBe(`https://pub-example.r2.dev/${key}`); + }); + + it("uploadDiffImage: uploads a computed diff overlay to the public bucket and returns its direct URL", async () => { + stubR2Fetch(true); + const availableSpy = vi.spyOn(pixelDiffModule, "isVisualDiffAvailable").mockReturnValue(true); + const compareSpy = vi.spyOn(pixelDiffModule, "compareCapturedScreenshots").mockResolvedValue({ + status: "changed", + changedPixelPercent: 30, + diffImagePng: new Uint8Array([7, 7, 7]), + }); + const captureShotSpy = vi.spyOn(shotModule, "captureShot").mockResolvedValue({ png: new Uint8Array([9, 9, 9]), authWalled: false }); + try { + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: memoryReviewAudit() }); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 104, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + expect(result.routes[0]?.diffUrl).toMatch(/^https:\/\/pub-example\.r2\.dev\//); + } finally { + availableSpy.mockRestore(); + compareSpy.mockRestore(); + captureShotSpy.mockRestore(); + } + }); + + it("uploadDiffImage: falls back to the private-origin URL when the public bucket upload fails", async () => { + stubR2Fetch(false); + const availableSpy = vi.spyOn(pixelDiffModule, "isVisualDiffAvailable").mockReturnValue(true); + const compareSpy = vi.spyOn(pixelDiffModule, "compareCapturedScreenshots").mockResolvedValue({ + status: "changed", + changedPixelPercent: 30, + diffImagePng: new Uint8Array([7, 7, 7]), + }); + const captureShotSpy = vi.spyOn(shotModule, "captureShot").mockResolvedValue({ png: new Uint8Array([9, 9, 9]), authWalled: false }); + try { + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: memoryReviewAudit() }); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 105, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + expect(result.routes[0]?.diffUrl).toContain("edge.internal.example"); + } finally { + availableSpy.mockRestore(); + compareSpy.mockRestore(); + captureShotSpy.mockRestore(); + } + }); + + it("captureScrollGif: uploads a freshly-encoded GIF to the public bucket and returns its direct URL", async () => { + stubR2Fetch(true); + const gifAvailableSpy = vi.spyOn(scrollGifModule, "isScrollGifAvailable").mockReturnValue(true); + const captureScrollSpy = vi.spyOn(shotModule, "captureScrollFrames").mockResolvedValue({ + frames: [new Uint8Array([1, 2, 3])], + authWalled: false, + }); + const encodeSpy = vi.spyOn(scrollGifModule, "encodeScrollGif").mockResolvedValue(new Uint8Array([7, 8, 9])); + try { + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: memoryReviewAudit() }); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 106, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { gif: true }, + ); + expect(result.routes[0]?.afterGifUrl).toMatch(/^https:\/\/pub-example\.r2\.dev\//); + } finally { + gifAvailableSpy.mockRestore(); + captureScrollSpy.mockRestore(); + encodeSpy.mockRestore(); + } + }); + + it("captureScrollGif: falls back to the private-origin URL when the public bucket upload fails", async () => { + stubR2Fetch(false); + const gifAvailableSpy = vi.spyOn(scrollGifModule, "isScrollGifAvailable").mockReturnValue(true); + const captureScrollSpy = vi.spyOn(shotModule, "captureScrollFrames").mockResolvedValue({ + frames: [new Uint8Array([1, 2, 3])], + authWalled: false, + }); + const encodeSpy = vi.spyOn(scrollGifModule, "encodeScrollGif").mockResolvedValue(new Uint8Array([7, 8, 9])); + try { + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: memoryReviewAudit() }); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 107, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { gif: true }, + ); + expect(result.routes[0]?.afterGifUrl).toContain("edge.internal.example"); + } finally { + gifAvailableSpy.mockRestore(); + captureScrollSpy.mockRestore(); + encodeSpy.mockRestore(); + } + }); + + it("captureScrollGif: trusts a cache hit as already-mirrored too, same as capturePage's own cache-hit branch", async () => { + const gifAvailableSpy = vi.spyOn(scrollGifModule, "isScrollGifAvailable").mockReturnValue(true); + const fetchSpy = vi.fn(async () => { throw new Error("must not be called on a cache hit"); }); + vi.stubGlobal("fetch", fetchSpy); + try { + const fingerprint = await sha256Hex("108:scrollgif:after:desktop:https://preview.example.com/app"); + const key = `gittensory/shots/${fingerprint.slice(0, 40)}.gif`; + const seeded = memoryReviewAudit(); + await seeded.put(key, new Uint8Array([7, 8, 9])); + const env = createTestEnv({ ...R2_PUBLIC_ENV, REVIEW_AUDIT: seeded }); + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 108, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { gif: true }, + ); + expect(result.routes[0]?.afterGifUrl).toBe(`https://pub-example.r2.dev/${key}`); + } finally { + gifAvailableSpy.mockRestore(); + } + }); }); describe("fetchShotContentBlock (#4111)", () => {