From cfc3cff434083e5294b29923740faf65f5139efa Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:40:09 +1000 Subject: [PATCH 1/2] feat(ui): add the scoped-permissions confirmation step to the install flow --- apps/loopover-ui/src/routeTree.gen.ts | 61 ++++- apps/loopover-ui/src/routes/install.index.tsx | 139 +++++++++++ .../src/routes/install.permissions.tsx | 218 ++++++++++++++++++ apps/loopover-ui/src/routes/install.test.tsx | 2 +- apps/loopover-ui/src/routes/install.tsx | 132 +---------- 5 files changed, 421 insertions(+), 131 deletions(-) create mode 100644 apps/loopover-ui/src/routes/install.index.tsx create mode 100644 apps/loopover-ui/src/routes/install.permissions.tsx diff --git a/apps/loopover-ui/src/routeTree.gen.ts b/apps/loopover-ui/src/routeTree.gen.ts index f8e14f1205..995e6d574b 100644 --- a/apps/loopover-ui/src/routeTree.gen.ts +++ b/apps/loopover-ui/src/routeTree.gen.ts @@ -20,9 +20,11 @@ import { Route as AppRouteImport } from './routes/app' import { Route as ApiRouteImport } from './routes/api' import { Route as AgentsRouteImport } from './routes/agents' import { Route as IndexRouteImport } from './routes/index' +import { Route as InstallIndexRouteImport } from './routes/install.index' import { Route as DocsIndexRouteImport } from './routes/docs.index' import { Route as AppIndexRouteImport } from './routes/app.index' import { Route as ApiIndexRouteImport } from './routes/api.index' +import { Route as InstallPermissionsRouteImport } from './routes/install.permissions' import { Route as DocsUpstreamDriftRouteImport } from './routes/docs.upstream-drift' import { Route as DocsTuningRouteImport } from './routes/docs.tuning' import { Route as DocsTroubleshootingRouteImport } from './routes/docs.troubleshooting' @@ -142,6 +144,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const InstallIndexRoute = InstallIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => InstallRoute, +} as any) const DocsIndexRoute = DocsIndexRouteImport.update({ id: '/', path: '/', @@ -157,6 +164,11 @@ const ApiIndexRoute = ApiIndexRouteImport.update({ path: '/', getParentRoute: () => ApiRoute, } as any) +const InstallPermissionsRoute = InstallPermissionsRouteImport.update({ + id: '/permissions', + path: '/permissions', + getParentRoute: () => InstallRoute, +} as any) const DocsUpstreamDriftRoute = DocsUpstreamDriftRouteImport.update({ id: '/upstream-drift', path: '/upstream-drift', @@ -498,7 +510,7 @@ export interface FileRoutesByFullPath { '/changelog': typeof ChangelogRoute '/docs': typeof DocsRouteWithChildren '/extension': typeof ExtensionRoute - '/install': typeof InstallRoute + '/install': typeof InstallRouteWithChildren '/maintainers': typeof MaintainersRoute '/miners': typeof MinersRoute '/roadmap': typeof RoadmapRoute @@ -564,9 +576,11 @@ export interface FileRoutesByFullPath { '/docs/troubleshooting': typeof DocsTroubleshootingRoute '/docs/tuning': typeof DocsTuningRoute '/docs/upstream-drift': typeof DocsUpstreamDriftRoute + '/install/permissions': typeof InstallPermissionsRoute '/api/': typeof ApiIndexRoute '/app/': typeof AppIndexRoute '/docs/': typeof DocsIndexRoute + '/install/': typeof InstallIndexRoute '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRoutesByTo { @@ -574,7 +588,6 @@ export interface FileRoutesByTo { '/agents': typeof AgentsRoute '/changelog': typeof ChangelogRoute '/extension': typeof ExtensionRoute - '/install': typeof InstallRoute '/maintainers': typeof MaintainersRoute '/miners': typeof MinersRoute '/roadmap': typeof RoadmapRoute @@ -640,9 +653,11 @@ export interface FileRoutesByTo { '/docs/troubleshooting': typeof DocsTroubleshootingRoute '/docs/tuning': typeof DocsTuningRoute '/docs/upstream-drift': typeof DocsUpstreamDriftRoute + '/install/permissions': typeof InstallPermissionsRoute '/api': typeof ApiIndexRoute '/app': typeof AppIndexRoute '/docs': typeof DocsIndexRoute + '/install': typeof InstallIndexRoute '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRoutesById { @@ -654,7 +669,7 @@ export interface FileRoutesById { '/changelog': typeof ChangelogRoute '/docs': typeof DocsRouteWithChildren '/extension': typeof ExtensionRoute - '/install': typeof InstallRoute + '/install': typeof InstallRouteWithChildren '/maintainers': typeof MaintainersRoute '/miners': typeof MinersRoute '/roadmap': typeof RoadmapRoute @@ -720,9 +735,11 @@ export interface FileRoutesById { '/docs/troubleshooting': typeof DocsTroubleshootingRoute '/docs/tuning': typeof DocsTuningRoute '/docs/upstream-drift': typeof DocsUpstreamDriftRoute + '/install/permissions': typeof InstallPermissionsRoute '/api/': typeof ApiIndexRoute '/app/': typeof AppIndexRoute '/docs/': typeof DocsIndexRoute + '/install/': typeof InstallIndexRoute '/repos/$owner/$repo/quality': typeof ReposOwnerRepoQualityRoute } export interface FileRouteTypes { @@ -801,9 +818,11 @@ export interface FileRouteTypes { | '/docs/troubleshooting' | '/docs/tuning' | '/docs/upstream-drift' + | '/install/permissions' | '/api/' | '/app/' | '/docs/' + | '/install/' | '/repos/$owner/$repo/quality' fileRoutesByTo: FileRoutesByTo to: @@ -811,7 +830,6 @@ export interface FileRouteTypes { | '/agents' | '/changelog' | '/extension' - | '/install' | '/maintainers' | '/miners' | '/roadmap' @@ -877,9 +895,11 @@ export interface FileRouteTypes { | '/docs/troubleshooting' | '/docs/tuning' | '/docs/upstream-drift' + | '/install/permissions' | '/api' | '/app' | '/docs' + | '/install' | '/repos/$owner/$repo/quality' id: | '__root__' @@ -956,9 +976,11 @@ export interface FileRouteTypes { | '/docs/troubleshooting' | '/docs/tuning' | '/docs/upstream-drift' + | '/install/permissions' | '/api/' | '/app/' | '/docs/' + | '/install/' | '/repos/$owner/$repo/quality' fileRoutesById: FileRoutesById } @@ -970,7 +992,7 @@ export interface RootRouteChildren { ChangelogRoute: typeof ChangelogRoute DocsRoute: typeof DocsRouteWithChildren ExtensionRoute: typeof ExtensionRoute - InstallRoute: typeof InstallRoute + InstallRoute: typeof InstallRouteWithChildren MaintainersRoute: typeof MaintainersRoute MinersRoute: typeof MinersRoute RoadmapRoute: typeof RoadmapRoute @@ -1056,6 +1078,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/install/': { + id: '/install/' + path: '/' + fullPath: '/install/' + preLoaderRoute: typeof InstallIndexRouteImport + parentRoute: typeof InstallRoute + } '/docs/': { id: '/docs/' path: '/' @@ -1077,6 +1106,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiIndexRouteImport parentRoute: typeof ApiRoute } + '/install/permissions': { + id: '/install/permissions' + path: '/permissions' + fullPath: '/install/permissions' + preLoaderRoute: typeof InstallPermissionsRouteImport + parentRoute: typeof InstallRoute + } '/docs/upstream-drift': { id: '/docs/upstream-drift' path: '/upstream-drift' @@ -1675,6 +1711,19 @@ const DocsRouteChildren: DocsRouteChildren = { const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren) +interface InstallRouteChildren { + InstallPermissionsRoute: typeof InstallPermissionsRoute + InstallIndexRoute: typeof InstallIndexRoute +} + +const InstallRouteChildren: InstallRouteChildren = { + InstallPermissionsRoute: InstallPermissionsRoute, + InstallIndexRoute: InstallIndexRoute, +} + +const InstallRouteWithChildren = + InstallRoute._addFileChildren(InstallRouteChildren) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AgentsRoute: AgentsRoute, @@ -1683,7 +1732,7 @@ const rootRouteChildren: RootRouteChildren = { ChangelogRoute: ChangelogRoute, DocsRoute: DocsRouteWithChildren, ExtensionRoute: ExtensionRoute, - InstallRoute: InstallRoute, + InstallRoute: InstallRouteWithChildren, MaintainersRoute: MaintainersRoute, MinersRoute: MinersRoute, RoadmapRoute: RoadmapRoute, diff --git a/apps/loopover-ui/src/routes/install.index.tsx b/apps/loopover-ui/src/routes/install.index.tsx new file mode 100644 index 0000000000..a2c3fabd63 --- /dev/null +++ b/apps/loopover-ui/src/routes/install.index.tsx @@ -0,0 +1,139 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowRight, UserPlus, Github, ShieldCheck } from "lucide-react"; + +import { Section, SectionTitle, Card, Callout, Eyebrow } from "@/components/site/primitives"; + +// Self-serve signup & App-install entry surface (part of #4802). This is the front door of the +// signup -> install -> confirm-scoped-permissions flow: it explains the three steps and routes a new +// customer to the existing GitHub App configuration guide, with a link to the scoped-permissions +// confirmation step (/install/permissions). It reads no secrets and changes no auth backend -- the +// install itself is completed on GitHub. + +export const Route = createFileRoute("/install/")({ + head: () => ({ + meta: [ + { title: "Install LoopOver — self-serve setup" }, + { + name: "description", + content: + "Sign up, install the LoopOver App on your own repository, and confirm the scoped permissions being granted — self-serve, no engineering step required.", + }, + { property: "og:title", content: "Install LoopOver — self-serve setup" }, + { + property: "og:description", + content: + "A self-serve signup-through-install flow: connect your repository and grant scoped permissions.", + }, + { property: "og:url", content: "/install" }, + ], + links: [{ rel: "canonical", href: "/install" }], + }), + component: InstallPage, +}); + +const STEPS = [ + { + icon: UserPlus, + title: "Sign up", + description: + "Create your account. No engineering involvement — you own the connection to your repository from the start.", + }, + { + icon: Github, + title: "Install the App", + description: + "Add the LoopOver GitHub App to the repositories you want reviewed. You choose which repos it can see.", + }, + { + icon: ShieldCheck, + title: "Confirm scoped permissions", + description: + "Review exactly which permissions you're granting before you finish — scoped to what review needs, nothing more.", + to: "/install/permissions" as const, + }, +]; + +export function InstallPage() { + return ( + <> +
+
+ Self-serve setup +

+ Connect your repository in three steps. +

+

+ Sign up, install the LoopOver App on your own repository, and confirm the scoped + permissions being granted — without a manual or engineering step. +

+
+ + Install on GitHub + + + + Read the onboarding guide + +
+
+
+ +
+ +
+ {STEPS.map((step, index) => { + const Icon = step.icon; + return ( + +
+ + + Step {index + 1} + +
+

{step.title}

+

{step.description}

+ {"to" in step && step.to ? ( + + Review the exact scopes + + + ) : null} +
+ ); + })} +
+
+ +
+
+ + LoopOver requests only the permissions review needs, on only the repositories you + choose. You confirm the exact scopes on GitHub before the install completes, and you can + review or revoke them at any time from your repository's installed-Apps settings. See{" "} + + privacy & security + {" "} + for what is and isn't stored. + +
+
+ + ); +} diff --git a/apps/loopover-ui/src/routes/install.permissions.tsx b/apps/loopover-ui/src/routes/install.permissions.tsx new file mode 100644 index 0000000000..e984fde7bf --- /dev/null +++ b/apps/loopover-ui/src/routes/install.permissions.tsx @@ -0,0 +1,218 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowLeft, ShieldCheck, Eye, PencilLine, Bell } from "lucide-react"; + +import { Section, SectionTitle, Card, Callout, Eyebrow } from "@/components/site/primitives"; + +// Self-serve "confirm scoped permissions" surface (part of #4802). This is the third step of the +// signup -> install -> confirm-scoped-permissions flow whose entry lives at /install: before a +// customer finishes the GitHub install, this page shows exactly which repository permissions the App +// requests and why, so the grant on GitHub holds no surprises. The permission set mirrors the +// canonical REQUIRED_INSTALLATION_PERMISSIONS / OPTIONAL_* constants in src/github/backfill.ts (the +// same source install-health checks against) -- baseline access is always requested; the write scopes +// are only exercised when the matching repo setting is enabled, so they are shown as opt-in. This is a +// read-only informational surface: it reads no secrets and changes no auth backend, and the real grant +// still happens on GitHub. + +export const Route = createFileRoute("/install/permissions")({ + head: () => ({ + meta: [ + { title: "Scoped permissions — LoopOver install" }, + { + name: "description", + content: + "Review exactly which repository permissions the LoopOver GitHub App requests, and why, before you finish installing — scoped to what review needs, nothing more.", + }, + { property: "og:title", content: "Scoped permissions — LoopOver install" }, + { + property: "og:description", + content: + "The exact scopes the LoopOver App requests: baseline read access plus opt-in write scopes only for the output you enable.", + }, + { property: "og:url", content: "/install/permissions" }, + ], + links: [{ rel: "canonical", href: "/install/permissions" }], + }), + component: InstallPermissionsPage, +}); + +type Scope = { + icon: typeof Eye; + name: string; + access: "Read" | "Write"; + summary: string; +}; + +// Baseline scopes every installation requests (REQUIRED_INSTALLATION_PERMISSIONS in +// src/github/backfill.ts: metadata:read, pull_requests:read, issues:write). +const REQUIRED_SCOPES: Scope[] = [ + { + icon: Eye, + name: "Metadata", + access: "Read", + summary: + "Baseline access GitHub requires for any App — the repository's name, description, and topology. No file contents.", + }, + { + icon: Eye, + name: "Pull requests", + access: "Read", + summary: + "Read the diff, title, and description of a pull request so the review agent has something to review.", + }, + { + icon: PencilLine, + name: "Issues", + access: "Write", + summary: + "Post the review as a comment and apply the review labels. GitHub routes PR comments and labels through the Issues endpoints, so this is what publishing the result needs.", + }, +]; + +// Opt-in scopes: only requested/exercised when the matching repository setting is enabled +// (OPTIONAL_CHECK_RUN_PERMISSION / OPTIONAL_PR_WRITE_PERMISSION / OPTIONAL_CONTENTS_WRITE_PERMISSION). +const OPTIONAL_SCOPES: Array = [ + { + icon: ShieldCheck, + name: "Checks", + access: "Write", + summary: + "Publish the LoopOver Orb Review Agent check run on the pull request, so its verdict shows in the PR's checks.", + enabledBy: "Enabled when you turn on check-run or review-agent enforcement.", + }, + { + icon: PencilLine, + name: "Pull requests", + access: "Write", + summary: + "Act on pull-request state — merge, close, approve, or request changes — when you let the agent enforce outcomes rather than only advise.", + enabledBy: "Enabled when autonomy is set to act on PR state.", + }, + { + icon: PencilLine, + name: "Contents", + access: "Write", + summary: + "Write suggested fixes back to a branch when fix-handoff is turned on. Left at read-only otherwise.", + enabledBy: "Enabled when fix-handoff writes are turned on.", + }, +]; + +// Webhook events the installation subscribes to (REQUIRED_INSTALLATION_EVENTS). +const EVENTS = ["issues", "issue_comment", "pull_request", "repository"]; + +function ScopeCard({ scope, footer }: { scope: Scope; footer?: string }) { + const Icon = scope.icon; + return ( + +
+
+ +

{scope.name}

+
+ + {scope.access} + +
+

{scope.summary}

+ {footer ?

{footer}

: null} +
+ ); +} + +export function InstallPermissionsPage() { + return ( + <> +
+
+ Step 3 · Confirm scoped permissions +

+ Exactly what you're granting. +

+

+ Before you finish the install, here is every repository permission the LoopOver App + requests — and why. GitHub shows you the same list to approve; nothing is granted until + you confirm it there. +

+
+ + + Back to setup steps + +
+
+
+ +
+ +
+ {REQUIRED_SCOPES.map((scope) => ( + + ))} +
+
+ +
+ +
+ {OPTIONAL_SCOPES.map((scope) => ( + + ))} +
+
+ +
+
+
+ +

Events it listens for

+
+

+ The installation subscribes to these webhook events so it knows when there's a pull + request to review: +

+
    + {EVENTS.map((event) => ( +
  • + {event} +
  • + ))} +
+
+
+ +
+
+ + You grant these on only the repositories you choose, and you can review or revoke them + at any time from your repository's installed-Apps settings on GitHub. See{" "} + + privacy & security + {" "} + for what is and isn't stored. + +
+
+ + ); +} diff --git a/apps/loopover-ui/src/routes/install.test.tsx b/apps/loopover-ui/src/routes/install.test.tsx index 0e4dd93e16..187de1a86e 100644 --- a/apps/loopover-ui/src/routes/install.test.tsx +++ b/apps/loopover-ui/src/routes/install.test.tsx @@ -7,7 +7,7 @@ vi.mock("@tanstack/react-router", () => ({ Link: ({ to, children }: { to: string; children: ReactNode }) => {children}, })); -import { InstallPage } from "./install"; +import { InstallPage } from "./install.index"; // Self-serve signup & App-install entry surface (part of #4802). describe("InstallPage (#4802 self-serve install entry)", () => { diff --git a/apps/loopover-ui/src/routes/install.tsx b/apps/loopover-ui/src/routes/install.tsx index 1e2c723a32..dab2856f5b 100644 --- a/apps/loopover-ui/src/routes/install.tsx +++ b/apps/loopover-ui/src/routes/install.tsx @@ -1,129 +1,13 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; -import { ArrowRight, UserPlus, Github, ShieldCheck } from "lucide-react"; - -import { Section, SectionTitle, Card, Callout, Eyebrow } from "@/components/site/primitives"; - -// Self-serve signup & App-install entry surface (part of #4802). This is the front door of the -// signup -> install -> confirm-scoped-permissions flow: it explains the three steps and routes a new -// customer to the existing GitHub App configuration guide. It reads no secrets and changes no auth -// backend -- the install itself is completed on GitHub, and post-install permission confirmation is a -// deliberate follow-up so this slice stays a small, reviewable UI surface. +import { createFileRoute, Outlet } from "@tanstack/react-router"; +// Layout for the self-serve install flow (part of #4802). `/install` is the signup->install->confirm +// entry (install.index.tsx); `/install/permissions` is the scoped-permissions confirmation step. This +// route only provides the shared outlet so those sibling pages render under the /install path -- it +// holds no content of its own and reads no secrets. export const Route = createFileRoute("/install")({ - head: () => ({ - meta: [ - { title: "Install LoopOver — self-serve setup" }, - { - name: "description", - content: - "Sign up, install the LoopOver App on your own repository, and confirm the scoped permissions being granted — self-serve, no engineering step required.", - }, - { property: "og:title", content: "Install LoopOver — self-serve setup" }, - { - property: "og:description", - content: - "A self-serve signup-through-install flow: connect your repository and grant scoped permissions.", - }, - { property: "og:url", content: "/install" }, - ], - links: [{ rel: "canonical", href: "/install" }], - }), - component: InstallPage, + component: InstallLayout, }); -const STEPS = [ - { - icon: UserPlus, - title: "Sign up", - description: - "Create your account. No engineering involvement — you own the connection to your repository from the start.", - }, - { - icon: Github, - title: "Install the App", - description: - "Add the LoopOver GitHub App to the repositories you want reviewed. You choose which repos it can see.", - }, - { - icon: ShieldCheck, - title: "Confirm scoped permissions", - description: - "Review exactly which permissions you're granting before you finish — scoped to what review needs, nothing more.", - }, -]; - -export function InstallPage() { - return ( - <> -
-
- Self-serve setup -

- Connect your repository in three steps. -

-

- Sign up, install the LoopOver App on your own repository, and confirm the scoped - permissions being granted — without a manual or engineering step. -

-
- - Install on GitHub - - - - Read the onboarding guide - -
-
-
- -
- -
- {STEPS.map((step, index) => { - const Icon = step.icon; - return ( - -
- - - Step {index + 1} - -
-

{step.title}

-

{step.description}

-
- ); - })} -
-
- -
-
- - LoopOver requests only the permissions review needs, on only the repositories you - choose. You confirm the exact scopes on GitHub before the install completes, and you can - review or revoke them at any time from your repository's installed-Apps settings. See{" "} - - privacy & security - {" "} - for what is and isn't stored. - -
-
- - ); +export function InstallLayout() { + return ; } From 99a9f7e5a43b5e253535de13fc832bcb0cafcb36 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:08:47 +1000 Subject: [PATCH 2/2] test(ui): cover the scoped-permissions install page rendering --- .../src/routes/install.permissions.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 apps/loopover-ui/src/routes/install.permissions.test.tsx diff --git a/apps/loopover-ui/src/routes/install.permissions.test.tsx b/apps/loopover-ui/src/routes/install.permissions.test.tsx new file mode 100644 index 0000000000..03bbfb8918 --- /dev/null +++ b/apps/loopover-ui/src/routes/install.permissions.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => () => ({}), + Link: ({ to, children }: { to: string; children: ReactNode }) => {children}, +})); + +import { InstallPermissionsPage } from "./install.permissions"; + +// Scoped-permissions confirmation step (part of #4802). The page must present the real installation +// permission set (mirroring REQUIRED_INSTALLATION_PERMISSIONS / OPTIONAL_* in src/github/backfill.ts) +// so a customer sees exactly what they're granting before finishing the GitHub install. +describe("InstallPermissionsPage (#4802 confirm scoped permissions)", () => { + it("presents the baseline (always-requested) scopes with their access levels", () => { + render(); + expect(screen.getByRole("heading", { name: /Exactly what you.re granting/i })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Metadata" })).toBeTruthy(); + // "Pull requests" appears in both baseline (read) and opt-in (write) groups. + expect(screen.getAllByRole("heading", { name: "Pull requests" }).length).toBeGreaterThanOrEqual( + 1, + ); + expect(screen.getByRole("heading", { name: "Issues" })).toBeTruthy(); + // Access badges: at least one READ and one WRITE are rendered. + expect(screen.getAllByText("Read").length).toBeGreaterThan(0); + expect(screen.getAllByText("Write").length).toBeGreaterThan(0); + }); + + it("presents the opt-in write scopes, each explaining what enables it", () => { + render(); + expect(screen.getByRole("heading", { name: "Checks" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Contents" })).toBeTruthy(); + // Every opt-in scope is gated on a setting, surfaced as an "Enabled when ..." note. + expect(screen.getAllByText(/Enabled when/i).length).toBe(3); + }); + + it("lists the webhook events the installation subscribes to", () => { + render(); + for (const event of ["issues", "issue_comment", "pull_request", "repository"]) { + expect(screen.getByText(event)).toBeTruthy(); + } + }); + + it("links back to the install setup steps and to privacy & security", () => { + render(); + expect(screen.getByRole("link", { name: /Back to setup steps/i }).getAttribute("href")).toBe( + "/install", + ); + expect(screen.getByRole("link", { name: /privacy & security/i }).getAttribute("href")).toBe( + "/docs/privacy-security", + ); + }); +});