char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Sompackages: name JSR and npm registries explicitly7abfdd9

main
6.1 KiB131 linesraw

Testing

avril test discovers test files, runs each in a fresh isolate, and reports one human-readable summary. There is no bench mode, no JSON reporter, and no Deno global: tests import their API from avril:test.

import { test } from "avril:test";

test("adds", () => {
  if (1 + 1 !== 2) throw new Error("math broke");
});

test("steps nest", async (t) => {
  await t.step("one", () => {});
  await t.step("two", async (t) => {
    await t.step("deeper", () => {});
  });
});
avril test
avril test tests/ ./extra_test.ts
avril test --filter=adds
avril test --no-run
avril test -0 --allow-net=example.test --allow-read=./fixtures/

Permission, package (--offline, --frozen, --jsr-registry, --cache-dir, --lock, --vendor), --include, and --env-file options behave exactly as for avril run. Each file loads with a child of the CLI capabilities plus its own packages, store, and graph, so a pledge() in one file never narrows another; each discovered file gets an implicit js-exec grant like an entry module, so avril test -0 <file> works without extra flags.

Discovery

With no arguments, the working directory is walked. Explicit directories are walked; explicit files run even when their names match nothing; explicit http(s) URLs run as remote modules.

Walked files must look like tests: test.<ext>, *_test.<ext>, or *.test.<ext>, where <ext> is one of js, mjs, cjs, cts, jsx, ts, mts, tsx. (d.ts files are excluded because they have no runtime code.) Dot-directories, node_modules, vendor, target, and __pycache__ are skipped, as are dot-files; symlinks are never followed while walking (an explicit symlinked directory walks its target), and results are sorted and deduplicated. An empty result is an error (No test files found).

API

test(name, fn);
test({ name, fn?, ignore?, only?, timeout? });
test.only(name, fn);   test.only(options);
test.ignore(name, fn); test.ignore(options);
  • fn receives a TestContext and may be sync or async. It is required unless ignore is true (test.ignore('name') needs no function); unknown option keys throw at registration, which fails the file, as does calling test() after collection (for example inside a test body).
  • timeout is milliseconds before the test fails (default 5000). timeout: 0 means the body must settle synchronously; values past 2147483647 are rejected at registration (timers truncate to 32 bits); Infinity disables the per-test timeout, but the file-level 60s progress budget below still applies. The timeout covers the body plus all of its steps, including steps the body never awaited.
  • When any test in a file sets only, every other test in that file is filtered out. Filtered-out tests never fail the run: a file whose focused tests pass exits 0. --filter=PATTERN keeps only tests whose top-level name contains the substring; steps inherit their test's selection, and filtered tests are counted, not run.
  • t.step(name, fn) / t.step({ name, fn?, ignore? }) runs a substep sequentially in call order and reports it indented. It resolves true when the step passed and false when it was ignored or failed — a failing step never throws. Any failed step still fails its ancestors up to the test, which reports due to N failed step(s) when its own body passed. Steps share their test's timeout; step-level only and timeout are rejected. Starting a step while another runs on the same context fails the newcomer (Started step ... while another step was running) without failing its victim; steps must be awaited one at a time. The parent waits out steps its body never awaited, and steps completing after their test settled (a timed-out body waking up) are dropped, never reported under a later test. Only top-level tests count toward file totals; steps are detail.
  • There is deliberately no (name, options, fn) third form and no compat shim beyond this surface.

Isolation and authority

Each file runs in its own fresh isolate, sequentially, in registration order. Global state is shared between tests in one file but never leaks across files. Each file also gets a child of the CLI capabilities with its own packages, store, and graph: a pledge() call inside a test narrows its whole file isolate irreversibly — including that file's own loading, exactly as under avril run — while later files still load and run under the un-narrowed parent, so tests after it in the same file inherit the restriction; prefer CLI flags for authority. There are no resource, timer, or op sanitizers: a test that leaves an interval running still passes, and the harness does not wait for idle handles before moving to the next file. Rejections nobody handles during the run are collected under the running test and fail the file with the full list instead of aborting silently; strays surfacing after the run resolves are not observed.

Reporter and exit codes

Check ./math_test.ts
ok - adds (0ms)
FAILED - subtracts (1ms)
  Error: boom
      at Object.fn (/tmp/proj/math_test.ts:18:29)
ignored - skipped
ok - ./math_test.ts (1 passed, 1 failed, 1 ignored, 0 filtered)
test result: FAILED. 1 passed; 1 failed; 1 ignored; 0 filtered; 1 file.

Failure stacks use source-mapped locations with the runner's own frames trimmed. Files that fail to prepare or evaluate (syntax errors, import failures, registration TypeErrors) print their detail in the file footer. Guest console output passes through without per-test capture. A test that spins synchronously past its timeout never yields to the runner's own timers, so a watchdog thread terminates the isolate (own timeout plus a small slack, or the file budget for Infinity): the spinning test reports a timeout and the rest of its file fails with it. A file that makes no progress at all for 60s — top-level evaluation or a stall between tests — is terminated with that message in its footer. The exit code is nonzero when any test fails or any file errors. --no-run still evaluates each file's top level to collect registrations, then lists test - name entries instead of running them.

