main
  1// Copyright 2022 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, before, after } from 'node:test'
 17import { fileURLToPath } from 'node:url'
 18import net from 'node:net'
 19import getPort from 'get-port'
 20import { $, path, tmpfile, tmpdir, fs } from '../build/index.js'
 21import { isMain, normalizeExt } from '../build/cli.js'
 22import { fakeServer } from './fixtures/server.mjs'
 23
 24const __filename = fileURLToPath(import.meta.url)
 25const spawn = $.spawn
 26const nodeMajor = +process.versions?.node?.split('.')[0]
 27const test22 = nodeMajor >= 22 ? test : test.skip
 28
 29describe('cli', () => {
 30  // Helps to detect unresolved ProcessPromise.
 31  before(() => {
 32    const spawned = []
 33    $.spawn = (...args) => {
 34      const proc = spawn(...args)
 35      const done = () => (proc._done = true)
 36      spawned.push(proc)
 37      return proc.once('close', done).once('error', done)
 38    }
 39    process.on('exit', () => {
 40      if (spawned.some((p) => p._done !== true)) {
 41        console.error('Error: ProcessPromise never resolved.')
 42        process.exitCode = 1
 43      }
 44    })
 45  })
 46  after(() => ($.spawn = spawn))
 47
 48  test('promise resolved', async () => {
 49    await $`echo`
 50  })
 51
 52  test('prints version', async () => {
 53    assert.match((await $`node build/cli.js -v`).toString(), /\d+.\d+.\d+/)
 54  })
 55
 56  test('prints help', async () => {
 57    const p = $`node build/cli.js -h`
 58    p.stdin.end()
 59    const help = await p
 60    assert.match(help.stdout, /zx/)
 61  })
 62
 63  test('zx prints usage if no param passed', async () => {
 64    const p = $`node build/cli.js`
 65    p.stdin.end()
 66    try {
 67      await p
 68      assert.fail('must throw')
 69    } catch (out) {
 70      assert.match(out.stdout, /A tool for writing better scripts/)
 71      assert.equal(out.exitCode, 1)
 72    }
 73  })
 74
 75  test('starts repl with --repl', async () => {
 76    const p = $`node build/cli.js --repl`
 77    p.stdin.write('await $`echo f"o"o`\n')
 78    p.stdin.write('"b"+"ar"\n')
 79    p.stdin.end()
 80    const out = await p
 81    assert.match(out.stdout, /foo/)
 82    assert.match(out.stdout, /bar/)
 83  })
 84
 85  test('starts repl with verbosity off', async () => {
 86    const p = $`node build/cli.js --repl`
 87    p.stdin.write('"verbose" + " is " + $.verbose\n')
 88    p.stdin.end()
 89    const out = await p
 90    assert.match(out.stdout, /verbose is false/)
 91  })
 92
 93  test('supports `--quiet` flag', async () => {
 94    const p = await $`node build/cli.js --quiet test/fixtures/markdown.md`
 95    assert.ok(!p.stderr.includes('ignore'), 'ignore was printed')
 96    assert.ok(!p.stderr.includes('hello'), 'no hello')
 97    assert.ok(p.stdout.includes('world'), 'no world')
 98  })
 99
100  test('supports `--shell` flag ', async () => {
101    const shell = $.shell
102    const p =
103      await $`node build/cli.js --verbose --shell=${shell} <<< '$\`echo \${$.shell}\`'`
104    assert.ok(p.stderr.includes(shell))
105  })
106
107  test('supports `--prefix` flag ', async () => {
108    const prefix = 'set -e;'
109    const p =
110      await $`node build/cli.js --verbose --prefix=${prefix} <<< '$\`echo \${$.prefix}\`'`
111    assert.ok(p.stderr.includes(prefix))
112  })
113
114  test('supports `--postfix` flag ', async () => {
115    const postfix = '; exit 0'
116    const p =
117      await $`node build/cli.js --verbose --postfix=${postfix} <<< '$\`echo \${$.postfix}\`'`
118    assert.ok(p.stderr.includes(postfix))
119  })
120
121  test('supports `--cwd` option ', async () => {
122    const cwd = path.resolve(fileURLToPath(import.meta.url), '../../temp')
123    fs.mkdirSync(cwd, { recursive: true })
124    const p =
125      await $`node build/cli.js --verbose --cwd=${cwd} <<< '$\`echo \${$.cwd}\`'`
126    assert.ok(p.stderr.endsWith(cwd + '\n'))
127  })
128
129  test('supports `--env` option', async () => {
130    const env = tmpfile(
131      '.env',
132      `FOO=BAR
133      BAR=FOO+`
134    )
135    const file = `
136    console.log((await $\`echo $FOO\`).stdout);
137    console.log((await $\`echo $BAR\`).stdout)
138    `
139
140    const out = await $`node build/cli.js --env=${env} <<< ${file}`
141    fs.remove(env)
142    assert.equal(out.stdout, 'BAR\n\nFOO+\n\n')
143  })
144
145  test('supports `--env` and `--cwd` options with file', async () => {
146    const env = tmpfile(
147      '.env',
148      `FOO=BAR
149      BAR=FOO+`
150    )
151    const dir = tmpdir()
152    const file = `
153      console.log((await $\`echo $FOO\`).stdout);
154      console.log((await $\`echo $BAR\`).stdout)
155      `
156
157    const out =
158      await $`node build/cli.js --cwd=${dir} --env=${env}  <<< ${file}`
159    fs.remove(env)
160    fs.remove(dir)
161    assert.equal(out.stdout, 'BAR\n\nFOO+\n\n')
162  })
163
164  test('supports handling errors with the `--env` option', async () => {
165    const file = `
166      console.log((await $\`echo $FOO\`).stdout);
167      console.log((await $\`echo $BAR\`).stdout)
168      `
169    try {
170      await $`node build/cli.js --env=./env <<< ${file}`
171      fs.remove(env)
172      assert.throw()
173    } catch (e) {
174      assert.equal(e.exitCode, 1)
175    }
176  })
177
178  describe('`--prefer-local`', () => {
179    const pkgIndex = `export const a = 'AAA'`
180    const pkgJson = {
181      name: 'a',
182      version: '1.0.0',
183      type: 'module',
184      exports: './index.js',
185    }
186    const script = `
187import {a} from 'a'
188console.log(a);
189`
190
191    test('true', async () => {
192      const cwd = tmpdir()
193      await fs.outputFile(path.join(cwd, 'node_modules/a/index.js'), pkgIndex)
194      await fs.outputJson(
195        path.join(cwd, 'node_modules/a/package.json'),
196        pkgJson
197      )
198
199      const out =
200        await $`node build/cli.js --cwd=${cwd} --prefer-local=true --test <<< ${script}`
201      assert.equal(out.stdout, 'AAA\n')
202      assert.ok(await fs.exists(path.join(cwd, 'node_modules/a/index.js')))
203    })
204
205    test('external dir', async () => {
206      const cwd = tmpdir()
207      const external = tmpdir()
208      await fs.outputFile(
209        path.join(external, 'node_modules/a/index.js'),
210        pkgIndex
211      )
212      await fs.outputJson(
213        path.join(external, 'node_modules/a/package.json'),
214        pkgJson
215      )
216
217      const out =
218        await $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`
219      assert.equal(out.stdout, 'AAA\n')
220      assert.ok(await fs.exists(path.join(external, 'node_modules/a/index.js')))
221    })
222
223    test('external alias', async () => {
224      const cwd = tmpdir()
225      const external = tmpdir()
226      await fs.outputFile(
227        path.join(external, 'node_modules/a/index.js'),
228        pkgIndex
229      )
230      await fs.outputJson(
231        path.join(external, 'node_modules/a/package.json'),
232        pkgJson
233      )
234      await fs.symlinkSync(
235        path.join(external, 'node_modules'),
236        path.join(cwd, 'node_modules'),
237        'junction'
238      )
239
240      const out =
241        await $`node build/cli.js --cwd=${cwd} --prefer-local=true --test <<< ${script}`
242      assert.equal(out.stdout, 'AAA\n')
243      assert.ok(await fs.exists(path.join(cwd, 'node_modules')))
244    })
245
246    test('throws if exists', async () => {
247      const cwd = tmpdir()
248      const external = tmpdir()
249      await fs.outputFile(path.join(cwd, 'node_modules/a/index.js'), pkgIndex)
250      await fs.outputFile(
251        path.join(external, 'node_modules/a/index.js'),
252        pkgIndex
253      )
254      assert.rejects(
255        () =>
256          $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`,
257        /node_modules already exists/
258      )
259    })
260
261    test('throws if not dir', async () => {
262      const cwd = tmpdir()
263      const external = tmpdir()
264      await fs.outputFile(path.join(external, 'node_modules'), pkgIndex)
265      assert.rejects(
266        () =>
267          $`node build/cli.js --cwd=${cwd} --prefer-local=${external} --test <<< ${script}`,
268        /node_modules doesn't exist or is not a directory/
269      )
270    })
271  })
272
273  test('scripts from https 200', async () => {
274    const resp = await fs.readFile(path.resolve('test/fixtures/echo.http'))
275    const port = await getPort()
276    const server = await fakeServer([resp]).start(port)
277    const out =
278      await $`node build/cli.js --verbose http://127.0.0.1:${port}/script.mjs`
279    assert.match(out.stderr, /test/)
280    await server.stop()
281  })
282
283  test('scripts from https 500', async () => {
284    const port = await getPort()
285    const server = await fakeServer([`HTTP/1.1 500\n\n500\n`]).listen(port)
286    const out = await $`node build/cli.js http://127.0.0.1:${port}`.nothrow()
287    assert.match(out.stderr, /Error: Can't get/)
288    await server.stop()
289  })
290
291  test('scripts (md) from https', async () => {
292    const resp = await fs.readFile(path.resolve('test/fixtures/md.http'))
293    const port = await getPort()
294    const server = await fakeServer([resp]).start(port)
295    const out =
296      await $`node build/cli.js --verbose http://127.0.0.1:${port}/script.md`
297    assert.match(out.stderr, /md/)
298    await server.stop()
299  })
300
301  test('scripts with no extension', async () => {
302    await $`node build/cli.js test/fixtures/no-extension`
303    assert.ok(
304      /Test file to verify no-extension didn't overwrite similarly name .mjs file./.test(
305        (await fs.readFile('test/fixtures/no-extension.mjs')).toString()
306      )
307    )
308  })
309
310  test('scripts with non standard extension', async () => {
311    const o =
312      await $`node build/cli.js --ext='.mjs' test/fixtures/non-std-ext.zx`
313    assert.ok(o.stdout.trim().endsWith('zx/test/fixtures/non-std-ext.zx.mjs'))
314
315    await assert.rejects(
316      $`node build/cli.js test/fixtures/non-std-ext.zx`,
317      /Unknown file extension "\.zx"/
318    )
319  })
320
321  test22('scripts from stdin with explicit extension', async () => {
322    const out =
323      await $`node --experimental-strip-types build/cli.js --ext='.ts' <<< 'const foo: string = "bar"; console.log(foo)'`
324    assert.match(out.stdout, /bar/)
325  })
326
327  test('require() is working from stdin', async () => {
328    const out =
329      await $`node build/cli.js <<< 'console.log(require("./package.json").name)'`
330    assert.match(out.stdout, /zx/)
331  })
332
333  test('require() is working in ESM', async () => {
334    await $`node build/cli.js test/fixtures/require.mjs`
335  })
336
337  test('__filename & __dirname are defined', async () => {
338    await $`node build/cli.js test/fixtures/filename-dirname.mjs`
339  })
340
341  test('markdown scripts are working', async () => {
342    await $`node build/cli.js test/fixtures/markdown.md`
343  })
344
345  test('markdown scripts are working for CRLF', async () => {
346    const p = await $`node build/cli.js test/fixtures/markdown-crlf.md`
347    assert.ok(p.stdout.includes('Hello, world!'))
348  })
349
350  test('exceptions are caught', async () => {
351    const out1 = await $`node build/cli.js <<<${'await $`wtf`'}`.nothrow()
352    const out2 = await $`node build/cli.js <<<'throw 42'`.nothrow()
353    assert.match(out1.stderr, /Error:/)
354    assert.match(out2.stderr, /42/)
355  })
356
357  test('eval works', async () => {
358    assert.equal((await $`node build/cli.js --eval 'echo(42)'`).stdout, '42\n')
359    assert.equal((await $`node build/cli.js -e='echo(69)'`).stdout, '69\n')
360  })
361
362  test('eval works with stdin', async () => {
363    const p = $`(printf foo; sleep 0.1; printf bar) | node build/cli.js --eval 'echo(await stdin())'`
364    assert.equal((await p).stdout, 'foobar\n')
365  })
366
367  test('executes a script from $PATH', async () => {
368    const isWindows = process.platform === 'win32'
369    const oldPath = process.env.PATH
370    const toPOSIXPath = (_path) => _path.split(path.sep).join(path.posix.sep)
371
372    const zxPath = path.resolve('./build/cli.js')
373    const zxLocation = isWindows ? toPOSIXPath(zxPath) : zxPath
374    const scriptCode = `#!/usr/bin/env ${zxLocation}\nconsole.log('The script from path runs.')`
375    const scriptName = 'script-from-path'
376    const scriptFile = tmpfile(scriptName, scriptCode, 0o744)
377    const scriptDir = path.dirname(scriptFile)
378
379    const envPathSeparator = isWindows ? ';' : ':'
380    process.env.PATH += envPathSeparator + scriptDir
381
382    try {
383      await $`chmod +x ${zxLocation}`
384      await $`${scriptName}`
385    } finally {
386      process.env.PATH = oldPath
387      await fs.rm(scriptFile)
388    }
389  })
390
391  test('argv works with zx and node', async () => {
392    assert.equal(
393      (await $`node build/cli.js test/fixtures/argv.mjs foo`).toString(),
394      `global {"_":["foo"]}\nimported {"_":["foo"]}\n`
395    )
396    assert.equal(
397      (await $`node test/fixtures/argv.mjs bar`).toString(),
398      `global {"_":["bar"]}\nimported {"_":["bar"]}\n`
399    )
400    assert.equal(
401      (
402        await $`node build/cli.js --eval 'console.log(argv._.join(''))' baz`
403      ).toString(),
404      `baz\n`
405    )
406  })
407
408  test('exit code can be set', async () => {
409    const p = await $`node build/cli.js test/fixtures/exit-code.mjs`.nothrow()
410    assert.equal(p.exitCode, 42)
411  })
412
413  describe('internals', () => {
414    test('isMain() checks process entry point', () => {
415      assert.equal(isMain(import.meta.url, __filename), true)
416
417      assert.equal(
418        isMain(import.meta.url.replace('.js', '.cjs'), __filename),
419        true
420      )
421
422      try {
423        assert.equal(
424          isMain(
425            'file:///root/zx/test/cli.test.js',
426            '/root/zx/test/all.test.js'
427          ),
428          true
429        )
430        assert.throw()
431      } catch (e) {
432        assert.ok(['EACCES', 'ENOENT'].includes(e.code))
433      }
434    })
435
436    test('isMain() function is running from the wrong path', () => {
437      assert.equal(isMain('///root/zx/test/cli.test.js'), false)
438    })
439
440    test('normalizeExt()', () => {
441      assert.equal(normalizeExt('.ts'), '.ts')
442      assert.equal(normalizeExt('ts'), '.ts')
443      assert.equal(normalizeExt('.'), '.')
444      assert.equal(normalizeExt(), undefined)
445    })
446  })
447})