Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

landing-live-feed.test.tsBlame351 lines · 1 contributor
534f04aClaude1/**
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
19import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
20import {
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
30const _real_demoActivity = await import("../lib/demo-activity");
31
32let _stubQueued: any[] = [];
33let _stubMerges: any[] = [];
34let _stubReviews: any[] = [];
35let _stubReviewCount = 0;
36let _stubFeed: any[] = [];
37
38function 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
52mock.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
61afterAll(() => {
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
73beforeEach(() => {
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
85const FIVE_MIN_AGO = new Date(Date.now() - 5 * 60 * 1000);
86const TWO_HOUR_AGO = new Date(Date.now() - 2 * 60 * 60 * 1000);
87
88const 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
142const 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.
155async 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
169describe("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
237describe("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
262describe("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
321describe("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});