1# Testing
2
3`avril test` discovers test files, runs each in a fresh isolate, and reports
4one human-readable summary. There is no bench mode, no JSON reporter, and no
5`Deno` global: tests import their API from `avril:test`.
6
7```ts
8import { test } from "avril:test";
9
10test("adds", () => {
11  if (1 + 1 !== 2) throw new Error("math broke");
12});
13
14test("steps nest", async (t) => {
15  await t.step("one", () => {});
16  await t.step("two", async (t) => {
17    await t.step("deeper", () => {});
18  });
19});
20```
21
22```sh
23avril test
24avril test tests/ ./extra_test.ts
25avril test --filter=adds
26avril test --no-run
27avril test -0 --allow-net=example.test --allow-read=./fixtures/
28```
29
30Permission, package (`--offline`, `--frozen`, `--jsr-registry`, `--cache-dir`,
31`--lock`, `--vendor`), `--include`, and `--env-file` options behave exactly as
32for `avril run`. Each file loads with a child of the CLI capabilities plus
33its own packages, store, and graph, so a `pledge()` in one file never narrows
34another; each discovered file gets an implicit js-exec grant like an entry
35module, so `avril test -0 <file>` works without extra flags.
36
37## Discovery
38
39With no arguments, the working directory is walked. Explicit directories are
40walked; explicit files run even when their names match nothing; explicit
41`http(s)` URLs run as remote modules.
42
43Walked files must look like tests: `test.<ext>`, `*_test.<ext>`, or
44`*.test.<ext>`, where `<ext>` is one of `js`, `mjs`, `cjs`, `cts`, `jsx`, `ts`, `mts`, `tsx`.
45(`d.ts` files are excluded because they have no runtime code.)
46Dot-directories, `node_modules`, `vendor`, `target`, and `__pycache__` are
47skipped, as are dot-files; symlinks are never followed while walking (an
48explicit symlinked directory walks its target), and results are sorted and
49deduplicated. An empty result is an error (`No test files found`).
50
51## API
52
53```ts
54test(name, fn);
55test({ name, fn?, ignore?, only?, timeout? });
56test.only(name, fn);   test.only(options);
57test.ignore(name, fn); test.ignore(options);
58```
59
60- `fn` receives a `TestContext` and may be sync or async. It is required
61  unless `ignore` is true (`test.ignore('name')` needs no function); unknown
62  option keys throw at registration, which fails the file, as does calling
63  `test()` after collection (for example inside a test body).
64- `timeout` is milliseconds before the test fails (default `5000`).
65  `timeout: 0` means the body must settle synchronously; values past
66  `2147483647` are rejected at registration (timers truncate to 32 bits);
67  `Infinity` disables the per-test timeout, but the file-level 60s progress
68  budget below still applies. The timeout covers the body plus all of its
69  steps, including steps the body never awaited.
70- When any test in a file sets `only`, every other test in that file is
71  filtered out. Filtered-out tests never fail the run: a file whose focused
72  tests pass exits 0. `--filter=PATTERN` keeps only tests whose top-level
73  name contains the substring; steps inherit their test's selection, and
74  filtered tests are counted, not run.
75- `t.step(name, fn)` / `t.step({ name, fn?, ignore? })` runs a substep
76  sequentially in call order and reports it indented. It resolves `true`
77  when the step passed and `false` when it was ignored or failed — a failing
78  step never throws. Any failed step still fails its ancestors up to the
79  test, which reports `due to N failed step(s)` when its own body passed.
80  Steps share their test's timeout; step-level `only` and `timeout` are
81  rejected. Starting a step while another runs on the same context fails
82  the newcomer (`Started step ... while another step was running`) without
83  failing its victim; steps must be awaited one at a time. The parent waits
84  out steps its body never awaited, and steps completing after their test
85  settled (a timed-out body waking up) are dropped, never reported under a
86  later test. Only top-level tests count toward file totals; steps are
87  detail.
88- There is deliberately no `(name, options, fn)` third form and no compat
89  shim beyond this surface.
90
91## Isolation and authority
92
93Each file runs in its own fresh isolate, sequentially, in registration order.
94Global state is shared between tests in one file but never leaks across files.
95Each file also gets a child of the CLI capabilities with its own packages,
96store, and graph: a `pledge()` call inside a test narrows its whole file
97isolate irreversibly — including that file's own loading, exactly as under
98`avril run` — while later files still load and run under the un-narrowed
99parent, so tests after it in the same file inherit the restriction; prefer
100CLI flags for authority. There are no resource, timer, or op sanitizers: a
101test that leaves an interval running still passes, and the harness does not
102wait for idle handles before moving to the next file. Rejections nobody
103handles during the run are collected under the running test and fail the file
104with the full list instead of aborting silently; strays surfacing after the
105run resolves are not observed.
106
107## Reporter and exit codes
108
109```
110Check ./math_test.ts
111ok - adds (0ms)
112FAILED - subtracts (1ms)
113  Error: boom
114      at Object.fn (/tmp/proj/math_test.ts:18:29)
115ignored - skipped
116ok - ./math_test.ts (1 passed, 1 failed, 1 ignored, 0 filtered)
117test result: FAILED. 1 passed; 1 failed; 1 ignored; 0 filtered; 1 file.
118```
119
120Failure stacks use source-mapped locations with the runner's own frames
121trimmed. Files that fail to prepare or evaluate (syntax errors, import
122failures, registration `TypeError`s) print their detail in the file footer.
123Guest `console` output passes through without per-test capture. A test that
124spins synchronously past its timeout never yields to the runner's own timers,
125so a watchdog thread terminates the isolate (own timeout plus a small slack,
126or the file budget for `Infinity`): the spinning test reports a timeout and
127the rest of its file fails with it. A file that makes no progress at all for
12860s — top-level evaluation or a stall between tests — is terminated with that
129message in its footer. The exit code is nonzero when any test fails or any
130file errors. `--no-run` still evaluates each file's top level to collect
131registrations, then lists `test - name` entries instead of running them.