UNPKG

10.5 kBJavaScriptView Raw
1import deepEqual from 'deep-equal';
2
3const getAssertionLocation = () => {
4 const err = new Error();
5 const stack = (err.stack || '').split('\n');
6 return (stack[3] || '').trim().replace(/^at/i, '');
7};
8
9const assertMethodHook = fn => function (...args) {
10 const assertResult = fn(...args);
11
12 if (assertResult.pass === false) {
13 assertResult.at = getAssertionLocation();
14 }
15
16 this.collect(assertResult);
17 return assertResult;
18};
19
20const Assertion = {
21 ok: assertMethodHook((val, description = 'should be truthy') => ({
22 pass: Boolean(val),
23 actual: val,
24 expected: true,
25 description,
26 operator: 'ok'
27 })),
28 deepEqual: assertMethodHook((actual, expected, description = 'should be equivalent') => ({
29 pass: deepEqual(actual, expected),
30 actual,
31 expected,
32 description,
33 operator: 'deepEqual'
34 })),
35 equal: assertMethodHook((actual, expected, description = 'should be equal') => ({
36 pass: actual === expected,
37 actual,
38 expected,
39 description,
40 operator: 'equal'
41 })),
42 notOk: assertMethodHook((val, description = 'should not be truthy') => ({
43 pass: !val,
44 expected: false,
45 actual: val,
46 description,
47 operator: 'notOk'
48 })),
49 notDeepEqual: assertMethodHook((actual, expected, description = 'should not be equivalent') => ({
50 pass: !deepEqual(actual, expected),
51 actual,
52 expected,
53 description,
54 operator: 'notDeepEqual'
55 })),
56 notEqual: assertMethodHook((actual, expected, description = 'should not be equal') => ({
57 pass: actual !== expected,
58 actual,
59 expected,
60 description,
61 operator: 'notEqual'
62 })),
63 throws: assertMethodHook((func, expected, description) => {
64 let caught;
65 let pass;
66 let actual;
67 if (typeof expected === 'string') {
68 [expected, description] = [description, expected];
69 }
70 try {
71 func();
72 } catch (err) {
73 caught = {error: err};
74 }
75 pass = caught !== undefined;
76 actual = caught && caught.error;
77 if (expected instanceof RegExp) {
78 pass = expected.test(actual) || expected.test(actual && actual.message);
79 expected = String(expected);
80 } else if (typeof expected === 'function' && caught) {
81 pass = actual instanceof expected;
82 actual = actual.constructor;
83 }
84 return {
85 pass,
86 expected,
87 actual,
88 operator: 'throws',
89 description: description || 'should throw'
90 };
91 }),
92 doesNotThrow: assertMethodHook((func, expected, description) => {
93 let caught;
94 if (typeof expected === 'string') {
95 [expected, description] = [description, expected];
96 }
97 try {
98 func();
99 } catch (err) {
100 caught = {error: err};
101 }
102 return {
103 pass: caught === undefined,
104 expected: 'no thrown error',
105 actual: caught && caught.error,
106 operator: 'doesNotThrow',
107 description: description || 'should not throw'
108 };
109 }),
110 fail: assertMethodHook((description = 'fail called') => ({
111 pass: false,
112 actual: 'fail called',
113 expected: 'fail not called',
114 description,
115 operator: 'fail'
116 }))
117};
118
119var assert = (collect, test) => Object.assign(
120 Object.create(Assertion, {collect: {value: collect}}), {
121 async test(description, spec) {
122 // Note: we return the task so the caller can control whether he wants to wait for the sub test to complete or not
123 return test(description, spec).task;
124 }
125 });
126
127const tester = (collect, {offset = 0} = {}) => (description, spec) => {
128 const buffer = [{type: 'title', data: description, offset}];
129 const result = {count: 0, pass: true, description, spec};
130 let done = false;
131
132 const createAssertion = item => {
133 result.pass = result.pass && item.pass;
134 return {type: 'assert', data: item, offset};
135 };
136
137 const collector = item => {
138 result.count++;
139 item.id = result.count;
140 if (item[Symbol.asyncIterator] === undefined) {
141 // Assertion
142 buffer.push(createAssertion(item));
143 } else {
144 // Sub test
145 buffer.push(item);
146 }
147 };
148
149 const handleDelegate = async delegate => {
150 const {value, done} = await delegate.next();
151
152 // Delegate is exhausted: create a summary test point in the stream and throw the delegate
153 if (done === true) {
154 const {executionTime, pass, description} = value;
155 const subTestAssertion = Object.assign(createAssertion({
156 pass,
157 description,
158 id: delegate.id,
159 executionTime
160 }), {type: 'testAssert'});
161 buffer.shift();
162 buffer.unshift(subTestAssertion);
163 return instance.next();
164 }
165 return {value, done};
166 };
167
168 const subTest = tester(collector, {offset: offset + 1});
169
170 const start = Date.now();
171 // Execute the test collecting assertions
172 const assertFn = assert(collector, subTest);
173 const task = new Promise(resolve => resolve(spec(assertFn)))
174 .then(() => {
175 // Always report a plan and summary: the calling test will know how to deal with it
176 result.executionTime = Date.now() - start;
177 buffer.push({type: 'plan', data: {start: 1, end: result.count}, offset});
178 buffer.push({type: 'time', data: result.executionTime, offset});
179 done = true;
180 return result;
181 })
182 .catch(err => {
183 // We report a failing test before bail out ... while unhandled promise rejection is still allowed by nodejs...
184 buffer.push({type: 'assert', data: {pass: false, description}});
185 buffer.push({type: 'comment', data: 'Unhandled exception'});
186 buffer.push({type: 'bailout', data: err, offset});
187 done = true;
188 });
189
190 const instance = {
191 test: subTest,
192 task,
193 [Symbol.asyncIterator]() {
194 return this;
195 },
196 async next() {
197 if (buffer.length === 0) {
198 if (done === true) {
199 return {done: true, value: result};
200 }
201 // Flush
202 await task;
203 return this.next();
204 }
205
206 const next = buffer[0];
207
208 // Delegate if sub test
209 if (next[Symbol.asyncIterator] !== undefined) {
210 return handleDelegate(next);
211 }
212
213 return {value: buffer.shift(), done: false};
214 }
215 };
216
217 // Collection by the calling test
218 collect(instance);
219
220 return instance;
221};
222
223const print = (message, offset = 0) => {
224 console.log(message.padStart(message.length + (offset * 4))); // 4 white space used as indent (see tap-parser)
225};
226
227const toYaml = print => (obj, offset = 0) => {
228 for (const [prop, value] of Object.entries(obj)) {
229 print(`${prop}: ${JSON.stringify(value)}`, offset + 0.5);
230 }
231};
232
233const tap = print => {
234 const yaml = toYaml(print);
235 return {
236 version(version = 13) {
237 print(`TAP version ${version}`);
238 },
239 title(value, offset = 0) {
240 const message = offset > 0 ? `Subtest: ${value}` : value;
241 this.comment(message, offset);
242 },
243 assert(value, offset = 0) {
244 const {pass, description, id, executionTime, expected = '', actual = '', at = '', operator = ''} = value;
245 const label = pass === true ? 'ok' : 'not ok';
246 print(`${label} ${id} - ${description}${executionTime ? ` # time=${executionTime}ms` : ''}`, offset);
247 if (pass === false && value.operator) {
248 print('---', offset + 0.5);
249 yaml({expected, actual, at, operator}, offset);
250 print('...', offset + 0.5);
251 }
252 },
253 plan(value, offset = 0) {
254 print(`1..${value.end}`, offset);
255 },
256 time(value, offset = 0) {
257 this.comment(`time=${value}ms`, offset);
258 },
259 comment(value, offset = 0) {
260 print(`# ${value}`, offset);
261 },
262 bailout(value = 'Unhandled exception') {
263 print(`Bail out! ${value}`);
264 },
265 testAssert(value, offset = 0) {
266 return this.assert(value, offset);
267 }
268 };
269};
270
271var tap$1 = (printFn = print) => {
272 const reporter = tap(printFn);
273 return (toPrint = {}) => {
274 const {data, type, offset = 0} = toPrint;
275 if (typeof reporter[type] === 'function') {
276 reporter[type](data, offset);
277 }
278 // Else ignore (unknown message type)
279 };
280};
281
282// Some combinators for asynchronous iterators: this will be way more easier when
283// Async generator are widely supported
284
285const asyncIterator = behavior => Object.assign({
286 [Symbol.asyncIterator]() {
287 return this;
288 }
289}, behavior);
290
291const filter = predicate => iterator => asyncIterator({
292 async next() {
293 const {done, value} = await iterator.next();
294
295 if (done === true) {
296 return {done};
297 }
298
299 if (!predicate(value)) {
300 return this.next();
301 }
302
303 return {done, value};
304 }
305});
306
307const map = mapFn => iterator => asyncIterator({
308 [Symbol.asyncIterator]() {
309 return this;
310 },
311 async next() {
312 const {done, value} = await iterator.next();
313 if (done === true) {
314 return {done};
315 }
316 return {done, value: mapFn(value)};
317 }
318});
319
320const stream = asyncIterator => Object.assign(asyncIterator, {
321 map(fn) {
322 return stream(map(fn)(asyncIterator));
323 },
324 filter(fn) {
325 return stream(filter(fn)(asyncIterator));
326 }
327});
328
329const combine = (...iterators) => {
330 const [...pending] = iterators;
331 let current = pending.shift();
332
333 return asyncIterator({
334 async next() {
335 if (current === undefined) {
336 return {done: true};
337 }
338
339 const {done, value} = await current.next();
340
341 if (done === true) {
342 current = pending.shift();
343 return this.next();
344 }
345
346 return {done, value};
347 }
348 });
349};
350
351let flatten = true;
352const tests = [];
353const test = tester(t => tests.push(t));
354
355// Provide a root context for BSD style test suite
356const subTest = (test('Root', () => {})).test;
357test.test = (description, spec) => {
358 flatten = false; // Turn reporter into BSD style
359 return subTest(description, spec);
360};
361
362const start = async ({reporter = tap$1()} = {}) => {
363 let count = 0;
364 let failure = 0;
365 reporter({type: 'version', data: 13});
366
367 // Remove the irrelevant root title
368 await tests[0].next();
369
370 let outputStream = stream(combine(...tests));
371 outputStream = flatten ? outputStream
372 .filter(({type}) => type !== 'testAssert')
373 .map(item => Object.assign(item, {offset: 0})) :
374 outputStream;
375
376 const filterOutAtRootLevel = ['plan', 'time'];
377 outputStream = outputStream
378 .filter(item => item.offset > 0 || !filterOutAtRootLevel.includes(item.type))
379 .map(item => {
380 if (item.offset > 0 || (item.type !== 'assert' && item.type !== 'testAssert')) {
381 return item;
382 }
383
384 count++;
385 item.data.id = count;
386 failure += item.data.pass ? 0 : 1;
387 return item;
388 });
389
390 // One day with for await loops ... :) !
391 while (true) {
392 const {done, value} = await outputStream.next();
393
394 if (done === true) {
395 break;
396 }
397
398 reporter(value);
399
400 if (value.type === 'bailout') {
401 throw value.data; // Rethrow but with Nodejs we keep getting the deprecation warning (unhandled promise) and the process exists with 0 exit code...
402 }
403 }
404
405 reporter({type: 'plan', data: {start: 1, end: count}});
406 reporter({type: 'comment', data: failure > 0 ? `failed ${failure} of ${count} tests` : 'ok'});
407};
408
409// Auto bootstrap following async env vs sync env (browser vs node)
410if (typeof window === 'undefined') {
411 setTimeout(start, 0);
412} else {
413 window.addEventListener('load', start);
414}
415
416export default test;