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}${elementName}>`;
+}
+
+/**
+ * @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;