Skip to content

Commit

Permalink
Core: Expose QUnit.urlParams, fix preconfig conflict, set initial exp…
Browse files Browse the repository at this point in the history
…licitly

* Always expose QUnit.urlParams

  Previously this API was left undefined in non-browser environments.
  To make usage easier and safer in environment-agnostic code,
  export the object always, with it remaining an empty object if
  globalThis.window.location is undefined.

* Fix broken preconfig for built-in urlConfig options.

  When the preconfig feature was first introduced in QUnit 2.1.0 (2016),
  it did not work for certain config keys. In urlparams.js we
  unconditionally re-assign some keys. Thus, even if preconfig sets
  one of these, and QUnit.urlParams does not, we'd effectively set it
  to undefined, wiping away the preconfig value.

  - QUnit.config.moduleId
  - QUnit.config.testId
  - QUnit.config.module
  - QUnit.config.filter

* Fix missing "unknown" option in custom menu defined by urlConfig.

  `let selection = false;` was scoped incorrectly in the urlConfig loop
  in html.js, causing it to only work for the first custom menu.

  When registering two or more custom menus, any non-first menu with
  an unknown value would be missing `<option selected disabled>c</option>`
  in the dropdown menu.

* Apply type validation for built-in curlConfig options. This fixes
  cases where, via urlParams, one could set a core config key to an
  invalid value (wrong type):

  - ?hidepassed=true (was string "true", now bool true)
    Previously, hidepassed=false was treated as truthy, which will now
    be treated as false/off.

    Invalid values like ?hidepassed=x will now also be treated as false.

    Since hidepassed=1 is not unheard of in the wild, this is
    now a supported boolean value.

  - ?filter=one&filter=two (was array, now undefined)
    Previously this would inject an invalid value, causing a runtime
    TypeError due to internal code calling String methods on it.

* Set the initial default values of `hidepassed`, `noglobals`, and
  `notrycatch` explicitly in config.js, instead of leaving them
  as implicitly undefined when not turned on. Especially because
  "noglboals" and "notrycatch" are valid in CLI as well.
  • Loading branch information
