diff --git a/.opencode/skills/unit-tests/SKILL.md b/.opencode/skills/unit-tests/SKILL.md index 8dfcc7cf6765..6cccfe758439 100644 --- a/.opencode/skills/unit-tests/SKILL.md +++ b/.opencode/skills/unit-tests/SKILL.md @@ -33,7 +33,7 @@ Sibling skills: `tui-tests` (OpenTUI renderer tests in tui and cli), `app-e2e-te - **Effect code.** `it.effect` from `packages/core/test/lib/effect.ts` runs on `TestClock`; `testEffect(layer)` adds your layer. server and cli import it from `../../core/test/lib/effect`; ai has `packages/ai/test/lib/effect.ts`. - **Fork, then adjust.** Fork with `Effect.forkChild({ startImmediately: true })` (`packages/ai/test/http-timeout.test.ts:58`), or await a Deferred the code completes and then `Effect.yieldNow` (`:80-81`). Otherwise `TestClock.adjust` can run before the sleep is registered, and the join hangs until Bun's timeout. -- **Debounced streams and watchers.** `advance(condition)` and `drain` in `packages/core/test/lib/clock.ts` alternate one real 1ms macrotask with 500ms adjusts, because debounces register sleeps across fiber hops. Use `drain` (4 such steps) before a "nothing happened" assertion instead of `Bun.sleep(25)`. `advance`'s 100-step bound is right under TestClock, but a condition waiting on slow real work (a child, a large write) can run out of steps under load. +- **Debounced streams and watchers.** `advance(condition)` and `drain` in `packages/core/test/lib/clock.ts` alternate one real 1ms macrotask with 500ms adjusts, because debounces register sleeps across fiber hops. Use `drain` (4 such steps) before a "nothing happened" assertion instead of `Bun.sleep(25)`. `advance` is bounded by 15s of real time, not a step count, because its condition can also wait on slow real work (a plugin import, a child, a large write) that took more than 100 steps on a loaded Linux runner (`supervisor-reload.test.ts` "retains a discovered plugin change during initial activation"). - **Real I/O between virtual steps.** `TestClock.withLive(Effect.sleep("10 millis"))`. `registered()` at `packages/cli/test/service.test.ts:740` tests the 5s registration watch with real file I/O under `it.effect`. This departs from the effect skill's "filesystem → `it.live`" on purpose: the timer is the contract. - **Real events need `it.live`.** Under `it.effect` the clock moves only when the test adjusts it, so an Effect timeout on a path driven by real processes, sockets or file events never fires and a missing event hangs instead of failing. The AppProcess tests (`packages/core/test/process/process.test.ts:13-14`) use `it.live` because the spawner's 1-second post-exit capture deadline (`packages/util/src/cross-spawn-spawner.ts:322`) never fired under TestClock. - **Give a racing component its own clock.** When a real-time deadline races slow setup, provide that component a `Clock` that only the test moves past the deadline. `packages/core/test/browser-idle.test.ts:24-38` wraps the live clock for `LocationActivity` (`:49-51`) and adds the TTL at `:84`, once the browser state is attached. Before, it waited out a real 2s TTL and failed on Windows CI with "RPC is unavailable: experimental.browser" when plugin activation was slow. Shortening a real TTL makes that race flakier. diff --git a/packages/core/test/lib/clock.ts b/packages/core/test/lib/clock.ts index 819304e7681a..6d15aad67429 100644 --- a/packages/core/test/lib/clock.ts +++ b/packages/core/test/lib/clock.ts @@ -16,13 +16,19 @@ const tick = Effect.gen(function* () { * their sleeps from separate fiber hops that a single adjust can miss, so the * loop alternates real-macrotask settles with adjusts until the condition * holds. Extra adjusts are harmless when nothing is pending. + * + * Bounded by real time, not a step count: the condition can also wait on real + * work such as a plugin import, which needs many more steps on a loaded CI + * runner than locally. */ export const advance = Effect.fnUntraced(function* (condition: () => boolean) { - for (let attempt = 0; attempt < 100; attempt++) { + const deadline = performance.now() + 15_000 + while (performance.now() < deadline) { if (condition()) return yield* tick } - return yield* Effect.die(new Error("condition never became true after 100 advances")) + if (condition()) return + return yield* Effect.die(new Error("condition never became true within 15s")) }) /**