Skip to content

Commit 97b2843

Browse files
committed
test(cli): add unit tests for meow CLI helper
1 parent ddfc53a commit 97b2843

File tree

1 file changed

+385
-0
lines changed

1 file changed

+385
-0
lines changed
Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
/**
2+
* Unit tests for meow CLI helper.
3+
*
4+
* Purpose:
5+
* Tests the simplified meow-like CLI argument parsing helper.
6+
*
7+
* Test Coverage:
8+
* - Basic argument parsing
9+
* - Flag parsing (boolean, string, number types)
10+
* - Short flags and aliases
11+
* - Default values
12+
* - Boolean defaults
13+
* - Unknown flag collection
14+
* - Help text generation
15+
* - Package.json reading from importMeta
16+
*
17+
* Related Files:
18+
* - src/meow.mts (implementation)
19+
*/
20+
21+
import { beforeEach, describe, expect, it, vi } from 'vitest'
22+
23+
// Mock logger.
24+
const mockLogger = vi.hoisted(() => ({
25+
log: vi.fn(),
26+
error: vi.fn(),
27+
warn: vi.fn(),
28+
}))
29+
vi.mock('@socketsecurity/lib/logger', () => ({
30+
getDefaultLogger: () => mockLogger,
31+
}))
32+
33+
// Mock readPackageJsonSync.
34+
const mockReadPackageJsonSync = vi.hoisted(() => vi.fn())
35+
vi.mock('@socketsecurity/lib/packages', () => ({
36+
readPackageJsonSync: mockReadPackageJsonSync,
37+
}))
38+
39+
import meow from '../../src/meow.mts'
40+
41+
describe('meow', () => {
42+
beforeEach(() => {
43+
vi.clearAllMocks()
44+
mockReadPackageJsonSync.mockReturnValue({
45+
name: 'test-cli',
46+
version: '1.0.0',
47+
})
48+
})
49+
50+
describe('basic parsing', () => {
51+
it('parses positional arguments', () => {
52+
const result = meow({
53+
argv: ['arg1', 'arg2'],
54+
})
55+
56+
expect(result.input).toEqual(['arg1', 'arg2'])
57+
})
58+
59+
it('returns empty input for no arguments', () => {
60+
const result = meow({
61+
argv: [],
62+
})
63+
64+
expect(result.input).toEqual([])
65+
})
66+
67+
it('uses process.argv.slice(2) by default', () => {
68+
const originalArgv = process.argv
69+
process.argv = ['node', 'script.js', 'test-arg']
70+
71+
const result = meow({})
72+
73+
process.argv = originalArgv
74+
expect(result.input).toContain('test-arg')
75+
})
76+
})
77+
78+
describe('flag parsing', () => {
79+
it('parses boolean flags', () => {
80+
const result = meow({
81+
argv: ['--verbose'],
82+
flags: {
83+
verbose: {
84+
type: 'boolean',
85+
},
86+
},
87+
})
88+
89+
expect(result.flags['verbose']).toBe(true)
90+
})
91+
92+
it('parses string flags', () => {
93+
const result = meow({
94+
argv: ['--name', 'test'],
95+
flags: {
96+
name: {
97+
type: 'string',
98+
},
99+
},
100+
})
101+
102+
expect(result.flags['name']).toBe('test')
103+
})
104+
105+
it('parses number flags', () => {
106+
const result = meow({
107+
argv: ['--count', '42'],
108+
flags: {
109+
count: {
110+
type: 'number',
111+
},
112+
},
113+
})
114+
115+
expect(result.flags['count']).toBe(42)
116+
})
117+
118+
it('handles short flags', () => {
119+
const result = meow({
120+
argv: ['-v'],
121+
flags: {
122+
verbose: {
123+
type: 'boolean',
124+
shortFlag: 'v',
125+
},
126+
},
127+
})
128+
129+
expect(result.flags['verbose']).toBe(true)
130+
})
131+
132+
it('handles flag aliases as string', () => {
133+
const result = meow({
134+
argv: ['--quiet'],
135+
flags: {
136+
verbose: {
137+
type: 'boolean',
138+
alias: 'quiet',
139+
},
140+
},
141+
})
142+
143+
expect(result.flags['quiet']).toBe(true)
144+
})
145+
146+
it('handles flag aliases as array', () => {
147+
const result = meow({
148+
argv: ['--q'],
149+
flags: {
150+
verbose: {
151+
type: 'boolean',
152+
aliases: ['q', 'quiet'],
153+
},
154+
},
155+
})
156+
157+
expect(result.flags['q']).toBe(true)
158+
})
159+
160+
it('uses default values when flag not provided', () => {
161+
const result = meow({
162+
argv: [],
163+
flags: {
164+
port: {
165+
type: 'number',
166+
default: 3000,
167+
},
168+
},
169+
})
170+
171+
expect(result.flags['port']).toBe(3000)
172+
})
173+
})
174+
175+
describe('boolean defaults', () => {
176+
it('applies booleanDefault to undefined boolean flags', () => {
177+
const result = meow({
178+
argv: [],
179+
flags: {
180+
verbose: {
181+
type: 'boolean',
182+
},
183+
},
184+
booleanDefault: false,
185+
})
186+
187+
expect(result.flags['verbose']).toBe(false)
188+
})
189+
190+
it('does not override explicit boolean flags with booleanDefault', () => {
191+
const result = meow({
192+
argv: ['--verbose'],
193+
flags: {
194+
verbose: {
195+
type: 'boolean',
196+
},
197+
},
198+
booleanDefault: false,
199+
})
200+
201+
expect(result.flags['verbose']).toBe(true)
202+
})
203+
})
204+
205+
describe('unknown flags', () => {
206+
it('collects unknown flags when enabled', () => {
207+
const result = meow({
208+
argv: ['--unknown', '--another-flag'],
209+
flags: {},
210+
collectUnknownFlags: true,
211+
})
212+
213+
expect(result.unknownFlags).toContain('--unknown')
214+
expect(result.unknownFlags).toContain('--another-flag')
215+
})
216+
217+
it('returns empty unknownFlags when not collecting', () => {
218+
const result = meow({
219+
argv: [],
220+
flags: {},
221+
collectUnknownFlags: false,
222+
})
223+
224+
expect(result.unknownFlags).toEqual([])
225+
})
226+
})
227+
228+
describe('help text', () => {
229+
it('includes description in help text', () => {
230+
const result = meow({
231+
argv: [],
232+
description: 'A test CLI tool',
233+
})
234+
235+
expect(result.help).toContain('A test CLI tool')
236+
})
237+
238+
it('includes custom help text', () => {
239+
const result = meow({
240+
argv: [],
241+
help: 'Usage: test [options]',
242+
})
243+
244+
expect(result.help).toContain('Usage: test [options]')
245+
})
246+
247+
it('applies help indent to multiline help text', () => {
248+
const result = meow({
249+
argv: [],
250+
help: 'Line 1\nLine 2',
251+
helpIndent: 4,
252+
})
253+
254+
expect(result.help).toContain(' Line 1')
255+
expect(result.help).toContain(' Line 2')
256+
})
257+
258+
it('omits description when set to false', () => {
259+
const result = meow({
260+
argv: [],
261+
description: false,
262+
help: 'Usage: test',
263+
})
264+
265+
expect(result.help).not.toContain('undefined')
266+
})
267+
})
268+
269+
describe('package.json reading', () => {
270+
it('reads package.json from importMeta url', () => {
271+
const result = meow({
272+
argv: [],
273+
importMeta: { url: 'file:///path/to/script.js' } as ImportMeta,
274+
})
275+
276+
expect(result.pkg).toEqual({ name: 'test-cli', version: '1.0.0' })
277+
})
278+
279+
it('returns empty object when importMeta is not provided', () => {
280+
const result = meow({
281+
argv: [],
282+
})
283+
284+
expect(result.pkg).toEqual({})
285+
})
286+
287+
it('handles package.json read failure gracefully', () => {
288+
mockReadPackageJsonSync.mockImplementation(() => {
289+
throw new Error('File not found')
290+
})
291+
292+
const result = meow({
293+
argv: [],
294+
importMeta: { url: 'file:///path/to/script.js' } as ImportMeta,
295+
})
296+
297+
expect(result.pkg).toEqual({})
298+
})
299+
})
300+
301+
describe('showHelp and showVersion', () => {
302+
it('showHelp logs help text', () => {
303+
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
304+
throw new Error('exit')
305+
})
306+
307+
const result = meow({
308+
argv: [],
309+
help: 'Test help',
310+
})
311+
312+
expect(() => result.showHelp()).toThrow('exit')
313+
expect(mockLogger.log).toHaveBeenCalledWith(expect.stringContaining('Test help'))
314+
expect(mockExit).toHaveBeenCalledWith(2)
315+
316+
mockExit.mockRestore()
317+
})
318+
319+
it('showVersion logs version from package.json', () => {
320+
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
321+
throw new Error('exit')
322+
})
323+
324+
const result = meow({
325+
argv: [],
326+
importMeta: { url: 'file:///path/to/script.js' } as ImportMeta,
327+
})
328+
329+
expect(() => result.showVersion()).toThrow('exit')
330+
expect(mockLogger.log).toHaveBeenCalledWith('1.0.0')
331+
expect(mockExit).toHaveBeenCalledWith(0)
332+
333+
mockExit.mockRestore()
334+
})
335+
336+
it('showVersion logs 0.0.0 when no version in package.json', () => {
337+
mockReadPackageJsonSync.mockReturnValue({})
338+
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
339+
throw new Error('exit')
340+
})
341+
342+
const result = meow({
343+
argv: [],
344+
importMeta: { url: 'file:///path/to/script.js' } as ImportMeta,
345+
})
346+
347+
expect(() => result.showVersion()).toThrow('exit')
348+
expect(mockLogger.log).toHaveBeenCalledWith('0.0.0')
349+
350+
mockExit.mockRestore()
351+
})
352+
})
353+
354+
describe('multiple flags', () => {
355+
it('handles isMultiple flag option', () => {
356+
const result = meow({
357+
argv: ['--include', 'a', '--include', 'b'],
358+
flags: {
359+
include: {
360+
type: 'string',
361+
isMultiple: true,
362+
},
363+
},
364+
})
365+
366+
expect(result.flags['include']).toEqual(['a', 'b'])
367+
})
368+
})
369+
370+
describe('number conversion', () => {
371+
it('handles invalid number values gracefully', () => {
372+
const result = meow({
373+
argv: ['--count', 'not-a-number'],
374+
flags: {
375+
count: {
376+
type: 'number',
377+
},
378+
},
379+
})
380+
381+
// Value stays as string when NaN.
382+
expect(result.flags['count']).toBe('not-a-number')
383+
})
384+
})
385+
})

0 commit comments

Comments
 (0)