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})