Skip to content

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

Terminal window
npm install --save-dev @presencelearning/frontend-testing

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.

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.

  1. Add setupLeakSentinel({ failOnLeak: false }) to your jest setup file.
  2. Burn down the warnings suite by suite.
  3. Flip to failOnLeak: true (or just remove the option).

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.

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.

  • setInterval/setTimeout outliving the fixture — track the handles and clear them in ngOnDestroy/afterEach.
  • Subscriptions without takeUntilDestroyed — complete them on destroy.
  • Material form fields — add flushAnimationFrame() to afterEach and use provideNoopAnimations().
  • MatSnackBar/MatDialog still open at suite end — dismiss before teardown.
  • Fake timers — always pair jest.useFakeTimers() with jest.useRealTimers() in afterEach.

| 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