diff --git a/package.json b/package.json index d24954c..a85162a 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "devDependencies": { "@commitlint/cli": "latest", "@commitlint/config-conventional": "latest", - "ava": "latest", + "ava": "7", "c8": "latest", "ci-publish": "latest", "finepack": "latest", diff --git a/src/api.js b/src/api.js index ce8864e..5541310 100644 --- a/src/api.js +++ b/src/api.js @@ -6,15 +6,16 @@ require('update-notifier')({ pkg: require('../package.json') }).notify() const getContentType = require('@kikobeats/content-type') const { URLSearchParams } = require('url') -const clipboardy = require('clipboardy') const mql = require('@microlink/mql') const prettyMs = require('pretty-ms') const temp = require('temperment') const fs = require('fs') const os = require('os') +const { toClipboard, toPlainHeaders } = require('./util') const { gray, green } = require('./colors') -const print = require('./print') +const printJson = require('./print-json') +const printText = require('./print-text') const exit = require('./exit') const microlinkUrl = () => @@ -22,134 +23,172 @@ const microlinkUrl = () => const normalizeInput = input => { if (!input) return input - ;[ + let normalized = input + const sanitizers = [ microlinkUrl, () => require('is-local-address/ipv4').regex, () => require('is-local-address/ipv6').regex - ].forEach(regex => { - return (input = input.replace(regex(), '')) - }) - return input.replace(/^\??url=/, '') + ] + + for (const createRegex of sanitizers) { + normalized = normalized.replace(createRegex(), '') + } + + return normalized.replace(/^\??url=/, '') } const getInput = input => { const collection = input.length === 1 ? input[0].split(os.EOL) : input - return collection.reduce((acc, item) => acc + item.trim(), '') + return collection.map(item => item.trim()).join('') } const toPlainObject = input => Object.fromEntries(new URLSearchParams(input)) const fetch = async (cli, gotOpts) => { - const { pretty, color, copy, endpoint, ...flags } = cli.flags + const { + pretty, + copy, + json, + 'json-full': jsonFull, + endpoint, + ...flags + } = cli.flags + const isJson = json || jsonFull const input = getInput(cli.input, endpoint) const { url, ...queryParams } = toPlainObject(`url=${normalizeInput(input)}`) const mqlOpts = { endpoint, ...queryParams, ...flags } - const spinner = print.spinner() + const spinner = printText.spinner() + const shouldSpin = !isJson && pretty + + let mergedGotOpts = gotOpts + if (Object.keys(cli.headers).length > 0) { + mergedGotOpts = { + ...gotOpts, + headers: { ...gotOpts.headers, ...cli.headers } + } + } - const mergedGotOpts = Object.keys(cli.headers).length > 0 - ? { ...gotOpts, headers: { ...gotOpts.headers, ...cli.headers } } - : gotOpts + const [requestUrl, requestOptions] = mql.getApiUrl( + url, + mqlOpts, + mergedGotOpts + ) try { - spinner.start() - const response = await mql.buffer(url, mqlOpts, mergedGotOpts) - spinner.stop() - return { response, flags: { copy, pretty } } + if (shouldSpin) spinner.start() + + const start = Date.now() + const request = isJson ? mql : mql.buffer + const mqlResponse = await request(url, mqlOpts, mergedGotOpts) + const duration = Date.now() - start + + let response = mqlResponse + if (isJson) { + response = printJson({ + requestUrl, + requestOptions, + response: mqlResponse.response, + full: jsonFull, + pretty + }) + } + + return { response, duration, flags: { copy, pretty, json: isJson } } } catch (error) { - spinner.stop() error.flags = cli.flags throw error + } finally { + if (shouldSpin) spinner.stop() } } -const render = ({ response, flags }) => { - const { headers, timings, requestUrl: uri, body } = response - if (!flags.pretty) return console.log(body.toString()) - - const contentType = getContentType(headers['content-type']) - const time = prettyMs(timings.phases.total) - const serverTiming = headers['server-timing'] - const id = headers['x-request-id'] - - const printMode = (() => { - if (body.toString().startsWith('data:')) return 'base64' - if (contentType !== 'application/json') return 'image' - })() - - switch (printMode) { - case 'base64': { - const extension = contentType.split('/')[1].split(';')[0] - const filepath = temp.file({ extension }) - fs.writeFileSync(filepath, body.toString().split(',')[1], 'base64') - print.image(filepath) - break - } - case 'image': - print.image(body) - console.log() - break - default: { - const isText = contentType === 'text/plain' - const isHtml = contentType === 'text/html' - const output = isText || isHtml ? body.toString() : JSON.parse(body) - print.json(output, flags) - break - } +const render = ({ response, duration, flags }) => { + const { headers, requestUrl, url: responseUrl, body } = response + + if (flags.json) { + if (flags.copy) toClipboard(JSON.parse(response), flags) + if (!flags.pretty) return console.log(response) + + return printText.json(JSON.parse(response), { color: true }) } - const edgeCacheStatus = headers['cf-cache-status'] - const unifiedCacheStatus = headers['x-cache-status'] + const plainHeaders = toPlainHeaders(headers) + + const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body) + const bodyText = bodyBuffer.toString() + + if (!flags.pretty) return console.log(bodyText) + + const contentType = getContentType(plainHeaders['content-type']) + const time = Number.isFinite(duration) ? prettyMs(duration) : 'unknown' + const serverTiming = plainHeaders['server-timing'] + const id = plainHeaders['x-request-id'] + + if (bodyText.startsWith('data:')) { + const extension = contentType + ? contentType.split('/')[1].split(';')[0] + : 'png' + const filepath = temp.file({ extension }) + fs.writeFileSync(filepath, bodyText.split(',')[1], 'base64') + printText.image(filepath) + } else if (contentType !== 'application/json') { + printText.image(bodyBuffer) + console.log() + } else { + const isText = contentType === 'text/plain' + const isHtml = contentType === 'text/html' + const output = isText || isHtml ? bodyText : JSON.parse(bodyText) + printText.json(output, flags) + } + + const edgeCacheStatus = plainHeaders['cf-cache-status'] + const unifiedCacheStatus = plainHeaders['x-cache-status'] const cacheStatus = unifiedCacheStatus === 'MISS' && edgeCacheStatus === 'HIT' ? edgeCacheStatus : unifiedCacheStatus - const timestamp = Number(headers['x-timestamp']) - const ttl = Number(headers['x-cache-ttl']) + const timestamp = Number(plainHeaders['x-timestamp']) + const ttl = Number(plainHeaders['x-cache-ttl']) const expires = timestamp + ttl - Date.now() const expiration = prettyMs(expires) const expiredAt = cacheStatus === 'HIT' ? `(${expiration})` : '' - const fetchMode = headers['x-fetch-mode'] - const fetchTime = fetchMode && `(${headers['x-fetch-time']})` - const size = Number(headers['content-length'] || Buffer.byteLength(body)) + const fetchMode = plainHeaders['x-fetch-mode'] + const fetchTime = fetchMode && `(${plainHeaders['x-fetch-time']})` + const size = Number(plainHeaders['content-length'] || bodyBuffer.length) + const uri = requestUrl || responseUrl console.error() console.error( - print.label('success', 'green'), - gray(`${print.bytes(size)} in ${time}`) + printText.label('success', 'green'), + gray(`${printText.bytes(size)} in ${time}`) ) console.error() if (serverTiming) { - console.error(' ', print.keyValue(green('timing'), serverTiming)) + console.error(' ', printText.keyValue(green('timing'), serverTiming)) } if (cacheStatus) { console.error( ' ', - print.keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`) + printText.keyValue(green('cache'), `${cacheStatus} ${gray(expiredAt)}`) ) } if (fetchMode) { console.error( ' ', - print.keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`) + printText.keyValue(green('mode'), `${fetchMode} ${gray(fetchTime)}`) ) } - console.error(' ', print.keyValue(green('uri'), uri)) - console.error(' ', print.keyValue(green('id'), id)) + console.error(' ', printText.keyValue(green('uri'), uri)) + console.error(' ', printText.keyValue(green('id'), id)) if (flags.copy) { - let copiedValue - try { - copiedValue = JSON.parse(body) - } catch (err) { - copiedValue = body - } - clipboardy.writeSync(JSON.stringify(copiedValue, null, 2)) + toClipboard(JSON.parse(bodyText), flags) console.error(`\n ${gray('Copied to clipboard!')}`) } } diff --git a/src/cli.js b/src/cli.js index 3c9a681..95a5eab 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,30 +1,22 @@ 'use strict' const mri = require('mri') +const { hasColorizedOutput, parseHeaders } = require('./util') -const { _, header, ...flags } = mri(process.argv.slice(2), { +const parsed = mri(process.argv.slice(2), { alias: { H: 'header' }, - boolean: ['color', 'copy', 'pretty'], + boolean: ['copy', 'json', 'json-full', 'pretty'], string: ['header'], default: { apiKey: process.env.MICROLINK_API_KEY, - pretty: true, - color: true, - copy: false + pretty: hasColorizedOutput(), + copy: false, + json: false, + 'json-full': false } }) -const parseHeaders = raw => { - if (!raw) return {} - const entries = Array.isArray(raw) ? raw : [raw] - const headers = {} - for (const entry of entries) { - const idx = entry.indexOf(':') - if (idx === -1) continue - headers[entry.slice(0, idx).trim().toLowerCase()] = entry.slice(idx + 1).trim() - } - return headers -} +const { _, header, ...flags } = parsed const headers = parseHeaders(header) diff --git a/src/exit.js b/src/exit.js index b76c1b4..82c713b 100644 --- a/src/exit.js +++ b/src/exit.js @@ -2,7 +2,7 @@ const { gray, red } = require('./colors') -const print = require('./print') +const print = require('./print-text') module.exports = async (promise, { flags }) => { try { diff --git a/src/help.js b/src/help.js index 3feff62..a2b9e2d 100644 --- a/src/help.js +++ b/src/help.js @@ -13,12 +13,21 @@ Flags white('$MICROLINK_API_KEY') )}` )} - ${gray(`--colors colorize output (default is ${code(white('true'))}`)} ${gray( `--copy copy output to clipboard (default is ${code( white('false') )}).` )} + ${gray( + `--json print request & response payload as JSON (API key masked, default is ${code( + white('false') + )}).` + )} + ${gray( + `--json-full print request & response payload as JSON including full API key (default is ${code( + white('false') + )}).` + )} ${gray( `--pretty beauty response payload (default is ${code( white('true') @@ -30,6 +39,8 @@ Flags Examples ${gray('microlink https://microlink.io&palette')} ${gray('microlink https://microlink.io&palette --no-pretty')} + ${gray('microlink https://microlink.io&palette --json')} + ${gray('microlink https://microlink.io&palette --json-full')} ${gray('microlink https://microlink.io&palette --api-key=MyApiKey')} ${gray("microlink https://example.com -H 'x-user-cookie: 1'")} ` diff --git a/src/index.js b/src/index.js index 7fbb0b3..fd9aa6d 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,5 @@ module.exports = { cli: require('./cli'), api: require('./api'), - exit: require('./exit'), - print: require('./print') + exit: require('./exit') } diff --git a/src/print-json.js b/src/print-json.js new file mode 100644 index 0000000..5864653 --- /dev/null +++ b/src/print-json.js @@ -0,0 +1,33 @@ +'use strict' + +const { humanizeApiKey, toPlainHeaders, stringify } = require('./util') + +module.exports = ({ + requestUrl, + requestOptions, + response, + full = false, + pretty = true +}) => { + const { responseType, ...requestOptionsWithoutResponseType } = requestOptions + const headers = { ...requestOptionsWithoutResponseType.headers } + + if (!full && headers['x-api-key']) { + headers['x-api-key'] = humanizeApiKey(headers['x-api-key']) + } + + return stringify( + { + request: { + url: requestUrl, + ...requestOptionsWithoutResponseType, + headers + }, + response: { + ...response, + headers: toPlainHeaders(response.headers) + } + }, + { pretty } + ) +} diff --git a/src/print.js b/src/print-text.js similarity index 100% rename from src/print.js rename to src/print-text.js diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..03aaf77 --- /dev/null +++ b/src/util.js @@ -0,0 +1,44 @@ +'use strict' + +const clipboardy = require('clipboardy') + +const toPlainHeaders = headers => Object.fromEntries(headers.entries()) + +const humanizeApiKey = apiKey => `${apiKey.substring(0, 5)}…` + +const stringify = (value, { pretty }) => + JSON.stringify(value, null, pretty ? 2 : 0) + +const toClipboard = (value, flags) => + clipboardy.writeSync(stringify(value, flags)) + +const hasColorizedOutput = () => + !process.env.NO_COLOR && + process.env.FORCE_COLOR !== '0' && + Boolean(process.stdout?.hasColors?.()) + +const parseHeaders = raw => { + if (!raw) return {} + + const entries = Array.isArray(raw) ? raw : [raw] + const headers = {} + + for (const entry of entries) { + const idx = entry.indexOf(':') + if (idx === -1) continue + headers[entry.slice(0, idx).trim().toLowerCase()] = entry + .slice(idx + 1) + .trim() + } + + return headers +} + +module.exports = { + hasColorizedOutput, + humanizeApiKey, + parseHeaders, + stringify, + toClipboard, + toPlainHeaders +} diff --git a/test/api.js b/test/api.js new file mode 100644 index 0000000..f6faa9c --- /dev/null +++ b/test/api.js @@ -0,0 +1,480 @@ +'use strict' + +const path = require('path') +const test = require('ava') + +const { loadFresh, withModuleMocks } = require('./helpers/module-mocks') + +const createMqlStub = ({ + mqlResponse, + bufferResponse, + rejectOnBuffer +} = {}) => { + const calls = { mql: [], buffer: [], getApiUrl: [] } + + const mql = async (...args) => { + calls.mql.push(args) + return mqlResponse + } + + mql.buffer = async (...args) => { + calls.buffer.push(args) + if (rejectOnBuffer) throw rejectOnBuffer + return bufferResponse + } + + mql.getApiUrl = (...args) => { + calls.getApiUrl.push(args) + return ['https://api.microlink.io?url=https://example.com', { headers: {} }] + } + + return { mql, calls } +} + +const createCli = flags => ({ + flags: { + pretty: true, + copy: false, + json: false, + 'json-full': false, + endpoint: undefined, + ...flags + }, + headers: {}, + input: ['https://example.com'] +}) + +test.serial('api json pretty flow parses and colorizes output', async t => { + const toClipboardCalls = [] + const printJsonCalls = [] + const printTextJsonCalls = [] + const spinnerCalls = { start: 0, stop: 0 } + + const { mql, calls } = createMqlStub({ + mqlResponse: { + response: { + headers: new Headers({ 'content-type': 'application/json' }), + body: { ok: true } + } + } + }) + + await withModuleMocks( + { + 'update-notifier': () => ({ notify: () => {} }), + '@kikobeats/content-type': value => value, + '@microlink/mql': mql, + 'pretty-ms': value => `${value}ms`, + temperment: { file: () => '/tmp/image.png' }, + fs: { writeFileSync: () => {} }, + './util': { + toClipboard: value => toClipboardCalls.push(value), + toPlainHeaders: headers => Object.fromEntries(headers.entries()) + }, + './colors': { + gray: value => value, + green: value => value + }, + './print-json': payload => { + printJsonCalls.push(payload) + return '{"status":"success"}' + }, + './print-text': { + spinner: () => ({ + start: () => { + spinnerCalls.start++ + }, + stop: () => { + spinnerCalls.stop++ + } + }), + json: (...args) => printTextJsonCalls.push(args), + image: () => {}, + label: () => '', + bytes: value => `${value}b`, + keyValue: () => '' + }, + './exit': async promise => promise + }, + async () => { + const api = loadFresh(path.join(process.cwd(), 'src/api.js')) + await api(createCli({ json: true, copy: true, pretty: true })) + } + ) + + t.is(calls.mql.length, 1) + t.is(calls.buffer.length, 0) + t.is(spinnerCalls.start, 0) + t.is(spinnerCalls.stop, 0) + t.is(printJsonCalls.length, 1) + t.deepEqual(toClipboardCalls, [{ status: 'success' }]) + t.deepEqual(printTextJsonCalls, [[{ status: 'success' }, { color: true }]]) +}) + +test.serial('api merges cli headers into got options', async t => { + const { mql, calls } = createMqlStub({ + mqlResponse: { + response: { + headers: new Headers({ 'content-type': 'application/json' }), + body: { ok: true } + } + } + }) + + await withModuleMocks( + { + 'update-notifier': () => ({ notify: () => {} }), + '@kikobeats/content-type': value => value, + '@microlink/mql': mql, + 'pretty-ms': value => `${value}ms`, + temperment: { file: () => '/tmp/image.png' }, + fs: { writeFileSync: () => {} }, + './util': { + toClipboard: () => {}, + toPlainHeaders: headers => Object.fromEntries(headers.entries()) + }, + './colors': { + gray: value => value, + green: value => value + }, + './print-json': () => '{"ok":true}', + './print-text': { + spinner: () => ({ start: () => {}, stop: () => {} }), + json: () => {}, + image: () => {}, + label: () => '', + bytes: value => `${value}b`, + keyValue: () => '' + }, + './exit': async promise => promise + }, + async () => { + const api = loadFresh(path.join(process.cwd(), 'src/api.js')) + const cli = createCli({ json: true, pretty: false }) + cli.headers = { 'x-user-cookie': '1' } + await api(cli, { headers: { authorization: 'Bearer token' } }) + } + ) + + t.deepEqual(calls.getApiUrl[0][2].headers, { + authorization: 'Bearer token', + 'x-user-cookie': '1' + }) +}) + +test.serial('api json compact flow prints raw response', async t => { + const originalLog = console.log + const logs = [] + console.log = value => logs.push(value) + + const printTextJsonCalls = [] + const { mql } = createMqlStub({ mqlResponse: { response: {} } }) + + try { + await withModuleMocks( + { + 'update-notifier': () => ({ notify: () => {} }), + '@kikobeats/content-type': value => value, + '@microlink/mql': mql, + 'pretty-ms': value => `${value}ms`, + temperment: { file: () => '/tmp/image.png' }, + fs: { writeFileSync: () => {} }, + './util': { + toClipboard: () => {}, + toPlainHeaders: headers => Object.fromEntries(headers.entries()) + }, + './colors': { + gray: value => value, + green: value => value + }, + './print-json': () => '{"status":"compact"}', + './print-text': { + spinner: () => ({ start: () => {}, stop: () => {} }), + json: (...args) => printTextJsonCalls.push(args), + image: () => {}, + label: () => '', + bytes: value => `${value}b`, + keyValue: () => '' + }, + './exit': async promise => promise + }, + async () => { + const api = loadFresh(path.join(process.cwd(), 'src/api.js')) + await api(createCli({ json: true, pretty: false })) + } + ) + } finally { + console.log = originalLog + } + + t.deepEqual(logs, ['{"status":"compact"}']) + t.deepEqual(printTextJsonCalls, []) +}) + +test.serial( + 'api non-json pretty flow prints parsed body and summary', + async t => { + const originalError = console.error + const errorLogs = [] + console.error = (...args) => errorLogs.push(args) + + const toClipboardCalls = [] + const printTextJsonCalls = [] + const spinnerCalls = { start: 0, stop: 0 } + + const now = Date.now() + + const { mql, calls } = createMqlStub({ + bufferResponse: { + headers: new Headers({ + 'content-type': 'application/json', + 'server-timing': 'edge;dur=10', + 'x-request-id': 'req-1', + 'cf-cache-status': 'HIT', + 'x-cache-status': 'MISS', + 'x-timestamp': String(now), + 'x-cache-ttl': '2000', + 'x-fetch-mode': 'browser', + 'x-fetch-time': '20ms', + 'content-length': '17' + }), + requestUrl: 'https://api.microlink.io?url=https://example.com', + url: 'https://api.microlink.io', + body: Buffer.from('{"hello":"world"}') + } + }) + + try { + await withModuleMocks( + { + 'update-notifier': () => ({ notify: () => {} }), + '@kikobeats/content-type': () => 'application/json', + '@microlink/mql': mql, + 'pretty-ms': value => `${value}ms`, + temperment: { file: () => '/tmp/image.png' }, + fs: { writeFileSync: () => {} }, + './util': { + toClipboard: value => toClipboardCalls.push(value), + toPlainHeaders: headers => Object.fromEntries(headers.entries()) + }, + './colors': { + gray: value => `[gray:${value}]`, + green: value => `[green:${value}]` + }, + './print-json': () => '', + './print-text': { + spinner: () => ({ + start: () => { + spinnerCalls.start++ + }, + stop: () => { + spinnerCalls.stop++ + } + }), + json: (...args) => printTextJsonCalls.push(args), + image: () => {}, + label: () => '[SUCCESS]', + bytes: value => `${value}b`, + keyValue: (key, value) => `${key}=${value}` + }, + './exit': async promise => promise + }, + async () => { + const api = loadFresh(path.join(process.cwd(), 'src/api.js')) + await api(createCli({ json: false, copy: true, pretty: true })) + } + ) + } finally { + console.error = originalError + } + + t.is(calls.mql.length, 0) + t.is(calls.buffer.length, 1) + t.is(spinnerCalls.start, 1) + t.is(spinnerCalls.stop, 1) + t.deepEqual(printTextJsonCalls, [ + [ + { hello: 'world' }, + { + copy: true, + pretty: true, + json: false + } + ] + ]) + t.deepEqual(toClipboardCalls, [{ hello: 'world' }]) + + const flat = errorLogs.map(args => args.join(' ')).join('\n') + t.true(flat.includes('[SUCCESS]')) + t.true(flat.includes('[green:timing]=edge;dur=10')) + t.true(flat.includes('[green:cache]=HIT')) + t.true(flat.includes('[green:mode]=browser')) + } +) + +test.serial('api data uri flow writes image to temp file', async t => { + const written = [] + const imageCalls = [] + + const { mql } = createMqlStub({ + bufferResponse: { + headers: new Headers({ 'content-type': 'image/png' }), + requestUrl: 'https://api.microlink.io?url=https://example.com', + url: 'https://api.microlink.io', + body: Buffer.from('data:image/png;base64,QUJD') + } + }) + + await withModuleMocks( + { + 'update-notifier': () => ({ notify: () => {} }), + '@kikobeats/content-type': () => 'image/png', + '@microlink/mql': mql, + 'pretty-ms': value => `${value}ms`, + temperment: { file: () => '/tmp/image.png' }, + fs: { + writeFileSync: (...args) => written.push(args) + }, + './util': { + toClipboard: () => {}, + toPlainHeaders: headers => Object.fromEntries(headers.entries()) + }, + './colors': { + gray: value => value, + green: value => value + }, + './print-json': () => '', + './print-text': { + spinner: () => ({ start: () => {}, stop: () => {} }), + json: () => {}, + image: value => imageCalls.push(value), + label: () => '', + bytes: value => `${value}b`, + keyValue: () => '' + }, + './exit': async promise => promise + }, + async () => { + const api = loadFresh(path.join(process.cwd(), 'src/api.js')) + await api(createCli({ json: false, pretty: true })) + } + ) + + t.deepEqual(imageCalls, ['/tmp/image.png']) + t.deepEqual(written, [['/tmp/image.png', 'QUJD', 'base64']]) +}) + +test.serial('api text payload flow renders image fallback branch', async t => { + const originalLog = console.log + const logs = [] + const imageCalls = [] + + console.log = (...args) => logs.push(args) + + const { mql } = createMqlStub({ + bufferResponse: { + headers: new Headers({ 'content-type': 'text/plain' }), + requestUrl: 'https://api.microlink.io?url=https://example.com', + url: 'https://api.microlink.io', + body: Buffer.from('plain response') + } + }) + + try { + await withModuleMocks( + { + 'update-notifier': () => ({ notify: () => {} }), + '@kikobeats/content-type': () => 'text/plain', + '@microlink/mql': mql, + 'pretty-ms': value => `${value}ms`, + temperment: { file: () => '/tmp/image.png' }, + fs: { writeFileSync: () => {} }, + './util': { + toClipboard: () => {}, + toPlainHeaders: headers => Object.fromEntries(headers.entries()) + }, + './colors': { + gray: value => value, + green: value => value + }, + './print-json': () => '', + './print-text': { + spinner: () => ({ start: () => {}, stop: () => {} }), + json: () => {}, + image: value => imageCalls.push(value), + label: () => '', + bytes: value => `${value}b`, + keyValue: () => '' + }, + './exit': async promise => promise + }, + async () => { + const api = loadFresh(path.join(process.cwd(), 'src/api.js')) + await api(createCli({ json: false, pretty: true })) + } + ) + } finally { + console.log = originalLog + } + + t.deepEqual(imageCalls, [Buffer.from('plain response')]) + t.deepEqual(logs, [[]]) +}) + +test.serial('api attaches flags to thrown errors', async t => { + const spinnerCalls = { start: 0, stop: 0 } + + const error = new Error('upstream failed') + + const { mql } = createMqlStub({ rejectOnBuffer: error }) + + const result = await withModuleMocks( + { + 'update-notifier': () => ({ notify: () => {} }), + '@kikobeats/content-type': () => 'application/json', + '@microlink/mql': mql, + 'pretty-ms': value => `${value}ms`, + temperment: { file: () => '/tmp/image.png' }, + fs: { writeFileSync: () => {} }, + './util': { + toClipboard: () => {}, + toPlainHeaders: headers => Object.fromEntries(headers.entries()) + }, + './colors': { + gray: value => value, + green: value => value + }, + './print-json': () => '', + './print-text': { + spinner: () => ({ + start: () => { + spinnerCalls.start++ + }, + stop: () => { + spinnerCalls.stop++ + } + }), + json: () => {}, + image: () => {}, + label: () => '', + bytes: value => `${value}b`, + keyValue: () => '' + }, + './exit': promise => + promise.then( + () => ({ ok: true }), + error => ({ ok: false, error }) + ) + }, + async () => { + const api = loadFresh(path.join(process.cwd(), 'src/api.js')) + return api(createCli({ json: false, pretty: true })) + } + ) + + t.false(result.ok) + t.truthy(result.error.flags) + t.is(result.error.flags.pretty, true) + t.is(spinnerCalls.start, 1) + t.is(spinnerCalls.stop, 1) +}) diff --git a/test/cli.js b/test/cli.js new file mode 100644 index 0000000..96e83b0 --- /dev/null +++ b/test/cli.js @@ -0,0 +1,90 @@ +'use strict' + +const path = require('path') +const test = require('ava') + +const { loadFresh, withModuleMocks } = require('./helpers/module-mocks') + +test.serial('cli parses args, flags and headers', async t => { + const originalArgv = process.argv + const initialApiKey = process.env.MICROLINK_API_KEY + + const parseHeadersCalls = [] + + process.argv = [ + 'node', + 'microlink', + 'https://example.com', + '-H', + 'x-user-cookie: 1', + '--json' + ] + process.env.MICROLINK_API_KEY = 'api-key-test' + + try { + await withModuleMocks( + { + './util': { + hasColorizedOutput: () => false, + parseHeaders: header => { + parseHeadersCalls.push(header) + return { parsed: true } + } + } + }, + async () => { + const cli = loadFresh(path.join(process.cwd(), 'src/cli.js')) + + t.deepEqual(parseHeadersCalls, ['x-user-cookie: 1']) + t.deepEqual(cli.headers, { parsed: true }) + t.deepEqual(cli.input, ['https://example.com']) + + t.is(cli.flags.apiKey, 'api-key-test') + t.is(cli.flags.pretty, false) + t.is(cli.flags.json, true) + } + ) + } finally { + process.argv = originalArgv + + if (initialApiKey === undefined) delete process.env.MICROLINK_API_KEY + else process.env.MICROLINK_API_KEY = initialApiKey + } +}) + +test.serial('cli showHelp prints help and exits 0', async t => { + const originalArgv = process.argv + const originalLog = console.log + const originalExit = process.exit + + process.argv = ['node', 'microlink'] + + const logs = [] + const exits = [] + + console.log = value => logs.push(value) + process.exit = code => exits.push(code) + + try { + await withModuleMocks( + { + './util': { + hasColorizedOutput: () => true, + parseHeaders: () => ({}) + }, + './help': 'HELP CONTENT' + }, + async () => { + const cli = loadFresh(path.join(process.cwd(), 'src/cli.js')) + cli.showHelp() + } + ) + } finally { + process.argv = originalArgv + console.log = originalLog + process.exit = originalExit + } + + t.deepEqual(logs, ['HELP CONTENT']) + t.deepEqual(exits, [0]) +}) diff --git a/test/colors.js b/test/colors.js new file mode 100644 index 0000000..f2b82b4 --- /dev/null +++ b/test/colors.js @@ -0,0 +1,43 @@ +'use strict' + +const path = require('path') +const test = require('ava') + +const { loadFresh, withModuleMocks } = require('./helpers/module-mocks') + +test.serial( + 'colors helpers call node:util.styleText with expected styles', + async t => { + const calls = [] + + await withModuleMocks( + { + 'node:util': { + styleText: (style, text) => { + calls.push([style, text]) + return `${text}` + } + } + }, + async () => { + const colors = loadFresh(path.join(process.cwd(), 'src/colors.js')) + + colors.gray('g') + colors.white('w') + colors.green('gr') + colors.red('r') + colors.yellow('y') + colors.label('ok', 'green') + } + ) + + t.deepEqual(calls, [ + ['gray', 'g'], + ['white', 'w'], + ['green', 'gr'], + ['red', 'r'], + ['yellow', 'y'], + [['inverse', 'bold', 'green'], ' OK '] + ]) + } +) diff --git a/test/exit.js b/test/exit.js new file mode 100644 index 0000000..90c44fb --- /dev/null +++ b/test/exit.js @@ -0,0 +1,131 @@ +'use strict' + +const path = require('path') +const test = require('ava') + +const { loadFresh, withModuleMocks } = require('./helpers/module-mocks') + +test.serial('exit with success calls process.exit()', async t => { + const originalExit = process.exit + const calls = [] + + process.exit = code => { + calls.push(code) + } + + try { + await withModuleMocks( + { + './colors': { gray: value => value, red: value => value }, + './print-text': { + label: () => '', + keyValue: () => '', + link: () => '' + } + }, + async () => { + const exit = loadFresh(path.join(process.cwd(), 'src/exit.js')) + await exit(Promise.resolve(), { flags: { pretty: true } }) + } + ) + } finally { + process.exit = originalExit + } + + t.deepEqual(calls, [undefined]) +}) + +test.serial( + 'exit with pretty error prints details and exits with code 1', + async t => { + const originalExit = process.exit + const originalLog = console.log + + const exitCalls = [] + const logs = [] + + process.exit = code => { + exitCalls.push(code) + } + console.log = (...args) => logs.push(args) + + try { + await withModuleMocks( + { + './colors': { + gray: value => `[gray:${value}]`, + red: value => `[red:${value}]` + }, + './print-text': { + label: text => `[label:${text}]`, + keyValue: (key, value) => `${key}=${value}`, + link: (text, url) => `${text}:${url}` + } + }, + async () => { + const exit = loadFresh(path.join(process.cwd(), 'src/exit.js')) + const error = new Error('ERR_TEST, failure happened') + + error.code = 'ERR_TEST' + error.status = 'bad' + error.statusCode = 502 + error.data = { reason: 'upstream' } + error.more = 'https://microlink.io/report' + error.url = 'https://api.microlink.io' + error.headers = { 'x-request-id': 'request-1' } + + await exit(Promise.reject(error), { flags: { pretty: true } }) + } + ) + } finally { + process.exit = originalExit + console.log = originalLog + } + + t.deepEqual(exitCalls, [1]) + + const flatOutput = logs.map(args => args.join(' ')).join('\n') + t.true(flatOutput.includes('[label:BAD]')) + t.true(flatOutput.includes('failure happened')) + t.true(flatOutput.includes('reason')) + t.true(flatOutput.includes('[red:id]=request-1')) + t.true(flatOutput.includes('[red:uri]=https://api.microlink.io')) + t.true(flatOutput.includes('[red:code]=ERR_TEST (502)')) + t.true(flatOutput.includes('click to report:https://microlink.io/report')) + } +) + +test.serial( + 'exit with non-pretty error does not call process.exit', + async t => { + const originalExit = process.exit + const calls = [] + + process.exit = code => { + calls.push(code) + } + + try { + await withModuleMocks( + { + './colors': { gray: value => value, red: value => value }, + './print-text': { + label: () => '', + keyValue: () => '', + link: () => '' + } + }, + async () => { + const exit = loadFresh(path.join(process.cwd(), 'src/exit.js')) + await exit(Promise.reject(new Error('boom')), { + flags: { pretty: false } + }) + } + ) + } finally { + process.exit = originalExit + } + + t.deepEqual(calls, []) + } +) diff --git a/test/help.js b/test/help.js new file mode 100644 index 0000000..ee98ce5 --- /dev/null +++ b/test/help.js @@ -0,0 +1,26 @@ +'use strict' + +const path = require('path') +const test = require('ava') + +const { loadFresh, withModuleMocks } = require('./helpers/module-mocks') + +test.serial('help includes documented flags and examples', async t => { + const help = await withModuleMocks( + { + './colors': { + gray: value => value, + white: value => value + } + }, + async () => loadFresh(path.join(process.cwd(), 'src/help.js')) + ) + + t.true(help.includes('--api-key')) + t.true(help.includes('--copy')) + t.true(help.includes('--json')) + t.true(help.includes('--json-full')) + t.true(help.includes('--pretty')) + t.true(help.includes('-H
')) + t.true(help.includes('microlink https://microlink.io&palette --json-full')) +}) diff --git a/test/helpers/module-mocks.js b/test/helpers/module-mocks.js new file mode 100644 index 0000000..7f09608 --- /dev/null +++ b/test/helpers/module-mocks.js @@ -0,0 +1,28 @@ +'use strict' + +const Module = require('module') + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key) + +const withModuleMocks = async (mocks, fn) => { + const originalLoad = Module._load + + Module._load = function (request, parent, isMain) { + if (hasOwn(mocks, request)) return mocks[request] + return originalLoad.apply(this, arguments) + } + + try { + return await fn() + } finally { + Module._load = originalLoad + } +} + +const loadFresh = modulePath => { + const resolved = require.resolve(modulePath, { paths: [process.cwd()] }) + delete require.cache[resolved] + return require(resolved) +} + +module.exports = { loadFresh, withModuleMocks } diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..90ce2c2 --- /dev/null +++ b/test/index.js @@ -0,0 +1,23 @@ +'use strict' + +const path = require('path') +const test = require('ava') + +const { loadFresh, withModuleMocks } = require('./helpers/module-mocks') + +test.serial('index exports cli, api and exit modules', async t => { + const exports = await withModuleMocks( + { + './cli': { name: 'cli' }, + './api': { name: 'api' }, + './exit': { name: 'exit' } + }, + async () => loadFresh(path.join(process.cwd(), 'src/index.js')) + ) + + t.deepEqual(exports, { + cli: { name: 'cli' }, + api: { name: 'api' }, + exit: { name: 'exit' } + }) +}) diff --git a/test/print-text.js b/test/print-text.js new file mode 100644 index 0000000..e18548f --- /dev/null +++ b/test/print-text.js @@ -0,0 +1,278 @@ +'use strict' + +const path = require('path') +const test = require('ava') + +const { loadFresh, withModuleMocks } = require('./helpers/module-mocks') + +test.serial('json prints with jsome when color is enabled', async t => { + let payloadReceived + + const jsome = payload => { + payloadReceived = payload + } + jsome.colors = {} + + await withModuleMocks( + { + jsome, + nanospinner: { createSpinner: () => ({}) }, + 'restore-cursor': () => {}, + 'terminal-link': () => '', + 'pretty-bytes': () => '', + 'pretty-ms': () => '', + 'term-img': () => '', + './colors': { + gray: value => value, + yellow: value => value, + label: value => value + } + }, + async () => { + const printText = loadFresh(path.join(process.cwd(), 'src/print-text.js')) + printText.json({ ok: true }, { color: true }) + } + ) + + t.deepEqual(payloadReceived, { ok: true }) +}) + +test.serial('json logs payload when color is disabled', async t => { + const originalLog = console.log + const calls = [] + console.log = value => calls.push(value) + + const jsome = () => {} + jsome.colors = {} + + try { + await withModuleMocks( + { + jsome, + nanospinner: { createSpinner: () => ({}) }, + 'restore-cursor': () => {}, + 'terminal-link': () => '', + 'pretty-bytes': () => '', + 'pretty-ms': () => '', + 'term-img': () => '', + './colors': { + gray: value => value, + yellow: value => value, + label: value => value + } + }, + async () => { + const printText = loadFresh( + path.join(process.cwd(), 'src/print-text.js') + ) + printText.json({ ok: true }, { color: false }) + } + ) + } finally { + console.log = originalLog + } + + t.deepEqual(calls, [{ ok: true }]) +}) + +test.serial('spinner starts, updates, stops, and handles SIGINT', async t => { + let startCall + const updates = [] + let clearCalls = 0 + let restoreCalls = 0 + let sigintHandler + let intervalHandler + let clearIntervalId + + const originalError = console.error + const originalOn = process.on + const originalExit = process.exit + const originalSetInterval = global.setInterval + const originalClearInterval = global.clearInterval + + console.error = () => {} + process.on = (event, handler) => { + if (event === 'SIGINT') sigintHandler = handler + } + + const exitCalls = [] + process.exit = code => { + exitCalls.push(code) + } + + global.setInterval = (handler, interval) => { + intervalHandler = handler + t.is(interval, 50) + return 99 + } + + global.clearInterval = id => { + clearIntervalId = id + } + + const jsome = () => {} + jsome.colors = {} + + try { + await withModuleMocks( + { + jsome, + nanospinner: { + createSpinner: () => ({ + start: payload => { + startCall = payload + }, + update: payload => updates.push(payload), + clear: () => { + clearCalls++ + } + }) + }, + 'restore-cursor': () => { + restoreCalls++ + }, + 'terminal-link': () => '', + 'pretty-bytes': () => '', + 'pretty-ms': value => `${value}ms`, + 'term-img': () => '', + './colors': { + gray: value => value, + yellow: value => value, + label: value => value + } + }, + async () => { + const printText = loadFresh( + path.join(process.cwd(), 'src/print-text.js') + ) + const spinner = printText.spinner() + + spinner.start() + t.truthy(startCall) + t.truthy(sigintHandler) + + intervalHandler() + t.true(updates.length >= 1) + + spinner.stop() + t.is(clearCalls, 1) + t.is(clearIntervalId, 99) + t.is(restoreCalls, 1) + + sigintHandler() + t.deepEqual(exitCalls, [130]) + t.is(restoreCalls, 2) + } + ) + } finally { + console.error = originalError + process.on = originalOn + process.exit = originalExit + global.setInterval = originalSetInterval + global.clearInterval = originalClearInterval + } +}) + +test.serial('keyValue formats with gray value', async t => { + const jsome = () => {} + jsome.colors = {} + + await withModuleMocks( + { + jsome, + nanospinner: { createSpinner: () => ({}) }, + 'restore-cursor': () => {}, + 'terminal-link': () => '', + 'pretty-bytes': () => '', + 'pretty-ms': () => '', + 'term-img': () => '', + './colors': { + gray: value => `[gray:${value}]`, + yellow: value => value, + label: value => value + } + }, + async () => { + const printText = loadFresh(path.join(process.cwd(), 'src/print-text.js')) + t.is(printText.keyValue('key', 'value'), 'key [gray:value]') + } + ) +}) + +test.serial( + 'image prints rendered terminal image and builds fallback', + async t => { + const originalLog = console.log + const logs = [] + console.log = value => logs.push(value) + + let fallbackOutput + + const jsome = () => {} + jsome.colors = {} + + try { + await withModuleMocks( + { + jsome, + nanospinner: { createSpinner: () => ({}) }, + 'restore-cursor': () => {}, + 'terminal-link': () => '', + 'pretty-bytes': () => '', + 'pretty-ms': () => '', + 'term-img': (filepath, options) => { + fallbackOutput = options.fallback() + return `IMAGE:${filepath}` + }, + './colors': { + gray: value => `[gray:${value}]`, + yellow: value => `[yellow:${value}]`, + label: value => value + } + }, + async () => { + const printText = loadFresh( + path.join(process.cwd(), 'src/print-text.js') + ) + printText.image('preview.png') + } + ) + } finally { + console.log = originalLog + } + + t.deepEqual(logs, ['IMAGE:preview.png']) + t.true(fallbackOutput.includes('[yellow: tip:]')) + t.true(fallbackOutput.includes('iTerm >=3')) + } +) + +test.serial('link uses terminal-link fallback', async t => { + const jsome = () => {} + jsome.colors = {} + + await withModuleMocks( + { + jsome, + nanospinner: { createSpinner: () => ({}) }, + 'restore-cursor': () => {}, + 'terminal-link': (text, url, options) => options.fallback(), + 'pretty-bytes': value => `${value}B`, + 'pretty-ms': () => '', + 'term-img': () => '', + './colors': { + gray: value => value, + yellow: value => value, + label: value => value + } + }, + async () => { + const printText = loadFresh(path.join(process.cwd(), 'src/print-text.js')) + t.is( + printText.link('docs', 'https://microlink.io'), + 'https://microlink.io' + ) + t.is(printText.bytes(20), '20B') + } + ) +}) diff --git a/test/serialize-response-payload.js b/test/serialize-response-payload.js new file mode 100644 index 0000000..ab23e58 --- /dev/null +++ b/test/serialize-response-payload.js @@ -0,0 +1,105 @@ +'use strict' + +const test = require('ava') + +const serialize = require('../src/print-json') + +test('serialize request and response payload', t => { + const output = JSON.parse( + serialize({ + requestUrl: 'https://api.microlink.io?url=https://example.com', + requestOptions: { headers: { 'x-test': '1' }, responseType: 'json' }, + response: { + statusCode: 200, + headers: new Headers({ 'content-type': 'application/json' }), + url: 'https://api.microlink.io', + body: { status: 'success' } + } + }) + ) + + t.deepEqual(output, { + request: { + url: 'https://api.microlink.io?url=https://example.com', + headers: { 'x-test': '1' } + }, + response: { + statusCode: 200, + headers: { 'content-type': 'application/json' }, + url: 'https://api.microlink.io', + body: { status: 'success' } + } + }) +}) + +test('serialize headers from Headers constructor', t => { + const output = JSON.parse( + serialize({ + requestUrl: 'https://api.microlink.io?url=https://example.com', + requestOptions: {}, + response: { + headers: new Headers({ + 'content-type': 'application/json', + 'x-test': 'yes' + }) + } + }) + ) + + t.deepEqual(output.response.headers, { + 'content-type': 'application/json', + 'x-test': 'yes' + }) +}) + +test('mask api key by default', t => { + const output = JSON.parse( + serialize({ + requestUrl: 'https://api.microlink.io?url=https://example.com', + requestOptions: { + headers: { 'x-api-key': '1234567890' }, + responseType: 'json' + }, + response: { + headers: new Headers() + } + }) + ) + + t.is(output.request.headers['x-api-key'], '12345…') +}) + +test('show full api key in full mode', t => { + const output = JSON.parse( + serialize({ + requestUrl: 'https://api.microlink.io?url=https://example.com', + requestOptions: { + headers: { 'x-api-key': '1234567890' }, + responseType: 'json' + }, + response: { + headers: new Headers() + }, + full: true + }) + ) + + t.is(output.request.headers['x-api-key'], '1234567890') +}) + +test('serialize minified payload when pretty is false', t => { + const output = serialize({ + requestUrl: 'https://api.microlink.io?url=https://example.com', + requestOptions: { headers: {} }, + response: { + statusCode: 200, + headers: new Headers({ 'content-type': 'application/json' }), + url: 'https://api.microlink.io', + body: { status: 'success' } + }, + pretty: false + }) + + t.false(output.includes('\n')) + t.deepEqual(JSON.parse(output).response.body, { status: 'success' }) +}) diff --git a/test/util.js b/test/util.js new file mode 100644 index 0000000..05f50b6 --- /dev/null +++ b/test/util.js @@ -0,0 +1,109 @@ +'use strict' + +const path = require('path') +const test = require('ava') + +const { loadFresh, withModuleMocks } = require('./helpers/module-mocks') + +test.serial('humanize api key', t => { + const { humanizeApiKey } = require('../src/util') + t.is(humanizeApiKey('1234567890'), '12345…') +}) + +test.serial('stringify pretty', t => { + const { stringify } = require('../src/util') + const output = stringify({ hello: 'world' }, { pretty: true }) + + t.true(output.includes('\n')) + t.deepEqual(JSON.parse(output), { hello: 'world' }) +}) + +test.serial('stringify compact', t => { + const { stringify } = require('../src/util') + const output = stringify({ hello: 'world' }, { pretty: false }) + + t.false(output.includes('\n')) + t.is(output, '{"hello":"world"}') +}) + +test.serial('to plain headers', t => { + const { toPlainHeaders } = require('../src/util') + const output = toPlainHeaders(new Headers({ 'x-test': '1' })) + + t.deepEqual(output, { 'x-test': '1' }) +}) + +test.serial('to clipboard writes serialized payload', async t => { + let written + + await withModuleMocks( + { + clipboardy: { + writeSync: value => { + written = value + } + } + }, + async () => { + const utilPath = path.join(process.cwd(), 'src/util.js') + const { toClipboard } = loadFresh(utilPath) + toClipboard({ hello: 'world' }, { pretty: true }) + } + ) + + t.is(written, '{\n "hello": "world"\n}') +}) + +test.serial('hasColorizedOutput returns false when NO_COLOR is set', t => { + const { hasColorizedOutput } = require('../src/util') + const initialNoColor = process.env.NO_COLOR + + process.env.NO_COLOR = '1' + + t.false(hasColorizedOutput()) + + if (initialNoColor === undefined) delete process.env.NO_COLOR + else process.env.NO_COLOR = initialNoColor +}) + +test.serial('hasColorizedOutput returns false when FORCE_COLOR=0', t => { + const { hasColorizedOutput } = require('../src/util') + const initialNoColor = process.env.NO_COLOR + const initialForceColor = process.env.FORCE_COLOR + + delete process.env.NO_COLOR + process.env.FORCE_COLOR = '0' + + t.false(hasColorizedOutput()) + + if (initialNoColor === undefined) delete process.env.NO_COLOR + else process.env.NO_COLOR = initialNoColor + + if (initialForceColor === undefined) delete process.env.FORCE_COLOR + else process.env.FORCE_COLOR = initialForceColor +}) + +test.serial('hasColorizedOutput delegates to process.stdout.hasColors', t => { + const { hasColorizedOutput } = require('../src/util') + const initialNoColor = process.env.NO_COLOR + const initialForceColor = process.env.FORCE_COLOR + const initialHasColors = process.stdout.hasColors + + delete process.env.NO_COLOR + delete process.env.FORCE_COLOR + process.stdout.hasColors = () => true + + t.true(hasColorizedOutput()) + + process.stdout.hasColors = () => false + t.false(hasColorizedOutput()) + + if (initialHasColors === undefined) delete process.stdout.hasColors + else process.stdout.hasColors = initialHasColors + + if (initialNoColor === undefined) delete process.env.NO_COLOR + else process.env.NO_COLOR = initialNoColor + + if (initialForceColor === undefined) delete process.env.FORCE_COLOR + else process.env.FORCE_COLOR = initialForceColor +})