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