diff --git a/.gittensory.yml.example b/.gittensory.yml.example index b146ffe25e..b4e1d11e8c 100644 --- a/.gittensory.yml.example +++ b/.gittensory.yml.example @@ -971,6 +971,14 @@ settings: # # SELF-HOST ONLY (no effect on the hosted service) and the heaviest capture mode here — up to 6 extra # # renders per side, ~4s wall-clock per side measured in practice. Bool. Default: false (no scroll capture). # gif: false +# # Config-as-code enable/disable for this repo, layered ON TOP OF (never a replacement for) the +# # GITTENSORY_REVIEW_SCREENSHOTS + per-repo cutover-allowlist env-var gate above (#4083). Bool or null. +# # Default: null (unset) ⇒ defers entirely to that env-var gate's own decision -- byte-identical to today. +# # Explicit `false` (set once at the global-default `.gittensory.yml`, or overridden per-repo here) forces +# # capture off for this repo even when the env-var gate would otherwise allow it. Explicit `true` opts this +# # repo back in at a layer where a broader default disabled it -- it does NOT bypass the env-var gate +# # itself, so the env vars remain the outer infra-availability switch. +# enabled: true # # Maintainer overrides for the public review-panel CONTENT (not what gittensory measures). The # # Gittensor attribution + register link is always appended to the footer regardless; maintainer text # # failing the public-safe filter is dropped, never published. diff --git a/config/examples/gittensory.full.yml b/config/examples/gittensory.full.yml index 32d162622d..6513dceaee 100644 --- a/config/examples/gittensory.full.yml +++ b/config/examples/gittensory.full.yml @@ -984,6 +984,14 @@ settings: # # SELF-HOST ONLY (no effect on the hosted service) and the heaviest capture mode here — up to 6 extra # # renders per side, ~4s wall-clock per side measured in practice. Bool. Default: false (no scroll capture). # gif: false +# # Config-as-code enable/disable for this repo, layered ON TOP OF (never a replacement for) the +# # GITTENSORY_REVIEW_SCREENSHOTS + per-repo cutover-allowlist env-var gate above (#4083). Bool or null. +# # Default: null (unset) ⇒ defers entirely to that env-var gate's own decision -- byte-identical to today. +# # Explicit `false` (set once at the global-default `.gittensory.yml`, or overridden per-repo here) forces +# # capture off for this repo even when the env-var gate would otherwise allow it. Explicit `true` opts this +# # repo back in at a layer where a broader default disabled it -- it does NOT bypass the env-var gate +# # itself, so the env vars remain the outer infra-availability switch. +# enabled: true # # Maintainer overrides for the public review-panel CONTENT (not what gittensory measures). The # # Gittensor attribution + register link is always appended to the footer regardless; maintainer text # # failing the public-safe filter is dropped, never published. diff --git a/packages/gittensory-engine/src/focus-manifest.ts b/packages/gittensory-engine/src/focus-manifest.ts index e04f99007f..f08b6367ba 100644 --- a/packages/gittensory-engine/src/focus-manifest.ts +++ b/packages/gittensory-engine/src/focus-manifest.ts @@ -648,6 +648,14 @@ export type VisualConfig = { * capture mode this pipeline has (up to 6 extra renders per side) — false (default, every existing * manifest) ⇒ byte-identical to today, no scroll frames captured at all. */ gif: boolean; + /** `review.visual.enabled` (#4083): a config-as-code override layered ON TOP OF the outer + * `GITTENSORY_REVIEW_SCREENSHOTS` / `GITTENSORY_REVIEW_REPOS` env-var gate, not a replacement for it. null + * (default, unset at every config layer) ⇒ defers entirely to that gate's own decision. `false` (settable at + * the global-default layer, or overridden per-repo) ⇒ forces capture off for this repo even when the env-var + * gate would otherwise allow it. `true` ⇒ no additional restriction — it does NOT bypass the env-var gate, + * 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; }; /** A `prefers-color-scheme` value the capture pipeline can emulate before rendering (#3678). */ @@ -685,6 +693,7 @@ export const EMPTY_VISUAL_CONFIG: VisualConfig = { routes: { paths: [], maxRoutes: null }, themes: [], gif: false, + enabled: null, }; /** One `review.path_instructions[]` entry: a manifest path glob + the public-safe instructions to apply when a @@ -2092,6 +2101,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), }; } @@ -2321,7 +2331,7 @@ 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; + return config.preview.urlTemplate !== null || config.routes.paths.length > 0 || config.routes.maxRoutes !== null || config.themes.length > 0 || config.gif || config.enabled !== null; } const VISUAL_THEME_VALUES: readonly VisualTheme[] = ["light", "dark"]; @@ -2402,8 +2412,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); - return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif }; + return { preview: { urlTemplate }, routes: { paths, maxRoutes }, themes, gif, enabled }; } function parseAutoReviewTitleKeywords(value: JsonValue | undefined, warnings: string[]): string[] { @@ -2718,6 +2729,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; out.visual = visual; } if (review.linkedIssueSatisfaction !== null) out.linkedIssueSatisfaction = review.linkedIssueSatisfaction; diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 2298b1307e..75c769f19f 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -10231,20 +10231,22 @@ async function maybePublishPrPublicSurface( // (the default for every repo today) ⇒ EMPTY_VISUAL_CONFIG ⇒ buildCapture's discovery/inference // behavior is byte-identical to pre-#3609. const reviewVisualConfig = await resolveVisualCaptureConfig(env, repoFullName); - const capture = await buildCapture( - env, - token, - { - repoFullName, - prNumber: pr.number, - ...(pr.headSha ? { headSha: pr.headSha } : {}), - ...(pr.headRef ? { headRef: pr.headRef } : {}), - previewFromChecks: true, - }, - visualFiles, - githubRateLimitAdmissionKeyForInstallation(installationId), - reviewVisualConfig, - ); + const captureTarget = { + repoFullName, + prNumber: pr.number, + ...(pr.headSha ? { headSha: pr.headSha } : {}), + ...(pr.headRef ? { headRef: pr.headRef } : {}), + previewFromChecks: true, + }; + // review.visual.enabled (#4083): a config-as-code override layered on top of the screenshotsAllowed + // env-var gate above, not a replacement for it. Unset/true ⇒ defer to that gate's decision (buildCapture + // runs exactly as before); explicit `false` (global default or per-repo, VPS-only) ⇒ force capture off + // for this repo -- a no-routes, non-pending sentinel result, so every line below behaves exactly as an + // ordinary "nothing found" capture would, with no separate code path to maintain. + const capture = + reviewVisualConfig.enabled === false + ? { routes: [], previewPending: false } + : await buildCapture(env, token, captureTarget, visualFiles, githubRateLimitAdmissionKeyForInstallation(installationId), reviewVisualConfig); beforeAfter = capture.routes; // Visual self-poll: the FIRST capture returns a "loading" placeholder for the AFTER shot when the // preview deploy isn't live yet (capture.previewPending). Schedule a delayed re-review to re-capture diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 5a4abb98f3..536fe9f4f7 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -3745,6 +3745,7 @@ describe("review.visual (#3609 preview.url_template / #3610 routes)", () => { routes: { paths: ["/pricing", "/docs"], maxRoutes: 3 }, themes: [], gif: false, + enabled: null, }); expect(m.review.present).toBe(true); expect(parseFocusManifest({ review: reviewConfigToJson(m.review) }).review.visual).toEqual(m.review.visual); @@ -3840,7 +3841,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 }); + expect(resolveReviewVisualConfig(manifest)).toEqual({ preview: { urlTemplate: null }, routes: { paths: ["/app"], maxRoutes: null }, themes: [], gif: false, enabled: null }); }); }); @@ -3925,7 +3926,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 }); + expect(m.review.visual).toEqual({ preview: { urlTemplate: null }, routes: { paths: [], maxRoutes: null }, themes: ["dark"], gif: true, enabled: null }); expect(reviewConfigToJson(m.review)).toEqual({ visual: { themes: ["dark"], gif: true } }); }); @@ -3935,6 +3936,61 @@ describe("review.visual.gif (#3612 scroll-through GIF capture)", () => { }); }); +describe("review.visual.enabled (#4083 config-as-code enable/disable)", () => { + it("parses enabled: true, marks present, and round-trips", () => { + const m = parseFocusManifest({ review: { visual: { enabled: true } } }); + expect(m.review.visual.enabled).toBe(true); + expect(m.review.present).toBe(true); + expect(reviewConfigToJson(m.review)).toEqual({ visual: { enabled: true } }); + }); + + it("parses enabled: false, marks present, and round-trips", () => { + const m = parseFocusManifest({ review: { visual: { enabled: false } } }); + expect(m.review.visual.enabled).toBe(false); + expect(m.review.present).toBe(true); + expect(reviewConfigToJson(m.review)).toEqual({ visual: { enabled: false } }); + }); + + it("absent enabled stays null and does not mark review present on its own", () => { + expect(parseFocusManifest({}).review.visual.enabled).toBeNull(); + expect(parseFocusManifest({ review: { visual: {} } }).review.present).toBe(false); + }); + + it("null enabled does not serialize into the round-tripped visual block", () => { + const m = parseFocusManifest({ review: { visual: { gif: true } } }); + expect(reviewConfigToJson(m.review)).toEqual({ visual: { gif: true } }); + }); + + it("warns and defaults to null when enabled is not a boolean", () => { + const bad = parseFocusManifest({ review: { visual: { enabled: "yes" } } }); + expect(bad.review.visual.enabled).toBeNull(); + expect(bad.warnings.some((w) => /review\.visual\.enabled.*must be a boolean/.test(w))).toBe(true); + }); + + it("marks present via enabled alone (preview + routes + themes + gif all empty)", () => { + const m = parseFocusManifest({ review: { visual: { enabled: false } } }); + expect(m.review.present).toBe(true); + }); + + it("resolveReviewVisualConfig passes a configured enabled: false through", () => { + const manifest = parseFocusManifest({ review: { visual: { enabled: false } } }); + expect(resolveReviewVisualConfig(manifest).enabled).toBe(false); + }); + + it("overlay: a per-repo enabled: false wins over a global-default enabled: true", () => { + const globalDefault = parseReviewConfigMapping({ visual: { enabled: true } }, []); + const perRepo = parseReviewConfigMapping({ visual: { enabled: false } }, []); + expect(overlayReviewConfig(globalDefault, perRepo).visual.enabled).toBe(false); + }); + + it("overlay: an unset per-repo enabled falls back to the global-default value", () => { + const globalDefault = parseReviewConfigMapping({ visual: { enabled: false } }, []); + const perRepo = parseReviewConfigMapping({ visual: { routes: { paths: ["/app"] } } }, []); + expect(overlayReviewConfig(globalDefault, perRepo).visual.enabled).toBe(false); + 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/queue.test.ts b/test/unit/queue.test.ts index f7af95e7f9..72928a2903 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -17534,6 +17534,151 @@ describe("queue processors", () => { } }); + // #4083: review.visual.enabled: false (config-as-code, VPS-only in practice) overrides the coarser + // GITTENSORY_REVIEW_SCREENSHOTS + GITTENSORY_REVIEW_REPOS env-var gate above — same fixture as the sibling + // test above (same webhook, same visual-file touch, same env flag ON), the ONLY difference being the + // .gittensory.yml content, so this isolates the new enabled:false branch in processors.ts. + it("skips the capture pipeline entirely when review.visual.enabled is false, even though the env-var gate allows it (#4083)", async () => { + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + GITTENSORY_REVIEW_UNIFIED_COMMENT: "1", + GITTENSORY_REVIEW_SCREENSHOTS: "true", + }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload( + { "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, + { kind: "raw-github", url: "https://example.test" }, + "2026-05-23T00:00:00.000Z", + ), + ); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "detected_contributors_only", + publicAudienceMode: "gittensor_only", + publicSignalLevel: "standard", + publicSurface: "comment_and_label", + autoLabelEnabled: false, + checkRunMode: "off", + checkRunDetailLevel: "minimal", + gateCheckMode: "enabled", + backfillEnabled: true, + autonomy: { update_branch: "auto" }, + }); + let postedBody = ""; + const liveCiSpy = vi.spyOn(backfillModule, "fetchLiveCiAggregatePreferGraphQl").mockResolvedValue({ + ciState: "passed", + hasPending: false, + hasVisiblePending: false, + hasMissingRequiredContext: false, + failingDetails: [], + nonRequiredFailingDetails: [], + ciCompletenessWarning: null, + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (url === "https://raw.githubusercontent.com/JSONbored/gittensory/HEAD/.gittensory.yml") { + return new Response("review:\n visual:\n enabled: false\n"); + } + if (url === "https://api.gittensor.io/miners") { + return Response.json([ + { + uid: 7, + githubUsername: "oktofeesh1", + githubId: "123", + totalPrs: 4, + totalMergedPrs: 3, + totalOpenPrs: 1, + totalClosedPrs: 0, + totalOpenIssues: 0, + totalClosedIssues: 0, + totalSolvedIssues: 0, + totalValidSolvedIssues: 0, + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + hotkey: "must-not-leak", + }, + ]); + } + if (url === "https://api.gittensor.io/miners/123") { + return Response.json({ + repositories: [ + { + repositoryFullName: "JSONbored/gittensory", + totalPrs: "4", + totalMergedPrs: "3", + totalOpenPrs: "1", + totalClosedPrs: "0", + totalOpenIssues: "0", + totalClosedIssues: "0", + isEligible: true, + credibility: "1.000000", + }, + ], + }); + } + if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]); + if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] }); + if (url.endsWith("/users/oktofeesh1")) return Response.json({ login: "oktofeesh1", public_repos: 2, followers: 1 }); + if (url.includes("/users/oktofeesh1/repos")) return Response.json([{ language: "TypeScript" }]); + if (url.includes("/access_tokens")) { + return Response.json({ token: "installation-token", expires_at: "2026-05-28T00:04:00.000Z" }); + } + if (url.includes("/pulls/3/files")) { + return Response.json([{ filename: "apps/gittensory-ui/src/routes/app.index.tsx", additions: 5, deletions: 1, status: "modified" }]); + } + if (/\/pulls\/3(?:\?|$)/.test(url)) return Response.json({ number: 3, mergeable_state: "clean" }); + if (url.includes("/check-runs") && method === "GET") return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && method === "POST") return Response.json({ id: 901 }, { status: 201 }); + if (url.includes("/check-runs/901") && method === "PATCH") return Response.json({ id: 901 }); + if (url.includes("/issues/3/comments") && method === "GET") return Response.json([]); + if (url.includes("/issues/3/comments") && method === "POST") { + postedBody = String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""); + return Response.json({ id: 1, html_url: "https://github.com/comment/1" }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + try { + await processJob(env, { + type: "github-webhook", + deliveryId: "pr-visual-config-disabled", + eventName: "pull_request", + payload: { + action: "synchronize", + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write", checks: "write" }, + events: ["issues", "issue_comment", "pull_request", "repository", "installation_repositories"], + }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { + number: 3, + title: "Update the app index route", + state: "open", + user: { login: "oktofeesh1" }, + // Empty sha + a present ref (the opposite combination from the sibling "threads review.visual config" + // test's { sha: "visualcfg123" }) so between the two tests, both branches of captureTarget's + // optional headSha/headRef spreads are exercised. + head: { sha: "", ref: "feature/visual-config-disabled" }, + labels: [{ name: "bug" }], + body: "Fixes #1\n\nValidation: npm test", + }, + }, + }); + + // review.visual.enabled: false overrode the env-var gate — no capture attempted, so no Visual preview + // section at all, even though the PR touches a visual file and GITTENSORY_REVIEW_SCREENSHOTS is on. + expect(postedBody).not.toContain("Visual preview"); + } finally { + liveCiSpy.mockRestore(); + } + }); + // #1957: with the unified comment on AND `.gittensory.yml` opting into `review.changed_files_summary`, the // rendered comment gains the deterministic "Changed files" collapsible built from the SAME PR-files fetch the // unified branch already does for the readiness chip — no separate call, no AI. Mirrors the base unified-comment diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 724ff803e1..14491baba2 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, testGeneration: null, impactMap: null, cultureProfile: 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 }, 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, testGeneration: null, impactMap: null, cultureProfile: 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 }, 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-config-wiring.test.ts b/test/unit/visual-config-wiring.test.ts index 43b0ccdf57..eb958cc21b 100644 --- a/test/unit/visual-config-wiring.test.ts +++ b/test/unit/visual-config-wiring.test.ts @@ -20,6 +20,7 @@ describe("review.visual wiring (#3609 / #3610)", () => { routes: { paths: ["/pricing"], maxRoutes: 3 }, themes: [], gif: false, + enabled: null, }); expect(loadSpy).toHaveBeenCalledWith(expect.anything(), "acme/widgets"); loadSpy.mockRestore(); @@ -36,4 +37,11 @@ describe("review.visual wiring (#3609 / #3610)", () => { await expect(resolveVisualCaptureConfig({} as Env, "acme/widgets")).resolves.toEqual({ ...EMPTY_VISUAL_CONFIG }); loadSpy.mockRestore(); }); + + it("resolves a configured enabled: false from the repo's focus manifest (#4083)", async () => { + const manifest = parseFocusManifest({ review: { visual: { enabled: false } } }); + const loadSpy = vi.spyOn(focusManifestLoader, "loadRepoFocusManifest").mockResolvedValue(manifest); + await expect(resolveVisualCaptureConfig({} as Env, "acme/widgets")).resolves.toEqual({ ...EMPTY_VISUAL_CONFIG, enabled: false }); + loadSpy.mockRestore(); + }); });