github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/test/web/unit/test.js (about) 1 // Copyright 2021 Daniel Erat. 2 // All rights reserved. 3 4 // TODO: Decide if there's a cleaner way to do this. Right now, this stuff gets 5 // shoved into globals so it can be accessed by exported functions in an 6 // approximation of the style of other JS test frameworks. 7 const initResult = newResult('init'); // errors outside of tests 8 const allSuites = []; // Suite objects registered via suite() 9 let curSuite = null; // Suite currently being added via suite() 10 let results = []; // test results from current run 11 let curResult = null; // in-progress test's result 12 let lastDone = null; // promise resolve func from last async test 13 14 // Suite contains a collection of tests. 15 class Suite { 16 constructor(name) { 17 this.name = name; 18 this.tests = []; // Test objects 19 this.beforeAlls = []; 20 this.afterAlls = []; 21 this.beforeEaches = []; 22 this.afterEaches = []; 23 } 24 25 // Sequentially run all tests in the suite. 26 // If |testNameRegExp| is supplied, only the test with names matched by it 27 // will be run. 28 async runTests(testNameRegexp) { 29 const tests = this.tests.filter( 30 (t) => 31 testNameRegexp === undefined || 32 testNameRegexp.test(`${this.name}.${t.name}`) 33 ); 34 if (!tests.length) return; 35 36 try { 37 this.beforeAlls.forEach((f) => f()); 38 for (const t of tests) { 39 const fullName = `${this.name}.${t.name}`; 40 console.log('-'.repeat(80)); 41 console.log(`Starting ${fullName}`); 42 curResult = newResult(fullName); 43 try { 44 this.beforeEaches.forEach((f) => f()); 45 await t.run(); 46 this.afterEaches.forEach((f) => f()); 47 } finally { 48 console.log( 49 `Finished ${fullName} with ${curResult.errors.length} error(s)` 50 ); 51 results.push(curResult); 52 curResult = null; 53 } 54 } 55 } finally { 56 this.afterAlls.forEach((f) => f()); 57 } 58 } 59 } 60 61 // Test contains an individual test. 62 class Test { 63 constructor(name, f) { 64 this.name = name; 65 this.func = f; 66 } 67 68 // Runs the test to completion. This could probably be simplified, but I'm 69 // terrible at promises and async functions. :-/ 70 async run() { 71 // First, create a promise to get a 'done' callback to pass to tests that 72 // want one. 73 await new Promise((done) => { 74 try { 75 // Save the 'done' callback to a global variable so it can be called by 76 // the 'error' and 'unhandledrejection' handlers if an error happens in 77 // e.g. a timeout, which runs in the window execution context. 78 lastDone = done; 79 80 Promise.resolve(this.func(done)) 81 .catch((reason) => { 82 // This handles exceptions thrown directly from async tests. 83 // It also handles rejected promises from async tests. 84 if (reason instanceof Error) handleException(reason); 85 else handleRejection(reason); 86 done(); 87 }) 88 .then(() => { 89 // If the test doesn't take any args, run the 'done' callback for it. 90 if (!this.func.length) done(); 91 }); 92 } catch (err) { 93 // This handles exceptions thrown directly from synchronous tests. 94 handleException(err); 95 done(); 96 } 97 }); 98 99 lastDone = null; 100 } 101 } 102 103 // Adds a test suite named |name|. 104 // |f| is is executed immediately; it should call test() to define the 105 // suite's tests. 106 export function suite(name, f) { 107 if (curSuite) throw new Error(`Already adding suite "${curSuite.name}"`); 108 if (allSuites.some((e) => e.name === name)) { 109 throw new Error(`Suite "${name}" already exists`); 110 } 111 112 const s = new Suite(name); 113 try { 114 curSuite = s; 115 f(); 116 if (!s.tests.length) throw new Error('No tests defined'); 117 allSuites.push(s); 118 } catch (err) { 119 handleException(err); 120 } finally { 121 curSuite = null; 122 } 123 } 124 125 // Adds |f| to run before all tests in the suite. 126 // This must be called from within a function passed to suite(). 127 export function beforeAll(f) { 128 if (!curSuite) throw new Error('beforeAll() called outside suite()'); 129 curSuite.beforeAlls.push(f); 130 } 131 132 // Adds |f| to run after all tests in the suite. 133 // This must be called from within a function passed to suite(). 134 export function afterAll(f) { 135 if (!curSuite) throw new Error('afterAll() called outside suite()'); 136 curSuite.afterAlls.push(f); 137 } 138 139 // Adds |f| to run before each tests in the suite. 140 // This must be called from within a function passed to suite(). 141 export function beforeEach(f) { 142 if (!curSuite) throw new Error('beforeEach() called outside suite()'); 143 curSuite.beforeEaches.push(f); 144 } 145 146 // Adds |f| to run after all tests in the suite. 147 // This must be called from within a function passed to suite(). 148 export function afterEach(f) { 149 if (!curSuite) throw new Error('afterEach() called outside suite()'); 150 curSuite.afterEaches.push(f); 151 } 152 153 // Adds a test named |name| with function |f|. 154 // This must be called from within a function passed to suite(). 155 // If |f| accepts an argument, it will be passed a 'done' callback that it 156 // must run upon completion. Additionally, |f| may be async. 157 export function test(name, f) { 158 if (!curSuite) throw new Error('test() called outside suite()'); 159 curSuite.tests.push(new Test(name, f)); 160 } 161 162 // Extracts filename, line, and column from a stack trace line: 163 // " at ... (http://127.0.0.1:43559/test.js:101:15)" 164 // " at http://127.0.0.1:43963/example.test.js:21:7" 165 const stackRegexp = /at .*\/([^/]+\.js):(\d+):(\d+)\)?$/; 166 167 // Tries to get filename and line (e.g. 'foo.test.js:123') from |err|. 168 // Uses the first stack frame not from this file. 169 function getSource(err) { 170 if (!err.stack) return ''; 171 for (const ln of err.stack.split('\n')) { 172 var matches = stackRegexp.exec(ln); 173 if (matches && matches[1] !== 'test.js') { 174 return `${matches[1]}:${matches[2]}`; 175 } 176 } 177 // TODO: Maybe it was triggered by test.js. 178 return ''; 179 } 180 181 // Adds an error to |curResult| if we're in a test or |initResult| otherwise. 182 // |src| takes the form 'foo.test.js:23'. 183 function addError(msg, src) { 184 const err = { msg }; 185 if (src) err.src = src; 186 (curResult || initResult).errors.push(err); 187 } 188 189 // Adds an error describing |err|. 190 function handleException(err) { 191 const src = getSource(err); 192 console.error(`Exception from ${src || '[unknown]'}: ${err.toString()}`); 193 const msg = err.toString() + ' (exception)'; 194 addError(msg, src); 195 } 196 197 // Adds an error describing |reason|. 198 function handleRejection(reason) { 199 const src = getSource(new Error()); 200 console.error(`Unhandled rejection from ${src || '[unknown]'}: ${reason}`); 201 addError(`Unhandled rejection: ${reason}`, src); 202 } 203 204 // Returns a result named |name| to return from runTests(). 205 function newResult(name) { 206 return { name, errors: [] }; 207 } 208 209 // Fails the current test but continues running it. 210 export function error(msg) { 211 const src = getSource(new Error()); 212 console.error(`Error from ${src}: ${msg}`); 213 addError(msg, src); 214 } 215 216 // Fails the current test and aborts it. 217 export function fatal(msg) { 218 throw new FatalError(msg); 219 } 220 221 // https://stackoverflow.com/a/32750746/6882947 222 class FatalError extends Error { 223 constructor(msg) { 224 super(msg); 225 this.name = 'Fatal'; 226 } 227 } 228 229 // Adds an error if |got| doesn't equal |want|, for some definition of "equal". 230 // |desc| can contain a description of what's being compared. 231 export function expectEq(got, want, desc) { 232 if ( 233 typeof got === typeof want && 234 (typeof got === 'object' 235 ? JSON.stringify(got) === JSON.stringify(want) 236 : got === want) 237 ) { 238 return; 239 } 240 // TODO: Improve formatting, e.g. this prints '(object)' for an array. 241 const gs = `${JSON.stringify(got)} (${typeof got})`; 242 const ws = `${JSON.stringify(want)} (${typeof want})`; 243 error(desc ? `${desc} is ${gs}; want ${ws}` : `Got ${gs}; want ${ws}`); 244 } 245 246 // expectThrows runs f and reports an error if an exception isn't thrown. 247 export function expectThrows(f, desc) { 248 let threw = false; 249 try { 250 f(); 251 } catch (e) { 252 threw = true; 253 } 254 if (!threw) error(desc ? `${desc} didn't throw` : `Didn't throw`); 255 } 256 257 // Errors in the window execution context, e.g. exceptions thrown from timeouts 258 // in either synchronous or async tests, trigger error events. 259 window.addEventListener('error', (ev) => { 260 handleException(ev.error); 261 if (lastDone) lastDone(); 262 }); 263 264 // Unhandled promise rejections trigger unhandledrejection events. 265 window.addEventListener('unhandledrejection', (ev) => { 266 handleRejection(ev.reason); 267 if (lastDone) lastDone(); 268 }); 269 270 // Runs tests and returns results as an array of objects: 271 // 272 // { 273 // name: 'suite.testName', 274 // errors: [ 275 // { 276 // msg: 'foo() = 3; want 4', 277 // src: 'foo.test.js:23', 278 // }, 279 // ... 280 // ], 281 // } 282 // 283 // |testRegexp| is compiled to a regexp and executed against test names to determine 284 // which ones run. 285 export async function runTests(testRegexp = '') { 286 results = [initResult]; 287 const re = new RegExp(testRegexp); 288 for (const s of allSuites) await s.runTests(re); 289 return Promise.resolve(results); 290 }