main
1// Copyright 2021 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import assert from 'node:assert'
16import { test, describe, after } from 'node:test'
17import { Duplex } from 'node:stream'
18import { $, chalk, fs, path, dotenv } from '../src/index.ts'
19import {
20 echo,
21 sleep,
22 argv,
23 parseArgv,
24 updateArgv,
25 stdin,
26 spinner,
27 fetch,
28 retry,
29 question,
30 expBackoff,
31 tempfile,
32 tempdir,
33 tmpdir,
34 tmpfile,
35 versions,
36} from '../src/goods.ts'
37import { Writable } from 'node:stream'
38import process from 'node:process'
39
40const __dirname = new URL('.', import.meta.url).pathname
41const root = path.resolve(__dirname, '..')
42
43describe('goods', () => {
44 function zx(script) {
45 return $`node build/cli.js --eval ${script}`.nothrow().timeout('5s')
46 }
47
48 describe('question()', async () => {
49 test('works', async () => {
50 let contents = ''
51 class Input extends Duplex {
52 constructor() {
53 super()
54 }
55 _read() {}
56 _write(chunk, encoding, callback) {
57 this.push(chunk)
58 callback()
59 }
60 _final() {
61 this.push(null)
62 }
63 }
64 const input = new Input() as any
65 const output = new Writable({
66 write: function (chunk, encoding, next) {
67 contents += chunk.toString()
68 next()
69 },
70 }) as NodeJS.WriteStream
71
72 setTimeout(() => {
73 input.write('foo\n')
74 input.end()
75 }, 10)
76 const result = await question('foo or bar? ', {
77 choices: ['foo', 'bar'],
78 input,
79 output,
80 })
81
82 assert.equal(result, 'foo')
83 assert(contents.includes('foo or bar? '))
84 })
85
86 test('integration', async () => {
87 const p = $`node build/cli.js --eval "
88 let answer = await question('foo or bar? ', { choices: ['foo', 'bar'] })
89 echo('Answer is', answer)
90"`
91 p.stdin.write('foo\n')
92 p.stdin.end()
93 assert.match((await p).stdout, /Answer is foo/)
94 })
95 })
96
97 test('echo() works', async () => {
98 const log = console.log
99 let stdout = ''
100 console.log = (...args) => {
101 stdout += args.join(' ')
102 }
103 echo(chalk.cyan('foo'), chalk.green('bar'), chalk.bold('baz'))
104 echo`${chalk.cyan('foo')} ${chalk.green('bar')} ${chalk.bold('baz')}`
105 echo(
106 await $`echo ${chalk.cyan('foo')}`,
107 await $`echo ${chalk.green('bar')}`,
108 await $`echo ${chalk.bold('baz')}`
109 )
110 console.log = log
111 assert.match(stdout, /foo/)
112 })
113
114 test('sleep() works', async () => {
115 const now = Date.now()
116 await sleep(100)
117 assert.ok(Date.now() >= now + 99)
118 })
119
120 describe('retry()', () => {
121 test('works', async () => {
122 let count = 0
123 const result = await retry(5, () => {
124 count++
125 if (count < 5) throw new Error('fail')
126 return 'success'
127 })
128 assert.equal(result, 'success')
129 assert.equal(count, 5)
130 })
131
132 test('works with custom delay and limit', async () => {
133 const now = Date.now()
134 let count = 0
135 try {
136 await retry(3, '2ms', () => {
137 count++
138 throw new Error('fail')
139 })
140 } catch (e) {
141 assert.match(e.message, /fail/)
142 assert.ok(Date.now() >= now + 4)
143 assert.equal(count, 3)
144 }
145 })
146
147 test('throws undefined on count misconfiguration', async () => {
148 try {
149 await retry(0, () => 'ok')
150 } catch (e) {
151 assert.equal(e, undefined)
152 }
153 })
154
155 test('throws err on empty callback', async () => {
156 try {
157 // @ts-ignore
158 await retry(5)
159 } catch (e) {
160 assert.match(e.message, /Callback is required for retry/)
161 }
162 })
163
164 test('supports expBackoff', async () => {
165 const result = await retry(5, expBackoff('10ms'), () => {
166 if (Math.random() < 0.1) throw new Error('fail')
167 return 'success'
168 })
169
170 assert.equal(result, 'success')
171 })
172
173 test('integration', async () => {
174 const now = Date.now()
175 const p = await zx(`
176 try {
177 await retry(5, '50ms', () => $\`exit 123\`)
178 } catch (e) {
179 echo('exitCode:', e.exitCode)
180 }
181 await retry(5, () => $\`exit 0\`)
182 echo('success')
183`)
184 assert.ok(p.toString().includes('exitCode: 123'))
185 assert.ok(p.toString().includes('success'))
186 assert.ok(Date.now() >= now + 50 * (5 - 1))
187 })
188
189 test('integration with expBackoff', async () => {
190 const now = Date.now()
191 const p = await zx(`
192 try {
193 await retry(5, expBackoff('60s', 0), () => $\`exit 123\`)
194 } catch (e) {
195 echo('exitCode:', e.exitCode)
196 }
197 echo('success')
198`)
199 assert.ok(p.toString().includes('exitCode: 123'))
200 assert.ok(p.toString().includes('success'))
201 assert.ok(Date.now() >= now + 2 + 4 + 8 + 16 + 32)
202 })
203 })
204
205 test('expBackoff()', async () => {
206 const g = expBackoff('10s', '100ms')
207
208 const [a, b, c] = [
209 g.next().value,
210 g.next().value,
211 g.next().value,
212 ] as number[]
213
214 assert.equal(a, 100)
215 assert.equal(b, 200)
216 assert.equal(c, 400)
217 })
218
219 describe('spinner()', () => {
220 test('works', async () => {
221 let contents = ''
222 const { CI } = process.env
223 const output = new Writable({
224 write: function (chunk, encoding, next) {
225 contents += chunk.toString()
226 next()
227 },
228 })
229
230 delete process.env.CI
231 $.log.output = output as NodeJS.WriteStream
232
233 const p = spinner(() => sleep(100))
234
235 delete $.log.output
236 process.env.CI = CI
237
238 await p
239 assert(contents.includes('⠋'))
240 })
241
242 describe('integration', () => {
243 test('works', async () => {
244 const out = await zx(
245 `
246 process.env.CI = ''
247 echo(await spinner(async () => {
248 await sleep(100)
249 await $\`echo hidden\`
250 return $\`echo result\`
251 }))
252 `
253 )
254 assert(out.stdout.includes('result'))
255 assert(out.stderr.includes('⠋'))
256 assert(!out.stderr.includes('result'))
257 assert(!out.stderr.includes('hidden'))
258 })
259
260 test('with title', async () => {
261 const out = await zx(
262 `
263 process.env.CI = ''
264 await spinner('processing', () => sleep(100))
265 `
266 )
267 assert.match(out.stderr, /processing/)
268 })
269
270 test('disabled in CI', async () => {
271 const out = await zx(
272 `
273 process.env.CI = 'true'
274 await spinner('processing', () => sleep(100))
275 `
276 )
277 assert.doesNotMatch(out.stderr, /processing/)
278 })
279
280 test('stops on throw', async () => {
281 const out = await zx(`
282 await spinner('processing', () => $\`wtf-cmd\`)
283 `)
284 assert.match(out.stderr, /Error:/)
285 assert(out.exitCode !== 0)
286 })
287 })
288 })
289
290 describe('args', () => {
291 test('parseArgv() works', () => {
292 assert.deepEqual(
293 parseArgv(
294 // prettier-ignore
295 [
296 '--foo-bar', 'baz',
297 '-a', '5',
298 '-a', '42',
299 '--aaa', 'AAA',
300 '--force',
301 './some.file',
302 '--b1', 'true',
303 '--b2', 'false',
304 '--b3',
305 '--b4', 'false',
306 '--b5', 'true',
307 '--b6', 'str'
308 ],
309 {
310 boolean: ['force', 'b3', 'b4', 'b5', 'b6'],
311 camelCase: true,
312 parseBoolean: true,
313 alias: { a: 'aaa' },
314 },
315 {
316 def: 'def',
317 }
318 ),
319 {
320 a: [5, 42, 'AAA'],
321 aaa: [5, 42, 'AAA'],
322 fooBar: 'baz',
323 force: true,
324 _: ['./some.file', 'str'],
325 b1: true,
326 b2: false,
327 b3: true,
328 b4: false,
329 b5: true,
330 b6: true,
331 def: 'def',
332 }
333 )
334 })
335
336 test('updateArgv() works', () => {
337 updateArgv(['--foo', 'bar'])
338 assert.deepEqual(argv, {
339 _: [],
340 foo: 'bar',
341 })
342 })
343 })
344
345 test('stdin()', async () => {
346 const stream = fs.createReadStream(path.resolve(root, 'package.json'))
347 const input = await stdin(stream)
348 assert.match(input, /"name": "zx"/)
349 })
350
351 test('fetch()', async () => {
352 const req1 = fetch('https://example.com/')
353 const req2 = fetch('https://example.com/')
354 const req3 = fetch('https://example.com/', { method: 'OPTIONS' })
355
356 const p1 = (await req1.pipe`cat`).stdout
357 const p2 = (await req2.pipe($`cat`)).stdout
358 const p3 = (await req3.pipe`cat`).stdout
359
360 assert.equal((await req1).status, 200)
361 assert.equal((await req2).status, 200)
362 assert.equal((await req3).status, 501)
363 assert(p1.includes('Example Domain'))
364 assert(p2.includes('Example Domain'))
365 assert(!p3.includes('Example Domain'))
366 })
367
368 describe('dotenv', () => {
369 test('parse()', () => {
370 assert.deepEqual(dotenv.parse(''), {})
371 assert.deepEqual(
372 dotenv.parse('ENV=v1\nENV2=v2\n\n\n ENV3 = v3 \nexport ENV4=v4'),
373 {
374 ENV: 'v1',
375 ENV2: 'v2',
376 ENV3: 'v3',
377 ENV4: 'v4',
378 }
379 )
380
381 const multiline = `SIMPLE=xyz123
382# comment ###
383NON_INTERPOLATED='raw text without variable interpolation'
384MULTILINE = """
385long text here, # not-comment
386e.g. a private SSH key
387"""
388ENV=v1\nENV2=v2\n\n\n\t\t ENV3 = v3 \n export ENV4=v4
389ENV5=v5 # comment
390`
391 assert.deepEqual(dotenv.parse(multiline), {
392 SIMPLE: 'xyz123',
393 NON_INTERPOLATED: 'raw text without variable interpolation',
394 MULTILINE: 'long text here, # not-comment\ne.g. a private SSH key',
395 ENV: 'v1',
396 ENV2: 'v2',
397 ENV3: 'v3',
398 ENV4: 'v4',
399 ENV5: 'v5',
400 })
401 })
402
403 describe('load()', () => {
404 const file1 = tempfile('.env.1', 'ENV1=value1\nENV2=value2')
405 const file2 = tempfile('.env.2', 'ENV2=value222\nENV3=value3')
406 after(() => Promise.all([fs.remove(file1), fs.remove(file2)]))
407
408 test('loads env from files', () => {
409 const env = dotenv.load(file1, file2)
410 assert.equal(env.ENV1, 'value1')
411 assert.equal(env.ENV2, 'value2')
412 assert.equal(env.ENV3, 'value3')
413 })
414
415 test('throws error on ENOENT', () => {
416 try {
417 dotenv.load('./.env')
418 throw new Error('unreachable')
419 } catch (e) {
420 assert.equal(e.code, 'ENOENT')
421 assert.equal(e.errno, -2)
422 }
423 })
424 })
425
426 describe('loadSafe()', () => {
427 const file1 = tempfile('.env.1', 'ENV1=value1\nENV2=value2')
428 const file2 = '.env.notexists'
429
430 after(() => fs.remove(file1))
431
432 test('loads env from files', () => {
433 const env = dotenv.loadSafe(file1, file2)
434 assert.equal(env.ENV1, 'value1')
435 assert.equal(env.ENV2, 'value2')
436 })
437 })
438
439 describe('config()', () => {
440 test('updates process.env', () => {
441 const file1 = tempfile('.env.1', 'ENV1=value1')
442
443 assert.equal(process.env.ENV1, undefined)
444 dotenv.config(file1)
445 assert.equal(process.env.ENV1, 'value1')
446 delete process.env.ENV1
447 })
448 })
449 })
450
451 describe('temp*', () => {
452 test('tempdir() creates temporary folders', () => {
453 assert.equal(tmpdir, tempdir)
454 assert.match(tempdir(), /\/zx-/)
455 assert.match(tempdir('foo'), /\/foo$/)
456 })
457
458 test('tempfile() creates temporary files', () => {
459 assert.equal(tmpfile, tempfile)
460 assert.match(tempfile(), /\/zx-.+/)
461 assert.match(tempfile('foo.txt'), /\/zx-.+\/foo\.txt$/)
462
463 const tf = tempfile('bar.txt', 'bar')
464 assert.match(tf, /\/zx-.+\/bar\.txt$/)
465 assert.equal(fs.readFileSync(tf, 'utf-8'), 'bar')
466 })
467 })
468
469 describe('versions', () => {
470 test('exports deps versions', () => {
471 assert.deepEqual(
472 Object.keys(versions).sort(),
473 [
474 'chalk',
475 'depseek',
476 'dotenv',
477 'fetch',
478 'fs',
479 'glob',
480 'minimist',
481 'ps',
482 'which',
483 'yaml',
484 'zx',
485 ].sort()
486 )
487 })
488 })
489})