fix(explore): discovery is opt-in — stop advertising every public repo #5607
2 changed files+132−43
Modifiedsrc/__tests__/synthetic-journeys.test.ts+57−14View fileUnifiedSplit
@@ -8,7 +8,12 @@
88 */
99
1010import { describe, expect, it } from "bun:test";
11import { runJourneyChecks, classifyRepoCreate } from "../lib/synthetic-journeys";
11import {
12 runJourneyChecks,
13 classifyRepoCreate,
14 classifyExploreFeed,
15 countExploreCards,
16} from "../lib/synthetic-journeys";
1217
1318function fakeFetch(
1419 responder: (url: string, init?: RequestInit) => Response
@@ -16,8 +21,6 @@ function fakeFetch(
1621 return (async (url: any, init?: any) => responder(String(url), init)) as typeof fetch;
1722}
1823
19const REPO_LINK_PAGE = `<html><body><a href="/ccantynz/Gluecron.com">repo</a></body></html>`;
20
2124async function journey(
2225 name: string,
2326 responder: (url: string, init?: RequestInit) => Response
@@ -61,24 +64,64 @@ describe("journey:login-form — the rejection is the pass", () => {
6164 });
6265});
6366
64describe("journey:explore-data — 200 is not enough", () => {
65 it("green when /explore actually lists repositories", async () => {
66 const r = await journey("journey:explore-data", (url) =>
67 url.endsWith("/explore")
68 ? new Response(REPO_LINK_PAGE, { status: 200 })
69 : new Response("ok", { status: 200 })
70 );
71 expect(r.status).toBe("green");
67describe("journey:explore-data — the feed must match the database", () => {
68 // The red/green decision is exercised through the pure classifier. The
69 // journey itself needs a live database AND a live HTTP surface, so testing
70 // the decision in place is not possible — the same reason classifyRepoCreate
71 // exists as a pure function.
72
73 it("green when the feed shows cards and repositories are listed", () => {
74 expect(classifyExploreFeed({ cardCount: 3, listedCount: 1 }).ok).toBe(true);
75 });
76
77 it("green when nothing is listed and nothing is rendered — the opt-in case", () => {
78 // Post-migration-0131 this is a CORRECT render, not a broken one. The old
79 // probe asserted "at least one card, always" and would have paged red
80 // every five minutes for a page working exactly as designed.
81 expect(classifyExploreFeed({ cardCount: 0, listedCount: 0 }).ok).toBe(true);
82 });
83
84 it("red when repositories are listed but the page renders no cards", () => {
85 const v = classifyExploreFeed({ cardCount: 0, listedCount: 1 });
86 expect(v.ok).toBe(false);
87 expect(v.reason).toContain("shell without data");
88 });
89
90 it("red when cards render although nothing is opted in — the leak is back", () => {
91 // This is the 0131 regression guard running continuously in production:
92 // cards with no listed rows means the listed_in_explore filter stopped
93 // being applied.
94 const v = classifyExploreFeed({ cardCount: 12, listedCount: 0 });
95 expect(v.ok).toBe(false);
96 expect(v.reason).toContain("filter is not being applied");
97 });
98
99 it("green when the listed count cannot be established", () => {
100 // The database has its own probes. A journey that reports a cause it did
101 // not observe sends people to the wrong place.
102 expect(classifyExploreFeed({ cardCount: 0, listedCount: null }).ok).toBe(true);
72103 });
73104
74 it("red when /explore renders a shell with zero repo links", async () => {
105 it("counts repo cards by aria-label, not by two-segment hrefs", () => {
106 // The old probe matched href="/a/b". Every page's chrome renders
107 // /api/graphql and /theme/toggle, both of which match — so it counted 2
108 // "repositories" on an empty feed and could never reach its own
109 // zero-links branch. A monitor that cannot fail is worse than none.
110 const chromeOnly = `<a href="/api/graphql">gql</a><a href="/theme/toggle">t</a>`;
111 expect(countExploreCards(chromeOnly)).toBe(0);
112
113 const withCards = chromeOnly + `<a aria-label="ccantynz/Gluecron.com">repo</a>`;
114 expect(countExploreCards(withCards)).toBe(1);
115 });
116
117 it("still red when /explore does not return 200", async () => {
75118 const r = await journey("journey:explore-data", (url) =>
76119 url.endsWith("/explore")
77 ? new Response("<html><body><h1>Explore</h1></body></html>", { status: 200 })
120 ? new Response("boom", { status: 500 })
78121 : new Response("ok", { status: 200 })
79122 );
80123 expect(r.status).toBe("red");
81 expect(r.error).toContain("shell without data");
124 expect(r.error).toContain("500");
82125 });
83126});
84127
Modifiedsrc/lib/synthetic-journeys.ts+75−29View fileUnifiedSplit
@@ -231,6 +231,72 @@ async function authedSessionJourney(
231231 * The second case is the regression guard for 0131 running continuously in
232232 * production, which is where it matters more than in CI.
233233 */
234/**
235 * Does the rendered /explore feed match what the database says it should be?
236 *
237 * Pulled out as a pure function for the same reason `classifyRepoCreate`
238 * below is: it is the part that is easy to get wrong and impossible to
239 * unit-test in place, because the surrounding journey needs both a live
240 * database and a live HTTP surface. Keeping the DB read in the journey and
241 * the decision here means the decision is exercised directly by tests.
242 *
243 * `listedCount === null` means the count could not be established. That is
244 * deliberately NOT a red: the database has its own probes, and a journey that
245 * reports a cause it did not observe sends people to the wrong place.
246 */
247export function classifyExploreFeed(args: {
248 cardCount: number;
249 listedCount: number | null;
250}): { ok: boolean; reason?: string } {
251 const { cardCount, listedCount } = args;
252 if (listedCount === null) return { ok: true };
253 if (listedCount > 0 && cardCount === 0) {
254 return {
255 ok: false,
256 reason:
257 "/explore rendered zero repository cards while listed repositories exist — shell without data",
258 };
259 }
260 if (listedCount === 0 && cardCount > 0) {
261 return {
262 ok: false,
263 reason: `/explore rendered ${cardCount} repository card(s) while no repository is opted in to discovery — the listed_in_explore filter is not being applied`,
264 };
265 }
266 return { ok: true };
267}
268
269/**
270 * Count the repo cards on a rendered /explore page.
271 *
272 * Cards carry `aria-label="owner/name"`. The previous probe matched
273 * `href="/a/b"` instead, and the page's own chrome always renders
274 * `/api/graphql` and `/theme/toggle` — both of which match that pattern. It
275 * therefore counted 2 "repositories" on a completely empty feed and could
276 * never reach its own zero-links branch: a monitor structurally incapable of
277 * firing the alarm it was written to fire.
278 */
279export function countExploreCards(body: string): number {
280 return (body.match(/aria-label="[A-Za-z0-9-]+\/[A-Za-z0-9._-]+"/g) ?? []).length;
281}
282
283/**
284 * /explore must render whatever the database says it should — no more, no less.
285 *
286 * This probe used to assert "at least one repo card, always", justified by
287 * "the platform's own repo is public and always present". Migration 0131 made
288 * discovery opt-in (`listed_in_explore`, default false), so an empty feed is
289 * now a *correct* render rather than evidence of a broken one, and the old
290 * assertion would have paged red every five minutes for a page working
291 * exactly as designed.
292 *
293 * Weakening it to "200 is fine" would have thrown away the thing it was built
294 * to catch. So it asks the database what the page is supposed to show and
295 * holds the page to that answer, failing in both directions — rows but no
296 * cards means the query or the view is broken; cards but no rows means the
297 * opt-in filter has stopped applying, i.e. the leak is back. The second case
298 * is the 0131 regression guard running continuously in production.
299 */
234300async function exploreDataJourney(
235301 baseUrl: string,
236302 fetchImpl: typeof fetch
@@ -242,16 +308,7 @@ async function exploreDataJourney(
242308 if (res.status !== 200) return red(name, t0, `/explore returned ${res.status}`);
243309 const body = await res.text();
244310
245 // Repo cards carry `aria-label="owner/name"`. Matching that instead of
246 // `href="/a/b"` also fixes a latent bug in the old probe rather than just
247 // porting it: the page's own chrome always renders `/api/graphql` and
248 // `/theme/toggle`, both of which match the two-segment href pattern. The
249 // old check therefore counted 2 "repositories" on a completely empty
250 // feed and could never reach its own zero-links branch — it was
251 // structurally incapable of firing the alarm it was written to fire.
252 const cards = body.match(/aria-label="[A-Za-z0-9-]+\/[A-Za-z0-9._-]+"/g) ?? [];
253
254 let expected = 0;
311 let listedCount: number | null = null;
255312 try {
256313 const rows = await db
257314 .select({ id: repositories.id })
@@ -263,28 +320,17 @@ async function exploreDataJourney(
263320 )
264321 )
265322 .limit(1);
266 expected = rows.length;
323 listedCount = rows.length;
267324 } catch {
268 // The database is the subject of other probes. If it is unreachable
269 // here, that is their red to raise — not this one's, and guessing would
270 // make this journey report a cause it did not observe.
271 return green(name, t0, 200);
325 // Left as null — see classifyExploreFeed.
326 listedCount = null;
272327 }
273328
274 if (expected > 0 && cards.length === 0) {
275 return red(
276 name,
277 t0,
278 "/explore rendered zero repository cards while listed repositories exist — shell without data"
279 );
280 }
281 if (expected === 0 && cards.length > 0) {
282 return red(
283 name,
284 t0,
285 `/explore rendered ${cards.length} repository card(s) while no repository is opted in to discovery — the listed_in_explore filter is not being applied`
286 );
287 }
329 const verdict = classifyExploreFeed({
330 cardCount: countExploreCards(body),
331 listedCount,
332 });
333 if (!verdict.ok) return red(name, t0, verdict.reason ?? "explore feed mismatch");
288334 return green(name, t0, 200);
289335 } catch (err) {
290336 return red(name, t0, err instanceof Error ? err.message : String(err));
291337
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts