diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index e07190bedd..46c278def5 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -719,6 +719,13 @@ export const EMPTY_SELF_HOST_AI_MODEL_CONFIG: SelfHostAiModelConfig = { /** Per-repo before/after screenshot-capture config under `review.visual` (#3609 / #3610). Generic by design — * every self-hoster wires their OWN repo's preview-deploy setup and route shape with config, not code. */ export type VisualConfig = { + /** `review.visual.production_url`: the repo's "before" production URL — e.g. `https://metagraph.sh` for a + * repo whose live site differs from the operator's own `PUBLIC_SITE_ORIGIN` env var (a single GLOBAL value + * with no per-repo awareness, correct for at most one repo on a multi-repo self-host instance). ALWAYS wins + * over `PUBLIC_SITE_ORIGIN` when set, mirroring `preview.url_template`'s precedence over GitHub-native + * discovery. null (default) ⇒ byte-identical to today (falls back to `PUBLIC_SITE_ORIGIN`). Validated at + * parse time against the same SSRF guard (`isSafeHttpUrl`) the renderer itself unconditionally applies. */ + productionUrl: string | null; preview: VisualPreviewConfig; routes: VisualRoutesConfig; themes: VisualTheme[]; @@ -787,6 +794,7 @@ export type VisualRoutesConfig = { }; export const EMPTY_VISUAL_CONFIG: VisualConfig = { + productionUrl: null, preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], @@ -2301,6 +2309,7 @@ function overlaySelfHostAiModelConfig(base: SelfHostAiModelConfig, override: Sel function overlayVisualConfig(base: VisualConfig, override: VisualConfig): VisualConfig { return { + productionUrl: pickOverlayNullable(override.productionUrl, base.productionUrl), preview: { urlTemplate: pickOverlayNullable(override.preview.urlTemplate, base.preview.urlTemplate) }, routes: { paths: pickOverlayStringList(override.routes.paths, base.routes.paths), @@ -2543,6 +2552,7 @@ function parseSelfHostAiModelConfig(value: JsonValue | undefined, warnings: stri function visualConfigPresent(config: VisualConfig): boolean { return ( + config.productionUrl !== null || config.preview.urlTemplate !== null || config.routes.paths.length > 0 || config.routes.maxRoutes !== null || @@ -2588,6 +2598,20 @@ const VISUAL_URL_TEMPLATE_DUMMY_VARS: Record = { "{head_sha}": "0000000000000000000000000000000000000000", }; +/** Parse `review.visual.production_url` — validated at CONFIG-READ time against the exact same SSRF guard + * (`isSafeHttpUrl`) the renderer itself unconditionally applies to every URL it navigates to. Unlike + * `preview.url_template`, this is a plain static origin with no `{number}`/`{head_sha}` placeholders to + * substitute — the "before" shot is always the SAME production page, just at a different path per route. */ +function parseVisualProductionUrl(value: JsonValue | undefined, warnings: string[]): string | null { + const url = parsePublicSafeText(value, "review.visual.production_url", warnings); + if (url === null) return null; + if (!isSafeHttpUrl(url)) { + warnings.push(`Manifest "review.visual.production_url" must be a valid HTTPS URL targeting a public host; ignoring it.`); + return null; + } + return url; +} + /** Parse `review.visual.preview.url_template` — validated at CONFIG-READ time against the exact same SSRF * guard (`isSafeHttpUrl`) the renderer itself unconditionally applies to every URL it navigates to, * regardless of source (`src/review/visual/shot.ts`). This is deliberately redundant with that runtime @@ -2617,6 +2641,8 @@ function parseVisualConfig(value: JsonValue | undefined, warnings: string[]): Vi } const record = value as Record; + const productionUrl = parseVisualProductionUrl(record.production_url, warnings); + const previewRecord = record.preview !== null && typeof record.preview === "object" && !Array.isArray(record.preview) ? (record.preview as Record) : undefined; if (record.preview !== undefined && record.preview !== null && previewRecord === undefined) { warnings.push(`Manifest "review.visual.preview" must be a mapping; ignoring it.`); @@ -2636,7 +2662,7 @@ function parseVisualConfig(value: JsonValue | undefined, warnings: string[]): Vi const themeStorageKey = parsePublicSafeText(record.theme_storage_key, "review.visual.theme_storage_key", warnings); const actionsFallback = normalizeOptionalBoolean(record.actions_fallback, "review.visual.actions_fallback", warnings) === true; - return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled, themeStorageKey, actionsFallback }; + return { productionUrl, preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled, themeStorageKey, actionsFallback }; } function parseAutoReviewTitleKeywords(value: JsonValue | undefined, warnings: string[]): string[] { @@ -2943,6 +2969,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue } if (visualConfigPresent(review.visual)) { const visual: Record = {}; + if (review.visual.productionUrl !== null) visual.production_url = review.visual.productionUrl; if (review.visual.preview.urlTemplate !== null) visual.preview = { url_template: review.visual.preview.urlTemplate }; if (review.visual.routes.paths.length > 0 || review.visual.routes.maxRoutes !== null) { const routes: Record = {}; diff --git a/src/review/visual/capture.ts b/src/review/visual/capture.ts index 8f5ac2fa18..2a2bc5af39 100644 --- a/src/review/visual/capture.ts +++ b/src/review/visual/capture.ts @@ -1,6 +1,7 @@ // Realtime visual capture (reviewbot→gittensory convergence — visual port). taopedia-style before/after. // -// before = production (PUBLIC_SITE_ORIGIN); after = the PR's preview-deploy URL, discovered the +// before = production (review.visual.production_url, falling back to the global PUBLIC_SITE_ORIGIN env var); +// after = the PR's preview-deploy URL, discovered the // provider-agnostic way (Deployments API → commit checks → cloudflare-bot PR comment). Each page is // rendered once here (in the queue consumer, which has the time budget), stored as a PNG in R2 // (env.REVIEW_AUDIT), and embedded either as /gittensory/shot?key= (this @@ -30,7 +31,11 @@ import { encodeScrollGif, isScrollGifAvailable } from "./scroll-gif"; const NAMESPACE = "gittensory"; const DEFAULT_ROUTES = ["/"]; -const DEFAULT_ROUTE_FILE = /apps\/gittensory-ui\/src\/routes\/(.+?)\.(?:tsx|jsx)$/i; +// The app-folder segment is a wildcard, not hardcoded to gittensory-ui: metagraphed's UI (apps/ui/src/routes/) +// uses the identical TanStack flat-file convention `routeForFile` below implements, just under a different app +// folder name. Only ever matched against the CURRENT repo's own changed-file paths (see mapFilesToRoutes' +// caller), so widening this carries no cross-repo ambiguity risk. +const DEFAULT_ROUTE_FILE = /apps\/[^/]+\/src\/routes\/(.+?)\.(?:tsx|jsx)$/i; // Each route renders desktop + mobile for before + after (up to 4 PNGs). Cap routes to bound browser-render // wall-clock — Browser Rendering is the costliest binding. const MAX_ROUTES = 2; @@ -368,6 +373,11 @@ async function captureScrollGif( * #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 = { + /** `review.visual.production_url` (#3611 follow-up): overrides `env.PUBLIC_SITE_ORIGIN` (a single GLOBAL + * value with no per-repo awareness) as the "before" base for THIS repo. ALWAYS wins when set, mirroring + * `preview.urlTemplate`'s precedence over discovery. null/undefined ⇒ falls back to `env.PUBLIC_SITE_ORIGIN`, + * byte-identical to today. */ + productionUrl?: string | null | undefined; preview?: VisualPreviewInput | null | undefined; routes?: VisualRoutesInput | null | undefined; themes?: readonly ShotTheme[] | null | undefined; @@ -391,8 +401,10 @@ export type VisualCaptureConfig = { export async function buildCapture(env: Env, token: string, target: CaptureTarget, visualFiles: string[], rateLimitAdmissionKey?: GitHubRateLimitAdmissionKey | undefined, visualConfig?: VisualCaptureConfig | null | undefined): Promise { const repo = parseRepo(target.repoFullName); const apiVersion = "2022-11-28"; - // before = production (PUBLIC_SITE_ORIGIN, e.g. https://gittensory.aethereal.dev). - const prodBase = env.PUBLIC_SITE_ORIGIN ?? ""; + // before = production. review.visual.production_url (#3611 follow-up) ALWAYS wins when set -- PUBLIC_SITE_ORIGIN + // is a single GLOBAL env var (e.g. https://gittensory.aethereal.dev) with no per-repo awareness, correct for at + // most one repo on a multi-repo self-host instance; every other repo needs its own override here. + const prodBase = visualConfig?.productionUrl ? visualConfig.productionUrl : (env.PUBLIC_SITE_ORIGIN ?? ""); // after = the PR's preview deploy. An explicit review.visual.preview.url_template (#3609) ALWAYS wins — // a maintainer-configured template is a stronger signal than inference, and is the only option for a diff --git a/src/review/visual/paths.ts b/src/review/visual/paths.ts index 287e7cff3f..29ac0b011f 100644 --- a/src/review/visual/paths.ts +++ b/src/review/visual/paths.ts @@ -1,15 +1,17 @@ // Visual-path classifier (reviewbot→gittensory convergence — visual capture port). // // PORTED VERBATIM from reviewbot's src/agents/gittensory/capabilities.ts `isVisualPath` (the three -// VISUAL_PATTERNS). This is the EMPHATIC gate: screenshots fire ONLY for WEB-VISIBLE changes — a -// frontend page (apps/gittensory-ui/**), a public asset (public/**, e.g. an OG image), or a -// front-of-house source extension (.tsx/.jsx/.css/.scss/.sass/.less/.html/.svg/.astro/.vue/.svelte/.mdx). -// A backend change (.ts/.md/.json/.py/...) matches NONE of these, so capture never triggers for it. +// VISUAL_PATTERNS), with the first pattern's app-folder segment widened to a wildcard (#3611 follow-up) so it +// isn't gittensory-ui-only — see capture.ts's DEFAULT_ROUTE_FILE for the same generalization. This is the +// EMPHATIC gate: screenshots fire ONLY for WEB-VISIBLE changes — any frontend app folder (apps/*/**, e.g. +// apps/gittensory-ui/** or apps/ui/**), a public asset (public/**, e.g. an OG image), or a front-of-house +// source extension (.tsx/.jsx/.css/.scss/.sass/.less/.html/.svg/.astro/.vue/.svelte/.mdx). A backend change +// (.ts/.md/.json/.py/...) matches NONE of these, so capture never triggers for it. // // PURE — no imports, no I/O. Callers MUST filter changed files through this before any capture. const VISUAL_PATTERNS: RegExp[] = [ - /^apps\/gittensory-ui\//i, + /^apps\/[^/]+\//i, /(^|\/)public\//i, /\.(tsx|jsx|css|scss|sass|less|html|svg|astro|vue|svelte|mdx)$/i, ]; diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index b1f38d5230..65fafcedc7 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3988,6 +3988,7 @@ describe("review.visual (#3609 preview.url_template / #3610 routes)", () => { }, }); expect(m.review.visual).toEqual({ + productionUrl: null, preview: { urlTemplate: "https://pr-{number}.preview.example.com" }, routes: { paths: ["/pricing", "/docs"], maxRoutes: 3 }, themes: [], @@ -4090,7 +4091,68 @@ describe("review.visual (#3609 preview.url_template / #3610 routes)", () => { it("resolveReviewVisualConfig: null manifest yields empty defaults; a set manifest passes through", () => { expect(resolveReviewVisualConfig(null)).toEqual({ ...EMPTY_VISUAL_CONFIG }); const manifest = parseFocusManifest({ review: { visual: { routes: { paths: ["/app"] } } } }); - expect(resolveReviewVisualConfig(manifest)).toEqual({ preview: { urlTemplate: null }, routes: { paths: ["/app"], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null, actionsFallback: false }); + expect(resolveReviewVisualConfig(manifest)).toEqual({ productionUrl: null, preview: { urlTemplate: null }, routes: { paths: ["/app"], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null, actionsFallback: false }); + }); +}); + +describe("review.visual.production_url (#3611 follow-up — per-repo override of the global PUBLIC_SITE_ORIGIN env var)", () => { + it("parses a valid production_url, marks present, and round-trips", () => { + const m = parseFocusManifest({ review: { visual: { production_url: "https://metagraph.sh" } } }); + expect(m.review.visual.productionUrl).toBe("https://metagraph.sh"); + expect(m.review.present).toBe(true); + expect(reviewConfigToJson(m.review)).toEqual({ visual: { production_url: "https://metagraph.sh" } }); + }); + + it("absent production_url stays null and does not mark review present on its own", () => { + expect(parseFocusManifest({}).review.visual.productionUrl).toBeNull(); + expect(parseFocusManifest({ review: { visual: {} } }).review.present).toBe(false); + }); + + it("rejects a non-HTTPS production_url with a warning", () => { + const bad = parseFocusManifest({ review: { visual: { production_url: "http://metagraph.sh" } } }); + expect(bad.review.visual.productionUrl).toBeNull(); + expect(bad.warnings.some((w) => /review\.visual\.production_url.*valid HTTPS URL/.test(w))).toBe(true); + }); + + it("rejects a production_url resolving to a private/internal host with a warning", () => { + const bad = parseFocusManifest({ review: { visual: { production_url: "https://prod.internal" } } }); + expect(bad.review.visual.productionUrl).toBeNull(); + expect(bad.warnings.some((w) => /review\.visual\.production_url.*valid HTTPS URL/.test(w))).toBe(true); + }); + + it("rejects a malformed production_url with a warning", () => { + const bad = parseFocusManifest({ review: { visual: { production_url: "not-a-url-at-all" } } }); + expect(bad.review.visual.productionUrl).toBeNull(); + expect(bad.warnings.some((w) => /review\.visual\.production_url.*valid HTTPS URL/.test(w))).toBe(true); + }); + + it("composes with preview.url_template — both configured independently and both round-trip", () => { + const m = parseFocusManifest({ + review: { visual: { production_url: "https://metagraph.sh", preview: { url_template: "https://pr-{number}.example.com" } } }, + }); + expect(m.review.visual.productionUrl).toBe("https://metagraph.sh"); + expect(m.review.visual.preview.urlTemplate).toBe("https://pr-{number}.example.com"); + expect(reviewConfigToJson(m.review)).toEqual({ + visual: { production_url: "https://metagraph.sh", preview: { url_template: "https://pr-{number}.example.com" } }, + }); + }); + + it("resolveReviewVisualConfig passes a configured production_url through", () => { + const manifest = parseFocusManifest({ review: { visual: { production_url: "https://metagraph.sh" } } }); + expect(resolveReviewVisualConfig(manifest).productionUrl).toBe("https://metagraph.sh"); + }); + + it("overlay: a per-repo production_url wins over a global-default value", () => { + const globalDefault = parseReviewConfigMapping({ visual: { production_url: "https://gittensory.aethereal.dev" } }, []); + const perRepo = parseReviewConfigMapping({ visual: { production_url: "https://metagraph.sh" } }, []); + expect(overlayReviewConfig(globalDefault, perRepo).visual.productionUrl).toBe("https://metagraph.sh"); + }); + + it("overlay: an unset per-repo production_url falls back to the global-default value", () => { + const globalDefault = parseReviewConfigMapping({ visual: { production_url: "https://gittensory.aethereal.dev" } }, []); + const perRepo = parseReviewConfigMapping({ visual: { routes: { paths: ["/app"] } } }, []); + expect(overlayReviewConfig(globalDefault, perRepo).visual.productionUrl).toBe("https://gittensory.aethereal.dev"); + expect(overlayReviewConfig(globalDefault, perRepo).visual.routes.paths).toEqual(["/app"]); }); }); @@ -4175,7 +4237,7 @@ describe("review.visual.gif (#3612 scroll-through GIF capture)", () => { it("composes with themes — both configured independently and both round-trip", () => { const m = parseFocusManifest({ review: { visual: { gif: true, themes: ["dark"] } } }); - expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: true, enabled: null, themeStorageKey: null, actionsFallback: false }); + expect(m.review.visual).toEqual({ productionUrl: null, preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: true, enabled: null, themeStorageKey: null, actionsFallback: false }); expect(reviewConfigToJson(m.review)).toEqual({ visual: { themes: ["dark"], gif: true } }); }); @@ -4278,7 +4340,7 @@ describe("review.visual.theme_storage_key (#4109 localStorage theme-forcing fall it("composes with themes — both configured independently and both round-trip", () => { const m = parseFocusManifest({ review: { visual: { themes: ["dark"], theme_storage_key: "theme" } } }); - expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: false, enabled: null, themeStorageKey: "theme", actionsFallback: false }); + expect(m.review.visual).toEqual({ productionUrl: null, preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: false, enabled: null, themeStorageKey: "theme", actionsFallback: false }); expect(reviewConfigToJson(m.review)).toEqual({ visual: { themes: ["dark"], theme_storage_key: "theme" } }); }); @@ -4333,7 +4395,7 @@ describe("review.visual.actions_fallback (#4112 GitHub-Actions build-and-serve f it("composes with gif — both configured independently and both round-trip", () => { const m = parseFocusManifest({ review: { visual: { actions_fallback: true, gif: true } } }); - expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: true, enabled: null, themeStorageKey: null, actionsFallback: true }); + expect(m.review.visual).toEqual({ productionUrl: null, preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: true, enabled: null, themeStorageKey: null, actionsFallback: true }); expect(reviewConfigToJson(m.review)).toEqual({ visual: { gif: true, actions_fallback: true } }); }); diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 2107170e1a..0a04d33f39 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, e2eTestDelivery: null, pathInstructions: [], instructions: null, excludePaths: [], pathFilters: [], preMergeChecks: [], autoReview: { skipDrafts: null, ignoreAuthors: [], ignoreTitleKeywords: [], skipLabels: [], skipDocsOnly: null, maxAddedLines: 0, maxFiles: 0, baseBranches: [], autoPauseAfterReviewedCommits: null }, labelingRules: [], aiModel: { claudeModel: null, claudeEffort: null, codexModel: null, codexEffort: null, ollamaModel: null, openaiModel: null, openaiCompatibleModel: null, anthropicModel: null }, visual: { preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null, actionsFallback: false }, linkedIssueSatisfaction: null, sharedConfigSource: null }, + 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, e2eTestDelivery: 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: { productionUrl: null, preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: [], gif: false, enabled: null, themeStorageKey: null, actionsFallback: false }, linkedIssueSatisfaction: null, sharedConfigSource: null }, aiReview: { notes: "The change is focused.\n\n**Nits (2)**\n- Add a test for the edge case.\n- Keep the validator helper scoped." }, }); expect(customizedComment).toContain("Reviewed by the Acme maintainer bot."); // custom footer lead diff --git a/test/unit/visual-capture.test.ts b/test/unit/visual-capture.test.ts index 76326e2646..1b4a04f59e 100644 --- a/test/unit/visual-capture.test.ts +++ b/test/unit/visual-capture.test.ts @@ -391,6 +391,67 @@ describe("mapFilesToRoutes maxRoutes parameter", () => { }); }); +describe("mapFilesToRoutes app-folder generalization (#3611 follow-up)", () => { + it("maps metagraphed-style apps/ui/src/routes/** files the same way as apps/gittensory-ui/** (identical TanStack flat-file convention, different app folder name)", () => { + expect(mapFilesToRoutes(["apps/ui/src/routes/settings.tsx"])).toEqual(["/settings"]); + expect(mapFilesToRoutes(["apps/ui/src/routes/accounts.index.tsx"])).toEqual(["/accounts"]); + }); + + it("still falls back to '/' for a file that matches no app-folder-routes pattern at all", () => { + expect(mapFilesToRoutes(["src/components/Widget.tsx"])).toEqual(["/"]); + }); +}); + +describe("review.visual.production_url (#3611 follow-up)", () => { + it("prefers visualConfig.productionUrl over the global PUBLIC_SITE_ORIGIN env var for the 'before' shot", async () => { + const result = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://gittensory.example.com" }), + "installation-token", + { repoFullName: "owner/metagraphed", prNumber: 50, previewUrl: "https://preview.example.com" }, + ["apps/ui/src/routes/index.tsx"], + undefined, + { productionUrl: "https://metagraph.example.com" }, + ); + expect(result.routes[0]?.beforeUrl).toContain(encodeURIComponent("https://metagraph.example.com/")); + expect(result.routes[0]?.beforeUrl).not.toContain(encodeURIComponent("https://gittensory.example.com")); + }); + + it("falls back to the global PUBLIC_SITE_ORIGIN when visualConfig.productionUrl is null/unset", async () => { + const withNull = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://gittensory.example.com" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 51, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { productionUrl: null }, + ); + expect(withNull.routes[0]?.beforeUrl).toContain(encodeURIComponent("https://gittensory.example.com/app")); + + const withoutConfig = await buildCapture( + createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example", PUBLIC_SITE_ORIGIN: "https://gittensory.example.com" }), + "installation-token", + { repoFullName: "owner/repo", prNumber: 52, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + ); + expect(withoutConfig.routes[0]?.beforeUrl).toContain(encodeURIComponent("https://gittensory.example.com/app")); + }); + + it("degrades to an empty 'before' base (no page, no shot) when NEITHER productionUrl nor PUBLIC_SITE_ORIGIN is set at all", async () => { + const env = createTestEnv({ PUBLIC_API_ORIGIN: "https://worker.example" }); + delete (env as Partial).PUBLIC_SITE_ORIGIN; + const result = await buildCapture( + env, + "installation-token", + { repoFullName: "owner/repo", prNumber: 53, previewUrl: "https://preview.example.com" }, + ["apps/gittensory-ui/src/routes/app.index.tsx"], + undefined, + { productionUrl: null }, + ); + expect(result.routes[0]?.beforeUrl).toBeUndefined(); + expect(result.routes[0]?.beforeUrlMobile).toBeUndefined(); + }); +}); + describe("buildCapture pixel-diff wiring (#3674)", () => { it("never calls the diff provider when diffing is unavailable (the real, unmocked default) — byte-identical to pre-#3674", async () => { const availableSpy = vi.spyOn(pixelDiffModule, "isVisualDiffAvailable"); diff --git a/test/unit/visual-config-wiring.test.ts b/test/unit/visual-config-wiring.test.ts index d9991259f9..a3da8ffca7 100644 --- a/test/unit/visual-config-wiring.test.ts +++ b/test/unit/visual-config-wiring.test.ts @@ -16,6 +16,7 @@ describe("review.visual wiring (#3609 / #3610)", () => { const loadSpy = vi.spyOn(focusManifestLoader, "loadRepoFocusManifest").mockResolvedValue(manifest); await expect(resolveVisualCaptureConfig({} as Env, "acme/widgets")).resolves.toEqual({ + productionUrl: null, preview: { urlTemplate: "https://pr-{number}.preview.example.com" }, routes: { paths: ["/pricing"], maxRoutes: 3 }, themes: [], diff --git a/test/unit/visual-paths.test.ts b/test/unit/visual-paths.test.ts index a9d021b54b..785e621049 100644 --- a/test/unit/visual-paths.test.ts +++ b/test/unit/visual-paths.test.ts @@ -10,6 +10,13 @@ describe("isVisualPath (web-visible-only capture gate)", () => { expect(isVisualPath("apps/gittensory-ui/README.md")).toBe(true); }); + it("matches ANY app folder, not just gittensory-ui (#3611 follow-up — e.g. metagraphed's apps/ui/**)", () => { + expect(isVisualPath("apps/ui/src/routes/index.tsx")).toBe(true); + // Same non-extension-matching-file case as gittensory-ui above, now for a different app folder name. + expect(isVisualPath("apps/ui/components.json")).toBe(true); + expect(isVisualPath("apps/marketing-site/README.md")).toBe(true); + }); + it("matches public asset paths (public/** — OG images etc.) at any depth", () => { expect(isVisualPath("public/og-image.png")).toBe(true); expect(isVisualPath("apps/web/public/banner.jpg")).toBe(true);