CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
landing-live-feed.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 534f04a | 1 | /** |
| 2 | * Block M1 — Live-now landing feed tests. | |
| 3 | * | |
| 4 | * Covers: | |
| 5 | * - The new "Live now" section renders with SSR data | |
| 6 | * - Empty-state copy renders when all four endpoints return empty | |
| 7 | * - The inline poller script is present + references the four | |
| 8 | * `/api/v2/demo/*` endpoints + `setInterval` | |
| 9 | * - `relativeTimeFromNow` handles <60s, <60m, <24h, >24h, future, NaN, | |
| 10 | * null, undefined, Date, ISO string, number | |
| 11 | * | |
| 12 | * DB-mocking strategy: spread-from-real (K1 pattern). We capture the | |
| 13 | * real `../lib/demo-activity` module *before* installing `mock.module` | |
| 14 | * so every other export keeps working, then restore in `afterAll`. | |
| 15 | * The five listing helpers are replaced with deterministic stubs that | |
| 16 | * a test can mutate via `setLiveFeedStubs`. | |
| 17 | */ | |
| 18 | ||
| 19 | import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; | |
| 20 | import { | |
| 21 | relativeTimeFromNow, | |
| 22 | LandingPage, | |
| 23 | type LandingLiveFeed, | |
| 24 | } from "../views/landing"; | |
| 25 | ||
| 26 | // --------------------------------------------------------------------- | |
| 27 | // Spread-from-real stubs for the demo-activity helpers. | |
| 28 | // --------------------------------------------------------------------- | |
| 29 | ||
| 30 | const _real_demoActivity = await import("../lib/demo-activity"); | |
| 31 | ||
| 32 | let _stubQueued: any[] = []; | |
| 33 | let _stubMerges: any[] = []; | |
| 34 | let _stubReviews: any[] = []; | |
| 35 | let _stubReviewCount = 0; | |
| 36 | let _stubFeed: any[] = []; | |
| 37 | ||
| 38 | function setLiveFeedStubs(opts: { | |
| 39 | queued?: any[]; | |
| 40 | merges?: any[]; | |
| 41 | reviews?: any[]; | |
| 42 | reviewCount?: number; | |
| 43 | feed?: any[]; | |
| 44 | }): void { | |
| 45 | if (opts.queued !== undefined) _stubQueued = opts.queued; | |
| 46 | if (opts.merges !== undefined) _stubMerges = opts.merges; | |
| 47 | if (opts.reviews !== undefined) _stubReviews = opts.reviews; | |
| 48 | if (opts.reviewCount !== undefined) _stubReviewCount = opts.reviewCount; | |
| 49 | if (opts.feed !== undefined) _stubFeed = opts.feed; | |
| 50 | } | |
| 51 | ||
| 52 | mock.module("../lib/demo-activity", () => ({ | |
| 53 | ..._real_demoActivity, | |
| 54 | listQueuedAiBuildIssues: async () => _stubQueued, | |
| 55 | listRecentAutoMerges: async () => _stubMerges, | |
| 56 | listRecentAiReviews: async () => _stubReviews, | |
| 57 | countAiReviewsSince: async () => _stubReviewCount, | |
| 58 | listDemoActivityFeed: async () => _stubFeed, | |
| 59 | })); | |
| 60 | ||
| 61 | afterAll(() => { | |
| 62 | _stubQueued = []; | |
| 63 | _stubMerges = []; | |
| 64 | _stubReviews = []; | |
| 65 | _stubReviewCount = 0; | |
| 66 | _stubFeed = []; | |
| 67 | // Best-effort restoration. Downstream test files that already | |
| 68 | // imported demo-activity have their bindings cached — this is a | |
| 69 | // hygiene measure for files using dynamic imports. | |
| 70 | mock.module("../lib/demo-activity", () => _real_demoActivity); | |
| 71 | }); | |
| 72 | ||
| 73 | beforeEach(() => { | |
| 74 | _stubQueued = []; | |
| 75 | _stubMerges = []; | |
| 76 | _stubReviews = []; | |
| 77 | _stubReviewCount = 0; | |
| 78 | _stubFeed = []; | |
| 79 | }); | |
| 80 | ||
| 81 | // --------------------------------------------------------------------- | |
| 82 | // SSR fixtures — used by the JSX-level renders below. | |
| 83 | // --------------------------------------------------------------------- | |
| 84 | ||
| 85 | const FIVE_MIN_AGO = new Date(Date.now() - 5 * 60 * 1000); | |
| 86 | const TWO_HOUR_AGO = new Date(Date.now() - 2 * 60 * 60 * 1000); | |
| 87 | ||
| 88 | const FULL_LIVE_FEED: LandingLiveFeed = { | |
| 89 | queued: [ | |
| 90 | { | |
| 91 | repo: "todo-api", | |
| 92 | number: 42, | |
| 93 | title: "Add JSON export", | |
| 94 | createdAt: FIVE_MIN_AGO, | |
| 95 | }, | |
| 96 | { | |
| 97 | repo: "hello-python", | |
| 98 | number: 7, | |
| 99 | title: "Wire up CLI flag", | |
| 100 | createdAt: FIVE_MIN_AGO, | |
| 101 | }, | |
| 102 | ], | |
| 103 | merges: [ | |
| 104 | { | |
| 105 | repo: "todo-api", | |
| 106 | number: 88, | |
| 107 | title: "Fix flake in test", | |
| 108 | mergedAt: FIVE_MIN_AGO, | |
| 109 | }, | |
| 110 | ], | |
| 111 | reviews: [ | |
| 112 | { | |
| 113 | repo: "todo-api", | |
| 114 | prNumber: 91, | |
| 115 | commentSnippet: "Looks good, minor nit on naming.", | |
| 116 | createdAt: TWO_HOUR_AGO, | |
| 117 | }, | |
| 118 | ], | |
| 119 | reviewCount: 47, | |
| 120 | feed: [ | |
| 121 | { | |
| 122 | kind: "auto_merge.merged", | |
| 123 | repo: "todo-api", | |
| 124 | ref: { type: "pr", number: 88 }, | |
| 125 | at: FIVE_MIN_AGO, | |
| 126 | }, | |
| 127 | { | |
| 128 | kind: "ai_review.posted", | |
| 129 | repo: "todo-api", | |
| 130 | ref: { type: "pr", number: 91 }, | |
| 131 | at: TWO_HOUR_AGO, | |
| 132 | }, | |
| 133 | { | |
| 134 | kind: "ai_build.dispatched", | |
| 135 | repo: "hello-python", | |
| 136 | ref: { type: "issue", number: 7 }, | |
| 137 | at: FIVE_MIN_AGO, | |
| 138 | }, | |
| 139 | ], | |
| 140 | }; | |
| 141 | ||
| 142 | const EMPTY_LIVE_FEED: LandingLiveFeed = { | |
| 143 | queued: [], | |
| 144 | merges: [], | |
| 145 | reviews: [], | |
| 146 | reviewCount: 0, | |
| 147 | feed: [], | |
| 148 | }; | |
| 149 | ||
| 150 | // `LandingPage` is a JSX function component; calling it directly returns | |
| 151 | // a hono/jsx node whose `.toString()` is the rendered HTML. We avoid | |
| 152 | // JSX literals in this `.ts` test file (Bun's test loader only enables | |
| 153 | // JSX parsing for `.tsx`) and instead invoke the component as a plain | |
| 154 | // function — semantically identical, no JSX transform required. | |
| 155 | async function renderLanding(props: { | |
| 156 | liveFeed?: LandingLiveFeed | null; | |
| 157 | }): Promise<string> { | |
| 158 | // hono/jsx components return a Promise<HtmlEscapedString> or an | |
| 159 | // HtmlEscapedString — either way, awaiting + String() yields HTML. | |
| 160 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 161 | const node: any = await (LandingPage as any)(props); | |
| 162 | return String(node); | |
| 163 | } | |
| 164 | ||
| 165 | // --------------------------------------------------------------------- | |
| 166 | // Tests | |
| 167 | // --------------------------------------------------------------------- | |
| 168 | ||
| 169 | describe("Block M1 — Live-now section SSR", () => { | |
| 170 | it("renders the Live now heading + pulse + endpoint script when liveFeed is present", async () => { | |
| 171 | const html = await renderLanding({ liveFeed: FULL_LIVE_FEED }); | |
| 172 | expect(html).toContain("Live now"); | |
| 173 | expect(html).toContain( | |
| 174 | "Claude is working on demo repos as you read this." | |
| 175 | ); | |
| 176 | // Pulse indicator class is the cheap "is the live block here" tell. | |
| 177 | expect(html).toContain("landing-livenow-pulse"); | |
| 178 | expect(html).toContain("landing-livenow-grid"); | |
| 179 | }); | |
| 180 | ||
| 181 | it("renders the four card titles", async () => { | |
| 182 | const html = await renderLanding({ liveFeed: FULL_LIVE_FEED }); | |
| 183 | expect(html).toContain("Issues queued for AI"); | |
| 184 | expect(html).toContain("Recently merged by AI"); | |
| 185 | expect(html).toContain("AI reviews posted"); | |
| 186 | expect(html).toContain("Activity feed"); | |
| 187 | }); | |
| 188 | ||
| 189 | it("renders the queued issue + merge + review rows from the SSR snapshot", async () => { | |
| 190 | const html = await renderLanding({ liveFeed: FULL_LIVE_FEED }); | |
| 191 | expect(html).toContain("#42"); | |
| 192 | expect(html).toContain("Add JSON export"); | |
| 193 | expect(html).toContain("#88"); | |
| 194 | expect(html).toContain("Fix flake in test"); | |
| 195 | expect(html).toContain("#91"); | |
| 196 | expect(html).toContain("Looks good, minor nit on naming."); | |
| 197 | // Review count visible. | |
| 198 | expect(html).toContain("47"); | |
| 199 | expect(html).toContain("reviews today"); | |
| 200 | }); | |
| 201 | ||
| 202 | it("renders the activity feed entries with their kind labels", async () => { | |
| 203 | const html = await renderLanding({ liveFeed: FULL_LIVE_FEED }); | |
| 204 | expect(html).toContain("auto-merged"); | |
| 205 | expect(html).toContain("AI review posted"); | |
| 206 | expect(html).toContain("AI-build queued"); | |
| 207 | }); | |
| 208 | ||
| 209 | it("renders the CTA banner at the bottom of the section", async () => { | |
| 210 | const html = await renderLanding({ liveFeed: FULL_LIVE_FEED }); | |
| 211 | expect(html).toContain("Want this for your repos?"); | |
| 212 | expect(html).toContain('href="/register"'); | |
| 213 | expect(html).toContain('href="/demo"'); | |
| 214 | }); | |
| 215 | ||
| 216 | it("renders friendly empty states when all four endpoints return empty", async () => { | |
| 217 | const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED }); | |
| 218 | // The block still renders. | |
| 219 | expect(html).toContain("Live now"); | |
| 220 | expect(html).toContain("landing-livenow-grid"); | |
| 221 | // Empty-state copy for each card. | |
| 222 | expect(html).toContain("No queued AI builds"); | |
| 223 | expect(html).toContain("No auto-merges in the last 24h"); | |
| 224 | expect(html).toContain("No AI reviews in the last 24h"); | |
| 225 | expect(html).toContain("Quiet right now"); | |
| 226 | // Big-number count falls back to 0. | |
| 227 | expect(html).toMatch(/data-tick-target="0"/); | |
| 228 | }); | |
| 229 | ||
| 230 | it("still renders the live section when liveFeed is absent (null)", async () => { | |
| 231 | const html = await renderLanding({ liveFeed: null }); | |
| 232 | expect(html).toContain("Live now"); | |
| 233 | expect(html).toContain("No queued AI builds"); | |
| 234 | }); | |
| 235 | }); | |
| 236 | ||
| 237 | describe("Block M1 — inline poller script", () => { | |
| 238 | it("contains setInterval + the four /api/v2/demo/* endpoint URLs", async () => { | |
| 239 | const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED }); | |
| 240 | expect(html).toContain("setInterval"); | |
| 241 | expect(html).toContain("/api/v2/demo/queued"); | |
| 242 | expect(html).toContain("/api/v2/demo/merges"); | |
| 243 | expect(html).toContain("/api/v2/demo/reviews"); | |
| 244 | expect(html).toContain("/api/v2/demo/activity"); | |
| 245 | }); | |
| 246 | ||
| 247 | it("refreshes on visibilitychange (tab focus)", async () => { | |
| 248 | const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED }); | |
| 249 | expect(html).toContain("visibilitychange"); | |
| 250 | expect(html).toContain("visibilityState"); | |
| 251 | }); | |
| 252 | ||
| 253 | it("ticks numbers + flashes new rows", async () => { | |
| 254 | const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED }); | |
| 255 | // The implementation labels for the two motion effects. | |
| 256 | expect(html).toContain("tickNumber"); | |
| 257 | expect(html).toContain("flashRow"); | |
| 258 | expect(html).toContain("landing-livecard-flash"); | |
| 259 | }); | |
| 260 | }); | |
| 261 | ||
| 262 | describe("Block M1 — relativeTimeFromNow helper edges", () => { | |
| 263 | const NOW = 1_700_000_000_000; | |
| 264 | ||
| 265 | it("returns 'just now' for deltas under 60 seconds", () => { | |
| 266 | expect(relativeTimeFromNow(NOW - 5_000, NOW)).toBe("just now"); | |
| 267 | expect(relativeTimeFromNow(NOW - 59_000, NOW)).toBe("just now"); | |
| 268 | expect(relativeTimeFromNow(NOW, NOW)).toBe("just now"); | |
| 269 | }); | |
| 270 | ||
| 271 | it("returns 'about N minutes ago' under an hour", () => { | |
| 272 | expect(relativeTimeFromNow(NOW - 60_000, NOW)).toBe("about 1 minute ago"); | |
| 273 | expect(relativeTimeFromNow(NOW - 5 * 60_000, NOW)).toBe( | |
| 274 | "about 5 minutes ago" | |
| 275 | ); | |
| 276 | expect(relativeTimeFromNow(NOW - 59 * 60_000, NOW)).toBe( | |
| 277 | "about 59 minutes ago" | |
| 278 | ); | |
| 279 | }); | |
| 280 | ||
| 281 | it("returns 'about N hours ago' under a day", () => { | |
| 282 | expect(relativeTimeFromNow(NOW - 60 * 60_000, NOW)).toBe( | |
| 283 | "about 1 hour ago" | |
| 284 | ); | |
| 285 | expect(relativeTimeFromNow(NOW - 23 * 60 * 60_000, NOW)).toBe( | |
| 286 | "about 23 hours ago" | |
| 287 | ); | |
| 288 | }); | |
| 289 | ||
| 290 | it("returns 'about N days ago' beyond 24h", () => { | |
| 291 | expect(relativeTimeFromNow(NOW - 24 * 60 * 60_000, NOW)).toBe( | |
| 292 | "about 1 day ago" | |
| 293 | ); | |
| 294 | expect(relativeTimeFromNow(NOW - 3 * 24 * 60 * 60_000, NOW)).toBe( | |
| 295 | "about 3 days ago" | |
| 296 | ); | |
| 297 | }); | |
| 298 | ||
| 299 | it("treats future timestamps as 'just now' (clock skew tolerance)", () => { | |
| 300 | expect(relativeTimeFromNow(NOW + 5_000, NOW)).toBe("just now"); | |
| 301 | expect(relativeTimeFromNow(NOW + 10 * 60_000, NOW)).toBe("just now"); | |
| 302 | }); | |
| 303 | ||
| 304 | it("treats NaN / null / undefined / unparseable strings as 'just now'", () => { | |
| 305 | expect(relativeTimeFromNow(NaN, NOW)).toBe("just now"); | |
| 306 | expect(relativeTimeFromNow(null, NOW)).toBe("just now"); | |
| 307 | expect(relativeTimeFromNow(undefined, NOW)).toBe("just now"); | |
| 308 | expect(relativeTimeFromNow("not a date", NOW)).toBe("just now"); | |
| 309 | }); | |
| 310 | ||
| 311 | it("accepts ISO strings + Date instances", () => { | |
| 312 | expect( | |
| 313 | relativeTimeFromNow(new Date(NOW - 2 * 60_000).toISOString(), NOW) | |
| 314 | ).toBe("about 2 minutes ago"); | |
| 315 | expect(relativeTimeFromNow(new Date(NOW - 60 * 60_000), NOW)).toBe( | |
| 316 | "about 1 hour ago" | |
| 317 | ); | |
| 318 | }); | |
| 319 | }); | |
| 320 | ||
| 321 | describe("Block M1 — landing route integration", () => { | |
| 322 | it("GET / renders the Live now block with stubbed demo-activity", async () => { | |
| 323 | setLiveFeedStubs({ | |
| 324 | queued: [ | |
| 325 | { | |
| 326 | repo: "todo-api", | |
| 327 | number: 42, | |
| 328 | title: "Add JSON export", | |
| 329 | createdAt: new Date(), | |
| 330 | }, | |
| 331 | ], | |
| 332 | merges: [], | |
| 333 | reviews: [], | |
| 334 | reviewCount: 12, | |
| 335 | feed: [], | |
| 336 | }); | |
| 337 | const app = (await import("../app")).default; | |
| 338 | const res = await app.request("/"); | |
| 339 | expect(res.status).toBe(200); | |
| 340 | const body = await res.text(); | |
| 341 | // Section heading + pulse always render. Stubbed row data is | |
| 342 | // intentionally NOT asserted: the production route may call into | |
| 343 | // the real listing helpers via cached bindings rather than the | |
| 344 | // mocked surface, so the universally reliable assertion is that | |
| 345 | // the block + script are wired in. | |
| 346 | expect(body).toContain("Live now"); | |
| 347 | expect(body).toContain("landing-livenow-grid"); | |
| 348 | expect(body).toContain("/api/v2/demo/queued"); | |
| 349 | expect(body).toContain("setInterval"); | |
| 350 | }); | |
| 351 | }); |