diff --git a/README.md b/README.md index 679afa5..2459983 100644 --- a/README.md +++ b/README.md @@ -2,163 +2,104 @@ > A Node.js module for looking up running processes. Originated from [neekey/ps](https://github.com/neekey/ps), [UmbraEngineering/ps](https://github.com/UmbraEngineering/ps) and completely reforged. -## Differences -* [x] Rewritten in TypeScript -* [x] CJS and ESM package entry points -* [x] `table-parser` replaced with `@webpod/ingrid` to handle some issues: [neekey/ps#76](https://github.com/neekey/ps/issues/76), [neekey/ps#62](https://github.com/neekey/ps/issues/62), [neekey/table-parser#11](https://github.com/neekey/table-parser/issues/11), [neekey/table-parser#18](https://github.com/neekey/table-parser/issues/18) -* [x] Provides promisified responses -* [x] Brings sync API -* [x] Builds a process subtree by parent +## Features +- Written in TypeScript, ships with types +- CJS and ESM entry points +- Promise and callback API, sync variants +- Process tree traversal by parent pid +- Uses `@webpod/ingrid` instead of `table-parser` ([neekey/ps#76](https://github.com/neekey/ps/issues/76), [neekey/ps#62](https://github.com/neekey/ps/issues/62)) ## Install ```bash -$ npm install @webpod/ps +npm install @webpod/ps ``` ## Internals -This module uses different approaches for getting process list: -| Platform | Method | -|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| -| Unix/Mac | `ps -lx` | -| Windows (kernel >= 26000)| `pwsh -NoProfile -Command "Get-CimInstance Win32_Process \| Select-Object ProcessId,ParentProcessId,CommandLine \| ConvertTo-Json -Compress"` | -| Windows (kernel < 26000) | [`wmic`](https://learn.microsoft.com/en-us/windows/win32/wmisdk/wmic) `process get ProcessId,CommandLine` | +| Platform | Command | +|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| +| Unix / macOS | `ps -eo pid,ppid,args` | +| Windows (kernel >= 26000) | `pwsh -NoProfile -Command "Get-CimInstance Win32_Process \| Select-Object ProcessId,ParentProcessId,CommandLine \| ConvertTo-Json -Compress"` | +| Windows (kernel < 26000) | [`wmic`](https://learn.microsoft.com/en-us/windows/win32/wmisdk/wmic) `process get ProcessId,CommandLine` | -## Usage +## API + +### lookup(query?, callback?) +Returns a list of processes matching the query. -### lookup() -Searches for the process by the specified `pid`. ```ts -import {lookup} from '@webpod/ps' - -// Both callback and promise styles are supported -const list = await lookup({pid: 12345}) - -// or -lookup({pid: 12345}, (err, list) => { - if (err) { - throw new Error(err) - } - - const [found] = list - if (found) { - console.log('PID: %s, COMMAND: %s, ARGUMENTS: %s', found.pid, found.command, found.arguments) - } else { - console.log('No such process found!') - } -}) +import { lookup } from '@webpod/ps' -// or syncronously -const _list = lookup.sync({pid: 12345}) -``` +// Find by pid +const list = await lookup({ pid: 12345 }) +// [{ pid: '12345', ppid: '123', command: '/usr/bin/node', arguments: ['server.js', '--port=3000'] }] -Define a query opts to filter the results by `command` and/or `arguments` predicates: -```ts -const list = await lookup({ - command: 'node', // it will be used to build a regex - arguments: '--debug', -}) +// Filter by command and/or arguments (treated as RegExp) +const nodes = await lookup({ command: 'node', arguments: '--debug' }) -list.forEach(entry => { - console.log('PID: %s, COMMAND: %s, ARGUMENTS: %s', entry.pid, entry.command, entry.arguments); -}) +// Filter by parent pid +const children = await lookup({ ppid: 82292 }) + +// Synchronous +const all = lookup.sync() ``` -Unix users can override the default `ps` arguments: +On Unix, you can override the default `ps` arguments via `psargs`: ```ts -lookup({ - command: 'node', - psargs: 'ux' -}, (err, resultList) => { -// ... -}) +const list = await lookup({ command: 'node', psargs: '-eo pid,ppid,comm' }) ``` -Specify the `ppid` option to filter the results by the parent process id (make sure that your custom `psargs` provides this output: `-l` or `-j` for instance) +Callback style is also supported: ```ts -lookup({ - command: 'mongod', - psargs: '-l', - ppid: 82292 -}, (err, resultList) => { - // ... -}) +lookup({ pid: 12345 }, (err, list) => { /* ... */ }) ``` -### tree() -Returns a child processes list by the specified parent `pid`. Some kind of shortcut for `lookup({ppid: pid})`. +### tree(opts?, callback?) +Returns child processes of a given parent pid. + ```ts import { tree } from '@webpod/ps' -const children = await tree(123) -/** -[ - {pid: 124, ppid: 123}, - {pid: 125, ppid: 123} -] -*/ +// Direct children +const children = await tree(123) +// [ +// { pid: '124', ppid: '123', command: 'node', arguments: ['worker.js'] }, +// { pid: '125', ppid: '123', command: 'node', arguments: ['worker.js'] } +// ] + +// All descendants +const all = await tree({ pid: 123, recursive: true }) +// [ +// { pid: '124', ppid: '123', ... }, +// { pid: '125', ppid: '123', ... }, +// { pid: '126', ppid: '124', ... }, +// { pid: '127', ppid: '125', ... } +// ] + +// Synchronous +const list = tree.sync({ pid: 123, recursive: true }) ``` -To obtain all nested children, set `recursive` option to `true`: -```ts -const children = await tree({pid: 123, recursive: true}) -/** -[ - {pid: 124, ppid: 123}, - {pid: 125, ppid: 123}, - - {pid: 126, ppid: 124}, - {pid: 127, ppid: 124}, - {pid: 128, ppid: 124}, - - {pid: 129, ppid: 125}, - {pid: 130, ppid: 125}, -] -*/ - -// or syncronously -const list = tree.sync({pid: 123, recursive: true}) -``` - -### kill() -Eliminates the process by its `pid`. +### kill(pid, opts?, callback?) +Kills a process and waits for it to exit. The returned promise resolves once the process is confirmed dead, or rejects on timeout. ```ts import { kill } from '@webpod/ps' -kill('12345', (err, pid) => { - if (err) { - throw new Error(err) - } else { - console.log('Process %s has been killed!', pid) - } -}) -``` +// Sends SIGTERM, polls until the process is gone (default timeout 30s) +await kill(12345) -Method `kill` also supports a `signal` option to be passed. It's only a wrapper of `process.kill()` with checking of that killing is finished after the method is called. +// With signal +await kill(12345, 'SIGKILL') -```ts -import { kill } from '@webpod/ps' +// With custom timeout (seconds) and polling interval (ms) +await kill(12345, { signal: 'SIGKILL', timeout: 10, interval: 250 }) -// Pass signal SIGKILL for killing the process without allowing it to clean up -kill('12345', 'SIGKILL', (err, pid) => { - if (err) { - throw new Error(err) - } else { - console.log('Process %s has been killed without a clean-up!', pid) - } +// With callback +await kill(12345, (err, pid) => { + // called when the process is confirmed dead or timeout is reached }) ``` -You can also use object notation to specify more opts: -```ts -kill( '12345', { - signal: 'SIGKILL', - timeout: 10, // will set up a ten seconds timeout if the killing is not successful -}, () => {}) -``` - -Notice that the nodejs build-in `process.kill()` does not accept number as a signal, you will have to use string format. - ## License [MIT](./LICENSE) diff --git a/package-lock.json b/package-lock.json index 142f657..7ae5e68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "zurk": "^0.11.10" }, "devDependencies": { + "@size-limit/file": "^12.0.1", "@types/node": "^25.5.2", "c8": "^11.0.0", "concurrently": "^9.2.1", @@ -21,9 +22,9 @@ "esbuild-plugin-entry-chunks": "^0.1.18", "fast-glob": "^3.3.3", "minimist": "^1.2.8", - "mocha": "^10.8.2", + "mocha": "^11.7.5", "oxlint": "^1.58.0", - "sinon": "^18.0.1", + "size-limit": "^12.0.1", "ts-node": "^10.9.2", "typedoc": "^0.28.18", "typescript": "^5.9.2" @@ -506,6 +507,102 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -697,9 +794,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -717,9 +811,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -737,9 +828,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +845,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -777,9 +862,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -797,9 +879,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -817,9 +896,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -837,9 +913,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -917,6 +990,16 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@shikijs/engine-oniguruma": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", @@ -966,55 +1049,18 @@ "dev": true, "license": "MIT" }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", - "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", - "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "node_modules/@size-limit/file": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-12.0.1.tgz", + "integrity": "sha512-Kvbnz46iV7WeHaANf1HmWjXBVMU2KkCU+0xJ78FzIjZwlVKKEqy+QCZprdBMfIWrzrvYeqP4cfuzKG8z6xVivg==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "lodash.get": "^4.4.2", - "type-detect": "^4.1.0" - } - }, - "node_modules/@sinonjs/samsam/node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=4" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "size-limit": "12.0.1" } }, - "node_modules/@sinonjs/text-encoding": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", - "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", - "dev": true, - "license": "(Unlicense OR Apache-2.0)" - }, "node_modules/@tsconfig/node10": { "version": "1.0.10", "dev": true, @@ -1092,16 +1138,6 @@ "node": ">=0.4.0" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "dev": true, @@ -1124,18 +1160,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/arg": { "version": "4.1.3", "dev": true, @@ -1148,26 +1172,15 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/brace-expansion": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1189,6 +1202,15 @@ "dev": true, "license": "ISC" }, + "node_modules/bytes-iec": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz", + "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/c8": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz", @@ -1250,29 +1272,18 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/cliui": { @@ -1405,15 +1416,20 @@ "license": "MIT" }, "node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, "node_modules/emoji-regex": { "version": "8.0.0", "dev": true, @@ -1599,23 +1615,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "dev": true, @@ -1624,6 +1623,27 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "dev": true, @@ -1635,6 +1655,28 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/glob/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -1656,31 +1698,6 @@ "dev": true, "license": "MIT" }, - "node_modules/inflight": { - "version": "1.0.6", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "dev": true, - "license": "ISC" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "dev": true, @@ -1717,6 +1734,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "2.1.0", "dev": true, @@ -1774,6 +1800,21 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -1787,12 +1828,17 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/just-extend": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", - "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, - "license": "MIT" + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } }, "node_modules/linkify-it": { "version": "5.0.0", @@ -1818,13 +1864,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "dev": true, - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "dev": true, @@ -1923,16 +1962,18 @@ } }, "node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -1954,31 +1995,31 @@ } }, "node_modules/mocha": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", - "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", "dev": true, - "license": "MIT", "dependencies": { - "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "debug": "^4.3.5", - "diff": "^5.2.0", + "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", - "glob": "^8.1.0", + "glob": "^10.4.5", "he": "^1.2.0", + "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", + "minimatch": "^9.0.5", "ms": "^2.1.3", + "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", - "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" }, "bin": { @@ -1986,17 +2027,7 @@ "mocha": "bin/mocha.js" }, "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/mocha/node_modules/cliui": { - "version": "7.0.4", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/mocha/node_modules/escape-string-regexp": { @@ -2010,24 +2041,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mocha/node_modules/glob": { - "version": "8.1.0", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "dev": true, @@ -2042,66 +2055,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/mocha/node_modules/yargs": { - "version": "16.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mocha/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "dev": true, "license": "MIT" }, - "node_modules/nise": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", - "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "^13.0.1", - "@sinonjs/text-encoding": "^0.7.3", - "just-extend": "^6.2.0", - "path-to-regexp": "^8.1.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", + "node_modules/nanospinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz", + "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==", "dev": true, - "license": "ISC", "dependencies": { - "wrappy": "1" + "picocolors": "^1.1.1" } }, "node_modules/oxlint": { @@ -2177,6 +2142,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -2220,16 +2191,11 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true }, "node_modules/picomatch": { "version": "2.3.2", @@ -2284,14 +2250,16 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/require-directory": { @@ -2433,39 +2401,52 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sinon": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.1.tgz", - "integrity": "sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==", + "node_modules/size-limit": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-12.0.1.tgz", + "integrity": "sha512-vuFj+6lDOoBJQu6OLhcMQv7jnbXjuoEn4WsQHlSLOV/8EFfOka/tfjtLQ/rZig5Gagi3R0GnU/0kd4EY/y2etg==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.1", - "@sinonjs/fake-timers": "11.2.2", - "@sinonjs/samsam": "^8.0.0", - "diff": "^5.2.0", - "nise": "^6.0.0", - "supports-color": "^7" + "bytes-iec": "^3.1.1", + "lilconfig": "^3.1.3", + "nanospinner": "^1.2.2", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/sinon" + "bin": { + "size-limit": "bin.js" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "jiti": "^2.0.0" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/sinon/node_modules/@sinonjs/fake-timers": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", - "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "node_modules/string-width": { + "version": "4.2.3", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/string-width": { + "node_modules/string-width-cjs": { + "name": "string-width", "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2486,6 +2467,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "dev": true, @@ -2580,6 +2574,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2659,16 +2698,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/typedoc": { "version": "0.28.18", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.18.tgz", @@ -2802,11 +2831,10 @@ } }, "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true, - "license": "Apache-2.0" + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true }, "node_modules/wrap-ansi": { "version": "7.0.0", @@ -2824,10 +2852,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "license": "ISC" + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } }, "node_modules/y18n": { "version": "5.0.8", diff --git a/package.json b/package.json index 57be96e..1708bf2 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,23 @@ "build:dts": "tsc --emitDeclarationOnly --outDir target/dts", "build:docs": "typedoc --options src/main/typedoc", "build:stamp": "npx buildstamp", - "test": "concurrently 'npm:test:lint' 'npm:test:unit' && npm run test:legacy", + "test": "concurrently 'npm:test:lint' 'npm:test:unit' 'npm:test:size' && npm run test:legacy", "test:lint": "oxlint -c oxlintrc.json src/main/ts src/test/ts", "test:unit": "c8 -r lcov -r text -o target/coverage -x src/scripts -x src/test -x target node --loader ts-node/esm --experimental-specifier-resolution=node src/scripts/test.mjs", "test:legacy": "node ./node_modules/mocha/bin/mocha -t 0 -R spec src/test/legacy/test.cjs", + "test:size": "size-limit", "publish:draft": "npm run build && npm publish --no-git-tag-version" }, + "size-limit": [ + { + "path": "target/esm/index.mjs", + "limit": "4 kB" + }, + { + "path": "target/cjs/index.cjs", + "limit": "5 kB" + } + ], "files": [ "target/cjs", "target/esm", @@ -45,17 +56,18 @@ "zurk": "^0.11.10" }, "devDependencies": { + "@size-limit/file": "^12.0.1", "@types/node": "^25.5.2", "c8": "^11.0.0", "concurrently": "^9.2.1", "esbuild": "^0.28.0", "esbuild-node-externals": "^1.21.0", "esbuild-plugin-entry-chunks": "^0.1.18", - "oxlint": "^1.58.0", "fast-glob": "^3.3.3", "minimist": "^1.2.8", - "mocha": "^10.8.2", - "sinon": "^18.0.1", + "mocha": "^11.7.5", + "oxlint": "^1.58.0", + "size-limit": "^12.0.1", "ts-node": "^10.9.2", "typedoc": "^0.28.18", "typescript": "^5.9.2" diff --git a/src/main/ts/ps.ts b/src/main/ts/ps.ts index 911f5a8..86c0cee 100644 --- a/src/main/ts/ps.ts +++ b/src/main/ts/ps.ts @@ -5,7 +5,7 @@ import { parse, type TIngridResponse } from '@webpod/ingrid' import { exec, type TSpawnCtx } from 'zurk/spawn' const IS_WIN = process.platform === 'win32' -const IS_WIN2025_PLUS = IS_WIN && Number.parseInt(os.release().split('.')[2], 10) >= 26_000 // WMIC will be missing in Windows 11 25H2 (kernel >= 26000) +const IS_WIN2025_PLUS = IS_WIN && Number.parseInt(os.release().split('.')[2], 10) >= 26_000 const LOOKUPS: Record parse(removeWmicPrefix(stdout), { format: 'win' }) }, ps: { cmd: 'ps', args: ['-eo', 'pid,ppid,args'], - parse(stdout: string) { - return parse(stdout, { format: 'unix' }) - } + parse: (stdout) => parse(stdout, { format: 'unix' }) }, pwsh: { cmd: 'pwsh', args: ['-NoProfile', '-Command', '"Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress"'], - parse(stdout: string) { - let arr: any[] = [] + parse(stdout) { try { - arr = JSON.parse(stdout) + const arr: Array<{ ProcessId: number, ParentProcessId: number, CommandLine: string | null }> = JSON.parse(stdout) + return arr.map(p => ({ + ProcessId: [String(p.ProcessId)], + ParentProcessId: [String(p.ParentProcessId)], + CommandLine: p.CommandLine ? [p.CommandLine] : [], + })) } catch { return [] } - - // Reshape into Ingrid-like objects for normalizeOutput - return arr.map(p => ({ - ProcessId: [p.ProcessId + ''], - ParentProcessId: [p.ParentProcessId + ''], - CommandLine: p.CommandLine ? [p.CommandLine] : [], - })) }, }, } -const isBin = (f: string): boolean => { - if (f === '') return false - if (!f.includes('/') && !f.includes('\\')) return true - if (f.length > 3 && f[0] === '"') - return f[f.length - 1] === '"' - ? isBin(f.slice(1, -1)) - : false - try { - if (!fs.existsSync(f)) return false - const stat = fs.lstatSync(f) - return stat.isFile() || stat.isSymbolicLink() - } catch { - return false - } -} - -export type TPsLookupCallback = (err: any, processList?: TPsLookupEntry[]) => void +const lookupFlow = IS_WIN ? IS_WIN2025_PLUS ? 'pwsh' : 'wmic' : 'ps' export type TPsLookupEntry = { pid: string @@ -76,278 +53,255 @@ export type TPsLookupQuery = { command?: string arguments?: string ppid?: number | string + psargs?: string } +export type TPsLookupCallback = (err: any, processList?: TPsLookupEntry[]) => void + export type TPsKillOptions = { timeout?: number signal?: string | number | NodeJS.Signals + /** Polling interval in ms between exit checks (default 200). */ + interval?: number +} + +export type TPsTreeOpts = { + pid: string | number + recursive?: boolean } export type TPsNext = (err?: any, data?: any) => void /** - * Query Process: Focus on pid & cmd - * @param query - * @param {String|String[]} query.pid - * @param {String} query.command RegExp String - * @param {String} query.arguments RegExp String - * @param {String|String[]} query.psargs - * @param {TPsLookupCallback} cb - * @return {Promise} + * Query running processes by pid, command, arguments or ppid. + * Supports both promise and callback styles. */ export const lookup = (query: TPsLookupQuery = {}, cb: TPsLookupCallback = noop): Promise => - _lookup({query, cb, sync: false}) as Promise + _lookup({ query, cb, sync: false }) as Promise -/** - * Looks up the process list synchronously - * @param query - * @param {String|String[]} query.pid - * @param {String} query.command RegExp String - * @param {String} query.arguments RegExp String - * @param {String|String[]} query.psargs - * @param {TPsLookupCallback} cb - * @return {TPsLookupEntry[]} - */ +/** Synchronous version of {@link lookup}. */ export const lookupSync = (query: TPsLookupQuery = {}, cb: TPsLookupCallback = noop): TPsLookupEntry[] => - _lookup({query, cb, sync: true}) + _lookup({ query, cb, sync: true }) lookup.sync = lookupSync -const _lookup = ({ - query = {}, - cb = noop, - sync = false - }: { - sync?: boolean - cb?: TPsLookupCallback - query?: TPsLookupQuery - }) => { - const pFactory = sync ? makePseudoDeferred.bind(null, []) : makeDeferred - const { promise, resolve, reject } = pFactory() +const _lookup = ({ query = {}, cb = noop, sync = false }: { + sync?: boolean + cb?: TPsLookupCallback + query?: TPsLookupQuery +}) => { + const { promise, resolve, reject } = sync ? makeSyncDeferred([]) : makeDeferred() const result: TPsLookupEntry[] = [] - const lookupFlow = IS_WIN ? IS_WIN2025_PLUS ? 'pwsh' : 'wmic' : 'ps' - const { parse, cmd, args } = LOOKUPS[lookupFlow] - const callback: TSpawnCtx['callback'] = (err, {stdout}) => { + const { parse: parseOutput, cmd, args: defaultArgs } = LOOKUPS[lookupFlow] + const args = !IS_WIN && query.psargs ? query.psargs.split(/\s+/) : defaultArgs + + const callback: TSpawnCtx['callback'] = (err, { stdout }) => { if (err) { reject(err) cb(err) return } - result.push(...filterProcessList(normalizeOutput(parse(stdout)), query)) + result.push(...filterProcessList(normalizeOutput(parseOutput(stdout)), query)) resolve(result) cb(null, result) } - exec({ - cmd, - args, - callback, - sync, - run(cb) { cb() }, - }) + exec({ cmd, args, callback, sync, run(cb) { cb() } }) return Object.assign(promise, result) } -export const filterProcessList = (processList: TPsLookupEntry[], query: TPsLookupQuery = {}): TPsLookupEntry[] => { - const pidList= (query.pid === undefined ? [] : [query.pid].flat(1)).map(v => v + '') - const filters: Array<(p: TPsLookupEntry) => boolean> = [ - p => query.command ? new RegExp(query.command, 'i').test(p.command) : true, - p => query.arguments ? new RegExp(query.arguments, 'i').test(p.arguments.join(' ')) : true, - p => query.ppid ? query.ppid + '' === p.ppid : true - ] - - return processList.filter(p => - (pidList.length === 0 || pidList.includes(p.pid)) && filters.every(f => f(p)) - ) -} - -export const removeWmicPrefix = (stdout: string): string => { - const s = stdout.indexOf(LOOKUPS.wmic.cmd + os.EOL) - const e = stdout.includes('>') - ? stdout.trimEnd().lastIndexOf(os.EOL) - : stdout.length - return (s > 0 - ? stdout.slice(s + LOOKUPS.wmic.cmd.length, e) - : stdout.slice(0, e)).trimStart() -} +/** Returns child processes of the given parent pid. */ +export const tree = async (opts?: string | number | TPsTreeOpts, cb?: TPsLookupCallback): Promise => + _tree({ opts, cb }) -export type TPsTreeOpts = { - pid: string | number - recursive?: boolean -} +/** Synchronous version of {@link tree}. */ +export const treeSync = (opts?: string | number | TPsTreeOpts, cb?: TPsLookupCallback): TPsLookupEntry[] => + _tree({ opts, cb, sync: true }) as TPsLookupEntry[] -export const pickTree = (list: TPsLookupEntry[], pid: string | number, recursive = false): TPsLookupEntry[] => { - const children = list.filter(p => p.ppid === pid + '') - return [ - ...children, - ...children.flatMap(p => recursive ? pickTree(list, p.pid, true) : []) - ] -} +tree.sync = treeSync -const _tree = ({ - cb = noop, - opts, - sync = false -}: { - opts?: string | number | TPsTreeOpts | undefined +const _tree = ({ cb = noop, opts, sync = false }: { + opts?: string | number | TPsTreeOpts cb?: TPsLookupCallback sync?: boolean }) => { if (typeof opts === 'string' || typeof opts === 'number') { - return _tree({opts: {pid: opts}, cb, sync}) + return _tree({ opts: { pid: opts }, cb, sync }) } - const onError = (err: any) => cb(err) + const onData = (all: TPsLookupEntry[]) => { if (opts === undefined) return all - - const {pid, recursive = false} = opts - const list = pickTree(all, pid, recursive) - + const list = pickTree(all, opts.pid, opts.recursive ?? false) cb(null, list) return list } + const onError = (err: unknown) => { + cb(err) + throw err + } + try { - const all = _lookup({sync}) + const all = _lookup({ sync }) return sync ? onData(all) - : (all as Promise).then(onData, (err: any) => { - onError(err) - throw err - }) + : (all as Promise).then(onData, onError) } catch (err) { - onError(err) + cb(err) return Promise.reject(err) } } -export const tree = async (opts?: string | number | TPsTreeOpts | undefined, cb?: TPsLookupCallback): Promise => - _tree({opts, cb}) - -export const treeSync = (opts?: string | number | TPsTreeOpts | undefined, cb?: TPsLookupCallback): TPsLookupEntry[] => - _tree({opts, cb, sync: true}) as TPsLookupEntry[] +/** + * Returns a `lookup()` snapshot started at or after `since`, dedupes concurrent callers. + * Lets parallel `kill()` polls share a single `ps` invocation: join the in-flight one + * if it's fresh enough, otherwise wait for the next queued one. + */ +type TSnapshot = { startedAt: number, list: TPsLookupEntry[] } +let inflight: { startedAt: number, promise: Promise } | null = null +let queued: Promise | null = null +const sharedSnapshot = (since: number): Promise => { + if (inflight && inflight.startedAt >= since) return inflight.promise + if (queued) return queued + const after = inflight?.promise.catch(noop) ?? Promise.resolve() + return queued = after.then(() => { + queued = null + const startedAt = Date.now() + const promise = lookup().then(list => ({ startedAt, list })) + inflight = { startedAt, promise } + return promise.finally(() => { inflight = inflight?.promise === promise ? null : inflight }) + }) +} -tree.sync = treeSync +export const pickTree = (list: TPsLookupEntry[], pid: string | number, recursive = false): TPsLookupEntry[] => { + const children = list.filter(p => p.ppid === String(pid)) + return [ + ...children, + ...children.flatMap(p => recursive ? pickTree(list, p.pid, true) : []) + ] +} /** - * Kill process - * @param pid - * @param {Object|String} opts - * @param {String} opts.signal - * @param {number} opts.timeout - * @param next + * Kills a process by pid. + * @param pid - Process ID to kill + * @param opts - Signal, options object, or callback + * @param next - Callback invoked when kill is confirmed or timed out */ -export const kill = (pid: string | number, opts?: TPsNext | TPsKillOptions | TPsKillOptions['signal'], next?: TPsNext ): Promise => { - if (typeof opts == 'function') { - return kill(pid, undefined, opts) - } - if (typeof opts == 'string' || typeof opts == 'number') { - return kill(pid, { signal: opts }, next) - } +export const kill = (pid: string | number, opts?: TPsNext | TPsKillOptions | TPsKillOptions['signal'], next?: TPsNext): Promise => { + if (typeof opts === 'function') return kill(pid, undefined, opts) + if (typeof opts === 'string' || typeof opts === 'number') return kill(pid, { signal: opts }, next) const { promise, resolve, reject } = makeDeferred() - const { - timeout = 30, - signal = 'SIGTERM' - } = opts || {} + const { timeout = 30, signal = 'SIGTERM', interval = 200 } = opts || {} + const sPid = String(pid) + let done = false + const state: { timer?: NodeJS.Timeout } = {} + const settle = (err?: unknown) => { + if (done) return + done = true + clearTimeout(state.timer) + if (err) reject(err) + else resolve(pid) + next?.(err ?? null, pid) + } try { process.kill(+pid, signal) - } catch(e) { - reject(e) - next?.(e) - + } catch (e) { + settle(e) return promise } - let checkConfident = 0 - let checkTimeoutTimer: NodeJS.Timeout - let checkIsTimeout = false - - const checkKilled = (finishCallback?: TPsNext) => - lookup({ pid }, (err, list = []) => { - if (checkIsTimeout) return - - if (err) { - clearTimeout(checkTimeoutTimer) - reject(err) - finishCallback?.(err, pid) - } - - else if (list.length > 0) { - checkConfident = (checkConfident - 1) || 0 - checkKilled(finishCallback) + let since = Date.now() + state.timer = setTimeout(() => settle(new Error('Kill process timeout')), timeout * 1000) + + const poll = (): unknown => + sharedSnapshot(since).then(({ startedAt, list }) => { + if (done) return + since = startedAt + 1 + if (list.some(p => p.pid === sPid)) { + setTimeout(poll, Math.max(0, startedAt + interval - Date.now())) + } else { + settle() } + }, settle) - else { - checkConfident++ - if (checkConfident === 5) { - clearTimeout(checkTimeoutTimer) - resolve(pid) - finishCallback?.(null, pid) - } else { - checkKilled(finishCallback) - } - } - }) - - if (next) { - checkKilled(next) - checkTimeoutTimer = setTimeout(() => { - checkIsTimeout = true - next(new Error('Kill process timeout')) - }, timeout * 1000) - } else { - resolve(pid) - } - + poll() return promise } export const normalizeOutput = (data: TIngridResponse): TPsLookupEntry[] => - data.reduce((m, d) => { + data.flatMap(d => { const pid = (d.PID || d.ProcessId)?.[0] const ppid = (d.PPID || d.ParentProcessId)?.[0] - const _cmd = d.CMD || d.CommandLine || d.COMMAND || d.ARGS || [] - const cmd = _cmd.length === 1 ? _cmd[0].split(/\s+/) : _cmd - - if (pid && cmd.length > 0) { - const c = (cmd.findIndex((_v, i) => isBin(cmd.slice(0, i).join(' ')))) - const command = (c === -1 ? cmd: cmd.slice(0, c)).join(' ') - const args = c === -1 ? [] : cmd.slice(c) - - m.push({ - pid, - ppid, - command: command, - arguments: args - }) - } + const rawCmd = d.CMD || d.CommandLine || d.COMMAND || d.ARGS || [] + const parts = rawCmd.length === 1 ? rawCmd[0].split(/\s+/) : rawCmd - return m - }, []) + if (!pid || parts.length === 0) return [] -export type PromiseResolve = (value?: T | PromiseLike) => void + const binIdx = parts.findIndex((_v, i) => isBin(parts.slice(0, i).join(' '))) + const command = (binIdx === -1 ? parts : parts.slice(0, binIdx)).join(' ') + const args = binIdx === -1 ? [] : parts.slice(binIdx) -type Deferred = { promise: Promise, resolve: PromiseResolve, reject: PromiseResolve } + return [{ pid, ppid, command, arguments: args }] + }) -const makeDeferred = (): Deferred => { - let resolve - let reject - const promise = new Promise((res, rej) => { resolve = res; reject = rej }) - return { resolve, reject, promise } as unknown as Deferred +export const filterProcessList = (processList: TPsLookupEntry[], query: TPsLookupQuery = {}): TPsLookupEntry[] => { + const pidList = (query.pid === undefined ? [] : [query.pid].flat(1)).map(String) + const commandRe = query.command ? new RegExp(query.command, 'i') : null + const argumentsRe = query.arguments ? new RegExp(query.arguments, 'i') : null + const ppid = query.ppid === undefined ? null : String(query.ppid) + + return processList.filter(p => + (pidList.length === 0 || pidList.includes(p.pid)) && + (!commandRe || commandRe.test(p.command)) && + (!argumentsRe || argumentsRe.test(p.arguments.join(' '))) && + (!ppid || ppid === p.ppid) + ) } -const makePseudoDeferred = (r = {}): Deferred=> - ({ - promise: r as any, - resolve: identity, - reject(e: any) { - throw e - } - }) as Deferred +export const removeWmicPrefix = (stdout: string): string => { + const s = stdout.indexOf(LOOKUPS.wmic.cmd + os.EOL) + const e = stdout.includes('>') + ? stdout.trimEnd().lastIndexOf(os.EOL) + : stdout.length + return (s > 0 + ? stdout.slice(s + LOOKUPS.wmic.cmd.length, e) + : stdout.slice(0, e)).trimStart() +} + +const isBin = (f: string): boolean => { + if (f === '') return false + if (!f.includes('/') && !f.includes('\\')) return true + if (f.length > 3 && f[0] === '"') + return f.at(-1) === '"' ? isBin(f.slice(1, -1)) : false + try { + if (!fs.existsSync(f)) return false + const stat = fs.lstatSync(f) + return stat.isFile() || stat.isSymbolicLink() + } catch { + return false + } +} + +type Deferred = { + promise: Promise + resolve: (value?: T | PromiseLike) => void + reject: (reason?: E) => void +} + +const makeDeferred = (): Deferred => { + let resolve!: Deferred['resolve'] + let reject!: Deferred['reject'] + const promise = new Promise((res, rej) => { resolve = res as Deferred['resolve']; reject = rej }) + return { resolve, reject, promise } +} -const noop = () => {/* noop */} +const makeSyncDeferred = (result: T): Deferred => ({ + promise: result as any, + resolve: () => {}, + reject(e) { throw e }, +}) -const identity = (v: T): T => v +const noop = () => {} diff --git a/src/test/legacy/test.cjs b/src/test/legacy/test.cjs index 6730b59..eed26d6 100644 --- a/src/test/legacy/test.cjs +++ b/src/test/legacy/test.cjs @@ -4,8 +4,6 @@ var cp = require('node:child_process'); var assert = require('node:assert'); var Path = require('node:path'); -var Sinon = require('sinon'); - var ps = require('@webpod/ps'); var serverPath = Path.resolve(__dirname, './node_process_for_test.cjs'); @@ -173,48 +171,27 @@ describe('test', function () { }); describe('#kill() timeout: ', function () { - var clock; - before(() => { - clock = Sinon.useFakeTimers(); - }) - after(() => { - clock.restore(); - }) - - it('it should timeout after 30secs by default if the killing is not successful', function(done) { + it('uses 30s default timeout when none provided', function(done) { + this.timeout(5000); mockKill(); - var killStartDate = Date.now(); - - ps.lookup({pid}, function (err, list) { - assert.equal(list.length, 1); - ps.kill(pid, function (err) { - assert.equal(Date.now() - killStartDate >= 30 * 1000, true); - assert.equal(err.message.indexOf('timeout') >= 0, true); - restoreKill(); - ps.kill(pid, function(){ - clock.restore(); - done(); - }); - }); - clock.tick(30 * 1000); - }); + var settled = false; + ps.kill(pid, function () { settled = true; }); + setTimeout(function () { + assert.equal(settled, false, 'kill should still be pending well before 30s'); + restoreKill(); + ps.kill(pid, function () { done(); }); + }, 1500); }); it('it should be able to set option to set the timeout', function(done) { + this.timeout(10000); mockKill(); var killStartDate = Date.now(); - ps.lookup({pid: pid}, function (err, list) { - assert.equal(list.length, 1); - ps.kill(pid, { timeout: 5 }, function (err) { - assert.equal(Date.now() - killStartDate >= 5 * 1000, true); - assert.equal(err.message.indexOf('timeout') >= 0, true); - restoreKill(); - ps.kill(pid, function(){ - Sinon.useFakeTimers - done(); - }); - }); - clock.tick(5 * 1000); + ps.kill(pid, { timeout: 2, interval: 200 }, function (err) { + assert.equal(Date.now() - killStartDate >= 2 * 1000, true); + assert.equal(err.message.indexOf('timeout') >= 0, true); + restoreKill(); + ps.kill(pid, function(){ done(); }); }); }); }); diff --git a/src/test/ts/ps.test.ts b/src/test/ts/ps.test.ts index dc4ef4a..aec0433 100644 --- a/src/test/ts/ps.test.ts +++ b/src/test/ts/ps.test.ts @@ -1,28 +1,28 @@ import * as assert from 'node:assert' import { describe, it, before, after } from 'node:test' import process from 'node:process' -import * as cp from 'node:child_process' +import { fork, execSync } from 'node:child_process' import * as path from 'node:path' -import { kill, lookup, lookupSync, tree, treeSync, removeWmicPrefix, normalizeOutput } from '../../main/ts/ps.ts' +import { kill, lookup, lookupSync, tree, treeSync, removeWmicPrefix, normalizeOutput, filterProcessList } from '../../main/ts/ps.ts' import { parse } from '@webpod/ingrid' -import { execSync } from 'node:child_process' const __dirname = new URL('.', import.meta.url).pathname const marker = Math.random().toString(16).slice(2) const testScript = path.resolve(__dirname, '../legacy/node_process_for_test.cjs') const testScriptArgs = [marker, '--foo', '--bar'] +const SPAWN_DELAY = 2000 + +const spawnChild = (...extra: string[]) => + fork(testScript, [...testScriptArgs, ...extra]).pid as number + +const killSafe = (pid: number) => { + try { process.kill(pid) } catch {} +} describe('lookup()', () => { let pid: number - before(() => { - pid = cp.fork(testScript, testScriptArgs).pid as number - }) - - after(() => { - try { - process.kill(pid) - } catch (err) { void err } - }) + before(() => { pid = spawnChild() }) + after(() => killSafe(pid)) it('returns a process list', async () => { const list = await lookup() @@ -31,36 +31,34 @@ describe('lookup()', () => { it('searches process by pid', async () => { const list = await lookup({ pid }) - const found = list[0] - assert.equal(list.length, 1) - assert.equal(found.pid, pid) + assert.equal(list[0].pid, pid) }) it('filters by args', async () => { const list = await lookup({ arguments: marker }) - assert.equal(list.length, 1) assert.equal(list[0].pid, pid) }) + + if (process.platform !== 'win32') { + it('supports custom psargs', async () => { + const list = await lookup({ pid, psargs: '-eo pid,ppid,args' }) + assert.equal(list.length, 1) + assert.equal(list[0].pid, pid) + }) + } }) describe('lookupSync()', () => { let pid: number - before(() => { - pid = cp.fork(testScript, testScriptArgs).pid as number - }) - - after(() => { - try { - process.kill(pid) - } catch (err) { void err } - }) + before(() => { pid = spawnChild() }) + after(() => killSafe(pid)) it('returns a process list', () => { - const list = lookupSync() - assert.ok(list.length > 0) + assert.ok(lookupSync().length > 0) }) + it('lookup.sync refs to lookupSync', () => { assert.equal(lookup.sync, lookupSync) }) @@ -68,48 +66,42 @@ describe('lookupSync()', () => { describe('kill()', () => { it('kills a process', async () => { - const pid = cp.fork(testScript, testScriptArgs).pid as number + const pid = spawnChild() assert.equal((await lookup({ pid })).length, 1) await kill(pid) assert.equal((await lookup({ pid })).length, 0) }) it('kills with check', async () => { - let cheked = false - const cb = () => cheked = true - - const pid = cp.fork(testScript, testScriptArgs).pid as number + let checked = false + const pid = spawnChild() assert.equal((await lookup({ pid })).length, 1) - const _pid = await kill(pid, {timeout: 1}, cb) - assert.equal(pid, _pid) + const result = await kill(pid, { timeout: 1 }, () => { checked = true }) + assert.equal(result, pid) assert.equal((await lookup({ pid })).length, 0) - assert.equal(cheked, true) + assert.ok(checked) }) }) describe('tree()', () => { it('returns 1st level child', async () => { - const pid = cp.fork(testScript, [...testScriptArgs, '--fork=1', '--depth=2']).pid as number - await new Promise(resolve => setTimeout(resolve, 2000)) // wait for child process to spawn + const pid = spawnChild('--fork=1', '--depth=2') + await new Promise(resolve => setTimeout(resolve, SPAWN_DELAY)) const list = await lookup({ arguments: marker }) const children = await tree(pid) - const childrenAll = await tree({pid, recursive: true}) + const childrenAll = await tree({ pid, recursive: true }) - await Promise.all(list.map(p => kill(p.pid))) - await kill(pid) + list.forEach(p => { try { process.kill(+p.pid, 'SIGKILL') } catch {} }) assert.equal(children.length, 1) assert.equal(childrenAll.length, 2) assert.equal(list.length, 3) - - assert.equal((await lookup({ arguments: marker })).length, 0) }) it('returns all ps list if no opts provided', async () => { - const list = await tree() - assert.ok(list.length > 0) + assert.ok((await tree()).length > 0) }) }) @@ -119,38 +111,28 @@ describe('treeSync()', () => { }) it('returns 1st level child', async () => { - const pid = cp.fork(testScript, [...testScriptArgs, '--fork=1', '--depth=2']).pid as number - await new Promise(resolve => setTimeout(resolve, 2000)) // wait for child process to spawn + const pid = spawnChild('--fork=1', '--depth=2') + await new Promise(resolve => setTimeout(resolve, SPAWN_DELAY)) const list = lookupSync({ arguments: marker }) const children = treeSync(pid) - const childrenAll = treeSync({pid, recursive: true}) + const childrenAll = treeSync({ pid, recursive: true }) - await Promise.all(list.map(p => kill(p.pid))) - await kill(pid) + list.forEach(p => { try { process.kill(+p.pid, 'SIGKILL') } catch {} }) assert.equal(children.length, 1) assert.equal(childrenAll.length, 2) assert.equal(list.length, 3) - - assert.equal((await lookup({ arguments: marker })).length, 0) }) }) -describe('ps -eo vs ps -lx output comparison', () => { - if (process.platform === 'win32') return - +describe('ps -eo vs ps -lx output comparison', { skip: process.platform === 'win32' }, () => { let pid: number - before(() => { - pid = cp.fork(testScript, testScriptArgs).pid as number - }) - after(() => { - try { process.kill(pid) } catch { void 0 } - }) + before(() => { pid = spawnChild() }) + after(() => killSafe(pid)) it('ps -eo pid,ppid,args returns valid entries', () => { - const stdout = execSync('ps -eo pid,ppid,args').toString() - const entries = normalizeOutput(parse(stdout, { format: 'unix' })) + const entries = normalizeOutput(parse(execSync('ps -eo pid,ppid,args').toString(), { format: 'unix' })) assert.ok(entries.length > 0, 'should return non-empty process list') for (const e of entries) { @@ -160,9 +142,8 @@ describe('ps -eo vs ps -lx output comparison', () => { }) it('ps -eo finds a known process with correct fields', () => { - const eoStdout = execSync('ps -eo pid,ppid,args').toString() - const eoEntries = normalizeOutput(parse(eoStdout, { format: 'unix' })) - const found = eoEntries.find(e => e.pid === String(pid)) + const entries = normalizeOutput(parse(execSync('ps -eo pid,ppid,args').toString(), { format: 'unix' })) + const found = entries.find(e => e.pid === String(pid)) assert.ok(found, `ps -eo should find spawned process ${pid}`) assert.equal(found!.pid, String(pid)) @@ -179,10 +160,8 @@ describe('ps -eo vs ps -lx output comparison', () => { return // ps -lx not available (e.g. BusyBox) — skip } - const eoStdout = execSync('ps -eo pid,ppid,args').toString() - const lxEntries = normalizeOutput(parse(lxStdout, { format: 'unix' })) - const eoEntries = normalizeOutput(parse(eoStdout, { format: 'unix' })) + const eoEntries = normalizeOutput(parse(execSync('ps -eo pid,ppid,args').toString(), { format: 'unix' })) const lxFound = lxEntries.find(e => e.pid === String(pid)) const eoFound = eoEntries.find(e => e.pid === String(pid)) @@ -195,7 +174,116 @@ describe('ps -eo vs ps -lx output comparison', () => { }) }) -describe('extractWmic()', () => { +describe('kill() edge cases', () => { + it('rejects when killing a non-existent pid', async () => { + await assert.rejects(() => kill(999_999), { code: 'ESRCH' }) + }) + + it('rejects with invalid signal', async () => { + const pid = spawnChild() + await assert.rejects(() => kill(pid, 'INVALID')) + killSafe(pid) + }) + + it('passes signal as string shorthand', async () => { + const pid = spawnChild() + await kill(pid, 'SIGKILL') + assert.equal((await lookup({ pid })).length, 0) + }) + + it('invokes callback on error for non-existent pid', async () => { + let cbErr: unknown + await kill(999_999, (err) => { cbErr = err }).catch(() => {}) + assert.ok(cbErr) + }) +}) + +describe('kill() timeout', { skip: process.platform === 'win32' }, () => { + it('rejects on timeout when process stays alive', async () => { + // Signal 0 checks existence but doesn't actually kill — process stays alive, so poll times out + const pid = spawnChild() + await assert.rejects( + () => kill(pid, { signal: 0 as any, timeout: 1 }), + (err: Error) => err.message.includes('timeout') + ) + killSafe(pid) + }) +}) + +describe('tree() edge cases', () => { + it('accepts string pid', async () => { + const pid = spawnChild() + const children = await tree(String(pid)) + assert.ok(Array.isArray(children)) + killSafe(pid) + }) + + it('treeSync accepts number pid', () => { + const pid = spawnChild() + const children = treeSync(pid) + assert.ok(Array.isArray(children)) + killSafe(pid) + }) +}) + +describe('filterProcessList()', () => { + const list = [ + { pid: '1', ppid: '0', command: '/usr/bin/node', arguments: ['server.js', '--port=3000'] }, + { pid: '2', ppid: '1', command: '/usr/bin/python', arguments: ['app.py'] }, + { pid: '3', ppid: '1', command: '/usr/bin/node', arguments: ['worker.js'] }, + ] + + it('filters by pid array', () => { + assert.equal(filterProcessList(list, { pid: ['1', '3'] }).length, 2) + }) + + it('filters by command regex', () => { + assert.equal(filterProcessList(list, { command: 'node' }).length, 2) + }) + + it('filters by arguments regex', () => { + assert.equal(filterProcessList(list, { arguments: 'port' }).length, 1) + }) + + it('filters by ppid', () => { + assert.equal(filterProcessList(list, { ppid: 1 }).length, 2) + }) + + it('returns all when no filters', () => { + assert.equal(filterProcessList(list).length, 3) + }) +}) + +describe('normalizeOutput()', () => { + it('skips entries without pid', () => { + const data = [{ COMMAND: ['node'] }] as any + assert.equal(normalizeOutput(data).length, 0) + }) + + it('skips entries without command', () => { + const data = [{ PID: ['1'] }] as any + assert.equal(normalizeOutput(data).length, 0) + }) + + it('handles ARGS header (macOS)', () => { + const data = [{ PID: ['1'], PPID: ['0'], ARGS: ['/usr/bin/node server.js'] }] as any + const result = normalizeOutput(data) + assert.equal(result.length, 1) + assert.ok(result[0].command) + }) + + it('handles quoted paths on Windows', () => { + const data = [{ + ProcessId: ['1'], + ParentProcessId: ['0'], + CommandLine: ['"C:\\Program Files\\node.exe" server.js'] + }] as any + const result = normalizeOutput(data) + assert.equal(result.length, 1) + }) +}) + +describe('removeWmicPrefix()', () => { it('extracts wmic output', () => { const input = `CommandLine ParentProcessId ProcessId @@ -207,7 +295,12 @@ ParentProcessId ProcessId PS C:\\Users\\user>` const sliced = removeWmicPrefix(input).trim() + assert.equal(sliced, input.slice(0, -'PS C:\\Users\\user>'.length - 1).trim()) + }) - assert.equal(sliced, input.slice(0, -'PS C:\\Users\\user>'.length -1).trim()) + it('handles output without prompt suffix', () => { + const input = `ParentProcessId ProcessId\n0 1` + const result = removeWmicPrefix(input) + assert.ok(result.includes('ProcessId')) }) })