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

pr-live.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.

pr-live.test.tsBlame352 lines · 1 contributor
3c03977Claude1/**
2 * PR live co-editing — session lifecycle, cursor fan-out, stale sweep.
3 *
4 * Two layers:
5 * - Pure helpers (colorFor determinism, topic naming) run
6 * unconditionally.
7 * - DB-backed flows (join/leave, listLive, sweepStale) and the
8 * in-process broadcast fan-out are gated behind HAS_DB so the
9 * suite stays green on machines without Postgres.
10 */
11
12import { describe, it, expect, beforeAll } from "bun:test";
13import { randomBytes } from "crypto";
14import {
15 colorFor,
16 prLiveTopic,
17 type CursorPosition,
18} from "../lib/pr-live";
19import { subscribe, type SSEEvent } from "../lib/sse";
20
21const HAS_DB = Boolean(process.env.DATABASE_URL);
22
23beforeAll(() => {
24 process.env.DATABASE_URL = process.env.DATABASE_URL || "";
25});
26
27// ---------------------------------------------------------------------------
28// Pure helpers (no DB)
29// ---------------------------------------------------------------------------
30
31describe("pr-live — colour helper", () => {
32 it("returns a stable colour for the same principal", () => {
33 const id = "user-abc-123";
34 expect(colorFor(id)).toBe(colorFor(id));
35 });
36
37 it("returns a hex colour from the curated palette", () => {
38 const c = colorFor("user-x");
39 expect(/^#[0-9a-f]{6}$/i.test(c)).toBe(true);
40 });
41
42 it("disjoint principals usually map to different colours", () => {
43 // Hash-mod over a 10-entry palette: two random ids land on
44 // different bins ~90% of the time. We probe 20 pairs and only
45 // assert that *most* differ — the property we care about is
46 // distribution, not strict pairwise inequality.
47 let differ = 0;
48 for (let i = 0; i < 20; i++) {
49 const a = colorFor(`p-${i}-a`);
50 const b = colorFor(`p-${i}-b`);
51 if (a !== b) differ++;
52 }
53 expect(differ).toBeGreaterThanOrEqual(14);
54 });
55});
56
57describe("pr-live — topic naming", () => {
58 it("prefixes pr-live: + prId", () => {
59 expect(prLiveTopic("abc-123")).toBe("pr-live:abc-123");
60 });
61});
62
63// ---------------------------------------------------------------------------
64// Broadcast fan-out — pure pub/sub layer, no DB needed.
65// ---------------------------------------------------------------------------
66
67describe("pr-live — broadcast fan-out (no DB)", () => {
68 it("publishes a cursor event to subscribers on the same topic", async () => {
69 const { updateCursor } = await import("../lib/pr-live");
70 const prId = `tmp-pr-${randomBytes(4).toString("hex")}`;
71 const events: SSEEvent[] = [];
72 const unsub = subscribe(prLiveTopic(prId), (e) => events.push(e));
73
74 const position: CursorPosition = {
75 field: "description",
76 range: { start: 4, end: 4 },
77 };
78 // sessionId can be a synthetic value — the in-process broadcaster
79 // does not require the row to exist in the DB.
80 await updateCursor(`tmp-${randomBytes(4).toString("hex")}`, prId, position);
81
82 expect(events.length).toBeGreaterThan(0);
83 const cursorEvt = events.find((e) => e.event === "cursor");
84 expect(cursorEvt).toBeDefined();
85 const data = cursorEvt!.data as { position: CursorPosition };
86 expect(data.position.field).toBe("description");
87 expect(data.position.range.start).toBe(4);
88
89 unsub();
90 });
91
92 it("publishes presence-leave on leaveSession", async () => {
93 const { leaveSession } = await import("../lib/pr-live");
94 const prId = `tmp-pr-${randomBytes(4).toString("hex")}`;
95 const events: SSEEvent[] = [];
96 const unsub = subscribe(prLiveTopic(prId), (e) => events.push(e));
97
98 await leaveSession(`tmp-${randomBytes(4).toString("hex")}`, prId);
99 expect(events.some((e) => e.event === "presence-leave")).toBe(true);
100 unsub();
101 });
102
103 it("publishes an edit event on broadcastEdit", async () => {
104 const { broadcastEdit } = await import("../lib/pr-live");
105 const prId = `tmp-pr-${randomBytes(4).toString("hex")}`;
106 const events: SSEEvent[] = [];
107 const unsub = subscribe(prLiveTopic(prId), (e) => events.push(e));
108
109 await broadcastEdit(`tmp-${randomBytes(4).toString("hex")}`, prId, {
110 field: "comment_new",
111 op: "replace",
112 at: 0,
113 value: "hi",
114 });
115
116 const editEvt = events.find((e) => e.event === "edit");
117 expect(editEvt).toBeDefined();
118 const data = editEvt!.data as { patch: { value: string } };
119 expect(data.patch.value).toBe("hi");
120 unsub();
121 });
122});
123
124// ---------------------------------------------------------------------------
125// DB-backed lifecycle + stale sweep.
126// ---------------------------------------------------------------------------
127
128describe.skipIf(!HAS_DB)("pr-live — DB lifecycle", () => {
129 /**
130 * Seed a real user + repository + PR so foreign keys are satisfied.
131 * Returns the PR id (which is what the live-session table joins on).
132 */
133 async function seedPr(): Promise<string | null> {
134 const { db } = await import("../db");
135 const { users, repositories, pullRequests } = await import("../db/schema");
136 const stamp = randomBytes(4).toString("hex");
137 const [u] = await db
138 .insert(users)
139 .values({
140 username: `prlive-${stamp}`,
141 email: `prlive-${stamp}@test.local`,
142 passwordHash: "x",
143 })
144 .returning();
145 if (!u) return null;
146
147 const [r] = await db
148 .insert(repositories)
149 .values({
150 name: `prlive-repo-${stamp}`,
151 ownerId: u.id,
152 diskPath: `/tmp/prlive-${stamp}`,
153 defaultBranch: "main",
154 })
155 .returning();
156 if (!r) return null;
157
158 const [pr] = await db
159 .insert(pullRequests)
160 .values({
161 repositoryId: r.id,
162 authorId: u.id,
163 title: "live edit test",
164 baseBranch: "main",
165 headBranch: `feat-${stamp}`,
166 })
167 .returning();
168 return pr?.id ?? null;
169 }
170
171 it("joinSession inserts a row and returns sessionId + colour", async () => {
172 const { joinSession, getSession } = await import("../lib/pr-live");
173 const { db } = await import("../db");
174 const { users } = await import("../db/schema");
175
176 const prId = await seedPr();
177 expect(prId).not.toBeNull();
178 if (!prId) return;
179
180 const stamp = randomBytes(4).toString("hex");
181 const [u] = await db
182 .insert(users)
183 .values({
184 username: `joiner-${stamp}`,
185 email: `joiner-${stamp}@test.local`,
186 passwordHash: "x",
187 })
188 .returning();
189 if (!u) return;
190
191 const joined = await joinSession({ prId, userId: u.id });
192 expect(joined).not.toBeNull();
193 if (!joined) return;
194 expect(joined.color).toMatch(/^#[0-9a-f]{6}$/i);
195 expect(joined.sessionId.length).toBeGreaterThan(0);
196
197 const row = await getSession(joined.sessionId);
198 expect(row).not.toBeNull();
199 expect(row?.status).toBe("active");
200 expect(row?.prId).toBe(prId);
201 });
202
203 it("leaveSession transitions status to 'left'", async () => {
204 const { joinSession, leaveSession, getSession } = await import(
205 "../lib/pr-live"
206 );
207 const { db } = await import("../db");
208 const { users } = await import("../db/schema");
209
210 const prId = await seedPr();
211 if (!prId) return;
212 const stamp = randomBytes(4).toString("hex");
213 const [u] = await db
214 .insert(users)
215 .values({
216 username: `leaver-${stamp}`,
217 email: `leaver-${stamp}@test.local`,
218 passwordHash: "x",
219 })
220 .returning();
221 if (!u) return;
222
223 const joined = await joinSession({ prId, userId: u.id });
224 if (!joined) return;
225 await leaveSession(joined.sessionId, prId);
226 const row = await getSession(joined.sessionId);
227 expect(row?.status).toBe("left");
228 });
229
230 it("listLive omits left sessions but keeps active + idle", async () => {
231 const { joinSession, leaveSession, listLive } = await import(
232 "../lib/pr-live"
233 );
234 const { db } = await import("../db");
235 const { users } = await import("../db/schema");
236
237 const prId = await seedPr();
238 if (!prId) return;
239 const mk = async () => {
240 const stamp = randomBytes(4).toString("hex");
241 const [u] = await db
242 .insert(users)
243 .values({
244 username: `m-${stamp}`,
245 email: `m-${stamp}@test.local`,
246 passwordHash: "x",
247 })
248 .returning();
249 return u?.id ?? null;
250 };
251 const u1 = await mk();
252 const u2 = await mk();
253 if (!u1 || !u2) return;
254
255 const j1 = await joinSession({ prId, userId: u1 });
256 const j2 = await joinSession({ prId, userId: u2 });
257 expect(j1).not.toBeNull();
258 expect(j2).not.toBeNull();
259
260 let list = await listLive(prId);
261 expect(list.length).toBeGreaterThanOrEqual(2);
262
263 if (j2) await leaveSession(j2.sessionId, prId);
264 list = await listLive(prId);
265 const ids = list.map((s) => s.id);
266 if (j2) expect(ids.includes(j2.sessionId)).toBe(false);
267 if (j1) expect(ids.includes(j1.sessionId)).toBe(true);
268 });
269
270 it("sweepStale marks idle (>60s) and left (>5m) rows", async () => {
271 const { joinSession, sweepStale, getSession } = await import(
272 "../lib/pr-live"
273 );
274 const { db } = await import("../db");
275 const { users, prLiveSessions } = await import("../db/schema");
276 const { eq } = await import("drizzle-orm");
277
278 const prId = await seedPr();
279 if (!prId) return;
280 const stamp = randomBytes(4).toString("hex");
281 const [u] = await db
282 .insert(users)
283 .values({
284 username: `stale-${stamp}`,
285 email: `stale-${stamp}@test.local`,
286 passwordHash: "x",
287 })
288 .returning();
289 if (!u) return;
290
291 const joined = await joinSession({ prId, userId: u.id });
292 if (!joined) return;
293 // Back-date last_seen_at by 70s so the next sweep transitions to
294 // 'idle'.
295 const idleStamp = new Date(Date.now() - 70_000);
296 await db
297 .update(prLiveSessions)
298 .set({ lastSeenAt: idleStamp })
299 .where(eq(prLiveSessions.id, joined.sessionId));
300
301 const r1 = await sweepStale();
302 expect(r1.idled).toBeGreaterThanOrEqual(1);
303 const idleRow = await getSession(joined.sessionId);
304 expect(idleRow?.status).toBe("idle");
305
306 // Push past 5 minutes; next sweep should mark 'left'.
307 const leftStamp = new Date(Date.now() - 6 * 60_000);
308 await db
309 .update(prLiveSessions)
310 .set({ lastSeenAt: leftStamp })
311 .where(eq(prLiveSessions.id, joined.sessionId));
312
313 const r2 = await sweepStale();
314 expect(r2.left).toBeGreaterThanOrEqual(1);
315 const leftRow = await getSession(joined.sessionId);
316 expect(leftRow?.status).toBe("left");
317 });
318
319 it("updateCursor persists position + touches last_seen_at", async () => {
320 const { joinSession, updateCursor, getSession } = await import(
321 "../lib/pr-live"
322 );
323 const { db } = await import("../db");
324 const { users } = await import("../db/schema");
325
326 const prId = await seedPr();
327 if (!prId) return;
328 const stamp = randomBytes(4).toString("hex");
329 const [u] = await db
330 .insert(users)
331 .values({
332 username: `cursor-${stamp}`,
333 email: `cursor-${stamp}@test.local`,
334 passwordHash: "x",
335 })
336 .returning();
337 if (!u) return;
338 const joined = await joinSession({ prId, userId: u.id });
339 if (!joined) return;
340
341 await updateCursor(joined.sessionId, prId, {
342 field: "description",
343 range: { start: 12, end: 18 },
344 });
345
346 const row = await getSession(joined.sessionId);
347 expect(row?.cursor?.field).toBe("description");
348 expect(row?.cursor?.range.start).toBe(12);
349 expect(row?.cursor?.range.end).toBe(18);
350 expect(row?.status).toBe("active");
351 });
352});