From a5d5baa07b1be4221f134703d4d4a573c2d46072 Mon Sep 17 00:00:00 2001 From: James Talmage Date: Sat, 9 Jan 2016 16:43:34 -0500 Subject: [PATCH] Prevent log corruption when using mini reporter. Fixes #392. This prevents interference between the mini logger and child processes that use `console.log`. The mini reporter previously used logUpdate which deletes lines previously written with the logUpdate API before writing a new one. This caused problems if lines from console.log were written inbetween logUpdate calls. It would delete the users log messages instead of the test status line. To fix this, we store the last written last log line, clear it, write the users log output, then restore the last log line. This keeps the miniReporter output always at the bottom of the log output. It also fixes an incorrect use of the `child_process` API. We were using the `stdio` option for `child_process.fork`, but that option is ignored (it is honored for just about every other method in that API). See: https://github.com/nodejs/node/blob/7b355c5bb30d7f9b38654fdb50e58dbd1dcd990a/lib/child_process.js#L49-L50 It also adds a set of visual tests which can be run via `npm run visual`. They must be run manually, and should be run as part of our pre-release process. --- api.js | 8 +- cli.js | 3 + lib/fork.js | 23 ++++- lib/logger.js | 16 ++++ lib/reporters/mini.js | 91 ++++++++++++++++-- lib/reporters/tap.js | 4 + lib/reporters/verbose.js | 4 + lib/test-worker.js | 21 +++- package.json | 11 ++- test/reporters/mini.js | 51 +++++----- test/visual/console-log.js | 17 ++++ test/visual/lorem-ipsum.js | 13 +++ test/visual/lorem-ipsum.txt | 5 + test/visual/print-lorem-ipsum.js | 33 +++++++ test/visual/run-visual-tests.js | 106 +++++++++++++++++++++ test/visual/stdout-write.js | 23 +++++ test/visual/text-ends-at-terminal-width.js | 39 ++++++++ 17 files changed, 419 insertions(+), 49 deletions(-) create mode 100644 test/visual/console-log.js create mode 100644 test/visual/lorem-ipsum.js create mode 100644 test/visual/lorem-ipsum.txt create mode 100644 test/visual/print-lorem-ipsum.js create mode 100644 test/visual/run-visual-tests.js create mode 100644 test/visual/stdout-write.js create mode 100644 test/visual/text-ends-at-terminal-width.js diff --git a/api.js b/api.js index 50462a21c7..3490c8c4d3 100644 --- a/api.js +++ b/api.js @@ -55,7 +55,13 @@ Api.prototype._runFile = function (file) { .on('stats', this._handleStats) .on('test', this._handleTest) .on('unhandledRejections', this._handleRejections) - .on('uncaughtException', this._handleExceptions); + .on('uncaughtException', this._handleExceptions) + .on('stdout', this._handleOutput.bind(this, 'stdout')) + .on('stderr', this._handleOutput.bind(this, 'stderr')); +}; + +Api.prototype._handleOutput = function (channel, data) { + this.emit(channel, data); }; Api.prototype._handleRejections = function (data) { diff --git a/cli.js b/cli.js index 78a6521d88..d72ef8f9b7 100755 --- a/cli.js +++ b/cli.js @@ -104,6 +104,9 @@ logger.start(); api.on('test', logger.test); api.on('error', logger.unhandledError); +api.on('stdout', logger.stdout); +api.on('stderr', logger.stderr); + api.run() .then(function () { logger.finish(); diff --git a/lib/fork.js b/lib/fork.js index 6660108655..bf1b89461b 100644 --- a/lib/fork.js +++ b/lib/fork.js @@ -8,12 +8,17 @@ var AvaError = require('./ava-error'); var send = require('./send'); module.exports = function (file, opts) { - opts = objectAssign({file: file}, opts); + opts = objectAssign({ + file: file, + tty: process.stdout.isTTY ? { + columns: process.stdout.columns, + rows: process.stdout.rows + } : false + }, opts); var ps = childProcess.fork(path.join(__dirname, 'test-worker.js'), [JSON.stringify(opts)], { cwd: path.dirname(file), - stdio: ['ignore', process.stderr, process.stderr], - silent: opts.silent + silent: true }); var relFile = path.relative('.', file); @@ -77,6 +82,18 @@ module.exports = function (file, opts) { send(ps, 'teardown'); }); + ps.stdout.on('data', function (data) { + if (!opts.silent) { + ps.emit('stdout', data); + } + }); + + ps.stderr.on('data', function (data) { + if (!opts.silent) { + ps.emit('stderr', data); + } + }); + promise.on = function () { ps.on.apply(ps, arguments); diff --git a/lib/logger.js b/lib/logger.js index 00d40a88cb..930c95d0ac 100644 --- a/lib/logger.js +++ b/lib/logger.js @@ -53,6 +53,22 @@ Logger.prototype.write = function (str) { this.reporter.write(str); }; +Logger.prototype.stdout = function (data) { + if (!this.reporter.stdout) { + return; + } + + this.reporter.stdout(data); +}; + +Logger.prototype.stderr = function (data) { + if (!this.reporter.stderr) { + return; + } + + this.reporter.stderr(data); +}; + Logger.prototype.exit = function (code) { // TODO: figure out why this needs to be here to // correctly flush the output when multiple test files diff --git a/lib/reporters/mini.js b/lib/reporters/mini.js index 9cf79eca77..d1f402698e 100644 --- a/lib/reporters/mini.js +++ b/lib/reporters/mini.js @@ -1,7 +1,9 @@ 'use strict'; -var logUpdate = require('log-update'); -var colors = require('../colors'); +var cliCursor = require('cli-cursor'); +var lastLineTracker = require('last-line-stream/tracker'); +var StringDecoder = require('string_decoder').StringDecoder; var plur = require('plur'); +var colors = require('../colors'); var beautifyStack = require('../beautify-stack'); function MiniReporter() { @@ -14,7 +16,11 @@ function MiniReporter() { this.skipCount = 0; this.rejectionCount = 0; this.exceptionCount = 0; - this.finished = false; + this.currentStatus = ''; + this.statusLineCount = 0; + this.lastLineTracker = lastLineTracker(); + this.stream = process.stderr; + this.stringDecoder = new StringDecoder(); } module.exports = MiniReporter; @@ -24,7 +30,7 @@ MiniReporter.prototype.start = function () { }; MiniReporter.prototype.test = function (test) { - var status = '\n'; + var status = ''; var title; if (test.skip) { @@ -61,9 +67,7 @@ MiniReporter.prototype.unhandledError = function (err) { }; MiniReporter.prototype.finish = function () { - this.finished = true; - - var status = '\n'; + var status = ''; if (this.passCount > 0) { status += ' ' + colors.pass(this.passCount, 'passed'); @@ -134,9 +138,76 @@ MiniReporter.prototype.finish = function () { }; MiniReporter.prototype.write = function (str) { - logUpdate.stderr(str); + cliCursor.hide(); + this.currentStatus = str + '\n'; + this._update(); + this.statusLineCount = this.currentStatus.split('\n').length; +}; + +MiniReporter.prototype.stdout = MiniReporter.prototype.stderr = function (data) { + this._update(data); +}; + +MiniReporter.prototype._update = function (data) { + var str = ''; + var ct = this.statusLineCount; + var columns = process.stdout.columns; + var lastLine = this.lastLineTracker.lastLine(); + + // Terminals automatically wrap text. We only need the last log line as seen on the screen. + lastLine = lastLine.substring(lastLine.length - (lastLine.length % columns)); + + // Don't delete the last log line if it's completely empty. + if (lastLine.length) { + ct++; + } + + // Erase the existing status message, plus the last log line. + str += eraseLines(ct); + + // Rewrite the last log line. + str += lastLine; - if (this.finished) { - logUpdate.stderr.done(); + if (str.length) { + this.stream.write(str); + } + + if (data) { + // send new log data to the terminal, and update the last line status. + this.lastLineTracker.update(this.stringDecoder.write(data)); + this.stream.write(data); + } + + var currentStatus = this.currentStatus; + if (currentStatus.length) { + lastLine = this.lastLineTracker.lastLine(); + // We need a newline at the end of the last log line, before the status message. + // However, if the last log line is the exact width of the terminal a newline is implied, + // and adding a second will cause problems. + if (lastLine.length % columns) { + currentStatus = '\n' + currentStatus; + } + // rewrite the status message. + this.stream.write(currentStatus); } }; + +// TODO(@jamestalamge): This should be fixed in log-update and ansi-escapes once we are confident it's a good solution. +var CSI = '\u001b['; +var ERASE_LINE = CSI + '2K'; +var CURSOR_TO_COLUMN_0 = CSI + '0G'; +var CURSOR_UP = CSI + '1A'; + +// Returns a string that will erase `count` lines from the end of the terminal. +function eraseLines(count) { + var clear = ''; + + for (var i = 0; i < count; i++) { + clear += ERASE_LINE + (i < count - 1 ? CURSOR_UP : ''); + } + if (count) { + clear += CURSOR_TO_COLUMN_0; + } + + return clear; +} diff --git a/lib/reporters/tap.js b/lib/reporters/tap.js index 38b08b73e2..444596f968 100644 --- a/lib/reporters/tap.js +++ b/lib/reporters/tap.js @@ -78,3 +78,7 @@ TapReporter.prototype.finish = function () { TapReporter.prototype.write = function (str) { console.log(str); }; + +TapReporter.prototype.stdout = TapReporter.prototype.stderr = function (data) { + process.stderr.write(data); +}; diff --git a/lib/reporters/verbose.js b/lib/reporters/verbose.js index 82351b13ef..698cb77994 100644 --- a/lib/reporters/verbose.js +++ b/lib/reporters/verbose.js @@ -100,3 +100,7 @@ VerboseReporter.prototype.finish = function () { VerboseReporter.prototype.write = function (str) { console.error(str); }; + +VerboseReporter.prototype.stdout = VerboseReporter.prototype.stderr = function (data) { + process.stderr.write(data); +}; diff --git a/lib/test-worker.js b/lib/test-worker.js index 4fed7c607e..a4f58da084 100644 --- a/lib/test-worker.js +++ b/lib/test-worker.js @@ -1,12 +1,27 @@ 'use strict'; +var opts = JSON.parse(process.argv[2]); +var testPath = opts.file; + +// Fake TTY support +if (opts.tty) { + process.stdout.isTTY = true; + process.stdout.columns = opts.tty.columns || 80; + process.stdout.rows = opts.tty.rows; + var tty = require('tty'); + var isatty = tty.isatty; + tty.isatty = function (fd) { + if (fd === 1 || fd === process.stdout) { + return true; + } + return isatty(fd); + }; +} + var path = require('path'); var fs = require('fs'); var debug = require('debug')('ava'); var sourceMapSupport = require('source-map-support'); -var opts = JSON.parse(process.argv[2]); -var testPath = opts.file; - if (debug.enabled) { // Forward the `time-require` `--sorted` flag. // Intended for internal optimization tests only. diff --git a/package.json b/package.json index e7e2a0da70..c8413f1b86 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ }, "scripts": { "test": "xo && nyc --cache --reporter=lcov --reporter=text tap --no-cov --timeout=150 test/*.js test/reporters/*.js", - "test-win": "tap --no-cov --reporter=classic --timeout=150 test/*.js test/reporters/*.js" + "test-win": "tap --no-cov --reporter=classic --timeout=150 test/*.js test/reporters/*.js", + "visual": "node test/visual/run-visual-tests.js" }, "files": [ "lib", @@ -87,6 +88,7 @@ "bluebird": "^3.0.0", "caching-transform": "^1.0.0", "chalk": "^1.0.0", + "cli-cursor": "^1.0.2", "co-with-promise": "^4.6.0", "commondir": "^1.0.1", "convert-source-map": "^1.1.2", @@ -101,7 +103,7 @@ "is-generator-fn": "^1.0.0", "is-observable": "^0.1.0", "is-promise": "^2.1.0", - "log-update": "^1.0.2", + "last-line-stream": "^1.0.0", "loud-rejection": "^1.2.0", "max-timeout": "^1.0.0", "md5-hex": "^1.2.0", @@ -118,6 +120,7 @@ "serialize-error": "^1.1.0", "set-immediate-shim": "^1.0.1", "source-map-support": "^0.4.0", + "strip-ansi": "^3.0.0", "strip-bom": "^2.0.0", "time-require": "^0.1.2", "unique-temp-dir": "^1.0.0", @@ -127,8 +130,10 @@ "coveralls": "^2.11.4", "delay": "^1.3.0", "get-stream": "^1.1.0", - "rimraf": "^2.5.0", + "inquirer": "^0.11.1", "nyc": "^5.1.0", + "pify": "^2.3.0", + "rimraf": "^2.5.0", "signal-exit": "^2.1.2", "sinon": "^1.17.2", "source-map-fixtures": "^0.4.0", diff --git a/test/reporters/mini.js b/test/reporters/mini.js index b80974844e..f291ec7c60 100644 --- a/test/reporters/mini.js +++ b/test/reporters/mini.js @@ -3,6 +3,8 @@ var chalk = require('chalk'); var test = require('tap').test; var miniReporter = require('../../lib/reporters/mini'); +process.stderr.setMaxListeners(50); + test('start', function (t) { var reporter = miniReporter(); @@ -18,7 +20,6 @@ test('passing test', function (t) { }); var expectedOutput = [ - '', ' ' + chalk.green('passed'), '', ' ' + chalk.green('1 passed') @@ -39,7 +40,6 @@ test('failing test', function (t) { }); var expectedOutput = [ - '', ' ' + chalk.red('failed'), '', ' ' + chalk.red('1 failed') @@ -58,7 +58,6 @@ test('skipped test', function (t) { }); var expectedOutput = [ - '', ' ' + chalk.yellow('- skipped'), '', '' @@ -75,7 +74,6 @@ test('results with passing tests', function (t) { var actualOutput = reporter.finish(); var expectedOutput = [ - '', ' ' + chalk.green('1 passed'), '' ].join('\n'); @@ -92,7 +90,6 @@ test('results with skipped tests', function (t) { var actualOutput = reporter.finish(); var expectedOutput = [ - '', ' ' + chalk.yellow('1 skipped'), '' ].join('\n'); @@ -108,9 +105,8 @@ test('results with passing skipped tests', function (t) { var output = reporter.finish().split('\n'); - t.is(output[0], ''); - t.is(output[1], ' ' + chalk.green('1 passed') + ' ' + chalk.yellow('1 skipped')); - t.is(output[2], ''); + t.is(output[0], ' ' + chalk.green('1 passed') + ' ' + chalk.yellow('1 skipped')); + t.is(output[1], ''); t.end(); }); @@ -128,13 +124,12 @@ test('results with passing tests and rejections', function (t) { var output = reporter.finish().split('\n'); - t.is(output[0], ''); - t.is(output[1], ' ' + chalk.green('1 passed')); - t.is(output[2], ' ' + chalk.red('1 rejection')); - t.is(output[3], ''); - t.is(output[4], ' ' + chalk.red('1. Unhandled Rejection')); - t.match(output[5], /Error: failure/); - t.match(output[6], /Test\.test/); + t.is(output[0], ' ' + chalk.green('1 passed')); + t.is(output[1], ' ' + chalk.red('1 rejection')); + t.is(output[2], ''); + t.is(output[3], ' ' + chalk.red('1. Unhandled Rejection')); + t.match(output[4], /Error: failure/); + t.match(output[5], /Test\.test/); t.end(); }); @@ -152,13 +147,12 @@ test('results with passing tests and exceptions', function (t) { var output = reporter.finish().split('\n'); - t.is(output[0], ''); - t.is(output[1], ' ' + chalk.green('1 passed')); - t.is(output[2], ' ' + chalk.red('1 exception')); - t.is(output[3], ''); - t.is(output[4], ' ' + chalk.red('1. Uncaught Exception')); - t.match(output[5], /Error: failure/); - t.match(output[6], /Test\.test/); + t.is(output[0], ' ' + chalk.green('1 passed')); + t.is(output[1], ' ' + chalk.red('1 exception')); + t.is(output[2], ''); + t.is(output[3], ' ' + chalk.red('1. Uncaught Exception')); + t.match(output[4], /Error: failure/); + t.match(output[5], /Test\.test/); t.end(); }); @@ -175,12 +169,11 @@ test('results with errors', function (t) { var output = reporter.finish().split('\n'); - t.is(output[0], ''); - t.is(output[1], ' ' + chalk.red('1 failed')); - t.is(output[2], ''); - t.is(output[3], ' ' + chalk.red('1. failed')); - t.match(output[4], /failure/); - t.match(output[5], /Error: failure/); - t.match(output[6], /Test\.test/); + t.is(output[0], ' ' + chalk.red('1 failed')); + t.is(output[1], ''); + t.is(output[2], ' ' + chalk.red('1. failed')); + t.match(output[3], /failure/); + t.match(output[4], /Error: failure/); + t.match(output[5], /Test\.test/); t.end(); }); diff --git a/test/visual/console-log.js b/test/visual/console-log.js new file mode 100644 index 0000000000..dce1191a1b --- /dev/null +++ b/test/visual/console-log.js @@ -0,0 +1,17 @@ +import test from '../../'; +import delay from 'delay'; + +test('testName1', async t => { + console.log('foo'); + await delay(2000); + console.log('baz'); + t.pass(); +}); + +test('testName2', async t => { + await delay(1000); + console.log('bar'); + await delay(2000); + console.log('quz'); + t.pass(); +}); diff --git a/test/visual/lorem-ipsum.js b/test/visual/lorem-ipsum.js new file mode 100644 index 0000000000..fc1df4ec8f --- /dev/null +++ b/test/visual/lorem-ipsum.js @@ -0,0 +1,13 @@ +'use strict'; +var test = require('../../'); +var delay = require('delay'); +require('./print-lorem-ipsum'); + +async function testFn(t) { + await delay(40); + t.pass(); +} + +for (var i = 0; i < 400; i++) { + test.serial('test number ' + i, testFn); +} diff --git a/test/visual/lorem-ipsum.txt b/test/visual/lorem-ipsum.txt new file mode 100644 index 0000000000..63a1466218 --- /dev/null +++ b/test/visual/lorem-ipsum.txt @@ -0,0 +1,5 @@ +Four score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal. + +Now we are engaged in a great civil war, testing whether that nation, or any nation so conceived and dedicated, can long endure. We are met on a great battle-field of that war. We have come to dedicate a portion of that field, as a final resting place for those who here gave their lives that that nation might live. It is altogether fitting and proper that we should do this. + +But, in a larger sense, we can not dedicate -- we can not consecrate -- we can not hallow -- this ground. The brave men, living and dead, who struggled here, have consecrated it, far above our poor power to add or detract. The world will little note, nor long remember what we say here, but it can never forget what they did here. It is for us the living, rather, to be dedicated here to the unfinished work which they who fought here have thus far so nobly advanced. It is rather for us to be here dedicated to the great task remaining before us -- that from these honored dead we take increased devotion to that cause for which they gave the last full measure of devotion -- that we here highly resolve that these dead shall not have died in vain -- that this nation, under God, shall have a new birth of freedom -- and that government of the people, by the people, for the people, shall not perish from the earth. diff --git a/test/visual/print-lorem-ipsum.js b/test/visual/print-lorem-ipsum.js new file mode 100644 index 0000000000..8d1aed5cc1 --- /dev/null +++ b/test/visual/print-lorem-ipsum.js @@ -0,0 +1,33 @@ +'use strict'; +var fs = require('fs'); +var path = require('path'); +var text = fs.readFileSync(path.join(__dirname, 'lorem-ipsum.txt'), 'utf8'); + +var lines = text.split(/\r?\n/g).map(function (line) { + return line.split(' '); +}); + +setTimeout(function () { + var lineNum = 0; + var wordNum = 0; + + var interval = setInterval(function () { + if (lineNum >= lines.length) { + clearInterval(interval); + return; + } + var line = lines[lineNum]; + if (wordNum >= line.length) { + process.stdout.write('\n'); + lineNum++; + wordNum = 0; + return; + } + var word = line[wordNum]; + wordNum++; + if (wordNum < line.length) { + word += ' '; + } + process.stdout.write(word); + }, 50); +}, 200); diff --git a/test/visual/run-visual-tests.js b/test/visual/run-visual-tests.js new file mode 100644 index 0000000000..1d2c2a8156 --- /dev/null +++ b/test/visual/run-visual-tests.js @@ -0,0 +1,106 @@ +'use strict'; + +require('loud-rejection'); +var path = require('path'); +var childProcess = require('child_process'); +var chalk = require('chalk'); +var arrify = require('arrify'); +var Promise = require('bluebird'); +var pify = require('pify'); +var inquirer = pify(require('inquirer'), Promise); +var cwd = path.resolve(__dirname, '../../'); + +function fixture(fixtureName) { + if (!path.extname(fixtureName)) { + fixtureName += '.js'; + } + return path.join(__dirname, fixtureName); +} + +function exec(args) { + childProcess.spawnSync(process.execPath, ['./cli.js'].concat(arrify(args)), { + cwd: cwd, + stdio: 'inherit' + }); +} + +function run(name, args, message, question) { + return new Promise(function (resolve, reject) { + console.log(chalk.cyan('**BEGIN ' + name + '**')); + exec(args); + console.log(chalk.cyan('**END ' + name + '**\n')); + console.log(arrify(message).join('\n') + '\n'); + inquirer.prompt( + [{ + type: 'confirm', + name: 'confirmed', + message: question || 'Does it appear correctly', + default: false + }], + function (data) { + if (!data.confirmed) { + reject(new Error(arrify(args).join(' ') + ' failed')); + } + resolve(); + } + ); + }); +} + +// thunked version of run for promise handlers +function thenRun() { + var args = Array.prototype.slice.call(arguments); + return function () { + return run.apply(null, args); + }; +} + +run( + 'console.log() should not mess up mini reporter', + fixture('console-log'), + [ + 'The output should have four logged lines in the following order (no empty lines in between): ', + '', + ' foo', + ' bar', + ' baz', + ' quz', + '', + 'The mini reporter output (2 passes) should only appear at the end.' + ]) + + .then(thenRun( + 'stdout.write() should not mess up the mini reporter', + fixture('stdout-write'), + [ + 'The output should have a single logged line: ', + '', + ' foo bar baz quz', + '', + 'The mini reporter output (3 passed) should only appear at the end' + ] + )) + + .then(thenRun( + 'stdout.write() of lines that are exactly the same width as the terminal', + fixture('text-ends-at-terminal-width'), + [ + 'The fixture runs twelve tests, each logging about half a line of text.', + 'The end result should be six lines of numbers, with no empty lines in between.', + 'Each line should fill the terminal completely left to right.', + '', + 'The mini reporter output (12 passed) should appear at the end.' + ] + )) + + .then(thenRun( + 'complex output', + fixture('lorem-ipsum'), + [ + 'You should see the entire contents of the Gettysburg address.', + 'Three paragraphs with a blank line in between each.', + 'There should be no other blank lines within the speech text.', + 'The test counter should display "400 passed" at the bottom.' + ] + )) +; diff --git a/test/visual/stdout-write.js b/test/visual/stdout-write.js new file mode 100644 index 0000000000..b938c7f7fc --- /dev/null +++ b/test/visual/stdout-write.js @@ -0,0 +1,23 @@ +import test from '../../'; +import delay from 'delay'; + +test('testName1', async t => { + process.stdout.write('foo '); + await delay(2000); + process.stdout.write('baz '); + t.pass(); +}); + +test('testName2', async t => { + await delay(1000); + process.stdout.write('bar '); + await delay(2000); + process.stdout.write('quz '); + await delay(1000); + t.pass(); +}); + +test('testName3', async t => { + await delay(5000); + t.pass(); +}); diff --git a/test/visual/text-ends-at-terminal-width.js b/test/visual/text-ends-at-terminal-width.js new file mode 100644 index 0000000000..d3fcc06ade --- /dev/null +++ b/test/visual/text-ends-at-terminal-width.js @@ -0,0 +1,39 @@ +import test from '../../'; +import delay from 'delay'; + +function writeFullWidth(even, adjust) { + return async function (t) { + await delay(200); + var len = Math[even ? 'floor' : 'ceil']((process.stdout.columns + adjust) / 2); + for (var i = 0; i < len; i++) { + process.stdout.write(String(i % 10)); + await delay(1); + } + await delay(200); + t.pass(); + }; +} + +// line 1 (exactly full width) +test.serial(writeFullWidth(true, 0)); +test.serial(writeFullWidth(false, 0)); + +// line 2 (one extra char on line 3) +test.serial(writeFullWidth(true, 1)); +test.serial(writeFullWidth(false, 1)); + +// line 3 (ends one char short of complete width) +test.serial(writeFullWidth(true, -2)); +test.serial(writeFullWidth(false, -2)); + +// line 4 (completes line 3 and ends the next line exactly complete width. +test.serial(writeFullWidth(true, 1)); +test.serial(writeFullWidth(false, 1)); + +// line 5 (exact complete width) +test.serial(writeFullWidth(true, 0)); +test.serial(writeFullWidth(false, 0)); + +// line 6 (exact complete width) +test.serial(writeFullWidth(true, 0)); +test.serial(writeFullWidth(false, 0));