test(core): bound advance by real time instead of steps (#57119)

This commit is contained in:
Filip 2026-10-19 18:08:14 +02:00 • committed by GitHub
parent ce15cd8821
commit 367c8fbec7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 9 additions and 3 deletions

View file

@ -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.

View file

@ -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"))
})
/**