diff --git a/scripts/test/feedback-widget-reach.ts b/scripts/test/feedback-widget-reach.ts new file mode 100644 index 00000000..c1a690b4 --- /dev/null +++ b/scripts/test/feedback-widget-reach.ts @@ -0,0 +1,95 @@ +// A user of FleetCrown must be able to report a bug in FleetCrown. +// +// The widget was scoped to eleven public marketing routes, on the stated +// grounds that "in-app feedback already has Loki". That was not true: +// insertSiteFeedback has exactly one caller — the widget's ingest route — and +// Loki has no feedback capability at all. So every signed-in user, on every +// app page, had no structured way to report anything. The omission was +// invisible, which is the failure mode this whole product exists to fix. +// +// The rule is now an exclusion list, so a NEW page ships with feedback by +// default instead of silently shipping without it. This test pins that +// direction: the app surfaces must be covered, the excluded ones must stay +// excluded, and Loki must not be cited as the in-app answer while it cannot +// file anything. +// Run: npx tsx scripts/test/feedback-widget-reach.ts +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { + isFeedbackWidgetRoute, + FEEDBACK_WIDGET_EXCLUDED_PREFIXES, +} from "../../src/config/feedback-widget"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +let pass = 0; +let fail = 0; +function ok(cond: boolean, label: string) { + if (cond) { + pass++; + } else { + fail++; + console.error(`✗ ${label}`); + } +} + +// The surfaces a signed-in operator actually works on. Each of these is a +// place someone can hit a bug, so each must be able to report one. +const APP_SURFACES = [ + "/today", + "/control", + "/projects", + "/projects/5c0ea00f-d098-49a0-aae5-c83b8f4be79c", + "/feedback", + "/activity", + "/goals", + "/people", + "/crew", + "/money", + "/habits", + "/prompts", + "/system", + "/settings", + "/loki", + "/approvals", +]; +for (const path of APP_SURFACES) { + ok(isFeedbackWidgetRoute(path), `widget reaches ${path}`); +} + +// Public pages keep it too — that was never in question, but a rewrite of the +// matcher could easily drop them. +for (const path of ["/", "/pricing", "/thoughts", "/thoughts/some-essay"]) { + ok(isFeedbackWidgetRoute(path), `widget still reaches public ${path}`); +} + +// The exclusions must actually exclude, including nested paths. +for (const p of FEEDBACK_WIDGET_EXCLUDED_PREFIXES) { + ok(!isFeedbackWidgetRoute(p), `excluded: ${p}`); + ok(!isFeedbackWidgetRoute(`${p}/nested`), `excluded: ${p}/nested`); +} + +// A near-miss must NOT be excluded — prefix matching that swallows unrelated +// routes is how an allowlist quietly loses pages. +ok(isFeedbackWidgetRoute("/terminals"), "/terminals is not caught by the /terminal exclusion"); +ok(isFeedbackWidgetRoute("/settings"), "/settings is an app page, not an auth page"); + +// The claim that justified the old scoping must not silently return. If Loki +// ever does gain the ability to file feedback, this test should be revisited +// deliberately rather than the assumption creeping back. +const loki = readFileSync(join(ROOT, "src/lib/loki-core.ts"), "utf8"); +ok( + !/insertSiteFeedback/.test(loki), + "loki-core still cannot file feedback — the reason the widget must reach the app", +); + +// The widget mounts from the ROOT layout, so coverage is not per-page opt-in. +const layout = readFileSync(join(ROOT, "src/app/layout.tsx"), "utf8"); +ok( + /DogfoodFeedbackWidget/.test(layout), + "the widget is mounted in the root layout, so new pages get it for free", +); + +console.log(`${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 11c87474..d0a98c7e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,7 @@ import { GeistSans } from "geist/font/sans"; import { GeistMono } from "geist/font/mono"; import { SessionProvider } from "next-auth/react"; import { ThemeProvider } from "@/components/shell/ThemeProvider"; -import { PublicFeedbackWidget } from "@/components/shell/PublicFeedbackWidget"; +import { DogfoodFeedbackWidget } from "@/components/shell/DogfoodFeedbackWidget"; import { APP_DESCRIPTION, APP_NAME, APP_URL } from "@/config/brand"; import { PALETTE } from "@/lib/palette"; import "./globals.css"; @@ -78,10 +78,14 @@ export default function RootLayout({ {children} - {/* Dogfood: FleetCrown's own feedback widget on public pages, active - only where FEEDBACK_WIDGET_TOKEN is provisioned (see config/feedback-widget.ts). */} + {/* Dogfood: FleetCrown's own feedback widget, on every surface except a + short excluded list (config/feedback-widget.ts). Active only where + FEEDBACK_WIDGET_TOKEN is provisioned. It sits in the ROOT layout on + purpose — a signed-in user hitting a bug in the app had no way to + report it, because the widget was scoped to marketing pages and + Loki cannot file feedback. */} {process.env.FEEDBACK_WIDGET_TOKEN && ( - + )} diff --git a/src/components/shell/PublicFeedbackWidget.tsx b/src/components/shell/DogfoodFeedbackWidget.tsx similarity index 77% rename from src/components/shell/PublicFeedbackWidget.tsx rename to src/components/shell/DogfoodFeedbackWidget.tsx index aaf30efe..39372481 100644 --- a/src/components/shell/PublicFeedbackWidget.tsx +++ b/src/components/shell/DogfoodFeedbackWidget.tsx @@ -5,8 +5,9 @@ import { usePathname } from "next/navigation"; import { isFeedbackWidgetRoute } from "@/config/feedback-widget"; /** - * Dogfood embed of FleetCrown's own feedback widget on the public pages - * (docs/architecture/feedback-widget.md, Phase 4). Injects the exact same + * Dogfood embed of FleetCrown's own feedback widget — on EVERY surface, not + * just the public pages (see config/feedback-widget.ts for the short list of + * exclusions and why each one is excluded). Injects the exact same * /widget.js script tag a customer site would use — same code path, same * ingest API — pointed at the project the FEEDBACK_WIDGET_TOKEN env var * belongs to. Rendered by the root layout only when that env var is set. @@ -14,7 +15,7 @@ import { isFeedbackWidgetRoute } from "@/config/feedback-widget"; * Imperative injection (not a JSX