UNPKG

txm

Version:

tool for testing code examples in Markdown files

903 lines (805 loc) 32.2 kB
import os from 'os' import { unified } from 'unified' import remarkParse from 'remark-parse' import async from 'async' import supportsColor from 'supports-color' import color from 'kleur' import { exec } from 'child_process' import DMP from 'diff-match-patch' import path from 'node:path' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' const homepageLink = (() => { const packageJsonPath = path.join( path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json') return JSON.parse(readFileSync(packageJsonPath)).homepage })() color.enabled = supportsColor.stdout const dmp = new DMP() const exitCode = { SUCCESS: 0, TEST_FAILURE: 1, FORMAT_ERROR: 2, INTERNAL_ERROR: 3 } const ANY_NONZERO_EXIT_MARKER = 'nonzero' const runTests = (queue, options) => { try { if (queue.length === 0) { console.log(color.yellow("1..0")) console.log(color.yellow("# no tests")) console.log(color.dim("# For help, see " + homepageLink)) process.exit(exitCode.SUCCESS) } // Print test plan console.log(color.dim("1.." + queue.length)); let nSuccesses = 0 let nFailures = 0 let nSkipped = 0 // Challenge: Results of parallel operations may arrive out-of-order, but // we want them to always print their results in order. // // Solution: Every time a result arrives, if it is not next in line to // print, save it as waiting, by its index. If the arriving result is next // in line, print it, and all consecutive-index results after it in the // waiting cache. const printsWaiting = [] let indexThatMayPrint = 0 const printWhenOurTurn = (index, text) => { if (index === indexThatMayPrint) { // Everything before us has been printed. We can go ahead. if (text !== undefined) console.log(text) const nextWaiting = printsWaiting[++indexThatMayPrint] if (nextWaiting) printWhenOurTurn(indexThatMayPrint, nextWaiting) } else { // It's not our turn yet. Wait in line for someone else to call us. printsWaiting[index] = text } } const succeed = (index, name, properties) => { ++nSuccesses printWhenOurTurn(index, successText(index + 1, name)) } const fail = (index, name, failureReason, properties) => { ++nFailures printWhenOurTurn(index, failureText(index + 1, name, failureReason, properties)) } const skip = (index, name) => { ++nSkipped printWhenOurTurn(index, undefined) } const normaliseTest = (t) => { // Turn lists of x into just x, so everything is neater. function getIfHas (prop) { return x => x[prop] && x[prop][0] } const validInput = getIfHas('input') const validOutput = getIfHas('output') const validError = getIfHas('error') const validCheck = getIfHas('check') const validProgram = (test) => test.program[0] const validExit = getIfHas('exit') const validOnly = getIfHas('only') const normalised = { name: t.name, program: validProgram(t), exit: validExit(t), only: validOnly(t), } let that if (that = validInput(t)) normalised.input = that if (that = validOutput(t)) normalised.output = that if (that = validError(t)) normalised.error = that if (that = validCheck(t)) normalised.check = that return normalised } const makeColouredDiff = (expected, actual) => { const diff = dmp.diff_main(expected, actual) dmp.diff_cleanupSemantic(diff); // We intend to replace invisible control characters with standard // Unicode Control Pictures. This set will be part of the return value. // It will contain the Control Pictures that were used, so that they can // be listed for easy reference by the user. const controlPicturesUsed = new Set() // Debugging issues with control characters is very painful, so let's // make it easier. const withVisibleControlCharacters = text => { const controlPictures = [] const resultingText = text.replace(/[\x00-\x1f\x20]/g, x => { const pic = String.fromCharCode(x.charCodeAt(0) + 0x2400) controlPictures.push(pic) // For line feeds, also retain the actual line feed. Else everything // that's part of a diff would be forced onto one line. if (x === '\n') { return pic + '\n' } else { return pic } }) return { text: resultingText, controlPictures } } const changeType = { NONE: 0, ADDED: 1, REMOVED: -1 } const highlightedExpected = diff.reduce( (textSoFar, [change, text]) => { switch (change) { case changeType.NONE: return textSoFar + text case changeType.ADDED: return textSoFar case changeType.REMOVED: { const { text: resultingText, controlPictures } = withVisibleControlCharacters(text) controlPictures.forEach(x => controlPicturesUsed.add(x)) return textSoFar + color.strikethrough(color.inverse(color.red( resultingText))) } } }, '') const highlightedActual = diff.reduce( (textSoFar, [change, text]) => { switch (change) { case changeType.NONE: return textSoFar + text case changeType.ADDED: { const { text: resultingText, controlPictures } = withVisibleControlCharacters(text) controlPictures.forEach(x => controlPicturesUsed.add(x)) return textSoFar + color.inverse(color.green(resultingText)) } case changeType.REMOVED: return textSoFar } }, '') return { expected: highlightedExpected, actual: highlightedActual, controlPicturesUsed, } } const collectAnnotationLocations = (test, annotationTypes) => { const locations = {} annotationTypes = annotationTypes || ['input', 'output', 'error', 'check', 'program', 'exit'] for (let type of annotationTypes) { if (test[type]) { if (test[type].length) { let annotationsOfType = test[type].filter((x) => x) if (annotationsOfType.length > 1) { locations[`${type} locations`] = annotationsOfType .filter((x) => x) .map((x) => formatPosition(x.position)) } else if (annotationsOfType.length === 1) { locations[`${type} location`] = formatPosition(annotationsOfType[0].position) } } else { locations[`${type} location`] = formatPosition(test[type].position) } } } return locations } const someTestsHaveOnlyMarker = queue.find(test => test.only) return async.eachOfLimit(queue, options.jobs, (test, index, cb) => { // If some tests are marked 'only', skip tests without that marker. if (someTestsHaveOnlyMarker) { if (!test.only) { skip(index, test.name) return cb() } } // Handle invalid 'program' if (!test.program[0]) { const debugProperties = Object.assign( collectAnnotationLocations(test), { 'how to fix': 'Declare a test program before your test,' + `\nusing <!-- !test program <TEST PROGRAM HERE> -->` }) fail(index, test.name, "no program defined", debugProperties) return cb() } // Handle ambiguous multiple exit codes if (test.exit && test.exit.length > 1) { fail(index, test.name, 'multiple expected exit statuses defined', Object.assign( collectAnnotationLocations(test, ['exit']), { 'how to fix': 'Have just 1 expected exit status, before one of' + '\nthis test\'s \'!test\' commands.' })) return cb() } // Handle invalid combinations with 'check' if (test.check) { for (let type of ['input', 'output', 'error']) { if (test[type]) { fail(index, test.name, `defined as check, but also has ${type}`, Object.assign( collectAnnotationLocations(test, [type, 'check']), { 'how to fix': `Remove the ${type}, or create an in/out test instead.` })) return cb() } } if (test.check.length > 1) { fail(index, test.name, 'multiple checks defined', Object.assign( collectAnnotationLocations(test, ['check']), { 'how to fix': 'Remove or rename the other checks.' })) return cb() } } else { // Handle missing 'in' if (!test.input || test.input.length === 0) { const debugProperties = collectAnnotationLocations(test, ['output']) debugProperties['how to fix'] = `Define an input for '${test.name}', using` + `\n\n <!-- !test in ${test.name} -->` + `\n\nfollowed by a code block.` fail(index, test.name, 'input not defined', debugProperties) return cb() } const noOut = !test.output || test.output.length === 0 const noErr = !test.error || test.error.length === 0 if (noOut && noErr) { const debugProperties = collectAnnotationLocations(test, ['input']) debugProperties['how to fix'] = `Define an output or error for '${test.name}', using` + `\n\n <!-- !test out ${test.name} -->` + `\n\nor\n\n <!-- !test err ${test.name} -->` + `\n\nfollowed by a code block.` fail(index, test.name, 'only input defined', debugProperties) return cb() } if (test.input && test.input.length > 1) { const debugProperties = collectAnnotationLocations(test) debugProperties['how to fix'] = 'Remove or rename the other inputs.' fail(index, test.name, "multiple inputs defined", debugProperties) return cb() } if (test.output && test.output.length > 1) { const debugProperties = collectAnnotationLocations(test) debugProperties['how to fix'] = 'Remove or rename the other outputs.' fail(index, test.name, "multiple outputs defined", debugProperties) return cb() } if (test.error && test.error.length > 1) { const debugProperties = collectAnnotationLocations(test) debugProperties['how to fix'] = 'Remove or rename the other errors.' fail(index, test.name, "multiple errors defined", debugProperties) return cb() } } // Alright, we've handled all possible errors; we can now collapse the // test from its parsed form into its normalised form. test = normaliseTest(test) const resultCallback = (e, stdout, stderr) => { if (e || test.exit) { e ??= { code: 0 } if ( // It's an error if one of the following is true: // We want to see a nonzero exit code, but it's zero (test.exit && test.exit.code === ANY_NONZERO_EXIT_MARKER && e.code === 0) || // We want to see a specific exit code, and it's not this one (test.exit && test.exit.code !== ANY_NONZERO_EXIT_MARKER && test.exit.code !== e.code) || // We aren't awaiting a particular one, but it's non-zero (!test.exit && e.code !== 0) ) { const failureData = {} failureData.program = test.program.code failureData['exit status'] = e.code if (test.exit) failureData['expected exit status'] = test.exit.code failureData.stderr = stderr, failureData.stdout = stdout let wording = 'error' if (test.exit) { if (e.code === 0 && test.exit.code !== 0) { wording = 'unexpected success' } else { wording = 'unexpected exit status' } } fail(index, test.name, `program exited with ${wording}`, Object.assign(failureData, collectAnnotationLocations(test))) return cb() } } if (test.check) { succeed(index, test.name) return cb() } // A helper function to generate the test-result note-text that lists // invisible control characters replaced for clarity. function controlPicturesNote(type, controlPicturesUsed) { return [...controlPicturesUsed.values()].map(picture => { const name = nameOfControlCharacterForControlPicture(picture) return `${picture} represents ${name}` }).join('\n') } if (('output' in test) && stdout !== test.output.text) { const {expected, actual, controlPicturesUsed} = makeColouredDiff(test.output.text, stdout) const notes = {} notes['expected stdout'] = expected notes['actual stdout'] = actual if (controlPicturesUsed.size > 0) { notes['invisible characters in diff'] = controlPicturesNote('stdout', controlPicturesUsed) } notes['program'] = test.program.code notes['stderr'] = stderr Object.assign(notes, collectAnnotationLocations(test)) fail(index, test.name, 'output mismatch', notes) return cb() } if (('error' in test) && stderr !== test.error.text) { const {expected, actual, controlPicturesUsed} = makeColouredDiff(test.error.text, stderr) const notes = {} notes['expected stderr'] = expected notes['actual stderr'] = actual if (controlPicturesUsed.size > 0) { notes['invisible characters in diff'] = controlPicturesNote('stderr', controlPicturesUsed) } notes['program'] = test.program.code notes['stdout'] = stdout Object.assign(notes, collectAnnotationLocations(test)) fail(index, test.name, 'error mismatch', notes) return cb() } // Got this far, must have been OK. succeed(index, test.name) return cb() } const subprocessOptions = { env: Object.assign({ 'TXM_INDEX': index + 1, 'TXM_NAME': test.name, 'TXM_INDEX_FIRST': 1, 'TXM_INDEX_LAST': queue.length, 'TXM_HAS_COLOR': color.enabled ? 1 : 0, 'TXM_HAS_COLOUR': color.enabled ? 1 : 0, }, process.env) } if (test.input && test.input.lang) subprocessOptions.env['TXM_INPUT_LANG'] = test.input.lang else if (test.check && test.check.lang) subprocessOptions.env['TXM_INPUT_LANG'] = test.check.lang const subprocess = exec( test.program.code, subprocessOptions, resultCallback); // Swallow EPIPE errors. These can happen when the subprocess closes its // stdin before we manage to write to it. It's not a problem if it does: // the subprocess just doesn't want any more input. subprocess.stdin.on('error', (e) => { // Seems to be non-deterministic and normal, so it's hard to replicate // in tests, so ignore in coverage. /* c8 ignore next */ if (e.code !== 'EPIPE') { throw e } }) if (test.input) subprocess.stdin.end(test.input.text) else subprocess.stdin.end(test.check.text) }, (e) => { /* c8 ignore start */ // Should never happen, but convenient for dev. if (e) die(e.message) /* c8 ignore stop */ // Print final summary comments console.log() const colourOfState = (nFailures === 0) ? color.green : color.red const colourInverse = (x) => color.inverse(colourOfState(x)) console.log(colourOfState(`# ${nSuccesses}/${queue.length} passed`)) if (nFailures === 0){ if (nSkipped === 0) { console.log(colourInverse('# OK')) } else { console.log(color.inverse(color.yellow(`# OK, SKIPPED ${nSkipped}`))) } } else { console.log(colourInverse(`# FAILED ${nFailures}`)) process.exit(exitCode.TEST_FAILURE) } }) } catch (e) /* c8 ignore start */ { // Should never happen die(e) } /* c8 ignore stop */ } const extractHtmlComments = function(input, nodePositionInMarkdown){ // Reference: https://html.spec.whatwg.org/#comments // // Comments are generally `<!-- stuff -->`, where `stuff` is disallowed from // containing the ending delimiter. However, the comment delimiters may also // occur inside CDATA blocks, where we do *not* want to parse them. const comments = [] const CDATA_OPENER = '<![CDATA[' const CDATA_CLOSER = ']]>' const COMMENT_OPENER = '<!--' const COMMENT_CLOSER = '-->' const IN_CDATA = Symbol('parser in CDATA') const IN_COMMENT = Symbol('parser in comment') const BASE = Symbol('parser in base state') let state = BASE let nextIndex = 0 let done = false while (!done) { const rest = input.slice(nextIndex) switch (state) { case BASE: // Parse the rest of whichever we see first. CDATA "swallows" // comments, and vice-versa. const cdataIndex = rest.indexOf(CDATA_OPENER) const commentIndex = rest.indexOf(COMMENT_OPENER) if (cdataIndex === -1 && commentIndex === -1) { // No more of either; done done = true } else if (cdataIndex === -1 && commentIndex >= 0) { // Comment only state = IN_COMMENT nextIndex += commentIndex } else if (cdataIndex >= 0 && commentIndex === -1) { // CDATA only state = IN_CDATA nextIndex += cdataIndex } else { // Matched both. Go with the earlier one. if (cdataIndex < commentIndex) { // CDATA earlier state = IN_CDATA nextIndex += cdataIndex } else { // Comment earlier state = IN_COMMENT nextIndex += commentIndex } } break case IN_COMMENT: { // Parse end of comment const closerIndex = rest.indexOf(COMMENT_CLOSER) if (closerIndex >= 0) { comments.push(rest.slice(0, closerIndex)) nextIndex += closerIndex state = BASE } else { // Unterminated comment const openerIndex = input.slice(nextIndex) const line = input.slice(0, nextIndex).split('\n').length + nodePositionInMarkdown.start.line - 1 parsingError(`'<!--'`, 'unterminated HTML comment', { location: formatPosition({ start: { line }, end: { line } }), 'how to fix': `Terminate the comment with '-->' where appropriate.` + `\nCheck that '-->' doesn't occur anywhere unexpected.` }) } break } case IN_CDATA: { // Parse end of CDATA const closerIndex = rest.indexOf(CDATA_CLOSER) if (closerIndex >= 0) { nextIndex += closerIndex state = BASE } else { // Unterminated CDATA const line = input.slice(0, nextIndex).split('\n').length + nodePositionInMarkdown.start.line - 1 parsingError(`'<![CDATA['`, 'unterminated HTML CDATA section', { location: formatPosition({ start: { line }, end: { line } }), 'how to fix': `Terminate the CDATA section with ']]>'` + ` where appropriate.` + `\nCheck that ']]>' doesn't occur anywhere unexpected.` }) } break } } } return comments }; /* Consecutive dashes ("--") are illegal inside HTML comments, so let's allow them to be escaped with the sequence "\-". We treat "\-" like a single token that can be escaped ("\\-") to get a literal "\-". This way, users can still write "\" in other contexts (which is common in shell scripts for the "program" command) without entering backslash hell. */ const unescape = (x) => x.replace(/#-/g, '-') const parseAndRunTests = (text, options={jobs: 1}) => { // TAP header console.log('TAP version 13') /* A test spec is a set of program, input, and expected output. We maintain a collection of the incomplete ones indexed by name (unique identifier decided by the user). Whenever new information is available for the test spec corresponding to a name, we add that information, and when it's complete, delete it from the incomplete list and queue it for running. */ const testSpecs = {} const addToTestSpec = (name, key, value) => { const testSpec = name in testSpecs ? testSpecs[name] : {} testSpecs[name] = testSpec const testSpecArrayForKey = testSpec[key] || [] testSpec[key] = testSpecArrayForKey testSpec[key].push(value) } const setFieldInTestSpec = (name, key, value) => { // We can assume it exists already const testSpec = testSpecs[name] testSpecs[name] = testSpec testSpec[key] = [value] } const howToFixUnexpectedCommandExplanation = `Check that your` + ` 'in' / 'out' / 'err' / 'check'` + ` commands are each followed` + `\nby a block of code, not another test command.` /* This state machine describes the state that the parser is in. The 'now' property holds its current state. States are represented by constructor functions take parameters, through which data is passed when transitioning between states. Each state can react to texts (i.e. code blocks) or commands (i.e. HTML comments containing "!test" commands) in whatever way is appropriate for that state. */ const parseStateMachine = { waitingForAnyCommand: ({program, exit, only}) => { return { gotText: () => {}, gotCommand: (name, text, position) => { switch (name) { case 'program': parseStateMachine.now = parseStateMachine.waitingForAnyCommand( { program: { code: text, position: position }, exit, only }) break case 'exit': text = text.trim() if (text.match(/[0-9]+/)) { const code = Number.parseInt(text) parseStateMachine.now = parseStateMachine.waitingForAnyCommand( { exit: { code, position: position }, program, only }) } else if (text === 'nonzero') { parseStateMachine.now = parseStateMachine.waitingForAnyCommand({ exit: { code: ANY_NONZERO_EXIT_MARKER, position: position }, program, only, }) } else { parsingError(`'${name} ${text}'`, `bad exit code specified`, { location: formatPosition(position), 'how to fix': 'Use an integer >=0, or the word' + ' \'nonzero\',\nto accept any non-zero exit code' }) } break case 'only': parseStateMachine.now = parseStateMachine.waitingForAnyCommand( { program, exit, only: { position } }) break case 'in': parseStateMachine.now = parseStateMachine.waitingForInputText( { program, exit, only, name: text }) break case 'out': parseStateMachine.now = parseStateMachine.waitingForOutputText( { program, exit, only, name: text }) break case 'err': parseStateMachine.now = parseStateMachine.waitingForErrorText( { program, exit, only, name: text }) break case 'check': parseStateMachine.now = parseStateMachine.waitingForCheckText( { program, exit, only, name: text }) break } } } }, } const capitalise = (text) => text.replace(/./, (x) => x.toUpperCase()) // Construct state-machine states for each of the states where we're // expecting to next see a code block. for (let annotationType of ['input', 'output', 'error', 'check']) { parseStateMachine[`waitingFor${capitalise(annotationType)}Text`] = ({program, name, exit, only}) => { return { gotText: (text, position, lang) => { // The exit code only applies to this test, not continuously, so // don't pass it back to the `waitingForAnyCommand` state. parseStateMachine.now = parseStateMachine.waitingForAnyCommand({ program }) addToTestSpec(name, annotationType, {text, position, lang}) setFieldInTestSpec(name, 'program', program) if (exit) addToTestSpec(name, 'exit', exit) if (only) addToTestSpec(name, 'only', only) }, gotCommand: (name, text, position) => { parsingError(`'${name} ${text}'`, `unexpected command (expected ${annotationType} text)`, { location: formatPosition(position), 'how to fix': howToFixUnexpectedCommandExplanation }) } } } } // Initial state parseStateMachine.now = parseStateMachine.waitingForAnyCommand({ program: null }) const visitMarkdownNode = (node) => { if (node.type === 'html') { extractHtmlComments(node.value, node.position).forEach((comment) => { // Optional whitespace, followed by '!test', more optional whitespace, // then the commands we actually care about. const re = /(?:\s+)?!test\s+([\s\S]*)/m; const match = comment.trim().match(re) if (match) { const [, command] = match const commandWords = command.split(/\s+/) const firstWord = commandWords[0] const supportedCommands = [ 'program', 'in', 'out', 'err', 'check', 'exit', 'only' ] if (supportedCommands.includes(firstWord)) { const rest = unescape(command.slice(firstWord.length).trim()) parseStateMachine.now.gotCommand(firstWord, rest, node.position) } else { parsingError(`'${firstWord}'`, 'unknown command type', { location: formatPosition(node.position), 'supported commands': supportedCommands }) } } }) } else if (node.type === 'code') { const text = node.value + os.EOL parseStateMachine.now.gotText(text, node.position, node.lang) } else if ('children' in node) { node.children.forEach(visitMarkdownNode) } } const mdastSyntaxTree = unified().use(remarkParse).parse(text) visitMarkdownNode(mdastSyntaxTree) const tests = [] for (let name in testSpecs) { const test = Object.assign({name}, testSpecs[name]) tests.push(test) } runTests(tests, options) } // // Helpers // var type = (obj) => Object.prototype.toString.call(obj).slice(8, -1) const repeatString = (str, n) => { let out = '' while (n-- > 0) out += str return out } /* c8 ignore start */ function die (message) { // For fatal errors. When possible, fail through 'parsingError', since that // still outputs valid TAP. console.error(message) process.exit(exitCode.INTERNAL_ERROR) } /* c8 ignore stop */ const formatPosition = (position) => { const pos = { start: position.start.line, end: position.end.line } return (pos.start === pos.end) ? `line ${pos.start}` : `lines ${pos.start}-${pos.end}` } const indent = (n, text) => { const spaces = repeatString(' ', n) return text .split(os.EOL) .map((line) => line.length ? `${spaces}${line}` : line) .join(os.EOL) } // Format the given object's properties as valid YAML. This is used to present // notes along with test results. const formatYAMLProperties = (properties, indentLevel=0) => { const propertyTexts = [] for (let [key, value] of Object.entries(properties)) { const keyText = indent(indentLevel, `${color.blue(key)}:`) const valueText = (() => { switch (type(value)) { case 'Array': return '\n' + value .map(v => indent(indentLevel + 1, `- ${v.toString()}`)) .join('\n') case 'Number': return ' ' + value.toString() case 'String': { // We want to present string values compactly in the YAML output, // while still being maximally unambiguous. // The easiest case: The string contains no newlines or single-quotes // that we'd have to escape, and there's space for it in 80 columns // and quote characters after the key. const availableWidthAfterKey = 80 - indent(indentLevel, key).length const fitsOnSameLine = availableWidthAfterKey > value.length + 3 if (!value.match(/[\n']/) && fitsOnSameLine) { return ` '${value}'` } else { // Otherwise, use "|"-notation. return ` |\n${indent(indentLevel + 1, value)}` } } /* c8 ignore start */ // Should never happen, but this is convenient while developing. default: throw Error(`Unexpected property type ${type(value)}:` + `${JSON.stringify(value)}`) /* c8 ignore stop */ } })() propertyTexts.push(`${keyText}${valueText}`) } const horizontalRule = indent(indentLevel, color.dim("---")) return `${horizontalRule}\n${propertyTexts.join('\n')}\n${horizontalRule}` } const successText = (index, name) => `${color.green('ok')} ${color.dim(index)} ${name}` const failureText = (index, name, failureReason, properties) => { let text = `${color.red('not ok')} ${color.dim(index)} ${name}` + color.dim(`: ${failureReason}`) if (properties) text += "\n" + formatYAMLProperties(properties, 1) return text } const parsingError = (name, failureReason, properties) => { console.log(color.dim('0..0')) console.log(failureText(0, name, failureReason, properties)) console.log() console.log(color.black(color.bgRed('# FAILED TO PARSE TESTS'))) process.exit(exitCode.FORMAT_ERROR) } // Since the Unicode Control Pictures are not always self-explanatory, this // function is for listing the names, C escapes, and Unicode code points of the // characters they stand for. This is a lot of information, but bugs relating // to invisible characters can be correspondingly hairy. function nameOfControlCharacterForControlPicture(c) { const codepoint = c.charCodeAt(0) - 0x2400 const hex = codepoint.toString(16) const unicodeCodepoint = "U+" + "0000".substring(0, 4 - hex.length) + hex return ({ 0x00: 'Null ("\\0")', 0x01: 'Start of Heading', 0x02: 'Start of Text', 0x03: 'End of Text', 0x04: 'End of Transmission', 0x05: 'Enquiry', 0x06: 'Acknowledge', 0x07: 'Bell ("\\a")', 0x08: 'Backspace ("\\b")', 0x09: 'Horizontal Tabulation ("\\t")', 0x0A: 'Line Feed ("\\n")', 0x0B: 'Vertical Tabulation ("\\v")', 0x0C: 'Form Feed ("\\f")', 0x0D: 'Carriage Return ("\\r")', 0x0E: 'Shift Out', 0x0F: 'Shift In', 0x10: 'Data Link Escape', 0x11: 'Device Control One', 0x12: 'Device Control Two', 0x13: 'Device Control Three', 0x14: 'Device Control Four', 0x15: 'Negative Acknowledge', 0x16: 'Synchronous Idle', 0x17: 'End of Transmission Block', 0x18: 'Cancel', 0x19: 'End of Medium', 0x1A: 'Substitute', 0x1B: 'Escape ("\\e")', 0x1C: 'File Separator', 0x1D: 'Group Separator', 0x1E: 'Record Separator', 0x1F: 'Unit Separator', 0x20: 'Space (" ")', 0x23: 'Space (" ")', })[codepoint] + ` [${unicodeCodepoint}]` } export default parseAndRunTests