Krinkle committed Jun 17, 2024
1 parent 77f1d90 commit 57c2dbc
Show file tree
Hide file tree
Showing 15 changed files with 246 additions and 137 deletions.
1 change: 1 addition & 0 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ module.exports = function (grunt) {
'test/only-each.html',
'test/overload.html',
'test/performance-mark.html',
'test/preconfig-flat-testId.html',
'test/preconfig-flat.html',
'test/preconfig-object.html',
'test/reorder.html',
Expand Down
14 changes: 7 additions & 7 deletions docs/api/config/urlConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ redirect_from:
version_added: "1.0.0"
---

In the HTML Reporter, this array is used to generate additional input fields in the toolbar.
In the HTML Reporter, this array is used to create additional input fields in the toolbar.

<table>
<tr>
Expand All @@ -23,7 +23,7 @@ In the HTML Reporter, this array is used to generate additional input fields in
</tr>
</table>

This property controls which form controls to put into the QUnit toolbar. By default, the `noglobals` and `notrycatch` checkboxes are registered. By adding to this array, you can add your own checkboxes and select dropdowns.
This property controls which form controls display in the QUnit toolbar. By default, the `noglobals` and `notrycatch` checkboxes are registered. By adding to this array, the HTML Reporter will add extra checkboxes and select dropdowns for your custom configuration.

Each array item should be an object shaped as follows:

Expand All @@ -32,15 +32,15 @@ Each array item should be an object shaped as follows:
id: string,
label: string,
tooltip: string, // optional
value: string | array | object // optional
value: undefined | string | array | object // optional
});
```

* The `id` property is used as the key for storing the value under `QUnit.config`, and as URL query parameter.
* The `label` property is used as text label in the user interface.
* The optional `tooltip` property is used as the `title` attribute and should explain what the control is used for.
* The `id` property is used as URL query parameter name, and corresponding key under `QUnit.urlParams`.
* The `label` property is used as text for the HTML label element in the user interface.
* The optional `tooltip` property is used as the `title` attribute and should explain what you code will do with this option.

Each element should also have a `value` property controlling available options and rendering.
Each item may also have a `value` property:

If `value` is undefined, the option will render as a checkbox. The corresponding URL parameter will be set to "true" when the checkbox is checked, and otherwise will be absent.

Expand Down
2 changes: 2 additions & 0 deletions src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { registerLoggingCallbacks, runLoggingCallbacks } from './core/logging';
import { sourceFromStacktrace } from './core/stacktrace';
import ProcessingQueue from './core/processing-queue';

import { urlParams } from './urlparams';
import { on, emit } from './events';
import onUncaughtException from './core/on-uncaught-exception';
import diff from './core/diff';
Expand All @@ -39,6 +40,7 @@ QUnit.version = '@VERSION';

extend(QUnit, {
config,
urlParams,

diff,
dump,
Expand Down
53 changes: 50 additions & 3 deletions src/core/config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { globalThis, process, localSessionStorage } from '../globals';
import { urlParams } from '../urlparams';
import { extend } from './utilities';

/**
Expand Down Expand Up @@ -26,6 +27,9 @@ const config = {

fixture: undefined,

// HTML Reporter: Hide results of passed tests.
hidepassed: false,

// Depth up-to which object will be dumped
maxDepth: 5,

Expand All @@ -35,6 +39,10 @@ const config = {
// HTML Reporter: Select module/test by array of internal IDs
moduleId: undefined,

noglobals: false,

notrycatch: false,

// By default, run previously failed tests first
// very useful in combination with "Hide passed tests" checked
reorder: true,
Expand All @@ -60,8 +68,27 @@ const config = {
// long-running scripts.
updateRate: 1000,

// HTML Reporter: List of URL parameters that are given visual controls
urlConfig: [],
// HTML Reporter: List of URL parameters that are given visual controls.
// These are given an `<input type=checkbox/>` or `<select/>` by the HTML Reporter.
// Values can be read from QUnit.urlParams.
urlConfig: [
{
id: 'hidepassed',
label: 'Hide passed tests',
tooltip: 'Only show tests and assertions that fail. Stored as query string.'
},
{
id: 'noglobals',
label: 'Check for globals',
tooltip: 'Enabling this will test if any test introduces new properties on the ' +
'global object (e.g. `window` in browsers). Stored as query string.'
},
{
id: 'notrycatch',
label: 'No try-catch',
tooltip: 'Enabling this will run tests outside of a try-catch block. Stored as query string.'
}
],

// Internal: The first unnamed module
//
Expand Down Expand Up @@ -134,7 +161,7 @@ const config = {

function readFlatPreconfigBoolean (val, dest) {
if (typeof val === 'boolean' || (typeof val === 'string' && val !== '')) {
config[dest] = (val === true || val === 'true');
config[dest] = (val === true || val === 'true' || val === '1');
}
}

Expand Down Expand Up @@ -191,6 +218,26 @@ if (preConfig) {
extend(config, preConfig);
}

// Apply QUnit.urlParams
// in accordance with /docs/api/config.index.md#order
readFlatPreconfigString(urlParams.filter, 'filter');
readFlatPreconfigString(urlParams.module, 'module');
if (urlParams.moduleId) {
config.moduleId = [].concat(urlParams.moduleId);
}
if (urlParams.testId) {
config.testId = [].concat(urlParams.testId);
}
readFlatPreconfigBoolean(urlParams.hidepassed, 'hidepassed');
readFlatPreconfigBoolean(urlParams.noglobals, 'noglobals');
readFlatPreconfigBoolean(urlParams.notrycatch, 'notrycatch');
if (urlParams.seed === true) {
// Generate a random seed if the option is specified without a value
config.seed = Math.random().toString(36).slice(2);
} else {
readFlatPreconfigString(urlParams.seed, 'seed');
}

// Push a loose unnamed module to the modules collection
config.modules.push(config.currentModule);

Expand Down
15 changes: 8 additions & 7 deletions src/html-reporter/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ const stats = {
}

function getUrlConfigHtml () {
let selection = false;
const urlConfig = config.urlConfig;
let urlConfigHtml = '';

Expand All @@ -116,6 +115,7 @@ const stats = {
label: val
};
}
const currentVal = config[val.id];

let escaped = escapeText(val.id);
let escapedTooltip = escapeText(val.tooltip);
Expand All @@ -125,9 +125,10 @@ const stats = {
"' title='" + escapedTooltip + "'><input id='qunit-urlconfig-" + escaped +
"' name='" + escaped + "' type='checkbox'" +
(val.value ? " value='" + escapeText(val.value) + "'" : '') +
(config[val.id] ? " checked='checked'" : '') +
(currentVal ? " checked='checked'" : '') +
" title='" + escapedTooltip + "' />" + escapeText(val.label) + '</label>';
} else {
let selection = false;
urlConfigHtml += "<label for='qunit-urlconfig-" + escaped +
"' title='" + escapedTooltip + "'>" + val.label +
": </label><select id='qunit-urlconfig-" + escaped +
Expand All @@ -137,7 +138,7 @@ const stats = {
for (let j = 0; j < val.value.length; j++) {
escaped = escapeText(val.value[j]);
urlConfigHtml += "<option value='" + escaped + "'" +
(config[val.id] === val.value[j]
(currentVal === val.value[j]
? (selection = true) && " selected='selected'"
: '') +
'>' + escaped + '</option>';
Expand All @@ -146,15 +147,15 @@ const stats = {
for (let j in val.value) {
if (hasOwn.call(val.value, j)) {
urlConfigHtml += "<option value='" + escapeText(j) + "'" +
(config[val.id] === j
(currentVal === j
? (selection = true) && " selected='selected'"
: '') +
'>' + escapeText(val.value[j]) + '</option>';
}
}
}
if (config[val.id] && !selection) {
escaped = escapeText(config[val.id]);
if (currentVal && !selection) {
escaped = escapeText(currentVal);
urlConfigHtml += "<option value='" + escaped +
"' selected='selected' disabled='disabled'>" + escaped + '</option>';
}
Expand Down Expand Up @@ -343,7 +344,7 @@ const stats = {
}
};

if (config.moduleId.length) {
if (config.moduleId && config.moduleId.length) {
// The module dropdown is seeded with the runtime configuration of the last run.
//
// We don't reference `config.moduleId` directly after this and keep our own
Expand Down
110 changes: 22 additions & 88 deletions src/html-runner/urlparams.js
Original file line number Diff line number Diff line change
@@ -1,96 +1,30 @@
import QUnit from '../core';
import { window } from '../globals';

(function () {
// Only interact with URLs via window.location
const location = typeof window !== 'undefined' && window.location;
if (!location) {
return;
}

const urlParams = getUrlParams();

// TODO: Move to /src/core/ in QUnit 3
// TODO: Document this as public API (read-only)
QUnit.urlParams = urlParams;

// TODO: Move to /src/core/config.js in QUnit 3,
// in accordance with /docs/api/config.index.md#order
QUnit.config.filter = urlParams.filter;
QUnit.config.module = urlParams.module;
QUnit.config.moduleId = [].concat(urlParams.moduleId || []);
QUnit.config.testId = [].concat(urlParams.testId || []);

// Test order randomization
if (urlParams.seed === true) {
// Generate a random seed if the option is specified without a value
QUnit.config.seed = Math.random().toString(36).slice(2);
} else if (urlParams.seed) {
QUnit.config.seed = urlParams.seed;
}

// Add URL-parameter-mapped config values with UI form rendering data
QUnit.config.urlConfig.push(
{
id: 'hidepassed',
label: 'Hide passed tests',
tooltip: 'Only show tests and assertions that fail. Stored as query-strings.'
},
{
id: 'noglobals',
label: 'Check for Globals',
tooltip: 'Enabling this will test if any test introduces new properties on the ' +
'global object (`window` in Browsers). Stored as query-strings.'
},
{
id: 'notrycatch',
label: 'No try-catch',
tooltip: 'Enabling this will run tests outside of a try-catch block. Makes debugging ' +
'exceptions in IE reasonable. Stored as query-strings.'
}
);

QUnit.begin(function () {
const urlConfig = QUnit.config.urlConfig;
const hasOwn = Object.prototype.hasOwnProperty;

for (let i = 0; i < urlConfig.length; i++) {
// Options can be either strings or objects with nonempty "id" properties
let option = QUnit.config.urlConfig[i];
if (typeof option !== 'string') {
option = option.id;
}
// Wait until QUnit.begin() so that users can add their keys to urlConfig
// any time during test loading, including during `QUnit.on('runStart')`.
QUnit.begin(function () {
const urlConfig = QUnit.config.urlConfig;

if (QUnit.config[option] === undefined) {
QUnit.config[option] = urlParams[option];
}
for (let i = 0; i < urlConfig.length; i++) {
// Options can be either strings or objects with nonempty "id" properties
let option = QUnit.config.urlConfig[i];
if (typeof option !== 'string') {
option = option.id;
}
});

function getUrlParams () {
const urlParams = Object.create(null);
const params = location.search.slice(1).split('&');
const length = params.length;

for (let i = 0; i < length; i++) {
if (params[i]) {
const param = params[i].split('=');
const name = decodeQueryParam(param[0]);

// Allow just a key to turn on a flag, e.g., test.html?noglobals
const value = param.length === 1 ||
decodeQueryParam(param.slice(1).join('='));
if (name in urlParams) {
urlParams[name] = [].concat(urlParams[name], value);
} else {
urlParams[name] = value;
}
}
// only create new property for user-defined QUnit.config.urlConfig keys
// that don't conflict with a built-in QUnit.config option or are otherwise
// already set. This prevents internal TypeError from bad urls where keys
// could otherwise unexpectedly be set to type string or array.
//
// Given that HTML Reporter renders checkboxes based on QUnit.config
// instead of QUnit.urlParams, this also helps make sure that checkboxes
// for built-in keys are correctly shown as off if a urlParams value exists
// but was invalid and discarded by config.js.
if (!hasOwn.call(QUnit.config, option)) {
QUnit.config[option] = QUnit.urlParams[option];
}

return urlParams;
}

function decodeQueryParam (param) {
return decodeURIComponent(param.replace(/\+/g, '%20'));
}
}());
});
36 changes: 36 additions & 0 deletions src/urlparams.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { window } from './globals';

function getUrlParams () {
const urlParams = Object.create(null);
// Only interact with URLs via window.location
const location = typeof window !== 'undefined' && window.location;
// Silently skip in non-browser environment
if (location) {
const params = location.search.slice(1).split('&');
const length = params.length;

for (let i = 0; i < length; i++) {
if (params[i]) {
const param = params[i].split('=');
const name = decodeQueryParam(param[0]);

// Allow just a key to turn on a flag, e.g., test.html?noglobals
const value = param.length === 1 ||
decodeQueryParam(param.slice(1).join('='));
if (name in urlParams) {
urlParams[name] = [].concat(urlParams[name], value);
} else {
urlParams[name] = value;
}
}
}
}

return urlParams;
}

function decodeQueryParam (param) {
return decodeURIComponent(param.replace(/\+/g, '%20'));
}

export const urlParams = getUrlParams();
6 changes: 5 additions & 1 deletion test/cli/cli-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ QUnit.module('CLI Main', () => {
env: {
qunit_config_filter: '!foobar',
qunit_config_seed: 'dummyfirstyes',
qunit_config_testtimeout: '7'
qunit_config_testtimeout: '7',

qunit_config_altertitle: 'true',
qunit_config_noglobals: '1',
qunit_config_notrycatch: 'false'
}
});
assert.equal(execution.snapshot, `TAP version 13
Expand Down
Loading

0 comments on commit 57c2dbc

Please sign in to comment.