@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.
@@ -0,0 +1,419 @@
1
+ var e=`## Zest Scenario Testing — Quick Reference
2
+
3
+ ### Imports
4
+
5
+ \`\`\`ts
6
+ import {
7
+ scenario,
8
+ eachRow,
9
+ Context,
10
+ FactList,
11
+ stringCodec,
12
+ intCodec,
13
+ floatCodec,
14
+ booleanCodec,
15
+ enumCodec,
16
+ mappedCodec
17
+ } from '@zaunt/zest';
18
+ \`\`\`
19
+
20
+ ### Codecs
21
+
22
+ A codec parses a string to a typed value and formats it back.
23
+
24
+ - \`stringCodec\` — identity.
25
+ - \`intCodec\` — \`parseInt\`/\`String()\`.
26
+ - \`floatCodec(fractionDigits?)\` — \`parseFloat\`; formats with \`toFixed\` if \`fractionDigits\` given.
27
+ - \`booleanCodec(truthy?, falsy?, ignoreCase?)\` — defaults \`'yes'\`/\`'no'\`, case-insensitive.
28
+ - \`enumCodec(values, ignoreCase?)\` — matches against string literal union.
29
+ - \`mappedCodec(parseMap, formatMap, ignoreCase?)\` — arbitrary string↔value mapping.
30
+
31
+ ### Exhibits, facts and tables
32
+
33
+ Zest extracts **exhibits** from markdown in document order. An exhibit is a \`Fact\` or a \`Table\`.
34
+
35
+ **Facts** (\`kind: 'fact'\`) come from backtick-wrapped text, bold (\`**v**\`), italic (\`*v*\`), fenced code blocks, or indented code blocks. All produce a single string value. Leading/trailing blank lines are trimmed; internal whitespace is preserved. Code block facts have \`metadata: { language: 'ts' }\` (fenced with language tag) or \`metadata: {}\`; non-code-block facts always have \`metadata: {}\`.
36
+
37
+ **Tables** (\`kind: 'table'\`) come from GFM markdown tables. Backticks, bold and italic inside cells don't produce separate facts.
38
+
39
+ Nested facts (e.g. \`\`**\`value\`**\`\`) and lists (bulleted/numbered) throw errors. Do not use them.
40
+
41
+ ### \`scenario\`
42
+
43
+ \`\`\`ts
44
+ scenario({
45
+ name?: string,
46
+ markdown: string,
47
+ result?: codec, // default stringCodec; only used if execute returns non-undefined
48
+ failFast?: boolean,
49
+ execute(context) { ... }
50
+ });
51
+ \`\`\`
52
+
53
+ The \`execute\` function receives a \`Context\` with:
54
+
55
+ - \`facts: FactList\`, \`tables: Table[]\`, \`exhibits: Exhibit[]\` — views over the same underlying objects.
56
+ - \`sectionNames: string[]\` — direct child section names.
57
+ - \`allSectionNames: { name: string; level: number }[]\` — all descendant headings.
58
+ - \`section(name)\` / \`section(/regex/)\` — returns a sub-context scoped to that section. Throws if zero or multiple headings match.
59
+
60
+ If \`execute\` returns a non-undefined value, it's compared to the **last fact** using the \`result\` codec. Otherwise assert manually with \`fact.assertEquals(actual)\` or \`fact.assertEquals(actual, codec)\`.
61
+
62
+ \`\`\`ts
63
+ // Return-value assertion (last fact is expected output)
64
+ scenario({
65
+ markdown: 'When made uppercase \`fred\` becomes \`FRED\`.',
66
+ execute({facts: [input]}) {
67
+ return input.asString().toUpperCase();
68
+ }
69
+ });
70
+
71
+ // Manual assertion
72
+ scenario({
73
+ name: 'Boolean negation',
74
+ markdown: 'The input is \`aye\` the output is \`nay\`.',
75
+ execute({facts: [input, output]}) {
76
+ const codec = booleanCodec('aye', 'nay');
77
+ output.assertEquals(!input.as(codec), codec);
78
+ }
79
+ });
80
+ \`\`\`
81
+
82
+ ### Sections
83
+
84
+ Headings divide the document into sections. A section spans from a heading to the next heading at the same or higher level, or to the end. Level 1 headings (\`#\`) are ignored for section purposes (treated as document title).
85
+
86
+ \`context.section(name)\` returns a sub-context with its own \`facts\`, \`tables\`, \`exhibits\`, \`sectionNames\`, \`allSectionNames\`, and \`section()\` — all filtered to that section's content (including subsections, flattened).
87
+
88
+ \`sectionNames\` returns direct child section names. If \`###\` headings appear before any \`##\`, they're treated as direct children until a shallower heading appears.
89
+
90
+ Sections nest: \`context.section('Parent').section('Child')\`. You can iterate:
91
+
92
+ \`\`\`ts
93
+ for (const name of context.sectionNames) {
94
+ const {
95
+ facts: [input, expected]
96
+ } = context.section(name);
97
+ expected.assertEquals(transform(input.asString()));
98
+ }
99
+ \`\`\`
100
+
101
+ ### Prose blocks
102
+
103
+ Top-level markdown lists throw errors because they are reserved for future features. To include lists or explanatory notes without creating facts, wrap narrative text in a \`<prose>\` tag.
104
+
105
+ Zest treats everything inside a prose block as plain text: lists are permitted, and backticks, bold, italic, or code blocks do not create facts.
106
+
107
+ \`\`\`markdown
108
+ # calculateDiscount()
109
+
110
+ <prose>
111
+
112
+ Discounts apply under these conditions:
113
+
114
+ - Orders over \`100\` receive **10%** off
115
+ - Premium members always receive **15%** off
116
+
117
+ </prose>
118
+
119
+ Spend \`150\` gets discount \`15\`.
120
+ \`\`\`
121
+
122
+ In this example, only \`150\` and \`15\` are extracted as facts.
123
+ Note the blank lines before and after the \`<prose>\` tags.
124
+ These are needed for the CommonMark parser to process the inside as Markdown.
125
+
126
+ ### FactList API
127
+
128
+ \`FactList\` is iterable and destructurable: \`const [a, b, c] = facts\`.
129
+
130
+ - \`facts.length\`, \`facts.at(index)\` — 0-based, throws if out of range.
131
+ - \`facts.byLanguage(language, n)\` — *n*th (0-based) code block with given language tag. Throws if not found.
132
+
133
+ ### Fact API
134
+
135
+ - \`fact.asString()\` — raw string value.
136
+ - \`fact.asInt()\` — parse as integer.
137
+ - \`fact.as(codec)\` — parse with codec.
138
+ - \`fact.assertEquals(actual)\` — asserts with \`stringCodec\`.
139
+ - \`fact.assertEquals(actual, codec)\` — asserts with given codec.
140
+ - \`fact.metadata\`, \`fact.kind\` (\`'fact'\`).
141
+
142
+ Each fact can only be asserted once.
143
+
144
+ ### Table API
145
+
146
+ - \`table.kind\` (\`'table'\`), \`table.headers\`, \`table.rowCount\`, \`table.columnCount\`.
147
+ - \`table.row(index)\` — 0-based body row, returns \`Row\`.
148
+ - \`table.cell(col, row)\` — returns \`Fact\`. Row 0 is header row.
149
+ - Iterable over body rows.
150
+ - \`table.toMap(codecs?)\` — 2-column → \`Record<string, T>\`. 3+ columns → \`Record<string, Record<string, T>>\`. First column is key. Duplicate keys throw.
151
+ - \`table.toRecords(codecs?)\` — \`Record<string, T>[]\`.
152
+ - \`table.eachRow(execute)\` or \`table.eachRow(options)\` — see below.
153
+
154
+ ### Row API
155
+
156
+ - \`row.value('Col')\` / \`row.value('Col', codec)\` — string or parsed value.
157
+ - \`row.fact('Col')\` — returns \`Fact\` for assertions.
158
+
159
+ ### \`table.eachRow\`
160
+
161
+ \`\`\`ts
162
+ table.eachRow(execute);
163
+ table.eachRow({
164
+ expectedColumn?: string, // defaults to last column
165
+ codecs?: { Col: codec },
166
+ result?: codec, // default stringCodec
167
+ failFast?: boolean,
168
+ execute(...facts) { ... }
169
+ });
170
+ \`\`\`
171
+
172
+ If \`execute\` returns non-undefined, it's compared to the expected column. Returns a \`Promise\` — must \`await\` inside scenario. When \`failFast\` is false, all rows are processed; one failure rethrows directly, multiple produce a summary.
173
+
174
+ ### \`eachRow\` (top-level)
175
+
176
+ Standalone function: parses markdown, finds first table, runs callback per row. Registers a Vitest test.
177
+
178
+ \`\`\`ts
179
+ eachRow({
180
+ name?: string,
181
+ markdown: string,
182
+ expectedColumn?: string, // defaults to last column
183
+ codecs?: { Col: codec },
184
+ result?: codec, // default stringCodec
185
+ failFast?: boolean,
186
+ execute(...facts) { ... }
187
+ });
188
+ \`\`\`
189
+
190
+ ### Example: scenario with table and inline facts
191
+
192
+ \`\`\`ts
193
+ import {redirectToTrailingSlash} from '@src/website/common/http/redirects';
194
+ import {scenario} from '@zaunt/zest';
195
+ import {Hono} from 'hono';
196
+
197
+ scenario({
198
+ name: 'redirectToTrailingSlash',
199
+ markdown: \`
200
+ # redirectToTrailingSlash()
201
+
202
+ Registers a '301 Moved Permanently' redirect from a path to the same path with a trailing slash.
203
+
204
+ Path | Request Path | Expected Location
205
+ --------------|-------------------|-------------------
206
+ /foo | /foo | /foo/
207
+ /bar/baz | /bar/baz | /bar/baz/
208
+ /a | /a | /a/
209
+
210
+ Throws an exception if the specified path already ends with a slash.
211
+
212
+ For example, if you try to register the path \\\`/foo/\\\`,
213
+ it throws an error with message:
214
+ <br/>\\\`Path "/foo/" already ends with a slash\\\`
215
+ \`,
216
+ async execute({facts: [invalidPath, expectedError], tables: [redirects]}) {
217
+ await redirects.eachRow({
218
+ async execute(path, requestPath) {
219
+ const app = new Hono();
220
+ redirectToTrailingSlash(app, path.asString());
221
+ const res = await app.request(requestPath.asString(), {
222
+ redirect: 'manual'
223
+ });
224
+ return res.headers.get('Location')!;
225
+ }
226
+ });
227
+
228
+ try {
229
+ const app = new Hono();
230
+ redirectToTrailingSlash(app, invalidPath.asString());
231
+ expectedError.assertEquals('[no error thrown]');
232
+ } catch (e) {
233
+ expectedError.assertEquals((e as Error).message);
234
+ }
235
+ }
236
+ });
237
+ \`\`\`
238
+
239
+ Note how the required behavior is briefly explained before the test cases. This is important because otherwise the reader must guess the rule from examples alone.
240
+
241
+ ### Example: eachRow with codec
242
+
243
+ \`\`\`ts
244
+ import {getFormBoolean} from '@src/website/common/http/form-util';
245
+ import {eachRow, booleanCodec} from '@zaunt/zest';
246
+
247
+ eachRow({
248
+ name: 'getFormBoolean',
249
+ markdown: \`
250
+ # getFormBoolean()
251
+
252
+ Returns \\\`true\\\` when the form field value is \\\`on\\\`, \\\`true\\\`, or \\\`1\\\`.
253
+ Returns \\\`false\\\` for any other value, including when the key is absent.
254
+
255
+ Value | Expected
256
+ ----------|----------
257
+ on | yes
258
+ true | yes
259
+ 1 | yes
260
+ off | no
261
+ false | no
262
+ 0 | no
263
+ whatever | no
264
+ (absent) | no
265
+ \`,
266
+ result: booleanCodec(),
267
+ execute(value) {
268
+ const formData = new FormData();
269
+ const raw = value.asString();
270
+ if (raw !== '(absent)') {
271
+ formData.set('field', raw);
272
+ }
273
+ return getFormBoolean(formData, 'field');
274
+ }
275
+ });
276
+ \`\`\`
277
+
278
+ ### Example: sections
279
+
280
+ \`\`\`ts
281
+ scenario({
282
+ name: 'createProdViteAssetTagsService',
283
+ markdown: \`# createProdViteAssetTagsService()
284
+
285
+ Generates asset tags from a Vite manifest. It collects the entry's JS file,
286
+ any CSS dependencies, and transitively imported chunks — emitting
287
+ \\\`<link rel="stylesheet">\\\` for CSS, \\\`<link rel="modulepreload">\\\` for
288
+ imported chunks, and a \\\`<script type="module">\\\` for the entry itself.
289
+
290
+ Given this manifest:
291
+
292
+ \\\`\\\`\\\`json
293
+ {
294
+ "_shared-abc123.js": {
295
+ "file": "js/shared-abc123.js",
296
+ "css": ["css/shared-xyz.css"]
297
+ },
298
+ "src/app.ts": {
299
+ "file": "js/app-def456.js",
300
+ "imports": ["_shared-abc123.js"],
301
+ "css": ["css/app-uvw.css"]
302
+ },
303
+ "src/other.ts": {
304
+ "file": "js/other-ghi789.js"
305
+ }
306
+ }
307
+ \\\`\\\`\\\`
308
+
309
+ ## Entry with imports and CSS
310
+
311
+ Requesting entry \\\`src/app.ts\\\` produces tags that include:
312
+ a stylesheet link for the entry's own CSS;
313
+ a stylesheet link for the shared chunk's CSS;
314
+ a modulepreload for the shared chunk;
315
+ the entry script
316
+
317
+ \\\`\\\`\\\`html
318
+ <link rel="stylesheet" href="/css/app-uvw.css" />
319
+ <link rel="stylesheet" href="/css/shared-xyz.css" />
320
+ <link rel="modulepreload" href="/js/shared-abc123.js" />
321
+ <script type="module" src="/js/app-def456.js"><\/script>
322
+ \\\`\\\`\\\`
323
+
324
+ ## Entry with no imports
325
+
326
+ Requesting entry \\\`src/other.ts\\\` produces just the entry script tag.
327
+
328
+ \\\`\\\`\\\`html
329
+ <script type="module" src="/js/other-ghi789.js"><\/script>
330
+ \\\`\\\`\\\`
331
+
332
+ ## Unknown entry
333
+
334
+ Requesting an entry that doesn't exist in the manifest (e.g. \\\`src/nonexistent.ts\\\`) produces empty output.
335
+
336
+ \\\`\\\`\\\`html
337
+ \\\`\\\`\\\`
338
+
339
+ ## Custom base path
340
+
341
+ Using a custom base path \\\`/static/\\\` for entry \\\`src/other.ts\\\` produces:
342
+
343
+ \\\`\\\`\\\`html
344
+ <script type="module" src="/static/js/other-ghi789.js"><\/script>
345
+ \\\`\\\`\\\`\`,
346
+ async execute(context) {
347
+ const manifest = JSON.parse(
348
+ context.facts.byLanguage('json', 0).asString()
349
+ ) as ViteManifest;
350
+ const defaultService = createProdViteAssetTagsService(manifest);
351
+ const normalize = (s: string) =>
352
+ s
353
+ .split('\\n')
354
+ .map((line) => line.trim())
355
+ .filter((line) => line.length > 0)
356
+ .join('\\n');
357
+
358
+ for (const sectionName of [
359
+ 'Entry with imports and CSS',
360
+ 'Entry with no imports',
361
+ 'Unknown entry'
362
+ ]) {
363
+ const {
364
+ facts: [input, expected]
365
+ } = context.section(sectionName);
366
+ expected.assertEquals(normalize(await defaultService(input.asString())));
367
+ }
368
+
369
+ const {
370
+ facts: [basePath, entry, expected]
371
+ } = context.section('Custom base path');
372
+ const customService = createProdViteAssetTagsService(
373
+ manifest,
374
+ basePath.asString()
375
+ );
376
+ expected.assertEquals(normalize(await customService(entry.asString())));
377
+ }
378
+ });
379
+ \`\`\`
380
+
381
+ Note that all relevant facts are drawn from facts in the spec.
382
+ Nothing relevant to the behavior being tested is hardcoded in the execute method.
383
+ But things that are irrelevant to the behavior being tested aren't included in the scenario and may be hardcoded in the execute method.
384
+
385
+ ### Hints for writing Zest tests
386
+
387
+ The point of Zest tests is to protect you when you refactor.
388
+ They protect you by explaining the functionality in a way that doesn't change when you refactor.
389
+ In other words, they explain the external behaviour rather than the implementation.
390
+ That lets you change the implementation and get the protection, because you can check that the behaviour still works as before.
391
+ Without this kind of stability and separation, you'd end up having to change the tests and code at the same time, which would be a recipe for errors.
392
+
393
+ The fixture code acts as a kind of go-between for the scenarios and the system under test.
394
+ Its purpose is to shield the scenarios from the details of the system under test.
395
+
396
+ The upshot is that the scenarios need to be written in a careful way to avoid introducing unnecessary implementation details that lock you into a specific implementation.
397
+
398
+ For example, rather than scripting and saying, 'Do this, then do that. Click here, click that,' the scenarios should just explain the desired state and let the fixture figure out how to get the system under test into that state.
399
+
400
+ A second advantage of this approach is that the same scenarios can be run against different levels of the implementation.
401
+ For example, they could be run against a particular unit, or they could be run against the system as a whole.
402
+ The scenarios are effectively scale-free.
403
+
404
+ Another important consideration when writing scenarios is to avoid including information that isn't necessary to describe the particular behaviour being demonstrated.
405
+ For example, if you are testing that passwords match a certain set of rules, then you don't need to include details of the user or the login page or anything like that.
406
+ You can just focus on explaining the rules of the password, to let the fixture decide how to set the system into the right state to expose that functionality to the test.
407
+
408
+ Often you will want to test each rule separately, but sometimes, as in password rules, you need to test all of the rules combined because they are all checked at once.
409
+ You can't just test the password length rule on its own with a bunch of different-length passwords that have no numbers, if the rules say that you also need numbers.
410
+ So you have to think quite carefully about exactly what you're testing and what needs to be included and what shouldn't be included, to make the test as simple as possible but no simpler.
411
+
412
+ One of the common failure modes is to include unnecessary information instead of letting the fixture code decide any details that are unimportant to the behaviour being explained.
413
+
414
+ Another failure mode is to give only the scenarios without explaining the rules behind them.
415
+ That forces the reader to have to infer (fallibly guess) the rules which is totally unnecessary if you just explain the rules.
416
+
417
+ Explaining the rules also helps you to figure out exactly what scenarios you need to test.
418
+ Sometimes scenario writers over-specify and test things that aren't necessary.
419
+ `;export{e as t};
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@zaunt/zest",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "main": "./dist/index.mjs",
7
+ "types": "./dist/index.d.mts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.mjs",
11
+ "types": "./dist/index.d.mts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/zaunt/zest.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/zaunt/zest/issues"
26
+ },
27
+ "homepage": "https://github.com/zaunt/zest",
28
+ "devDependencies": {
29
+ "@types/markdown-it": "^14.2.0",
30
+ "@types/node": "^25.9.8",
31
+ "@typescript-eslint/eslint-plugin": "^8.70.0",
32
+ "@typescript-eslint/parser": "^8.70.0",
33
+ "eslint": "^10.11.0",
34
+ "prettier": "^3.9.8",
35
+ "prettier-plugin-curly": "^0.4.1",
36
+ "tsdown": "^0.23.0",
37
+ "typescript": "^6.0.3",
38
+ "typescript-eslint": "^8.70.0"
39
+ },
40
+ "dependencies": {
41
+ "markdown-it": "^14.3.2",
42
+ "vitest": "^4.1.11"
43
+ },
44
+ "scripts": {
45
+ "build": "tsdown --minify --dts",
46
+ "build:watch": "tsdown --watch",
47
+ "format": "prettier \"**/*.{mjs,html,js,json,ts}\" --write --list-different",
48
+ "test": "vitest run",
49
+ "lint": "eslint \"src/**/*.ts\""
50
+ }
51
+ }