npq 3.8.0 → 3.9.0
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.
- package/.github/npq.mov +0 -0
- package/.github/npq.mp4 +0 -0
- package/.github/npq.png +0 -0
- package/.github/workflows/main.yml +2 -2
- package/README.md +18 -9
- package/__tests__/__fixtures__/test.marshall.js +5 -6
- package/__tests__/cli.test.js +110 -0
- package/__tests__/cliPrompt.test.js +409 -0
- package/__tests__/marshalls.base.test.js +1 -1
- package/__tests__/marshalls.classMethods.test.js +11 -14
- package/__tests__/marshalls.expiredDomains.test.js +2 -2
- package/__tests__/marshalls.provenance.test.js +181 -4
- package/__tests__/marshalls.repo.test.js +6 -6
- package/__tests__/marshalls.signatures.test.js +0 -4
- package/__tests__/marshalls.snyk.test.js +294 -0
- package/__tests__/marshalls.tasks.test.js +48 -24
- package/__tests__/marshalls.typosquatting.test.js +16 -9
- package/__tests__/marshalls.version-maturity.test.js +6 -6
- package/__tests__/packageRepoUtils.test.js +4 -4
- package/__tests__/reportResults.test.js +772 -0
- package/__tests__/scripts.test.js +2 -2
- package/bin/npq-hero.js +70 -20
- package/bin/npq.js +113 -21
- package/lib/cli.js +127 -24
- package/lib/helpers/cliPrompt.js +97 -0
- package/lib/helpers/cliSpinner.js +104 -0
- package/lib/helpers/cliSupportHandler.js +50 -7
- package/lib/helpers/packageRepoUtils.js +7 -2
- package/lib/helpers/promiseThrottler.js +96 -0
- package/lib/helpers/reportResults.js +304 -0
- package/lib/helpers/sourcePackages.js +36 -0
- package/lib/marshall.js +42 -65
- package/lib/marshalls/age.marshall.js +4 -3
- package/lib/marshalls/author.marshall.js +38 -9
- package/lib/marshalls/baseMarshall.js +9 -17
- package/lib/marshalls/deprecation.marshall.js +43 -0
- package/lib/marshalls/downloads.marshall.js +3 -3
- package/lib/marshalls/expiredDomains.marshall.js +7 -5
- package/lib/marshalls/index.js +110 -80
- package/lib/marshalls/license.marshall.js +3 -3
- package/lib/marshalls/newbin.marshall.js +1 -2
- package/lib/marshalls/provenance.marshall.js +152 -33
- package/lib/marshalls/repo.marshall.js +8 -7
- package/lib/marshalls/scripts.marshall.js +1 -1
- package/lib/marshalls/signatures.marshall.js +26 -2
- package/lib/marshalls/snyk.marshall.js +61 -15
- package/lib/marshalls/typosquatting.marshall.js +8 -5
- package/lib/marshalls/version-maturity.marshall.js +4 -4
- package/lib/marshallsDecomissioned/readme.marshall.js +2 -2
- package/package.json +16 -19
- package/scripts/postinstall.js +10 -11
- package/lib/cliCommons.js +0 -80
package/.github/npq.mov
ADDED
|
Binary file
|
package/.github/npq.mp4
ADDED
|
Binary file
|
package/.github/npq.png
ADDED
|
Binary file
|
|
@@ -7,7 +7,7 @@ jobs:
|
|
|
7
7
|
strategy:
|
|
8
8
|
matrix:
|
|
9
9
|
platform: [ubuntu-latest]
|
|
10
|
-
node: ['
|
|
10
|
+
node: ['20', '22', '24']
|
|
11
11
|
name: Node ${{ matrix.node }} (${{ matrix.platform }})
|
|
12
12
|
runs-on: ${{ matrix.platform }}
|
|
13
13
|
steps:
|
|
@@ -48,7 +48,7 @@ jobs:
|
|
|
48
48
|
- uses: actions/checkout@v2
|
|
49
49
|
- uses: actions/setup-node@v2
|
|
50
50
|
with:
|
|
51
|
-
node-version: '
|
|
51
|
+
node-version: '24'
|
|
52
52
|
- name: install dependencies
|
|
53
53
|
run: npm ci --ignore-engines --ignore-scripts
|
|
54
54
|
- name: release
|
package/README.md
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
<img width="200" src="https://github.com/lirantal/npq/raw/main/.github/Logo%20Horizontal.png">
|
|
4
|
-
<br>
|
|
5
|
-
safely* install packages with npm/yarn by auditing them as part of your install process
|
|
6
|
-
</p>
|
|
1
|
+
npq allows you to audit npm packages _before_ you install them
|
|
2
|
+
|
|
7
3
|
|
|
8
4
|
[](https://www.npmjs.com/package/npq)
|
|
9
5
|
[](https://www.npmjs.com/package/npq)
|
|
@@ -12,8 +8,7 @@
|
|
|
12
8
|
[](https://snyk.io/test/github/lirantal/npq)
|
|
13
9
|
[](./SECURITY.md)
|
|
14
10
|
|
|
15
|
-

|
|
17
12
|
|
|
18
13
|
Media coverage about npq:
|
|
19
14
|
- As mentioned on [Thomas Gentilhomme](https://github.com/fraxken)'s French book of [Become a Node.js Developer](https://docs.google.com/document/d/1JHgmEFkc8Py4XSuCB8_DQ5FFEJoogyeninFK6ucTd4o/edit#)
|
|
@@ -44,7 +39,11 @@ npq install express
|
|
|
44
39
|
|
|
45
40
|
If npq is prompted to continue with the install, it simply hands over the actual package install job to the package manager (npm by default).
|
|
46
41
|
|
|
47
|
-
|
|
42
|
+
DISCLAIMER: there's no guaranteed absolute safety; a malicious or vulnerable package could still exist that has no security vulnerabilities publicly disclosed and passes npq's checks.
|
|
43
|
+
|
|
44
|
+
## Demo
|
|
45
|
+
|
|
46
|
+
https://github.com/user-attachments/assets/619ab3f6-aa3f-483c-9560-0f18e033e6bf
|
|
48
47
|
|
|
49
48
|
## Install
|
|
50
49
|
|
|
@@ -99,6 +98,9 @@ Note: `npq` by default will offload all commands and their arguments to the `npm
|
|
|
99
98
|
| provenance | Will verify the package's attestations of provenance metadata for the published package
|
|
100
99
|
| version-maturity | Will show a warning if the specific version being installed was published less than 7 days ago | Helps identify recently published versions that may not have been reviewed by the community yet
|
|
101
100
|
| newBin | Will show a warning if the package version being installed introduces a new command-line binary (via the `bin` field in `package.json`) that was not present in its previous version. | Helps identify potentially unexpected new executables being added to your `node_modules/.bin/` directory.
|
|
101
|
+
| typosquatting | Will show a warning if the package name is similar to a popular package name, which could indicate a potential typosquatting attack. | Helps identify packages that may be trying to trick users into installing them by mimicking popular package names.
|
|
102
|
+
| deprecation | Will show a warning if the package version being installed is deprecated. | Helps identify packages that are no longer maintained or recommended for use.
|
|
103
|
+
|
|
102
104
|
|
|
103
105
|
### Disabling Marshalls
|
|
104
106
|
|
|
@@ -129,6 +131,7 @@ Here are all the available environment variable names for disabling specific mar
|
|
|
129
131
|
| typosquatting | `MARSHALL_DISABLE_TYPOSQUATTING` | Disable typosquatting detection |
|
|
130
132
|
| version-maturity | `MARSHALL_DISABLE_VERSION_MATURITY` | Disable version maturity checks |
|
|
131
133
|
| newBin | `MARSHALL_DISABLE_NEWBIN` | Disable new binary introduction checks |
|
|
134
|
+
| deprecation | `MARSHALL_DISABLE_DEPRECATION` | Disable deprecation checks |
|
|
132
135
|
|
|
133
136
|
### Run checks on package without installing it:
|
|
134
137
|
|
|
@@ -136,6 +139,12 @@ Here are all the available environment variable names for disabling specific mar
|
|
|
136
139
|
npq install express --dry-run
|
|
137
140
|
```
|
|
138
141
|
|
|
142
|
+
### Force non-rich text output:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
npq install express --plain
|
|
146
|
+
```
|
|
147
|
+
|
|
139
148
|
## Learn Node.js Security
|
|
140
149
|
|
|
141
150
|
<div align="center">
|
|
@@ -22,25 +22,24 @@ class TestMarshall extends BaseMarshall {
|
|
|
22
22
|
return Promise.all(tasks)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
mockCheck(pkg, ctx
|
|
25
|
+
mockCheck(pkg, ctx) {
|
|
26
26
|
return this.validateSomething(pkg)
|
|
27
27
|
.then(() => {
|
|
28
28
|
const data = 'mock data check'
|
|
29
|
-
|
|
30
|
-
ctx.marshalls[this.name].data[pkg] = data
|
|
29
|
+
ctx.marshalls[this.name].data[pkg.packageName] = data
|
|
31
30
|
|
|
32
31
|
return data
|
|
33
32
|
})
|
|
34
33
|
.catch((err) => {
|
|
35
34
|
this.setMessage({
|
|
36
|
-
pkg,
|
|
35
|
+
pkg: pkg.packageName,
|
|
37
36
|
message: err.message
|
|
38
37
|
})
|
|
39
38
|
})
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
validateSomething(pkg) {
|
|
43
|
-
if (pkg === 'express' || pkg === 'semver') {
|
|
42
|
+
if (pkg.packageName === 'express' || pkg.packageName === 'semver') {
|
|
44
43
|
return Promise.resolve()
|
|
45
44
|
} else {
|
|
46
45
|
return Promise.reject(new Error('simulating mock error'))
|
|
@@ -48,7 +47,7 @@ class TestMarshall extends BaseMarshall {
|
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
validate(pkg) {
|
|
51
|
-
if (pkg === 'express' || pkg === 'semver') {
|
|
50
|
+
if (pkg.packageName === 'express' || pkg.packageName === 'semver') {
|
|
52
51
|
return Promise.resolve('validation-result')
|
|
53
52
|
} else {
|
|
54
53
|
return Promise.reject(new Error('simulating mock error'))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// __tests__/cli.test.js
|
|
2
|
+
|
|
3
|
+
// Mock dependencies. These are hoisted by Jest.
|
|
4
|
+
jest.mock('../lib/cli', () => ({
|
|
5
|
+
CliParser: {
|
|
6
|
+
parseArgsFull: jest.fn(),
|
|
7
|
+
exit: jest.fn()
|
|
8
|
+
}
|
|
9
|
+
}))
|
|
10
|
+
jest.mock('../lib/helpers/cliSupportHandler', () => ({
|
|
11
|
+
isEnvSupport: jest.fn().mockReturnValue(true),
|
|
12
|
+
isInteractiveTerminal: jest.fn(),
|
|
13
|
+
noSupportError: jest.fn()
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
// Create a shared mock for the spinner instance that we can inspect in tests.
|
|
17
|
+
const mockSpinnerInstance = {
|
|
18
|
+
start: jest.fn(),
|
|
19
|
+
stop: jest.fn()
|
|
20
|
+
}
|
|
21
|
+
jest.mock('../lib/helpers/cliSpinner', () => ({
|
|
22
|
+
// The Spinner property is a mock constructor that returns our shared mock instance.
|
|
23
|
+
Spinner: jest.fn().mockImplementation(() => mockSpinnerInstance)
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
jest.mock('../lib/helpers/sourcePackages', () => ({
|
|
27
|
+
getProjectPackages: jest.fn().mockResolvedValue(['express'])
|
|
28
|
+
}))
|
|
29
|
+
jest.mock('../lib/marshall', () =>
|
|
30
|
+
jest.fn(() => ({
|
|
31
|
+
process: jest.fn().mockResolvedValue({})
|
|
32
|
+
}))
|
|
33
|
+
)
|
|
34
|
+
jest.mock('../lib/helpers/reportResults', () => ({
|
|
35
|
+
reportResults: jest.fn().mockReturnValue({ countErrors: 0, countWarnings: 0 })
|
|
36
|
+
}))
|
|
37
|
+
jest.mock('../lib/packageManager', () => ({
|
|
38
|
+
process: jest.fn()
|
|
39
|
+
}))
|
|
40
|
+
jest.mock('../lib/helpers/cliPrompt.js', () => ({
|
|
41
|
+
prompt: jest.fn().mockResolvedValue({ install: true }),
|
|
42
|
+
autoContinue: jest.fn().mockResolvedValue({ install: true })
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
describe('npq CLI script', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
// Reset modules to ensure mocks are fresh for each test.
|
|
48
|
+
jest.resetModules()
|
|
49
|
+
// Clear mock history on the shared instance and the constructor.
|
|
50
|
+
const { Spinner } = require('../lib/helpers/cliSpinner')
|
|
51
|
+
mockSpinnerInstance.start.mockClear()
|
|
52
|
+
mockSpinnerInstance.stop.mockClear()
|
|
53
|
+
Spinner.mockClear()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('should initialize and start spinner in interactive mode without --plain flag', async () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
const { CliParser } = require('../lib/cli')
|
|
59
|
+
const { isInteractiveTerminal } = require('../lib/helpers/cliSupportHandler')
|
|
60
|
+
const { Spinner } = require('../lib/helpers/cliSpinner')
|
|
61
|
+
|
|
62
|
+
CliParser.parseArgsFull.mockReturnValue({ packages: ['express'], plain: false, dryRun: true })
|
|
63
|
+
isInteractiveTerminal.mockReturnValue(true)
|
|
64
|
+
|
|
65
|
+
// Act: Dynamically require the script to run it.
|
|
66
|
+
require('../bin/npq.js')
|
|
67
|
+
// Wait for async operations in the script to settle.
|
|
68
|
+
await new Promise(process.nextTick)
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
expect(Spinner).toHaveBeenCalledTimes(1)
|
|
72
|
+
expect(mockSpinnerInstance.start).toHaveBeenCalledTimes(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('should not initialize spinner when --plain flag is used', async () => {
|
|
76
|
+
// Arrange
|
|
77
|
+
const { CliParser } = require('../lib/cli')
|
|
78
|
+
const { isInteractiveTerminal } = require('../lib/helpers/cliSupportHandler')
|
|
79
|
+
const { Spinner } = require('../lib/helpers/cliSpinner')
|
|
80
|
+
|
|
81
|
+
CliParser.parseArgsFull.mockReturnValue({ packages: ['express'], plain: true, dryRun: true })
|
|
82
|
+
isInteractiveTerminal.mockReturnValue(true)
|
|
83
|
+
|
|
84
|
+
// Act
|
|
85
|
+
require('../bin/npq.js')
|
|
86
|
+
await new Promise(process.nextTick)
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
89
|
+
expect(Spinner).not.toHaveBeenCalled()
|
|
90
|
+
expect(mockSpinnerInstance.start).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('should not initialize spinner in non-interactive mode', async () => {
|
|
94
|
+
// Arrange
|
|
95
|
+
const { CliParser } = require('../lib/cli')
|
|
96
|
+
const { isInteractiveTerminal } = require('../lib/helpers/cliSupportHandler')
|
|
97
|
+
const { Spinner } = require('../lib/helpers/cliSpinner')
|
|
98
|
+
|
|
99
|
+
CliParser.parseArgsFull.mockReturnValue({ packages: ['express'], plain: false, dryRun: true })
|
|
100
|
+
isInteractiveTerminal.mockReturnValue(false)
|
|
101
|
+
|
|
102
|
+
// Act
|
|
103
|
+
require('../bin/npq.js')
|
|
104
|
+
await new Promise(process.nextTick)
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(Spinner).not.toHaveBeenCalled()
|
|
108
|
+
expect(mockSpinnerInstance.start).not.toHaveBeenCalled()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
const { prompt } = require('../lib/helpers/cliPrompt')
|
|
2
|
+
const readline = require('node:readline/promises')
|
|
3
|
+
|
|
4
|
+
// Mock readline module
|
|
5
|
+
jest.mock('node:readline/promises', () => ({
|
|
6
|
+
createInterface: jest.fn()
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
describe('cliPrompt', () => {
|
|
10
|
+
let mockRl
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockRl = {
|
|
14
|
+
question: jest.fn(),
|
|
15
|
+
close: jest.fn()
|
|
16
|
+
}
|
|
17
|
+
readline.createInterface.mockReturnValue(mockRl)
|
|
18
|
+
|
|
19
|
+
// Mock console.log to avoid output during tests
|
|
20
|
+
jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.clearAllMocks()
|
|
25
|
+
if (console.log.mockRestore) {
|
|
26
|
+
console.log.mockRestore()
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('parameter validation', () => {
|
|
31
|
+
test('should throw error when message is missing', async () => {
|
|
32
|
+
await expect(prompt({ name: 'test' })).rejects.toThrow('Message is required for prompt')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('should throw error when name is missing', async () => {
|
|
36
|
+
await expect(prompt({ message: 'Test message?' })).rejects.toThrow(
|
|
37
|
+
'Name is required for prompt'
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('should throw error when both name and message are missing', async () => {
|
|
42
|
+
await expect(prompt({})).rejects.toThrow('Message is required for prompt')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('successful prompts', () => {
|
|
47
|
+
test('should return true for "y" input', async () => {
|
|
48
|
+
mockRl.question.mockResolvedValue('y')
|
|
49
|
+
|
|
50
|
+
const result = await prompt({
|
|
51
|
+
name: 'install',
|
|
52
|
+
message: 'Do you want to install?'
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual({ install: true })
|
|
56
|
+
expect(mockRl.question).toHaveBeenCalledWith('Do you want to install? (y/N) ')
|
|
57
|
+
expect(mockRl.close).toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('should return true for "yes" input', async () => {
|
|
61
|
+
mockRl.question.mockResolvedValue('yes')
|
|
62
|
+
|
|
63
|
+
const result = await prompt({
|
|
64
|
+
name: 'confirm',
|
|
65
|
+
message: 'Are you sure?'
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
expect(result).toEqual({ confirm: true })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('should return false for "n" input', async () => {
|
|
72
|
+
mockRl.question.mockResolvedValue('n')
|
|
73
|
+
|
|
74
|
+
const result = await prompt({
|
|
75
|
+
name: 'proceed',
|
|
76
|
+
message: 'Continue?'
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(result).toEqual({ proceed: false })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('should return false for "no" input', async () => {
|
|
83
|
+
mockRl.question.mockResolvedValue('no')
|
|
84
|
+
|
|
85
|
+
const result = await prompt({
|
|
86
|
+
name: 'delete',
|
|
87
|
+
message: 'Delete files?'
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
expect(result).toEqual({ delete: false })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('should handle case-insensitive input', async () => {
|
|
94
|
+
mockRl.question.mockResolvedValue('YES')
|
|
95
|
+
|
|
96
|
+
const result = await prompt({
|
|
97
|
+
name: 'test',
|
|
98
|
+
message: 'Test?'
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(result).toEqual({ test: true })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('should trim whitespace from input', async () => {
|
|
105
|
+
mockRl.question.mockResolvedValue(' y ')
|
|
106
|
+
|
|
107
|
+
const result = await prompt({
|
|
108
|
+
name: 'test',
|
|
109
|
+
message: 'Test?'
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual({ test: true })
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('default value handling', () => {
|
|
117
|
+
test('should use default false when user presses Enter', async () => {
|
|
118
|
+
mockRl.question.mockResolvedValue('')
|
|
119
|
+
|
|
120
|
+
const result = await prompt({
|
|
121
|
+
name: 'install',
|
|
122
|
+
message: 'Install package?'
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
expect(result).toEqual({ install: false })
|
|
126
|
+
expect(mockRl.question).toHaveBeenCalledWith('Install package? (y/N) ')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('should use default true when specified and user presses Enter', async () => {
|
|
130
|
+
mockRl.question.mockResolvedValue('')
|
|
131
|
+
|
|
132
|
+
const result = await prompt({
|
|
133
|
+
name: 'continue',
|
|
134
|
+
message: 'Continue process?',
|
|
135
|
+
default: true
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
expect(result).toEqual({ continue: true })
|
|
139
|
+
expect(mockRl.question).toHaveBeenCalledWith('Continue process? (Y/n) ')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('should show correct prompt indicators for default values', async () => {
|
|
143
|
+
mockRl.question.mockResolvedValue('y')
|
|
144
|
+
|
|
145
|
+
await prompt({
|
|
146
|
+
name: 'test',
|
|
147
|
+
message: 'Test with default false?',
|
|
148
|
+
default: false
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
expect(mockRl.question).toHaveBeenCalledWith('Test with default false? (y/N) ')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('invalid input handling', () => {
|
|
156
|
+
test('should log error message when invalid input is provided', () => {
|
|
157
|
+
// Test the logic path for invalid input without actual recursion
|
|
158
|
+
const invalidInputs = ['maybe', '1', 'true', 'false', 'ok', 'sure', 'xyz']
|
|
159
|
+
|
|
160
|
+
invalidInputs.forEach((input) => {
|
|
161
|
+
const normalized = input.trim().toLowerCase()
|
|
162
|
+
const isValidInput = ['y', 'yes', 'n', 'no'].includes(normalized)
|
|
163
|
+
expect(isValidInput).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('should recognize valid inputs correctly', () => {
|
|
168
|
+
const validInputs = ['y', 'yes', 'n', 'no', 'Y', 'YES', 'N', 'NO', ' y ', ' no ']
|
|
169
|
+
|
|
170
|
+
validInputs.forEach((input) => {
|
|
171
|
+
const normalized = input.trim().toLowerCase()
|
|
172
|
+
const isValidInput = ['y', 'yes', 'n', 'no'].includes(normalized)
|
|
173
|
+
expect(isValidInput).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('should handle invalid input and retry successfully', async () => {
|
|
178
|
+
// Create a mock that returns invalid input first, then valid input
|
|
179
|
+
let callCount = 0
|
|
180
|
+
mockRl.question.mockImplementation(() => {
|
|
181
|
+
callCount++
|
|
182
|
+
if (callCount === 1) {
|
|
183
|
+
return Promise.resolve('invalid')
|
|
184
|
+
}
|
|
185
|
+
return Promise.resolve('y')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Mock the second readline interface creation for the retry
|
|
189
|
+
let interfaceCallCount = 0
|
|
190
|
+
const mockRl2 = {
|
|
191
|
+
question: jest.fn().mockResolvedValue('y'),
|
|
192
|
+
close: jest.fn()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
readline.createInterface.mockImplementation(() => {
|
|
196
|
+
interfaceCallCount++
|
|
197
|
+
if (interfaceCallCount === 1) {
|
|
198
|
+
return mockRl
|
|
199
|
+
}
|
|
200
|
+
return mockRl2
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const result = await prompt({
|
|
204
|
+
name: 'test',
|
|
205
|
+
message: 'Test?'
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
expect(console.log).toHaveBeenCalledWith('Please answer with y/yes or n/no.')
|
|
209
|
+
expect(result).toEqual({ test: true })
|
|
210
|
+
expect(mockRl.close).toHaveBeenCalled()
|
|
211
|
+
expect(mockRl2.close).toHaveBeenCalled()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('readline interface management', () => {
|
|
216
|
+
test('should create readline interface with correct options', async () => {
|
|
217
|
+
mockRl.question.mockResolvedValue('y')
|
|
218
|
+
|
|
219
|
+
await prompt({
|
|
220
|
+
name: 'test',
|
|
221
|
+
message: 'Test?'
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
expect(readline.createInterface).toHaveBeenCalledWith({
|
|
225
|
+
input: process.stdin,
|
|
226
|
+
output: process.stdout
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('should close readline interface even if error occurs', async () => {
|
|
231
|
+
mockRl.question.mockRejectedValue(new Error('Test error'))
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
prompt({
|
|
235
|
+
name: 'test',
|
|
236
|
+
message: 'Test?'
|
|
237
|
+
})
|
|
238
|
+
).rejects.toThrow('Test error')
|
|
239
|
+
|
|
240
|
+
expect(mockRl.close).toHaveBeenCalled()
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
describe('return value format', () => {
|
|
245
|
+
test('should return object with specified property name', async () => {
|
|
246
|
+
mockRl.question.mockResolvedValue('y')
|
|
247
|
+
|
|
248
|
+
const result = await prompt({
|
|
249
|
+
name: 'customProperty',
|
|
250
|
+
message: 'Test?'
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
expect(result).toHaveProperty('customProperty')
|
|
254
|
+
expect(result.customProperty).toBe(true)
|
|
255
|
+
expect(Object.keys(result)).toHaveLength(1)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('should handle different property names correctly', async () => {
|
|
259
|
+
mockRl.question.mockResolvedValue('n')
|
|
260
|
+
|
|
261
|
+
const result = await prompt({
|
|
262
|
+
name: 'shouldProceed',
|
|
263
|
+
message: 'Proceed?'
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
expect(result).toEqual({ shouldProceed: false })
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('autoContinue', () => {
|
|
271
|
+
const { autoContinue } = require('../lib/helpers/cliPrompt')
|
|
272
|
+
let mockWrite, originalWrite
|
|
273
|
+
|
|
274
|
+
beforeEach(() => {
|
|
275
|
+
// Mock process.stdout.write to capture output
|
|
276
|
+
originalWrite = process.stdout.write
|
|
277
|
+
mockWrite = jest.fn()
|
|
278
|
+
process.stdout.write = mockWrite
|
|
279
|
+
|
|
280
|
+
// Mock setTimeout to speed up tests
|
|
281
|
+
jest.useFakeTimers()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
afterEach(() => {
|
|
285
|
+
// Restore original stdout.write
|
|
286
|
+
process.stdout.write = originalWrite
|
|
287
|
+
jest.useRealTimers()
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('should return object with specified property name', async () => {
|
|
291
|
+
const promise = autoContinue({
|
|
292
|
+
name: 'install',
|
|
293
|
+
message: 'Auto-installing in ',
|
|
294
|
+
timeInSeconds: 2
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// Fast-forward all timers
|
|
298
|
+
jest.runAllTimers()
|
|
299
|
+
|
|
300
|
+
const result = await promise
|
|
301
|
+
expect(result).toEqual({ install: true })
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('should handle custom timeInSeconds', async () => {
|
|
305
|
+
const promise = autoContinue({
|
|
306
|
+
name: 'proceed',
|
|
307
|
+
message: 'Proceeding in ',
|
|
308
|
+
timeInSeconds: 3
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// Fast-forward all timers
|
|
312
|
+
jest.runAllTimers()
|
|
313
|
+
|
|
314
|
+
const result = await promise
|
|
315
|
+
expect(result).toEqual({ proceed: true })
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('should use default timeInSeconds of 5 when not specified', async () => {
|
|
319
|
+
const promise = autoContinue({
|
|
320
|
+
name: 'default',
|
|
321
|
+
message: 'Starting in '
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// Fast-forward all timers
|
|
325
|
+
jest.runAllTimers()
|
|
326
|
+
|
|
327
|
+
const result = await promise
|
|
328
|
+
expect(result).toEqual({ default: true })
|
|
329
|
+
}, 10000) // 10 second timeout
|
|
330
|
+
|
|
331
|
+
test('should write countdown to stdout', async () => {
|
|
332
|
+
const promise = autoContinue({
|
|
333
|
+
name: 'test',
|
|
334
|
+
message: 'Testing in ',
|
|
335
|
+
timeInSeconds: 3
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// Fast-forward all timers
|
|
339
|
+
jest.runAllTimers()
|
|
340
|
+
|
|
341
|
+
await promise
|
|
342
|
+
|
|
343
|
+
// Should write initial message with starting number
|
|
344
|
+
expect(mockWrite).toHaveBeenCalledWith('Testing in 3')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('should handle single digit countdown correctly', async () => {
|
|
348
|
+
const promise = autoContinue({
|
|
349
|
+
name: 'test',
|
|
350
|
+
message: 'Starting in ',
|
|
351
|
+
timeInSeconds: 2
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// Fast-forward all timers
|
|
355
|
+
jest.runAllTimers()
|
|
356
|
+
|
|
357
|
+
await promise
|
|
358
|
+
|
|
359
|
+
// Should write initial message
|
|
360
|
+
expect(mockWrite).toHaveBeenCalledWith('Starting in 2')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test('should handle double digit to single digit countdown with padding', async () => {
|
|
364
|
+
const promise = autoContinue({
|
|
365
|
+
name: 'test',
|
|
366
|
+
message: 'Waiting ',
|
|
367
|
+
timeInSeconds: 10
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// Fast-forward all timers
|
|
371
|
+
jest.runAllTimers()
|
|
372
|
+
|
|
373
|
+
await promise
|
|
374
|
+
|
|
375
|
+
// Should write initial message with double digit
|
|
376
|
+
expect(mockWrite).toHaveBeenCalledWith('Waiting 10')
|
|
377
|
+
}, 15000) // 15 second timeout
|
|
378
|
+
|
|
379
|
+
test('should call console.log at the end', async () => {
|
|
380
|
+
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {})
|
|
381
|
+
|
|
382
|
+
const promise = autoContinue({
|
|
383
|
+
name: 'test',
|
|
384
|
+
message: 'Done in ',
|
|
385
|
+
timeInSeconds: 1
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// Fast-forward all timers
|
|
389
|
+
jest.runAllTimers()
|
|
390
|
+
|
|
391
|
+
await promise
|
|
392
|
+
|
|
393
|
+
expect(mockConsoleLog).toHaveBeenCalledWith()
|
|
394
|
+
mockConsoleLog.mockRestore()
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test('should handle default parameters', async () => {
|
|
398
|
+
const promise = autoContinue()
|
|
399
|
+
|
|
400
|
+
// Fast-forward all timers
|
|
401
|
+
jest.runAllTimers()
|
|
402
|
+
|
|
403
|
+
const result = await promise
|
|
404
|
+
|
|
405
|
+
// Should handle undefined name gracefully
|
|
406
|
+
expect(result).toEqual({ undefined: true })
|
|
407
|
+
}, 10000) // 10 second timeout
|
|
408
|
+
})
|
|
409
|
+
})
|
|
@@ -23,7 +23,7 @@ test('checkPackage returns validation data if it was a success', async () => {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const result = await testMarshall.checkPackage('express', ctx, {})
|
|
26
|
+
const result = await testMarshall.checkPackage({ packageName: 'express' }, ctx, {})
|
|
27
27
|
expect(result).toEqual('validation-result')
|
|
28
28
|
})
|
|
29
29
|
|