diff --git a/packages/zulip/contract.live.test.ts b/packages/zulip/contract.live.test.ts index 54628da..a34942d 100644 --- a/packages/zulip/contract.live.test.ts +++ b/packages/zulip/contract.live.test.ts @@ -18,15 +18,18 @@ * (`/register`) — a reused adapter re-polls a stale queue that silently * stops delivering. The bot account is suite-scoped; only the adapter is * per-test. - * - **Per-test channel namespace.** `seedChannel(name)` creates a real stream - * named `cc-ct---` so each test reads/writes an isolated - * channel. The contract reuses fixed logical names (`lobby`, `alpha`) across - * ~50 tests; on a realm that persists channel state that is cross-test - * contamination within a run (the channel-wide `readChannel` range tests - * assert `not.toContain` / exact-equality). The fake sidesteps it by - * building a fresh realm per `beforeEach`; the live factory namespaces - * instead. Streams accumulate (the non-admin minter cannot delete them) — - * acceptable on a test realm. + * - **A fixed pool of channels, emptied before each use.** Zulip's API cannot + * delete a channel, and an archived one still loads in every web client, so + * a run must create none. `seedChannel(name)` maps each logical name the + * contract uses (`lobby`, `alpha`, …) to one permanent stream, + * `cc-ct-`, created by the first run ever and reused by every run + * after. The contract's channel-wide reads assert exact contents, so the + * first seed of a name in a test empties that stream: it clears the + * description and moves every topic out to `cc-ct-spent`. The minter created + * both streams, so the realm makes it their administrator, which lets it + * move topics out. Emptying before use rather than after covers a run that + * failed or was killed. The fake sidesteps all this by building a fresh + * realm per `beforeEach`. * - **Shared peer agents.** `seedAgent(name)` find-or-creates a persistent * peer bot (`cc-contract-peer-`) once and reuses it — peers carry no * per-test state, so suite-level reuse costs no per-test mint. @@ -91,13 +94,21 @@ const httpClient = Effect.runSync(HttpClient.HttpClient.pipe(Effect.provide(Fetc const MINTER_PACE = Duration.millis(900) const SELF_NAME = decodeBotNameSync('cc-contract-self') -const NS_PREFIX = 'cc-ct' +// ponytail: one pool per realm, so two runs at once on the same realm +// trample each other's channels; namespace the pool per machine if that happens +const POOL_PREFIX = 'cc-ct' +const SPENT_CHANNEL = `${POOL_PREFIX}-spent` const PEER_PREFIX = 'cc-contract-peer' const successSchema = Schema.Struct({ result: Schema.Literal('success') }) -const streamsListSchema = Schema.Struct({ +const streamIdSchema = Schema.Struct({ result: Schema.Literal('success'), stream_id: Schema.Int }) +const streamSchema = Schema.Struct({ result: Schema.Literal('success'), - streams: Schema.Array(Schema.Struct({ name: Schema.NonEmptyString, stream_id: Schema.Int })), + stream: Schema.Struct({ description: Schema.String }), +}) +const topicsSchema = Schema.Struct({ + result: Schema.Literal('success'), + topics: Schema.Array(Schema.Struct({ max_id: Schema.Int })), }) const usersListSchema = Schema.Struct({ result: Schema.Literal('success'), @@ -144,11 +155,8 @@ if (env === undefined) { minterApiKey: Redacted.make(Effect.runSync(ApiKey(liveEnv.minterApiKey))), }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.orDie) - // One process-wide run id keeps each `bun run test:live` invocation's streams - // distinct from prior runs; the per-call sequence keeps tests distinct within - // a run. (Test files may use Date.now — only Workflow scripts may not.) - const runId = Date.now().toString(36) - let seq = 0 + // Pool stream ids never change, so each is looked up once per run. + const poolIds = new Map() // Persistent peer bots are find-or-created once and cached for the suite. const peerCache = new Map() @@ -178,34 +186,59 @@ if (env === undefined) { // event pump is unwound here so handles don't leak across the run. const builtAdapters: ZulipAdapter[] = [] - const ensureStream = (minter: ZulipHttp, realName: string): Effect.Effect => - minter - .post('/users/me/subscriptions', successSchema, { + // Subscribing creates the stream only the first time any run asks for it. + const poolStream = (minter: ZulipHttp, realName: string) => + Effect.gen(function* () { + const cached = poolIds.get(realName) + if (cached !== undefined) return cached + yield* minter.post('/users/me/subscriptions', successSchema, { subscriptions: JSON.stringify([{ name: realName }]), }) - .pipe( - Effect.zipRight( - minter.get('/streams', streamsListSchema, { - include_public: true, - include_subscribed: true, + const { stream_id } = yield* minter.get('/get_stream_id', streamIdSchema, { + stream: realName, + }) + poolIds.set(realName, stream_id) + return stream_id + }) + + // A minter that is not a moderator may move a message only within the + // realm's move time limit, so a stream left unemptied for longer than that + // fails here until a realm administrator empties it. + const emptyStream = (minter: ZulipHttp, streamId: number) => + Effect.gen(function* () { + const spentId = yield* poolStream(minter, SPENT_CHANNEL) + const { stream } = yield* minter.get(`/streams/${streamId}`, streamSchema) + // Clearing a description posts a notice into the stream, so this goes + // before the topics are moved out. + if (stream.description !== '') + yield* minter.patch(`/streams/${streamId}`, successSchema, { description: '' }) + const { topics } = yield* minter.get(`/users/me/${streamId}/topics`, topicsSchema) + yield* Effect.forEach( + topics, + (topic) => + minter.patch(`/messages/${topic.max_id}`, successSchema, { + stream_id: spentId, + propagate_mode: 'change_all', + send_notification_to_old_thread: false, + send_notification_to_new_thread: false, }), - ), - Effect.flatMap((res) => { - const found = res.streams.find((s) => s.name === realName) - if (found === undefined) - return Effect.die(new Error(`seedChannel: stream ${realName} not found after create`)) - return Effect.succeed( - withChannelPermalink( - permalinkBase({ realmUrl: Effect.runSync(RealmUrl(liveEnv.site)) }), - { - id: decodeChannelIdSync(String(found.stream_id)), - name: decodeChannelNameSync(realName), - }, - ), - ) - }), - Effect.orDie, + { discard: true }, ) + }) + + const seedPoolChannel = (minter: ZulipHttp, name: string): Effect.Effect => { + const realName = `${POOL_PREFIX}-${name}` + return poolStream(minter, realName).pipe( + Effect.tap((streamId) => emptyStream(minter, streamId)), + Effect.map((streamId) => + withChannelPermalink(permalinkBase({ realmUrl: Effect.runSync(RealmUrl(liveEnv.site)) }), { + id: decodeChannelIdSync(String(streamId)), + name: decodeChannelNameSync(realName), + }), + ), + Effect.orDie, + ) + } const ensurePeer = ( minter: ZulipHttp, @@ -241,7 +274,6 @@ if (env === undefined) { }) const liveContractFactory: AttachmentContractFactory = async () => { - const ns = `${NS_PREFIX}-${runId}-${seq++}` const adapter = await Effect.runPromise(buildAdapter()) builtAdapters.push(adapter) const minter = await Effect.runPromise(buildMinterHttp()) @@ -255,10 +287,19 @@ if (env === undefined) { for (const channel of held) await Effect.runPromise(adapter.inbox.unsubscribe(channel).pipe(Effect.orDie)) + // A name seeded twice in one test is the same channel, emptied only once. + const seeded = new Map() + return { comms: adapter, attachments: adapter, - seedChannel: (name) => ensureStream(minter, `${ns}-${name}`), + seedChannel: (name) => { + const cached = seeded.get(name) + if (cached !== undefined) return Effect.succeed(cached) + return seedPoolChannel(minter, name).pipe( + Effect.tap((channel) => Effect.sync(() => seeded.set(name, channel))), + ) + }, seedAgent: (name) => ensurePeer(minter, adapter, name), // A seeded peer posts (mentioning self) through its own bound adapter so // the mention-floor tests run cross-identity — the shape that surfaces on