├── .gitignore ├── LICENSE ├── README.json ├── README.md ├── _build ├── eleventy.js └── filters.js ├── _data └── eleventyComputed.js ├── _headers ├── _includes ├── docs.njk └── page.njk ├── _redirects ├── assets ├── defaults.css ├── images │ ├── logo.png │ ├── logo.svg │ └── terminal-output.png ├── prism.css ├── prism.js ├── style.css └── videos │ └── interactive-cli.mp4 ├── bin └── htest.js ├── docs ├── README.md ├── define │ └── README.md ├── docs.json ├── overview.md └── run │ ├── README.md │ ├── console │ └── README.md │ ├── html │ └── README.md │ └── node │ └── README.md ├── eslint.config.js ├── htest.css ├── htest.js ├── package-lock.json ├── package.json ├── src ├── check.js ├── classes │ ├── BubblingEventTarget.js │ ├── Test.js │ └── TestResult.js ├── cli.js ├── content.js ├── env │ ├── auto.js │ ├── console.js │ ├── index.js │ └── node.js ├── format-console.js ├── hooks.js ├── index.js ├── map.js ├── objects.js ├── render.js ├── run.js └── util.js ├── tests ├── check.js ├── failing-tests.js ├── format-console.js ├── index.js ├── run.js ├── stringify.js └── throws.js ├── tsconfig.json └── typedoc.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.html 3 | api/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lea Verou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Declarative unit testing" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # **h**Test 4 | 5 | Declarative, boilerplate-free unit testing, for everyone. 6 | 7 | https://htest.dev 8 |
9 |
10 | 11 | 21 | 22 |
23 | 24 | ## Features at a glance 25 | 26 | - **Friendly**: Never used a unit test framework before? No problem! hTest is designed to be as friendly as possible to newcomers. 27 | - **Declarative**: Write your tests as nested object literals, with nice, readable syntax. 28 | - **Flexible**: You decide where your tests live, across how many files, and how they’re grouped. Anything you can `import`, you can test. 29 | - **Boilerplate-free**: Any commonalities between tests only specified once on the parent, and inherited. Anything that can be optional, is. 30 | - **Quick to write**: Most tests only need two properties: `args` and `expect`. No more excuses for not testing your utility functions! 31 | - **ESM-first**: Written in ESM from the get-go. 32 | - **CLI and browser**: Run your tests in the command line, in the browser, or both. 33 | - **CI-ready**: Fully compatible with continuous integration and automated test running processes. 34 | - **Optional HTML-first mode**: Working on UI-heavy code? Write tests in HTML, with reactive evaluation and mock interactions! 35 | 36 | ## Installation 37 | 38 | ```sh 39 | npm install htest.dev 40 | ``` 41 | 42 | ## Quick start 43 | 44 | Suppose you have written a utility function `sum()` that takes a variable number of arguments and adds them together. 45 | Testing it could be as simple as: 46 | 47 | ```js 48 | import { sum } from "../src/util.js"; 49 | 50 | export default { 51 | run: sum, 52 | tests: [ 53 | { 54 | arg: 1, 55 | expect: 1 56 | }, 57 | { 58 | args: [1, 2, 3], 59 | expect: 6 60 | }, 61 | { 62 | args: [], 63 | expect: undefined 64 | } 65 | ] 66 | } 67 | ``` 68 | 69 | Yes, **that’s really it**! 70 | You can add `name`, `description` and other metadata if you want, but you don’t have to. 71 | 72 | But the real power of hTest is in its nested structure. 73 | Suppose we wanted to add more tests for `sum()`, e.g. for the case where you’re summing with `NaN`. 74 | We can abstract away the commonality between these tests, and write them as a nested object: 75 | 76 | ```js 77 | import { sum } from "../src/util.js"; 78 | 79 | export default { 80 | run: sum, 81 | tests: [ 82 | { 83 | arg: 1, 84 | expect: 1 85 | }, 86 | { 87 | args: [1, 2, 3], 88 | expect: 6 89 | }, 90 | { 91 | args: [], 92 | expect: undefined 93 | }, 94 | { 95 | name: "With NaN", 96 | run (...args) { 97 | return sum(NaN, ...args); 98 | }, 99 | expect: NaN, 100 | tests: [ 101 | { 102 | args: [1, 2, 3], 103 | }, 104 | { 105 | args: [], 106 | }, 107 | { 108 | args: [NaN] 109 | } 110 | ] 111 | } 112 | ] 113 | } 114 | ``` 115 | 116 | Now let’s suppose these NaN tests grew too much to be maintained in a single file. You can just move them whenever you want, and import them: 117 | 118 | ```js 119 | import { sum } from "../src/util.js"; 120 | import NaNTests from "./sum-nan.js"; 121 | 122 | export default { 123 | run: sum, 124 | tests: [ 125 | { 126 | arg: 1, 127 | expect: 1 128 | }, 129 | { 130 | args: [1, 2, 3], 131 | expect: 6 132 | }, 133 | { 134 | args: [], 135 | expect: undefined 136 | }, 137 | NaNTests 138 | ] 139 | } 140 | ``` 141 | 142 | Of course this is a rather contrived example, but it showcases some of the essence of hTest. 143 | 144 | ## What the hTest? Do we really need another unit testing framework? 145 | 146 | Unit testing is hard enough as it stands — the more friction in writing tests, the fewer get written. 147 | **hTest** is a unit testing framework that focuses on making it as quick and painless as possible to write tests. 148 | Forget nested function calls with repetitive code. 149 | hTest aims to eliminate all boilerplate, so you can focus on writing the actual tests. 150 | 151 | 152 | 153 | hTest can be used in one of two ways: [JS-first](docs/define/js/) or [HTML-first](https://html.htest.dev/). 154 | 155 | 156 | 157 | 158 | 162 | 166 | 167 | 168 | 169 | 170 | 175 | 178 | 179 | 186 | 194 | 195 | 196 |
159 | 160 | JS-first mode 161 | 163 | 164 | HTML-first mode 165 |
171 | 172 | Write your tests in nested object literals, and you can [run them either in Node](docs/run/node) or [in the browser](docs/run/html). 173 | Tests inherit values they don’t specify from their parents, so you never have to repeat yourself. 174 | 176 | 177 | Write your tests in HTML files and run them only in the browser.
180 | 181 | * More suitable for pure JS code. 182 | * Compatible with CI and other automated test running processes. 183 | * Code must be compatible with Node to use the Node test runner. 184 | 185 | 187 | 188 | * More suitable for UI-heavy code. 189 | * Pass-criteria extends beyond value matching or error catching, and could even be things like what CSS selectors match or what the DOM looks like. 190 | * Reactive evaluation: if the HTML changes or the user interacts with the UI, relevant tests are re-evaluated. 191 | * Mock interactions like click or focus with HTML attributes. 192 | 193 |
197 | 198 | You can even mix and match the two modes in the same testsuite! 199 | E.g. even a UI-heavy library has many JS-only functions that are better tested via JS-first tests. 200 | 201 | ## Interactive CLI output 202 | 203 | ![Sample terminal output](assets/images/terminal-output.png) 204 | 205 | The CLI output with test results is built as an _interactive tree_ that starts collapsed but can be navigated and expanded with the keyboard. Messages written to the console while the test suite runs are preserved and part of the corresponding test results. 206 | 207 | 210 | 211 | ### Supported keys and keyboard shortcuts 212 | 213 | - — “Go Level Up” 214 | - — “Go Level Down” 215 | - — “Expand Group” 216 | - — “Collapse Group.” Consecutively press to collapse the current group first, then the parent group, then the grandparent group, and so on. 217 | - Ctrl+ — “Go to the First Child of a Group” 218 | - Ctrl+ — "Go to the Last Child of a Group" 219 | - Ctrl+ — "Expand Subtree" (starting from the current group) 220 | - Ctrl+ — "Collapse Subtree" (including the current group) 221 | - Ctrl+Shift+ — "Expand All" 222 | - Ctrl+Shift+ — "Collapse All" 223 | 224 | ## Roadmap 225 | 226 | hTest is still a work in progress, but is stable enough to be used in production. 227 | It was soft launched in Q4 2023, but has been in use since 2022 (2017 if you count its early precursor — though that only included the HTML syntax). 228 | 229 | The main things that still need to be done before launch are: 230 | * Improve documentation — this is top priority, people keep asking for things that are already possible because they’re not documented well! 231 | * Fix CLI output glitches 232 | * Implement watch functionality 233 | * Ensure we're not missing essential use cases 234 | 235 | Any help with these would be greatly appreciated! 236 | 237 | ## hTest in the wild 238 | 239 | ### JS-first testsuites 240 | 241 | * [Color.js](https://colorjs.io/test/) [\[source\]](https://github.com/color-js/color.js/tree/main/test) 242 | * [vᴀꜱᴛly](https://vastly.mavo.io/test/) [\[source\]](https://github.com/mavoweb/vastly/tree/main/test) 243 | 244 | ### HTML-first testsuites 245 | 246 | #### Testsuites 247 | 248 | * [Color.js old testsuite](https://colorjs.io/tests/) 249 | * [Mavo](https://test.mavo.io) (using a precursor of hTest) 250 | 251 | #### Single page tests 252 | 253 | * [Parsel](https://parsel.verou.me/test.html) 254 | * [Stretchy](https://stretchy.verou.me/test.html) 255 | 256 |
257 | -------------------------------------------------------------------------------- /_build/eleventy.js: -------------------------------------------------------------------------------- 1 | import markdownIt from "markdown-it"; 2 | import anchor from "markdown-it-anchor"; 3 | import markdownItAttrs from "markdown-it-attrs"; 4 | import pluginTOC from "eleventy-plugin-toc"; 5 | import eleventyNavigationPlugin from "@11ty/eleventy-navigation"; 6 | import { createRequire } from "module"; 7 | const require = createRequire(import.meta.url); 8 | import * as filters from "./filters.js"; 9 | 10 | export default eleventyConfig => { 11 | let data = { 12 | "layout": "page.njk", 13 | "permalink": "{{ page.filePathStem | replace('README', 'index') }}.html", 14 | }; 15 | 16 | for (let p in data) { 17 | eleventyConfig.addGlobalData(p, data[p]); 18 | } 19 | 20 | eleventyConfig.setDataDeepMerge(true); 21 | 22 | eleventyConfig.setLibrary("md", markdownIt({ 23 | html: true, 24 | }) 25 | .disable("code") 26 | .use(markdownItAttrs) 27 | .use(anchor, { 28 | permalink: anchor.permalink.headerLink(), 29 | }), 30 | ); 31 | 32 | for (let f in filters) { 33 | eleventyConfig.addFilter(f, filters[f]); 34 | } 35 | 36 | eleventyConfig.addPlugin(eleventyNavigationPlugin); 37 | eleventyConfig.addPlugin(pluginTOC); 38 | 39 | return { 40 | markdownTemplateEngine: "njk", 41 | templateFormats: ["md", "njk"], 42 | dir: { 43 | output: ".", 44 | }, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /_build/filters.js: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | export function relative (page) { 4 | if (!page.url) { 5 | return ""; 6 | } 7 | 8 | let pagePath = page.url.replace(/[^/]+$/, ""); 9 | let ret = path.relative(pagePath, "/"); 10 | 11 | return ret || "."; 12 | } 13 | 14 | export function safeDump (o) { 15 | var cache = new WeakSet(); 16 | 17 | return JSON.stringify(o, (key, value) => { 18 | if (typeof value === "object" && value !== null) { 19 | // No circular reference found 20 | 21 | if (cache.has(value)) { 22 | return; // Circular reference found! 23 | } 24 | 25 | cache.add(value); 26 | } 27 | 28 | return value; 29 | }, "\t"); 30 | } 31 | -------------------------------------------------------------------------------- /_data/eleventyComputed.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Extract default title from content 3 | title (data) { 4 | if (data.title) { 5 | return data.title; 6 | } 7 | 8 | let ext = data.page.inputPath.split(".").pop(); 9 | 10 | // Title must appear in first 1000 chars 11 | let content = data.page.rawInput.slice(0, 1000); 12 | 13 | if (ext === "md") { 14 | // First heading 15 | return content.match(/^#+\s+(.*)/m)?.[1]; 16 | } 17 | else if (ext === "njk") { 18 | // First level 1 heading 19 | return content.match(/

(.*)<\/h1>/)?.[1]; 20 | } 21 | }, 22 | eleventyNavigation: { 23 | key: data => data.navKey ?? data.page.url, 24 | title: data => data.nav ?? data.title, 25 | parent: data => { 26 | if (data.parent) { 27 | return data.parent; 28 | } 29 | 30 | let parts = data.page.url.split("/"); 31 | let i = parts.findLastIndex((part, i) => part); 32 | parts.splice(i, 1); 33 | return parts.join("/"); 34 | }, 35 | order: data => data.order, 36 | url: data => data.url ?? data.page.url, 37 | }, 38 | allData (data) { 39 | return data; 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Access-Control-Allow-Origin: * 3 | -------------------------------------------------------------------------------- /_includes/docs.njk: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | --- 4 | 5 | 29 | 30 |
31 | {{ content | safe }} 32 |
33 | -------------------------------------------------------------------------------- /_includes/page.njk: -------------------------------------------------------------------------------- 1 | {%- set toc = toc and content | toc -%} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if title -%} 9 | {{ title }} • hTest 10 | {%- else -%} 11 | hTest: Declarative unit testing, for everyone 12 | {%- endif %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 |
23 |

24 | Logo showing a white checkmark on a greenish background hTest 25 |

26 | 27 |

Declarative unit testing,
for everyone

28 |
29 | 30 | 37 | 38 |
39 | 40 |
41 | 42 | {% if not ('
' in content ) %} 43 |
44 | {% endif %} 45 | 46 | {{ content | safe }} 47 | 48 | {% if not ('
' in content ) %} 49 |
50 | {% endif %} 51 | 52 | {% if toc %} 53 | 59 | {% endif %} 60 | 61 |
62 | 63 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | # Docs 2 | /docs/define/html https://html.htest.dev 301 3 | 4 | # RefTest class 5 | /src/html/reftest.js https://html.htest.dev/src/classes/RefTest.js 301 6 | 7 | # All other files 8 | /src/html/* https://html.htest.dev/src/:splat 301 9 | -------------------------------------------------------------------------------- /assets/defaults.css: -------------------------------------------------------------------------------- 1 | /* Default styles for plain HTML elements */ 2 | h1, h2, h3, h4, h5, h6, p, ul, ol, dl { 3 | margin-block: .5rem; 4 | } 5 | 6 | h1, h2, h3, h4, h5, h6 { 7 | line-height: 1.1; 8 | margin-top: 1.5rem; 9 | } 10 | 11 | ul, ol { 12 | & & { 13 | padding-inline-start: 2em; 14 | } 15 | &:not(& *) { 16 | padding-inline-start: 0; 17 | } 18 | } 19 | 20 | pre { 21 | --padding-block: .6rem; 22 | border-radius: .25rem; 23 | } 24 | 25 | table { 26 | border-spacing: 0; 27 | border-collapse: collapse; 28 | margin-bottom: 1em; 29 | background: white; 30 | 31 | thead { 32 | th { 33 | padding-block: 0; 34 | text-align: left; 35 | background: var(--color-accent); 36 | color: white; 37 | line-height: 1.5; 38 | } 39 | } 40 | 41 | th, td { 42 | border-bottom: 1px solid var(--color-neutral-85); 43 | padding-inline: .4rem; 44 | } 45 | 46 | 47 | 48 | td { 49 | vertical-align: top; 50 | font-variant-numeric: tabular-nums; 51 | padding-block: .2rem; 52 | } 53 | } 54 | 55 | video { 56 | max-width: 100%; 57 | } 58 | -------------------------------------------------------------------------------- /assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htest-dev/htest/9ad8883ce94affc92a2d6c408e79e09bc8fafeac/assets/images/logo.png -------------------------------------------------------------------------------- /assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/images/terminal-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htest-dev/htest/9ad8883ce94affc92a2d6c408e79e09bc8fafeac/assets/images/terminal-output.png -------------------------------------------------------------------------------- /assets/prism.css: -------------------------------------------------------------------------------- 1 | pre { 2 | background-color: var(--wa-color-gray-20); 3 | color: white; 4 | 5 | /* Ensures a discernible background color in dark mode 6 | * Useful for themes that use gray-20 as --wa-color-surface-default */ 7 | .wa-dark & { 8 | background-color: var(--wa-color-surface-lowered); 9 | } 10 | } 11 | 12 | code[class*="language-"]:not(pre[class*="language-"] > *) .token { 13 | color: var(--wa-color-text-normal); 14 | } 15 | 16 | .token.comment, 17 | .token.prolog, 18 | .token.doctype, 19 | .token.cdata, 20 | .token.operator, 21 | .token.punctuation { 22 | color: var(--wa-color-gray-80); 23 | } 24 | 25 | .token.namespace { 26 | opacity: 0.7; 27 | } 28 | 29 | .token.property, 30 | .token.tag, 31 | .token.url { 32 | color: var(--wa-color-indigo-80); 33 | } 34 | 35 | .token.keyword { 36 | font-weight: bold; 37 | color: var(--wa-color-indigo-70); 38 | } 39 | 40 | .token.symbol, 41 | .token.deleted, 42 | .token.important { 43 | color: var(--wa-color-red-80); 44 | } 45 | 46 | .token.boolean, 47 | .token.constant, 48 | .token.selector, 49 | .token.attr-name, 50 | .token.string, 51 | .token.char, 52 | .token.builtin, 53 | .token.inserted { 54 | color: var(--wa-color-green-80); 55 | } 56 | 57 | .token.atrule, 58 | .token.attr-value, 59 | .token.number, 60 | .token.variable, 61 | .token.function, 62 | .token.class-name, 63 | .token.regex { 64 | color: var(--wa-color-blue-80); 65 | } 66 | 67 | .token.important, 68 | .token.bold { 69 | font-weight: bold; 70 | } 71 | 72 | .token.italic { 73 | font-style: italic; 74 | } 75 | -------------------------------------------------------------------------------- /assets/prism.js: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.29.0 2 | https://prismjs.com/download.html#themes=prism-solarizedlight&languages=markup+css+clike+javascript+bash+shell-session&plugins=keep-markup+normalize-whitespace */ 3 | var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(jg.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); 4 | Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; 5 | !function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism); 6 | Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; 7 | Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript; 8 | !function(e){var t="\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b",a={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:"punctuation",inside:null},n={bash:a,environment:{pattern:RegExp("\\$"+t),alias:"constant"},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp("(\\{)"+t),lookbehind:!0,alias:"constant"}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:"important"},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:"function"},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:"function"}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:"variable",lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp("(^|[\\s;|&]|[<>]\\()"+t),lookbehind:!0,alias:"constant"}},alias:"variable",lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:"variable",lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:n},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:a}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:n},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:n.entity}}],environment:{pattern:RegExp("\\$?"+t),alias:"constant"},variable:n.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:"class-name"},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:"important"},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:"important"}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},a.inside=e.languages.bash;for(var s=["comment","function-name","for-or-select","assign-left","parameter","string","environment","function","keyword","builtin","boolean","file-descriptor","operator","punctuation","number"],o=n.variable[1].inside,i=0;i:;|]+)?|[/~.][^\0-\\x1F$#%*?"<>@:;|]*)?[$#%](?=\\s)'+"(?:[^\\\\\r\n \t'\"<$]|[ \t](?:(?!#)|#.*$)|\\\\(?:[^\r]|\r\n?)|\\$(?!')|<(?!<)|<>)+".replace(/<>/g,(function(){return n})),"m"),greedy:!0,inside:{info:{pattern:/^[^#$%]+/,alias:"punctuation",inside:{user:/^[^\s@:$#%*!/\\]+@[^\r\n@:$#%*!/\\]+/,punctuation:/:/,path:/[\s\S]+/}},bash:{pattern:/(^[$#%]\s*)\S[\s\S]*/,lookbehind:!0,alias:"language-bash",inside:s.languages.bash},"shell-symbol":{pattern:/^[$#%]/,alias:"important"}}},output:/.(?:.*(?:[\r\n]|.$))*/},s.languages["sh-session"]=s.languages.shellsession=s.languages["shell-session"]}(Prism); 10 | "undefined"!=typeof Prism&&"undefined"!=typeof document&&document.createRange&&(Prism.plugins.KeepMarkup=!0,Prism.hooks.add("before-highlight",(function(e){if(e.element.children.length&&Prism.util.isActive(e.element,"keep-markup",!0)){var n=Prism.util.isActive(e.element,"drop-tokens",!1),t=0,o=[];r(e.element),o.length&&(e.keepMarkup=o)}function d(e){if(function(e){return!n||"span"!==e.nodeName.toLowerCase()||!e.classList.contains("token")}(e)){var d={element:e,posOpen:t};o.push(d),r(e),d.posClose=t}else r(e)}function r(e){for(var n=0,o=e.childNodes.length;nt.node.posOpen&&(t.nodeStart=r,t.nodeStartPos=t.node.posOpen-t.pos),t.nodeStart&&t.pos+r.data.length>=t.node.posClose&&(t.nodeEnd=r,t.nodeEndPos=t.node.posClose-t.pos),t.pos+=r.data.length);if(t.nodeStart&&t.nodeEnd){var s=document.createRange();return s.setStart(t.nodeStart,t.nodeStartPos),s.setEnd(t.nodeEnd,t.nodeEndPos),t.node.element.innerHTML="",t.node.element.appendChild(s.extractContents()),s.insertNode(t.node.element),s.detach(),!1}}return!0};e.keepMarkup.forEach((function(t){n(e.element,{node:t,pos:0})})),e.highlightedCode=e.element.innerHTML}}))); 11 | !function(){if("undefined"!=typeof Prism){var e=Object.assign||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e},t={"remove-trailing":"boolean","remove-indent":"boolean","left-trim":"boolean","right-trim":"boolean","break-lines":"number",indent:"number","remove-initial-line-feed":"boolean","tabs-to-spaces":"number","spaces-to-tabs":"number"};n.prototype={setDefaults:function(t){this.defaults=e(this.defaults,t)},normalize:function(t,n){for(var r in n=e(this.defaults,n)){var i=r.replace(/-(\w)/g,(function(e,t){return t.toUpperCase()}));"normalize"!==r&&"setDefaults"!==i&&n[r]&&this[i]&&(t=this[i].call(this,t,n[r]))}return t},leftTrim:function(e){return e.replace(/^\s+/,"")},rightTrim:function(e){return e.replace(/\s+$/,"")},tabsToSpaces:function(e,t){return t=0|t||4,e.replace(/\t/g,new Array(++t).join(" "))},spacesToTabs:function(e,t){return t=0|t||4,e.replace(RegExp(" {"+t+"}","g"),"\t")},removeTrailing:function(e){return e.replace(/\s*?$/gm,"")},removeInitialLineFeed:function(e){return e.replace(/^(?:\r?\n|\r)/,"")},removeIndent:function(e){var t=e.match(/^[^\S\n\r]*(?=\S)/gm);return t&&t[0].length?(t.sort((function(e,t){return e.length-t.length})),t[0].length?e.replace(RegExp("^"+t[0],"gm"),""):e):e},indent:function(e,t){return e.replace(/^[^\S\n\r]*(?=\S)/gm,new Array(++t).join("\t")+"$&")},breakLines:function(e,t){t=!0===t?80:0|t||80;for(var n=e.split("\n"),i=0;it&&(o[l]="\n"+o[l],a=s)}n[i]=o.join("")}return n.join("\n")}},"undefined"!=typeof module&&module.exports&&(module.exports=n),Prism.plugins.NormalizeWhitespace=new n({"remove-trailing":!0,"remove-indent":!0,"left-trim":!0,"right-trim":!0}),Prism.hooks.add("before-sanity-check",(function(e){var n=Prism.plugins.NormalizeWhitespace;if((!e.settings||!1!==e.settings["whitespace-normalization"])&&Prism.util.isActive(e.element,"whitespace-normalization",!0))if(e.element&&e.element.parentNode||!e.code){var r=e.element.parentNode;if(e.code&&r&&"pre"===r.nodeName.toLowerCase()){for(var i in null==e.settings&&(e.settings={}),t)if(Object.hasOwnProperty.call(t,i)){var o=t[i];if(r.hasAttribute("data-"+i))try{var a=JSON.parse(r.getAttribute("data-"+i)||"true");typeof a===o&&(e.settings[i]=a)}catch(e){}}for(var l=r.childNodes,s="",c="",u=!1,m=0;m :is(a, a:hover) { 78 | color: inherit; 79 | text-decoration: none; 80 | 81 | & img { 82 | block-size: .75em; 83 | } 84 | } 85 | 86 | & strong { 87 | color: hsl(85 85% 30%); /* a bit darker than in the logo to visually match it */ 88 | margin-inline-end: -.13em; 89 | } 90 | } 91 | 92 | & p { 93 | margin-block-start: var(--wa-space-xs); 94 | font-size: var(--wa-font-size-s); 95 | font-weight: var(--wa-font-weight-semibold); 96 | color: var(--color-neutral-50); 97 | line-height: 1.12; 98 | 99 | @media (width <= 425px) { 100 | margin-block-start: 0; 101 | 102 | br { 103 | display: none; 104 | } 105 | } 106 | } 107 | 108 | nav { 109 | display: flex; 110 | gap: var(--wa-space-m); 111 | align-items: center; 112 | 113 | a { 114 | font-weight: var(--wa-font-weight-bold); 115 | color: var(--wa-color-on-quiet); 116 | font-size: var(--wa-font-size-m); 117 | } 118 | 119 | a:has(> .fa-github) { 120 | margin-inline-start: var(--wa-space-m); 121 | font-size: var(--wa-font-size-xl); 122 | } 123 | } 124 | } 125 | 126 | nav { 127 | a:not(:hover) > i { 128 | color: var(--wa-color-on-quiet); 129 | } 130 | } 131 | 132 | .page { 133 | flex: 1; 134 | display: flex; 135 | max-width: var(--max-page-width); 136 | margin-inline: auto; 137 | 138 | @media (width > 1360px) { 139 | &:has(#main-contents) { 140 | padding-inline-start: var(--wa-space-3xl); 141 | } 142 | } 143 | 144 | @media (width > 1000px) { 145 | &:has(> .sidebar) { 146 | > .sidebar { 147 | > .toc .toc { 148 | block-size: calc(100vh - var(--header-height) - 1.2lh); 149 | padding-block-end: var(--wa-space-3xl); /* allow scrolling past the last item */ 150 | overflow-y: auto; 151 | overscroll-behavior: contain; 152 | } 153 | 154 | ~ .sidebar { 155 | padding-inline-end: 0; 156 | border-inline-start: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-surface-border); 157 | border-inline-end: none; 158 | } 159 | } 160 | 161 | #section-contents { 162 | padding-inline-start: 0; 163 | } 164 | } 165 | } 166 | 167 | @media (width <= 1000px) { 168 | &:has(.sidebar ~ .sidebar) { 169 | flex-direction: column; 170 | 171 | .sidebar { 172 | border: none; 173 | } 174 | 175 | #section-contents { 176 | order: 999; 177 | } 178 | } 179 | 180 | #page-contents { 181 | order: -1; 182 | } 183 | } 184 | 185 | @media (width > 625px) { 186 | &:not(:has(.sidebar ~ .sidebar)) .sidebar { 187 | padding-inline-start: 0; 188 | } 189 | } 190 | 191 | @media (width <= 625px) { 192 | flex-direction: column; 193 | 194 | .sidebar { 195 | border: none; 196 | } 197 | } 198 | } 199 | 200 | main { 201 | max-inline-size: var(--max-content-width); 202 | min-inline-size: 0; 203 | padding: var(--wa-space-xl); 204 | margin-inline-end: auto; 205 | 206 | ul:is(#features-at-a-glance + *) { 207 | list-style: "✅ "; 208 | padding-inline-start: 0; 209 | 210 | span:first-of-type { 211 | display: none; 212 | } 213 | 214 | li::marker { 215 | font: var(--fa-font-solid); 216 | color: var(--wa-color-green); 217 | } 218 | } 219 | 220 | & h1 { 221 | color: var(--color-neutral); 222 | margin-bottom: 0; 223 | } 224 | 225 | :is(h1, h2):first-child { 226 | margin-block-start: 0; 227 | } 228 | } 229 | 230 | :is(nav, aside) a, 231 | :is(h1, h2, h3, h4) > a:only-child { 232 | text-decoration: none; 233 | color: inherit; 234 | } 235 | 236 | :is(h1, h2, h3, h4) { 237 | scroll-margin-block-start: var(--header-height); 238 | } 239 | 240 | .sidebar { 241 | border-inline-end: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-surface-border); 242 | padding: var(--wa-space-l); 243 | font-size: var(--wa-font-size-s); 244 | color: var(--color-neutral-30); 245 | 246 | h2 { 247 | margin-block-start: var(--wa-space-xs); 248 | color: var(--color-accent); 249 | font-size: var(--wa-font-size-s); 250 | text-transform: uppercase; 251 | } 252 | 253 | .toc { 254 | :is(.sidebar > &) { 255 | margin: 0; 256 | position: sticky; 257 | top: var(--header-height); 258 | } 259 | 260 | .active.toc-link { 261 | font-weight: var(--wa-font-weight-bold); 262 | color: var(--color-accent); 263 | } 264 | 265 | ul, ol { 266 | margin: 0; 267 | list-style-type: none; 268 | 269 | & & { 270 | padding-inline-start: var(--wa-space-s); 271 | 272 | li { 273 | color: var(--color-neutral-50); 274 | font-weight: var(--wa-font-weight-normal); 275 | } 276 | 277 | #page-contents & { 278 | li ~ li { 279 | margin-block-start: var(--wa-space-xs); 280 | } 281 | } 282 | } 283 | 284 | :is(.toc > &) { 285 | > li { 286 | font-weight: var(--wa-font-weight-semibold); 287 | } 288 | } 289 | 290 | &:is(.toc > *):not(:has(&)) > li { 291 | font-weight: var(--wa-font-weight-normal); 292 | 293 | & + & { 294 | margin-block-start: var(--wa-space-xs); 295 | } 296 | } 297 | } 298 | } 299 | } 300 | 301 | #section-contents { 302 | white-space: nowrap; 303 | } 304 | 305 | #main-contents { 306 | ul { 307 | margin: 0; 308 | position: sticky; 309 | top: var(--header-height); 310 | list-style: none; 311 | line-height: var(--wa-line-height-expanded); 312 | } 313 | 314 | a { 315 | font-weight: var(--wa-font-weight-semibold); 316 | white-space: nowrap; 317 | } 318 | } 319 | 320 | .readme-only { 321 | display: none; 322 | } 323 | 324 | .scrollable, 325 | pre:has(code) { 326 | max-width: 100%; 327 | overflow-x: auto; 328 | overscroll-behavior: contain; 329 | } 330 | 331 | :nth-child(1) { --index: 1; } 332 | :nth-child(2) { --index: 2; } 333 | :nth-child(3) { --index: 3; } 334 | -------------------------------------------------------------------------------- /assets/videos/interactive-cli.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htest-dev/htest/9ad8883ce94affc92a2d6c408e79e09bc8fafeac/assets/videos/interactive-cli.mp4 -------------------------------------------------------------------------------- /bin/htest.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import cli from "../src/cli.js"; 4 | 5 | cli(); 6 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | toc: false 3 | --- 4 | 5 | # Overview 6 | 7 | The guiding design principles behind hTest are: 8 | 1. **Users should not have to write more code than the minimum necessary to (clearly and unambiguously) declare their intent.** 9 | Data and logic should have a single point of truth. 10 | 2. **Incremental user effort should provide incremental value.** 11 | It should be able to run a test as soon as you have the inputs and expected result, metadata can wait (or be generated automatically). 12 | 3. **Be liberal in what you accept ([robustness principle](https://en.wikipedia.org/wiki/Robustness_principle))**. 13 | hTest parameters should accept a wide range of inputs and just deal with them. Internal complexity is preferred over external (user-facing) complexity. 14 | 15 | The way hTest implements these design principles is better illustrated with an example. 16 | Most (all?) JS testing frameworks have adopted a similar syntax involving nested callbacks, which in practice ends up involving a lot of redundant duplicate effort and boilerplate code. 17 | 18 | As an example, this is the sample test from [Mocha’s homepage](https://mochajs.org/#getting-started): 19 | 20 | ```js 21 | var assert = require('assert'); 22 | describe('Array', function () { 23 | describe('#indexOf()', function () { 24 | it('should return -1 when the value is not present', function () { 25 | assert.equal([1, 2, 3].indexOf(4), -1); 26 | }); 27 | }); 28 | }); 29 | ``` 30 | 31 | Most testing frameworks have a similar syntax: tests are defined via nested callbacks, and assertions via function calls. 32 | 33 | If we want to stay as close to the original test as possible, the hTest equivalent would be: 34 | 35 | ```js 36 | export default { 37 | name: "Array", 38 | tests: [{ 39 | name: "#indexOf()", 40 | tests: [ 41 | { 42 | name: "should return -1 when the value is not present", 43 | run: () => [1, 2, 3].indexOf(4), 44 | expect: -1, 45 | } 46 | ] 47 | }] 48 | } 49 | ``` 50 | 51 | However, this way of writing tests does not do hTest justice. 52 | Suppose we wanted to test more results for `indexOf()` and more array methods (e.g. `array.find()`). 53 | 54 | The Mocha code would look like this: 55 | 56 | ```js 57 | var assert = require('assert'); 58 | describe('Array', function () { 59 | describe('#indexOf()', function () { 60 | it('should return -1 when the value is not present', function () { 61 | assert.equal([1, 2, 3].indexOf(4), -1); 62 | }); 63 | it('should return 0 when the value is 1', function () { 64 | assert.equal([1, 2, 3].indexOf(1), 0); 65 | }); 66 | it('should return 3 when the value is 1 and starting from 3', function () { 67 | assert.equal([1, 1, 1, 1].indexOf(1, 3), 3); 68 | }); 69 | }); 70 | 71 | describe('#with()', function () { 72 | it('should return 2 when looking for an even number', function () { 73 | assert.equal([1, 2, 3].find(x => x % 2 === 0), 2); 74 | }); 75 | it('should return undefined no element matches', function () { 76 | assert.equal([1, 2, 3].find(x => x === 4), undefined); 77 | }); 78 | }); 79 | }); 80 | ``` 81 | 82 | We *could* write hTest code that is similar: 83 | 84 |
85 | Show what that would look like 86 | 87 | ```js 88 | export default { 89 | name: "Array", 90 | tests: [ 91 | { 92 | name: "#indexOf()", 93 | tests: [ 94 | { 95 | name: "should return -1 when the value is not present", 96 | run: () => [1, 2, 3].indexOf(4), 97 | expect: -1, 98 | }, 99 | { 100 | name: "should return 0 when the value is 1", 101 | run: () => [1, 2, 3].indexOf(1), 102 | expect: 0, 103 | }, 104 | { 105 | name: "should return 3 when the value is 1 and starting from 3", 106 | run: () => [1, 1, 1, 1].indexOf(1, 3), 107 | expect: 3, 108 | } 109 | ] 110 | }, 111 | { 112 | name: "#find()", 113 | tests: [ 114 | { 115 | name: "should return 2 when looking for an even number", 116 | run: () => [1, 2, 3].find(x => x % 2 === 0), 117 | expect: 2, 118 | }, 119 | { 120 | name: "should return undefined when no element matches", 121 | run: () => [1, 2, 3].find(x => x === 4), 122 | expect: undefined, 123 | } 124 | ] 125 | } 126 | ] 127 | } 128 | ``` 129 | 130 |
131 | 132 | While this already looks cleaner, it doesn’t really illustrate hTest’s value proposition. 133 | Where hTest shines is that it allows us to abstract repetition away and have a single point of truth. 134 | This is what the hTest code would look like: 135 | 136 | ```js 137 | export default { 138 | name: "Array", 139 | run (...args) { 140 | let {method, arr} = this.data; 141 | return arr[method](...args); 142 | }, 143 | data: { // custom inherited data 144 | arr: [1, 2, 3] 145 | }, 146 | getName () { 147 | if (this.level === 1) { 148 | return "#" + this.data.method + "()"; 149 | } 150 | 151 | return `should return ${this.expect} when the value is ${this.args[0]}`; 152 | } 153 | tests: [ 154 | { 155 | data: { 156 | method: "indexOf", 157 | }, 158 | tests: [ 159 | { 160 | name: "should return -1 when the value is not present", 161 | arg: 4, 162 | expect: -1, 163 | }, 164 | { 165 | arg: 1, 166 | expect: 0, 167 | }, 168 | { 169 | data: { 170 | arr: [1, 1, 1, 1] 171 | }, 172 | name: "should return 3 when the value is 1 and starting from 3", 173 | args: [1, 3], 174 | expect: 3, 175 | } 176 | ] 177 | }, 178 | { 179 | data: { 180 | method: "find", 181 | }, 182 | tests: [ 183 | { 184 | name: "should return 2 when looking for an even number", 185 | arg: x => x % 2 === 0, 186 | expect: 2, 187 | }, 188 | { 189 | name: "should return undefined when no element matches", 190 | arg: x => x === 4, 191 | expect: undefined, 192 | } 193 | ] 194 | } 195 | ] 196 | } 197 | ``` 198 | 199 | Here we moved the commonalities to test parents and used inherited data to pass the array and code to be tested to the tests. 200 | We are also only specifying a name when it's non-obvious, and using the `getName` method to generate the name for us 201 | (even with no `getName` method, hTest will generate a name for us based on the test parameters). 202 | 203 | Notice that there is a spectrum between how much you want to abstract away and how much you want to specify in each test. 204 | It’s up to you where your tests would be in that spectrum. 205 | You may prefer to keep inherited settings simple, and add local overrides, 206 | or you may prefer to add more code at the root, so the tests can be as lean as possible. 207 | hTest allows you to do both. 208 | 209 | Have a huge test suite in another testing framework and converting seems like a daunting task? 210 | Or maybe you like hTest’s test specification syntax, but prefer another testing framework’s test runners? 211 | Because hTest’s tests are declarative, you can actually *convert* them to any other testing framework! 212 | 213 | There are existing adapters for the following frameworks: 214 | - Coming soon 215 | -------------------------------------------------------------------------------- /docs/define/README.md: -------------------------------------------------------------------------------- 1 | # Defining tests 2 | 3 | Tests are defined and grouped by object literals with a defined structure. 4 | Each of these objects can either be a test, or contain child tests. 5 | All properties work across both: if a property doesn’t directly apply to a group, it inherits down to the tests it contains. 6 | This allows you to only specify what is different in each test, 7 | and makes it easier to evolve the testsuite over time. 8 | You can access the inherited property via `this.parent` when re-defining either of these properties on a child or descendant test; for example, `this.parent.run(...args)`. 9 | 10 | ## Property index 11 | 12 | **All properties are optional**. 13 | 14 |
15 | 16 | | Property | Type | Description | 17 | |----------|------|-------------| 18 | | [`run`](#run) | Function | The code to run. | 19 | | [`args`](#args) | Array | Arguments to pass to the running function. | 20 | | [`arg`](#args) | Any | A single argument to pass to the running function. | 21 | | [`beforeEach`](#setup-teardown) | Function | Code to run before each test. | 22 | | [`afterEach`](#setup-teardown) | Function | Code to run after each test. | 23 | | [`beforeAll`](#setup-teardown) | Function | Code to run before all tests in the group. | 24 | | [`afterAll`](#setup-teardown) | Function | Code to run after all tests in the group. | 25 | | [`data`](#data) | Object | Data that will be accessible to the running function as `this.data`. | 26 | | [`name`](#name) | String or Function | A string that describes the test. | 27 | | [`getName`](#name) | Function | A function that generates the test name dynamically. | 28 | | [`description`](#description) | String | A longer description of the test or group of tests. | 29 | | [`id`](#id) | String | A unique identifier for the test. | 30 | | [`expect`](#expect) | Any | The expected result. | 31 | | [`getExpect`](#expect) | Function | A function that generates the expected result dynamically. | 32 | | [`throws`](#throws) | Boolean, Error subclass, or Function | Whether an error is expected to be thrown. | 33 | | [`maxTime`](#maxtime) | Number | The maximum time (in ms) that the test should take to run. | 34 | | [`maxTimeAsync`](#maxtimeasync) | Number | The maximum time (in ms) that the test should take to resolve. | 35 | | [`map`](#map) | Function | A mapping function to apply to the result and expected value before comparing them. | 36 | | [`check`](#check) | Function | A custom function that takes the result and the expected value (if present) as argments and returns a boolean indicating whether the test passed. | 37 | | [`skip`](#skip) | Boolean | Whether to skip the test(s). | 38 |
39 | 40 | ## Defining the test 41 | 42 | ### Defining the code to be tested (`run`) { #run } 43 | 44 | `run` defines the code to run, as a function. It can be either sync or async. 45 | It is common to define a single `run` function on a parent or ancestor and differentiate child tests via `args` and `data` (described below). 46 | 47 | ### Argument(s) to pass to the testing function (`args` and `arg`) { #args } 48 | 49 | There are two ways to pass arguments to the running function: 50 | - `args` is an array of arguments to pass 51 | If you pass a single argument, it will be converted to an array. 52 | - `arg` will *always* be assumed to be a single argument, even when it’s an array. 53 | If both `arg` and `args` are defined, `arg` wins. 54 | 55 | `arg` is internally rewritten to `args`, so in any functions that run with the current test as their context you can just use `this.args` without having to explicitly check for `this.arg` 56 | 57 | ### Setup and teardown (`beforeEach`, `afterEach`, `beforeAll`, `afterAll`) { #setup-teardown } 58 | 59 | Some tests require setup and teardown code that runs before and after the test or group of tests, such as setting up DOM fixtures. 60 | 61 | Functions `beforeEach` and `afterEach` define the code to run before and after *each test* if it is not [skipped](#skip). 62 | Functions `beforeAll` and `afterAll` define the code to run before and after *all tests in the group* regardless of whether they are skipped. 63 | 64 | All of these functions can be either sync or async. 65 | 66 | You can define a single `beforeEach` or `afterEach` function on a parent or ancestor and differentiate child tests via [`args`](#args) and [`data`](#data). 67 | 68 | ### `data`: Context parameters 69 | 70 | `data` is an optional object with data that will be accessible to the running function as `this.data`. 71 | A test’s data is merged with its parent’s data, so you can define common data at a higher level and override it where needed. 72 | It is useful for differentiating the behavior of `run()` across groups of tests without having to redefine it or pass repetitive arguments. 73 | 74 | ## Describing the test 75 | 76 | ### Names and name generators (`name` and `getName()`) { #name } 77 | 78 | `name` is a string that describes the test. 79 | It is optional, but recommended, as it makes it easier to identify the test in the results. 80 | 81 | `name` can also be a *name generator* function. 82 | It is called with the same context and arguments as `run()` and returns the name as a string. 83 | You can also explicitly provide a function, via `getName`. 84 | This can be useful if you want to specify a name for the root of tests, as well as a name generator for child tests. 85 | In fact, if `name` is a function, it gets rewritten as `getName` internally. 86 | 87 | Single names are not inherited, but name generator functions are. 88 | 89 | Name generators are useful for providing a default name for tests, that you can override on a case by case basis via `name`. 90 | You may find `this.level` useful in the name generator, as it tells you how deep in the hierarchy the test is, allowing you to provide depth-sensitive name patterns. 91 | 92 | If no name is provided, it defaults to the first argument passed to `run`, if any. 93 | 94 | ### Description (`description`) { #description } 95 | 96 | `description` is an optional longer description of the test or group of tests. 97 | 98 | ### Id (`id`) { #id } 99 | 100 | This is an optional unique identifier for the test that can be used to refer to it programmatically. 101 | 102 | ## Setting expectations 103 | 104 | All of these properties define the criteria for a test to pass. 105 | 106 | To make it easier to interpret the results, each test can only have one main pass criterion: result-based, error-based, or time-based. 107 | E.g. you can use `maxTime` and `maxTimeAsync` together, but not with `expect` or `throws`. 108 | 109 | If you specify multiple criteria, nothing will break, but you will get a warning. 110 | 111 | ### Result-based criteria (`expect` and `getExpect()`) { #expect } 112 | 113 | `expect` defines the expected result, so you'll be using it the most. 114 | If `expect` is *not defined*, it defaults to the first argument passed to `run()`, i.e. `this.args[0]`. 115 | 116 | The expected result can also be generated dynamically via `getExpect`. 117 | It is called with the same context and arguments as `run()` and returns the expected result. 118 | 119 | If both `expect` and `getExpect` are defined, `expect` wins. 120 | 121 | ### Error-based criteria (`throws`) { #throws } 122 | 123 | If you are testing that an error is thrown, you can use `throws`. 124 | `throws: true` will pass if any error is thrown, but you can also have more granular criteria: 125 | - If the value is an `Error` subclass, the error thrown *also* needs to be an instance of that class. 126 | - If the value is a function, the function *also* needs to return a truthy value when called with the error thrown as its only argument. 127 | 128 | You can use `throws: false` to ensure the test passes as long as it doesn't throw an error, regardless of what value it returns. 129 | 130 | ### Time-based criteria (`maxTime`, `maxTimeAsync`) 131 | 132 | The time a test took is always measured and displayed anyway. 133 | If the test returns a promise, the time it took to resolve is also measured, separately. 134 | To test performance-sensitive functionality, you can set `maxTime` or `maxTimeAsync` to specify the maximum time (in ms) that the test should take to run. 135 | 136 | ## Customizing how the result is evaluated 137 | 138 | The properties in this section center around making it easier to specify **result-based tests** (i.e. those with `expect` values). 139 | 140 | ### Defining the checking logic (`check`) { #check } 141 | 142 | By default, if you provide an `expect` value, the test will pass if the result is equal to it (using deep equality). 143 | However, often you don’t really need full equality, just to verify that the result passes some kind of test, 144 | or that it has certain things in common with the expected output. 145 | 146 | `check` allows you to provide a custom function that takes the actual result and the expected value as arguments and returns a boolean indicating whether the test passed. 147 | If the return value is not a boolean, it is coerced to one. 148 | You can use any existing assertion library, but hTest provides a few helpers in `/src/check.js` (import `htest/check`): 149 | 150 | All of these take parameters and return a checking function: 151 | - `and(...fns)`: Combine multiple checks with logical AND. 152 | - `or(...fns)`: Combine multiple checks with logical OR. 153 | - `is(type)`: Check if the result is of a certain type. 154 | - `deep(shallowCheck)`: Check if the result passes a deep equality check with the expected value. 155 | - `proximity({epsilon})`: Check if the result is within a certain distance of the expected value. 156 | - `range({min, max, from, to, lt, lte, gt, gte})`: Check if the result is within a certain range. 157 | - `between()`: Alias of `range()`. 158 | 159 | There are also the following checking functions that can be used directly: 160 | - `equals()`: Check if the result is equal to the expected value. 161 | 162 | Instead of providing a custom checking function, you can also tweak the default one — the `equals()` function. Simply pass an object literal with desired options as the value of `check`, and hTest will produce the proper checking function for you. 163 | The supported options are: 164 | - `deep`: Use a deep equality check between the result and the expected value. Defaults to `false`. 165 | - `looseTypes`: Skip the check that the result and the expected value are of the same type. If skipped, e.g. `"5"` can match `5`. Defaults to `false`. 166 | - `subset`: Consider `undefined` equals to anything. The test will always pass if the expected value is `undefined`. Defaults to `false`. 167 | - `epsilon`: Allowed distance between the result and the expected value. Defaults to `0`. 168 | 169 | #### Examples 170 | 171 | ```js 172 | import * as check from "../node_modules/htest.dev/src/check.js"; 173 | 174 | export default { 175 | run: Math.random, 176 | args: [], 177 | check: check.between({min: 0, max: 1}), 178 | } 179 | ``` 180 | 181 | You can even do logical operations on them: 182 | 183 | ```js 184 | import getHue from "../src/getHue.js"; 185 | import * as check from "../node_modules/htest.dev/src/check.js"; 186 | 187 | export default { 188 | run (color) { getHue(color) }, 189 | args: ["green"], 190 | expect: 90, 191 | check: check.and( 192 | check.is("number"), 193 | check.proximity({epsilon: 1}) 194 | ) 195 | } 196 | ``` 197 | 198 | Or, for nicer syntax: 199 | 200 | ```js 201 | import getHue from "../src/getHue.js"; 202 | import {and, is, proximity } from "../node_modules/htest.dev/src/check.js"; 203 | 204 | export default { 205 | run (color) { getHue(color) }, 206 | args: ["green"], 207 | expect: 90, 208 | check: and( 209 | is("number"), 210 | proximity({epsilon: 1}) 211 | ) 212 | } 213 | ``` 214 | 215 | hTest can build the right checking function behind the scenes: 216 | 217 | ```js 218 | export default { 219 | arg: [1.01, 2, 3.042], 220 | expect: [1, 2, 3], 221 | check: {deep: true, epsilon: .1} 222 | } 223 | ``` 224 | 225 | ### Mapping the result and expected value (`map`) { #map } 226 | 227 | In some cases you want to do some post-processing on both the actual result and the expected value before comparing them (using `check`). 228 | `map` allows you to provide a mapping function that will be applied to both the result and the expected value before any checking. 229 | If the test return value is an array, each individual item will be mapped separately. 230 | 231 | Some commonly needed functions can be found in `/src/map.js` (import `htest/map`). 232 | The following are *mapping function generators*, i.e. take parameters and generate a suitable mapping function: 233 | - `extract(patterns)`: Match and extract values using regex patterns and/or fixed strings 234 | 235 | These can be used directly: 236 | - `extractNumbers()`: Match and extract numbers from strings 237 | - `trimmed()` : Trim strings 238 | 239 | ### Skipping tests (`skip`) { #skip } 240 | 241 | Often, we have written tests for parts of the API that are not yet implemented. 242 | It doesn't make sense to remove these tests, but they should also not be making the testsuite fail. 243 | You can set `skip: true` to skip a test. 244 | The number of skipped tests will be shown separately. 245 | -------------------------------------------------------------------------------- /docs/docs.json: -------------------------------------------------------------------------------- 1 | { 2 | "toc": true, 3 | "layout": "docs" 4 | } -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | permalink: false 3 | navKey: "/docs/overview/" 4 | parent: "/docs/" 5 | title: "Overview" 6 | url: "/docs/" 7 | order: -1 8 | --- -------------------------------------------------------------------------------- /docs/run/README.md: -------------------------------------------------------------------------------- 1 | # Test Runners 2 | 3 | Because hTest tests are declarative, this allows decoupling the test specification from the test execution. 4 | This means you can run hTest tests in a variety of environments, or even other testing frameworks! 5 | 6 | -------------------------------------------------------------------------------- /docs/run/console/README.md: -------------------------------------------------------------------------------- 1 | # Console 2 | 3 | This is a basic test runner that works in any environment that supports the Console API, 4 | without taking advantage of any fancy terminal stuff like updating the screen in place. 5 | 6 | It allows running hTest tests in the browser console, which can provide a better debugging experience in some cases. 7 | 8 | The basic idea is this: 9 | 10 | ```js 11 | import tests from "./test/index.js"; 12 | import run from "./node_modules/htest.dev/src/run/console.js"; 13 | 14 | run(tests); 15 | ``` 16 | 17 | which you can run from any HTML page. 18 | 19 | You can also import the `run()` function which takes a parameter for the environment: 20 | 21 | ```js 22 | import tests from "./test/index.js"; 23 | import run from "./node_modules/htest.dev/src/run.js"; 24 | 25 | run(tests, {env: "console"}); 26 | ``` 27 | 28 | In fact, you can leave the environment out. This defaults to `"auto"`, 29 | which detects whether the code is running in Node and uses the CLI output if so and the `console` environment otherwise. 30 | 31 | ```js 32 | import tests from "./test/index.js"; 33 | import run from "./node_modules/htest.dev/src/run.js"; 34 | 35 | run(tests); 36 | ``` -------------------------------------------------------------------------------- /docs/run/html/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav: Browser (HTML reftests) 3 | --- 4 | 5 | # Running tests in the browser (as HTML reftests) 6 | 7 | These tests provide a UI very similar to [HTML-first reftests](https://html.htest.dev/), and can be useful 8 | for providing a unified front if your testsuite includes both. 9 | 10 | ## Running JS-first tests in the browser 11 | 12 | To run HTML-first tests in the browser, you just open the HTML file. 13 | For JS-first tests, you need to create an HTML file that runs the tests. 14 | 15 | For a test file named `test.js`, the HTML file would look like this: 16 | 17 | ```html 18 | 19 | 20 | 21 | 22 | Tests 23 | 24 | 25 | 26 | 32 | 33 | 34 | ``` 35 | 36 | You could even use a URL param to specify the test file, so you can use one HTML file for all your tests: 37 | 38 | ```html 39 | 40 | 41 | 42 | 43 | Tests 44 | 45 | 46 | 47 | 54 | 55 | 56 | ``` 57 | 58 | Then you'd run it `foo.js` by visiting `index.html?test=foo.js`. 59 | 60 | In fact, you could configure it to output an index of tests if no test is provided, encapsulating your entire testsuite in one HTML file! 61 | 62 |
63 | View code 64 | 65 | ```html 66 | 67 | 68 | 69 | 70 | Tests 71 | 72 | 73 | 116 | 117 | 118 | 119 | 120 | ``` 121 | 122 |
123 | 124 | You can see this in action at [Color.js](https://colorjs.io/test/). 125 | Note that this requires an `index.json` with test filenames to names. You can also hardcode the data in your index, or use a different format — you'd just need to tweak the code accordingly. 126 | 127 |
128 | 129 | When viewing JS-first tests in HTML, top-level tests are rendered as sections, and all their descendants are rendered inside the same table. 130 | 131 |
132 | 133 | ## Test runner UI 134 | 135 | ### Isolating tests 136 | 137 | It is often useful to isolate a single group of tests, or even a single test so you can debug a particular failure. 138 | 139 | To isolate a group of tests (`
`), simply click the link of the section heading. 140 | 141 | To isolate a specific test (``), hold down the Alt/Option key and double click on the table row. 142 | -------------------------------------------------------------------------------- /docs/run/node/README.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | When [defining tests with JS](../../define/js/), most of the time you would want to run them in Node. 4 | This allows you to use CI (continuous integration) services like Travis CI and GitHub Actions, 5 | post-commit hooks, and other tools that run on the command line. 6 | 7 | ## Setup and requirements 8 | 9 | For your tests to work with the Node.js runner, your JS code needs to be compatible with Node.js. 10 | You need at least Node.js 16.x, but it is recommended to use the latest LTS version. 11 | 12 | While to [run HTML tests](../define/html) it may be enough to simply link to hTest’s JS and CSS files, 13 | to run JS tests in Node, you need to use npm to install hTest: 14 | 15 | ```sh 16 | npm i htest.dev -D 17 | ``` 18 | 19 | ## Zero hassle, some control 20 | 21 | You just use the `htest` command line tool to run your tests: 22 | 23 | ```sh 24 | npx htest tests 25 | ``` 26 | 27 | By default, hTest will look for all JS files in the directory you specify except for those starting with `index`. 28 | You can use a glob to customize this: 29 | 30 | ```sh 31 | npx htest tests/*.js,!tests/util.js 32 | ``` 33 | 34 | ## Minimal hassle, more control 35 | 36 | You can create your own CLI script to run your tests, by importing the same code the `htest` command line tool uses: 37 | 38 | ```js 39 | import htest from "../node_modules/htest.dev/src/cli.js"; 40 | 41 | let test = { 42 | name: "Addtion", 43 | run: (a, b) => a + b, 44 | args: [1, 2], 45 | expect: 3, 46 | } 47 | 48 | htest(test, {verbose: true}); 49 | ``` 50 | 51 | Try running it: 52 | 53 | ```sh 54 | node my-test.js 55 | ``` 56 | 57 | ## More hassle, total control 58 | 59 | With both previous methods you can still pass command line arguments as well and hTest processes them: 60 | 61 | ```sh 62 | node my-test.js footests.js 63 | ``` 64 | 65 | If you pass a directory, hTest will look for all JS files in that directory except for those starting with `index`. 66 | 67 | If that's not desirable, you can use the lower level `run()` function: 68 | 69 | ```js 70 | import run from "../node_modules/htest.dev/src/run.js"; 71 | import fooTests from "./foo.js"; 72 | import barTests from "./bar.js"; 73 | 74 | run({ 75 | name: "All tests", 76 | tests: [ 77 | fooTests, 78 | barTests, 79 | ] 80 | }); 81 | ``` 82 | 83 | You could even have separate files for this: 84 | 85 | `tests/index-fn.js`: 86 | 87 | ```js 88 | import fooTests from "./foo.js"; 89 | import barTests from "./bar.js"; 90 | 91 | export default { 92 | name: "All tests", 93 | tests: [ 94 | fooTests, 95 | barTests, 96 | ] 97 | } 98 | ``` 99 | 100 | `tests/index.js:` 101 | 102 | ```js 103 | import run from "../node_modules/htest.dev/src/cli.js"; 104 | import tests from "./index-fn.js"; 105 | 106 | run(tests); 107 | ``` 108 | 109 | Just like `htest()`, any string arguments in the `run()` function are interpreted as globs (relative to the current working directory): 110 | 111 | ```js 112 | import run from "../node_modules/htest.dev/src/run.js"; 113 | 114 | run("tests/*.js"); 115 | ``` 116 | 117 | ## Running in CI environments 118 | 119 | For continuous integration environments, you can use the `--ci` flag to optimize output for CI systems: 120 | 121 | ```sh 122 | npx htest tests --ci 123 | ``` 124 | 125 | This mode: 126 | - Disables interactive elements 127 | - Outputs in a format optimized for CI logging 128 | - Exits with code `1` if any tests fail 129 | 130 | You can use it in your `package.json` scripts: 131 | 132 | ```json 133 | { 134 | "scripts": { 135 | "test": "npx htest tests --ci" 136 | } 137 | } 138 | ``` 139 | 140 | Example GitHub Actions workflow: 141 | 142 | ```yaml 143 | name: Tests 144 | on: [push, pull_request] 145 | jobs: 146 | test: 147 | runs-on: ubuntu-latest 148 | steps: 149 | - uses: actions/checkout@v4 150 | - uses: actions/setup-node@v4 151 | - run: npm install 152 | - run: npm test 153 | ``` 154 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import stylistic from "@stylistic/eslint-plugin"; 3 | 4 | export default [ 5 | { 6 | languageOptions: { 7 | ecmaVersion: 2022, 8 | sourceType: "module", 9 | globals: { 10 | ...globals.browser, 11 | }, 12 | }, 13 | plugins: { 14 | "@stylistic": stylistic, 15 | }, 16 | rules: { 17 | /** 18 | * ESLint rules: https://eslint.org/docs/latest/rules/ 19 | * Based off of: https://github.com/eslint/eslint/blob/v8.54.0/packages/js/src/configs/eslint-recommended.js 20 | */ 21 | // Enforce curly braces for all control statements 22 | // https://eslint.org/docs/latest/rules/curly 23 | curly: 1, 24 | 25 | // Require `super()` calls in constructors 26 | // https://eslint.org/docs/latest/rules/constructor-super 27 | "constructor-super": 1, 28 | 29 | // Enforce “for” loop update clause moving the counter in the right direction 30 | // https://eslint.org/docs/latest/rules/for-direction 31 | "for-direction": 1, 32 | 33 | // Enforce `return` statements in getters 34 | // https://eslint.org/docs/latest/rules/getter-return 35 | "getter-return": 1, 36 | 37 | // Disallow using an async function as a Promise executor 38 | // https://eslint.org/docs/latest/rules/no-async-promise-executor 39 | "no-async-promise-executor": 1, 40 | 41 | // Disallow `let`/const`/function`/`class` in `case`/`default` clauses 42 | // https://eslint.org/docs/latest/rules/no-case-declarations 43 | "no-case-declarations": 1, 44 | 45 | // Disallow reassigning class members 46 | // https://eslint.org/docs/latest/rules/no-class-assign 47 | "no-class-assign": 1, 48 | 49 | // Disallow comparing against -0 50 | // https://eslint.org/docs/latest/rules/no-compare-neg-zero 51 | "no-compare-neg-zero": 1, 52 | 53 | // Disallow reassigning `const` variables 54 | // https://eslint.org/docs/latest/rules/no-const-assign 55 | "no-const-assign": 1, 56 | 57 | // Disallow constant expressions in conditions 58 | // https://eslint.org/docs/latest/rules/no-constant-condition 59 | "no-constant-condition": 1, 60 | 61 | // Disallow control characters in regular expressions 62 | // https://eslint.org/docs/latest/rules/no-control-regex 63 | "no-control-regex": 1, 64 | 65 | // Disallow the use of `debugger` 66 | // https://eslint.org/docs/latest/rules/no-debugger 67 | "no-debugger": 1, 68 | 69 | // Disallow deleting variables 70 | // https://eslint.org/docs/latest/rules/no-delete-var 71 | "no-delete-var": 1, 72 | 73 | // Disallow duplicate arguments in `function` definitions 74 | // https://eslint.org/docs/latest/rules/no-dupe-args 75 | "no-dupe-args": 1, 76 | 77 | // Disallow duplicate class members 78 | // https://eslint.org/docs/latest/rules/no-dupe-class-members 79 | "no-dupe-class-members": 1, 80 | 81 | // Disallow duplicate conditions in if-else-if chains 82 | // https://eslint.org/docs/latest/rules/no-dupe-else-if 83 | "no-dupe-else-if": 1, 84 | 85 | // Disallow duplicate keys in object literals 86 | // https://eslint.org/docs/latest/rules/no-dupe-keys 87 | "no-dupe-keys": 1, 88 | 89 | // Disallow duplicate case labels 90 | // https://eslint.org/docs/latest/rules/no-duplicate-case 91 | "no-duplicate-case": 1, 92 | 93 | // Disallow empty character classes in regular expressions 94 | // https://eslint.org/docs/latest/rules/no-empty-character-class 95 | "no-empty-character-class": 1, 96 | 97 | // Disallow empty destructuring patterns 98 | // https://eslint.org/docs/latest/rules/no-empty-pattern 99 | "no-empty-pattern": 1, 100 | 101 | // Disallow reassigning exceptions in `catch` clauses 102 | // https://eslint.org/docs/latest/rules/no-ex-assign 103 | "no-ex-assign": 1, 104 | 105 | // Disallow unnecessary boolean casts 106 | // https://eslint.org/docs/latest/rules/no-extra-boolean-cast 107 | "no-extra-boolean-cast": 1, 108 | 109 | // Disallow fallthrough of `case` statements 110 | // unless marked with a comment that matches `/falls?\s?through/i` regex 111 | // https://eslint.org/docs/latest/rules/no-fallthrough 112 | "no-fallthrough": 1, 113 | 114 | // Disallow reassigning `function` declarations 115 | // https://eslint.org/docs/latest/rules/no-func-assign 116 | "no-func-assign": 1, 117 | 118 | // Disallow assignments to native objects or read-only global variables 119 | // https://eslint.org/docs/latest/rules/no-global-assign 120 | "no-global-assign": 1, 121 | 122 | // Disallow assigning to imported bindings 123 | // https://eslint.org/docs/latest/rules/no-import-assign 124 | "no-import-assign": 1, 125 | 126 | // Disallow invalid regular expression strings in `RegExp` constructors 127 | // https://eslint.org/docs/latest/rules/no-invalid-regexp 128 | "no-invalid-regexp": 1, 129 | 130 | // Disallow whitespace that is not `tab` or `space` except in string literals 131 | // https://eslint.org/docs/latest/rules/no-irregular-whitespace 132 | "no-irregular-whitespace": 1, 133 | 134 | // Disallow characters which are made with multiple code points in character class syntax 135 | // https://eslint.org/docs/latest/rules/no-misleading-character-class 136 | "no-misleading-character-class": 1, 137 | 138 | // Disallow `new` operators with the `Symbol` object 139 | // https://eslint.org/docs/latest/rules/no-new-symbol 140 | "no-new-symbol": 1, 141 | 142 | // Disallow `\8` and `\9` escape sequences in string literals 143 | // https://eslint.org/docs/latest/rules/no-nonoctal-decimal-escape 144 | "no-nonoctal-decimal-escape": 1, 145 | 146 | // Disallow calling global object properties as functions 147 | // https://eslint.org/docs/latest/rules/no-obj-calls 148 | "no-obj-calls": 1, 149 | 150 | // Disallow octal literals 151 | // https://eslint.org/docs/latest/rules/no-octal 152 | "no-octal": 1, 153 | 154 | // Disallow calling some `Object.prototype` methods directly on objects 155 | // https://eslint.org/docs/latest/rules/no-prototype-builtins 156 | "no-prototype-builtins": 1, 157 | 158 | // Disallow multiple spaces in regular expressions 159 | // https://eslint.org/docs/latest/rules/no-regex-spaces 160 | "no-regex-spaces": 1, 161 | 162 | // Disallow assignments where both sides are exactly the same 163 | // https://eslint.org/docs/latest/rules/no-self-assign 164 | "no-self-assign": 1, 165 | 166 | // Disallow identifiers from shadowing restricted names 167 | // https://eslint.org/docs/latest/rules/no-shadow-restricted-names 168 | "no-shadow-restricted-names": 1, 169 | 170 | // Disallow `this`/`super` before calling `super()` in constructors 171 | // https://eslint.org/docs/latest/rules/no-this-before-super 172 | "no-this-before-super": 1, 173 | 174 | // Disallow the use of undeclared variables unless mentioned in `/*global */` comments 175 | // https://eslint.org/docs/latest/rules/no-undef 176 | // TODO: At-risk; subject to change. 177 | "no-undef": 1, 178 | 179 | // Disallow confusing multiline expressions 180 | // https://eslint.org/docs/latest/rules/no-unexpected-multiline 181 | // TODO: At-risk; subject to change. 182 | "no-unexpected-multiline": 1, 183 | 184 | // Disallow unreachable code after `return`, `throw`, `continue`, and `break` statements 185 | // https://eslint.org/docs/latest/rules/no-unreachable 186 | "no-unreachable": 1, 187 | 188 | // Disallow control flow statements in `finally` blocks 189 | // https://eslint.org/docs/latest/rules/no-unsafe-finally 190 | "no-unsafe-finally": 1, 191 | 192 | // Disallow negating the left operand of relational operators 193 | // https://eslint.org/docs/latest/rules/no-unsafe-negation 194 | "no-unsafe-negation": 1, 195 | 196 | // Disallow use of optional chaining in contexts where the `undefined` value is not allowed 197 | // https://eslint.org/docs/latest/rules/no-unsafe-optional-chaining 198 | "no-unsafe-optional-chaining": 1, 199 | 200 | // Disallow unused labels 201 | // https://eslint.org/docs/latest/rules/no-unused-labels 202 | "no-unused-labels": 1, 203 | 204 | // Disallow useless backreferences in regular expressions 205 | // https://eslint.org/docs/latest/rules/no-useless-backreference 206 | "no-useless-backreference": 1, 207 | 208 | // Disallow unnecessary calls to `.call()` and `.apply()` 209 | // https://eslint.org/docs/latest/rules/no-useless-call 210 | "no-useless-call": 1, 211 | 212 | // Disallow unnecessary `catch` clauses 213 | // https://eslint.org/docs/latest/rules/no-useless-catch 214 | "no-useless-catch": 1, 215 | 216 | // Disallow unnecessary escape characters 217 | // https://eslint.org/docs/latest/rules/no-useless-escape 218 | "no-useless-escape": 1, 219 | 220 | // Disallow `with` statements 221 | // https://eslint.org/docs/latest/rules/no-with 222 | "no-with": 1, 223 | 224 | // Require generator functions to contain `yield` 225 | // https://eslint.org/docs/latest/rules/require-yield 226 | "require-yield": 1, 227 | 228 | // Require calls to `isNaN()` when checking for `NaN` 229 | // https://eslint.org/docs/latest/rules/use-isnan 230 | "use-isnan": 1, 231 | 232 | // Enforce comparing `typeof` expressions against valid strings 233 | // https://eslint.org/docs/latest/rules/valid-typeof 234 | "valid-typeof": 1, 235 | 236 | /** 237 | * ESLint Stylistic rules: https://eslint.style/packages/default#rules 238 | */ 239 | // Enforce a space before and after `=>` in arrow functions 240 | // https://eslint.style/rules/default/arrow-spacing 241 | "@stylistic/arrow-spacing": 1, 242 | 243 | // Enforce consistent brace style for blocks 244 | // https://eslint.style/rules/default/brace-style 245 | "@stylistic/brace-style": [1, "stroustrup"], 246 | 247 | // Enforce trailing commas unless closing `]` or `}` is on the same line 248 | // https://eslint.style/rules/default/comma-dangle 249 | "@stylistic/comma-dangle": [1, "always-multiline"], 250 | 251 | // Enforce no space before and one or more spaces after a comma 252 | // https://eslint.style/rules/default/comma-spacing 253 | "@stylistic/comma-spacing": 1, 254 | 255 | // Require newline at the end of files 256 | // https://eslint.style/rules/default/eol-last 257 | "@stylistic/eol-last": 1, 258 | 259 | // Enforce consistent indentation 260 | // https://eslint.style/rules/default/indent 261 | "@stylistic/indent": [1, "tab", { SwitchCase: 1, outerIIFEBody: 0 }], 262 | 263 | // Enforce consistent spacing before and after keywords 264 | // https://eslint.style/rules/default/keyword-spacing 265 | "@stylistic/keyword-spacing": 1, 266 | 267 | // Disallow unnecessary semicolons 268 | // https://eslint.style/rules/default/no-extra-semi 269 | "@stylistic/no-extra-semi": 1, 270 | 271 | // Disallow mixed spaces and tabs for indentation 272 | // https://eslint.style/rules/default/no-mixed-spaces-and-tabs 273 | "@stylistic/no-mixed-spaces-and-tabs": [1, "smart-tabs"], 274 | 275 | // Disallow trailing whitespace at the end of lines 276 | // https://eslint.style/rules/default/no-trailing-spaces 277 | "@stylistic/no-trailing-spaces": 1, 278 | 279 | // Enforce the consistent use of double quotes 280 | // https://eslint.style/rules/default/quotes 281 | "@stylistic/quotes": [ 282 | 1, 283 | "double", 284 | { avoidEscape: true, allowTemplateLiterals: true }, 285 | ], 286 | 287 | // Require semicolons instead of ASI 288 | // https://eslint.style/rules/default/semi 289 | "@stylistic/semi": 1, 290 | 291 | // Enforce at least one space before blocks 292 | // https://eslint.style/rules/default/space-before-blocks 293 | "@stylistic/space-before-blocks": 1, 294 | 295 | // Enforce a space before `function` definition opening parenthesis 296 | // https://eslint.style/rules/default/space-before-function-paren 297 | "@stylistic/space-before-function-paren": 1, 298 | 299 | // Require spaces around infix operators (e.g. `+`, `=`, `?`, `:`) 300 | // https://eslint.style/rules/default/space-infix-ops 301 | "@stylistic/space-infix-ops": 1, 302 | 303 | // Enforce a space after unary word operators (`new`, `delete`, `typeof`, `void`, `yield`) 304 | // https://eslint.style/rules/default/space-unary-ops 305 | "@stylistic/space-unary-ops": 1, 306 | 307 | // Enforce whitespace after the `//` or `/*` in a comment 308 | // https://eslint.style/rules/default/spaced-comment 309 | "@stylistic/spaced-comment": [ 310 | 1, 311 | "always", 312 | { block: { exceptions: ["*"] } }, 313 | ], 314 | }, 315 | }, 316 | ]; 317 | -------------------------------------------------------------------------------- /htest.css: -------------------------------------------------------------------------------- 1 | ::before { 2 | content: var(--prepend) " "; 3 | } 4 | 5 | :root { 6 | --color-light: hsl(200, 100%, 90%); 7 | --color-dark: hsl(200, 50%, 40%); 8 | --color-pass: hsl(80, 70%, 75%); 9 | --color-fail: hsl(0, 70%, 90%); 10 | --color-skipped: hsl(0, 0%, 85%); 11 | --color-error: hsl(0, 70%, 70%); 12 | --color-pass-darker: hsl(80, 70%, 40%); 13 | --color-fail-darker: hsl(0, 70%, 48%); 14 | --color-skipped-darker: hsl(0, 0%, 48%); 15 | --color-error-darker: hsl(0, 70%, 40%); 16 | --font-base: system-ui, "Helvetica Neue", "Segoe UI", sans-serif; 17 | } 18 | 19 | body { 20 | margin: auto; 21 | tab-size: 4; 22 | font-family: var(--font-base); 23 | 24 | & > h1 { 25 | font: 300 350%/1 var(--font-base); 26 | color: orange; 27 | 28 | @media (max-height: 10rem) { 29 | margin: 0; 30 | } 31 | 32 | & .home, 33 | & .remote { 34 | display: inline-block; 35 | vertical-align: middle; 36 | padding: .3em .4em; 37 | border-radius: .3em; 38 | margin-left: .5em; 39 | background: hsl(200, 50%, 70%); 40 | color: white; 41 | text-transform: uppercase; 42 | font-size: 40%; 43 | font-weight: bold; 44 | letter-spacing: -.03em; 45 | 46 | &:hover { 47 | background: var(--color-dark); 48 | text-decoration: none; 49 | } 50 | } 51 | 52 | .remote { 53 | background: hsl(200, 50%, 80%); 54 | } 55 | } 56 | } 57 | 58 | a { 59 | color: var(--color-dark) 60 | } 61 | 62 | a:not(:hover) { 63 | text-decoration: none; 64 | } 65 | 66 | h1 { 67 | font-size: 200%; 68 | line-height: 1; 69 | color: var(--color-dark); 70 | letter-spacing: -.04em; 71 | } 72 | 73 | .pass, .count-pass { 74 | --color: var(--color-pass); 75 | --dark-color: var(--color-pass-darker); 76 | } 77 | 78 | .fail, .count-fail { 79 | --color: var(--color-fail); 80 | --dark-color: var(--color-fail-darker); 81 | } 82 | 83 | .skipped, .count-skipped { 84 | --color: var(--color-skipped); 85 | --dark-color: var(--color-skipped-darker); 86 | } 87 | 88 | .error { 89 | --color: var(--color-error); 90 | --dark-color: var(--color-error-darker); 91 | } 92 | 93 | html:not(.index) { 94 | & body { 95 | width: 80em; 96 | max-width: calc(100vw - 5em); 97 | padding-top: 2em; 98 | 99 | counter-reset: passed var(--pass, 0) failed var(--fail, 0) skipped var(--skipped, 0); 100 | } 101 | 102 | & body > nav { 103 | position: fixed; 104 | top: 0; 105 | right: 0; 106 | left: 0; 107 | z-index: 3; 108 | display: flex; 109 | background: black; 110 | color: white; 111 | font: bold 150%/1 var(--font-base); 112 | 113 | @media (max-height: 5em) { 114 | &::after { 115 | content: var(--page) " tests"; 116 | position: absolute; 117 | top: 0; 118 | right: 0; 119 | padding: .2em; 120 | opacity: .5; 121 | pointer-events: auto; 122 | } 123 | } 124 | 125 | & .home { 126 | padding: .4em .3em; 127 | font-size: 80%; 128 | 129 | &:hover { 130 | text-decoration: none; 131 | background: #444; 132 | } 133 | } 134 | 135 | & .count-fail, 136 | & .count-pass, 137 | & .count-skipped { 138 | flex-shrink: 0; 139 | min-width: 0; 140 | align-items: center; 141 | padding: .2em; 142 | background: var(--dark-color); 143 | cursor: pointer; 144 | transition: .4s; 145 | 146 | &:not([hidden]) { 147 | display: flex; 148 | } 149 | } 150 | 151 | .count-pass { 152 | .count::before { 153 | content: counter(passed); 154 | } 155 | } 156 | 157 | .count-fail { 158 | .count::before { 159 | content: counter(failed); 160 | } 161 | } 162 | 163 | .count-skipped { 164 | .count::before { 165 | content: counter(skipped); 166 | } 167 | } 168 | 169 | .count-fail:is(.no-failed *), 170 | .count-pass:is(.no-passed *), 171 | .count-skipped:is(.no-skipped *) { 172 | display: none; 173 | } 174 | 175 | & .nav:not([hidden]) { 176 | display: flex; 177 | align-items: center; 178 | margin-left: auto; 179 | font-size: 75%; 180 | line-height: 1; 181 | color: var(--color); 182 | 183 | & button { 184 | padding: 0 .2em; 185 | background: transparent; 186 | border: none; 187 | color: inherit; 188 | font-size: inherit; 189 | line-height: 1; 190 | cursor: pointer; 191 | } 192 | } 193 | 194 | & .count::after { 195 | font-weight: 300; 196 | margin-left: .2em; 197 | } 198 | 199 | & .count-fail { 200 | flex-grow: var(--fail, 1); 201 | 202 | & .count::after { 203 | content: "failing"; 204 | } 205 | } 206 | 207 | & .count-pass { 208 | flex-grow: var(--pass, 1); 209 | 210 | & .count::after { 211 | content: "passing"; 212 | } 213 | } 214 | 215 | & .count-skipped { 216 | flex-grow: var(--skipped, 1); 217 | 218 | & .count::after { 219 | content: "skipped"; 220 | } 221 | } 222 | 223 | &[style*="--fail:0;"] .count-fail { 224 | display: none; 225 | } 226 | 227 | &[style*="--pass:0;"] .count-pass { 228 | display: none; 229 | } 230 | 231 | &[style*="--skipped:0;"] .count-skipped { 232 | display: none; 233 | } 234 | } 235 | 236 | & body > section { 237 | border: .2em solid var(--color-light); 238 | margin: .5em 0; 239 | padding: .5em; 240 | border-radius: .3em; 241 | 242 | & h1 { 243 | margin: 0; 244 | } 245 | 246 | & script[type="application/json"] { 247 | padding: .5em; 248 | background: var(--color-light); 249 | display: block; 250 | font-family: monospace; 251 | white-space: pre; 252 | } 253 | } 254 | } 255 | 256 | body > section > p, 257 | .notice { 258 | width: max-content; 259 | max-width: 100%; 260 | box-sizing: border-box; 261 | padding: .4em 1em; 262 | background: var(--color-light); 263 | font-style: italic; 264 | border-radius: .2em; 265 | } 266 | 267 | .notice { 268 | margin: 1em auto; 269 | font-size: 125%; 270 | } 271 | 272 | body > section > p { 273 | &::before { 274 | content: "Instructions: "; 275 | font-size: 80%; 276 | font-weight: bold; 277 | font-style: normal; 278 | text-transform: uppercase; 279 | } 280 | 281 | &.note::before { 282 | content: "Note: "; 283 | } 284 | } 285 | 286 | table.reftest { 287 | width: 100%; 288 | display: flex; 289 | flex-flow: column; 290 | 291 | & tr { 292 | display: flex; 293 | } 294 | 295 | & tbody > tr, 296 | & > tr { 297 | border: 1px solid hsla(0, 0%, 0%, .1); 298 | 299 | &:not([class]) { 300 | --color: rgba(0,0,0,.06); 301 | --dark-color: rgba(0,0,0,.3); 302 | } 303 | } 304 | 305 | & td, 306 | & th { 307 | flex: 1; 308 | } 309 | 310 | & th { 311 | padding: 0; 312 | color: gray; 313 | } 314 | 315 | & td { 316 | padding: .4em; 317 | } 318 | 319 | & td:not(:last-child) { 320 | border-right: 1px solid hsla(0, 0%, 0%, .1); 321 | } 322 | 323 | & td.details { 324 | display: flex; 325 | align-items: center; 326 | justify-content: space-between; 327 | gap: .5em; 328 | color: var(--dark-color); 329 | 330 | &::after { 331 | content: ""; 332 | height: 1.1lh; 333 | aspect-ratio: 1; 334 | background-color: currentColor; 335 | /* Icon source: https: //icon-sets.iconify.design/?query=console */ 336 | --svg: url('data:image/svg+xml,Click to log the error stack to the console'); 337 | mask-image: var(--svg); 338 | mask-repeat: no-repeat; 339 | mask-size: 100% 100%; 340 | cursor: pointer; 341 | } 342 | } 343 | 344 | & tr { 345 | position: relative; 346 | margin: .4em 0; 347 | background: var(--color); 348 | scroll-margin-top: 2.5rem; 349 | 350 | &[data-time]::after { 351 | content: attr(data-time); 352 | position: absolute; 353 | top: 0; 354 | right: 0; 355 | z-index: 1; 356 | padding: .4em; 357 | color: var(--dark-color); 358 | font: bold 75%/1 var(--font-base); 359 | text-shadow: 0 0 1px white, 0 0 2px white; 360 | } 361 | } 362 | 363 | &.skipped tr, 364 | & tr.skipped { 365 | --append: " (Skipped)"; 366 | 367 | &.fail { 368 | --append: " (skipped)" 369 | } 370 | } 371 | 372 | & tr[title], 373 | &.skipped tr, 374 | & tr.skipped { 375 | & td { 376 | padding-top: 1.5em; 377 | } 378 | 379 | &::before { 380 | content: attr(title) var(--append, ""); 381 | position: absolute; 382 | top: 0; left: 0; right: 0; 383 | padding: .4em; 384 | background: linear-gradient(to right, var(--dark-color), transparent); 385 | color: white; 386 | font: bold 75%/1 var(--font-base); 387 | text-shadow: 0 0 1px rgba(0,0,0,.5); 388 | } 389 | } 390 | 391 | &[data-test="selector"], 392 | & [data-test="selector"] { 393 | & .not::before, 394 | & .not::after { 395 | content: ":not("; 396 | font-weight: bold; 397 | color: red; 398 | mix-blend-mode: multiply; 399 | } 400 | 401 | & .not::after { 402 | content: ")" 403 | } 404 | } 405 | } 406 | 407 | body > section > div { 408 | border: 1px solid rgba(0,0,0,.15); 409 | background: rgba(0,0,0,.06); 410 | padding: .5em; 411 | margin: .5em; 412 | 413 | &[title] { 414 | position: relative; 415 | 416 | &::before { 417 | content: attr(title); 418 | display: block; 419 | padding: .4em; 420 | margin: -.66em; 421 | margin-bottom: .8em; 422 | background: linear-gradient(to right, rgba(0,0,0,.4), transparent); 423 | color: white; 424 | font: bold 75%/1 var(--font-base); 425 | text-shadow: 0 0 1px rgba(0,0,0,.5); 426 | } 427 | } 428 | } 429 | 430 | .index { 431 | & body { 432 | margin: 0; 433 | display: flex; 434 | height: 100vh; 435 | } 436 | 437 | & body > section { 438 | padding: 1em .5em; 439 | background: var(--color-light); 440 | overflow: auto; 441 | 442 | & h1 { 443 | font-weight: 300; 444 | margin: 0; 445 | } 446 | 447 | & ul { 448 | padding: 0; 449 | list-style: none; 450 | } 451 | 452 | & li { 453 | display: flex; 454 | align-items: center; 455 | padding: 0 .5em; 456 | border-radius: .2em; 457 | 458 | & a:first-of-type { 459 | flex: 1; 460 | padding: 0 .3em 0 .1em; 461 | font-weight: bold; 462 | font: 600 150%/1.4 var(--font-base); 463 | } 464 | 465 | &:not(:focus-within):not(:hover) a.new-tab { 466 | opacity: 0; 467 | transition: .4s; 468 | } 469 | 470 | a.new-tab { 471 | text-decoration: none; 472 | 473 | &:not(:focus-within):not(:hover) { 474 | mix-blend-mode: multiply; 475 | } 476 | } 477 | 478 | &.selected { 479 | background: var(--color-dark); 480 | 481 | & a { 482 | color: white; 483 | } 484 | } 485 | } 486 | } 487 | 488 | & iframe { 489 | flex: 1; 490 | border: 0; 491 | } 492 | } 493 | 494 | #iframes { 495 | flex: 1; 496 | display: flex; 497 | flex-flow: column; 498 | 499 | &:empty, 500 | &:not(:empty) + iframe { 501 | display: none; 502 | } 503 | 504 | & iframe { 505 | width: 100%; 506 | height: 2em; 507 | } 508 | } 509 | -------------------------------------------------------------------------------- /htest.js: -------------------------------------------------------------------------------- 1 | { // Careful: this is NOT run in module context! 2 | let currentPage; 3 | 4 | if (/\/$/.test(location.pathname)) { 5 | currentPage = "index"; 6 | } 7 | else { 8 | currentPage = (location.pathname.match(/\/([a-z-]+)(?:\.html|\/?$)/) || [, "index"])[1]; 9 | } 10 | 11 | document.documentElement.style.setProperty("--page", `"${currentPage}"`); 12 | 13 | 14 | let isIndex = document.documentElement.classList.contains("index"); 15 | let loaded = import(`https://html.htest.dev/src/${isIndex? "harness" : "testpage"}.js`); 16 | 17 | let util; 18 | 19 | async function ready (doc = document) { 20 | await new Promise(resolve => { 21 | if (doc.readyState !== "loading") { 22 | resolve(); 23 | } 24 | else { 25 | doc.addEventListener("DOMContentLoaded", resolve, {once: true}); 26 | } 27 | }); 28 | await Promise.all([ 29 | loaded, 30 | import("https://html.htest.dev/src/util.js").then(m => util = m) 31 | ]); 32 | } 33 | 34 | /** 35 | * Global functions to be available to tests 36 | */ 37 | 38 | async function $out (...texts) { 39 | var script = this instanceof HTMLElement && this.matches("script")? this : document.currentScript; 40 | 41 | for (let text of texts) { 42 | if (typeof text === "function") { 43 | await ready(); 44 | try { 45 | text = text(); 46 | } 47 | catch (err) { 48 | text = `
${err}
`; 49 | } 50 | } 51 | 52 | text = util.output(text); 53 | 54 | if (document.readyState == "loading") { 55 | document.write(text); 56 | } 57 | else if (script && script.parentNode) { 58 | script.insertAdjacentHTML("afterend", text); 59 | } 60 | else { 61 | console.log(script, script.parentNode) 62 | console.log("Test print", text); 63 | } 64 | } 65 | } 66 | 67 | function $outln (...text) { 68 | $out(...text, " ", document.createElement("br")); 69 | } 70 | 71 | Object.assign(globalThis, {$out, $outln}); 72 | 73 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "htest.dev", 3 | "version": "0.0.17", 4 | "description": "", 5 | "scripts": { 6 | "test": "bin/htest.js tests/index.js", 7 | "build:html": "npx @11ty/eleventy --config=_build/eleventy.js", 8 | "watch:html": "npx @11ty/eleventy --config=_build/eleventy.js --watch", 9 | "build": "npm run build:docs && npm run build:html", 10 | "build:docs": "npx typedoc", 11 | "watch:docs": "npx typedoc --watch --preserveWatchOutput", 12 | "watch": "npm run watch:html", 13 | "eslint": "npx eslint .", 14 | "eslint:fix": "npx eslint . --fix", 15 | "release": "release-it" 16 | }, 17 | "type": "module", 18 | "exports": { 19 | ".": { 20 | "import": "./src/index.js" 21 | }, 22 | "./check": { 23 | "import": "./src/check.js" 24 | }, 25 | "./map": { 26 | "import": "./src/map.js" 27 | }, 28 | "./env": { 29 | "import": "./src/env/index.js" 30 | } 31 | }, 32 | "bin": { 33 | "htest": "bin/htest.js" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/htest-dev/htest.git" 38 | }, 39 | "keywords": [], 40 | "author": "Lea Verou", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/htest-dev/htest/issues" 44 | }, 45 | "homepage": "https://htest.dev", 46 | "devDependencies": { 47 | "@11ty/eleventy": "^3.0.0-alpha.5", 48 | "@11ty/eleventy-navigation": "^0.3.5", 49 | "@stylistic/eslint-plugin": "latest", 50 | "chalk": "^5.3.0", 51 | "eleventy-plugin-toc": "^1.1.5", 52 | "eslint": "latest", 53 | "globals": "latest", 54 | "markdown-it-anchor": "^8.6.7", 55 | "markdown-it-attrs": "^4.1.6", 56 | "release-it": "latest", 57 | "typedoc": "^0.27" 58 | }, 59 | "dependencies": { 60 | "diff": "^7.0.0", 61 | "glob": "^10.3.10", 62 | "log-update": "^6.0.0", 63 | "oo-ascii-tree": "^1.91.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/check.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * This is hTest’s assertion library (but you can use any other) 4 | * Most functions generate assertion functions based on the parameters you specify. 5 | */ 6 | import { getType } from "./util.js"; 7 | 8 | /** 9 | * Combine multiple checks, requiring a test to pass all of them to pass 10 | * @param {...function} fns 11 | * @returns {function} 12 | */ 13 | export function and (...fns) { 14 | return function (...args) { 15 | return fns.every(fn => fn(...args)); 16 | }; 17 | } 18 | 19 | /** 20 | * Combine multiple checks, requiring a test to pass any of them to pass 21 | * @param {...function} fns 22 | * @returns {function} 23 | */ 24 | export function or (...fns) { 25 | return function (...args) { 26 | return fns.some(fn => fn(...args)); 27 | }; 28 | } 29 | 30 | /** 31 | * Check value type 32 | * @param {string} type 33 | * @returns {function} 34 | */ 35 | export function is (type) { 36 | type = type.toLowerCase(); 37 | 38 | return function (actual) { 39 | return getType(actual) === type; 40 | }; 41 | } 42 | 43 | /** 44 | * Apply a checking function recursively to objects and collections 45 | * @param {function} [check] Function to apply to compare primitive values. Defaults to strict equality 46 | * @returns {(actual, expect) => boolean} 47 | */ 48 | export function deep (check = (a, b) => a === b) { 49 | let callee = function (actual, expect) { 50 | if (check.call(this, actual, expect)) { 51 | return true; 52 | } 53 | 54 | if (typeof expect !== "object") { 55 | // If not an object, it's definitely not a container object, 56 | // and we know it doesn't pass, so we can fail early. 57 | return false; 58 | } 59 | 60 | if (Array.isArray(expect)) { 61 | if (!Array.isArray(actual) || actual.length < expect.length) { 62 | return false; 63 | } 64 | 65 | return expect.every((ref, i) => callee.call(this, actual[i], ref)); 66 | } 67 | 68 | let type = getType(expect); 69 | let actualtype = getType(actual); 70 | 71 | if (expect?.[Symbol.iterator]) { 72 | // Iterable collection (Array, Set, Map, NodeList, etc.) 73 | if (actualtype !== type) { 74 | return false; 75 | } 76 | 77 | return callee.call(this, [...actual], [...expect]); 78 | } 79 | 80 | // Compare objects recursively 81 | if (type === "object") { 82 | if (actualtype !== type) { 83 | return false; 84 | } 85 | 86 | let propertyUnion = new Set([...Object.keys(expect), ...Object.keys(actual)]); 87 | return [...propertyUnion].every(key => callee(actual[key], expect[key])); 88 | } 89 | 90 | return false; 91 | }; 92 | 93 | callee.shallow = check; 94 | 95 | return callee; 96 | } 97 | 98 | /** 99 | * Shallow equals function at the core of many other comparison functions 100 | * @param {object} options 101 | * @param {boolean} [options.looseTypes = false] If true, skip type check (e.g. "5" can match 5) 102 | * @param {boolean} [options.subset = false] If true, `undefined` is considered equal to anything 103 | * @param {number} [options.epsilon = 0] Epsilon for number comparison 104 | * @returns {(actual, expect) => boolean} 105 | */ 106 | export function shallowEquals ({ 107 | looseTypes = false, 108 | subset = false, 109 | epsilon = 0, 110 | } = {}) { 111 | return function (actual, expect) { 112 | if (expect === actual) { 113 | return true; 114 | } 115 | 116 | if (expect === null) { 117 | return actual === null; 118 | } 119 | 120 | if (expect === undefined && subset) { 121 | return true; 122 | } 123 | 124 | let expectType = getType(expect); 125 | let actualType = getType(actual); 126 | 127 | if (expectType === actualType || looseTypes) { 128 | if (expectType === "number") { 129 | if (Number.isNaN(expect)) { 130 | return Number.isNaN(actual); 131 | } 132 | 133 | if (epsilon > 0) { 134 | return Math.abs(expect - actual) <= epsilon; 135 | } 136 | } 137 | 138 | return expect == actual; 139 | } 140 | 141 | return false; 142 | }; 143 | } 144 | 145 | /** 146 | * Compare by equality. Slightly more permissive than `===`. 147 | * Deep by default, use `equals.shallow` for shallow comparison. 148 | * @param {*} expect 149 | * @param {*} actual 150 | * @returns {boolean} 151 | */ 152 | export const equals = deep(shallowEquals()); 153 | 154 | /** 155 | * Compare by specifying subsets of properties to compare 156 | * @param {*} expect 157 | * @param {*} actual 158 | * @returns {boolean} 159 | */ 160 | export const subset = deep(shallowEquals({subset: true})); 161 | 162 | /** 163 | * Compare numbers or lists of numbers with a margin of error 164 | * @param {object} [options] Options object 165 | * @param {number} [options.epsilon = Number.EPSILON] Epsilon for comparison 166 | * @returns {(actual, expect) => boolean} 167 | */ 168 | export function proximity ({epsilon = Number.EPSILON, ...options} = {}) { 169 | return shallowEquals({epsilon, ...options}); 170 | } 171 | 172 | /** 173 | * Check that numbers (or lists of numbers) are within certain upper and/or lower bounds 174 | * @param {object} [options] 175 | * @param {number} options.gt 176 | * @param {number} options.gte 177 | * @param {number} options.lt 178 | * @param {number} options.lte 179 | * @param {number} options.from Alias of `options.lt` 180 | * @param {number} options.max Alias of `options.lte` 181 | * @param {number} options.to Alias of `options.gt` 182 | * @param {number} options.min Alias of `options.gte` 183 | * @returns {(actual, expect) => boolean} 184 | */ 185 | export function range (options = {}) { 186 | options.lt ??= options.from; 187 | options.lte ??= options.min; 188 | options.gt ??= options.to; 189 | options.gte ??= options.max; 190 | 191 | return function (actual) { 192 | return ( 193 | (options.lt === undefined || actual < options.lt) && 194 | (options.lte === undefined || actual <= options.lte) && 195 | (options.gt === undefined || actual > options.gt) && 196 | (options.gte === undefined || actual >= options.gte) 197 | ); 198 | }; 199 | } 200 | 201 | /** 202 | * Alias of `range()` 203 | */ 204 | export const between = range; 205 | -------------------------------------------------------------------------------- /src/classes/BubblingEventTarget.js: -------------------------------------------------------------------------------- 1 | export default class BubblingEventTarget extends EventTarget { 2 | parent = null; 3 | 4 | constructor () { 5 | super(); 6 | } 7 | 8 | dispatchEvent (event) { 9 | if (event.bubbles) { 10 | let parent = this; 11 | while (parent = parent.parent) { 12 | let target = event.detail?.target ?? event.target ?? this; 13 | let newEvent = new CustomEvent(event.type, { 14 | ...event, 15 | detail: { target }, 16 | }); 17 | parent.dispatchEvent(newEvent); 18 | } 19 | } 20 | 21 | return super.dispatchEvent(event); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/classes/Test.js: -------------------------------------------------------------------------------- 1 | import * as check from "../check.js"; 2 | import { stringify } from "../util.js"; 3 | 4 | /** 5 | * Represents a single test or a group of tests 6 | */ 7 | export default class Test { 8 | data = {}; 9 | 10 | constructor (test, parent) { 11 | if (!test) { 12 | console.warn("Empty test: ", test); 13 | return; 14 | } 15 | 16 | if (parent) { 17 | test.parent = parent; 18 | this.level = parent.level + 1; 19 | } 20 | else { 21 | this.level = 0; 22 | } 23 | 24 | Object.assign(this, test); 25 | 26 | this.data = Object.assign({}, this.parent?.data, this.data); 27 | this.originalName = this.name; 28 | 29 | if (typeof this.name === "function") { 30 | this.getName = this.name; 31 | } 32 | 33 | // Inherit properties from parent 34 | // This works recursively because the parent constructor runs before its children 35 | if (this.parent) { 36 | for (let prop of ["beforeEach", "run", "afterEach", "map", "check", "getName", "args", "expect", "getExpect", "throws", "maxTime", "maxTimeAsync", "skip"]) { 37 | if (!(prop in this) && prop in this.parent) { 38 | this[prop] = this.parent[prop]; 39 | } 40 | } 41 | } 42 | 43 | if (!this.check) { 44 | this.check = check.equals; 45 | } 46 | else if (typeof this.check === "object") { 47 | let {deep, ...options} = this.check; 48 | let shallowEquals = check.shallowEquals(options); 49 | this.check = deep ? check.deep(shallowEquals) : shallowEquals; 50 | } 51 | 52 | if ("arg" in this) { 53 | // Single argument 54 | this.args = [this.arg]; 55 | } 56 | else if ("args" in this) { 57 | // Single args don't need to be wrapped in an array 58 | if (!Array.isArray(this.args)) { 59 | this.args = [this.args]; 60 | } 61 | } 62 | else { 63 | // No args 64 | this.args = []; 65 | } 66 | 67 | if (!this.name) { 68 | if (this.getName) { 69 | this.name = this.getName.apply(this, this.args); 70 | } 71 | else if (this.isTest) { 72 | this.name = this.args.length > 0 ? stringify(this.args[0]) : "(No args)"; 73 | } 74 | } 75 | 76 | if (this.isGroup) { 77 | this.tests = this.tests.filter(Boolean).map(t => t instanceof Test ? t : new Test(t, this)); 78 | } 79 | 80 | if (!("expect" in this)) { 81 | if (this.getExpect) { 82 | this.expect = this.getExpect.apply(this, this.args); 83 | } 84 | else { 85 | this.expect = this.args[0]; 86 | } 87 | } 88 | } 89 | 90 | get isTest () { 91 | return !this.isGroup; 92 | } 93 | 94 | get isGroup () { 95 | return this.tests?.length > 0; 96 | } 97 | 98 | get testCount () { 99 | let count = this.isTest ? 1 : 0; 100 | 101 | if (this.tests) { 102 | count += this.tests.reduce((prev, current) => prev + current.testCount, 0); 103 | } 104 | 105 | return count; 106 | } 107 | 108 | warn (msg) { 109 | let message = `[${this.name}] ${msg}`; 110 | (this.warnings ??= []).push(message); 111 | 112 | if (this.constructor.warn) { 113 | this.constructor.warn(message); 114 | } 115 | } 116 | 117 | static warn (...args) { 118 | console.warn("[hTest test]", ...args); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/classes/TestResult.js: -------------------------------------------------------------------------------- 1 | import BubblingEventTarget from "./BubblingEventTarget.js"; 2 | import format, { stripFormatting } from "../format-console.js"; 3 | import { delay, formatDuration, interceptConsole, pluralize, stringify, formatDiff } from "../util.js"; 4 | import { IS_NODEJS } from "../util.js"; 5 | 6 | // Make the diff package available both in Node.js and the browser 7 | const { diffChars } = await import(IS_NODEJS ? "diff" : "https://cdn.jsdelivr.net/npm/diff@7.0.0/lib/index.es6.js"); 8 | 9 | /** 10 | * Represents the result of a test or group of tests. 11 | */ 12 | export default class TestResult extends BubblingEventTarget { 13 | pass; 14 | details = []; 15 | timeTaken = 0; 16 | stats = {}; 17 | 18 | /** 19 | * 20 | * @param {object} test 21 | * @param {object} [parent = null] 22 | * @param {object} [options] 23 | * @param {string | string[] | number | number[]} [options.only] Only run a subset of tests 24 | * If one or more numbers, or a string that begins with a number, it is a path to a test/group 25 | * If one or more identifiers, it will only run tests with that id, regardless of nesting 26 | * If mixed, it will follow the numbers as a path, then will not consume any more numbers until it finds the id. 27 | * @param {boolean} [options.verbose] Show all tests, not just failed ones 28 | */ 29 | constructor (test, parent, options = {}) { 30 | super(); 31 | 32 | this.test = test; 33 | this.parent = parent ?? null; 34 | this.options = options; 35 | 36 | if (this.options.only) { 37 | this.options.only = Array.isArray(this.options.only) ? this.options.only : [this.options.only]; 38 | } 39 | 40 | this.addEventListener("done", e => { 41 | let originalTarget = e.detail?.target ?? e.target; 42 | 43 | if (originalTarget.test.isTest) { 44 | if (originalTarget.test.skip) { 45 | this.stats.skipped++; 46 | } 47 | else if (originalTarget.pass) { 48 | this.stats.pass++; 49 | } 50 | else { 51 | this.stats.fail++; 52 | } 53 | 54 | if (originalTarget.messages?.length > 0) { 55 | this.stats.messages += originalTarget.messages.length; 56 | } 57 | 58 | this.timeTaken += originalTarget.timeTaken; 59 | 60 | if (originalTarget.timeTakenAsync) { 61 | this.timeTakenAsync ??= 0; 62 | this.timeTakenAsync += originalTarget.timeTakenAsync; 63 | } 64 | 65 | this.stats.pending--; 66 | 67 | if (this.stats.pending <= 0) { 68 | this.dispatchEvent(new Event("finish")); 69 | } 70 | } 71 | }); 72 | } 73 | 74 | get name () { 75 | return this.test.name; 76 | } 77 | 78 | warn (msg) { 79 | return Test.prototype.warn.call(this, msg); 80 | } 81 | 82 | /** 83 | * Run the test(s) 84 | */ 85 | async run () { 86 | this.messages = await interceptConsole(async () => { 87 | if (!this.parent) { 88 | // We are running the test in isolation, so we need to run beforeAll (if it exists) 89 | await this.test.beforeAll?.(); 90 | } 91 | 92 | await this.test.beforeEach?.(); 93 | 94 | let start = performance.now(); 95 | 96 | try { 97 | this.actual = this.test.run ? this.test.run.apply(this.test, this.test.args) : this.test.args[0]; 98 | this.timeTaken = performance.now() - start; 99 | 100 | if (this.actual instanceof Promise) { 101 | this.actual = await this.actual; 102 | this.timeTakenAsync = performance.now() - start; 103 | } 104 | } 105 | catch (e) { 106 | this.error = e; 107 | } 108 | finally { 109 | await this.test.afterEach?.(); 110 | 111 | if (!this.parent) { 112 | // We are running the test in isolation, so we need to run afterAll 113 | await this.test.afterAll?.(); 114 | } 115 | } 116 | }); 117 | 118 | this.evaluate(); 119 | } 120 | 121 | static STATS_AVAILABLE = ["pass", "fail", "error", "skipped", "total", "totalTime", "totalTimeAsync", "messages"]; 122 | 123 | /** 124 | * Run all tests in the group 125 | * @returns {TestResult} 126 | */ 127 | runAll () { 128 | this.stats = Object.fromEntries(TestResult.STATS_AVAILABLE.map(k => [k, 0])); 129 | this.stats.total = this.test.testCount; 130 | this.stats.pending = this.stats.total; 131 | this.finished = new Promise(resolve => this.addEventListener("finish", resolve, {once: true})); 132 | 133 | let tests = this.test.tests; 134 | let childOptions = Object.assign({}, this.options); 135 | 136 | this.dispatchEvent(new Event("start", {bubbles: true})); 137 | 138 | if (this.options.only) { 139 | childOptions.only = childOptions.only.slice(); 140 | let first = childOptions.only[0]; 141 | if (/^\d/.test(first)) { // Path 142 | // TODO ranges (e.g. "0-5") 143 | tests = [tests[first]]; 144 | childOptions.only.unshift(); 145 | } 146 | else { 147 | // Id 148 | let test = tests.find(t => t.id === first); 149 | 150 | if (test) { 151 | tests = [test]; 152 | // We only remove the id if found, since otherwise it may be found in a descendant 153 | childOptions.only.unshift(); 154 | } 155 | } 156 | } 157 | 158 | this.tests = this.test.tests?.map(t => new TestResult(t, this, childOptions)); 159 | 160 | delay(1) 161 | .then(() => this.test.beforeAll?.()) 162 | .then(() => { 163 | if (this.test.isTest) { 164 | if (this.test.skip) { 165 | this.skip(); 166 | } 167 | else { 168 | this.run(); 169 | } 170 | } 171 | 172 | return Promise.allSettled((this.tests ?? []).map(test => test.runAll())); 173 | }) 174 | .then(() => this.finished) 175 | .finally(() => this.test.afterAll?.()); 176 | 177 | return this; 178 | } 179 | 180 | /** 181 | * Evaluate the result of the test 182 | */ 183 | evaluate () { 184 | let test = this.test; 185 | 186 | if (test.throws !== undefined) { 187 | Object.assign(this, this.evaluateThrown()); 188 | } 189 | else if (test.maxTime || test.maxTimeAsync) { 190 | Object.assign(this, this.evaluateTimeTaken()); 191 | } 192 | else { 193 | Object.assign(this, this.evaluateResult()); 194 | } 195 | 196 | this.dispatchEvent(new Event("done", {bubbles: true})); 197 | } 198 | 199 | /** 200 | * Skip the test 201 | */ 202 | skip () { 203 | this.dispatchEvent(new Event("done", {bubbles: true})); 204 | } 205 | 206 | /** 207 | * Evaluate whether a thrown error is as expected 208 | * @returns {{pass, details: string[]} 209 | */ 210 | evaluateThrown () { 211 | let test = this.test; 212 | let ret = {pass: !!this.error, details: []}; 213 | 214 | // We may have more picky criteria for the error 215 | if (ret.pass) { 216 | if (test.throws === false) { 217 | // We expect no error, but got one 218 | ret.pass = false; 219 | ret.details.push(`Expected no error, but got ${ this.error }`); 220 | } 221 | else if (test.throws.prototype instanceof Error) { 222 | // We want a specific subclass, e.g. TypeError 223 | ret.pass &&= this.error instanceof test.throws; 224 | 225 | if (!ret.pass) { 226 | ret.details.push(`Got error ${ this.error }, but was not a subclass of ${ test.throws.name }`); 227 | } 228 | } 229 | else if (typeof test.throws === "function") { 230 | ret.pass &&= test.throws(this.error); 231 | 232 | if (!ret.pass) { 233 | ret.details.push(`Got error ${ this.error }, but didn’t pass test ${ test.throws }`); 234 | } 235 | } 236 | } 237 | else if (test.throws === false) { 238 | // We expect no error and got none, so this is good 239 | ret.pass = true; 240 | } 241 | else { 242 | ret.details.push(`Expected error but ${ this.actual !== undefined ? `got ${ stringify(this.actual) }` : "none was thrown" }`); 243 | } 244 | 245 | return ret; 246 | } 247 | 248 | /** 249 | * Evaluate whether the test passed or failed 250 | * @returns {{pass, details: string[]}} 251 | */ 252 | evaluateResult () { 253 | let test = this.test; 254 | let ret = {pass: true, details: []}; 255 | 256 | if (test.map) { 257 | try { 258 | this.mapped = { 259 | actual: Array.isArray(this.actual) ? this.actual.map(test.map) : test.map(this.actual), 260 | expect: Array.isArray(test.expect) ? test.expect.map(test.map) : test.map(test.expect), 261 | }; 262 | 263 | try { 264 | ret.pass = test.check(this.mapped.actual, this.mapped.expect); 265 | } 266 | catch (e) { 267 | this.error = new Error(`check() failed (working with mapped values). ${ e.message }`); 268 | } 269 | } 270 | catch (e) { 271 | this.error = new Error(`map() failed. ${ e.message }`); 272 | } 273 | } 274 | else { 275 | try { 276 | ret.pass = test.check(this.actual, test.expect); 277 | } 278 | catch (e) { 279 | this.error = new Error(`check() failed. ${ e.message }`); 280 | } 281 | } 282 | 283 | // If `map()` or `check()` errors, consider the test failed 284 | if (this.error) { 285 | ret.pass = false; 286 | } 287 | 288 | if (!ret.pass) { 289 | if (this.error) { 290 | ret.details.push(`Got error ${ this.error } 291 | ${ this.error.stack }`); 292 | } 293 | else { 294 | let actual = this.mapped?.actual ?? this.actual; 295 | let actualString = stringify(actual); 296 | 297 | let message; 298 | if ("expect" in test) { 299 | let expect = this.mapped?.expect ?? test.expect; 300 | let expectString = stringify(expect); 301 | 302 | let changes = diffChars(actualString, expectString); 303 | 304 | // Calculate output lengths to determine formatting style 305 | let actualLength = actualString.length; 306 | if (this.mapped && actual !== this.actual) { 307 | actualLength += stringify(this.actual).length; 308 | } 309 | 310 | let expectedLength = expectString.length; 311 | if (this.mapped && expect !== test.expect) { 312 | expectedLength += stringify(test.expect).length; 313 | } 314 | 315 | // TODO: Use global (?) option instead of the magic number 40 316 | let inline = Math.max(actualLength, expectedLength) <= 40; 317 | if (inline) { 318 | message = `Got ${ formatDiff(changes) }`; 319 | if (this.mapped && actual !== this.actual) { 320 | message += ` (${ stringify(this.actual) } unmapped)`; 321 | } 322 | 323 | message += `, expected ${ formatDiff(changes, { expected: true }) }`; 324 | if (this.mapped && expect !== test.expect) { 325 | message += ` (${ stringify(test.expect) } unmapped)`; 326 | } 327 | } 328 | else { 329 | // Vertical format for long values 330 | message = "\n Actual: " + formatDiff(changes); 331 | if (this.mapped && actual !== this.actual) { 332 | message += `\n\t\t ${ stringify(this.actual) } unmapped`; 333 | } 334 | 335 | message += "\n Expected: " + formatDiff(changes, { expected: true }); 336 | if (this.mapped && expect !== test.expect) { 337 | message += `\n\t\t ${ stringify(test.expect) } unmapped`; 338 | } 339 | } 340 | } 341 | else { 342 | message = `Got ${ actualString }`; 343 | if (this.mapped && actual !== this.actual) { 344 | message += ` (${ stringify(this.actual) } unmapped)`; 345 | } 346 | message += " which doesn't pass the test provided"; 347 | } 348 | 349 | ret.details.push(message); 350 | } 351 | } 352 | 353 | return ret; 354 | } 355 | 356 | /** 357 | * Evaluate whether the test took too long (for tests with time constraints) 358 | * @returns {{pass, details: string[]}} 359 | */ 360 | evaluateTimeTaken () { 361 | let test = this.test; 362 | let ret = {pass: true, details: []}; 363 | 364 | if (test.maxTime) { 365 | ret.pass &&= this.timeTaken <= test.maxTime; 366 | 367 | if (!ret.pass) { 368 | ret.details.push(`Exceeded max time of ${ test.maxTime }ms (took ${ this.timeTaken }ms)`); 369 | } 370 | } 371 | 372 | if (test.maxTimeAsync) { 373 | ret.pass &&= this.timeTakenAsync <= test.maxTimeAsync; 374 | 375 | if (!ret.pass) { 376 | ret.details.push(`Exceeded max async time of ${ test.maxTimeAsync }ms (took ${ this.timeTakenAsync }ms)`); 377 | } 378 | } 379 | 380 | return ret; 381 | } 382 | 383 | get isLast () { 384 | return this.parent.tests[this.parent.tests.length - 1] === this; 385 | } 386 | 387 | /** 388 | * Get a string representation of the test result 389 | * @param {object} [o] 390 | * @returns {string} 391 | */ 392 | getResult (o) { 393 | let color = this.pass ? "green" : "red"; 394 | let ret = [ 395 | ` ${ this.pass ? "PASS" : "FAIL" } `, 396 | `${this.name ?? "(Anonymous)"}`, 397 | ].join(" "); 398 | 399 | if (this.messages?.length > 0) { 400 | let suffix = pluralize(this.messages.length, "message", "messages"); 401 | ret += ` ${ this.messages.length } ${ suffix }`; 402 | } 403 | 404 | ret += ` (${ formatDuration(this.timeTaken ?? 0) })`; 405 | 406 | if (this.details?.length > 0) { 407 | ret += ": " + this.details.join(", "); 408 | } 409 | 410 | return o?.format === "rich" ? ret : stripFormatting(ret); 411 | } 412 | 413 | /** 414 | * Get a summary of the current status of the test 415 | * @param {object} [o] Options 416 | * @param {"rich" | "plain"} [o.format="rich"] Format to use for output. Defaults to "rich" 417 | * @returns {string} 418 | */ 419 | getSummary (o = {}) { 420 | let stats = this.stats; 421 | let ret = [ 422 | `${this.name ?? (this.test.level === 0 ? "(All tests)" : "")}`, 423 | ]; 424 | 425 | if (stats.pass > 0) { 426 | ret.push(`${ stats.pass }/${ stats.total } PASS`); 427 | } 428 | 429 | if (stats.fail > 0) { 430 | ret.push(`${ stats.fail }/${ stats.total } FAIL`); 431 | } 432 | 433 | if (stats.pending > 0) { 434 | ret.push(`${ stats.pending }/${ stats.total } remaining`); 435 | } 436 | 437 | if (stats.skipped > 0) { 438 | ret.push(`${ stats.skipped }/${ stats.total } skipped`); 439 | } 440 | 441 | if (stats.messages > 0) { 442 | let suffix = pluralize(stats.messages, "message", "messages"); 443 | ret.push(`${ stats.messages } ${ suffix }`); 444 | } 445 | 446 | let icon = stats.fail > 0 ? "❌" : stats.pending > 0 ? "⏳" : "✅"; 447 | ret.splice(1, 0, icon); 448 | 449 | if (this.timeTaken) { 450 | ret.push(`(${ formatDuration(this.timeTaken) })`); 451 | } 452 | 453 | ret = ret.join(" "); 454 | 455 | return o?.format === "rich" ? ret : stripFormatting(ret); 456 | } 457 | 458 | /** 459 | * Get a summary of console messages intercepted during the test run. 460 | * @param {object} [o] Options 461 | * @param {"rich" | "plain"} [o.format="rich"] Format to use for output. Defaults to "rich". 462 | * @returns {string} 463 | */ 464 | getMessages (o = {}) { 465 | let ret = new String("(Messages)"); 466 | ret.children = this.messages.map(m => `(${ m.method }) ${ m.args.join(" ") }`); 467 | 468 | return o?.format === "rich" ? ret : stripFormatting(ret); 469 | } 470 | 471 | toString (o) { 472 | let ret = []; 473 | 474 | if (this.test.isGroup) { 475 | ret.push(this.getSummary(o)); 476 | } 477 | else if (this.pass === false || this.messages?.length > 0 || o?.verbose) { 478 | ret.push(this.getResult(o)); 479 | } 480 | 481 | ret = ret.join("\n"); 482 | 483 | if (this.tests || this.messages) { 484 | ret = new String(ret); 485 | 486 | if (this.tests) { 487 | ret.children = this.tests.filter(t => t.stats.fail + t.stats.pending + t.stats.skipped + t.stats.messages > 0) 488 | .flatMap(t => t.toString(o)).filter(Boolean); 489 | } 490 | 491 | if (this.messages?.length > 0) { 492 | (ret.children ??= []).push(this.getMessages(o)); 493 | } 494 | 495 | if (ret.children?.length > 0 || ret.messages?.length > 0) { 496 | ret.collapsed = this.collapsed; 497 | ret.highlighted = this.highlighted; 498 | } 499 | } 500 | 501 | return ret; 502 | } 503 | 504 | static warn (...args) { 505 | console.warn("[hTest result]", ...args); 506 | } 507 | } 508 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import path from "path"; 3 | import { globSync } from "glob"; 4 | import env from "./env/node.js"; 5 | import run from "./run.js"; 6 | 7 | const CONFIG_GLOB = "{,_,.}htest.{json,config.json,config.js}"; 8 | let config; 9 | 10 | 11 | export async function getConfig (glob = CONFIG_GLOB) { 12 | if (config) { 13 | return config; 14 | } 15 | 16 | let paths = globSync(glob); 17 | 18 | if (paths.length > 0) { 19 | let configPath = "./" + paths[0]; 20 | let importParams; 21 | configPath = path.join(process.cwd(), configPath); 22 | // return import(p).then(m => m.default ?? m); 23 | if (configPath.endsWith(".json")) { 24 | importParams = {assert: { type: "json" }, with: { type: "json" }}; 25 | } 26 | 27 | config = await import(configPath, importParams).then(m => config = m.default); 28 | 29 | return config; 30 | } 31 | } 32 | 33 | /** 34 | * Run tests via a CLI command 35 | * First argument is the location to look for tests (defaults to the current directory) 36 | * Second argument is the test path (optional) 37 | * 38 | * Supported flags: 39 | * --ci Run in continuous integration mode (disables interactive features) 40 | * 41 | * @param {object} [options] Same as `run()` options, but command line arguments take precedence 42 | */ 43 | export default async function cli (options = {}) { 44 | let config = await getConfig(); 45 | if (config) { 46 | options = {...config, ...options}; 47 | } 48 | 49 | let argv = process.argv.slice(2); 50 | 51 | // Check for “--ci” flag 52 | let ciIndex = argv.indexOf("--ci"); 53 | if (ciIndex !== -1) { 54 | // Remove “--ci” from args 55 | argv.splice(ciIndex, 1); 56 | options.ci = true; 57 | } 58 | 59 | let location = argv[0]; 60 | 61 | if (argv[1]) { 62 | options.path = argv[1]; 63 | } 64 | 65 | run(location, {env, ...options}); 66 | } 67 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | export { equals } from "./check.js"; 2 | 3 | console.warn("equals() from /src/content.js has moved to /src/check.js"); 4 | -------------------------------------------------------------------------------- /src/env/auto.js: -------------------------------------------------------------------------------- 1 | import { IS_NODEJS } from "../util.js"; 2 | 3 | /** 4 | * Resolves to one of the existing environments based on heuristics 5 | */ 6 | const env = await import(IS_NODEJS ? "./node.js" : "./console.js").then(m => m.default); 7 | export default env; 8 | -------------------------------------------------------------------------------- /src/env/console.js: -------------------------------------------------------------------------------- 1 | import format from "../format-console.js"; 2 | 3 | function printTree (str, parent) { 4 | if (str.children?.length > 0) { 5 | console["group" + (parent ? "Collapsed" : "")](format(str)); 6 | for (let child of str.children) { 7 | printTree(child, str); 8 | } 9 | console.groupEnd(); 10 | } 11 | else { 12 | console.log(format(str)); 13 | } 14 | } 15 | 16 | /** 17 | * Environment-agnostic env that prints results in the console (without depending on terminal-specific things) 18 | */ 19 | export default { 20 | name: "Console", 21 | resolveLocation (test) { 22 | return import(test).then(m => m.default ?? m); 23 | }, 24 | finish (result, options, event) { 25 | let str = result.toString({ format: options.format ?? "rich" }); 26 | printTree(str); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/env/index.js: -------------------------------------------------------------------------------- 1 | export { default as nodeRun } from "./node.js"; 2 | export { default as consoleRun } from "./console.js"; 3 | export { default as autoRun } from "./auto.js"; 4 | -------------------------------------------------------------------------------- /src/env/node.js: -------------------------------------------------------------------------------- 1 | // Native Node packages 2 | import fs from "fs"; 3 | import path from "path"; 4 | import { pathToFileURL } from "url"; 5 | import * as readline from "node:readline"; 6 | 7 | // Dependencies 8 | import logUpdate from "log-update"; 9 | import { AsciiTree } from "oo-ascii-tree"; 10 | import { globSync } from "glob"; 11 | 12 | // Internal modules 13 | import format from "../format-console.js"; 14 | import { getType } from "../util.js"; 15 | 16 | /** 17 | * Recursively traverse a subtree starting from `node` 18 | * and make groups of tests and test with console messages 19 | * either collapsed or expanded by setting its `collapsed` property. 20 | * @param {object} node - The root node of the subtree. 21 | * @param {boolean} collapsed - Whether to collapse or expand the subtree. 22 | */ 23 | function setCollapsed (node, collapsed = true) { 24 | if (node.tests?.length || node.messages?.length) { 25 | node.collapsed = collapsed; 26 | 27 | let nodes = [...(node.tests ?? []), ...(node.messages ?? [])]; 28 | for (let node of nodes) { 29 | setCollapsed(node, collapsed); 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * Recursively traverse a subtree starting from `node` and return all visible groups of tests or tests with console messages. 36 | */ 37 | function getVisibleGroups (node, options, groups = []) { 38 | groups.push(node); 39 | 40 | if (node.collapsed === false && node.tests?.length) { 41 | let tests = node.tests.filter(test => test.toString(options).collapsed !== undefined); // we are interested in groups only 42 | for (let test of tests) { 43 | getVisibleGroups(test, options, groups); 44 | } 45 | } 46 | 47 | return groups; 48 | } 49 | 50 | function getTree (msg, i) { 51 | if (msg.collapsed !== undefined) { 52 | const icons = { 53 | collapsed: "▷", 54 | expanded: "▽", 55 | collapsedHighlighted: "▶︎", 56 | expandedHighlighted: "▼", 57 | }; 58 | 59 | let {collapsed, highlighted, children} = msg; 60 | 61 | let icon = collapsed ? icons.collapsed : icons.expanded; 62 | if (highlighted) { 63 | icon = `${ collapsed ? icons.collapsedHighlighted : icons.expandedHighlighted }`; 64 | msg = `${ msg }`; 65 | } 66 | msg = icon + " " + msg; 67 | msg = new String(msg); 68 | msg.collapsed = collapsed; 69 | msg.children = collapsed ? [] : children; 70 | } 71 | 72 | return new AsciiTree(`${ msg }`, ...(msg.children?.map(getTree) ?? [])); 73 | } 74 | 75 | // Render the tests stats 76 | function render (root, options = {}) { 77 | let messages = root.toString({ ...options, format: options.format ?? "rich" }); 78 | let tree = getTree(messages).toString(); 79 | tree = format(tree); 80 | 81 | logUpdate(tree); 82 | } 83 | 84 | // Set up environment for Node 85 | const filenamePatterns = { 86 | include: /\.m?js$/, 87 | exclude: /^index/, 88 | }; 89 | 90 | async function getTestsIn (dir) { 91 | let filenames = fs.readdirSync(dir).filter(name => !filenamePatterns.exclude.test(name) && filenamePatterns.include.test(name)); 92 | let cwd = process.cwd(); 93 | let paths = filenames.map(name => path.join(cwd, dir, name)); 94 | 95 | return Promise.all(paths.map(path => import(pathToFileURL(path)).then(module => module.default, err => { 96 | console.error(`Error importing tests from ${path}:`, err); 97 | }))); 98 | } 99 | 100 | export default { 101 | name: "Node.js", 102 | defaultOptions: { 103 | format: "rich", 104 | get location () { 105 | return process.cwd(); 106 | }, 107 | }, 108 | resolveLocation: async function (location) { 109 | if (fs.statSync(location).isDirectory()) { 110 | // Directory provided, fetch all files 111 | return getTestsIn(location); 112 | } 113 | else { // Probably a glob 114 | // Convert paths to imported modules 115 | let modules = globSync(location).flatMap(paths => { 116 | // Convert paths to imported modules 117 | paths = getType(paths) === "string" ? [paths] : paths; 118 | return paths.map(p => { 119 | p = path.join(process.cwd(), p); 120 | return import(pathToFileURL(p)).then(m => m.default ?? m); 121 | }); 122 | }); 123 | 124 | return Promise.all(modules); 125 | } 126 | 127 | }, 128 | setup () { 129 | process.env.NODE_ENV = "test"; 130 | }, 131 | done (result, options, event, root) { 132 | if (options.ci) { 133 | if (root.stats.pending === 0) { 134 | if (root.stats.fail > 0) { 135 | let messages = root.toString(options); 136 | let tree = getTree(messages).toString(); 137 | tree = format(tree); 138 | 139 | console.error(tree); 140 | process.exit(1); 141 | } 142 | 143 | process.exit(0); 144 | } 145 | } 146 | else { 147 | setCollapsed(root); // all groups and console messages are collapsed by default 148 | render(root, options); 149 | 150 | if (root.stats.pending === 0) { 151 | logUpdate.clear(); 152 | 153 | let hint = ` 154 | Use and arrow keys to navigate groups of tests, and to expand and collapse them, respectively. 155 | Use Ctrl+↑ and Ctrl+↓ to go to the first or last child group of the current group. 156 | To expand or collapse the current group and all its subgroups, use Ctrl+→ and Ctrl+←. 157 | Press Ctrl+Shift+→ and Ctrl+Shift+← to expand or collapse all groups, regardless of the current group. 158 | Use any other key to quit interactive mode. 159 | `; 160 | hint = format(hint); 161 | // Why not console.log(hint)? Because we don't want to mess up other console messages produced by tests, 162 | // especially the async ones. 163 | logUpdate(hint); 164 | logUpdate.done(); 165 | 166 | readline.emitKeypressEvents(process.stdin); 167 | process.stdin.setRawMode(true); // handle keypress events instead of Node 168 | 169 | root.highlighted = true; 170 | render(root, options); 171 | 172 | let active = root; // active (highlighted) group of tests that can be expanded/collapsed; root by default 173 | process.stdin.on("keypress", (character, key) => { 174 | let name = key.name; 175 | 176 | if (name === "up") { 177 | // Figure out what group of tests is active (and should be highlighted) 178 | let groups = getVisibleGroups(root, options); 179 | 180 | if (key.ctrl) { 181 | let parent = active.parent; 182 | if (parent) { 183 | active = groups.filter(group => group.parent === parent)[0]; // the first one from all groups with the same parent 184 | } 185 | } 186 | else { 187 | let index = groups.indexOf(active); 188 | index = Math.max(0, index - 1); // choose the previous group, but don't go higher than the root 189 | active = groups[index]; 190 | } 191 | 192 | for (let group of groups) { 193 | group.highlighted = false; 194 | } 195 | active.highlighted = true; 196 | render(root, options); 197 | } 198 | else if (name === "down") { 199 | let groups = getVisibleGroups(root, options); 200 | 201 | if (key.ctrl) { 202 | let parent = active.parent; 203 | if (parent) { 204 | active = groups.filter(group => group.parent === parent).at(-1); // the last one from all groups with the same parent 205 | } 206 | } 207 | else { 208 | let index = groups.indexOf(active); 209 | index = Math.min(groups.length - 1, index + 1); // choose the next group, but don't go lower than the last one 210 | active = groups[index]; 211 | } 212 | 213 | for (let group of groups) { 214 | group.highlighted = false; 215 | } 216 | active.highlighted = true; 217 | render(root, options); 218 | } 219 | else if (name === "left") { 220 | if (key.ctrl && key.shift) { 221 | // Collapse all groups on Ctrl+Shift+← 222 | let groups = getVisibleGroups(root, options); 223 | for (let group of groups) { 224 | group.highlighted = false; 225 | } 226 | 227 | setCollapsed(root); 228 | active = root; 229 | active.highlighted = true; 230 | render(root, options); 231 | } 232 | else if (key.ctrl) { 233 | // Collapse the current group and all its subgroups on Ctrl+← 234 | setCollapsed(active); 235 | render(root, options); 236 | } 237 | else if (active.collapsed === false) { 238 | active.collapsed = true; 239 | render(root, options); 240 | } 241 | else if (active.parent) { 242 | // If the current group is collapsed, collapse its parent group 243 | let groups = getVisibleGroups(root, options); 244 | let index = groups.indexOf(active.parent); 245 | active = groups[index]; 246 | active.collapsed = true; 247 | 248 | groups = groups.map(group => group.highlighted = false); 249 | active.highlighted = true; 250 | render(root, options); 251 | } 252 | } 253 | else if (name === "right") { 254 | if (key.ctrl && key.shift) { 255 | // Expand all groups on Ctrl+Shift+→ 256 | setCollapsed(root, false); 257 | render(root, options); 258 | } 259 | else if (key.ctrl) { 260 | // Expand the current group and all its subgroups on Ctrl+→ 261 | setCollapsed(active, false); 262 | render(root, options); 263 | } 264 | else if (active.collapsed === true) { 265 | active.collapsed = false; 266 | render(root, options); 267 | } 268 | } 269 | else { 270 | // Quit interactive mode on any other key 271 | logUpdate.done(); 272 | process.exit(); 273 | } 274 | }); 275 | } 276 | 277 | if (root.stats.fail > 0) { 278 | process.exitCode = 1; 279 | } 280 | } 281 | }, 282 | }; 283 | -------------------------------------------------------------------------------- /src/format-console.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Format console text with HTML-like tags 3 | */ 4 | // https://stackoverflow.com/a/41407246/90826 5 | let modifiers = { 6 | reset: "\x1b[0m", 7 | b: "\x1b[1m", 8 | dim: "\x1b[2m", 9 | i: "\x1b[3m", 10 | }; 11 | 12 | let hues = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]; 13 | 14 | let colors = Object.fromEntries(hues.map((hue, i) => [hue, `\x1b[3${i}m`])); 15 | let bgColors = Object.fromEntries(hues.map((hue, i) => [hue, `\x1b[4${i}m`])); 16 | 17 | function getColorCode (hue, {light, bg} = {}) { 18 | if (!hue) { 19 | return ""; 20 | } 21 | if (hue.startsWith("light")) { 22 | hue = hue.replace("light", ""); 23 | light = true; 24 | } 25 | let i = hues.indexOf(hue); 26 | 27 | if (i === -1) { 28 | return ""; 29 | } 30 | 31 | if (light) { 32 | return `\x1b[${ bg ? 10 : 9 }${i}m`; 33 | } 34 | 35 | return `\x1b[${ light ? "1;" : ""}${ bg ? 4 : 3 }${i}m`; 36 | } 37 | 38 | let tags = [ 39 | Object.keys(modifiers).map(tag => ``), 40 | ``, ``, 41 | ``, ``, 42 | ]; 43 | let tagRegex = RegExp(tags.flat().join("|"), "gi"); 44 | 45 | export default function format (str) { 46 | if (!str) { 47 | return str; 48 | } 49 | 50 | str = str + ""; 51 | // Iterate over all regex matches in str 52 | let active = new Set(); 53 | let colorStack = []; 54 | let bgStack = []; 55 | return str.replace(tagRegex, tag => { 56 | let isClosing = tag[1] === "/"; 57 | let name = tag.match(/<\/?(\w+)/)[1]; 58 | let color = tag.match(/<(?:bg|c)\s+(\w+)>/)?.[1]; 59 | 60 | if (isClosing) { 61 | if (name === "c") { 62 | colorStack.pop(); 63 | } 64 | else if (name === "bg") { 65 | bgStack.pop(); 66 | } 67 | else if (active.has(name)) { 68 | active.delete(name); 69 | } 70 | else { 71 | // Closing tag for formatting that wasn't active 72 | return ""; 73 | } 74 | 75 | let activeColor = colorStack.at(-1); 76 | let colorModifier = getColorCode(activeColor); 77 | let activeBg = bgStack.at(-1); 78 | let bgColorModifier = getColorCode(activeBg, {bg: true}); 79 | return modifiers.reset + [...active].map(name => modifiers[name]).join("") + colorModifier + bgColorModifier; 80 | } 81 | else { 82 | if (name === "c") { 83 | colorStack.push(color); 84 | return getColorCode(color); 85 | } 86 | else if (name === "bg") { 87 | bgStack.push(color); 88 | return getColorCode(color, {bg: true}); 89 | } 90 | else { 91 | active.add(name); 92 | return modifiers[name]; 93 | } 94 | } 95 | }); 96 | } 97 | 98 | export function stripFormatting (str) { 99 | return str.replace(tagRegex, ""); 100 | } 101 | 102 | // /** 103 | // * Platform agnostic console formatting 104 | // * @param {*} str 105 | // * @param {*} format 106 | // */ 107 | // export default function format (str, format) { 108 | // if (typeof format === "string") { 109 | // format = Object.fromEntries(format.split(/\s+/).map(type => [type, true])); 110 | // } 111 | 112 | // for (let type in format) { 113 | // str = formats[type] ? formats[type](str) : str; 114 | // } 115 | // str = str.replaceAll("\x1b", "\\x1b"); 116 | // return str; 117 | // } 118 | -------------------------------------------------------------------------------- /src/hooks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A class for adding deep extensibility to any piece of JS code 3 | */ 4 | export class Hooks { 5 | add (name, callback, first) { 6 | if (typeof arguments[0] != "string") { 7 | // Multiple hooks 8 | for (var name in arguments[0]) { 9 | this.add(name, arguments[0][name], arguments[1]); 10 | } 11 | 12 | return; 13 | } 14 | 15 | (Array.isArray(name) ? name : [name]).forEach(function (name) { 16 | this[name] = this[name] || []; 17 | 18 | if (callback) { 19 | this[name][first ? "unshift" : "push"](callback); 20 | } 21 | }, this); 22 | } 23 | 24 | run (name, env) { 25 | this[name] = this[name] || []; 26 | this[name].forEach(function (callback) { 27 | callback.call(env && env.context ? env.context : env, env); 28 | }); 29 | } 30 | } 31 | 32 | /** 33 | * The instance of {@link Hooks} used throughout Color.js 34 | */ 35 | const hooks = new Hooks(); 36 | 37 | export default hooks; 38 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Test } from "./classes/Test.js"; 2 | export { default as TestResult } from "./classes/TestResult.js"; 3 | export * as map from "./map.js"; 4 | export * as check from "./check.js"; 5 | export {default as render} from "./render.js"; 6 | export * as env from "./env/index.js"; 7 | -------------------------------------------------------------------------------- /src/map.js: -------------------------------------------------------------------------------- 1 | import { getType, regexEscape } from "./util.js"; 2 | 3 | export function extract (patterns) { 4 | // Convert patterns to one big regex 5 | let flags = new Set("g"); 6 | patterns = Array.isArray(patterns) ? patterns : [patterns]; 7 | let regex = patterns.map(pattern => { 8 | if (getType(pattern) === "string") { 9 | return regexEscape(pattern); 10 | } 11 | else if (getType(pattern) === "regexp") { 12 | // Merge flags 13 | pattern.flags.split("").forEach(flag => flags.add(flag)); 14 | return pattern.source; 15 | } 16 | }).join("|"); 17 | 18 | regex = RegExp(regex, [...flags].join("")); 19 | 20 | let callee = function (value) { 21 | if (Array.isArray(value)) { 22 | return value.map(n => callee(n)); 23 | } 24 | 25 | let type = getType(value); 26 | value = value + ""; 27 | return (value + "").match(regex) ?? []; 28 | }; 29 | 30 | return callee; 31 | } 32 | 33 | /** 34 | * Extract lists of numbers from a value 35 | * @param {*} value 36 | * 37 | * @returns 38 | */ 39 | let rNumber = /-?\d*\.?\d+(?:e-?\d+)?/g; 40 | export function extractNumbers (value) { 41 | let type = getType(value); 42 | 43 | if (type === "number") { 44 | return [value]; 45 | } 46 | else { 47 | let patterns = [rNumber, "NaN", "null", "Infinity", "-Infinity"]; 48 | 49 | let f = extract(patterns); 50 | return f(value).map(n => !Number.isNaN(n) ? parseFloat(n) : n); 51 | } 52 | } 53 | 54 | export function trimmed (value) { 55 | return (value + "").trim(); 56 | } 57 | -------------------------------------------------------------------------------- /src/objects.js: -------------------------------------------------------------------------------- 1 | // Object utils 2 | import { getType } from "./util.js"; 3 | 4 | // TODO there must be a better way to do this than hardcoding them all? 5 | // How can we detect from the object itself? 6 | const dictionaryTypes = ["Map", "FormData", "URLSearchParams", "Headers"]; 7 | 8 | export function children (obj) { 9 | switch (obj) { 10 | case undefined: 11 | case null: 12 | return obj; 13 | } 14 | 15 | switch (typeof obj) { 16 | case "symbol": 17 | case "number": 18 | case "string": 19 | case "boolean": 20 | case "function": 21 | return obj; 22 | } 23 | 24 | // Only objects from here on out 25 | 26 | let type = getType(obj, { preserveCase: true }); 27 | 28 | switch (type) { 29 | case "String": 30 | case "Number": 31 | case "Boolean": 32 | return obj; 33 | } 34 | 35 | // Ok, for reals now 36 | 37 | if (obj?.[Symbol.iterator]) { 38 | // Iterables (Array, Set, Map, NodeList, etc.) 39 | let isDictionary = dictionaryTypes.includes(type); 40 | return isDictionary ? new Map(obj) : Array.from(obj); 41 | } 42 | 43 | if (type === "Object") { 44 | // Plain objects 45 | return new Map(Object.entries(obj)); 46 | } 47 | 48 | return obj; 49 | } 50 | 51 | /** 52 | * Walk an object recursively and call a function on each value 53 | */ 54 | export function walk (obj, fn, ο) { 55 | return walker(obj, fn); 56 | } 57 | 58 | function walker (obj, fn, meta = {}) { 59 | meta.level ??= 0; 60 | meta.key ??= null; 61 | meta.parent ??= null; 62 | 63 | let children = children(obj); 64 | 65 | if (children instanceof Map || Array.isArray(children)) { 66 | // Key-value pairs 67 | return children.forEach((value, key) => { 68 | let newMeta = {parent: obj, key, level: meta.level + 1}; 69 | fn(value, key, newMeta.parent); 70 | 71 | walker(value, fn, newMeta); 72 | }); 73 | 74 | } 75 | else { 76 | return fn(obj, meta.key, meta.parent); 77 | } 78 | } 79 | 80 | // export function map (obj, fn) { 81 | // obj = clone(obj); 82 | // walk (obj, (value, key, parent) => { 83 | // let newValue = fn(value, key, parent); 84 | 85 | // if (newValue !== undefined) { 86 | // parent[key] = newValue; 87 | // } 88 | // }); 89 | 90 | // return obj; 91 | // } 92 | 93 | export function reduce (obj, fn, initialValue) { 94 | let ret = initialValue; 95 | walk(obj, (value, key, parent) => { 96 | ret = fn(ret, value, key, parent); 97 | }); 98 | 99 | return ret; 100 | } 101 | 102 | /** 103 | * Graceful structured clone algorithm 104 | * Doesn’t throw if it cannot clone, it just returns the original object 105 | * @param {*} obj 106 | * @returns {*} 107 | */ 108 | function clone (obj) { 109 | switch (obj) { 110 | case undefined: 111 | case null: 112 | return obj; 113 | } 114 | 115 | switch (typeof obj) { 116 | case "symbol": 117 | case "number": 118 | case "string": 119 | case "boolean": 120 | case "function": 121 | return obj; 122 | } 123 | 124 | // Only objects from here on out 125 | 126 | let type = getType(obj, { preserveCase: true }); 127 | let ret; 128 | 129 | switch (type) { 130 | case "String": 131 | case "Number": 132 | case "Boolean": 133 | ret = new globalThis[type](obj); 134 | // A common reason to use a wrapper object is to add properties to it 135 | let properties = Object.fromEntries(Object.entries(obj).filter(([key, value]) => !isNaN(key) && key !== "length")); 136 | Object.assign(ret, properties); 137 | return ret; 138 | case "Date": 139 | case "RegExp": 140 | case "Set": 141 | case "Map": 142 | ret = new globalThis[type](obj); 143 | // FIXME if these properties are not primitives, they will be shared between the original and the clone 144 | Object.assign(ret, obj); 145 | return ret; 146 | case "Array": 147 | return obj.map(o => clone(o)); 148 | case "Object": 149 | return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, clone(value)])); 150 | } 151 | 152 | if (obj instanceof Node) { 153 | return obj.cloneNode(true); 154 | } 155 | 156 | if (obj.clone) { 157 | return obj.clone(); 158 | } 159 | 160 | return obj; 161 | } 162 | 163 | export function join (obj, { 164 | separator = ",", 165 | keyValueSeparator = ":", 166 | open = "{", 167 | close = "}", 168 | indent = "\t", 169 | map = _ => _, 170 | mapKey = _ => _, 171 | maxLineLength = 80, 172 | } = {}) { 173 | let kids = children(obj); 174 | 175 | if (kids instanceof Map || Array.isArray(kids)) { 176 | let stringKids; 177 | if (kids instanceof Map) { 178 | stringKids = Object.entries(obj).map(([key, value]) => `${ mapKey(key) }${keyValueSeparator} ${ map(value) }`); 179 | } 180 | else { 181 | stringKids = kids.map(o => map(o)); 182 | } 183 | 184 | let childrenSingleLine = stringKids.join(separator + " "); 185 | return childrenSingleLine.length > indent.length + maxLineLength ? 186 | [ 187 | `${open}`, 188 | `${indent}\t${stringKids.join(`,\n${indent}\t`)}`, 189 | `${indent}${close}`, 190 | ].join(`${separator}\n`) : 191 | `${open}${ childrenSingleLine }${close}`; 192 | } 193 | else { 194 | // Nothing to join 195 | return obj; 196 | } 197 | } 198 | 199 | /** 200 | * Like JSON.stringify but its serialization can be customized 201 | * and prevents cycles 202 | * @param {*} obj 203 | * @param {object} options 204 | * @param {function | function[]} custom - Override how a certain value is serialized. 205 | * Should return undefined if the value should be serialized normally. 206 | * If an array, the first function that returns a non-undefined value is used. 207 | * @returns {string} 208 | */ 209 | export function stringify (obj, options = {}) { 210 | let seen = new WeakSet(); 211 | 212 | return callee(obj, 0); 213 | 214 | function callee (obj, level) { 215 | if (typeof obj === "object" && obj !== null) { 216 | if (seen.has(obj)) { 217 | return "[Circular]"; 218 | } 219 | seen.add(obj); 220 | } 221 | 222 | if (options.custom) { 223 | let fns = Array.isArray(options.custom) ? options.custom : [options.custom]; 224 | for (let fn of fns) { 225 | let ret = fn(obj, level); 226 | if (ret !== undefined) { 227 | return ret; 228 | } 229 | } 230 | } 231 | 232 | let indent = "\t".repeat(level); 233 | 234 | switch (obj) { 235 | case undefined: 236 | case null: 237 | return obj; 238 | } 239 | 240 | switch (typeof obj) { 241 | case "symbol": 242 | return undefined; 243 | case "number": 244 | if (Number.isNaN(obj)) { 245 | return "NaN"; 246 | } 247 | // pass-through 248 | case "string": 249 | case "boolean": 250 | return JSON.stringify(obj); 251 | } 252 | 253 | // Only objects from here on out 254 | 255 | if (obj.toJSON) { 256 | return obj.toJSON(); 257 | } 258 | 259 | let type = getType(obj, { preserveCase: true }); 260 | let ret; 261 | 262 | switch (type) { 263 | case "String": 264 | case "Number": 265 | case "Boolean": 266 | return ret.valueOf(); 267 | case "Date": 268 | return ret + ""; 269 | case "RegExp": 270 | case "Set": 271 | case "Map": 272 | return "{}"; 273 | case "Array": 274 | return join(obj, { 275 | open: "[", close: "]", 276 | indent, 277 | map: o => callee(o, level + 1), 278 | }); 279 | } 280 | 281 | return join(obj, { 282 | open: "{", close: "}", 283 | indent, 284 | map: o => callee(o, level + 1), 285 | mapKey: o => callee(o, level + 1), 286 | }); 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Render JS-first tests to HTML 3 | */ 4 | 5 | import Test from "./classes/Test.js"; 6 | import TestResult from "./classes/TestResult.js"; 7 | import RefTest from "https://html.htest.dev/src/classes/RefTest.js"; 8 | import { create, output } from "https://html.htest.dev/src/util.js"; 9 | import { formatDuration } from "./util.js"; 10 | import format from "./format-console.js"; 11 | 12 | export default function render (test) { 13 | let root = new Test(test); 14 | 15 | create("h1", { 16 | textContent: root.name, 17 | inside: document.body, 18 | }); 19 | 20 | document.title = root.name; 21 | 22 | let testRows = new Map(); 23 | 24 | root.tests?.map?.(t => { 25 | let tests = t.isGroup ? t.tests : [t]; 26 | 27 | let table; 28 | let section = create("section", { 29 | contents: [ 30 | {tag: "h1", textContent: t.name}, 31 | t.description && {tag: "p", textContent: t.description}, 32 | table = create({tag: "table", class: "manual reftest", 33 | contents: tests.flatMap(t2 => t2?.tests ?? t2).map((t2, i) => { 34 | let tr = t2.render?.() ?? create("tr", { 35 | title: t2.name, 36 | contents: [ 37 | {tag: "td", textContent: t2.args?.map(a => output(a)).join(", ") }, 38 | {tag: "td"}, 39 | {tag: "td", textContent: output(t2.expect) }, 40 | ], 41 | }); 42 | 43 | if (t2.throws) { 44 | tr.dataset.error = ""; 45 | } 46 | 47 | testRows.set(t2, tr); 48 | return tr; 49 | }), 50 | }), 51 | ], 52 | inside: document.body, 53 | }); 54 | 55 | requestAnimationFrame(() => { 56 | if (!table.reftest) { 57 | new RefTest(table); 58 | } 59 | }); 60 | 61 | return section; 62 | }); 63 | 64 | let result = new TestResult(root); 65 | 66 | result.addEventListener("done", e => { 67 | let target = e.detail.target; 68 | 69 | if (target.test.isTest) { 70 | let tr = testRows.get(target.test); 71 | let error = target.error; 72 | let cell = tr.cells[1]; 73 | if (error) { 74 | cell.dataset.errorStack = error.stack; 75 | cell.textContent = error; 76 | } 77 | else { 78 | cell.textContent = output(target.actual); 79 | } 80 | tr.classList.add(target.pass ? "pass" : "fail"); 81 | if (target.test.skip) { 82 | tr.classList.add("skipped"); 83 | } 84 | else if (!target.pass) { 85 | cell.classList.add("details"); 86 | cell.onclick = () => console.log(target.details.map(format).join("\n")); 87 | } 88 | tr.dataset.time = formatDuration(target.timeTaken); 89 | } 90 | }); 91 | 92 | result.runAll(); 93 | } 94 | -------------------------------------------------------------------------------- /src/run.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Environment agnostic function for running one or more tests. 3 | */ 4 | 5 | import Test from "./classes/Test.js"; 6 | import TestResult from "./classes/TestResult.js"; 7 | import { getType, subsetTests } from "./util.js"; 8 | 9 | /** 10 | * Run a test or group of tests 11 | * @param {Test | object} test 12 | * @param {object} [options] 13 | * @param {"rich" | "plain"} [options.format] Format to use for output. Defaults to "rich" 14 | * @param {object | string} [options.env="auto"] Environment-specific params. 15 | * Either an object like those in `src/env/` or a string (`"auto"`, `"node"`, `"console"`) 16 | */ 17 | export default function run (test, options = {}) { 18 | if (!options.env) { 19 | options.env = "auto"; 20 | } 21 | 22 | if (typeof options.env === "string") { 23 | import(`./env/${ options.env }.js`) 24 | .then(m => run(test, {...options, env: m.default})) 25 | .catch(err => { 26 | console.error(`Error importing environment ${options.env}`, err); 27 | }); 28 | return; 29 | } 30 | 31 | let env = options.env; 32 | 33 | if (env.defaultOptions) { 34 | options = Object.assign({}, env.defaultOptions, options); 35 | } 36 | 37 | if (!test) { 38 | test ??= options.location; 39 | } 40 | 41 | if (getType(test) == "string") { 42 | if (env.resolveLocation) { 43 | env.resolveLocation(test).then(tests => { 44 | run(tests, options); 45 | }); 46 | return; 47 | } 48 | else { 49 | throw new Error(`Cannot resolve string specifiers in env ${env.name}`); 50 | } 51 | } 52 | 53 | if (Array.isArray(test)) { 54 | if (test.length === 1) { 55 | test = test[0]; 56 | } 57 | else { 58 | return run({tests: test}, options); 59 | } 60 | } 61 | 62 | if (!test || test.tests?.length === 0) { 63 | Test.warn("No tests found" + (options.location ? " in " + options.location : "")); 64 | return; 65 | } 66 | 67 | if (options.path) { 68 | subsetTests(test, options.path); 69 | 70 | if (!test || test.tests?.length === 0) { 71 | Test.warn(`Path ${options.path} produced no tests.`); 72 | return; 73 | } 74 | } 75 | 76 | if (env.setup) { 77 | env.setup(); 78 | } 79 | 80 | if (!(test instanceof Test)) { 81 | test = new Test(test, null, options); 82 | } 83 | 84 | let ret = new TestResult(test, null, options); 85 | 86 | let hooks = ["start", "done", "finish"]; 87 | for (let hook of hooks) { 88 | let fn = options[hook] ?? env[hook]; 89 | if (fn) { 90 | ret.addEventListener(hook, function (evt) { 91 | let target = evt.detail?.target ?? evt.target ?? ret; 92 | fn(target, options, evt, ret); 93 | }); 94 | } 95 | } 96 | 97 | return ret.runAll(); 98 | } 99 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import * as objects from "./objects.js"; 2 | 3 | /** 4 | * Whether we are in Node.js 5 | */ 6 | export const IS_NODEJS = typeof process === "object" && process?.versions?.node; 7 | 8 | /** 9 | * Determine the internal JavaScript [[Class]] of an object. 10 | * @param {*} value - Value to check 11 | * @returns {string} 12 | */ 13 | export function getType (value, { preserveCase = false } = {}) { 14 | let str = Object.prototype.toString.call(value); 15 | 16 | let ret = (str.match(/^\[object\s+(.*?)\]$/)[1] || ""); 17 | 18 | if (!preserveCase) { 19 | ret = ret.toLowerCase(); 20 | } 21 | 22 | return ret; 23 | } 24 | 25 | export function idify (readable) { 26 | return ((readable || "") + "") 27 | .replace(/\s+/g, "-") // Convert whitespace to hyphens 28 | .replace(/[^\w-]/g, "") // Remove weird characters 29 | .toLowerCase(); 30 | } 31 | 32 | export function delay (ms) { 33 | return new Promise(resolve => setTimeout(resolve, ms)); 34 | } 35 | 36 | export function toPrecision (number, significantDigits) { 37 | let n = getType(number) === "number" ? number : parseFloat(number); 38 | 39 | if (Number.isNaN(n)) { 40 | // 🤷🏽‍♀️ 41 | return number; 42 | } 43 | 44 | let abs = Math.abs(n); 45 | 46 | 47 | if (abs < 1) { 48 | return n.toPrecision(significantDigits); 49 | } 50 | 51 | let f10 = 10 ** significantDigits; 52 | if (abs < f10) { 53 | return Math.round(n * f10) / f10; 54 | } 55 | 56 | return Math.round(n); 57 | } 58 | 59 | let durations = [ 60 | { unit: "ms", from: 0 }, 61 | { unit: "s", from: 1000 }, 62 | { unit: "m", from: 60 }, 63 | { unit: "h", from: 60 }, 64 | { unit: "d", from: 24 }, 65 | { unit: "w", from: 7 }, 66 | { unit: "y", from: 52 }, 67 | ]; 68 | 69 | export function formatDuration (ms) { 70 | if (!ms) { 71 | return "0 ms"; 72 | } 73 | 74 | let unit = "ms"; 75 | let n = ms; 76 | 77 | for (let i = 0; i < durations.length; i++) { 78 | let next = durations[i + 1]; 79 | 80 | if (next && n >= next.from) { 81 | n /= next.from; 82 | unit = next.unit; 83 | } 84 | else { 85 | if (n < 10) { 86 | n = toPrecision(+n, 1); 87 | } 88 | else { 89 | n = Math.round(n); 90 | } 91 | 92 | break; 93 | } 94 | } 95 | 96 | return n + " " + unit; 97 | } 98 | 99 | /** 100 | * Escape a string so it can be used literally in regular expressions 101 | * @param {string} str 102 | * @returns {string} 103 | */ 104 | export function regexEscape (str) { 105 | return str.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); 106 | } 107 | 108 | /** 109 | * Stringify object in a useful way 110 | */ 111 | export const stringifyFlavors = { 112 | console: (obj, level) => { 113 | switch (typeof obj) { 114 | case "symbol": 115 | return `Symbol(${obj.description})`; 116 | case "function": 117 | return obj.toString(); 118 | } 119 | 120 | let type = getType(obj, { preserveCase: true }); 121 | 122 | if (!(typeof obj === "object") || !obj || Array.isArray(obj)) { 123 | return; 124 | } 125 | 126 | let indent = "\t".repeat(level); 127 | 128 | if (obj?.[Symbol.iterator] && !["String", "Array"].includes(type)) { 129 | return `${type}(${ obj.length ?? obj.size }) ` + objects.join(obj, ", ", { 130 | indent, 131 | map: o => stringify(o), 132 | }); 133 | } 134 | else if (globalThis.HTMLElement && obj instanceof HTMLElement) { 135 | return obj.outerHTML; 136 | } 137 | 138 | let toString = obj + ""; 139 | 140 | if (!/\[object \w+/.test(toString)) { 141 | // Has reasonable toString method, return that 142 | return toString; 143 | } 144 | }, 145 | }; 146 | export function stringify (obj, options = {}) { 147 | let overrides = options.custom ? [].concat(options.custom) : []; 148 | 149 | overrides.push(stringifyFlavors.console); 150 | 151 | return objects.stringify(obj, { 152 | custom: overrides, 153 | }); 154 | } 155 | 156 | export function subsetTests (test, path) { 157 | if (!Array.isArray(path)) { 158 | path = path.split("/"); 159 | } 160 | 161 | let tests = test; 162 | 163 | for (let segment of path) { 164 | if (tests?.tests) { 165 | tests = tests.tests; 166 | } 167 | else if (!Array.isArray(tests)) { 168 | tests = null; 169 | } 170 | 171 | if (!tests) { 172 | break; 173 | } 174 | 175 | segment = Number(segment); 176 | let segmentIndex = (segment < 0 ? tests.length : 0) + segment; 177 | 178 | for (let i = 0; i < tests.length; i++) { 179 | let t = tests[i]; 180 | if (i !== segmentIndex) { 181 | t.skip = true; 182 | } 183 | } 184 | 185 | tests = tests[segment]; 186 | } 187 | 188 | return tests; 189 | } 190 | 191 | // Used in `interceptConsole()` to maintain isolated contexts for each function call. 192 | let asyncLocalStorage; 193 | if (IS_NODEJS) { 194 | const { AsyncLocalStorage } = await import("node:async_hooks"); 195 | asyncLocalStorage = new AsyncLocalStorage(); 196 | } 197 | 198 | /** 199 | * Intercept console output while running a function. 200 | * @param {Function} fn Function to run. 201 | * @returns {Promise, method: string}>>} A promise that resolves with an array of intercepted messages containing the used console method and passed arguments. 202 | */ 203 | export async function interceptConsole (fn) { 204 | if (!IS_NODEJS) { 205 | await fn(); 206 | return []; 207 | } 208 | 209 | let messages = []; 210 | 211 | // We don't want to mix up the console messages intercepted during the function's parallel calls, 212 | // so we use `AsyncLocalStorage` to maintain isolated contexts for each function call. 213 | return asyncLocalStorage.run(messages, async () => { 214 | for (let method of ["log", "warn", "error"]) { 215 | if (console[method].original) { 216 | continue; 217 | } 218 | 219 | let original = console[method]; 220 | console[method] = (...args) => { 221 | let context = asyncLocalStorage.getStore(); 222 | if (context) { 223 | // context === messages 224 | context.push({ args, method }); 225 | } 226 | else { 227 | original(...args); 228 | } 229 | }; 230 | console[method].original = original; 231 | } 232 | 233 | await fn(); 234 | return messages; 235 | }); 236 | } 237 | 238 | /** 239 | * Pluralize a word. 240 | * @param {number} n Number to check. 241 | * @param {string} singular Singular form of the word. 242 | * @param {string} plural Plural form of the word. 243 | * @returns {string} 244 | */ 245 | export function pluralize (n, singular, plural) { 246 | return n === 1 ? singular : plural; 247 | } 248 | 249 | /** 250 | * Format diff changes with color highlighting. 251 | * @param {Array} changes - Array of diff changes. 252 | * @param {Object} options - Arbitrary options. 253 | * @param {boolean} [options.expected] - Whether the expected value is being formatted. 254 | * @returns {string} Formatted diff string with HTML-like tags for styling: 255 | * - Changes in the actual value are wrapped in red 256 | * - Changes in the expected value are wrapped in green 257 | * - Whitespace is shown with background color. 258 | */ 259 | export function formatDiff (changes, { expected = false } = {}) { 260 | let ret = ""; 261 | 262 | for (let change of changes) { 263 | // Handle differences that need highlighting 264 | if ((change.added && expected) || (change.removed && !expected)) { 265 | let color = expected ? "green" : "red"; 266 | 267 | // Split by whitespace, but preserve it in the output 268 | let parts = change.value.split(/(\s+)/); 269 | 270 | for (let part of parts) { 271 | if (/^\s+$/.test(part)) { 272 | // Show whitespace as a background color 273 | ret += `${ part }`; 274 | } 275 | else if (part) { 276 | ret += `${ part }`; 277 | } 278 | } 279 | } 280 | else if (!change.added && !change.removed) { 281 | // Pass through unchanged portions as-is 282 | ret += change.value; 283 | } 284 | } 285 | 286 | return ret; 287 | } 288 | -------------------------------------------------------------------------------- /tests/check.js: -------------------------------------------------------------------------------- 1 | import * as check from "../src/check.js"; 2 | 3 | export default { 4 | name: "Check tests", 5 | tests: [ 6 | { 7 | name: "shallowEquals()", 8 | run: check.shallowEquals(), 9 | tests: [ 10 | { 11 | args: [1, 1], 12 | expect: true, 13 | }, 14 | { 15 | args: [1, 0], 16 | expect: false, 17 | }, 18 | { 19 | args: [NaN, NaN], 20 | expect: true, 21 | }, 22 | { 23 | args: [null, null], 24 | expect: true, 25 | }, 26 | ], 27 | }, 28 | { 29 | name: "subset()", 30 | run: check.subset, 31 | tests: [ 32 | { 33 | args: [1, undefined], 34 | expect: true, 35 | }, 36 | { 37 | args: [1, undefined], 38 | expect: true, 39 | }, 40 | { 41 | args: [{foo: 1, bar: 2}, {foo: 1}], 42 | expect: true, 43 | }, 44 | { 45 | args: [{bar: 2}, {foo: 1}], 46 | expect: false, 47 | }, 48 | { 49 | name: "Array missing first argument", 50 | args: [[1, 2, 3], [, 2, 3]], 51 | expect: true, 52 | }, 53 | { 54 | name: "Array with fewer elements", 55 | args: [[1, 2, 3], [1, 2]], 56 | expect: true, 57 | }, 58 | { 59 | name: "Array with fewer elements missing first argument", 60 | args: [[1, 2, 3], [, 2]], 61 | expect: true, 62 | }, 63 | { 64 | args: [[1, 4, 3], [, 2]], 65 | expect: false, 66 | }, 67 | ], 68 | }, 69 | ], 70 | }; 71 | -------------------------------------------------------------------------------- /tests/failing-tests.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "Failing tests", 3 | description: "These tests are designed to fail and should not break the test runner", 4 | tests: [ 5 | { 6 | name: "map() fails", 7 | map: arg => arg.length, 8 | }, 9 | { 10 | name: "check() fails", 11 | check: (actual, expected) => actual.length < expected.length, 12 | }, 13 | { 14 | name: "map() → check() fails", 15 | map: arg => undefined, 16 | check: (actual, expected) => actual.length < expected.length, 17 | arg: 42, 18 | expect: 42, 19 | }, 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /tests/format-console.js: -------------------------------------------------------------------------------- 1 | import format, { stripFormatting } from "../src/format-console.js"; 2 | import chalk from "chalk"; 3 | 4 | // We don't want to use map because it will output unmapped values on fail as well, causing a mess in this very special case 5 | function escape (str) { 6 | return str.replaceAll("\x1b", "\\x1b"); 7 | } 8 | 9 | export default { 10 | name: "Console formatting tests", 11 | tests: [ 12 | { 13 | name: "Formatting", 14 | run (str) { 15 | return escape(format(str)); 16 | }, 17 | tests: [ 18 | { 19 | name: "Bold", 20 | args: "bold", 21 | expect: "\\x1b[1mbold\\x1b[0m", 22 | }, 23 | { 24 | name: "Text color", 25 | args: "red", 26 | expect: "\\x1b[31mred\\x1b[0m", 27 | }, 28 | { 29 | name: "Background color", 30 | args: "red", 31 | expect: "\\x1b[41mred\\x1b[0m", 32 | }, 33 | { 34 | name: "Light color", 35 | args: "light red", 36 | // expect: "\\x1b[91mlight red\\x1b[0m" 37 | expect: escape(chalk.redBright("light red")), 38 | }, 39 | { 40 | name: "Light background color", 41 | args: "light red", 42 | // expect: "\\x1b[101mlight red\\x1b[0m" 43 | expect: escape(chalk.bgRedBright("light red")), 44 | }, 45 | ], 46 | }, 47 | { 48 | name: "Strip formatting", 49 | run: stripFormatting, 50 | tests: [ 51 | { 52 | args: "bold", 53 | expect: "bold", 54 | }, 55 | { 56 | name: "Malformed tags", 57 | args: "bold red?", 58 | expect: "bold red?", 59 | }, 60 | ], 61 | }, 62 | ], 63 | }; 64 | -------------------------------------------------------------------------------- /tests/index.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | // Read filenames in this directory 4 | const __dirname = new URL(".", import.meta.url).pathname; 5 | let filenames = fs.readdirSync(__dirname) 6 | .filter(name => !name.startsWith("index") && name.endsWith(".js")); 7 | 8 | let tests = await Promise.all(filenames.map(name => import(`./${name}`).then(module => module.default))); 9 | 10 | let root = { 11 | name: "All tests", 12 | tests, 13 | }; 14 | 15 | export default root; 16 | -------------------------------------------------------------------------------- /tests/run.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "Run tests", 3 | args: [], 4 | expect: "foo", 5 | tests: [ 6 | { 7 | name: "Synchronous run()", 8 | run: () => "foo", 9 | }, 10 | { 11 | name: "Asynchronous run()", 12 | run: async () => await Promise.resolve("foo"), 13 | }, 14 | { 15 | name: "run() returning a promise", 16 | run: () => new Promise(resolve => setInterval(() => resolve("foo"), 100)), 17 | }, 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /tests/stringify.js: -------------------------------------------------------------------------------- 1 | import { stringify } from "../src/util.js"; 2 | 3 | export default { 4 | run: stringify, 5 | tests: [ 6 | { arg: 1, expect: "1" }, 7 | { arg: NaN, expect: "NaN" }, 8 | { arg: "foo", expect: '"foo"' }, 9 | { arg: [1, 2, 3], expect: "[1, 2, 3]" }, 10 | { arg: { foo: "bar" }, expect: '{"foo": "bar"}' }, 11 | { arg: new Set([1, 2, 3]), expect: "Set(3) {1, 2, 3}" }, 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /tests/throws.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "Tests for error-based criteria", 3 | tests: [ 4 | { 5 | name: "Any error", 6 | run: () => { 7 | throw new TypeError(); 8 | }, 9 | throws: true, 10 | }, 11 | { 12 | name: "Function", 13 | run: () => { 14 | throw new TypeError(); 15 | }, 16 | throws: (error) => error.constructor === TypeError, 17 | }, 18 | { 19 | name: "Subclass", 20 | run: () => { 21 | throw new SyntaxError(); 22 | }, 23 | throws: SyntaxError, 24 | }, 25 | { 26 | name: "Expect no error", 27 | run: () => "bar", 28 | throws: false, 29 | }, 30 | { 31 | name: "Failing tests", 32 | description: "These tests are designed to fail.", 33 | tests: [ 34 | { 35 | name: "Expect error", 36 | run: () => "foo", 37 | throws: true, 38 | }, 39 | { 40 | name: "Expect no error", 41 | run: () => { 42 | throw new Error(); 43 | }, 44 | throws: false, 45 | }, 46 | { 47 | name: "Subclass", 48 | run: () => { 49 | throw new SyntaxError(); 50 | }, 51 | throws: TypeError, 52 | }, 53 | ], 54 | }, 55 | ], 56 | }; 57 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "lib": ["ESNext", "DOM"], 5 | "skipLibCheck": true, 6 | "outDir": "./dist" 7 | }, 8 | "include": [ 9 | "src/**/*.js" 10 | ], 11 | "exclude": [ 12 | "node_modules/**", 13 | "./dist" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "out": "./api", 3 | "json": "./api/docs.json", 4 | "name": "hTest API", 5 | "entryPoints": ["src/index.js"], 6 | "entryPointStrategy": "expand", 7 | "excludeExternals": true, 8 | "includeVersion": true, 9 | "markdownItOptions": { 10 | "html": true, 11 | "linkify": true, 12 | "typographer": true 13 | }, 14 | "navigationLinks": { 15 | "Home": "https://htest.dev", 16 | "GitHub": "https://github.com/htest-dev/htest" 17 | }, 18 | "favicon": "assets/images/logo.svg", 19 | "hostedBaseUrl": "https://htest.dev/api", 20 | "readme": "none" 21 | } 22 | --------------------------------------------------------------------------------