diff --git a/.gittensory.yml.example b/.gittensory.yml.example index 61782cebf8..5d1f499ce6 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -963,6 +963,13 @@ settings: # themes: # - light # - dark +# # The localStorage key to ALSO force `theme` into (plus a reload) before rendering (#4109) -- a +# # fallback for a target whose theming reads an explicit stored preference instead of consulting +# # `prefers-color-scheme` (VERIFIED: emulateMediaFeatures alone has zero effect on that class of app, +# # since it only changes what CSS media queries / matchMedia report -- it never touches localStorage). +# # Only takes effect when `themes` above is also configured. String or null. Default: null (no +# # localStorage write, no reload -- byte-identical to today). +# theme_storage_key: "theme" # # Also capture a short scroll-through GIF per route (desktop only) — evidence for scroll-linked behavior # # (parallax, reveal-on-scroll, a sticky header) that a static screenshot can't show (#3612). Rendered as # # a separate "Scroll preview" section alongside the static before/after table, never replacing it. diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index e6d3414fef..27ac70393f 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -976,6 +976,13 @@ settings: # themes: # - light # - dark +# # The localStorage key to ALSO force `theme` into (plus a reload) before rendering (#4109) -- a +# # fallback for a target whose theming reads an explicit stored preference instead of consulting +# # `prefers-color-scheme` (VERIFIED: emulateMediaFeatures alone has zero effect on that class of app, +# # since it only changes what CSS media queries / matchMedia report -- it never touches localStorage). +# # Only takes effect when `themes` above is also configured. String or null. Default: null (no +# # localStorage write, no reload -- byte-identical to today). +# theme_storage_key: "theme" # # Also capture a short scroll-through GIF per route (desktop only) — evidence for scroll-linked behavior # # (parallax, reveal-on-scroll, a sticky header) that a static screenshot can't show (#3612). Rendered as # # a separate "Scroll preview" section alongside the static before/after table, never replacing it. diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index dabb2129e8..13988bb4f9 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -662,6 +662,15 @@ export type VisualConfig = { * it only opts back in at a layer where a global default of `false` disabled this repo. This is what lets an * operator flip visual review on/off per-repo purely through the VPS config files, without a redeploy. */ enabled: boolean | null; + /** `review.visual.theme_storage_key` (#4109): the `localStorage` key the capture pipeline ALSO forces + * `theme` into (plus a reload) before rendering, for a target whose theming reads an explicit stored + * preference instead of consulting `prefers-color-scheme` — verified (against gittensory-ui's own + * dark-mode-only build) that `emulateMediaFeatures` alone has zero effect on that class of app, since it + * only changes what CSS media queries / `matchMedia` report. null (default) ⇒ no `localStorage` write, no + * reload — byte-identical to today. Only takes effect when `themes` is also configured; the key name is + * 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; }; /** A `prefers-color-scheme` value the capture pipeline can emulate before rendering (#3678). */ @@ -700,6 +709,7 @@ export const EMPTY_VISUAL_CONFIG: VisualConfig = { themes: [], gif: false, enabled: null, + themeStorageKey: null, }; /** One `review.path_instructions[]` entry: a manifest path glob + the public-safe instructions to apply when a @@ -2109,6 +2119,7 @@ function overlayVisualConfig(base: VisualConfig, override: VisualConfig): Visual themes: override.themes.length > 0 ? [...override.themes] : [...base.themes], gif: override.gif ? override.gif : base.gif, enabled: pickOverlayNullable(override.enabled, base.enabled), + themeStorageKey: pickOverlayNullable(override.themeStorageKey, base.themeStorageKey), }; } @@ -2338,7 +2349,15 @@ function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: stri } function visualConfigPresent(config: VisualConfig): boolean { - return config.preview.urlTemplate !== null || config.routes.paths.length > 0 || config.routes.maxRoutes !== null || config.themes.length > 0 || config.gif || config.enabled !== null; + return ( + config.preview.urlTemplate !== null || + config.routes.paths.length > 0 || + config.routes.maxRoutes !== null || + config.themes.length > 0 || + config.gif || + config.enabled !== null || + config.themeStorageKey !== null + ); } const VISUAL_THEME_VALUES: readonly VisualTheme[] = ["light", "dark"]; @@ -2420,8 +2439,9 @@ function parseVisualConfig(value: JsonValue | undefined, warnings: string[]): Vi const themes = parseVisualThemes(record.themes, warnings); 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); - return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled }; + return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled, themeStorageKey }; } function parseAutoReviewTitleKeywords(value: JsonValue | undefined, warnings: string[]): string[] { @@ -2737,6 +2757,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue if (review.visual.themes.length > 0) visual.themes = [...review.visual.themes]; 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; out.visual = visual; } if (review.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = review.linkedIssueSatisfaction; diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts index 5a330bace8..47d9d54ae6 100644 --- a/src/review/visual/capture.ts +++ b/src/review/visual/capture.ts @@ -165,18 +165,27 @@ async function capturePage( // #3678: emulate prefers-color-scheme before rendering. Undefined (every pre-#3678 caller) ⇒ no emulation // call and an UNCHANGED cache key — byte-identical to today. theme?: ShotTheme | undefined, + // #4109: ALSO force `theme` via localStorage.setItem(themeStorageKey, theme) + a reload, for a target whose + // theming ignores prefers-color-scheme (see shot.ts's CaptureShotOptions.theme doc). Only takes effect + // together with `theme`; undefined (every pre-#4109 caller) ⇒ byte-identical to today. + themeStorageKey?: string | undefined, ): Promise<{ url?: string | undefined; png?: Uint8Array | undefined }> { if (!page) return {}; const shotBase = env.PUBLIC_API_ORIGIN; // this worker's public origin (serves /gittensory/shot) - // Carries the theme (#3678) so a LATER on-demand fetch of this exact URL (e.g. a failed/never-persisted - // render retried by GitHub's image proxy) still requests the matching prefers-color-scheme, not the - // default — handleShot's Mode B reads this same &theme= param. Omitted when unset, unchanged from today. - const onDemand = shotBase ? `${shotBase}/${NAMESPACE}/shot?url=${encodeURIComponent(page)}&w=${viewport.width}&h=${viewport.height}${theme ? `&theme=${theme}` : ""}` : page; + // Carries the theme (#3678) and, when set, the storage key (#4109) so a LATER on-demand fetch of this + // exact URL (e.g. a failed/never-persisted render retried by GitHub's image proxy) still requests the + // matching prefers-color-scheme/localStorage forcing, not the default — handleShot's Mode B reads these + // same &theme=/&themeStorageKey= params. Omitted when unset, unchanged from today. + const onDemand = shotBase + ? `${shotBase}/${NAMESPACE}/shot?url=${encodeURIComponent(page)}&w=${viewport.width}&h=${viewport.height}${theme ? `&theme=${theme}` : ""}${theme && themeStorageKey ? `&themeStorageKey=${encodeURIComponent(themeStorageKey)}` : ""}` + : page; if (env.REVIEW_AUDIT) { - // Key includes the viewport (and, when set, the theme) so desktop/mobile and light/dark shots of the - // same page don't collide in R2. - const fingerprint = await sha256Hex(`${target.headSha ?? target.prNumber}:${slot}:${viewportName}:${page}${theme ? `:${theme}` : ""}`); + // Key includes the viewport (and, when set, the theme + storage key) so desktop/mobile, light/dark, and + // differently-configured-storage-key shots of the same page don't collide in R2. + const fingerprint = await sha256Hex( + `${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 cached = await env.REVIEW_AUDIT.get(key).catch(() => null); @@ -185,7 +194,7 @@ async function capturePage( const bytes = await new Response(cached.body).arrayBuffer().then((buf) => new Uint8Array(buf)).catch(() => undefined); return { url, ...(bytes ? { png: bytes } : {}) }; } - const { png, authWalled } = await captureShot(env, page, viewport, theme ? { theme } : {}).catch(() => ({ png: null, authWalled: false })); + const { png, authWalled } = await captureShot(env, page, viewport, theme ? { theme, ...(themeStorageKey ? { themeStorageKey } : {}) } : {}).catch(() => ({ png: null, authWalled: false })); // A protected route that redirected to a sign-in wall: show an honest "requires authentication" // placeholder rather than caching/serving a screenshot of the login screen. if (authWalled) { @@ -238,16 +247,20 @@ async function captureScrollGif( viewportName: "desktop" | "mobile", viewport: Viewport, theme?: ShotTheme | undefined, + // #4109: see capturePage's own themeStorageKey param — same fallback, same "only with theme" guard. + themeStorageKey?: string | undefined, ): Promise { if (!page) return undefined; const shotBase = env.PUBLIC_API_ORIGIN; if (!env.REVIEW_AUDIT || !shotBase) return undefined; - const fingerprint = await sha256Hex(`${target.headSha ?? target.prNumber}:scrollgif:${slot}:${viewportName}:${page}${theme ? `:${theme}` : ""}`); + const fingerprint = await sha256Hex( + `${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 cached = await env.REVIEW_AUDIT.get(key).catch(() => null); if (cached) return url; - const { frames, authWalled } = await captureScrollFrames(env, page, viewport, theme ? { theme } : {}).catch(() => ({ frames: [] as Uint8Array[], authWalled: false })); + 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( frames.map((png) => ({ png })), @@ -259,13 +272,18 @@ async function captureScrollGif( } /** Per-repo `review.visual` config, as resolved by the caller from the manifest (#3609 / #3610 / #3678 / - * #3612). Absent ⇒ byte-identical to today (GitHub-native discovery, automatic route inference, single - * default-theme capture, built-in route cap, no scroll-GIF). */ + * #3612 / #4109). Absent ⇒ byte-identical to today (GitHub-native discovery, automatic route inference, + * single default-theme capture, built-in route cap, no scroll-GIF, no localStorage theme forcing). */ export type VisualCaptureConfig = { preview?: VisualPreviewInput | null | undefined; routes?: VisualRoutesInput | null | undefined; themes?: readonly ShotTheme[] | null | undefined; gif?: boolean | null | undefined; + /** #4109: the localStorage key `emulateMediaFeatures`-driven captures fall back to for a target whose + * theming reads an explicit stored preference instead of `prefers-color-scheme` — see shot.ts's + * `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; }; /** @@ -339,6 +357,10 @@ export async function buildCapture(env: Env, token: string, target: CaptureTarge // capturePage/captureShot already treat an undefined theme as "no emulation call at all", so this one // iteration is byte-identical to every pre-#3678 call. const themes: readonly (ShotTheme | undefined)[] = visualConfig?.themes && visualConfig.themes.length > 0 ? visualConfig.themes : [undefined]; + // #4109: the localStorage fallback only ever matters alongside a configured theme — resolved once, threaded + // through every capturePage/captureScrollGif call below, each of which independently no-ops it when its own + // `theme` iteration is undefined (the untagged default pass). + const themeStorageKey = visualConfig?.themeStorageKey ? visualConfig.themeStorageKey : undefined; const captureRoutes: CaptureRoute[] = []; for (const theme of themes) { for (const path of routes) { @@ -346,10 +368,10 @@ export async function buildCapture(env: Env, token: string, target: CaptureTarge const afterPage = previewBase ? joinUrl(previewBase, path) : ""; // Render desktop + mobile for each slot in parallel (4 PNGs/route) to bound wall-clock. const [beforeShot, beforeMobileShot, afterShot, afterMobileShot] = await Promise.all([ - capturePage(env, target, beforePage, "before", "desktop", DESKTOP_VIEWPORT, diffAvailable, theme), - capturePage(env, target, beforePage, "before", "mobile", MOBILE_VIEWPORT, diffAvailable, theme), - afterPage ? capturePage(env, target, afterPage, "after", "desktop", DESKTOP_VIEWPORT, diffAvailable, theme) : Promise.resolve<{ url?: string | undefined; png?: Uint8Array | undefined }>({ url: afterPlaceholder }), - afterPage ? capturePage(env, target, afterPage, "after", "mobile", MOBILE_VIEWPORT, diffAvailable, theme) : Promise.resolve<{ url?: string | undefined; png?: Uint8Array | undefined }>({ url: afterPlaceholder }), + 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 }), ]); // 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. @@ -365,8 +387,8 @@ export async function buildCapture(env: Env, token: string, target: CaptureTarge ]); const [beforeGifUrl, afterGifUrl] = gifWanted ? await Promise.all([ - captureScrollGif(env, target, beforePage, "before", "desktop", DESKTOP_VIEWPORT, theme), - afterPage ? captureScrollGif(env, target, afterPage, "after", "desktop", DESKTOP_VIEWPORT, theme) : Promise.resolve(undefined), + captureScrollGif(env, target, beforePage, "before", "desktop", DESKTOP_VIEWPORT, theme, themeStorageKey), + afterPage ? captureScrollGif(env, target, afterPage, "after", "desktop", DESKTOP_VIEWPORT, theme, themeStorageKey) : Promise.resolve(undefined), ]) : [undefined, undefined]; captureRoutes.push({ diff --git a/src/review/visual/shot.ts b/src/review/visual/shot.ts index 17282eb719..602781a52f 100644 --- a/src/review/visual/shot.ts +++ b/src/review/visual/shot.ts @@ -25,8 +25,23 @@ export type ShotTheme = "light" | "dark"; export interface CaptureShotOptions { isAllowedUrl?: (targetUrl: string) => boolean; /** Emulate `prefers-color-scheme: ` before navigation (#3678). Omitted (every existing caller) ⇒ - * no emulation call at all — Chromium's own unconfigured default, byte-identical to today. */ + * no emulation call at all — Chromium's own unconfigured default, byte-identical to today. + * + * VERIFIED (#4109): `emulateMediaFeatures` maps to CDP's `Emulation.setEmulatedMedia`, which only changes + * what CSS media queries and `window.matchMedia` report — it cannot write `localStorage` and has NO effect + * on any theme mechanism that reads an explicit stored preference instead of consulting + * `prefers-color-scheme`. This is reproducible today against gittensory's own UI: `apps/gittensory-ui` + * forces dark mode unconditionally in its no-flash script (`components/site/theme-toggle.tsx`), never + * consulting the media feature at all, so a `light` vs `dark` capture of gittensory's own site renders + * byte-identical regardless of this option. `themeStorageKey` below is the fallback for exactly that class + * of app. */ theme?: ShotTheme; + /** Also force `theme` via `localStorage.setItem(themeStorageKey, theme)` + a reload before capture (#4109), + * for apps (like metagraphed's own manual-screenshot convention) whose theme is driven by a stored + * preference rather than `prefers-color-scheme`. Configurable per-repo since the key name is + * app-specific — there is no universal convention. Only takes effect together with `theme`; omitted + * (every pre-#4109 caller) ⇒ no `localStorage` write and no reload, byte-identical to today. */ + themeStorageKey?: string; } type ScreenshotRequest = { url(): string; @@ -38,6 +53,16 @@ type ScreenshotPage = { evaluate(fn: () => T): Promise; screenshot(options: { type: "png"; fullPage: true }): Promise; }; +// Viewport matrix (#4109): DELIBERATELY kept at 2 (desktop + mobile), not widened to metagraphed's 3-viewport +// manual convention (375×812 / 768×1024 / 1280×800). That convention is a human clicking through DevTools -- +// free to run. This pipeline's cost is Browser Rendering wall-clock: every route already renders up to 4 PNGs +// (before+after × desktop+mobile), multiplied again by `review.visual.themes` when configured -- a 3rd +// viewport would raise that to 6 (a 50% jump) for every repo, every review, forever, not just the reviewer +// who wants tablet coverage. gittensory's own pair already straddles a real breakpoint on each side (1440 is +// past a typical Tailwind `lg`; 390 is an iPhone-class portrait well under `sm`), so it is not an arbitrary +// choice either. If a repo genuinely needs tablet coverage, that is a `review.visual` opt-in follow-up +// (mirroring `routes.maxRoutes`'s per-repo override precedent) -- not a default-on cost increase for repos +// that never asked for a 3rd viewport. export const DESKTOP_VIEWPORT: Viewport = { width: 1440, height: 900 }; export const MOBILE_VIEWPORT: Viewport = { width: 390, height: 844 }; // iPhone-class portrait const VIEWPORT = DESKTOP_VIEWPORT; @@ -46,6 +71,9 @@ export const MAX_SCREENSHOT_PIXELS = 14_400_000; // 1440 × 10000, matching the export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024; const SCREENSHOT_TIMEOUT_MS = 10000; const SCREENSHOT_HEIGHT_PROBE_TIMEOUT_MS = 2_000; +// The reload triggered by a configured `themeStorageKey` (#4109) waits for the same network-idle signal as +// the initial navigation, with the same bound -- a reload is not expected to be any slower than the first load. +const THEME_STORAGE_RELOAD_TIMEOUT_MS = 20000; /** Per-call shot-route options: the R2 namespace (key prefix) + the production host for the on-demand render * allowlist. Defaults to gittensory so the /gittensory/shot route works with no options. */ @@ -243,6 +271,26 @@ export async function captureShot(env: Env, url: string, viewport: Viewport = VI console.log(JSON.stringify({ event: "render_screenshot_auth_walled", url, final: page.url().slice(0, 200) })); return { png: null, authWalled: true }; } + // A configured themeStorageKey (#4109) ALSO forces the theme via localStorage, then reloads so the + // app's own theme-init logic re-runs against the new stored value -- the fallback for a target whose + // theming ignores prefers-color-scheme (see CaptureShotOptions.theme's doc for what this fixes and why). + // Only after the safe-url/auth-wall checks above, so a page we're about to reject never pays for a reload. + if (opts.theme && opts.themeStorageKey) { + const storageKey = opts.themeStorageKey; + const storageValue = opts.theme; + await page.evaluate( + (key: string, value: string) => { + try { + (globalThis as unknown as { localStorage: Storage }).localStorage.setItem(key, value); + } catch { + // Storage can be unavailable (privacy mode, disabled storage, a cross-origin frame, etc.) -- best-effort only. + } + }, + storageKey, + storageValue, + ); + await page.reload({ waitUntil: "networkidle0", timeout: THEME_STORAGE_RELOAD_TIMEOUT_MS }); + } // Full-page (not just the viewport), but bounded: before/after should include the same page position for // normal review pages without letting attacker-controlled document height or PNG size drive unbounded // Chromium raster work on the public screenshot route. @@ -340,6 +388,24 @@ export async function captureScrollFrames(env: Env, url: string, viewport: Viewp console.log(JSON.stringify({ event: "render_scroll_frames_auth_walled", url, final: page.url().slice(0, 200) })); return { frames: [], authWalled: true }; } + // A configured themeStorageKey (#4109) ALSO forces the theme via localStorage, then reloads -- mirrors + // captureShot's own fallback exactly (see CaptureShotOptions.theme's doc for what this fixes and why). + if (opts.theme && opts.themeStorageKey) { + const storageKey = opts.themeStorageKey; + const storageValue = opts.theme; + await page.evaluate( + (key: string, value: string) => { + try { + (globalThis as unknown as { localStorage: Storage }).localStorage.setItem(key, value); + } catch { + // Storage can be unavailable (privacy mode, disabled storage, a cross-origin frame, etc.) -- best-effort only. + } + }, + storageKey, + storageValue, + ); + await page.reload({ waitUntil: "networkidle0", timeout: THEME_STORAGE_RELOAD_TIMEOUT_MS }); + } // `document`/`window` below run inside the real page (the callback is serialized and executed in the // browser realm, not this Worker/Node one) — this project's `lib` deliberately excludes `dom` (it would // shadow the Workers-runtime `Request`/`Response` globals used everywhere else), so these two reach the @@ -403,7 +469,9 @@ export async function handleShot(request: Request, env: Env, opts: ShotOptions = // Mode B: render on demand (host-allowlisted + SSRF-guarded). Optional &w=&h= selects the viewport; // optional &theme= (#3678) emulates prefers-color-scheme — an unrecognized value is ignored (falls back to - // no emulation) rather than rejecting the whole request over a cosmetic param. + // no emulation) rather than rejecting the whole request over a cosmetic param. Optional &themeStorageKey= + // (#4109) ALSO forces the theme via localStorage + reload — only applied alongside a recognized &theme=, + // same as capturePage's own guard. const target = params.get("url"); if (!target || !isSafeHttpUrl(target)) return new Response("bad url", { status: 400 }); if (!isAllowedHost(target, env, opts.productionUrl)) return new Response("forbidden host", { status: 403 }); @@ -412,7 +480,13 @@ export async function handleShot(request: Request, env: Env, opts: ShotOptions = const viewport: Viewport = Number.isFinite(w) && w > 0 && Number.isFinite(h) && h > 0 ? { width: Math.min(w, 2560), height: Math.min(h, 2560) } : DESKTOP_VIEWPORT; const requestedTheme = params.get("theme"); const theme: ShotTheme | undefined = requestedTheme === "light" || requestedTheme === "dark" ? requestedTheme : undefined; - const png = await renderScreenshot(env, target, viewport, { isAllowedUrl: (candidate) => isAllowedHost(candidate, env, opts.productionUrl), ...(theme ? { theme } : {}) }); + const requestedThemeStorageKey = params.get("themeStorageKey"); + const themeStorageKey: string | undefined = theme && requestedThemeStorageKey ? requestedThemeStorageKey : undefined; + const png = await renderScreenshot(env, target, viewport, { + isAllowedUrl: (candidate) => isAllowedHost(candidate, env, opts.productionUrl), + ...(theme ? { theme } : {}), + ...(themeStorageKey ? { themeStorageKey } : {}), + }); if (!png) return new Response("screenshot unavailable", { status: 502 }); // png is always a plain (never shared) ArrayBuffer view — the cast only narrows the TYPE for the UI // workspace's stricter DOM-lib BodyInit, which excludes SharedArrayBuffer from ArrayBufferLike. diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 5c373d6a53..7c50ca6056 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3726,6 +3726,7 @@ describe("review.visual (#3609 preview.url_template / #3610 routes)", () => { themes: [], gif: false, enabled: null, + themeStorageKey: null, }); expect(m.review.present).toBe(true); expect(parseFocusManifest({ review: reviewConfigToJson(m.review) }).review.visual).toEqual(m.review.visual); @@ -3821,7 +3822,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 }); + expect(resolveReviewVisualConfig(manifest)).toEqual({ preview: { urlTemplate: null }, routes: { paths: ["/app"], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null }); }); }); @@ -3906,7 +3907,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 }); + expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: true, enabled: null, themeStorageKey: null }); expect(reviewConfigToJson(m.review)).toEqual({ visual: { themes: ["dark"], gif: true } }); }); @@ -3971,6 +3972,67 @@ describe("review.visual.enabled (#4083 config-as-code enable/disable)", () => { }); }); +describe("review.visual.theme_storage_key (#4109 localStorage theme-forcing fallback)", () => { + it("parses theme_storage_key, marks present, and round-trips", () => { + const m = parseFocusManifest({ review: { visual: { theme_storage_key: "theme" } } }); + expect(m.review.visual.themeStorageKey).toBe("theme"); + expect(m.review.present).toBe(true); + expect(reviewConfigToJson(m.review)).toEqual({ visual: { theme_storage_key: "theme" } }); + }); + + it("absent theme_storage_key stays null and does not mark review present on its own", () => { + expect(parseFocusManifest({}).review.visual.themeStorageKey).toBeNull(); + expect(parseFocusManifest({ review: { visual: {} } }).review.present).toBe(false); + }); + + it("null theme_storage_key does not serialize into the round-tripped visual block", () => { + const m = parseFocusManifest({ review: { visual: { gif: true } } }); + expect(m.review.visual.themeStorageKey).toBeNull(); + expect(reviewConfigToJson(m.review)).toEqual({ visual: { gif: true } }); + }); + + it("drops a non-public-safe value with a warning and falls back to null", () => { + const bad = parseFocusManifest({ review: { visual: { theme_storage_key: "reward payout" } } }); + expect(bad.review.visual.themeStorageKey).toBeNull(); + expect(bad.warnings.some((w) => /review\.visual\.theme_storage_key.*not public-safe/.test(w))).toBe(true); + }); + + it("warns and defaults to null when theme_storage_key is not a string", () => { + const bad = parseFocusManifest({ review: { visual: { theme_storage_key: 42 } } }); + expect(bad.review.visual.themeStorageKey).toBeNull(); + expect(bad.warnings.some((w) => /theme_storage_key.*must be a non-empty string/.test(w))).toBe(true); + }); + + it("marks present via theme_storage_key alone (preview + routes + themes + gif + enabled all empty)", () => { + const m = parseFocusManifest({ review: { visual: { theme_storage_key: "colorMode" } } }); + expect(m.review.present).toBe(true); + }); + + 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(reviewConfigToJson(m.review)).toEqual({ visual: { themes: ["dark"], theme_storage_key: "theme" } }); + }); + + it("resolveReviewVisualConfig passes a configured theme_storage_key through", () => { + const manifest = parseFocusManifest({ review: { visual: { theme_storage_key: "theme" } } }); + expect(resolveReviewVisualConfig(manifest).themeStorageKey).toBe("theme"); + }); + + it("overlay: a per-repo theme_storage_key wins over a global-default value", () => { + const globalDefault = parseReviewConfigMapping({ visual: { theme_storage_key: "theme" } }, []); + const perRepo = parseReviewConfigMapping({ visual: { theme_storage_key: "colorMode" } }, []); + expect(overlayReviewConfig(globalDefault, perRepo).visual.themeStorageKey).toBe("colorMode"); + }); + + it("overlay: an unset per-repo theme_storage_key falls back to the global-default value", () => { + const globalDefault = parseReviewConfigMapping({ visual: { theme_storage_key: "theme" } }, []); + const perRepo = parseReviewConfigMapping({ visual: { routes: { paths: ["/app"] } } }, []); + expect(overlayReviewConfig(globalDefault, perRepo).visual.themeStorageKey).toBe("theme"); + 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 9ba69cdaa8..3865077d0d 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 }, 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 }, 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 8806b38a36..d2528493b2 100644 --- a/test/unit/visual-capture.test.ts +++ b/test/unit/visual-capture.test.ts @@ -746,6 +746,122 @@ describe("buildCapture theme matrix (#3678)", () => { }); }); +describe("buildCapture theme-storage-key wiring (#4109)", () => { + it("passes themeStorageKey through to captureShot's render options when both themes and theme_storage_key are configured", async () => { + const captureShotSpy = vi.spyOn(shotModule, "captureShot").mockResolvedValue({ png: null, authWalled: false }); + try { + await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://prod.example.com", REVIEW_AUDIT: memoryReviewAudit() }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 40, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { themes: ["dark"], themeStorageKey: "theme" }, + ); + expect(captureShotSpy).toHaveBeenCalled(); + const themedCall = captureShotSpy.mock.calls.find(([, , , opts]) => opts?.theme === "dark" && opts?.themeStorageKey === "theme"); + expect(themedCall).toBeDefined(); + } finally { + captureShotSpy.mockRestore(); + } + }); + + it("never passes themeStorageKey to captureShot when no themes are configured, even if theme_storage_key is set", async () => { + const captureShotSpy = vi.spyOn(shotModule, "captureShot").mockResolvedValue({ png: null, authWalled: false }); + try { + await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://prod.example.com", REVIEW_AUDIT: memoryReviewAudit() }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 41, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { themeStorageKey: "theme" }, + ); + expect(captureShotSpy).toHaveBeenCalled(); + expect(captureShotSpy.mock.calls.every(([, , , opts]) => !opts?.theme && !opts?.themeStorageKey)).toBe(true); + } finally { + captureShotSpy.mockRestore(); + } + }); + + it("carries the theme storage key in the on-demand URL fallback so a later GitHub-image-proxy retry still forces it", async () => { + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://prod.example.com" }), // no REVIEW_AUDIT -> always the on-demand fallback + "installation-token", + { repoFullName: "owner/repo", prNumber: 44, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { themes: ["dark"], themeStorageKey: "theme" }, + ); + expect(result.routes[0]?.beforeUrl).toBe( + `https://worker.example/gittensory/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900&theme=dark&themeStorageKey=${encodeURIComponent("theme")}`, + ); + }); + + it("omits themeStorageKey from the on-demand URL fallback when no theme is configured", async () => { + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://prod.example.com" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 45, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { themeStorageKey: "theme" }, + ); + expect(result.routes[0]?.beforeUrl).toBe(`https://worker.example/gittensory/shot?url=${encodeURIComponent("https://prod.example.com/app")}&w=1440&h=900`); + }); + + it("threads the theme storage key into the shot fingerprint too, so it never collides with an untagged-key capture of the same theme", async () => { + const captureShotSpy = vi.spyOn(shotModule, "captureShot").mockResolvedValue({ png: new Uint8Array([9, 9, 9]), authWalled: false }); + 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: 42, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { themes: ["dark"], themeStorageKey: "theme" }, + ); + expect(result.routes[0]?.theme).toBe("dark"); + expect(result.routes[0]?.beforeUrl).toContain("/gittensory/shot?key="); + // Same PR/path/theme, but tagged with a storage key — must not reuse the untagged-key fingerprint. + const untaggedFingerprint = await sha256Hex(`42:before:desktop:https://prod.example.com/app:dark`); + expect(result.routes[0]?.beforeUrl).not.toContain(untaggedFingerprint.slice(0, 40)); + } finally { + captureShotSpy.mockRestore(); + } + }); + + it("threads the theme storage key into the scroll-GIF fingerprint too, so it never collides with an untagged-key GIF", async () => { + 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({ 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: 43, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { gif: true, themes: ["dark"], themeStorageKey: "theme" }, + ); + expect(result.routes[0]?.theme).toBe("dark"); + expect(result.routes[0]?.afterGifUrl).toContain("/gittensory/shot?key="); + const untaggedFingerprint = await sha256Hex(`43:scrollgif:after:desktop:https://preview.example.com/app:dark`); + expect(result.routes[0]?.afterGifUrl).not.toContain(untaggedFingerprint.slice(0, 40)); + expect(captureScrollSpy.mock.calls.some(([, , , opts]) => opts?.themeStorageKey === "theme")).toBe(true); + } finally { + gifAvailableSpy.mockRestore(); + captureScrollSpy.mockRestore(); + encodeSpy.mockRestore(); + } + }); +}); + describe("buildCapture scroll-GIF wiring (#3612)", () => { it("never captures scroll frames when review.visual.gif is unset, even when isScrollGifAvailable is true", async () => { const gifAvailableSpy = vi.spyOn(scrollGifModule, "isScrollGifAvailable").mockReturnValue(true); diff --git a/test/unit/visual-config-wiring.test.ts b/test/unit/visual-config-wiring.test.ts index eb958cc21b..0497cb40af 100644 --- a/test/unit/visual-config-wiring.test.ts +++ b/test/unit/visual-config-wiring.test.ts @@ -21,6 +21,7 @@ describe("review.visual wiring (#3609 / #3610)", () => { themes: [], gif: false, enabled: null, + themeStorageKey: null, }); expect(loadSpy).toHaveBeenCalledWith(expect.anything(), "acme/widgets"); loadSpy.mockRestore(); diff --git a/test/unit/visual-shot.test.ts b/test/unit/visual-shot.test.ts index 8457bea743..345cb8d796 100644 --- a/test/unit/visual-shot.test.ts +++ b/test/unit/visual-shot.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ close: vi.fn(async () => undefined), launch: vi.fn(), emulateMediaFeatures: vi.fn(async () => undefined), + reload: vi.fn(async () => undefined), evaluate: vi.fn(), // captureScrollFrames' FIRST page.evaluate() call queries scrollHeight; every later call (scrollTo, the // settle delay) discards its return value — so only the first call's resolved value matters to the code @@ -85,7 +86,11 @@ describe("visual screenshot on-demand SSRF guard", () => { } catch { // expected — see above. } - return mocks.evaluateCallCount === 1 ? mocks.scrollHeight : undefined; + // The height/scrollHeight probe is the only zero-arg evaluate() call in either function — everything + // else (scrollTo, the #4109 localStorage-forcing callback) always passes at least one extra arg. Keying + // off arg count (not call order) keeps this resolvable regardless of whether a themeStorageKey-forcing + // evaluate() call runs BEFORE the height probe, which it now can (#4109). + return fnArgs.length === 0 ? mocks.scrollHeight : undefined; }); mocks.launch.mockImplementation(async () => { let onRequest: ((request: ReturnType) => void) | undefined; @@ -101,6 +106,7 @@ describe("visual screenshot on-demand SSRF guard", () => { onRequest?.(makeRequest(url)); if (mocks.finalUrl !== url) onRequest?.(makeRequest(mocks.finalUrl)); }), + reload: mocks.reload, url: vi.fn(() => mocks.finalUrl), screenshot: mocks.screenshot, evaluate: mocks.evaluate, @@ -286,6 +292,40 @@ describe("visual screenshot on-demand SSRF guard", () => { expect(mocks.emulateMediaFeatures).not.toHaveBeenCalled(); }); + it("forces the theme via localStorage.setItem + reload when both theme and themeStorageKey are set (#4109)", async () => { + mocks.finalUrl = "https://preview.pages.dev/page"; + await captureShot(env(), "https://preview.pages.dev/page", undefined, { theme: "dark", themeStorageKey: "theme" }); + expect(mocks.evaluate).toHaveBeenCalledWith(expect.any(Function), "theme", "dark"); + expect(mocks.reload).toHaveBeenCalledWith({ waitUntil: "networkidle0", timeout: 20000 }); + }); + + it("never forces a theme via localStorage/reload when no themeStorageKey is configured — byte-identical to pre-#4109", async () => { + mocks.finalUrl = "https://preview.pages.dev/page"; + await captureShot(env(), "https://preview.pages.dev/page", undefined, { theme: "dark" }); + expect(mocks.reload).not.toHaveBeenCalled(); + }); + + it("never forces a theme via localStorage/reload when themeStorageKey is set but theme is not (#4109)", async () => { + mocks.finalUrl = "https://preview.pages.dev/page"; + await captureShot(env(), "https://preview.pages.dev/page", undefined, { themeStorageKey: "theme" }); + expect(mocks.emulateMediaFeatures).not.toHaveBeenCalled(); + expect(mocks.reload).not.toHaveBeenCalled(); + }); + + it("handleShot's on-demand render reads &themeStorageKey= only alongside a recognized &theme= (#4109)", async () => { + mocks.finalUrl = "https://preview.pages.dev/page"; + const response = await handleShot(shotRequest(`url=${encodeURIComponent("https://preview.pages.dev/page")}&theme=dark&themeStorageKey=theme`), env()); + expect(response.status).toBe(200); + expect(mocks.reload).toHaveBeenCalledWith({ waitUntil: "networkidle0", timeout: 20000 }); + }); + + it("handleShot ignores &themeStorageKey= when &theme= is absent (#4109)", async () => { + mocks.finalUrl = "https://preview.pages.dev/page"; + const response = await handleShot(shotRequest(`url=${encodeURIComponent("https://preview.pages.dev/page")}&themeStorageKey=theme`), env()); + expect(response.status).toBe(200); + expect(mocks.reload).not.toHaveBeenCalled(); + }); + it("captureShot rejects an unsafe target before launching the browser (defense-in-depth)", async () => { const result = await captureShot(env(), "http://127.0.0.1/admin"); expect(result).toEqual({ png: null, authWalled: false }); @@ -341,7 +381,11 @@ describe("captureScrollFrames (#3612 scroll-through GIF evidence)", () => { } catch { // expected — see above. } - return mocks.evaluateCallCount === 1 ? mocks.scrollHeight : undefined; + // The height/scrollHeight probe is the only zero-arg evaluate() call in either function — everything + // else (scrollTo, the #4109 localStorage-forcing callback) always passes at least one extra arg. Keying + // off arg count (not call order) keeps this resolvable regardless of whether a themeStorageKey-forcing + // evaluate() call runs BEFORE the height probe, which it now can (#4109). + return fnArgs.length === 0 ? mocks.scrollHeight : undefined; }); mocks.launch.mockImplementation(async () => { let onRequest: ((request: ReturnType) => void) | undefined; @@ -357,6 +401,7 @@ describe("captureScrollFrames (#3612 scroll-through GIF evidence)", () => { onRequest?.(makeRequest(url)); if (mocks.finalUrl !== url) onRequest?.(makeRequest(mocks.finalUrl)); }), + reload: mocks.reload, url: vi.fn(() => mocks.finalUrl), screenshot: mocks.screenshot, evaluate: mocks.evaluate, @@ -393,6 +438,17 @@ describe("captureScrollFrames (#3612 scroll-through GIF evidence)", () => { expect(mocks.emulateMediaFeatures).toHaveBeenCalledWith([{ name: "prefers-color-scheme", value: "dark" }]); }); + it("forces the theme via localStorage.setItem + reload when both theme and themeStorageKey are set, mirroring captureShot (#4109)", async () => { + await captureScrollFrames(env(), "https://preview.pages.dev/page", { width: 1440, height: 900 }, { theme: "dark", themeStorageKey: "theme" }); + expect(mocks.evaluate).toHaveBeenCalledWith(expect.any(Function), "theme", "dark"); + expect(mocks.reload).toHaveBeenCalledWith({ waitUntil: "networkidle0", timeout: 20000 }); + }); + + it("never forces a theme via localStorage/reload when no themeStorageKey is configured — byte-identical to pre-#4109", async () => { + await captureScrollFrames(env(), "https://preview.pages.dev/page", { width: 1440, height: 900 }, { theme: "dark" }); + expect(mocks.reload).not.toHaveBeenCalled(); + }); + it("returns no frames when a redirect leads to a private endpoint", async () => { mocks.finalUrl = "http://127.0.0.1/admin"; const result = await captureScrollFrames(env(), "https://attacker.workers.dev/redirect", { width: 1440, height: 900 });