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.
| 3c03977 | 1 | /** |
| 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 | ||
| 12 | import { describe, it, expect, beforeAll } from "bun:test"; | |
| 13 | import { randomBytes } from "crypto"; | |
| 14 | import { | |
| 15 | colorFor, | |
| 16 | prLiveTopic, | |
| 17 | type CursorPosition, | |
| 18 | } from "../lib/pr-live"; | |
| 19 | import { subscribe, type SSEEvent } from "../lib/sse"; | |
| 20 | ||
| 21 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 22 | ||
| 23 | beforeAll(() => { | |
| 24 | process.env.DATABASE_URL = process.env.DATABASE_URL || ""; | |
| 25 | }); | |
| 26 | ||
| 27 | // --------------------------------------------------------------------------- | |
| 28 | // Pure helpers (no DB) | |
| 29 | // --------------------------------------------------------------------------- | |
| 30 | ||
| 31 | describe("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 | ||
| 57 | describe("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 | ||
| 67 | describe("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 | ||
| 128 | describe.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 | }); |