@zaunt/zest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,590 @@
1
+ # @zaunt/zest
2
+
3
+ A scenario-based testing framework for TypeScript. Write tests as natural-language Markdown with embedded facts and tables, and let Zest extract, parse, and assert them for you.
4
+
5
+ Zest produces colour-coded HTML reports showing which facts passed, failed, or weren't checked.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install --save-dev @zaunt/zest
11
+ ```
12
+
13
+ Zest currently integrates with [Vitest](https://vitest.dev/). You'll need Vitest installed as well.
14
+
15
+ ## Quick start
16
+
17
+ ```ts
18
+ import {scenario, eachRow, stringCodec, intCodec} from '@zaunt/zest';
19
+
20
+ scenario({
21
+ markdown: 'When made uppercase `fred` becomes `FRED`.',
22
+ execute({facts: [input]}) {
23
+ return input.asString().toUpperCase();
24
+ }
25
+ });
26
+ ```
27
+
28
+ Zest extracts `fred` and `FRED` as facts, passes them via the `execute` argument, and compares the return value against the last fact.
29
+
30
+ ## Core concepts
31
+
32
+ ### Exhibits, facts, and tables
33
+
34
+ Zest extracts **exhibits** from the Markdown in document order. An exhibit is either a `Fact` or a `Table`.
35
+
36
+ **Facts** (`kind: 'fact'`) come from:
37
+
38
+ - Backtick-wrapped text: `` `value` ``
39
+ - Bold text: `**value**`
40
+ - Italic text: `*value*`
41
+ - Fenced code blocks (with or without a language tag)
42
+ - Indented code blocks
43
+
44
+ All of these produce a single string value. Leading and trailing blank lines are trimmed, but internal whitespace is preserved. Code block facts have a `metadata` property: `{ language: 'ts' }` for fenced blocks with a language tag, or `{}` otherwise. Non-code-block facts always have `metadata: {}`.
45
+
46
+ **Tables** (`kind: 'table'`) come from GFM Markdown tables. Cell content is plain text — backticks, bold, and italic inside cells don't produce separate facts.
47
+
48
+ **Restrictions:** Nested facts (e.g. ``**`value`**``) throw an error. Lists (bulleted or numbered) throw an error; they're reserved for future use.
49
+
50
+ ### Codecs
51
+
52
+ A codec converts between strings and typed values. Zest provides several built-in codecs:
53
+
54
+ | Codec | Description |
55
+ | ----------------------------------------------- | -------------------------------------------- |
56
+ | `stringCodec` | Identity — no conversion |
57
+ | `intCodec` | Parses/formats integers |
58
+ | `floatCodec(fractionDigits?)` | Parses/formats floating-point numbers |
59
+ | `booleanCodec(truthy?, falsy?, ignoreCase?)` | Defaults to `'yes'`/`'no'`, case-insensitive |
60
+ | `enumCodec(values, ignoreCase?)` | Matches against a set of string literals |
61
+ | `mappedCodec(parseMap, formatMap, ignoreCase?)` | Arbitrary string↔value mapping |
62
+
63
+ Use codecs to read typed values from facts:
64
+
65
+ ```ts
66
+ fact.asString(); // string
67
+ fact.asInt(); // number (integer)
68
+ fact.as(floatCodec(2)); // number (float)
69
+ ```
70
+
71
+ ## Writing scenarios
72
+
73
+ ### The `execute` function
74
+
75
+ The `execute` function receives a `Context` object with three properties, all in document order:
76
+
77
+ - `facts: FactList` — all facts extracted from the Markdown.
78
+ - `tables: Table[]` — all tables extracted from the Markdown.
79
+ - `exhibits: Exhibit[]` — all exhibits (facts and tables interleaved).
80
+
81
+ These are views over the same underlying objects, not copies.
82
+
83
+ `FactList` is iterable and supports destructuring, so you can write `execute({facts: [a, b]})` for simple cases. For scenarios with many facts, it also supports indexed access and language-based filtering (see below).
84
+
85
+ ### Accessing facts by section
86
+
87
+ When a scenario has multiple sections under different headings, positional fact indices can become hard to follow. The `Context` object's `section` method lets you scope into a heading's content instead.
88
+
89
+ A section is everything underneath a heading up to the next heading at the same level or higher. The returned object is itself a `Context`, with its own `facts`, `tables`, `exhibits`, and `section` method — so you can drill down through nested headings.
90
+
91
+ ```ts
92
+ scenario({
93
+ name: 'createProdViteAssetTagsService',
94
+ markdown: `
95
+ # createProdViteAssetTagsService()
96
+
97
+ Given this manifest:
98
+
99
+ \`\`\`json
100
+ { "src/app.ts": { "file": "js/app.js", "css": ["css/app.css"] } }
101
+ \`\`\`
102
+
103
+ ## Entry with CSS
104
+
105
+ Requesting entry \`src/app.ts\` produces:
106
+
107
+ \`\`\`html
108
+ <link rel="stylesheet" href="/css/app.css" />
109
+ <script type="module" src="/js/app.js"></script>
110
+ \`\`\`
111
+
112
+ ## Custom base path
113
+
114
+ Using base path \`/static/\` for entry \`src/app.ts\` produces:
115
+
116
+ \`\`\`html
117
+ <script type="module" src="/static/js/app.js"></script>
118
+ \`\`\`
119
+ `,
120
+ async execute(context) {
121
+ const manifest = JSON.parse(context.facts.json(0).asString());
122
+ const defaultService = createProdViteAssetTagsService(manifest);
123
+
124
+ const {
125
+ facts: [entry, expected]
126
+ } = context.section('Entry with CSS');
127
+ expected.assertEquals(await defaultService(entry.asString()));
128
+
129
+ const {
130
+ facts: [basePath, customEntry, customExpected]
131
+ } = context.section('Custom base path');
132
+ const customService = createProdViteAssetTagsService(
133
+ manifest,
134
+ basePath.asString()
135
+ );
136
+ customExpected.assertEquals(await customService(customEntry.asString()));
137
+ }
138
+ });
139
+ ```
140
+
141
+ `section` accepts either an exact string or a regular expression. It throws if no heading matches, or if multiple headings match — there must be exactly one unambiguous match.
142
+
143
+ #### Discovering sections programmatically
144
+
145
+ `Context` exposes two properties for inspecting available sections:
146
+
147
+ - `sectionNames` — the heading texts of the immediate child sections within the current scope. A heading is an "immediate child" if no shallower heading (ignoring `h1`) sits between the scope start and that heading. For example, if `### Foo` appears before any `##` heading, both are immediate children of the root scope.
148
+ - `allSectionNames` — all descendant headings within the scope (excluding `h1`), in document order, each with its `name` and `level`.
149
+
150
+ ```ts
151
+ scenario({
152
+ markdown: `
153
+ # My service
154
+
155
+ ### Setup
156
+ \`config-value\`
157
+
158
+ ## Addition
159
+ Input: \`3\`, output: \`6\`
160
+
161
+ ## Subtraction
162
+ Input: \`10\`, output: \`7\`
163
+
164
+ ### Edge case
165
+ Input: \`0\`, output: \`-3\`
166
+ `,
167
+ execute(context) {
168
+ // context.sectionNames → ['Setup', 'Addition', 'Subtraction']
169
+ // context.allSectionNames → [
170
+ // { name: 'Setup', level: 3 },
171
+ // { name: 'Addition', level: 2 },
172
+ // { name: 'Subtraction', level: 2 },
173
+ // { name: 'Edge case', level: 3 },
174
+ // ]
175
+
176
+ const sub = context.section('Subtraction');
177
+ // sub.sectionNames → ['Edge case']
178
+ // sub.facts contains the facts from both '## Subtraction' and '### Edge case'
179
+ }
180
+ });
181
+ ```
182
+
183
+ ### Accessing facts by language
184
+
185
+ When your Markdown contains several code blocks with language tags, positional destructuring can become unwieldy. `FactList` lets you filter by language instead:
186
+
187
+ ```ts
188
+ scenario({
189
+ markdown: `
190
+ Given this config:
191
+
192
+ \`\`\`json
193
+ {"port": 3000}
194
+ \`\`\`
195
+
196
+ It produces:
197
+
198
+ \`\`\`yaml
199
+ port: 3000
200
+ \`\`\`
201
+ `,
202
+ execute({facts}) {
203
+ const config = JSON.parse(facts.json(0).asString());
204
+ facts.byLanguage('yaml', 0).assertEquals(toYaml(config));
205
+ }
206
+ });
207
+ ```
208
+
209
+ `facts.json(0)` returns the first JSON code block, `facts.html(2)` returns the third HTML block, and so on. You can get facts by their language tag, using e.g. `facts.byLanguage('python', 0)`.
210
+
211
+ These methods throw if the requested block doesn't exist, with a message indicating how many blocks of that language were found.
212
+
213
+ ### Simple assertion by return value
214
+
215
+ If `execute` returns a non-undefined value, Zest compares it to the **last fact** using the `result` codec (which defaults to `stringCodec`).
216
+
217
+ ```ts
218
+ scenario({
219
+ markdown: 'The result of `3` + `4` is `7`.',
220
+ result: intCodec,
221
+ execute({facts: [a, b]}) {
222
+ return a.asInt() + b.asInt();
223
+ }
224
+ });
225
+ ```
226
+
227
+ ### Manual assertions
228
+
229
+ Instead of returning a result, you can assert facts manually using `assertEquals`.
230
+
231
+ ```ts
232
+ scenario({
233
+ name: 'Boolean negation',
234
+ markdown: 'The input is `Aye` the output is `Nay`.',
235
+ execute({facts: [input, output]}) {
236
+ const codec = booleanCodec('Aye', 'Nay');
237
+ output.assertEquals(!input.as(codec), codec);
238
+ }
239
+ });
240
+ ```
241
+
242
+ Each fact can only be asserted once.
243
+
244
+ ### Tables
245
+
246
+ Tables in the Markdown are available via the `tables` array.
247
+
248
+ ```ts
249
+ scenario({
250
+ markdown: `
251
+ Input | Result
252
+ --------|--------
253
+ foo | FOO
254
+ bar | BAR
255
+ `,
256
+ execute({tables: [table]}) {
257
+ for (const row of table) {
258
+ row.fact('Result').assertEquals(row.value('Input').toUpperCase());
259
+ }
260
+ }
261
+ });
262
+ ```
263
+
264
+ ### Multiple tables and inline facts
265
+
266
+ Tables and inline facts are interleaved in document order. You can mix setup data, test cases, and assertions freely.
267
+
268
+ ```ts
269
+ scenario({
270
+ markdown: `
271
+ Name | Age
272
+ -------|------
273
+ Fred | 35
274
+ Wilma | 33
275
+
276
+ Input | Expected
277
+ -----------|----------
278
+ fred | FRED
279
+ wilma | WILMA
280
+
281
+ The author is *Betty*.
282
+ `,
283
+ execute({facts: [author], tables: [people, testCases]}) {
284
+ const map = people.toMap({Age: intCodec});
285
+ // map = { "Fred": 35, "Wilma": 33 }
286
+
287
+ for (const row of testCases) {
288
+ row.fact('Expected').assertEquals(row.value('Input').toUpperCase());
289
+ }
290
+
291
+ author.assertEquals('Betty');
292
+ }
293
+ });
294
+ ```
295
+
296
+ ### Code block facts
297
+
298
+ Code blocks are treated as single-string facts, not parsed for tables or other exhibits.
299
+
300
+ ```ts
301
+ scenario({
302
+ markdown: `
303
+ The template produces:
304
+
305
+ \`\`\`html
306
+ <div>
307
+ <p>Hello</p>
308
+ </div>
309
+ \`\`\`
310
+ `,
311
+ execute({facts: [output]}) {
312
+ // output.metadata is { language: 'html' }
313
+ return renderTemplate();
314
+ }
315
+ });
316
+ ```
317
+
318
+ ## API reference
319
+
320
+ ### `Context`
321
+
322
+ The `execute` function receives a `Context` object. It has the same `facts`, `tables`, and `exhibits` properties as previous versions' `ExecuteArgs`, so all existing destructuring patterns continue to work.
323
+
324
+ | Method / Property | Description |
325
+ | ------------------------- | ----------------------------------------------------------- |
326
+ | `context.facts` | `FactList` — all facts in the current scope |
327
+ | `context.tables` | `Table[]` — all tables in the current scope |
328
+ | `context.exhibits` | `Exhibit[]` — all exhibits in the current scope |
329
+ | `context.section(name)` | Returns a `Context` scoped to the named section |
330
+ | `context.section(regex)` | Same, but matches heading text against a regular expression |
331
+ | `context.sectionNames` | `string[]` — heading texts of the immediate child sections |
332
+ | `context.allSectionNames` | `{name: string, level: number}[]` — all descendant headings |
333
+
334
+ `section` throws if no heading matches or if multiple headings match. Heading level 1 is ignored for section scoping (there should be at most one, covering the entire scenario).
335
+
336
+ ### `FactList`
337
+
338
+ | Method / Property | Description |
339
+ | --------------------------- | -------------------------------------------------- |
340
+ | `facts.length` | Number of facts |
341
+ | `facts.at(index)` | Fact by 0-based index (throws if out of bounds) |
342
+ | `facts.byLanguage(lang, n)` | The *n*th code block with the given language tag |
343
+ | `facts.json(n)` | Shorthand for `byLanguage('json', n)` |
344
+ | `facts.html(n)` | Shorthand for `byLanguage('html', n)` |
345
+ | `facts.css(n)` | Shorthand for `byLanguage('css', n)` |
346
+ | `facts.js(n)` | Shorthand for `byLanguage('js', n)` |
347
+ | `facts.ts(n)` | Shorthand for `byLanguage('ts', n)` |
348
+ | `facts.xml(n)` | Shorthand for `byLanguage('xml', n)` |
349
+ | `facts.sql(n)` | Shorthand for `byLanguage('sql', n)` |
350
+ | `for (const f of facts)` | Iterable — works with destructuring and `for...of` |
351
+
352
+ `FactList` supports destructuring (e.g. `{facts: [a, b]}`).
353
+
354
+ ### `Fact`
355
+
356
+ | Method / Property | Description |
357
+ | ---------------------------------- | ---------------------------------------------- |
358
+ | `fact.asString()` | Returns the raw string value |
359
+ | `fact.asInt()` | Parses as integer |
360
+ | `fact.as(codec)` | Parses with a codec |
361
+ | `fact.assertEquals(actual)` | Asserts equality using `stringCodec` |
362
+ | `fact.assertEquals(actual, codec)` | Asserts equality using the given codec |
363
+ | `fact.metadata` | `{}` or `{ language: string }` for code blocks |
364
+ | `fact.kind` | Always `'fact'` |
365
+
366
+ ### `Table`
367
+
368
+ | Method / Property | Description |
369
+ | --------------------------------- | ---------------------------------------------------------------------------------- |
370
+ | `table.kind` | Always `'table'` |
371
+ | `table.headers` | Column names |
372
+ | `table.rowCount` | Number of body rows |
373
+ | `table.columnCount` | Number of columns |
374
+ | `table.row(index)` | Body row by 0-based index, returns `Row` |
375
+ | `table.cell(col, row)` | Fact by column and row index (row 0 = headers) |
376
+ | `for (const row of table)` | Iterate body rows |
377
+ | `table.toMap()` | 2-column → `Record<string, string>`, 3+ → `Record<string, Record<string, string>>` |
378
+ | `table.toMap({ Col: codec })` | Same but with codecs applied to named columns |
379
+ | `table.toRecords()` | Array of `Record<string, string>` |
380
+ | `table.toRecords({ Col: codec })` | Array of `Record<string, unknown>` with codecs applied |
381
+ | `table.eachRow(execute)` | Run a callback for every body row (see below) |
382
+ | `table.eachRow(options)` | Same, with additional options (see below) |
383
+
384
+ The first column is always the key for `toMap`. Duplicate keys throw an error.
385
+
386
+ ### `table.eachRow`
387
+
388
+ Runs a callback for every body row of a table. Each column becomes a positional `Fact` argument, just like the top-level `eachRow`. Returns a `Promise`, so you must `await` it inside a `scenario` execute function.
389
+
390
+ ```ts
391
+ // Simple form — just a callback
392
+ await table.eachRow((input, expected) => {
393
+ expected.assertEquals(input.asString().toUpperCase());
394
+ });
395
+
396
+ // Options form
397
+ await table.eachRow({
398
+ expectedColumn: 'Sum',
399
+ codecs: {A: intCodec, B: intCodec, Sum: intCodec},
400
+ result: intCodec,
401
+ failFast: false,
402
+ execute(a, b) {
403
+ return a.asInt() + b.asInt();
404
+ }
405
+ });
406
+ ```
407
+
408
+ If `execute` returns a non-undefined value, it's compared against the expected column (the last column by default). You can also assert manually instead of returning.
409
+
410
+ When `failFast` is false (the default, falling back to the global config), all rows are processed before errors are surfaced. One failure is rethrown directly. Multiple failures produce a summary with the count and first error's details.
411
+
412
+ ### `Row`
413
+
414
+ | Method / Property | Description |
415
+ | ------------------------- | ------------------------------- |
416
+ | `row.value('Col')` | String value |
417
+ | `row.value('Col', codec)` | Parsed value |
418
+ | `row.fact('Col')` | Returns `Fact` (for assertions) |
419
+
420
+ ## Data-driven tests with `eachRow`
421
+
422
+ The top-level `eachRow` is a standalone function that parses a Markdown string, finds the first table, and runs `execute` for every row. Each column becomes a positional `Fact` argument. It registers a Vitest test directly.
423
+
424
+ ```ts
425
+ eachRow({
426
+ name: 'uppercasing',
427
+ markdown: `
428
+ Input | Expected Output
429
+ -----------|----------------
430
+ a | A
431
+ abc | ABC
432
+ An example | AN EXAMPLE
433
+ `,
434
+ execute(input) {
435
+ return input.asString().toUpperCase();
436
+ }
437
+ });
438
+ ```
439
+
440
+ If `execute` returns a value, it's compared against the expected column (the last column by default). You can specify a different expected column, per-column codecs, and a result codec:
441
+
442
+ ```ts
443
+ eachRow({
444
+ markdown: `
445
+ A | B | Sum | Notes
446
+ ----|-----|-----|------
447
+ 1 | 2 | 3 | simple
448
+ 10 | 20 | 30 | tens
449
+ `,
450
+ expectedColumn: 'Sum',
451
+ codecs: {A: intCodec, B: intCodec, Sum: intCodec},
452
+ result: intCodec,
453
+ execute(a, b) {
454
+ return a.asInt() + b.asInt();
455
+ }
456
+ });
457
+ ```
458
+
459
+ You can also assert manually instead of returning:
460
+
461
+ ```ts
462
+ execute(input, expected) {
463
+ expected.assertEquals(input.asString().toUpperCase());
464
+ }
465
+ ```
466
+
467
+ ## A more involved example
468
+
469
+ When you have a setup table alongside a test-cases table, use `scenario` and call `table.eachRow` on the test-cases table.
470
+
471
+ ```ts
472
+ import {LocalizedSlugs} from '@src/services/i18n/localized-slugs';
473
+ import {scenario} from '@zaunt/zest';
474
+
475
+ scenario({
476
+ name: 'getFullLocalizedPath',
477
+ markdown: `
478
+ Given the following localized slug data:
479
+
480
+ Locale | sign-in | now | yes
481
+ -------|------------|-------------|-----
482
+ en | sign-in | now | yes
483
+ fr | connexion | maintenant | oui
484
+ de | anmelden | jetzt | ja
485
+
486
+ The full localized path for each case is:
487
+
488
+ Locale | Path | Expected
489
+ -------|---------------------|-------------------------
490
+ en | / | /
491
+ fr | / | /fr/
492
+ de | / | /de/
493
+ en | /sign-in | /sign-in
494
+ en | /sign-in/ | /sign-in/
495
+ fr | /sign-in | /fr/connexion
496
+ fr | /sign-in/ | /fr/connexion/
497
+ de | /sign-in | /de/anmelden
498
+ de | /sign-in/now | /de/anmelden/jetzt
499
+ de | /sign-in/now/ | /de/anmelden/jetzt/
500
+ de | /sign-in/now/yes | /de/anmelden/jetzt/ja
501
+ en | /sign-in/now/yes | /sign-in/now/yes
502
+ es | /sign-in | [ERROR] Locale "es" not found
503
+ fr | /missing | [ERROR] Slug key "missing" not found for locale "fr"
504
+ de | /sign-in/missing | [ERROR] Slug key "missing" not found for locale "de"
505
+ `,
506
+ async execute({tables: [setup, expectations]}) {
507
+ const slugKeys = setup.headers.slice(1);
508
+
509
+ const localizedSlugData: Record<string, Record<string, string>> = {};
510
+ for (const row of setup) {
511
+ const loc = row.value('Locale');
512
+ localizedSlugData[loc] = {};
513
+ for (const key of slugKeys) {
514
+ localizedSlugData[loc][key] = row.value(key);
515
+ }
516
+ }
517
+
518
+ const localizedSlugs = new LocalizedSlugs(localizedSlugData);
519
+
520
+ await expectations.eachRow({
521
+ execute(locale, path) {
522
+ try {
523
+ return localizedSlugs.getFullLocalizedPath(
524
+ locale.asString(),
525
+ path.asString()
526
+ );
527
+ } catch (e) {
528
+ return `[ERROR] ${(e as Error).message}`;
529
+ }
530
+ }
531
+ });
532
+ }
533
+ });
534
+ ```
535
+
536
+ ## Configuration
537
+
538
+ ```ts
539
+ import {setConfig} from '@zaunt/zest';
540
+
541
+ setConfig({
542
+ outputEnabled: true, // write HTML reports (default: true)
543
+ outputDir: './reports', // report output directory
544
+ failFast: false // stop on first failure (default: false)
545
+ });
546
+ ```
547
+
548
+ These can also be set via environment variables:
549
+
550
+ | Variable | Default |
551
+ | --------------------- | -------------------------------------- |
552
+ | `ZEST_OUTPUT_ENABLED` | `true` |
553
+ | `ZEST_OUTPUT_DIR` | System temp directory + `/zest-output` |
554
+ | `ZEST_FAIL_FAST` | `false` |
555
+
556
+ Per-scenario `failFast` overrides the global setting:
557
+
558
+ ```ts
559
+ scenario({
560
+ markdown: '...',
561
+ failFast: true,
562
+ execute({ facts, tables, exhibits }) { ... }
563
+ });
564
+ ```
565
+
566
+ ## HTML reports
567
+
568
+ After each test, Zest writes a self-contained HTML file showing the scenario with every fact colour-coded:
569
+
570
+ - 🟢 **Green** — asserted and passed
571
+ - 🔴 **Red** — asserted and failed (shows expected and actual values)
572
+ - **Neutral** — read but not asserted, or never accessed
573
+
574
+ Reports are written to the configured output directory. The filename is derived from the test name, or a hash of the Markdown if no name is given.
575
+
576
+ ## Markdown format
577
+
578
+ Zest uses a proper Markdown parser ([markdown-it](https://github.com/markdown-it/markdown-it)). Tables follow GFM syntax:
579
+
580
+ ```
581
+ Header1 | Header2
582
+ --------|--------
583
+ value1 | value2
584
+ ```
585
+
586
+ The separator row needs at least three dashes per column. Inline facts can use any of these delimiters:
587
+
588
+ - Backticks: `` `value` ``
589
+ - Bold: `**value**`
590
+ - Italic: `*value*`