From bc409b9815da8cae6429d68956557efa7fb23041 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:24:01 +0000 Subject: [PATCH 01/15] fix(e2e): add ClickHouse retry/polling to session replay filter and pagination tests Multiple session replay tests were flaky because they queried ClickHouse immediately after uploading data without waiting for ingestion. ClickHouse ingestion is asynchronous and can take several seconds under load. Tests that called listReplays() directly after uploadBatch() would fail intermittently when the data hadn't been ingested yet. Root cause: ClickHouse eventual consistency - the tests assumed synchronous data availability after writes. Fix: Use listReplaysWithRetry() (which polls with 500ms intervals up to 30 attempts) in all filter and pagination tests that depend on recently uploaded data being visible in ClickHouse queries. This includes: - filters by user_ids - filters by team_ids - filters by duration range - filters by last_event_at time range - pagination without skipping items - pagination with identical timestamps - combines filters with AND semantics - chunks pagination Co-Authored-By: Konstantin Wohlwend --- .../endpoints/api/v1/session-replays.test.ts | 110 +++++++++++++----- 1 file changed, 82 insertions(+), 28 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index 826712a544..6de5cab291 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -528,6 +528,16 @@ it("admin list session replays paginates without skipping items", async ({ expec expect(uploadB.status).toBe(200); const recordingB = uploadB.body?.session_replay_id; + // Wait for ClickHouse to ingest both replays before paginating + await listReplaysWithRetry( + {}, + (res) => { + const items = res.body?.items ?? []; + const ids = items.map((i: any) => i.id); + return res.status === 200 && ids.includes(recordingA) && ids.includes(recordingB); + }, + ); + const first = await niceBackendFetch("/api/v1/internal/session-replays?limit=1", { method: "GET", accessType: "admin", @@ -752,10 +762,16 @@ it("admin list chunks paginates and rejects a cursor from another session", asyn expect(upload2.status).toBe(200); const recording2 = upload2.body?.session_replay_id; - const first = await niceBackendFetch(`/api/v1/internal/session-replays/${recording1}/chunks?limit=1`, { - method: "GET", - accessType: "admin", - }); + // Wait for ClickHouse to ingest both sessions' chunks before paginating + let first: any; + for (let attempt = 0; attempt < 30; attempt++) { + first = await niceBackendFetch(`/api/v1/internal/session-replays/${recording1}/chunks?limit=1`, { + method: "GET", + accessType: "admin", + }); + if (first.status === 200 && (first.body?.items?.length ?? 0) >= 1 && first.body?.pagination?.next_cursor) break; + await wait(500); + } expect(first.status).toBe(200); expect(first.body?.items?.length).toBe(1); @@ -1021,14 +1037,20 @@ it("admin list session replays filters by user_ids", async ({ expect }) => { expect(resBoth.status).toBe(200); expect(resBoth.body?.items?.length).toBe(2); - // Filter by user A only - const resA = await listReplays({ user_ids: userA.userId }); + // Filter by user A only (ClickHouse already confirmed ingested above) + const resA = await listReplaysWithRetry( + { user_ids: userA.userId }, + (res) => res.status === 200 && res.body?.items?.length === 1, + ); expect(resA.status).toBe(200); expect(resA.body?.items?.length).toBe(1); expect(resA.body?.items?.[0]?.project_user?.id).toBe(userA.userId); // Filter by user B only - const resB = await listReplays({ user_ids: userB.userId }); + const resB = await listReplaysWithRetry( + { user_ids: userB.userId }, + (res) => res.status === 200 && res.body?.items?.length === 1, + ); expect(resB.status).toBe(200); expect(resB.body?.items?.length).toBe(1); expect(resB.body?.items?.[0]?.project_user?.id).toBe(userB.userId); @@ -1068,8 +1090,11 @@ it("admin list session replays filters by team_ids", async ({ expect }) => { }); expect(uploadB.status).toBe(200); - // Filter by team → only user A's replay - const resTeam = await listReplays({ team_ids: teamId }); + // Filter by team → only user A's replay (wait for ClickHouse to ingest) + const resTeam = await listReplaysWithRetry( + { team_ids: teamId }, + (res) => res.status === 200 && res.body?.items?.length === 1, + ); expect(resTeam.status).toBe(200); expect(resTeam.body?.items?.length).toBe(1); expect(resTeam.body?.items?.[0]?.project_user?.id).toBe(userA.userId); @@ -1117,23 +1142,32 @@ it("admin list session replays filters by duration range", async ({ expect }) => expect(uploadLong.status).toBe(200); const longId = uploadLong.body?.session_replay_id; + // Wait for ClickHouse to ingest both replays before asserting filters + const resBoth = await listReplaysWithRetry( + { duration_ms_min: "0", duration_ms_max: "50000" }, + (res) => res.status === 200 && res.body?.items?.length === 2, + ); + expect(resBoth.status).toBe(200); + expect(resBoth.body?.items?.length).toBe(2); + // duration_ms_min=10000 → only long replay - const resMin = await listReplays({ duration_ms_min: "10000" }); + const resMin = await listReplaysWithRetry( + { duration_ms_min: "10000" }, + (res) => res.status === 200 && res.body?.items?.length === 1, + ); expect(resMin.status).toBe(200); expect(resMin.body?.items?.length).toBe(1); expect(resMin.body?.items?.[0]?.id).toBe(longId); // duration_ms_max=10000 → only short replay - const resMax = await listReplays({ duration_ms_max: "10000" }); + const resMax = await listReplaysWithRetry( + { duration_ms_max: "10000" }, + (res) => res.status === 200 && res.body?.items?.length === 1, + ); expect(resMax.status).toBe(200); expect(resMax.body?.items?.length).toBe(1); expect(resMax.body?.items?.[0]?.id).toBe(shortId); - // duration range that includes both: 0–50000 - const resBoth = await listReplays({ duration_ms_min: "0", duration_ms_max: "50000" }); - expect(resBoth.status).toBe(200); - expect(resBoth.body?.items?.length).toBe(2); - // duration range that includes neither: 10000–20000 const resNeither = await listReplays({ duration_ms_min: "10000", duration_ms_max: "20000" }); expect(resNeither.status).toBe(200); @@ -1172,26 +1206,32 @@ it("admin list session replays filters by last_event_at time range", async ({ ex expect(uploadLate.status).toBe(200); const lateId = uploadLate.body?.session_replay_id; - // Filter from midpoint → only late replay + // Wait for ClickHouse to ingest both replays before asserting filters const midpoint = earlyTime + 50_000; - const resFrom = await listReplays({ last_event_at_from_millis: String(midpoint) }); + const resBoth = await listReplaysWithRetry( + { last_event_at_from_millis: String(earlyTime), last_event_at_to_millis: String(lateTime + 200) }, + (res) => res.status === 200 && res.body?.items?.length === 2, + ); + expect(resBoth.status).toBe(200); + expect(resBoth.body?.items?.length).toBe(2); + + // Filter from midpoint → only late replay + const resFrom = await listReplaysWithRetry( + { last_event_at_from_millis: String(midpoint) }, + (res) => res.status === 200 && res.body?.items?.length === 1, + ); expect(resFrom.status).toBe(200); expect(resFrom.body?.items?.length).toBe(1); expect(resFrom.body?.items?.[0]?.id).toBe(lateId); // Filter to midpoint → only early replay - const resTo = await listReplays({ last_event_at_to_millis: String(midpoint) }); + const resTo = await listReplaysWithRetry( + { last_event_at_to_millis: String(midpoint) }, + (res) => res.status === 200 && res.body?.items?.length === 1, + ); expect(resTo.status).toBe(200); expect(resTo.body?.items?.length).toBe(1); expect(resTo.body?.items?.[0]?.id).toBe(earlyId); - - // Filter range that includes both - const resBoth = await listReplays({ - last_event_at_from_millis: String(earlyTime), - last_event_at_to_millis: String(lateTime + 200), - }); - expect(resBoth.status).toBe(200); - expect(resBoth.body?.items?.length).toBe(2); }); it("admin list session replays filters by click_count_min", async ({ expect }) => { @@ -1333,6 +1373,16 @@ it("admin list session replays paginates correctly when last_event_at timestamps expect(uploadB.status).toBe(200); const replayIdB = uploadB.body?.session_replay_id; + // Wait for ClickHouse to ingest both replays before paginating + await listReplaysWithRetry( + {}, + (res) => { + const items = res.body?.items ?? []; + const ids = items.map((i: any) => i.id); + return res.status === 200 && ids.includes(replayIdA) && ids.includes(replayIdB); + }, + ); + const first = await listReplays({ limit: "1" }); expect(first.status).toBe(200); expect(first.body?.items?.length).toBe(1); @@ -1379,7 +1429,11 @@ it("admin list session replays combines filters with AND semantics", async ({ ex }); expect(uploadB.status).toBe(200); - const matchingIntersection = await listReplays({ user_ids: userA.userId, team_ids: teamId }); + // Wait for ClickHouse to ingest both replays before asserting combined filters + const matchingIntersection = await listReplaysWithRetry( + { user_ids: userA.userId, team_ids: teamId }, + (res) => res.status === 200 && res.body?.items?.length === 1, + ); expect(matchingIntersection.status).toBe(200); expect(matchingIntersection.body?.items?.length).toBe(1); expect(matchingIntersection.body?.items?.[0]?.project_user?.id).toBe(userA.userId); From 3e5230c2ef3557f57034920410bbd0f0f41f01d9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:24:44 +0000 Subject: [PATCH 02/15] fix(e2e): increase session expiry test time budget from 5s/6s to 10s/11s The 'creates sessions that expire' test was flaky because it created a session with a 5-second expiry and asserted that the refresh request completed within 6 seconds of session creation. Under CI load, API requests can take 7-8 seconds total (project creation + session creation + refresh + auth check), exceeding the 6s budget. Root cause: The 1-second margin (6s threshold - 5s expiry) was insufficient for CI environments where multiple test suites run in parallel and API latency spikes. Fix: Increase the session expiry to 10 seconds and the threshold to 11 seconds. This preserves the test's intent (verify sessions expire) while providing enough headroom for CI latency. The test still validates expiry behavior since it waits for the full 10s before checking that the session is expired. Co-Authored-By: Konstantin Wohlwend --- .../backend/endpoints/api/v1/auth/sessions/index.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/index.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/index.test.ts index 479269e127..01ccc48408 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/index.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/index.test.ts @@ -67,7 +67,7 @@ it("creates sessions that expire", async ({ expect }) => { method: "POST", body: { user_id: res.userId, - expires_in_millis: 5_000, + expires_in_millis: 10_000, }, }); expect(res2).toMatchInlineSnapshot(` @@ -80,7 +80,7 @@ it("creates sessions that expire", async ({ expect }) => { "headers": Headers {