@presencelearning/frontend-testing
Shared test tooling for Presence frontend repos: a jest leak sentinel that names whatever kept the worker alive, plus two async drain helpers for the timers that Angular test suites routinely leave behind.
Extracted from edu-clients, where a leaked-timer class of CI failures — “worker process has failed to exit gracefully”, cross-suite callback firing, coverage-job OOMs — was burned down with exactly this tooling.
Current version: 0.1.0.
Install
Section titled “Install”npm install --save-dev @presencelearning/frontend-testingImports and module resolution
Section titled “Imports and module resolution”The /leak-sentinel and /timers subpaths require a moduleResolution of bundler, node16, or nodenext. Repos still on classic "node" resolution (which ignores exports) import from the package root instead:
import { setupLeakSentinel, drainPendingTimers } from '@presencelearning/frontend-testing';The package has no peer dependencies — the sentinel duck-types the jest globals at runtime.
Leak sentinel
Section titled “Leak sentinel”Call it once from a jest setup file listed in setupFilesAfterEnv:
import { setupLeakSentinel } from '@presencelearning/frontend-testing/leak-sentinel';
setupLeakSentinel({ failOnLeak: true });After every spec file it reports each async resource the suite left behind — timers, sockets, child processes — with the stack of where it was created, and fails the suite. Reports mark your own frames with a trailing <-- so the owning line is obvious among the zone.js and jsdom noise.
It throws immediately if called somewhere beforeAll/afterAll/expect are not defined, so a misplaced call fails loudly rather than silently doing nothing.
| Configuration | Behavior |
| ------------------- | ---------------------------------------------- |
| default | leaked resource fails the suite |
| failOnLeak: false | warn-only (repo mid-burndown) |
| LEAK_FAIL=0 (env) | warn-only, overrides the option (escape hatch) |
| LEAK_FAIL=1 (env) | fail, overrides the option (CI pinning) |
| LEAK_SENTINEL=0 | sentinel off entirely (e.g. coverage jobs) |
(LEAK_SENTINEL=0 exists because async_hooks tracking has a real per-suite heap cost that compounds under single-worker coverage instrumentation.)
resolveFailOnLeak(options, env?) is exported if you need to reproduce that precedence yourself.
Adoption path
Section titled “Adoption path”- Add
setupLeakSentinel({ failOnLeak: false })to your jest setup file. - Burn down the warnings suite by suite.
- Flip to
failOnLeak: true(or just remove the option).
What counts as a leak
Section titled “What counts as a leak”Tracked resource types are the ones that keep the event loop alive: Timeout, the TCP/UDP wraps, FSEVENTWRAP, STATWATCHER, PROCESSWRAP, and MESSAGEPORT. Promises and in-flight immediates are deliberately not tracked — too high-volume, and they do not block worker exit.
Known-benign resources are filtered out before reporting: destroyed or unref’d timers, jsdom’s internal self-reschedulers, jest-circus/@jest/reporters frames, and short (≤5ms) one-shots from @testing-library. What remains is zone.js or your own application code.
Drain helpers
Section titled “Drain helpers”import { drainPendingTimers, flushAnimationFrame } from '@presencelearning/frontend-testing/timers';
afterEach(async () => { await flushAnimationFrame(); // Material notched-outline rAF is never cancelled await drainPendingTimers(); // testing-library waitFor's trailing 0ms poke});drainPendingTimers() yields one macrotask turn so already-expired one-shot timers fire against a live fixture. Fast microtask-only tests never yield to the macrotask queue, so per-render 0/1ms timers pile up — testing-library’s waitFor poke, FullCalendar’s post-init updateSize(), deferred URL writes during component init.
flushAnimationFrame() flushes a single animation frame. MatFormField’s notched outline queues an uncancelled requestAnimationFrame in ngAfterViewInit, which keeps jsdom’s 16ms rAF driver alive and gets reported as a leaked interval.
Both reject with a descriptive error after 1000ms rather than hanging, which is what you get if they run outside jsdom or with timers faked but never advanced.
Common fixes for sentinel reports
Section titled “Common fixes for sentinel reports”setInterval/setTimeoutoutliving the fixture — track the handles and clear them inngOnDestroy/afterEach.- Subscriptions without
takeUntilDestroyed— complete them on destroy. - Material form fields — add
flushAnimationFrame()toafterEachand useprovideNoopAnimations(). MatSnackBar/MatDialogstill open at suite end — dismiss before teardown.- Fake timers — always pair
jest.useFakeTimers()withjest.useRealTimers()inafterEach.
API reference
Section titled “API reference”| Export | Subpath | Description |
| ---------------------------------- | ----------------- | ----------------------------------------------------- |
| setupLeakSentinel(options?) | ./leak-sentinel | Registers the sentinel for the current suite |
| resolveFailOnLeak(options, env?) | ./leak-sentinel | Resolves the fail/warn decision from options plus env |
| LeakSentinelOptions | ./leak-sentinel | { failOnLeak?: boolean } |
| drainPendingTimers() | ./timers | Promise<void> — lets expired one-shot timers fire |
| flushAnimationFrame() | ./timers | Promise<void> — flushes one animation frame |
All five are also re-exported from the package root.
Source: packages/testing