jest-doctor 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,6 @@ const hook_1 = __importDefault(require("./patch/hook"));
21
21
  const getAllAfterEach_1 = __importDefault(require("./utils/getAllAfterEach"));
22
22
  const normalizeOptions_1 = __importDefault(require("./utils/normalizeOptions"));
23
23
  const getReporterTmpDir_1 = __importDefault(require("./utils/getReporterTmpDir"));
24
- const promise_1 = __importDefault(require("./patch/promise"));
25
24
  const promiseConcurrency_1 = __importDefault(require("./patch/promiseConcurrency"));
26
25
  const createEnvMixin = (Environment) => {
27
26
  // @ts-expect-error strange ts rule where constructor arguments should be any[] where again TypeScript complains
@@ -57,8 +56,7 @@ const createEnvMixin = (Environment) => {
57
56
  : '';
58
57
  this.options = (0, normalizeOptions_1.default)(config.projectConfig.testEnvironmentOptions);
59
58
  (0, initLeakRecord_1.default)(this, consts_1.MAIN_THREAD);
60
- if (this.options.report.promises &&
61
- this.options.report.promises.patch === 'async_hooks') {
59
+ if (this.options.report.promises) {
62
60
  this.asyncHookCleaner = (0, createAsyncHookCleaner_1.default)(this);
63
61
  this.asyncHookDetector = (0, createAsyncHookDetector_1.default)(this);
64
62
  }
@@ -76,9 +74,6 @@ const createEnvMixin = (Environment) => {
76
74
  }
77
75
  if (report.promises) {
78
76
  (0, promiseConcurrency_1.default)(this);
79
- if (report.promises.patch === 'promise') {
80
- (0, promise_1.default)(this);
81
- }
82
77
  }
83
78
  }
84
79
  // unfortunately @jest/types doesn't export the necessary types,
@@ -104,6 +99,14 @@ const createEnvMixin = (Environment) => {
104
99
  (0, hook_1.default)(this, 'beforeAll');
105
100
  (0, hook_1.default)(this, 'afterEach');
106
101
  (0, hook_1.default)(this, 'afterAll');
102
+ Object.assign(circusEvent.runtimeGlobals, {
103
+ it: this.global.it,
104
+ test: this.global.test,
105
+ beforeEach: this.global.beforeEach,
106
+ beforeAll: this.global.beforeAll,
107
+ afterEach: this.global.afterEach,
108
+ afterAll: this.global.afterAll,
109
+ });
107
110
  }
108
111
  await super.handleEvent?.(circusEvent, circusState);
109
112
  }
@@ -4,20 +4,26 @@ const patchPromiseConcurrency = (that) => {
4
4
  const env = that.global;
5
5
  const concurrencyFactor = (fn) => (concurrentPromises) => {
6
6
  const promises = that.leakRecords.get(that.currentTestName)?.promises;
7
- // remove all related promises is a concurrent promise ends
8
- const removePromises = () => {
7
+ return fn(concurrentPromises).finally(() => {
9
8
  if (promises) {
10
- concurrentPromises.forEach((racePromise) => {
11
- promises.delete(racePromise);
9
+ concurrentPromises.forEach((concurrentPromise) => {
10
+ const leak = promises.get(concurrentPromise);
11
+ if (leak) {
12
+ const asyncId = leak?.asyncId;
13
+ that.asyncIdToPromise.delete(asyncId);
14
+ that.promiseOwner.delete(asyncId);
15
+ promises.delete(concurrentPromise);
16
+ // Promise.race and Promise.any will create a new Promise for every entry that needs to be deleted
17
+ promises.forEach((childLeak, key) => {
18
+ if (childLeak.parentAsyncId === asyncId) {
19
+ that.asyncIdToPromise.delete(childLeak.asyncId);
20
+ that.promiseOwner.delete(childLeak.asyncId);
21
+ promises.delete(key);
22
+ }
23
+ });
24
+ }
12
25
  });
13
26
  }
14
- };
15
- return fn(concurrentPromises).then((returnValues) => {
16
- removePromises();
17
- return returnValues;
18
- }, (error) => {
19
- removePromises();
20
- throw error;
21
27
  });
22
28
  };
23
29
  env.Promise.race = concurrencyFactor(env.Promise.race.bind(env.Promise));
package/dist/types.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ModernFakeTimers } from '@jest/fake-timers';
2
2
  import { JestEnvironment } from '@jest/environment';
3
3
  import initOriginal from './utils/initOriginal';
4
+ import type { AsyncHook } from 'node:async_hooks';
4
5
  export interface TimerRecord {
5
6
  type: 'timeout' | 'interval' | 'fakeTimeout' | 'fakeInterval';
6
7
  delay: number;
@@ -10,6 +11,8 @@ export interface TimerRecord {
10
11
  export interface PromiseRecord {
11
12
  stack: string;
12
13
  testName: string;
14
+ asyncId: number;
15
+ parentAsyncId: number;
13
16
  }
14
17
  export interface ConsoleRecord {
15
18
  method: keyof Console;
@@ -39,9 +42,8 @@ export interface FakeTimers extends Omit<ModernFakeTimers, '_fakeTimers'> {
39
42
  };
40
43
  }
41
44
  export type TimerIsolation = 'afterEach' | 'immediate';
42
- export type OnError = boolean | 'throw' | 'warn';
45
+ export type OnError = false | 'throw' | 'warn';
43
46
  export type ThrowOrWarn = 'throw' | 'warn';
44
- export type Patch = 'async_hooks' | 'promise';
45
47
  export interface ConsoleOptions {
46
48
  onError: ThrowOrWarn;
47
49
  methods: Array<keyof Console>;
@@ -53,10 +55,7 @@ export interface NormalizedOptions {
53
55
  console: NormalizedConsoleOptions;
54
56
  timers: OnError;
55
57
  fakeTimers: OnError;
56
- promises: false | {
57
- onError: ThrowOrWarn;
58
- patch: Patch;
59
- };
58
+ promises: OnError;
60
59
  };
61
60
  verbose: boolean;
62
61
  delayThreshold: number;
@@ -68,16 +67,12 @@ export type RawConsoleOptions = boolean | {
68
67
  methods?: Array<keyof Console>;
69
68
  ignore?: string | RegExp | Array<string | RegExp>;
70
69
  };
71
- export type RawPromise = boolean | {
72
- onError?: ThrowOrWarn;
73
- patch?: Patch;
74
- };
75
70
  export interface RawOptions {
76
71
  report?: {
77
72
  console?: RawConsoleOptions;
78
73
  timers?: OnError;
79
74
  fakeTimers?: OnError;
80
- promises?: RawPromise;
75
+ promises?: OnError;
81
76
  };
82
77
  verbose?: boolean;
83
78
  delayThreshold?: number;
@@ -103,5 +98,6 @@ export interface JestDoctorEnvironment {
103
98
  options: NormalizedOptions;
104
99
  aggregatedReport: AggregatedReport;
105
100
  asyncIdToPromise: Map<number, Promise<unknown>>;
101
+ asyncHookDetector?: AsyncHook;
106
102
  }
107
103
  export {};
@@ -5,8 +5,9 @@ const createAsyncHookCleaner = (that) => {
5
5
  return (0, node_async_hooks_1.createHook)({
6
6
  promiseResolve(asyncId) {
7
7
  const owner = that.promiseOwner.get(asyncId);
8
- if (!owner)
8
+ if (!owner) {
9
9
  return;
10
+ }
10
11
  const promise = that.asyncIdToPromise.get(asyncId);
11
12
  if (promise) {
12
13
  that.leakRecords.get(owner)?.promises.delete(promise);
@@ -6,9 +6,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const node_async_hooks_1 = require("node:async_hooks");
7
7
  const getStack_1 = __importDefault(require("./getStack"));
8
8
  const createAsyncHookDetector = (that) => {
9
- const init = (asyncId, type, _, resource) => {
10
- if (type !== 'PROMISE')
9
+ const init = (asyncId, type, parentAsyncId, resource) => {
10
+ if (type !== 'PROMISE') {
11
11
  return;
12
+ }
12
13
  const promise = resource;
13
14
  const owner = that.currentTestName;
14
15
  that.promiseOwner.set(asyncId, owner);
@@ -16,6 +17,8 @@ const createAsyncHookDetector = (that) => {
16
17
  that.leakRecords.get(owner)?.promises.set(promise, {
17
18
  stack: (0, getStack_1.default)(init, 'Promise'),
18
19
  testName: owner,
20
+ asyncId,
21
+ parentAsyncId,
19
22
  });
20
23
  };
21
24
  return (0, node_async_hooks_1.createHook)({ init });
@@ -5,6 +5,6 @@ const getStack = (stackFrom, prefix) => {
5
5
  stack: '',
6
6
  };
7
7
  Error.captureStackTrace(error, stackFrom);
8
- return prefix + ' ' + error.stack;
8
+ return prefix + ' ' + error.stack.replace(/\\/g, '/');
9
9
  };
10
10
  exports.default = getStack;
@@ -1,3 +1,3 @@
1
1
  import { NormalizedOptions, RawOptions } from '../types';
2
- export declare function normalizeOptions(raw: RawOptions): NormalizedOptions;
2
+ export declare function normalizeOptions(raw?: RawOptions): NormalizedOptions;
3
3
  export default normalizeOptions;
@@ -1,6 +1,40 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.normalizeOptions = normalizeOptions;
37
+ const zod = __importStar(require("zod"));
4
38
  const DEFAULTS = {
5
39
  report: {
6
40
  console: {
@@ -10,58 +44,56 @@ const DEFAULTS = {
10
44
  },
11
45
  timers: 'throw',
12
46
  fakeTimers: 'throw',
13
- promises: {
14
- onError: 'throw',
15
- patch: 'async_hooks',
16
- },
47
+ promises: 'throw',
17
48
  },
18
49
  verbose: false,
19
50
  delayThreshold: 0,
20
51
  timerIsolation: 'afterEach',
21
52
  clearTimers: true,
22
53
  };
23
- const normalizeConsole = (rawConsole) => {
24
- if (rawConsole === undefined || rawConsole === true) {
25
- return DEFAULTS.report.console;
26
- }
27
- if (typeof rawConsole === 'object') {
28
- return {
29
- onError: rawConsole.onError ?? DEFAULTS.report.console.onError,
30
- methods: rawConsole.methods ?? DEFAULTS.report.console.methods,
31
- ignore: Array.isArray(rawConsole.ignore)
32
- ? rawConsole.ignore
33
- : rawConsole.ignore === undefined
34
- ? []
35
- : [rawConsole.ignore],
36
- };
37
- }
38
- return false;
39
- };
40
- const normalizePromise = (rawPromise) => {
41
- if (rawPromise === undefined || rawPromise === true) {
42
- return DEFAULTS.report.promises;
54
+ const onError = zod
55
+ .union([zod.literal(false), zod.enum(['throw', 'warn'])])
56
+ .default('throw');
57
+ const schema = zod
58
+ .object({
59
+ report: zod
60
+ .object({
61
+ console: zod
62
+ .union([
63
+ zod.literal(false),
64
+ zod.object({
65
+ onError: zod.enum(['throw', 'warn']).default('throw'),
66
+ methods: zod
67
+ .array(zod.enum(['log', 'warn', 'error', 'info', 'debug']))
68
+ .default(DEFAULTS.report.console.methods),
69
+ ignore: zod
70
+ .union([
71
+ zod.string(),
72
+ zod.instanceof(RegExp),
73
+ zod.array(zod.union([zod.string(), zod.instanceof(RegExp)])),
74
+ ])
75
+ .transform((value) => (Array.isArray(value) ? value : [value]))
76
+ .default([]),
77
+ }),
78
+ ])
79
+ .default(DEFAULTS.report.console),
80
+ timers: onError,
81
+ fakeTimers: onError,
82
+ promises: onError,
83
+ })
84
+ .default(DEFAULTS.report),
85
+ verbose: zod.boolean().default(false),
86
+ delayThreshold: zod.int().gte(0).default(0),
87
+ timerIsolation: zod.enum(['afterEach', 'immediate']).default('afterEach'),
88
+ clearTimers: zod.boolean().default(true),
89
+ })
90
+ .default(DEFAULTS);
91
+ function normalizeOptions(raw) {
92
+ try {
93
+ return schema.parse(raw || {});
43
94
  }
44
- if (typeof rawPromise === 'object') {
45
- return {
46
- onError: rawPromise.onError ?? DEFAULTS.report.promises.onError,
47
- patch: rawPromise.patch ?? DEFAULTS.report.promises.patch,
48
- };
95
+ catch (error) {
96
+ throw zod.prettifyError(error);
49
97
  }
50
- return false;
51
- };
52
- function normalizeOptions(raw) {
53
- const report = raw.report ?? {};
54
- return {
55
- report: {
56
- console: normalizeConsole(report.console),
57
- timers: report.timers ?? DEFAULTS.report.timers,
58
- fakeTimers: report.fakeTimers ?? DEFAULTS.report.fakeTimers,
59
- promises: normalizePromise(report.promises),
60
- },
61
- verbose: raw.verbose ?? DEFAULTS.verbose,
62
- delayThreshold: raw.delayThreshold ?? DEFAULTS.delayThreshold,
63
- timerIsolation: raw.timerIsolation ?? DEFAULTS.timerIsolation,
64
- clearTimers: raw.clearTimers ?? DEFAULTS.clearTimers,
65
- };
66
98
  }
67
99
  exports.default = normalizeOptions;
@@ -13,8 +13,7 @@ const reportLeaks = (that, leakRecord) => {
13
13
  leakRecord[property].values().next().value?.stack,
14
14
  ].join('\n');
15
15
  const option = that.options.report[property];
16
- if (option === 'throw' ||
17
- (typeof option === 'object' && option.onError === 'throw')) {
16
+ if (option === 'throw') {
18
17
  const error = new Error();
19
18
  error.stack = message;
20
19
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jest-doctor",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "jest environment for leak detection",
5
5
  "license": "MIT",
6
6
  "author": "Stephan Dum",
@@ -38,11 +38,11 @@
38
38
  "@types/react": "^19.2.8",
39
39
  "@types/react-dom": "^19.2.3",
40
40
  "c8": "^10.1.3",
41
- "cross-env": "^10.1.0",
42
41
  "cross-spawn": "^7.0.6",
43
42
  "eslint": "^9.39.2",
44
- "istanbul-lib-report": "^3.0.1",
45
43
  "jest": "30.2.0",
44
+ "jest-environment-jsdom": "^30.2.0",
45
+ "jest-environment-node": "^30.2.0",
46
46
  "memfs": "^4.52.0",
47
47
  "nyc": "^17.1.0",
48
48
  "react": "^19.2.3",
@@ -63,7 +63,8 @@
63
63
  }
64
64
  },
65
65
  "dependencies": {
66
- "chalk": "4.1.2"
66
+ "chalk": "4.1.2",
67
+ "zod": "^4.3.5"
67
68
  },
68
69
  "scripts": {
69
70
  "typecheck": "tsc",
package/readme.md CHANGED
@@ -40,12 +40,8 @@ The environment can be configured through jest config `testEnvironmentOptions`:
40
40
  - **report**: an object defining which leaks should be tracked and reported
41
41
  - **timers**: `false | 'warn' | 'trow'` (default `throw`) whether normal setTimeout and setInterval should be reported and how
42
42
  - **fakeTimers**: `false | 'warn' | 'trow'` (default `throw`) same as timers but for fake api
43
- - **promises**: `false` or object
44
- - **onError**: `'warn' | 'trow'` (default `throw`) indicating if promises should be reported and how
45
- - **patch**: `'async_hooks' | 'promise'` (default `async_hooks`) controls how to patch promises
46
- - **async_hooks**: uses node async_hook API to detect any promise generation, robust solution but with an overhead.
47
- - **promise**: overwrites the global Promise object which is faster but will not detect: internal promises, async await, native API promises!
48
- - **console**: `false` or object (default object)
43
+ - **promises**: `false | 'warn' | 'trow'` (default `throw`) indicating if promises should be reported and how
44
+ - **console**: `false` or object (default object)
49
45
  - **onError**: `'warn' | 'trow'` (default `throw`) how to handle reporting
50
46
  - **methods**: `keyof Console` (default all methods) which console methods should be tracked
51
47
  - **ignore**: `string | regexp | Array<string | regexp>` (default: []) allows to excluded console output from tracking
@@ -67,9 +63,7 @@ export default {
67
63
  },
68
64
  timers: 'warn',
69
65
  fakeTimers: 'warn',
70
- promises: {
71
- onErro: 'warn',
72
- },
66
+ promises: 'warn',
73
67
  },
74
68
  delayThreshold: 1000,
75
69
  timerIsolation: 'afterEach',
@@ -100,9 +94,11 @@ Promise.resolve().then(() => {
100
94
  /* i am not tracked as unresolved */
101
95
  });
102
96
  ```
103
- - injectGlobals must be used for totalDelay and test attribution to work, because imports from jest can not be patched! Also this could give a wrong sense of confidence because one test could have open timers or promises that resolve while running other tests and are not present in the final report.
97
+ - Promise.race and Promise.any can only accept unchained promises, or they will report the chained promise as open.
104
98
  ```js
105
- import { expect, it, describe, beforeEach /*...*/ } from '@jest/globals';
99
+ const p1 = Promise.resolve().then(() => { /* not allowed */});
100
+ const p2 = Promise.resolve();
101
+ await Promise.race([p1, p2]);
106
102
  ```
107
103
 
108
104
  - console, setTimeout / setInterval can also be imported and will not participate in leak detection in these cases, but this can also serve as exit hatch if needed.
@@ -1,3 +0,0 @@
1
- import { JestDoctorEnvironment } from '../types';
2
- declare const patchPromise: (that: JestDoctorEnvironment) => void;
3
- export default patchPromise;
@@ -1,34 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const getStack_1 = __importDefault(require("../utils/getStack"));
7
- const patchPromise = (that) => {
8
- const OriginalPromise = that.global.Promise;
9
- class TrackedPromise extends OriginalPromise {
10
- constructor(executor) {
11
- let resolveRef;
12
- let rejectRef;
13
- super((resolve, reject) => {
14
- resolveRef = resolve;
15
- rejectRef = reject;
16
- });
17
- const promiseLeaks = that.leakRecords.get(that.currentTestName)?.promises;
18
- promiseLeaks?.set(this, {
19
- testName: that.currentTestName,
20
- stack: (0, getStack_1.default)(that.global.Promise, 'Promise'),
21
- });
22
- executor((value) => {
23
- promiseLeaks?.delete(this);
24
- resolveRef(value);
25
- }, (reason) => {
26
- promiseLeaks?.delete(this);
27
- rejectRef(reason);
28
- });
29
- }
30
- }
31
- Object.assign(Promise, OriginalPromise);
32
- that.global.Promise = TrackedPromise;
33
- };
34
- exports.default = patchPromise;
@@ -1 +0,0 @@
1
- declare const patchRace: () => void;
@@ -1,2 +0,0 @@
1
- "use strict";
2
- const patchRace = () => { };