feat(discovery): added built-in test timers rule to defaults

- Added a new built-in TTSR rule `ts-no-test-timers.md` that flags `Bun.sleep`, `setTimeout`, and `setInterval` usage in `*.test.ts` files.
- Registered `ts-no-test-timers` in the built-in rules index so it ships with default discovery providers.
- Updated builtin-defaults tests to enforce rule-name uniqueness and verify the new rule only matches in `*.test.ts` scopes.
This commit is contained in:
can1357
2026-06-08 15:18:54 +02:00
parent c0b457a643
commit bf6b80e575
4 changed files with 96 additions and 22 deletions
@@ -20,6 +20,7 @@ import tsNoAny from "./ts-no-any.md" with { type: "text" };
import tsNoDeprecatedLeftovers from "./ts-no-deprecated-leftovers.md" with { type: "text" };
import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" };
import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" };
import tsNoTestTimers from "./ts-no-test-timers.md" with { type: "text" };
import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" };
import tsPromiseWithResolvers from "./ts-promise-with-resolvers.md" with { type: "text" };
import tsRedundantClearGuard from "./ts-redundant-clear-guard.md" with { type: "text" };
@@ -45,6 +46,7 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [
{ name: "ts-no-deprecated-leftovers", content: tsNoDeprecatedLeftovers },
{ name: "ts-no-dynamic-import", content: tsNoDynamicImport },
{ name: "ts-no-return-type", content: tsNoReturnType },
{ name: "ts-no-test-timers", content: tsNoTestTimers },
{ name: "ts-no-tiny-functions", content: tsNoTinyFunctions },
{ name: "ts-promise-with-resolvers", content: tsPromiseWithResolvers },
{ name: "ts-redundant-clear-guard", content: tsRedundantClearGuard },
@@ -0,0 +1,55 @@
---
description: Do not use real timers (Bun.sleep, setTimeout, setInterval) in tests — drive time with fake timers instead
condition:
- "Bun\\.sleep\\("
- "\\bsetInterval\\("
- "\\bsetTimeout\\("
scope: "tool:edit(*.test.ts), tool:write(*.test.ts)"
interruptMode: never
---
**Do not reach for real wall-clock timers in test files.** `Bun.sleep(...)`, `setTimeout(...)`, and `setInterval(...)` tie a test's duration to real time: they slow the suite on every run, and any delay tuned to "long enough" eventually races on a loaded machine and flakes.
## Why it's wrong
- Real delays add fixed latency to every invocation; CI pays it on every run.
- A sleep sized to mask a race is a guess — the race resurfaces under load.
- A fixed wait hides *what* you are waiting for, so a failure points at a timeout instead of the real cause.
## Avoid
```typescript
test("debounce fires once", async () => {
const fn = debounce(handler, 100);
fn();
await Bun.sleep(150); // real delay — slow and timing-dependent
expect(handler).toHaveBeenCalledTimes(1);
});
```
## Use
Drive time deterministically with fake timers:
```typescript
import { expect, test, vi } from "bun:test";
test("debounce fires once", () => {
vi.useFakeTimers();
const fn = debounce(handler, 100);
fn();
vi.advanceTimersByTime(150); // advance the clock, no real wait
expect(handler).toHaveBeenCalledTimes(1);
});
```
When the code under test resolves a promise or emits an event, await that signal directly instead of guessing a duration:
```typescript
await once(emitter, "done"); // await the real event
const value = await pending; // await the promise the code already exposes
```
## Exceptions
An integration test that deliberately exercises real timer behavior against the platform clock may need a genuine delay. Keep it rare, and add a short comment naming why deterministic time control will not work.