diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 00000000..465ea5f0 --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,15 @@ +{ + "all": false, + "check-coverage": true, + "statements": 100, + "branches": 100, + "functions": 100, + "lines": 100, + "exclude": [ + "test/**", + "templates/**", + "examples/**", + "suite-runner.js", + "coverage/**" + ] +} diff --git a/args.js b/args.js index e877f675..93344431 100644 --- a/args.js +++ b/args.js @@ -67,7 +67,7 @@ module.exports = function parseCliArgs (args) { const configFileOptions = commandLineArguments.config ? requireModule(commandLineArguments.config) : undefined - const additionalArgs = commandLineArguments['--'] || [] + const additionalArgs = commandLineArguments['--'] const pluginParsed = parseArgs(additionalArgs, { options: {}, strict: false }) const { _, ...pluginOptions } = pluginParsed const ignoreWatchArg = commandLineArguments.ignoreWatch || configFileOptions?.ignoreWatch || '' diff --git a/eject.js b/eject.js index 7a53418e..f46fe15d 100644 --- a/eject.js +++ b/eject.js @@ -51,5 +51,5 @@ module.exports = { } if (require.main === module) { - cli() + cli(process.argv.slice(2)) } diff --git a/generate-plugin.js b/generate-plugin.js index 0c71a68f..813c6162 100755 --- a/generate-plugin.js +++ b/generate-plugin.js @@ -66,11 +66,11 @@ async function generate (dir, template) { pkg.types = template.types pkg.description = '' pkg.license = 'MIT' - pkg.scripts = Object.assign(pkg.scripts || {}, template.scripts) + pkg.scripts = { ...pkg.scripts, ...template.scripts } pkg.dependencies = Object.assign(pkg.dependencies || {}, template.dependencies) pkg.devDependencies = Object.assign(pkg.devDependencies || {}, template.devDependencies) if (template.tstyche) { - pkg.tstyche = Object.assign(pkg.tstyche || {}, template.tstyche) + pkg.tstyche = { ...pkg.tstyche, ...template.tstyche } } log('debug', 'edited package.json, saving') diff --git a/generate-readme.js b/generate-readme.js index d063a1e2..4ab0a3a3 100644 --- a/generate-readme.js +++ b/generate-readme.js @@ -70,19 +70,11 @@ function generate (dir, { pluginMeta, encapsulated, pluginFileName }) { }) } -function stop (error) { - if (error) { - console.log(error) - process.exit(1) - } - process.exit() -} - function showHelp () { console.log( readFileSync(path.join(__dirname, 'help', 'readme.txt'), 'utf8') ) - return stop() + process.exit() } function cli (args) { diff --git a/generate.js b/generate.js index 1abe1e94..535a13c3 100755 --- a/generate.js +++ b/generate.js @@ -96,7 +96,11 @@ function generate (dir, template) { } process.chdir(dir) - execSync('npm init -y') + try { + execSync('npm init -y') + } catch (err) { + return reject(err) + } log('info', `reading package.json in ${dir}`) readFile('package.json', (err, data) => { @@ -115,7 +119,7 @@ function generate (dir, template) { pkg.type = template.type - pkg.scripts = Object.assign(pkg.scripts || {}, template.scripts) + pkg.scripts = { ...pkg.scripts, ...template.scripts } pkg.dependencies = Object.assign(pkg.dependencies || {}, template.dependencies) diff --git a/lib/parse-args.js b/lib/parse-args.js index b33f96d1..23e2365b 100644 --- a/lib/parse-args.js +++ b/lib/parse-args.js @@ -251,7 +251,7 @@ function parseArgsStandard (args, config) { } if (token.kind === 'positional') { if (inRest) { - rest.push(token.original !== undefined ? token.original : token.value) + rest.push(token.value) } else { mainPositionals.push(token.value) } diff --git a/lib/watch/fork.js b/lib/watch/fork.js index 9f800430..d2827dd2 100644 --- a/lib/watch/fork.js +++ b/lib/watch/fork.js @@ -1,7 +1,7 @@ 'use strict' const chalk = require('chalk').default -const { stop, runFastify } = require('../../start') +const { runFastify } = require('../../start') const { GRACEFUL_SHUT, @@ -42,12 +42,8 @@ const main = async () => { process.send({ type, err: null }) - try { - await fastify.ready() - process.send({ type: READY }) - } catch (err) { - stop(err) - } + await fastify.ready() + process.send({ type: READY }) } main().catch((err) => { diff --git a/lib/watch/index.js b/lib/watch/index.js index d8c736b1..db58786f 100644 --- a/lib/watch/index.js +++ b/lib/watch/index.js @@ -90,10 +90,7 @@ const watch = function (args, ignoreWatch, verboseWatch, followWatch) { const child = childs.shift() child.send(GRACEFUL_SHUT) } catch (err) { - if (childs.length !== 0) { - console.log(chalk.red(err)) - stop(watcher, err) - } + // the previous child already exited: start a new one childs.push(run('restart')) } }) diff --git a/package.json b/package.json index 6ab62a1c..548054bf 100644 --- a/package.json +++ b/package.json @@ -58,8 +58,7 @@ "is-docker": "^4.0.0", "pino-pretty": "^13.0.0", "pkg-up": "^5.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.3.5" + "resolve-from": "^5.0.0" }, "devDependencies": { "@fastify/autoload": "^6.0.0", @@ -75,6 +74,7 @@ "neostandard": "^0.13.0", "proxyquire": "^2.1.3", "rimraf": "^6.1.0", + "semver": "^7.3.5", "sinon": "^22.0.0", "strip-ansi": "^6.0.1", "ts-node": "^10.4.0", diff --git a/start.js b/start.js index 60f722be..28442fbb 100755 --- a/start.js +++ b/start.js @@ -150,14 +150,10 @@ async function runFastify (args, additionalOptions, serverOptions, serverModule) } if (opts.debug) { - if (process.version.match(/v[0-6]\..*/g)) { - stop('Fastify debug mode not compatible with Node.js version < 6') - } else { - const debugHost = opts.debugHost ?? ( - isDocker() || isKubernetes() ? listenAddressDocker : undefined - ) - require('node:inspector').open(opts.debugPort, debugHost) - } + const debugHost = opts.debugHost ?? ( + isDocker() || isKubernetes() ? listenAddressDocker : undefined + ) + require('node:inspector').open(opts.debugPort, debugHost) } if (serverOptions) { diff --git a/test/args.test.js b/test/args.test.js index 1f924742..d3dca609 100644 --- a/test/args.test.js +++ b/test/args.test.js @@ -558,3 +558,21 @@ test('should collect repeated options into an array', t => { t.assert.deepStrictEqual(parsedArgs.require, ['./a.js', './b.js']) t.assert.strictEqual(parsedArgs.import, './c.mjs') }) + +test('should drop dist from the ignore list when watching typescript sources', t => { + t.plan(1) + + const parsedArgs = parseArgs(['--ignore-watch', '.ts$', 'app.ts']) + + t.assert.strictEqual(parsedArgs.ignoreWatch, 'node_modules build .git bower_components logs .swp .nyc_output .ts$') +}) + +test('should read the ignore and follow watch options from the config file', t => { + t.plan(3) + + const parsedArgs = parseArgs(['--config', './test/data/watch-config.js', 'app.js']) + + t.assert.strictEqual(parsedArgs.ignoreWatch, 'node_modules build dist .git bower_components logs .swp .nyc_output ignoreme.js') + t.assert.strictEqual(parsedArgs.followWatch, 'followme.js') + t.assert.strictEqual(parsedArgs.trustProxy, true) +}) diff --git a/test/cli.test.js b/test/cli.test.js index 4a2e722c..adfc8dff 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -43,3 +43,10 @@ test('generate --help', async t => { readFileSync(path.join(__dirname, '../help/generate.txt'), 'utf-8') ) }) + +test('version', async t => { + t.assert.equal( + execSync('node cli.js version').toString().trim(), + require('../package.json').version + ) +}) diff --git a/test/data/crashing-plugin.js b/test/data/crashing-plugin.js new file mode 100644 index 00000000..055f6da7 --- /dev/null +++ b/test/data/crashing-plugin.js @@ -0,0 +1,10 @@ +'use strict' + +// Throws asynchronously once the server is up, to simulate an app crash +module.exports = function (fastify, opts, next) { + fastify.addHook('onReady', function (done) { + setTimeout(() => { throw new Error('async crash') }, 50) + done() + }) + next() +} diff --git a/test/data/hanging-close-plugin.js b/test/data/hanging-close-plugin.js new file mode 100644 index 00000000..f78de115 --- /dev/null +++ b/test/data/hanging-close-plugin.js @@ -0,0 +1,7 @@ +'use strict' + +// Never completes the onClose hook, so close() hangs forever +module.exports = function (fastify, opts, next) { + fastify.addHook('onClose', function (instance, done) {}) + next() +} diff --git a/test/data/ip-plugin.js b/test/data/ip-plugin.js new file mode 100644 index 00000000..873420b4 --- /dev/null +++ b/test/data/ip-plugin.js @@ -0,0 +1,8 @@ +'use strict' + +module.exports = function (fastify, opts, next) { + fastify.get('/', function (req, reply) { + reply.send({ ip: req.ip }) + }) + next() +} diff --git a/test/data/slow-plugin.js b/test/data/slow-plugin.js new file mode 100644 index 00000000..ca7e231a --- /dev/null +++ b/test/data/slow-plugin.js @@ -0,0 +1,6 @@ +'use strict' + +// Takes a while to become ready, so the server is not up when the plugin loads +module.exports = function (fastify, opts, next) { + setTimeout(next, 1000) +} diff --git a/test/data/watch-config.js b/test/data/watch-config.js new file mode 100644 index 00000000..624a11b0 --- /dev/null +++ b/test/data/watch-config.js @@ -0,0 +1,7 @@ +'use strict' + +module.exports = { + ignoreWatch: 'ignoreme.js', + followWatch: 'followme.js', + trustProxyEnabled: 'true' +} diff --git a/test/eject-errors.test.js b/test/eject-errors.test.js new file mode 100644 index 00000000..23161b1b --- /dev/null +++ b/test/eject-errors.test.js @@ -0,0 +1,48 @@ +'use strict' + +const { test } = require('node:test') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { execFile } = require('node:child_process') +const { promisify } = require('node:util') +const proxyquire = require('proxyquire') +const rimraf = require('rimraf') + +const pExecFile = promisify(execFile) +const ejectCli = path.join(__dirname, '..', 'eject.js') + +function makeTmpDir (t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fastify-cli-eject-')) + t.after(() => rimraf.sync(dir)) + return dir +} + +test('eject should reject when the template cannot be copied', async (t) => { + const { eject } = proxyquire('../eject', { + generify: (from, to, data, onFile, cb) => cb(new Error('copy failed')) + }) + + await t.assert.rejects(eject(makeTmpDir(t), 'eject'), /copy failed/) +}) + +test('cli should log the error and exit when eject fails', async (t) => { + const { cli } = proxyquire('../eject', { + generify: (from, to, data, onFile, cb) => cb(new Error('copy failed')) + }) + t.mock.method(process, 'exit', () => {}) + const log = t.mock.method(console, 'log', () => {}) + + await cli([]) + + t.assert.strictEqual(process.exit.mock.calls[0].arguments[0], 1) + t.assert.match(log.mock.calls.map(c => c.arguments.join(' ')).join('\n'), /copy failed/) +}) + +test('should eject the esm template when run directly', async (t) => { + const dir = makeTmpDir(t) + await pExecFile(process.execPath, [ejectCli, '--esm'], { cwd: dir }) + + t.assert.ok(fs.existsSync(path.join(dir, 'server.js'))) + t.assert.match(fs.readFileSync(path.join(dir, 'server.js'), 'utf8'), /^import /m) +}) diff --git a/test/generate-errors.test.js b/test/generate-errors.test.js new file mode 100644 index 00000000..9a1e38e6 --- /dev/null +++ b/test/generate-errors.test.js @@ -0,0 +1,97 @@ +'use strict' + +const { test } = require('node:test') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { execFile } = require('node:child_process') +const { promisify } = require('node:util') +const proxyquire = require('proxyquire') +const rimraf = require('rimraf') +const { javascriptTemplate } = require('../generate') + +const pExecFile = promisify(execFile) +const generateCli = path.join(__dirname, '..', 'generate.js') + +function makeTmpDir (t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fastify-cli-generate-')) + t.after(() => rimraf.sync(dir)) + return dir +} + +function silence (t) { + t.mock.method(console, 'log', () => {}) +} + +test('generate should reject when the template cannot be copied', async (t) => { + silence(t) + const { generate } = proxyquire('../generate', { + generify: (from, to, data, onFile, cb) => cb(new Error('copy failed')) + }) + + await t.assert.rejects(generate(makeTmpDir(t), javascriptTemplate), /copy failed/) +}) + +test('generate should reject when package.json cannot be read', async (t) => { + silence(t) + const { generate } = proxyquire('../generate', { + 'node:fs': { + ...fs, + readFile: (file, cb) => cb(new Error('read failed')) + } + }) + + await t.assert.rejects(generate(makeTmpDir(t), javascriptTemplate), /read failed/) +}) + +test('generate should reject when package.json is not valid JSON', async (t) => { + silence(t) + const { generate } = proxyquire('../generate', { + 'node:fs': { + ...fs, + readFile: (file, cb) => cb(null, '{ not json') + } + }) + + await t.assert.rejects(generate(makeTmpDir(t), javascriptTemplate), SyntaxError) +}) + +test('generate should reject when package.json cannot be written', async (t) => { + silence(t) + const { generate } = proxyquire('../generate', { + 'node:fs': { + ...fs, + writeFile: (file, data, cb) => cb(new Error('write failed')) + } + }) + + await t.assert.rejects(generate(makeTmpDir(t), javascriptTemplate), /write failed/) +}) + +test('cli should exit with an error when generate fails', async (t) => { + const dir = makeTmpDir(t) + // an invalid package.json makes `npm init -y` fail inside generate() + fs.writeFileSync(path.join(dir, 'package.json'), '{ not json') + + await t.assert.rejects( + pExecFile(process.execPath, [generateCli, '.', '--integrate'], { cwd: dir }), + err => { + t.assert.strictEqual(err.code, 1) + t.assert.match(err.stdout, /Command failed: npm init -y/) + return true + } + ) +}) + +test('cli should generate a typescript esm project', async (t) => { + const dir = makeTmpDir(t) + const target = path.join(dir, 'app') + const { stdout } = await pExecFile(process.execPath, [generateCli, target, '--lang=ts', '--esm'], { cwd: dir }) + + t.assert.match(stdout, /generated successfully/) + const pkg = JSON.parse(fs.readFileSync(path.join(target, 'package.json'), 'utf8')) + t.assert.strictEqual(pkg.type, 'module') + t.assert.strictEqual(pkg.scripts.dev, 'fastify start -w -l info src/app.ts') + t.assert.strictEqual(pkg.scripts['dev:start'], undefined) + t.assert.ok(pkg.devDependencies.c8) +}) diff --git a/test/generate-plugin-errors.test.js b/test/generate-plugin-errors.test.js new file mode 100644 index 00000000..61282c85 --- /dev/null +++ b/test/generate-plugin-errors.test.js @@ -0,0 +1,57 @@ +'use strict' + +const { test } = require('node:test') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { execFile } = require('node:child_process') +const { promisify } = require('node:util') +const rimraf = require('rimraf') +const { generate, pluginTemplate } = require('../generate-plugin') + +const pExecFile = promisify(execFile) +const generatePluginCli = path.join(__dirname, '..', 'generate-plugin.js') + +function makeTmpDir (t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fastify-cli-plugin-')) + t.after(() => rimraf.sync(dir)) + return dir +} + +test('generate should merge tstyche settings and preexisting package.json fields', async (t) => { + t.mock.method(console, 'log', () => {}) + const dir = makeTmpDir(t) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'my-plugin', + scripts: { custom: 'echo custom' }, + dependencies: { 'left-pad': '1.0.0' }, + devDependencies: { 'right-pad': '1.0.0' }, + tstyche: { target: ['5.0'] } + })) + + await generate(dir, { + ...pluginTemplate, + tstyche: { testFileMatch: ['**/*.tst.ts'] } + }) + + const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) + t.assert.strictEqual(pkg.scripts.custom, 'echo custom') + t.assert.strictEqual(pkg.dependencies['left-pad'], '1.0.0') + t.assert.strictEqual(pkg.devDependencies['right-pad'], '1.0.0') + t.assert.deepStrictEqual(pkg.tstyche, { target: ['5.0'], testFileMatch: ['**/*.tst.ts'] }) +}) + +test('cli should exit with an error when generate fails', async (t) => { + const dir = makeTmpDir(t) + // an invalid package.json makes `npm init -y` fail inside generate() + fs.writeFileSync(path.join(dir, 'package.json'), '{ not json') + + await t.assert.rejects( + pExecFile(process.execPath, [generatePluginCli, '.', '--integrate'], { cwd: dir }), + err => { + t.assert.strictEqual(err.code, 1) + t.assert.match(err.stdout, /Command failed: npm init -y/) + return true + } + ) +}) diff --git a/test/generate-readme.test.js b/test/generate-readme.test.js index 8ec2e9c1..14c0b33a 100644 --- a/test/generate-readme.test.js +++ b/test/generate-readme.test.js @@ -2,12 +2,29 @@ const path = require('node:path') const fs = require('node:fs') +const os = require('node:os') const { test } = require('node:test') +const { execFile } = require('node:child_process') +const { promisify } = require('node:util') const rimraf = require('rimraf') const { generate } = require('../generate-readme') +const pExecFile = promisify(execFile) const plugindir = path.join(__dirname, 'plugindir') const plugin = require(plugindir) +const readmeCli = path.join(__dirname, '..', 'generate-readme.js') + +function makeTmpDir (t, prefix = 'fastify-cli-readme-') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + t.after(() => rimraf.sync(dir)) + return dir +} + +function runCli (args, cwd) { + return pExecFile(process.execPath, [readmeCli, ...args], { cwd }) + .then(res => ({ code: 0, ...res })) + .catch(err => ({ code: err.code, stdout: err.stdout, stderr: err.stderr })) +} test('should create readme', async (t) => { t.plan(1) @@ -23,3 +40,128 @@ test('should create readme', async (t) => { t.assert.ifError(err) } }) + +test('should run npm init and fill in decorators, dependencies and fastify version when missing', async (t) => { + const dir = makeTmpDir(t) + fs.copyFileSync(path.join(plugindir, 'plugin.js'), path.join(dir, 'plugin.js')) + + const pluginMeta = { + decorators: { fastify: ['someSupport'], reply: ['view'] }, + dependencies: ['@fastify/sensible'] + } + await generate(dir, { pluginMeta, encapsulated: true, pluginFileName: 'plugin.js' }) + + t.assert.ok(fs.existsSync(path.join(dir, 'package.json')), 'package.json was generated') + const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8') + t.assert.match(readme, /- someSupport/) + t.assert.match(readme, /- view/) + t.assert.match(readme, /- @fastify\/sensible/) + t.assert.match(readme, /\[X\] Accessible only in a child context/) +}) + +test('should use the peerDependencies fastify version', async (t) => { + const dir = makeTmpDir(t) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'peer-plugin', + peerDependencies: { fastify: '^5.0.0' } + })) + + await generate(dir, { pluginMeta: {}, encapsulated: false, pluginFileName: 'plugin.js' }) + + const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8') + t.assert.match(readme, /\^5\.0\.0/) + t.assert.match(readme, /\[X\] Accessible in the same context/) +}) + +test('should reject on an invalid package.json', async (t) => { + const dir = makeTmpDir(t) + fs.writeFileSync(path.join(dir, 'package.json'), '{ not json') + + await t.assert.rejects( + generate(dir, { pluginMeta: {}, encapsulated: false, pluginFileName: 'plugin.js' }), + SyntaxError + ) +}) + +test('cli should print the help when the file parameter is missing', async (t) => { + const dir = makeTmpDir(t) + const res = await runCli([], dir) + + t.assert.strictEqual(res.code, 0) + t.assert.match(res.stdout, /Usage: fastify readme/) +}) + +test('cli should print the help with --help', async (t) => { + const dir = makeTmpDir(t) + const res = await runCli(['--help'], dir) + + t.assert.strictEqual(res.code, 0) + t.assert.match(res.stdout, /Usage: fastify readme/) +}) + +test('cli should fail when README.md already exists', async (t) => { + const dir = makeTmpDir(t) + fs.writeFileSync(path.join(dir, 'README.md'), '# hello') + const res = await runCli(['plugin.js'], dir) + + t.assert.strictEqual(res.code, 1) + t.assert.match(res.stdout, /README\.md file already exists/) +}) + +test('cli should fail when the plugin cannot be loaded', async (t) => { + const dir = makeTmpDir(t) + const res = await runCli(['missing.js'], dir) + + t.assert.strictEqual(res.code, 1) + t.assert.match(res.stdout, /plugin could not be loaded/) +}) + +test('cli should fail when the plugin has no metadata', async (t) => { + const dir = makeTmpDir(t) + fs.writeFileSync(path.join(dir, 'plugin.js'), 'module.exports = function (fastify, opts, next) { next() }') + const res = await runCli(['plugin.js'], dir) + + t.assert.strictEqual(res.code, 1) + t.assert.match(res.stdout, /no plugin metadata could be found/) +}) + +test('cli should generate the README', async (t) => { + const dir = makeTmpDir(t) + fs.copyFileSync(path.join(plugindir, 'plugin.js'), path.join(dir, 'plugin.js')) + fs.copyFileSync(path.join(plugindir, 'package.json'), path.join(dir, 'package.json')) + fs.symlinkSync(path.join(__dirname, '..', 'node_modules'), path.join(dir, 'node_modules'), 'dir') + const res = await runCli(['plugin.js'], dir) + + t.assert.strictEqual(res.code, 0) + t.assert.match(res.stdout, /README for plugin plugindir generated successfully/) + t.assert.ok(fs.existsSync(path.join(dir, 'README.md'))) +}) + +test('cli should report errors from generate', async (t) => { + const dir = makeTmpDir(t) + // a .cjs plugin without dependencies can be loaded even if package.json is broken + fs.writeFileSync(path.join(dir, 'plugin.cjs'), [ + 'const plugin = function (fastify, opts, next) { next() }', + "plugin[Symbol.for('plugin-meta')] = {}", + 'module.exports = plugin' + ].join('\n')) + fs.writeFileSync(path.join(dir, 'package.json'), '{ not json') + const res = await runCli(['plugin.cjs'], dir) + + t.assert.strictEqual(res.code, 1) + t.assert.match(res.stdout, /JSON/) +}) + +test('should reject when the template cannot be copied', async (t) => { + const proxyquire = require('proxyquire') + const readme = proxyquire('../generate-readme', { + generify: (from, to, data, onFile, cb) => cb(new Error('copy failed')) + }) + const dir = makeTmpDir(t) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x' })) + + await t.assert.rejects( + readme.generate(dir, { pluginMeta: {}, encapsulated: false, pluginFileName: 'plugin.js' }), + /copy failed/ + ) +}) diff --git a/test/generate-swagger.test.js b/test/generate-swagger.test.js index f03ff405..a15925d2 100644 --- a/test/generate-swagger.test.js +++ b/test/generate-swagger.test.js @@ -28,3 +28,77 @@ test('should generate swagger in yaml format', async (t) => { t.assert.ifError(err) } }) + +const proxyquire = require('proxyquire') +const { execFile } = require('node:child_process') +const { promisify } = require('node:util') +const pExecFile = promisify(execFile) +const swaggerCli = path.join(__dirname, '..', 'generate-swagger.js') + +function mockExit (t) { + t.mock.method(process, 'exit', () => {}) + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'log', () => {}) +} + +test('should print the help with --help', async (t) => { + mockExit(t) + await generateSwagger(['--help']) + t.assert.match(console.log.mock.calls[0].arguments[0], /Usage:/) + t.assert.strictEqual(process.exit.mock.calls[0].arguments[0], undefined) +}) + +test('should print the help when the file parameter is missing', async (t) => { + mockExit(t) + await generateSwagger([]) + t.assert.strictEqual(console.error.mock.calls[0].arguments[0], 'Missing the required file parameter\n') + t.assert.match(console.log.mock.calls[0].arguments[0], /Usage:/) +}) + +test('should register the plugin with a prefix', async (t) => { + const swagger = JSON.parse(await generateSwagger(['--prefix', '/api', swaggerplugin])) + t.assert.strictEqual(swagger.openapi, '3.0.3') +}) + +test('should stop when fastify cannot be loaded', async (t) => { + const command = proxyquire('../generate-swagger', { + './util': { + ...require('../util'), + requireFastifyForModule () { throw new Error('nope') } + } + }) + const stop = t.mock.method(command, 'stop', () => { throw new Error('stopped') }) + await t.assert.rejects(command.generateSwagger([swaggerplugin]), /stopped/) + t.assert.strictEqual(stop.mock.calls[0].arguments[0].message, 'nope') +}) + +test('should stop when the plugin cannot be loaded', async (t) => { + const command = require('../generate-swagger') + const stop = t.mock.method(command, 'stop', () => { throw new Error('stopped') }) + await t.assert.rejects(command.generateSwagger(['./test/data/not-found.js']), /stopped/) + t.assert.match(stop.mock.calls[0].arguments[0].message, /not-found\.js doesn't exist within/) +}) + +test('should fail when @fastify/swagger is not registered', async (t) => { + const command = require('../generate-swagger') + t.mock.method(process, 'exit', () => { throw new Error('exited') }) + const log = t.mock.method(console, 'log', () => {}) + await t.assert.rejects(command.generateSwagger(['./examples/plugin.js']), /exited/) + t.assert.match(log.mock.calls.map(c => c.arguments.join(' ')).join('\n'), /@fastify\/swagger plugin not installed/) +}) + +test('should write swagger to stdout when run directly', async (t) => { + const { stdout } = await pExecFile(process.execPath, [swaggerCli, swaggerplugin]) + t.assert.strictEqual(JSON.parse(stdout).openapi, '3.0.3') +}) + +test('should exit with an error when run directly on a missing file', async (t) => { + await t.assert.rejects( + pExecFile(process.execPath, [swaggerCli, './test/data/not-found.js']), + err => { + t.assert.strictEqual(err.code, 1) + t.assert.match(err.stderr, /not-found\.js doesn't exist within/) + return true + } + ) +}) diff --git a/test/parse-args.test.js b/test/parse-args.test.js new file mode 100644 index 00000000..837a9c57 --- /dev/null +++ b/test/parse-args.test.js @@ -0,0 +1,16 @@ +'use strict' + +const { test } = require('node:test') +const parseArgs = require('../lib/parse-args') + +test('should work without an options map', t => { + const parsed = parseArgs(['file.js', '--flag'], { populateRest: true }) + + t.assert.strictEqual(parsed.flag, true) + t.assert.deepStrictEqual(parsed._, ['file.js']) + t.assert.deepStrictEqual(parsed['--'], []) +}) + +test('should reject unknown options in strict mode', t => { + t.assert.throws(() => parseArgs(['--unknown', 'value'], { strict: true, options: {} }), /Unknown option/) +}) diff --git a/test/print-plugins.test.js b/test/print-plugins.test.js index de46138f..ccfc1617 100644 --- a/test/print-plugins.test.js +++ b/test/print-plugins.test.js @@ -8,11 +8,7 @@ const exec = util.promisify(require('node:child_process').exec) const printPlugins = require('../print-plugins') -const { NYC_PROCESS_ID, NODE_V8_COVERAGE } = process.env -const SHOULD_SKIP = NYC_PROCESS_ID || NODE_V8_COVERAGE - -// This test should be skipped when coverage reporting is used since outputs won't match -test('should print plugins', { skip: SHOULD_SKIP }, async t => { +test('should print plugins', async t => { t.plan(3) const spy = sinon.spy() @@ -27,8 +23,7 @@ test('should print plugins', { skip: SHOULD_SKIP }, async t => { t.assert.match(spy.args[0][1], /root \d+ ms\n├── bound _after \d+ ms\n├─┬ function \(fastify, options, next\) { -- fastify\.decorate\('test', true\) \d+ ms\n│ ├── bound _after \d+ ms\n│ ├── bound _after \d+ ms\n│ └── bound _after \d+ ms\n└── bound _after \d+ ms\n/) }) -// This test should be skipped when coverage reporting is used since outputs won't match -test('should plugins routes via cli', { skip: SHOULD_SKIP }, async t => { +test('should plugins routes via cli', async t => { t.plan(1) const { stdout } = await exec('node cli.js print-plugins ./examples/plugin.js', { encoding: 'utf-8', timeout: 10000 }) t.assert.match( @@ -94,8 +89,7 @@ test('should exit without error on help', t => { t.assert.strictEqual(process.exit.lastCall.args[0], undefined) }) -// This test should be skipped when coverage reporting is used since outputs won't match -test('should print plugins of server with an async/await plugin', { skip: SHOULD_SKIP }, async t => { +test('should print plugins of server with an async/await plugin', async t => { const nodeMajorVersion = process.versions.node.split('.').map(x => parseInt(x, 10))[0] if (nodeMajorVersion < 7) { t.assert.ok('Skip because Node version < 7') @@ -116,3 +110,59 @@ test('should print plugins of server with an async/await plugin', { skip: SHOULD t.assert.deepStrictEqual(spy.args[0][0], 'debug') t.assert.match(spy.args[0][1], /root \d+ ms\n├── bound _after \d+ ms\n├─┬ async function \(fastify, options\) { -- fastify\.get\('\/', async function \(req, reply\) { \d+ ms\n│ ├── bound _after \d+ ms\n│ └── bound _after \d+ ms\n└── bound _after \d+ ms\n/) }) + +test('should print the help when the file parameter is missing', t => { + t.mock.method(process, 'exit', () => {}) + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'log', () => {}) + + printPlugins.printPlugins([]) + + t.assert.strictEqual(console.error.mock.calls[0].arguments[0], 'Missing the required file parameter\n') + t.assert.match(console.log.mock.calls[0].arguments[0], /Usage:/) + t.assert.strictEqual(process.exit.mock.calls[0].arguments[0], undefined) +}) + +test('should register the plugin with a prefix', async t => { + const spy = sinon.spy() + const command = proxyquire('../print-plugins', { + './log': spy + }) + const fastify = await command.printPlugins(['./examples/plugin.js', '--prefix', '/api']) + await fastify.close() + + t.assert.ok(spy.called) + t.assert.match(spy.args[0][1], /api|root/) +}) + +test('should stop when fastify cannot be loaded', t => { + t.mock.method(process, 'exit', () => {}) + t.mock.method(console, 'warn', () => {}) + const command = proxyquire('../print-plugins', { + './util': { + ...require('../util'), + requireFastifyForModule () { throw new Error('nope') } + } + }) + const stop = t.mock.method(command, 'stop', () => {}) + + command.printPlugins(['./examples/plugin.js']).catch(() => {}) + + t.assert.strictEqual(stop.mock.calls[0].arguments[0].message, 'nope') +}) + +test('should exit with an error when run directly on a missing file', async t => { + await t.assert.rejects( + exec('node print-plugins.js ./test/data/not-found.js', { encoding: 'utf-8', timeout: 10000 }), + err => { + t.assert.strictEqual(err.code, 1) + t.assert.match(err.stderr, /not-found\.js doesn't exist within/) + return true + } + ) +}) + +test('should print plugins when run directly', async t => { + const { stdout } = await exec('node print-plugins.js ./examples/plugin.js', { encoding: 'utf-8', timeout: 10000 }) + t.assert.ok(stdout.length > 0) +}) diff --git a/test/print-routes.test.js b/test/print-routes.test.js index 014a4506..535c32c4 100644 --- a/test/print-routes.test.js +++ b/test/print-routes.test.js @@ -8,9 +8,6 @@ const exec = util.promisify(require('node:child_process').exec) const printRoutes = require('../print-routes') -const { NYC_PROCESS_ID, NODE_V8_COVERAGE } = process.env -const SHOULD_SKIP = NYC_PROCESS_ID || NODE_V8_COVERAGE - test('should print routes', async t => { t.plan(2) @@ -26,7 +23,7 @@ test('should print routes', async t => { }) // This never exits in CI for some reason -test('should print routes via cli', { skip: SHOULD_SKIP }, async t => { +test('should print routes via cli', async t => { t.plan(1) const { stdout } = await exec('node cli.js print-routes ./examples/plugin.js', { encoding: 'utf-8', timeout: 10000 }) t.assert.deepStrictEqual( @@ -150,3 +147,59 @@ test('should print routes with hooks with --include-hooks flag', async t => { t.assert.ok(spy.called) t.assert.deepStrictEqual(spy.args, [['debug', '└── / (GET, POST)\n / (HEAD)\n • (onSend) ["headRouteOnSendHandler()"]\n']]) }) + +test('should print the help when the file parameter is missing', t => { + t.mock.method(process, 'exit', () => {}) + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'log', () => {}) + + printRoutes.printRoutes([]) + + t.assert.strictEqual(console.error.mock.calls[0].arguments[0], 'Missing the required file parameter\n') + t.assert.match(console.log.mock.calls[0].arguments[0], /Usage:/) + t.assert.strictEqual(process.exit.mock.calls[0].arguments[0], undefined) +}) + +test('should register the plugin with a prefix', async t => { + const spy = sinon.spy() + const command = proxyquire('../print-routes', { + './log': spy + }) + const fastify = await command.printRoutes(['./examples/plugin.js', '--prefix', '/api']) + await fastify.close() + + t.assert.ok(spy.called) + t.assert.match(spy.args[0][1], /api|root/) +}) + +test('should stop when fastify cannot be loaded', t => { + t.mock.method(process, 'exit', () => {}) + t.mock.method(console, 'warn', () => {}) + const command = proxyquire('../print-routes', { + './util': { + ...require('../util'), + requireFastifyForModule () { throw new Error('nope') } + } + }) + const stop = t.mock.method(command, 'stop', () => {}) + + command.printRoutes(['./examples/plugin.js']).catch(() => {}) + + t.assert.strictEqual(stop.mock.calls[0].arguments[0].message, 'nope') +}) + +test('should exit with an error when run directly on a missing file', async t => { + await t.assert.rejects( + exec('node print-routes.js ./test/data/not-found.js', { encoding: 'utf-8', timeout: 10000 }), + err => { + t.assert.strictEqual(err.code, 1) + t.assert.match(err.stderr, /not-found\.js doesn't exist within/) + return true + } + ) +}) + +test('should print routes when run directly', async t => { + const { stdout } = await exec('node print-routes.js ./examples/plugin.js', { encoding: 'utf-8', timeout: 10000 }) + t.assert.ok(stdout.length > 0) +}) diff --git a/test/start-extra.test.js b/test/start-extra.test.js new file mode 100644 index 00000000..c2670389 --- /dev/null +++ b/test/start-extra.test.js @@ -0,0 +1,77 @@ +'use strict' + +const { test } = require('node:test') +const proxyquire = require('proxyquire').noPreserveCache() +const start = require('../start') + +let port = 4101 +const getPort = () => String(port++) + +test('should print the help when the file parameter is missing', async t => { + t.mock.method(process, 'exit', () => {}) + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'log', () => {}) + + await start.start([]) + + t.assert.strictEqual(console.error.mock.calls[0].arguments[0], 'Missing the required file parameter\n') + t.assert.match(console.log.mock.calls[0].arguments[0], /Usage:/) + t.assert.strictEqual(process.exit.mock.calls[0].arguments[0], undefined) +}) + +test('should stop when fastify cannot be loaded', async t => { + const command = proxyquire('../start', { + './util': { + ...require('../util'), + requireFastifyForModule () { throw new Error('nope') } + } + }) + const stop = t.mock.method(command, 'stop', () => { throw new Error('stopped') }) + + await t.assert.rejects(command.start(['./examples/plugin.js']), /stopped/) + t.assert.strictEqual(stop.mock.calls[0].arguments[0].message, 'nope') +}) + +test('stop should delegate to util.exit', t => { + t.mock.method(process, 'exit', () => {}) + t.mock.method(console, 'warn', () => {}) + + start.stop('bye') + + t.assert.strictEqual(console.warn.mock.calls[0].arguments[0], 'Warn: bye') + t.assert.strictEqual(process.exit.mock.calls[0].arguments[0], 1) +}) + +test('should listen on the given address', async t => { + const fastify = await start.start(['-p', getPort(), '-a', '127.0.0.1', './examples/plugin.js']) + t.after(() => fastify.close()) + + t.assert.strictEqual(fastify.server.address().address, '127.0.0.1') +}) + +test('should set trustProxy on the server', async t => { + const fastify = await start.start(['-p', getPort(), '--trust-proxy-enabled', './test/data/ip-plugin.js']) + t.after(() => fastify.close()) + + const res = await fastify.inject({ url: '/', headers: { 'x-forwarded-for': '203.0.113.7' } }) + t.assert.strictEqual(res.json().ip, '203.0.113.7') +}) + +test('should log the error when the process is closing because of an error', async t => { + let closeHandler + const command = proxyquire('../start', { + 'close-with-grace': (opts, handler) => { + closeHandler = handler + return { uninstall () {} } + } + }) + const fastify = await command.start(['-p', getPort(), './examples/plugin.js']) + const logged = [] + fastify.log.error = (err) => logged.push(err) + + const err = new Error('fatal') + await closeHandler({ err }) + + t.assert.deepStrictEqual(logged, [err]) + t.assert.strictEqual(fastify.server.listening, false) +}) diff --git a/test/start.test.js b/test/start.test.js index 7bbace33..75503259 100644 --- a/test/start.test.js +++ b/test/start.test.js @@ -610,7 +610,7 @@ test('should start the server listening on 0.0.0.0 when running in docker', asyn isDocker.returns(true) const start = proxyquire('../start', { - 'is-docker': { default: isDocker } + 'is-docker': isDocker // CJS shape (is-docker@3) }) const argv = ['-p', getPort(), './examples/plugin.js'] @@ -848,7 +848,7 @@ for (const inspectorHostCase of inspectorHostCases) { const isDocker = sinon.stub().returns(inspectorHostCase.isDocker) const isKubernetes = sinon.stub().returns(inspectorHostCase.isKubernetes) const start = proxyquire('../start', { - 'is-docker': { default: isDocker }, + 'is-docker': { default: isDocker }, // ESM shape (is-docker@4) './util': { ...require('../util'), isKubernetes diff --git a/test/util.test.js b/test/util.test.js new file mode 100644 index 00000000..5132a07d --- /dev/null +++ b/test/util.test.js @@ -0,0 +1,95 @@ +'use strict' + +const { test } = require('node:test') +const path = require('node:path') +const proxyquire = require('proxyquire') +const util = require('../util') + +function mockExit (t) { + const calls = [] + t.mock.method(process, 'exit', (code) => { calls.push(code) }) + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'warn', () => {}) + t.mock.method(console, 'log', () => {}) + return calls +} + +test('exit should print errors and exit with 1', t => { + const calls = mockExit(t) + util.exit(new Error('boom')) + t.assert.deepStrictEqual(calls, [1]) + t.assert.strictEqual(console.error.mock.callCount(), 1) +}) + +test('exit should warn on string messages and exit with 1', t => { + const calls = mockExit(t) + util.exit('something went wrong') + t.assert.deepStrictEqual(calls, [1]) + t.assert.strictEqual(console.warn.mock.calls[0].arguments[0], 'Warn: something went wrong') +}) + +test('exit should exit cleanly without a message', t => { + const calls = mockExit(t) + util.exit() + t.assert.deepStrictEqual(calls, [undefined]) +}) + +test('requireModule should load a file path or a package name', t => { + t.assert.strictEqual(util.requireModule('./examples/plugin.js'), require('../examples/plugin.js')) + t.assert.strictEqual(util.requireModule('fastify'), require('fastify')) +}) + +test('requireESModule should load a file path or a package name', async t => { + const fromFile = await util.requireESModule('./examples/ts-plugin-with-custom-options.mjs') + t.assert.strictEqual(typeof fromFile.default, 'function') + const fromPackage = await util.requireESModule('fastify') + t.assert.strictEqual(typeof fromPackage.default, 'function') +}) + +test('requireModuleDefaultExport should handle cjs, esm and package names', async t => { + const cjs = await util.requireModuleDefaultExport('./examples/plugin.js') + t.assert.strictEqual(cjs, require('../examples/plugin.js')) + const esm = await util.requireModuleDefaultExport('./examples/ts-plugin-with-custom-options.mjs') + t.assert.strictEqual(typeof esm, 'function') + const pkg = await util.requireModuleDefaultExport('fastify') + t.assert.strictEqual(typeof pkg, 'function') +}) + +test('requireFastifyForModule should resolve fastify relative to the module', t => { + const { module } = util.requireFastifyForModule('./examples/plugin.js') + t.assert.strictEqual(module, require('fastify')) +}) + +test('requireFastifyForModule should exit when fastify cannot be loaded', t => { + const calls = mockExit(t) + const brokenUtil = proxyquire('../util', { + 'resolve-from': { silent: () => path.join(__dirname, 'data', 'not-found.js') } + }) + brokenUtil.requireFastifyForModule('./examples/plugin.js') + t.assert.deepStrictEqual(calls, [1]) + t.assert.strictEqual(console.warn.mock.calls[0].arguments[0], 'Warn: unable to load fastify module') +}) + +test('requireServerPluginFromPath should reject async plugins with the wrong arity', async t => { + await t.assert.rejects( + util.requireServerPluginFromPath('./test/data/async-plugin-with-one-argument.js'), + /should contain 2 arguments/ + ) +}) + +test('showHelpForCommand should exit with an error for an unknown command', t => { + const calls = mockExit(t) + util.showHelpForCommand('this-command-does-not-exist') + t.assert.deepStrictEqual(calls, [1]) + t.assert.match(console.warn.mock.calls[0].arguments[0], /unable to get help for command/) +}) + +test('isKubernetes should detect the service host env variable', t => { + const previous = process.env.KUBERNETES_SERVICE_HOST + t.after(() => { + if (previous === undefined) delete process.env.KUBERNETES_SERVICE_HOST + else process.env.KUBERNETES_SERVICE_HOST = previous + }) + process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1' + t.assert.strictEqual(util.isKubernetes(), true) +}) diff --git a/test/watch-fork.test.js b/test/watch-fork.test.js new file mode 100644 index 00000000..ffdf1cf7 --- /dev/null +++ b/test/watch-fork.test.js @@ -0,0 +1,86 @@ +'use strict' + +const { test } = require('node:test') +const path = require('node:path') +const net = require('node:net') +const { fork } = require('node:child_process') +const { once } = require('node:events') +const { GRACEFUL_SHUT } = require('../lib/watch/constants') + +const forkPath = path.join(__dirname, '..', 'lib', 'watch', 'fork.js') + +// Every test forks a real child: kill it on teardown so a stuck child +// cannot hang the whole suite, and fail fast through the test timeout. +const testOptions = { timeout: 30000 } + +function forkChild (t, args) { + const child = fork(forkPath, args, { + env: { ...process.env, childEvent: 'start' }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }) + t.after(() => { if (child.exitCode === null) child.kill('SIGKILL') }) + let stdout = '' + child.stdout.on('data', chunk => { stdout += chunk }) + child.stderr.on('data', chunk => { stdout += chunk }) + const message = (type) => new Promise(resolve => { + child.on('message', function onMessage (msg) { + if (msg.type === type) { + child.off('message', onMessage) + resolve(msg) + } + }) + }) + return { child, message, output: () => stdout } +} + +test('should exit with 1 when the app crashes', testOptions, async (t) => { + const { child, message, output } = forkChild(t, ['-p', '0', './test/data/crashing-plugin.js']) + await message('ready') + const [code] = await once(child, 'exit') + + t.assert.strictEqual(code, 1) + t.assert.match(output(), /async crash/) + t.assert.match(output(), /app crashed - waiting for file changes/) +}) + +test('should exit with 1 when the server cannot start', testOptions, async (t) => { + // bind the blocker and the child to the same address: on macOS a server on + // [::] does not make 127.0.0.1 busy, so the child would start normally + const server = net.createServer().listen(0, '127.0.0.1') + await once(server, 'listening') + t.after(() => server.close()) + const { port } = server.address() + + const { child, output } = forkChild(t, ['-p', String(port), '-a', '127.0.0.1', './examples/plugin.js']) + const [code] = await once(child, 'exit') + + t.assert.strictEqual(code, 1) + t.assert.match(output(), /EADDRINUSE/) +}) + +test('should close the server on graceful shutdown', testOptions, async (t) => { + const { child, message } = forkChild(t, ['-p', '0', './examples/plugin.js']) + await message('ready') + child.send(GRACEFUL_SHUT) + const [code] = await once(child, 'exit') + + t.assert.strictEqual(code, 0) +}) + +test('should force the exit when the server does not close in time', testOptions, async (t) => { + const { child, message, output } = forkChild(t, ['-p', '0', './test/data/hanging-close-plugin.js']) + await message('ready') + child.send(GRACEFUL_SHUT) + const [code] = await once(child, 'exit') + + t.assert.strictEqual(code, 1) + t.assert.match(output(), /process forced end/) +}) + +test('should exit immediately on graceful shutdown when the server is not up yet', testOptions, async (t) => { + const { child } = forkChild(t, ['-p', '0', './test/data/slow-plugin.js']) + child.send(GRACEFUL_SHUT) + const [code] = await once(child, 'exit') + + t.assert.strictEqual(code, 0) +}) diff --git a/test/watch-unit.test.js b/test/watch-unit.test.js new file mode 100644 index 00000000..31d65545 --- /dev/null +++ b/test/watch-unit.test.js @@ -0,0 +1,127 @@ +'use strict' + +const { test } = require('node:test') +const EventEmitter = require('node:events') +const proxyquire = require('proxyquire') +const { logWatchVerbose } = require('../lib/watch/utils') +const { GRACEFUL_SHUT } = require('../lib/watch/constants') + +// Builds a watch() with a fake chokidar and a fake child_process.fork, +// so the restart logic can be driven synchronously. +function setup (t) { + const forks = [] + const childProcessMock = { + fork () { + const child = new EventEmitter() + child.kill = t.mock.fn() + child.send = t.mock.fn() + forks.push(child) + return child + } + } + + const watcher = new EventEmitter() + watcher.close = t.mock.fn() + const chokidarMock = { watch: () => watcher } + + const uncaught = [] + t.mock.method(process, 'on', (event, listener) => { + if (event === 'uncaughtException') uncaught.push(listener) + }) + t.mock.method(console, 'log', () => {}) + + const watch = proxyquire('../lib/watch', { + chokidar: chokidarMock, + 'node:child_process': childProcessMock + }) + + return { watch, forks, watcher, uncaught } +} + +test('should restart the child when a watched file changes', t => { + const { watch, forks, watcher } = setup(t) + watch(['app.js'], 'node_modules', false) + + watcher.emit('ready') + watcher.emit('all', 'change', 'app.js') + + t.assert.strictEqual(forks[0].send.mock.calls[0].arguments[0], GRACEFUL_SHUT) + + forks[0].emit('exit', 0, null) + t.assert.strictEqual(forks.length, 2, 'a new child was forked after the exit') +}) + +test('should log file events with verbose watch', t => { + const { watch, watcher } = setup(t) + watch(['app.js'], 'node_modules', true) + + watcher.emit('ready') + watcher.emit('all', 'change', 'app.js') + + t.assert.match(console.log.mock.calls[0].arguments[0], /watch - 'change' occurred on 'app.js'/) +}) + +test('should fork a new child when the previous one already exited', t => { + const { watch, forks, watcher } = setup(t) + watch(['app.js'], 'node_modules', false) + + watcher.emit('ready') + forks[0].send = () => { throw new Error('channel closed') } + watcher.emit('all', 'change', 'app.js') + + t.assert.strictEqual(forks.length, 2) +}) + +test('should forward child events and emit ready only once', t => { + const { watch, forks } = setup(t) + const emitter = watch(['app.js'], 'node_modules', false) + const events = [] + emitter.on('start', () => events.push('start')) + emitter.on('ready', () => events.push('ready')) + + forks[0].emit('message', { type: 'start', err: null }) + forks[0].emit('message', { type: 'ready' }) + forks[0].emit('message', { type: 'ready' }) + + t.assert.deepStrictEqual(events, ['start', 'ready']) +}) + +test('should stop everything when the child reports an error', t => { + const { watch, forks, watcher } = setup(t) + watch(['app.js'], 'node_modules', false) + + forks[0].emit('message', { type: 'start', err: 'boom' }) + + t.assert.strictEqual(forks[0].kill.mock.callCount(), 1) + t.assert.strictEqual(watcher.close.mock.callCount(), 1) + t.assert.match(console.log.mock.calls[0].arguments[0], /boom/) + forks[0].emit('exit', 1, null) + t.assert.strictEqual(forks.length, 1, 'no restart after stop') +}) + +test('should stop on close', t => { + const { watch, forks, watcher } = setup(t) + const emitter = watch(['app.js'], 'node_modules', false) + + emitter.emit('close') + + t.assert.strictEqual(forks[0].kill.mock.callCount(), 1) + t.assert.strictEqual(watcher.close.mock.callCount(), 1) +}) + +test('should restart the child on an uncaught exception', t => { + const { watch, forks, uncaught } = setup(t) + watch(['app.js'], 'node_modules', false) + + t.assert.strictEqual(uncaught.length, 1) + uncaught[0](new Error('uncaught')) + + t.assert.strictEqual(forks[0].kill.mock.callCount(), 1) + t.assert.strictEqual(forks.length, 2) +}) + +test('logWatchVerbose should print the relative path', t => { + t.mock.method(console, 'log', () => {}) + logWatchVerbose('add', `${process.cwd()}/lib/a.js`) + t.assert.match(console.log.mock.calls[0].arguments[0], /'add' occurred on 'lib\/a.js'/) +}) diff --git a/util.js b/util.js index 2e58fe32..13df83f1 100644 --- a/util.js +++ b/util.js @@ -3,12 +3,9 @@ const fs = require('node:fs') const path = require('node:path') const url = require('node:url') -const semver = require('semver') const { pkgUp } = require('pkg-up') const resolveFrom = require('resolve-from') -const moduleSupport = semver.satisfies(process.version, '>= 14 || >= 12.17.0 < 13.0.0') - function exit (message) { if (message instanceof Error) { console.error(message) @@ -95,11 +92,7 @@ async function requireServerPluginFromPath (modulePath) { let serverPlugin if (type === 'module') { - if (moduleSupport) { - serverPlugin = await import(url.pathToFileURL(resolvedModulePath).href) - } else { - throw new Error(`fastify-cli cannot import plugin at '${resolvedModulePath}'. Your version of node does not support ES modules. To fix this error upgrade to Node 14 or use CommonJS syntax.`) - } + serverPlugin = await import(url.pathToFileURL(resolvedModulePath).href) } else { serverPlugin = require(resolvedModulePath) }