diff --git a/lib/Supervisor.js b/lib/Supervisor.js index d8c9920b..3bd85fda 100644 --- a/lib/Supervisor.js +++ b/lib/Supervisor.js @@ -4,6 +4,7 @@ const fs = require('fs'); const net = require('net'); const split = require('split'); const Report = require('./Report'); +const XMLBuilder = require('./XMLBuilder'); /** * @param { string } elmTestVersion @@ -211,36 +212,8 @@ function run(elmTestVersion, pipeFilename, report, processes, dest, watch) { if (report === 'junit') { var xml = response.message; var values = Array.from(results.values()); - xml.testsuite.testcase = xml.testsuite.testcase.concat(values); - - // The XmlBuilder by default does not remove characters that are - // invalid in XML, like backspaces. However, we can pass it an - // `invalidCharReplacement` option to tell it how to handle - // those characters, rather than crashing. In an attempt to - // retain useful information in the output, we try and output a - // hex-encoded unicode codepoint for the invalid character. For - // example, the start of a terminal escape (`\u{001B}` in Elm) will be output as a - // literal `\u{001B}`. - /** @type { (char: string) => string } */ - var invalidCharReplacement = function (char) { - return ( - '\\u{' + - (char.codePointAt(0) || 0).toString(16).padStart(4, '0') + - '}' - ); - }; - - console.log( - // xmlbuilder takes 10–20 ms to load, and is only needed - // when using the junit report, so `require` it late. - require('xmlbuilder') - .create(xml, { - // @ts-expect-error The type annotation says that `invalidCharReplacement` should be a string, but we have always passed a function. This got noticed when migrating from Flow to TypeScript. - invalidCharReplacement: invalidCharReplacement, - }) - .end() - ); + console.log(XMLBuilder.toString(xml)); } } diff --git a/lib/XMLBuilder.js b/lib/XMLBuilder.js new file mode 100644 index 00000000..5a45836c --- /dev/null +++ b/lib/XMLBuilder.js @@ -0,0 +1,165 @@ +/** + * We used to use `xmlbuilder@15.1.1` from npm to generate XML. + * However, we only used a tiny fraction of that package. + * This file implements only the bits we need. + * + * This function takes an object that describes XML. + * You can specify element names, attributes, text and nested elements. + * + * NOTE: Element names and attribute names are assumed to be valid. + * Attribute values and text are escaped. + * + * Example input: + * + * { + * "plain": "text content", // text content + * "rich": { + * "@my-attr": "my value", // + * "nested": "text", // text (nested under ) + * "child": [ + * { name: "John" }, // John (nested under ) + * { name: "Jane" }, // Jane (nested under ) + * ], + * }, + * } + * + * @typedef { { [key: string]: XmlValue } } Xml + * @typedef { string | number | Array | Xml } XmlValue + * + * @param { Xml } xml + * @returns { string } + */ +function toString(xml) { + const string = Object.entries(xml) + .map(([key, value]) => { + if (key.startsWith('@')) { + throw new Error( + `Attributes cannot be set at the top level. Key: ${JSON.stringify( + key + )}` + ); + } + return xmlValueToString(key, value); + }) + .join(''); + return '' + string; +} + +/** + * @param { string } elementName + * @param { XmlValue } xmlValue + * @returns { string } + */ +function xmlValueToString(elementName, xmlValue) { + if (Array.isArray(xmlValue)) { + return xmlValue + .map((value) => xmlValueToString(elementName, value)) + .join(''); + } + + const attributes = + typeof xmlValue === 'object' + ? Object.entries(xmlValue).flatMap(([key, value]) => { + if (!key.startsWith('@')) { + return []; + } + const escapedValue = xmlValueToAttributeValue(key, value); + return `${key.slice(1)}="${escapedValue}"`; + }) + : []; + + const attributesString = + attributes.length === 0 ? '' : ' ' + attributes.join(' '); + + const childrenString = + typeof xmlValue === 'object' + ? Object.entries(xmlValue) + .flatMap(([key, value]) => + key.startsWith('@') ? [] : xmlValueToString(key, value) + ) + .join('') + : typeof xmlValue === 'string' + ? escapeText(xmlValue) + : xmlValue.toString(); + + return childrenString === '' + ? `<${elementName}${attributesString}/>` + : `<${elementName}${attributesString}>${childrenString}`; +} + +/** + * @param { string } key + * @param { XmlValue } xmlValue + * @returns { string } + */ +function xmlValueToAttributeValue(key, xmlValue) { + switch (typeof xmlValue) { + case 'string': + return escapeAttributeValue(xmlValue); + case 'number': + return xmlValue.toString(); + default: + throw new Error( + `Attribute values must be strings or numbers. Key: ${JSON.stringify( + key + )}. Value: ${JSON.stringify(xmlValue)}` + ); + } +} + +// https://github.com/oozcitak/xmlbuilder-js/blob/ce625aeb9f52d1f75d5d94260794d3b5fd74a8b1/src/XMLStringifier.coffee#L119 +const invalidCharRegex = + // eslint-disable-next-line no-control-regex + /[\0-\x08\x0B\f\x0E-\x1F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/g; + +/** + * @param { string } string + * @returns { string } + */ +function escapeAttributeValue(string) { + return ( + string + .replace(invalidCharRegex, invalidCharReplacement) + // https://github.com/oozcitak/xmlbuilder-js/blob/ce625aeb9f52d1f75d5d94260794d3b5fd74a8b1/src/XMLStringifier.coffee#L179-L184 + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\r/g, ' ') + ); +} + +/** + * Some characters are invalid in XML, like backspaces. In an attempt to + * retain useful information in the output, we try and output a + * hex-encoded unicode codepoint for the invalid character. For + * example, the start of a terminal escape (`\u{001B}` in Elm) will be output as a + * literal `\u{001B}`. + * + * @param { string } char + * @returns { string } + */ +function invalidCharReplacement(char) { + return `\\u{${(char.codePointAt(0) || 0).toString(16).padStart(4, '0')}}`; +} + +module.exports = { + toString, +}; diff --git a/package-lock.json b/package-lock.json index 0240c8a1..e4c18673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,7 @@ "elm-solve-deps-wasm": "^1.0.2 || ^2.0.0", "split": "^1.0.1", "tinyglobby": "^0.2.10", - "which": "^2.0.2", - "xmlbuilder": "^15.1.1" + "which": "^2.0.2" }, "bin": { "elm-test": "bin/elm-test" @@ -3230,14 +3229,6 @@ "node": ">=4.0" } }, - "node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "engines": { - "node": ">=8.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -5450,11 +5441,6 @@ } } }, - "xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==" - }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 1475957a..47837f5b 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,7 @@ "elm-solve-deps-wasm": "^1.0.2 || ^2.0.0", "split": "^1.0.1", "tinyglobby": "^0.2.10", - "which": "^2.0.2", - "xmlbuilder": "^15.1.1" + "which": "^2.0.2" }, "devDependencies": { "@eslint/js": "9.20.0", diff --git a/tests/flags.js b/tests/flags.js index d56eb44b..351eb3a4 100644 --- a/tests/flags.js +++ b/tests/flags.js @@ -315,6 +315,11 @@ describe('flags', () => { path.join('tests', 'Passing', 'One.elm'), ]); + assert.strictEqual( + runResult.stdout.replace(/time="[^"]+"/g, `time="1337"`), + '\n' + ); + xml2js.parseString(runResult.stdout, (err, data) => { if (err) throw err; @@ -338,6 +343,11 @@ describe('flags', () => { path.join('tests', 'Failing', 'One.elm'), ]); + assert.strictEqual( + runResult.stdout.replace(/time="[^"]+"/g, `time="1337"`), + 'This should fail!\n' + ); + xml2js.parseString(runResult.stdout, (err, data) => { if (err) throw err;