diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..404abb2 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +coverage/ diff --git a/.eslintrc.json b/.eslintrc.json index 3e5cf2d..f951df6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,5 +2,8 @@ "extends": "notninja/es8", "env": { "node": true + }, + "rules": { + "no-bitwise": "off" } } diff --git a/.gitignore b/.gitignore index 552f221..1dd4390 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ +.nyc_output/ +coverage/ node_modules/ *.log diff --git a/.npmignore b/.npmignore index 09cf02d..d1181e5 100644 --- a/.npmignore +++ b/.npmignore @@ -1,4 +1,6 @@ +coverage/ test/ .* AUTHORS.md +codecov.yml CONTRIBUTING.md diff --git a/.nycrc b/.nycrc new file mode 100644 index 0000000..f637097 --- /dev/null +++ b/.nycrc @@ -0,0 +1,9 @@ +{ + "check-coverage": true, + "lines": 95, + "statements": 95, + "functions": 95, + "branches": 95, + "include": [ "src/**/*.js" ], + "reporter": [ "lcov" ] +} diff --git a/.travis.yml b/.travis.yml index e85d686..ae5428d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,8 @@ node_js: - "8" script: - npm test +after_success: + - npm run coverage notifications: slack: rooms: diff --git a/README.md b/README.md index 0fb212b..147bc14 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Java's Native-to-ASCII Converter. [![Build Status](https://img.shields.io/travis/NotNinja/node-native2ascii/develop.svg?style=flat-square)](https://travis-ci.org/NotNinja/node-native2ascii) +[![Coverage](https://img.shields.io/codecov/c/github/NotNinja/node-native2ascii/develop.svg?style=flat-square)](https://codecov.io/gh/NotNinja/node-native2ascii) [![Dependency Status](https://img.shields.io/david/NotNinja/node-native2ascii.svg?style=flat-square)](https://david-dm.org/NotNinja/node-native2ascii) [![Dev Dependency Status](https://img.shields.io/david/dev/NotNinja/node-native2ascii.svg?style=flat-square)](https://david-dm.org/NotNinja/node-native2ascii?type=dev) [![License](https://img.shields.io/npm/l/node-native2ascii.svg?style=flat-square)](https://github.com/NotNinja/node-native2ascii/blob/master/LICENSE.md) @@ -42,11 +43,85 @@ $ npm install --global node-native2ascii ## CLI -TODO: Document CLI + Usage: native2ascii [options] [inputfile] [outputfile] + + + Options: + + -e, --encoding specify encoding to be used by the conversion procedure + -r, --reverse perform reverse operation + -V, --version output the version number + -h, --help output usage information + +Converts a file that is encoded to any character encoding that is +[supported by Node.js](https://nodejs.org/dist/latest-v8.x/docs/api/buffer.html#buffer_buffers_and_character_encodings) +(which can be controlled via the `encoding` command line option and defaults to `utf8`) to a file encoded in ASCII, +using Unicode escapes ("\uxxxx" notation) for all characters that are not part of the ASCII character set. + +This command is useful for properties files containing characters not in ISO-8859-1 character sets. + +A reverse conversion can be performed by passing the `reverse` command line option. + +If the `outputfile` command line argument is omitted, standard output is used for output. If, in addition, the +`inputfile` command line argument is omitted, standard input is used for input. + +### Examples + +Converts a UTF-8 encoded file into an file encoding in ASCII, Unicode escaping characters not in the ASCII character +set: + +``` bash +# Using file command line arguments: +$ native2ascii utf8.properties ascii.properties +# Using STDIN and STDOUT: +$ cat utf8.properties | native2ascii > ascii.properties +``` + +Converts a ASCII encoded file into a file encoded in UTF-8, unescaping any Unicode escapes: + +``` bash +# Using file command line arguments: +$ native2ascii --reverse ascii.properties utf8.properties +# Using STDIN and STDOUT: +$ cat ascii.properties | native2ascii --reverse > utf8.properties +``` ## API -TODO: Document API + native2ascii(input[, options]) + +Converts the specified `input` so that it can be encoded in ASCII by using Unicode escapes ("\uxxxx" notation) for all +characters that are not part of the ASCII character set. + +This function is useful for properties files containing characters not in ISO-8859-1 character sets. + +A reverse conversion can be performed by enabling the `reverse` option. + +### Options + +| Option | Description | Default | +| --------- | -------------------------------- | ------- | +| `reverse` | Whether to reverse the operation | `false` | + +### Examples + +Unicode escape characters not in the ASCII character set so that they can be safely written encoded into ASCII: + +``` javascript +const native2ascii = require('native2ascii'); + +native2ascii('I ♥ native2ascii!'); +//=> "I \u2665 native2ascii!" +``` + +These can be later unescaped by reversing the operation: + +``` javascript +const native2ascii = require('native2ascii'); + +native2ascii('I \u2665 native2ascii!', { reverse: true }); +//=> "I ♥ native2ascii!" +``` ## Bugs diff --git a/bin/native2ascii b/bin/native2ascii index 5e002f4..fdd26ff 100755 --- a/bin/native2ascii +++ b/bin/native2ascii @@ -24,15 +24,13 @@ 'use strict'; -const CLI = require('../src/cli'); +const { parse, writeError } = require('../src/cli'); (async() => { - const cli = new CLI(); - try { - await cli.parse(process.argv); + await parse(process.argv); } catch (e) { - cli.error(`native2ascii failed: ${e.stack}`); + writeError(`native2ascii failed: ${e.stack}`); process.exit(1); } diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..8dc7782 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,5 @@ +comment: off +coverage: + precision: 2 + range: "95...100" + round: down diff --git a/package.json b/package.json index 701da6a..0218518 100644 --- a/package.json +++ b/package.json @@ -25,24 +25,27 @@ "url": "https://github.com/NotNinja/node-native2ascii.git" }, "dependencies": { - "commander": "^2.12.2", - "debug": "^3.1.0", - "get-stdin": "^5.0.1", - "glob": "^7.1.2", - "iconv-lite": "^0.4.19" + "commander": "^2.12.2" }, "devDependencies": { "chai": "^4.1.2", + "codecov": "^3.0.0", "eslint": "^4.12.1", "eslint-config-notninja": "^0.2.3", - "mocha": "^4.0.1" + "mocha": "^4.0.1", + "nyc": "^11.3.0", + "sinon": "^4.1.3", + "tmp": "0.0.33" }, "bin": { "native2ascii": "bin/native2ascii" }, "main": "src/native2ascii.js", "scripts": { - "pretest": "eslint \"bin/native2ascii\" \"src/**/*.js\" \"test/**/*.js\"" + "coverage": "nyc report && codecov", + "pretest": "eslint \"bin/native2ascii\" \"src/**/*.js\" \"test/**/*.js\"", + "test": "nyc mocha -R list \"test/**/*.spec.js\"", + "posttest": "nyc check-coverage" }, "engines": { "node": ">=8" diff --git a/src/cli.js b/src/cli.js index feac286..e024e18 100644 --- a/src/cli.js +++ b/src/cli.js @@ -22,4 +22,246 @@ 'use strict'; -// TODO: Complete +const { Command } = require('commander'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const util = require('util'); + +const native2ascii = require('./native2ascii'); +const { version } = require('../package.json'); + +const readFile = util.promisify(fs.readFile); +const writeFile = util.promisify(fs.writeFile); + +/** + * Determines the character encodings to be used to read input and write output based on the specified + * command. + * + * This function will throw an error if command.encoding is specified but is not a valid character + * encoding. + * + * @param {Command} command - the Command for the parsed arguments + * @return {cli~Encodings} The character encodings to be used. + * @throws {Error} If command.encoding is specified but is invalid. + */ +function getEncodings(command) { + if (command.encoding && !Buffer.isEncoding(command.encoding)) { + throw new Error(`Invalid encoding: ${command.encoding}`); + } + + const asciiEncoding = 'latin1'; + const nativeEncoding = command.encoding || 'utf8'; + let inputEncoding; + let outputEncoding; + + if (command.reverse) { + inputEncoding = asciiEncoding; + outputEncoding = nativeEncoding; + } else { + inputEncoding = nativeEncoding; + outputEncoding = asciiEncoding; + } + + return { inputEncoding, outputEncoding }; +} + +/** + * Parses the command line arguments and performs the necessary operation. + * + * The primary operation is to convert a file that is encoded to any character encoding that is supported by Node.js + * (which can be controlled via the "encoding" command line option) to a file encoded in ASCII, using Unicode escapes + * ("\uxxxx" notation) for all characters that are not part of the ASCII character set. + * + * This command is useful for properties files containing characters not in ISO-8859-1 character sets. + * + * A reverse conversion can be performed by passing the "reverse" command line option. + * + * If the "outputfile" command line argument is omitted, standard output is used for output. If, in addition, the + * "inputfile" command line argument is omitted, standard input is used for input. + * + * The Node.js process may exist as a result if calling this function, depending on how argv are parsed. + * + * Optionally, options can be specified for additional control, however, this is primarily intended for + * testing purposes only. + * + * @param {string[]} argv - the command line arguments to be parsed + * @param {?cli~Options} [options] - the options to be used (may be null) + * @return {Promise.} A Promise that is resolved once the conversion operation has completed, + * if needed. + */ +async function parse(argv, options) { + options = parseOptions(options); + + const command = new Command('native2ascii') + .arguments('[inputfile] [outputfile]') + .option('-e, --encoding ', 'specify encoding to be used by the conversion procedure') + .option('-r, --reverse', 'perform reverse operation') + .version(version) + .parse(argv); + + const native2asciiOptions = { reverse: Boolean(command.reverse) }; + const { inputEncoding, outputEncoding } = getEncodings(command); + const [ inputFile, outputFile ] = command.args; + const input = await readInput(inputFile, inputEncoding, options); + const output = native2ascii(input, native2asciiOptions); + + await writeOutput(output, outputFile, outputEncoding, options); +} + +/** + * Parses the specified options, using default values where needed. + * + * This function does not modify options but, instead, returns a new object based on it. + * + * @param {?cli~Options} options - the options to be parsed (may be null) + * @return {cli~Options} The parsed options. + */ +function parseOptions(options) { + return Object.assign({ + cwd: process.cwd(), + eol: os.EOL, + stderr: process.stderr, + stdin: process.stdin, + stdout: process.stdout + }, options); +} + +/** + * Reads the input to be converted as a string using the character encoding provided. + * + * If file is specified, its contents are read as the input. Otherwise, the input is read directly from + * STDIN. + * + * @param {?string} file - the input file to be read (may be null) + * @param {string} encoding - the character encoding to be used to read the input + * @param {cli~Options} options - the options to be used + * @return {Promise.} A Promise that is resolved with the input to be converted. + */ +async function readInput(file, encoding, options) { + let buffer; + if (file) { + buffer = await readFile(path.resolve(options.cwd, file)); + } else { + buffer = await readStdin(options); + } + + return buffer.toString(encoding); +} + +/** + * Reads the input from STDIN. + * + * @param {cli~Options} options - the options to be used + * @return {Promise.} A Promise that is resolved with the input read from STDIN. + */ +function readStdin(options) { + const { stdin } = options; + const data = []; + let length = 0; + + return new Promise((resolve, reject) => { + if (stdin.isTTY) { + resolve(Buffer.alloc(0)); + } else { + stdin.on('error', (error) => { + reject(error); + }); + + stdin.on('readable', () => { + let chunk; + + while ((chunk = stdin.read()) != null) { + data.push(chunk); + length += chunk.length; + } + }); + + stdin.on('end', () => { + resolve(Buffer.concat(data, length)); + }); + } + }); +} + +/** + * Writes the specified message to STDERR. + * + * @param {string} message - the error message to be written + * @param {?cli~Options} [options] - the options to be used (may be null) + * @return {void} + */ +function writeError(message, options) { + options = parseOptions(options); + + options.stderr.write(`${message}${options.eol}`); +} + +/** + * Writes the specified output as a string using the character encoding provided. + * + * If file is specified, output will be written to it. Otherwise, output is + * written directly to STDOUT. + * + * @param {string} output - the output to be written + * @param {?string} file - the file to which output should be written (may be null) + * @param {string} encoding - the character encoding to be used to write output + * @param {cli~Options} options - the options to be used + * @return {Promise.} A Promise that is resolved once output has be written. + */ +async function writeOutput(output, file, encoding, options) { + if (file) { + await writeFile(path.resolve(options.cwd, file), output, encoding); + } else { + await writeStdout(output, encoding, options); + } +} + +/** + * Writes the specified output to STDOUT using the character encoding provided. + * + * @param {string} output - the output to be written to STDOUT + * @param {string} encoding - the character encoding to be used to write output + * @param {cli~Options} options - the options to be used + * @return {Promise.} A Promise that is resolved once output has be written to + * STDOUT. + */ +function writeStdout(output, encoding, options) { + const { stdout } = options; + + return new Promise((resolve, reject) => { + stdout.on('error', (error) => { + reject(error); + }); + + stdout.on('finish', () => { + resolve(); + }); + + stdout.end(output, encoding); + }); +} + +module.exports = { + parse, + writeError +}; + +/** + * Contains the character encodings to be used when reading input and writing output. + * + * @typedef {Object} cli~Encodings + * @property {string} inputEncoding - The character encoding to be used when reading input. + * @property {string} outputEncoding - The character encoding to be used when writing output. + */ + +/** + * The options that can be passed to {@link parse}. + * + * @typedef {Object} cli~Options + * @property {string} [cwd=process.cwd()] - The current working directory to be used. + * @property {string} [eol=os.EOL] - The end-of-line character to be used. + * @property {Writable} [stderr=process.stderr] - The stream to which standard errors may be written. + * @property {Readable} [stdin=process.stdin] - The stream from which standard input may be read. + * @property {Writable} [stdout=process.stdout] - The stream to which standard output may be written. + */ diff --git a/src/native2ascii.js b/src/native2ascii.js index 128c997..beaef4d 100644 --- a/src/native2ascii.js +++ b/src/native2ascii.js @@ -22,13 +22,44 @@ 'use strict'; -// TODO: Complete - const { escape, unescape } = require('./unicode'); -// TODO: Document -function native2ascii(str, options) { - // TODO: Complete +/** + * Converts the specified input so that it can be encoded in ASCII by using Unicode escapes ("\uxxxx" + * notation) for all characters that are not part of the ASCII character set. + * + * This function is useful for properties files containing characters not in ISO-8859-1 character sets. + * + * A reverse conversion can be performed by enabling the reverse option. + * + * This function will throw an error when performing a reverse conversion if input contains a malformed + * Unicode escape. + * + * @param {?string} input - the string to be converted (may be null) + * @param {?native2ascii~Options} [options] - the options to be used (may be null) + * @return {?string} The converted output from input or + * null if input is null. + * @throws {Error} If the reverse option is enabled and input contains a malformed Unicode + * escape. + */ +function native2ascii(input, options) { + if (input == null) { + return input; + } + + options = Object.assign({ reverse: false }, options); + + const converter = options.reverse ? unescape : escape; + + return converter(input); } module.exports = native2ascii; + +/** + * The options that can be passed to {@link native2ascii}. + * + * @typedef {Object} native2ascii~Options + * @property {?boolean} [reverse] - true to reverse the operation; otherwise false. May be + * null. + */ diff --git a/src/unicode/escape.js b/src/unicode/escape.js index a6d76fa..73ea3dd 100644 --- a/src/unicode/escape.js +++ b/src/unicode/escape.js @@ -22,11 +22,51 @@ 'use strict'; -// TODO: Complete +const hexDigits = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' ]; -// TODO: Document -function escape(str) { - // TODO: Complete +/** + * Converts all Unicode characters within the specifed input to Unicode escapes ("\uxxxx" notation). + * + * @param {string} input - the string to be converted + * @return {string} The converted output from input. + */ +function escape(input) { + let result = ''; + + for (let i = 0, length = input.length; i < length; i++) { + const code = input.charCodeAt(i); + + if (code > 0x7f) { + result += `\\u${toHex(code)}`; + } else { + result += input.charAt(i); + } + } + + return result; +} + +/** + * Converts the specified character code to a hexadecimal value. + * + * @param {number} code - the character code to be converted + * @return {string} The 4-digit hexadecimal string. + */ +function toHex(code) { + return toHexDigit((code >> 12) & 15) + + toHexDigit((code >> 8) & 15) + + toHexDigit((code >> 4) & 15) + + toHexDigit(code & 15); +} + +/** + * Converts the specified nibble to a hexadecimal digit. + * + * @param {number} nibble - the nibble to be converted + * @return {string} The single-digit hexadecimal string. + */ +function toHexDigit(nibble) { + return hexDigits[nibble & 15]; } module.exports = escape; diff --git a/src/unicode/unescape.js b/src/unicode/unescape.js index 3239e2a..348055d 100644 --- a/src/unicode/unescape.js +++ b/src/unicode/unescape.js @@ -22,11 +22,98 @@ 'use strict'; -// TODO: Complete +/* eslint complexity: "off" */ -// TODO: Document -function unescape(str) { - // TODO: Complete +/** + * Converts all Unicode escapes ("\uxxxx" notation) within the specified input into their corresponding + * Unicode characters. + * + * This function will throw an error if input contains a malformed Unicode escape. + * + * @param {string} input - the string to be converted + * @return {string} The converted output from input. + * @throws {Error} If input contains a malformed Unicode escape. + */ +function unescape(input) { + let result = ''; + + for (let i = 0, length = input.length; i < length; i++) { + let ch = input.charAt(i); + + if (ch === '\\') { + ch = input.charAt(++i); + + if (ch === 'u') { + result += getUnicode(input, i + 1); + i += 4; + } else { + result += `\\${ch}`; + } + } else { + result += ch; + } + } + + return result; +} + +/** + * Attempts to convert the Unicode escape within input at the specified offset. + * + * offset should be the index of the first character after the "\u" prefix of the Unicode escape and will + * result in the offset being increased as it reads in the next four characters within input. + * + * This function will throw an error if the hexadecimal value corresponding to the Unicode escape at the specified + * offset is malformed. + * + * @param {string} input - the string to be converted + * @param {number} offset - the offset of the hexadecimal segment of the Unicode escape from which the Unicode character + * is to be derived relative to input + * @return {string} The Unicode character converted from the escape at offset within input. + * @throws {Error} If the Unicode escape is malformed. + */ +function getUnicode(input, offset) { + let unicode = 0; + + for (let i = offset, end = offset + 4; i < end; i++) { + const ch = input.charAt(i); + const code = ch.charCodeAt(0); + + switch (ch) { + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + unicode = (unicode << 4) + code - 0x30; + break; + case 'A': + case 'B': + case 'C': + case 'D': + case 'E': + case 'F': + unicode = (unicode << 4) + 10 + code - 0x41; + break; + case 'a': + case 'b': + case 'c': + case 'd': + case 'e': + case 'f': + unicode = (unicode << 4) + 10 + code - 0x61; + break; + default: + throw new Error(`Malformed character found in \\uxxxx encoding: ${ch}`); + } + } + + return String.fromCharCode(unicode); } module.exports = unescape; diff --git a/test/.eslintrc.json b/test/.eslintrc.json new file mode 100644 index 0000000..ba3ed6c --- /dev/null +++ b/test/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "../.eslintrc.json", + "env": { + "mocha": true + } +} diff --git a/test/cli.spec.js b/test/cli.spec.js index feac286..8e40535 100644 --- a/test/cli.spec.js +++ b/test/cli.spec.js @@ -22,4 +22,454 @@ 'use strict'; -// TODO: Complete +const { expect } = require('chai'); +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +const { Readable, Writable } = require('stream'); +const tmp = require('tmp'); +const util = require('util'); + +const cli = require('../src/cli'); +const { version } = require('../package.json'); + +const readFile = util.promisify(fs.readFile); + +describe('cli', () => { + class MockReadable extends Readable { + + constructor(options) { + super(options); + + this.buffer = Buffer.alloc(0); + this.error = null; + this._bufferRead = false; + } + + _read() { + if (this.error) { + this.emit('error', this.error); + } + + if (this.buffer.length === 0) { + this._bufferRead = true; + } + + if (this._bufferRead) { + this.push(null); + } else { + this.push(this.buffer); + + this._bufferRead = true; + } + } + + } + + class MockWritable extends Writable { + + constructor(options) { + super(options); + + this.buffer = Buffer.alloc(0); + this.error = null; + this._length = 0; + } + + _write(chunk, encoding, callback) { + if (this.error) { + return callback(this.error); + } + + this._length += chunk.length; + this.buffer = Buffer.concat([ this.buffer, Buffer.from(chunk, encoding) ], this._length); + + return callback(); + } + + } + + let options; + + beforeEach(() => { + options = { + cwd: __dirname, + eol: '\n', + stderr: new MockWritable(), + stdin: new MockReadable(), + stdout: new MockWritable() + }; + }); + + describe('.parse', () => { + context('when no files are included in argv', () => { + it('should escape all non-ASCII characters read from STDIN and write to STDOUT', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/unescaped/utf8.txt')); + const expected = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-utf8.txt')); + + options.stdin.buffer = input; + + await cli.parse([ null, null ], options); + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + + context('and STDIN is empty', () => { + it('should write empty buffer to STDOUT', async() => { + const expected = Buffer.alloc(0); + + await cli.parse([ null, null ], options); + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + }); + + context('and STDIN is TTY', () => { + it('should write empty buffer to STDOUT', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/unescaped/utf8.txt')); + const expected = Buffer.alloc(0); + + options.stdin.buffer = input; + options.stdin.isTTY = true; + + await cli.parse([ null, null ], options); + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + }); + + context('and failed to read from STDIN', () => { + it('should throw an error', async() => { + const expected = Buffer.alloc(0); + const expectedError = new Error('foo'); + + options.stdin.error = expectedError; + + try { + await cli.parse([ null, null ], options); + // Should have thrown + expect.fail(); + } catch (e) { + expect(e).to.equal(expectedError); + } + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + }); + + context('and failed to write to STDOUT', () => { + it('should throw an error', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/unescaped/utf8.txt')); + const expected = Buffer.alloc(0); + const expectedError = new Error('foo'); + + options.stdin.buffer = input; + options.stdout.error = expectedError; + + try { + await cli.parse([ null, null ], options); + // Should have thrown + expect.fail(); + } catch (e) { + expect(e).to.equal(expectedError); + } + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + }); + + context('and --encoding option is included in argv', () => { + it('should escape all non-ASCII characters read from STDIN and write to STDOUT', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/unescaped/latin1.txt')); + const expected = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-latin1.txt')); + + options.stdin.buffer = input; + + await cli.parse([ + null, null, + '--encoding', 'latin1' + ], options); + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + + context('and value is invalid', () => { + it('should throw an error', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/unescaped/latin1.txt')); + const expected = Buffer.alloc(0); + + options.stdin.buffer = input; + + try { + await cli.parse([ + null, null, + '--encoding', 'foo' + ], options); + // Should have thrown + expect.fail(); + } catch (e) { + expect(e).to.be.an('error'); + expect(e.message).to.equal('Invalid encoding: foo'); + } + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + }); + }); + + context('and --reverse option is included in argv', () => { + it('should unescape all escaped unicode values read from STDIN and write to STDOUT', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-utf8.txt')); + const expected = await readFile(path.resolve(__dirname, './fixtures/unescaped/utf8.txt')); + + options.stdin.buffer = input; + + await cli.parse([ + null, null, + '--reverse' + ], options); + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + }); + + context('and both --encoding and --reverse options are included in argv', () => { + it('should unescape all escaped unicode values read from STDIN and write to STDOUT', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-latin1.txt')); + const expected = await readFile(path.resolve(__dirname, './fixtures/unescaped/latin1.txt')); + + options.stdin.buffer = input; + + await cli.parse([ + null, null, + '--encoding', 'latin1', + '--reverse' + ], options); + + expect(options.stdout.buffer).to.deep.equal(expected); + }); + }); + }); + + context('when input and output files are included in argv', () => { + let outputFile; + let removeOutputFile; + + beforeEach((done) => { + tmp.file((error, filePath, fd, cleanUp) => { + if (error) { + done(error); + } else { + outputFile = filePath; + removeOutputFile = cleanUp; + + done(); + } + }); + }); + + afterEach(() => { + removeOutputFile(); + }); + + it('should escape all non-ASCII characters read from input file and write to output file', async() => { + await cli.parse([ + null, null, + './fixtures/unescaped/utf8.txt', + outputFile + ], options); + + const actual = await readFile(outputFile); + const expected = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-utf8.txt')); + + expect(actual).to.deep.equal(expected); + }); + + context('and input file is empty', () => { + it('should write empty buffer to output file', async() => { + await cli.parse([ + null, null, + './fixtures/empty.txt', + outputFile + ], options); + + const actual = await readFile(outputFile); + const expected = Buffer.alloc(0); + + expect(actual).to.deep.equal(expected); + }); + }); + + context('and --encoding option is included in argv', () => { + it('should escape all non-ASCII characters read from input file and write to output file', async() => { + await cli.parse([ + null, null, + '--encoding', 'latin1', + './fixtures/unescaped/latin1.txt', + outputFile + ], options); + + const actual = await readFile(outputFile); + const expected = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-latin1.txt')); + + expect(actual).to.deep.equal(expected); + }); + + context('and value is invalid', () => { + it('should throw an error', async() => { + try { + await cli.parse([ + null, null, + '--encoding', 'foo', + './fixtures/unescaped/latin1.txt', + outputFile + ], options); + // Should have thrown + expect.fail(); + } catch (e) { + expect(e).to.be.an('error'); + expect(e.message).to.equal('Invalid encoding: foo'); + } + + const actual = await readFile(outputFile); + const expected = Buffer.alloc(0); + + expect(actual).to.deep.equal(expected); + }); + }); + }); + + context('and --reverse option is included in argv', () => { + it('should unescape all escaped unicode values read from imput file and write to output file', async() => { + await cli.parse([ + null, null, + '--reverse', + './fixtures/escaped/latin1-from-utf8.txt', + outputFile + ], options); + + const actual = await readFile(outputFile); + const expected = await readFile(path.resolve(__dirname, './fixtures/unescaped/utf8.txt')); + + expect(actual).to.deep.equal(expected); + }); + }); + + context('and both --encoding and --reverse options are included in argv', () => { + it('should unescape all escaped unicode values read from imput file and write to output file', async() => { + await cli.parse([ + null, null, + '--encoding', 'latin1', + '--reverse', + './fixtures/escaped/latin1-from-latin1.txt', + outputFile + ], options); + + const actual = await readFile(outputFile); + const expected = await readFile(path.resolve(__dirname, './fixtures/unescaped/latin1.txt')); + + expect(actual).to.deep.equal(expected); + }); + }); + }); + + context('when --help option is included in argv', () => { + function cleanUp() { + if (process.exit.restore) { + process.exit.restore(); + } + if (process.stdout.write.restore) { + process.stdout.write.restore(); + } + } + + beforeEach(() => { + sinon.stub(process, 'exit'); + sinon.stub(process.stdout, 'write'); + }); + + afterEach(cleanUp); + + it('should print help information and exit', async() => { + process.exit.throws(); + + try { + await cli.parse([ + null, null, + '--help' + ], options); + // Stubbed process.exit should have thrown + expect.fail(); + } catch (e) { + expect(process.stdout.write.callCount).to.equal(1); + expect(process.stdout.write.getCall(0).args).to.deep.equal([ + ` + Usage: native2ascii [options] [inputfile] [outputfile] + + + Options: + + -e, --encoding specify encoding to be used by the conversion procedure + -r, --reverse perform reverse operation + -V, --version output the version number + -h, --help output usage information +` + ]); + expect(process.exit.callCount).to.be.at.least(1); + expect(process.exit.getCall(0).args).to.deep.equal([ 0 ]); + } finally { + cleanUp(); + } + }); + }); + + context('when --version option is included in argv', () => { + function cleanUp() { + if (process.exit.restore) { + process.exit.restore(); + } + if (process.stdout.write.restore) { + process.stdout.write.restore(); + } + } + + beforeEach(() => { + sinon.stub(process, 'exit'); + sinon.stub(process.stdout, 'write'); + }); + + afterEach(cleanUp); + + it('should print version and exit', async() => { + process.exit.throws(); + + try { + await cli.parse([ + null, null, + '--version' + ], options); + // Stubbed process.exit should have thrown + expect.fail(); + } catch (e) { + expect(process.stdout.write.callCount).to.equal(1); + expect(process.stdout.write.getCall(0).args).to.deep.equal([ + `${version} +` + ]); + expect(process.exit.callCount).to.be.at.least(1); + expect(process.exit.getCall(0).args).to.deep.equal([ 0 ]); + } finally { + cleanUp(); + } + }); + }); + }); + + describe('.writeError', () => { + it('should write message to stderr', () => { + cli.writeError('foo', options); + + expect(options.stderr.buffer.toString()).to.equal('foo\n'); + }); + }); +}); diff --git a/test/fixtures/empty.txt b/test/fixtures/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/escaped/latin1-from-ascii.txt b/test/fixtures/escaped/latin1-from-ascii.txt new file mode 100644 index 0000000..b01f757 Binary files /dev/null and b/test/fixtures/escaped/latin1-from-ascii.txt differ diff --git a/test/fixtures/escaped/latin1-from-latin1.txt b/test/fixtures/escaped/latin1-from-latin1.txt new file mode 100644 index 0000000..1b5fd8f Binary files /dev/null and b/test/fixtures/escaped/latin1-from-latin1.txt differ diff --git a/test/fixtures/escaped/latin1-from-utf8.txt b/test/fixtures/escaped/latin1-from-utf8.txt new file mode 100644 index 0000000..a2f39e9 Binary files /dev/null and b/test/fixtures/escaped/latin1-from-utf8.txt differ diff --git a/test/fixtures/unescaped/ascii.txt b/test/fixtures/unescaped/ascii.txt new file mode 100644 index 0000000..b01f757 Binary files /dev/null and b/test/fixtures/unescaped/ascii.txt differ diff --git a/test/fixtures/unescaped/latin1.txt b/test/fixtures/unescaped/latin1.txt new file mode 100644 index 0000000..7c0b4e5 Binary files /dev/null and b/test/fixtures/unescaped/latin1.txt differ diff --git a/test/fixtures/unescaped/utf8.txt b/test/fixtures/unescaped/utf8.txt new file mode 100644 index 0000000..f589393 Binary files /dev/null and b/test/fixtures/unescaped/utf8.txt differ diff --git a/test/native2ascii.spec.js b/test/native2ascii.spec.js index feac286..0739dfa 100644 --- a/test/native2ascii.spec.js +++ b/test/native2ascii.spec.js @@ -22,4 +22,84 @@ 'use strict'; -// TODO: Complete +const { expect } = require('chai'); +const fs = require('fs'); +const path = require('path'); +const util = require('util'); + +const native2ascii = require('../src/native2ascii'); + +const readFile = util.promisify(fs.readFile); + +describe('native2ascii', () => { + context('when input is null', () => { + it('should return null', () => { + expect(native2ascii(null)).to.equal(null); + }); + }); + + context('when input is undefined', () => { + it('should return undefined', () => { + /* eslint-disable no-undefined */ + expect(native2ascii(undefined)).to.equal(undefined); + /* eslint-enable no-undefined */ + }); + }); + + context('when no options are specified', () => { + it('should escape all non-ASCII characters within input', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/unescaped/utf8.txt'), 'utf8'); + const expected = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-utf8.txt'), 'latin1'); + const actual = native2ascii(input); + + expect(actual).to.equal(expected); + }); + + context('and input is empty', () => { + it('should return an empty string', () => { + const expected = ''; + const actual = native2ascii(''); + + expect(actual).to.equal(expected); + }); + }); + }); + + context('when "reverse" option is disabled', () => { + it('should escape all non-ASCII characters within input', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/unescaped/utf8.txt'), 'utf8'); + const expected = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-utf8.txt'), 'latin1'); + const actual = native2ascii(input); + + expect(actual).to.equal(expected, { reverse: false }); + }); + + context('and input is empty', () => { + it('should return an empty string', () => { + const expected = ''; + const actual = native2ascii('', { reverse: false }); + + expect(actual).to.equal(expected); + }); + }); + }); + + context('when "reverse" option is enabled', () => { + it('should unescape all escaped unicode values within input', async() => { + const input = await readFile(path.resolve(__dirname, './fixtures/escaped/latin1-from-utf8.txt'), 'latin1'); + const expected = await readFile(path.resolve(__dirname, './fixtures/unescaped/utf8.txt'), 'utf8'); + const actual = native2ascii(input, { reverse: true }); + + expect(actual).to.equal(expected); + }); + + context('and input is empty', () => { + it('should return an empty string', () => { + const expected = ''; + const actual = native2ascii('', { reverse: true }); + + expect(actual).to.equal(expected); + }); + }); + }); +}); diff --git a/test/unicode/escape.spec.js b/test/unicode/escape.spec.js index feac286..60d14a6 100644 --- a/test/unicode/escape.spec.js +++ b/test/unicode/escape.spec.js @@ -22,4 +22,46 @@ 'use strict'; -// TODO: Complete +const { expect } = require('chai'); +const fs = require('fs'); +const path = require('path'); +const util = require('util'); + +const escape = require('../../src/unicode/escape'); + +const readFile = util.promisify(fs.readFile); + +describe('unicode/escape', () => { + it('should not escape any characters within ASCII character set', async() => { + const input = await readFile(path.resolve(__dirname, '../fixtures/unescaped/ascii.txt'), 'ascii'); + const expected = await readFile(path.resolve(__dirname, '../fixtures/escaped/latin1-from-ascii.txt'), 'ascii'); + const actual = escape(input); + + expect(actual).to.equal(expected); + }); + + it('should only escape non-ASCII characters within ISO-8859-1 character set', async() => { + const input = await readFile(path.resolve(__dirname, '../fixtures/unescaped/latin1.txt'), 'latin1'); + const expected = await readFile(path.resolve(__dirname, '../fixtures/escaped/latin1-from-latin1.txt'), 'latin1'); + const actual = escape(input); + + expect(actual).to.equal(expected); + }); + + it('should only escape non-ASCII characters within UTF-8 character set', async() => { + const input = await readFile(path.resolve(__dirname, '../fixtures/unescaped/utf8.txt'), 'utf8'); + const expected = await readFile(path.resolve(__dirname, '../fixtures/escaped/latin1-from-utf8.txt'), 'latin1'); + const actual = escape(input); + + expect(actual).to.equal(expected); + }); + + context('when input is empty', () => { + it('should return empty string', () => { + const expected = ''; + const actual = escape(''); + + expect(actual).to.equal(expected); + }); + }); +}); diff --git a/test/unicode/index.spec.js b/test/unicode/index.spec.js index feac286..ecdd69a 100644 --- a/test/unicode/index.spec.js +++ b/test/unicode/index.spec.js @@ -22,4 +22,15 @@ 'use strict'; -// TODO: Complete +const { expect } = require('chai'); + +const escape = require('../../src/unicode/escape'); +const index = require('../../src/unicode/index'); +const unescape = require('../../src/unicode/unescape'); + +describe('unicode/index', () => { + it('should export correct functions', () => { + expect(index.escape).to.equal(escape); + expect(index.unescape).to.equal(unescape); + }); +}); diff --git a/test/unicode/unescape.spec.js b/test/unicode/unescape.spec.js index feac286..9fe706a 100644 --- a/test/unicode/unescape.spec.js +++ b/test/unicode/unescape.spec.js @@ -22,4 +22,61 @@ 'use strict'; -// TODO: Complete +const { expect } = require('chai'); +const fs = require('fs'); +const path = require('path'); +const util = require('util'); + +const unescape = require('../../src/unicode/unescape'); + +const readFile = util.promisify(fs.readFile); + +describe('unicode/unescape', () => { + it('should not unescape any characters within ASCII character set', async() => { + const input = await readFile(path.resolve(__dirname, '../fixtures/escaped/latin1-from-ascii.txt'), 'ascii'); + const expected = await readFile(path.resolve(__dirname, '../fixtures/unescaped/ascii.txt'), 'ascii'); + const actual = unescape(input); + + expect(actual).to.equal(expected); + }); + + it('should only unescape escaped unicode values within ISO-8859-1 character set', async() => { + const input = await readFile(path.resolve(__dirname, '../fixtures/escaped/latin1-from-latin1.txt'), 'latin1'); + const expected = await readFile(path.resolve(__dirname, '../fixtures/unescaped/latin1.txt'), 'latin1'); + const actual = unescape(input); + + expect(actual).to.equal(expected); + }); + + it('should only unescape escaped unicode values within UTF-8 character set', async() => { + const input = await readFile(path.resolve(__dirname, '../fixtures/escaped/latin1-from-utf8.txt'), 'utf8'); + const expected = await readFile(path.resolve(__dirname, '../fixtures/unescaped/utf8.txt'), 'utf8'); + const actual = unescape(input); + + expect(actual).to.equal(expected); + }); + + it('should ignore case when unescaping escaped unicode values', () => { + const expected = '\u001a\u001b\u001c\u001d\u001e\u001f'; + const actual = unescape('\\u001A\\u001B\\u001C\\u001D\\u001E\\u001F'); + + expect(actual).to.equal(expected); + }); + + context('when input is empty', () => { + it('should return empty string', () => { + const expected = ''; + const actual = unescape(''); + + expect(actual).to.equal(expected); + }); + }); + + context('when input contains invalid escaped unicode value', () => { + it('should throw an error', () => { + expect(() => { + unescape('\\u00ah'); + }).to.throw(Error, 'Malformed character found in \\uxxxx encoding: h'); + }); + }); +});