diff --git a/packages/opencode/test/cli/run-events.test.ts b/packages/opencode/test/cli/run-events.test.ts index 8d0c1bf078eb..5b5d2cb15d11 100644 --- a/packages/opencode/test/cli/run-events.test.ts +++ b/packages/opencode/test/cli/run-events.test.ts @@ -1,8 +1,9 @@ import { describe, expect } from "bun:test" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect" import { testEffect } from "../lib/effect" import { provideTmpdirInstance } from "../fixture/fixture" +import { pollForLength, pollUntil } from "../lib/polling" import { Question } from "../../src/question" import { Permission } from "../../src/permission" import { Session } from "../../src/session" @@ -20,32 +21,6 @@ const it = testEffect( ), ) -const waitForQuestionCount = ( - question: Question.Interface, - count: number, -): Effect.Effect, Error> => - Effect.gen(function* () { - for (const _ of Array.from({ length: 100 })) { - const pending = yield* question.list() - if (pending.length === count) return pending - yield* Effect.sleep("10 millis") - } - return yield* Effect.fail(new Error(`timed out waiting for ${count} question(s)`)) - }) - -const waitForPermissionCount = ( - permission: Permission.Interface, - count: number, -): Effect.Effect, Error> => - Effect.gen(function* () { - for (const _ of Array.from({ length: 100 })) { - const pending = yield* permission.list() - if (pending.length === count) return pending - yield* Effect.sleep("10 millis") - } - return yield* Effect.fail(new Error(`timed out waiting for ${count} permission(s)`)) - }) - describe("cli/run-events", () => { it.live("auto-rejects question.asked for the root session (non-attach, non-json)", () => provideTmpdirInstance(() => @@ -196,7 +171,7 @@ describe("cli/run-events", () => { }) .pipe(Effect.forkScoped) - const pending = yield* waitForQuestionCount(question, 1) + const pending = yield* pollForLength(() => question.list(), 1) expect(handler.stats.autoRejectedQuestions).toBe(0) expect(pending[0].sessionID).toBe(unrelatedSessionID) @@ -244,7 +219,7 @@ describe("cli/run-events", () => { }) .pipe(Effect.forkScoped) - const pending = yield* waitForQuestionCount(question, 1) + const pending = yield* pollForLength(() => question.list(), 1) expect(pending[0].sessionID).toBe(deepSessionID) expect(handler.stats.autoRejectedQuestions).toBe(0) @@ -371,7 +346,7 @@ describe("cli/run-events", () => { }) const yFiber = yield* askPermission(y.id).pipe(Effect.forkScoped) - const firstPending = yield* waitForPermissionCount(permission, 1) + const firstPending = yield* pollForLength(() => permission.list(), 1) expect(firstPending[0].sessionID).toBe(y.id) expect(handler.stats.autoRejectedPermissions).toBe(0) yield* permission.reply({ requestID: firstPending[0].id, reply: "once" }) @@ -379,7 +354,7 @@ describe("cli/run-events", () => { expect(Exit.isSuccess(yExit)).toBe(true) const xFiber = yield* askPermission(x.id).pipe(Effect.forkScoped) - const secondPending = yield* waitForPermissionCount(permission, 1) + const secondPending = yield* pollForLength(() => permission.list(), 1) expect(secondPending[0].sessionID).toBe(x.id) expect(handler.stats.autoRejectedPermissions).toBe(0) yield* permission.reply({ requestID: secondPending[0].id, reply: "once" }) @@ -426,13 +401,10 @@ describe("cli/run-events", () => { }), ) - yield* Effect.gen(function* () { - for (const _ of Array.from({ length: 100 })) { - if (replies.length === 1) return - yield* Effect.sleep("10 millis") - } - return yield* Effect.fail(new Error("timed out waiting for permission.replied event")) - }) + yield* pollUntil( + () => Effect.sync(() => (replies.length === 1 ? Option.some(true) : Option.none())), + { label: "permission.replied event" }, + ) expect(Exit.isSuccess(exit)).toBe(true) expect(handler.stats.autoRejectedPermissions).toBe(0) diff --git a/packages/opencode/test/lib/polling.ts b/packages/opencode/test/lib/polling.ts new file mode 100644 index 000000000000..9aa2adfccb04 --- /dev/null +++ b/packages/opencode/test/lib/polling.ts @@ -0,0 +1,56 @@ +// Generic "wait for predicate" helper for test fiber synchronization. +// Replaces per-file bespoke waitForXxx polling loops so the polling +// interval + timeout budget stays consistent across the suite. + +import { Effect, Option } from "effect" + +export interface PollOptions { + readonly iterations?: number // default 100 + readonly intervalMillis?: number // default 10 + readonly label?: string // for timeout error message +} + +export function pollUntil( + probe: () => Effect.Effect, E, R>, + opts: PollOptions = {}, +): Effect.Effect { + const iterations = opts.iterations ?? 100 + const interval = `${opts.intervalMillis ?? 10} millis` as const + const label = opts.label ?? "pollUntil predicate" + return Effect.gen(function* () { + if (!Number.isInteger(iterations) || iterations < 0) { + return yield* Effect.fail( + new Error(`invalid iterations for ${label}: expected a non-negative integer, got ${String(iterations)}`), + ) + } + for (let i = 0; i < iterations; i++) { + const value = yield* probe() + if (Option.isSome(value)) return value.value + yield* Effect.sleep(interval) + } + return yield* Effect.fail(new Error(`timed out waiting for ${label}`)) + }) +} + +export function pollForLength( + probe: () => Effect.Effect, E, R>, + count: number, + opts: PollOptions = {}, +): Effect.Effect, E | Error, R> { + const label = opts.label ?? `list length=${count}` + return Effect.gen(function* () { + if (!Number.isInteger(count) || count < 0) { + return yield* Effect.fail( + new Error(`invalid count for ${label}: expected a non-negative integer, got ${String(count)}`), + ) + } + return yield* pollUntil( + () => + Effect.gen(function* () { + const list = yield* probe() + return list.length === count ? Option.some(list) : Option.none() + }), + { ...opts, label }, + ) + }) +}