diff --git a/.eslintrc.json b/.eslintrc.json index c6f3c5a0f..eb50260e3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -245,6 +245,7 @@ "rules": { // https://github.com/eslint/eslint/issues/15732 "array-bracket-spacing": "off", + "n/handle-callback-err": "off", "no-labels": "off", "no-undef": "off", "no-unused-expressions": "off", diff --git a/docs/api/callbacks/QUnit.on.md b/docs/api/callbacks/QUnit.on.md index 8298aa13b..4f58baa2e 100644 --- a/docs/api/callbacks/QUnit.on.md +++ b/docs/api/callbacks/QUnit.on.md @@ -14,7 +14,7 @@ version_added: "2.2.0" Register a callback that will be invoked after the specified event is emitted. -This API is the primary interface for QUnit plugins, continuous integration support, and other reporters. It is based on the [js-reporters CRI standard](https://github.com/js-reporters/js-reporters/blob/v2.1.0/spec/cri-draft.adoc). +This API is the primary interface for QUnit reporters, plugins, and continuous integration support. It is based on the [js-reporters CRI standard](https://github.com/js-reporters/js-reporters/blob/v2.1.0/spec/cri-draft.adoc). | type | parameter | description |--|--|-- @@ -166,3 +166,59 @@ QUnit.on('error', error => { console.error(error); }); ``` + +## Reporter API + +The QUnit CLI accepts a [`--reporter` option](../../cli.md#--reporter) that loads a reporter from a Node module (e.g. npm package). Such module must export an `init` function, which QUnit will call and pass the `QUnit` object, which you can then use to call `QUnit.on()`. This contract is known as the *Reporter API*. + +You can implement your reporter either as simply an exported function, or as a class with a static `init` method. + +### Example: Reporter class + +```js +class MyReporter { + static init (QUnit) { + return new MyReporter(QUnit); + } + + constructor (QUnit) { + QUnit.on('error', this.onError.bind(this)); + QUnit.on('testEnd', this.onTestEnd.bind(this)); + QUnit.on('runEnd', this.onRunEnd.bind(this)); + } + + onError (error) { + } + + onTestEnd (testEnd) { + } + + onRunEnd (runEnd) { + } +} + +// CommonJS, or ES Module +module.exports = MyReporter; +export default MyReporter; +``` + +### Example: Reporter function + +```js +function init (QUnit) { + QUnit.on('error', onError); + QUnit.on('testEnd', onTestEnd); + QUnit.on('runEnd', onRunEnd); + + function onError (error) { + } + function onTestEnd (testEnd) { + } + function onRunEnd (runEnd) { + } +} + +// CommonJS, or ES Module +module.exports.init = init; +export { init }; +``` diff --git a/docs/cli.md b/docs/cli.md index 9aec79907..0d971dc2c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -127,14 +127,18 @@ Check [`QUnit.config.module`](./api/config/module.md) for more information. ### `--reporter` -By default, the TAP reporter is used. +By default, the TAP reporter is used. This allows you to pair QUnit with any [TAP-compatible reporter](https://github.com/sindresorhus/awesome-tap#reporters), by piping the output. For example: -Run `qunit --reporter ` to use a different reporter, where `` can be the name of a built-in reporter, or an Node module that implements the [js-reporters](https://github.com/js-reporters/js-reporters) spec. The reporter will be loaded and initialised automatically. +```sh +qunit test/ | tap-min +``` + +To change the reporting from QUnit itself, use `qunit --reporter ` to set a different reporter, where `` can be the name of a built-in reporter, or an Node module that implements the [QUnit ReporterĀ API](./api/callbacks/QUnit.on.md#reporter-api). The reporter will be loaded and initialised automatically. Built-in reporters: * `tap`: [TAP compliant](https://testanything.org/) reporter. -* `console`: Log the JSON object for each reporter event from [`QUnit.on`](./api/callbacks/QUnit.on.md). Use this to explore or debug the reporter interface. +* `console`: Log the JSON object for each reporter event from [`QUnit.on`](./api/callbacks/QUnit.on.md). Use this to explore or debug the Reporter API. ### `--require` diff --git a/docs/upgrade-guide-2.x.md b/docs/upgrade-guide-2.x.md index f47ee6b31..db89d1236 100644 --- a/docs/upgrade-guide-2.x.md +++ b/docs/upgrade-guide-2.x.md @@ -170,7 +170,7 @@ Early alpha releases of QUnit 0.x required property assignments to register call

-See also [`QUnit.on()`](./api/callbacks/QUnit.on.md), which implements the [js-reporters spec](https://github.com/js-reporters/js-reporters) since QUnit 2.2. +See also [`QUnit.on()`](./api/callbacks/QUnit.on.md).

diff --git a/package-lock.json b/package-lock.json index 4024966e7..d3f4f4cf4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,8 @@ "nyc": "^17.0.0", "proxyquire": "^1.8.0", "requirejs": "^2.3.6", - "rollup": "^4.18.0" + "rollup": "^4.18.0", + "tap-min": "^3.0.0" }, "engines": { "node": ">=18" @@ -4040,6 +4041,18 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/duplexer3": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-1.0.0.tgz", + "integrity": "sha512-6O5ndCyJ9CGF9cR2Yi3VFq1OvXXLEgX848InIOl8xUBPYwb8jn/93j10lGaZyLnMRa71IT5OHhURlOiVjH9OVg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4982,6 +4995,15 @@ "integrity": "sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ==", "dev": true }, + "node_modules/events-to-array": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-2.0.3.tgz", + "integrity": "sha512-f/qE2gImHRa4Cp2y1stEOSgw8wTFyUdVJX7G//bMwbaV9JqISFxg99NbmVQeP7YLnDUZ2un851jlaDrlpmGehQ==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -6172,6 +6194,15 @@ "node": ">= 0.4" } }, + "node_modules/hirestime": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/hirestime/-/hirestime-7.0.4.tgz", + "integrity": "sha512-+MBUB+eJCfsLnl/DOeQTUi/GgS+Ottk5/pR6pDN7HJNOkMOHMMmmvNmQhes1zevzVi6xiulsY8ZnPrDZEcXXxg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -8158,6 +8189,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz", + "integrity": "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse-passwd": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", @@ -8387,6 +8430,21 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-ms": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-8.0.0.tgz", + "integrity": "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==", + "dev": true, + "dependencies": { + "parse-ms": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -9537,6 +9595,63 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tap-min": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tap-min/-/tap-min-3.0.0.tgz", + "integrity": "sha512-oEhFyYCUEmRLYwtlIkRgeVkX538yEQfoSg6BcWXMq05TFsnpsi3vtQZJeEBkXzMWlj1h/rKaRMaWPCvCTmIIXg==", + "dev": true, + "dependencies": { + "chalk": "^5.3.0", + "duplexer3": "^1.0.0", + "hirestime": "^7.0.3", + "pretty-ms": "^8.0.0", + "tap-parser": "^13.0.2-1" + }, + "bin": { + "tap-min": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tap-min/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/tap-parser": { + "version": "13.0.2-1", + "resolved": "https://registry.npmjs.org/tap-parser/-/tap-parser-13.0.2-1.tgz", + "integrity": "sha512-A6U6TvfwEUFVivyZFiTth3LVrGw9aa4j4cSU8W6ui3sfrZBRNzE1y5YhBomz0+RIRURdspFJdaJvVgSXI68h0Q==", + "dev": true, + "dependencies": { + "events-to-array": "^2.0.3", + "tap-yaml": "2.1.1-1" + }, + "bin": { + "tap-parser": "bin/cmd.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/tap-yaml": { + "version": "2.1.1-1", + "resolved": "https://registry.npmjs.org/tap-yaml/-/tap-yaml-2.1.1-1.tgz", + "integrity": "sha512-acWZWpwstr0YMms30FW2nlFkJ0hSm/o2x32Hq5v10IKqYKwnRXARSx6TKbz/gzcSoODPi9PkFQYGYBgowxKDfA==", + "dev": true, + "dependencies": { + "yaml": "^2.3.0", + "yaml-types": "^0.3.0" + } + }, "node_modules/tar-fs": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", @@ -10277,6 +10392,31 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yaml-types": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/yaml-types/-/yaml-types-0.3.0.tgz", + "integrity": "sha512-i9RxAO/LZBiE0NJUy9pbN5jFz5EasYDImzRkj8Y81kkInTi1laia3P3K/wlMKzOxFQutZip8TejvQP/DwgbU7A==", + "dev": true, + "engines": { + "node": ">= 16", + "npm": ">= 7" + }, + "peerDependencies": { + "yaml": "^2.3.0" + } + }, "node_modules/yargs": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.3.1.tgz", diff --git a/package.json b/package.json index 26c7d08da..4afbd3dfd 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "nyc": "^17.0.0", "proxyquire": "^1.8.0", "requirejs": "^2.3.6", - "rollup": "^4.18.0" + "rollup": "^4.18.0", + "tap-min": "^3.0.0" }, "scripts": { "build": "rollup -c && grunt copy:src-css", diff --git a/src/core/test.js b/src/core/test.js index 1069e713e..f65aa866a 100644 --- a/src/core/test.js +++ b/src/core/test.js @@ -397,12 +397,12 @@ Test.prototype = { } } - // After emitting the js-reporters event we cleanup the assertion data to + // After emitting the event, we trim the assertion data to // avoid leaking it. It is not used by the legacy testDone callbacks. emit('testEnd', this.testReport.end(true)); this.testReport.slimAssertions(); - const test = this; + const test = this; return runLoggingCallbacks('testDone', { name: testName, module: moduleName, diff --git a/test/cli/cli-main.js b/test/cli/cli-main.js index 412174d96..39e058f34 100644 --- a/test/cli/cli-main.js +++ b/test/cli/cli-main.js @@ -2,7 +2,7 @@ const path = require('path'); -const { execute, concurrentMapKeys } = require('./helpers/execute.js'); +const { execute, executeRaw, concurrentMapKeys } = require('./helpers/execute.js'); const { readFixtures } = require('./helpers/fixtures.js'); const FIXTURES_DIR = path.join(__dirname, 'fixtures'); @@ -146,6 +146,51 @@ ok 1 test-object > example test # fail 0`); }); + QUnit.test.if('tap pipe [pass]', !isWindows, async assert => { + const command = 'qunit basic-one.js | ../../../node_modules/.bin/tap-min'; + const execution = await executeRaw(command); + assert.equal(execution.snapshot.trim(), '1 test complete (1ms)'); + }); + + QUnit.test.if('tap pipe [fail]', !isWindows, async assert => { + const command = 'qunit basic-fail.js | ../../../node_modules/.bin/tap-min'; + const execution = await executeRaw(command); + assert.equal(execution.snapshot.trim(), `bar +\tat: undefined +\toperator: undefined +\texpected: true +\tactual: false + +at /qunit/test/cli/fixtures/basic-fail.js:5:14 + + + +3 tests complete (1ms) + +# exit code: 1`); + }); + + QUnit.test.if('tap pipe [error]', !isWindows, async assert => { + assert.timeout(10000); + const command = 'qunit syntax-error.js | ../../../node_modules/.bin/tap-min'; + const execution = await executeRaw(command); + assert.equal(execution.snapshot.trim(), `global failure +\tat: undefined +\toperator: undefined +\texpected: undefined +\tactual: undefined + +ReferenceError: varIsNotDefined is not defined + at /qunit/test/cli/fixtures/syntax-error.js:1:1 + at internal + + + +1 test complete (1ms) + +# exit code: 1`); + }); + // TODO: Workaround fact that child_process.spawn() args array is a lie on Windows. // https://github.com/nodejs/node/issues/29532 // Can't trivially quote since that breaks Linux which would interpret quotes diff --git a/test/cli/fixtures/basic-fail.js b/test/cli/fixtures/basic-fail.js new file mode 100644 index 000000000..1283e068d --- /dev/null +++ b/test/cli/fixtures/basic-fail.js @@ -0,0 +1,9 @@ +QUnit.test('foo', function (assert) { + assert.true(true); +}); +QUnit.test('bar', function (assert) { + assert.true(false); +}); +QUnit.test('baz', function (assert) { + assert.true(true); +}); diff --git a/test/cli/fixtures/events.js b/test/cli/fixtures/events.js index e65281b9c..99091c285 100644 --- a/test/cli/fixtures/events.js +++ b/test/cli/fixtures/events.js @@ -1,7 +1,6 @@ /** * This test file verifies the execution order and contents of events emitted - * by QUnit after the test run finishes. They are expected to adhere to the - * js-reporters standard. + * by QUnit after the test run finishes. */ function removeUnstableProperties (obj) { diff --git a/test/cli/helpers/execute.js b/test/cli/helpers/execute.js index 4a33b2a18..37aa88dbd 100644 --- a/test/cli/helpers/execute.js +++ b/test/cli/helpers/execute.js @@ -54,7 +54,10 @@ function normalize (actual) { // Consolidate subsequent qunit.js frames .replace(/^(\s+at qunit\.js$)(\n\s+at qunit\.js$)+/gm, '$1') // Consolidate subsequent internal frames - .replace(/^(\s+at internal$)(\n\s+at internal$)+/gm, '$1'); + .replace(/^(\s+at internal$)(\n\s+at internal$)+/gm, '$1') + + // Normalize tap-min time duration + .replace(/\(\d+ms\)/g, '(1ms)'); } /** @@ -97,6 +100,37 @@ async function execute (command, options = {}, hook) { hook(spawned); } + return await getProcessResult(spawned); +} + +/** + * Executes the provided command from within the fixtures directory. + * + * This variation of execute() exists to allow for pipes. + * + * @param {string} command + */ +async function executeRaw (command, options = {}) { + options.cwd = path.join(__dirname, '..', 'fixtures'); + options.env = { + PATH: process.env.PATH + }; + + const parts = command.split(' '); + if (parts[0] === 'qunit') { + parts[0] = JSON.stringify(process.execPath) + ' ../../../bin/qunit.js'; + } + if (parts[0] === 'node') { + parts[0] = JSON.stringify(process.execPath); + } + + const cmd = parts.join(' '); + const spawned = cp.exec(cmd, options); + + return await getProcessResult(spawned); +} + +async function getProcessResult (spawned) { const result = { code: null, stdout: '', @@ -144,7 +178,6 @@ async function execute (command, options = {}, hook) { if (result.code) { result.snapshot += (result.snapshot ? '\n\n' : '') + '# exit code: ' + result.code; } - result.command = command; return result; } @@ -207,6 +240,7 @@ function concurrentMapKeys (input, concurrency, asyncFn) { module.exports = { normalize, execute, + executeRaw, concurrentMap, concurrentMapKeys }; diff --git a/test/events-in-test.js b/test/events-in-test.js index 8a7579026..58dabfc19 100644 --- a/test/events-in-test.js +++ b/test/events-in-test.js @@ -1,7 +1,6 @@ /** * This test file verifies the execution order and contents of events emitted - * by QUnit during the test run. They are expected to adhere to the js-reporters - * standard. + * by QUnit during the test run. */ // These tests are order-dependent