1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381 |
x2
x2
x2
x2
x2
x2
x23
x23
x23
x23
x23
x2
x9
x3
x3
x3
x9
x3
x3
x2
x2
x2
x3
x2
x2
x2
x2
x4
x4
x4
x18
x2
x4
x4
x4
x4
x4
x4
x4
x4
x4
x2
x4
x4
x175
x875
x175
x175
x175
x4
x175
x175
x175
x175
x175
x175
x1575
x175
x175
x175
x4
x4
x2
x2
x2
x96
x85
x1164
x194
x194
x16
x16
x2
x3
x3
x3
x18
x5
x29
x6
x6
x6
x6
x6
x6
x6
x3
x3
x10
x10
x10
x3
x3
x3
x2
x166
x166
x2
x2
x67
x67
x363
x121
x171
x171
x171
x335
x335
x335
x171
x390
x390
x390
x390
x390
x441
x441
x441
x390
x390
x390
x390
x390
x390
x390
x390
x390
x2
x2
x2
x173
x173
x173
x2
x334
x28
x28
x174
x58
x73
x73
x84
x84
x84
x77
x58
x58
x58
x38
x38
x398
x72
x72
x72
x72
x72
x107
x107
x107
x130
x137
x137
x130
x146
x107
x107
x72
x78
x40
x40
x41
x41
x43
x43
x2 |
I
I
I
I
I
I
I
I
I
I
I
|
import type { Fn, Reflects, Async, Prototype, Meta } from '../index.ts';
import { Log } from './log.ts';
import { ok, equal, throws, rejects, stdout, exit, argv,
setImmediate, inspect } from '../deps.ts';
import { ANSI, Error, Name, Seq, spaced, lines, msec, toString, merged, isEntrypoint,
todo, addStepStack, withInfiniteStack, alignTrace } from '../format.ts';
/** Test entrypoint. When test module is run (not imported),
* tests in the `suite` run, and a report is printed.
*
* Uncaught errors fail the test, unless they have the `todo` property set.
* Use `todo('...description...')` to scaffold future test cases and specify
* the expected shape of your project.
*
* To define your tests: [the], [has], [is], [equals], [includes];
* or pass functions which take `(lastValue, context)`.
*
* Example:
*
* import { suite, the, todo, ok, equal } from '@hackbg/fadroma';
* export default suite(import.meta, 'my module',
* 'strings are TODOs',
* the('empty tests are TODOs'),
* the('substeps do not throw',
* context => { ok(true, "unary assertion") },
* the('tests can nest', context => {
* equal(1, 1, "binary assertion")
* })));
*
**/
export function suite (meta: Meta, name: string, ...steps: (Step|string)[]) {
const suite = the(name, ...steps);
const enter = isEntrypoint(meta, argv[1]);
if (enter) setImmediate(()=>testAndExit(suite, argv.slice(2)));
return suite as Step;
};
/** Run single test step, then exit interpreter. */
async function testAndExit (test: Step, args: string[]) {
const context = await Testing({ args });
try {
await testRun(test, args, context);
} finally {
stdout.write('\n' + lines(testReport({ context })) + '\n');
}
}
/** Run test suite, collecting results into test report. */
const testRun = async <T extends Testing> (
test: Step<T>, _args?: string[], context?: Async<T>
): Promise<{ context: Testing, result: Result }> => ({
context: (context = await (context || Testing())) as T,
result: await withInfiniteStack(Step(test), undefined, context) as T
});
/** Test stack and context. Passed to eacgh step as second argument. */
export type Testing = Stack & Log & Result & Options & Categories;
/** Create empty test context. */
export const Testing = <T extends Testing> (...contexts: Partial<T>[]) =>
Seq(Log, Options, Categories, Stack)(merged(...contexts)) as Promise<T>;
/** Test options. */
export type Options = { args?: string[], only?: string[], except?: string[] };
/** Parse test filters. */
function Options (context: Partial<Options> = {}) {
context.args ??= [];
context.only ??= context.args.filter(x=>(!x.startsWith('--'))&&(x[2]!=='!'));
context.except ??= context.args?.filter(x=>x.startsWith('--!'));
return context;
}
/** Test category name. */
export type State = 'pass'|'fail'|'todo'|'idea'|'warn'|'skip'|'note';
/** Test category names. */
const categories: State[] = ['pass', 'fail', 'todo', 'idea', 'warn', 'skip', 'note'];
/** Test results by category.. */
export type Categories = Record<State, Category>;
/** Define result categories. */
function Categories (context: Partial<Testing> = {}) {
context.pass = Category(context.stack, 'pass', `🟢`, ANSI.green, 'passed' );
context.fail = Category(context.stack, 'fail', `🔴`, ANSI.red, 'failed' );
context.todo = Category(context.stack, 'todo', `🟠`, ANSI.orange, 'tasks' );
context.warn = Category(context.stack, 'warn', `🟡`, ANSI.yellow, 'warnings');
context.skip = Category(context.stack, 'skip', `🟣`, ANSI.purple, 'skipped' );
context.idea = Category(context.stack, 'idea', `🔵`, ANSI.blue, 'ideas' );
context.note = Category(context.stack, 'note', `⚫️`, ANSI.dim, 'notes' );
return context;
}
/** Keeps track of nested steps. */
export type Stack = {
stack: Frame[];
begin (index: number, step: Name): number;
end (index: number, step: Name, t0: number, t1: number,
state: State, returned: unknown, threw: unknown): void;
};
/** Define test stack. */
function Stack (context: Partial<Log & Categories & Stack> = {}) {
context.stack ??= [];
context.begin ??= (index, step) => {
const t0 = performance.now();
context.stack.push({ index, name: step.name, t0 });
context.info(msec(t0).padStart(8), '⏳');
return t0;
};
context.end ??= (index, step, t0, t1, state, returned, threw) => {
const tD = t1 - t0;
const label = testLabel(context.stack);
const { icon, color } = context[state];
context.log(color(msec(t1).padStart(8)),
ANSI.gray(6+2*Math.max(0, Math.log10(tD)), '+'+msec(tD).padStart(8)),
icon, color(label));
const result = { index, name: step.name, t0, t1, tD, returned, threw };
context[state](result);
context.stack.pop();
};
return context;
}
/** Collects test results. */
export type Category = Fn<[Step], Result> & {
icon: string, label: string, color: Fn<[string], string>, results: Result[]
};
/** Define result category. */
function Category (
stack: Frame[], state: State, icon: string, color: Fn<[string], string>,
label: string = state
): Category {
const props = { state, icon, label, color, results: [],
get count () { return props.results.length } };
const info = () => `[Category: ${icon} ${color(state)} (${props.results.length})]`;
return toString(info)(Name(state, function categorize (result: Partial<Result>): Result {
result = { ...result, state, stack: [...stack||[]] };
props.results.push(result);
return result as Result;
}, props)) as Category;
}
/** Collect summary of test results. */
function testReport ({ context, details = [] }) {
let line = '';
const thrown = new Set();
for (const {threw, stack: origin} of context.fail.results) {
const label = [testIndex(origin), testNames(origin)].join(' ');
const { message, stack = '' } = threw || {};
if (!thrown.has(threw)) {
details.push(['\n 🔴', ANSI.red(label), message].join(' '));
thrown.add(threw);
details.push(stack.replace(message).split('\n')
.map((x: string)=>x.trim())
.filter((x: string)=>!(x.includes('(ext:')||x.includes(' (node:')))
.map(alignTrace).join('\n '));
}
}
for (const name of categories) {
const category = context[name];
const { icon, color, label } = category;
line = line + spaced(` ${icon}`, color(`${context[name].count} ${label}`), '');
}
details.push(line);
return details;
}
/** Test stack frame. */
export type Frame = Name & { t0: number, index: number };
/** Test step. */
export type Step <C extends Testing = Testing, A = unknown, B = A> =
Reflects & ((_: A, __?: C) => Async<B>) & { skip?: boolean };
/** Ensure test step is function. */
function Step <T extends Testing>(step: Step<T>|string) {
if (typeof step === 'string') return todo(step);
if (typeof step === 'function') return step;
if (step) throw new Error(`invalid step: ${step}`);
}
/** Result of test step. */
export type Result = {
tD: number,
state: State,
stack: Frame[],
substeps?: Result[],
returned?: unknown,
threw?: { todo?: boolean },
};
/** Test case. Consists of name and zero or more test steps.
*
* 1. Steps run in sequence. If no step throws, the case passes.
* 2. **Empty case** like `the('empty')`
* amounts to `the('empty', todo())`.
* 3. **Stringy step** like `the('empty', 'string')`
* amounts to `the('empty', the('string'))`
* 4. **Falsy step** like `the('something', null)` is only counted.
*
* Example:
*
* import { suite, the, ok, equal } from '@hackbg/fadroma';
*
* export const test1 = the('Thing',
* _ => ok(1 == 1) // unnamed step (sync)
* async _ => ok(await something() != 5) // unnamed step (async)
* );
*
* export const test2 = the('Other',
* test1, // include defined substep
* the('named step', _ => equal(1, 1)), // define named substep in place
* the('renamed step', test1) // rename defined substep
* );
*
* export default suite(import.meta, 'Test suite', test2);
*
**/
export function the <T extends Testing> (
name: string|null, ...steps: (Step<T>|string)[]
): Step<T, void> {
if (steps.length === 0) return todo(name);
const substeps: Step<T>[] = steps.map(Step);
return Name(name, testStep, { steps });
async function testStep (last: unknown, context: T): Promise<void> {
let state: State = null, threw: Error, returned = last;
if (substeps.length === 0)
context.todo({ index: 1, name, t0: performance.now() });
if (substeps.length === 1)
await runStep(Name(substeps[0].name||name, substeps[0]));
for (let index = 1; index <= substeps.length; index++) {
const step = substeps[index - 1];
const stepName = substepName(name, step as { name?: string });
await Name(stepName, runStep)(step, index);
}
async function runStep (step: Step<T>, index = 1) {
const t0 = context.begin(index, step);
try {
returned = await step(returned, context);
state ||= 'pass';
} catch (error) {
threw ||= addStepStack(step, error);
if (!threw.todo) state = 'fail';
if (state === 'fail') throw threw;
} finally {
const t1 = performance.now();
const tD = t1 - t0;
state ||= 'todo';
context.end(index, step, t0, t1, state, returned, threw);
}
}
}
}
const testIndex = (stack: Frame[]) => stack.map(s=>s.index).join('.')+'.';
const testNames = (stack: Frame[]) => stack.map(s=>s.name).join(': ');
const testLabel = (stack: Frame[], col1 = 20, col2 = 40) => [
testIndex(stack).padEnd(20),
testNames(stack).padEnd(40)
].join(' │ ');
const substepName = (name?: string, step?: { name?: string }) =>
[name, step?.name].filter(Boolean).join(': ') || ANSI.gray(7, '(unnamed)');
/** Confirm that value returned by previous test step is of given type.
* Optionally, confirms other predicates about the value. */
export function is <T extends Testing> (t: null|undefined): Step<T, unknown>;
export function is <T extends Testing> (t: 'string', expected?: string): Step<T, unknown>;
export function is <T extends Testing> (t: 'number', expected?: number): Step<T, unknown>;
export function is <T extends Testing> (t: 'bigint', expected?: bigint): Step<T, unknown>;
export function is <T extends Testing> (t: 'boolean', expected?: string): Step<T, unknown>;
export function is <T extends Testing> (t: 'symbol', expected?: symbol): Step<T, unknown>;
export function is <T extends Testing> (t: 'function', expectedName?: string): Step<T, unknown>;
export function is <T extends Testing> (t: 'object', expectedConstructorName?: string): Step<T, unknown>;
export function is <T extends Testing> (t: 'object', ...steps: Step<T, unknown>[]): Step<T, unknown>;
export function is <T extends Testing> (proto: { [Symbol.hasInstance] (_: unknown): boolean }): Step<T, unknown>;
export function is <T extends Testing> (type: string|object, ...args: unknown[]): Step<T, unknown> {
// TODO: refactor: early dispatch
const name = `MUST be ${type}`;
// call the returned function to assert
return toString(`[${name}]`)(Name(name, function mustBe (value: unknown) {
// type check
ok(typeof value === type, `not ${type}: ${inspect(value, { depth: 4 })}`);
// value check
if (args.length >= 1) {
// 1st arg after type is type-specific predicate
const [arg] = args;
if (type === 'object') {
// object prototype check
if (typeof arg === 'string') {
// by constructor name as string:
equal(Object.getPrototypeOf(value).constructor.name, arg);
} else if (
typeof arg === 'object' &&
typeof arg[Symbol.hasInstance] === 'function'
) {
// using the `instanceof` operator:
ok(value instanceof (arg as Prototype));
} else {
throw new Error('unsupported object predicate');
}
} else if (type === 'function') {
// function name check
if (typeof arg === 'string') {
equal((value as Fn).name, arg);
} else {
throw new Error('unsupported function predicate');
}
} else if (type === 'string' || type === 'number' || type === 'bigint' || type === 'symbol') {
equal(value, arg);
} else {
throw new Error(`unsupported type predicate: ${type} ${arg}`);
}
}
return value;
})) as Step<T, unknown>;
}
/** Confirm that return value of previous test step
* is deeply, strictly equal to expected value. */
export function equals <T extends Testing, V> (value: V, info?: string|Error): Step<T, unknown> {
return Name(`MUST equal ${inspect(value)}`, function mustEqual (last: unknown) {
equal(value, last, info);
return last;
}, { value, info });
}
/** Confirm that object has key.
* Optionally, confirm predicates about the value of that key. */
export function has <T extends Testing, X> (
k: keyof X, value?: boolean|number|bigint|symbol|null|undefined): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, proto: { [Symbol.hasInstance] (_: unknown): boolean }): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, type: 'boolean', value?: boolean, ...check: Step<T, unknown>[]): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, type: 'number', value?: number, ...check: Step<T, unknown>[]): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, type: 'bigint', value?: bigint, ...check: Step<T, unknown>[]): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, type: 'symbol', value?: symbol, ...check: Step<T, unknown>[]): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, type: 'string', value?: string, ...check: Step<T, unknown>[]): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, type: 'function', name?: string, ...check: Step<T, unknown>[]): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, type: 'object', ctor?: string, ...check: Step<T, unknown>[]): Step<T, unknown>;
export function has <T extends Testing, X> (
k: keyof X, ...check: Step<T, unknown>[]): Step<T, unknown>;
export function has <T extends Testing, X> (k: keyof X, ...args: Parameters<typeof has>):
Step<T, unknown>;
export function has <T extends Testing, X> (props: Partial<X>):
Step<T, unknown>;
export function has <T extends Testing, X> (...args: unknown[]):
Step<T, unknown>
{
const arg0Type = typeof args[0];
switch (true) {
case (['string','number','symbol'].includes(arg0Type)): {
// check for one key
const key = args[0] as keyof X;
const checks = args.slice(1);
const name = `MUST have "${String(key)}"`
const info = `[${name}]`;
return toString(info)(Name(name, async function testHasProperty (object: X, context: T) {
ok(key in (object as object), `${String(key)} missing in ${inspect(object)}`);
for (const check of checks) {
if (typeof check !== 'function') {
context.warn('check is not a function, ignoring:', check);
continue
}
await check(object[key as keyof typeof object], context);
}
return object;
})) as Step<T, unknown>;
};
case (args[0] && typeof args[0] === 'object'): {
// partial equal
const name = `MUST match "${inspect(args[0])}"`;
return toString(`[${name}]`)(Name(name, function testHasProperties (object: object) {
for (const [k, expected] of Object.entries(args[0])) {
const actual = object[k]
equal(actual, expected, `${k} = ${inspect(actual)} != ${inspect(expected)}`);
}
return object;
})) as Step<T, unknown>;
};
default: throw new Error('has: invalid argument', { args });
}
}
/** Confirm that string or array includes an expected value. */
export function includes <T extends Testing, X> (item: X) {
return Name(`MUST include ${inspect(item)}`,
function mustInclude (object: { includes (_: X): boolean }, _: T) {
ok(object && ((typeof object === 'string') ||
((typeof object === 'object') && (typeof object['includes'] === 'function'))),
`no "includes" method in ${inspect(object)}`);
ok(object.includes(item),
`${inspect(item)} not included in ${inspect(object)}`)
return object;
}, { item })
}
// Reexport some default assertions:
export { ok, equal, throws, rejects, todo };
|