Skip to content

Commit fabfcb4

Browse files
committed
testing: event-pump tests on stub HttpClient + TestClock (comms-e5vm.2)
Tier-2 proof: migrate the event-pump / long-poll LOGIC tests off the real Bun.serve realm onto the owned-fake stub HttpClient driven by TestClock, exercised through the full adapter stack. Deterministic, no socket. - Extend the stub seam with a hang ({ hang: true }) response variant that parks on Effect.never. An instant in-memory stub has no long-poll *hold*, so an eager Stream.runDrain would burn through the whole response sequence in a hot loop; the hang models the hold (and stops one iterator racing ahead into another's BAD_EVENT_QUEUE_ID). The downstream Tier-2 migrations (.3/.7/.8) drive the same stack and get it from the shared seam. - New packages/zulip/adapter-events.test.ts on stub + TestClock via effectTest: gap-replay watermark across iterator instances (comms-4au), 429 RATE_LIMIT_HIT recovery on /events and /register (comms-9wi), BAD_EVENT_QUEUE_ID reconnect + inbox.replay() gap-backfill (comms-jnn), cursor/heartbeat advancement, and the scope-close fiber-interrupt (spj3.8). - Delete the migrated tests and the happy-path live-contract duplicates (first .next, mention-received) from adapter.test.ts. The rest of adapter.test.ts stays on the realm for comms-e5vm.3. Dissolves the real-socket contention flake structurally: the hanging long-poll tests leave the realm-bound file, so comms-hbm9 auto-resolves. Tier-3 residue for comms-4lz5: the genuine AbortSignal -> fetch -> TCP teardown of an in-flight FetchHttpClient long-poll on scope close stays one real-socket test; the stub only proves the Effect fiber-interrupt logic.
1 parent 2e0f52c commit fabfcb4

4 files changed

Lines changed: 569 additions & 614 deletions

File tree

‎packages/testing/stub-http-client.test.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect, test } from 'bun:test'
22
import { HttpClient, HttpClientRequest } from '@effect/platform'
3-
import { Effect } from 'effect'
3+
import { Duration, Effect, Fiber, Option } from 'effect'
44
import { makeStubHttpClient } from './stub-http-client.ts'
55

66
const REALM = 'https://zulip.example.com/api/v1'
@@ -202,6 +202,29 @@ test('opens no socket — a request to an unroutable host still resolves', () =>
202202
}),
203203
))
204204

205+
test('a hang response captures the request, then never resolves and stays interruptible', () =>
206+
Effect.runPromise(
207+
Effect.gen(function* () {
208+
const stub = yield* makeStubHttpClient
209+
// The long-poll hold: the stub answers instantly for everything else, so
210+
// a terminal hang is what stops an eager consumer draining the sequence.
211+
yield* stub.respondSequence('GET', '/api/v1/events', [{ hang: true }])
212+
const fiber = yield* Effect.fork(
213+
stub.client.execute(HttpClientRequest.get(`${REALM}/events`)),
214+
)
215+
// Let the forked request issue and park on the hang.
216+
yield* Effect.sleep(Duration.millis(10))
217+
const captured = yield* stub.captured
218+
expect(captured).toHaveLength(1)
219+
expect(captured[0]?.url.pathname).toBe('/api/v1/events')
220+
// Still parked — a hang resolves to no Exit.
221+
expect(Option.isNone(yield* fiber.poll)).toBe(true)
222+
// Interrupting unwinds it cleanly (the scope-close path).
223+
const exit = yield* Fiber.interrupt(fiber)
224+
expect(exit._tag).toBe('Failure')
225+
}),
226+
))
227+
205228
test('is a drop-in for the HttpClient.HttpClient service', () =>
206229
Effect.runPromise(
207230
Effect.gen(function* () {

‎packages/testing/stub-http-client.ts‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,22 @@ import {
2323
type HttpClientRequest,
2424
HttpClientResponse,
2525
} from '@effect/platform'
26-
import { Data, Effect, HashMap, Option, Ref } from 'effect'
26+
import { Data, Effect, HashMap, Option, Predicate, Ref } from 'effect'
2727

28-
export type StubResponse = {
28+
/**
29+
* A response that never resolves — the request is captured, then the effect
30+
* parks on `Effect.never`. Models the real Zulip long-poll *holding* the
31+
* connection open: an in-memory stub answers instantly, so without a hold an
32+
* eager consumer (the event-pump's `Stream.runDrain`) would burn through the
33+
* whole response sequence in a hot loop. A fiber blocked on a hang is
34+
* *interrupted* (not errored) when its scope closes — which is exactly the
35+
* scope-close-interrupt path the event-pump tests exercise.
36+
*/
37+
export type StubHang = {
38+
readonly hang: true
39+
}
40+
41+
export type StubBody = {
2942
/** Object → JSON-encoded; string → verbatim; `Uint8Array` → raw bytes. */
3043
readonly body: unknown
3144
/** HTTP status; defaults to 200. */
@@ -34,6 +47,8 @@ export type StubResponse = {
3447
readonly headers?: Readonly<Record<string, string>>
3548
}
3649

50+
export type StubResponse = StubBody | StubHang
51+
3752
export type CapturedHttpRequest = {
3853
readonly method: string
3954
readonly url: URL
@@ -87,19 +102,19 @@ const requestBodyInit = (body: HttpBody.HttpBody): string | Uint8Array | FormDat
87102
}
88103
}
89104

90-
const responseBodyInit = (response: StubResponse): string | Uint8Array => {
105+
const responseBodyInit = (response: StubBody): string | Uint8Array => {
91106
if (response.body instanceof Uint8Array) return response.body
92107
if (typeof response.body === 'string') return response.body
93108
return JSON.stringify(response.body)
94109
}
95110

96-
const responseHeaders = (response: StubResponse): Record<string, string> => {
111+
const responseHeaders = (response: StubBody): Record<string, string> => {
97112
const base: Record<string, string> =
98113
response.body instanceof Uint8Array ? {} : { 'content-type': 'application/json' }
99114
return { ...base, ...response.headers }
100115
}
101116

102-
const notFound = (method: string, path: string): StubResponse => ({
117+
const notFound = (method: string, path: string): StubBody => ({
103118
body: { result: 'error', code: 'NO_STUB_HANDLER', msg: `no stub handler for ${method} ${path}` },
104119
status: 404,
105120
})
@@ -155,14 +170,18 @@ export const makeStubHttpClient: Effect.Effect<StubHttpClient> = Effect.gen(func
155170
const client = HttpClient.make((request, url) =>
156171
capture(request, url).pipe(
157172
Effect.zipRight(nextResponse(request.method, url.pathname)),
158-
Effect.map((response) =>
159-
HttpClientResponse.fromWeb(
160-
request,
161-
new Response(responseBodyInit(response), {
162-
status: response.status ?? 200,
163-
headers: responseHeaders(response),
164-
}),
165-
),
173+
Effect.flatMap((response) =>
174+
Predicate.hasProperty(response, 'hang')
175+
? Effect.never
176+
: Effect.succeed(
177+
HttpClientResponse.fromWeb(
178+
request,
179+
new Response(responseBodyInit(response), {
180+
status: response.status ?? 200,
181+
headers: responseHeaders(response),
182+
}),
183+
),
184+
),
166185
),
167186
),
168187
)

0 commit comments

Comments
 (0)