Decisions
Each design decision, with its reason.
One entry for each decision. Each entry states the decision, who made it, and the reason. Add new entries at the end. If a decision changes, edit its entry and state the new date.
D1. Coverage is always on (user, 2026-11-11)#
Per-test coverage runs in each test run. There is no option to turn it off.
Reason. Deep insight into test behavior is not possible without it. Slowness is a cost, but it is not the only cost.
Result. The speed goal stays. We make coverage as fast as we can, and we get speed from other parts of the system to pay for it. Coverage cost needs a measurement early, so that the design of the other parts can account for it.
D2. Strict rules, no opt-out at the start (user, 2026-11-11)#
The tool has one correct way to do each thing. Examples: a test
with zero assertions fails, there is no .only, and zero matched
tests is an error. We add flexibility later only if a real need
shows up.
Reason. Agents adapt to rules more easily than humans do, and they do better work when the correct way is not in doubt.
D3. The guardrails must work for weaker models (user, 2026-11-11)#
The design must help older and lower-powered models do the right thing, not only frontier models.
Result. These rules apply to each feature:
- The tool must not depend on the agent to remember a step. The safe behavior is the default behavior.
- Each failure and each error prints the next command to run.
- There is one way to do each thing, so that there is no wrong choice to make.
- The output states the result in plain words. The agent must not have to infer a failure from missing text.
- A mistake must fail loudly and early. A silent wrong result is the worst outcome.
D4. Wire format: one event stream, many sinks (agent, 2026-11-11)#
The user left this decision to the agent, with one constraint: in CI, stdio is often the only thing that a person or an agent can read.
Decision.
- The test library produces typed events: run start, test start, assertion, console output, coverage, test end, run end. The event types are the wire format. They have a version number.
- In the test process, the library publishes each event on a
diagnostics_channelchannel. Sinks subscribe to the channels. - A sink is one consumer of the events. The planned sinks are:
- Database sink. Writes the run to SQLite.
- Compact text sink. The default on stdout. Prints nothing for a passing test, one block for each failure, and one summary line.
- CI text sink. See below.
- Event dump sink. Writes the raw events as one JSON object for each line. This is the debug tool for the wire format itself.
- When a runner starts test processes, each child sends its events to the runner, and only the runner writes to the database. SQLite permits one writer at a time, so many writer processes would wait on each other.
- When a test file runs as a plain program (
node test/foo.ts), the process is its own runner. The same sinks run in that process. - TAP is not the wire format. A TAP sink can be added later, since the events contain all the data that TAP needs.
Reason. TAP is text that a consumer must parse again, and it
has no standard place for coverage, console capture, or handle
data. Direct database writes from each test process are hard to
debug and make the processes wait on the database lock. An event
stream keeps the data typed from the test to the sink. It also
gives one extension point for reporters, as node-tap and
node:test do with their lower layers.
CI behavior. When the tool detects CI (assumption: the CI
environment variable, as most CI systems set it), stdout must be
enough to debug with no database. The CI text sink adds to the
compact output, for each failure:
- the captured console output of the test
- the full diff, not the short one
- the hang report, if the test timed out
- the seed, the runtime version, and the platform
It still prints nothing for a passing test. The run also writes the database file, so that a CI job can keep it as a build artifact and a developer can query it on a local machine.
Not decided. The transport from a child process to the runner (Node IPC, a pipe with JSON lines, a binary format, or worker thread messages). This needs a measurement.
D5. Coverage method: V8 precise coverage with counts (agent, 2026-11-11)#
The collector uses Profiler.startPreciseCoverage with
callCount: true, detailed: true, and takes a snapshot at each
test end.
Reason. It is the only V8 mode that gives correct data for each
test (03-measurements.md, section 2.1). It needs no source
transform.
Result. Tests in one process run one at a time, because a snapshot cannot split the coverage of two tests that overlap.
D6. Transport: JSON lines on fd 3 (agent, 2026-11-11)#
A child writes its events as JSON lines to file descriptor 3, with one sync write at each test end. Only the runner writes to the database. This settles the open point in D4 for Node and Bun. Deno needs a different method.
Reason. All methods have the same speed (03, section 6). A
sync write of plain text is the simplest one to debug, and it
cannot be lost after the write returns.
D7. Async tracking is off in a normal run (agent, 2026-11-11)#
Hang reports use the handle list and an inspector stack probe from
the runner. The async_hooks init hook runs only when the tool
runs a timed-out test again.
Reason. The init hook makes await-heavy code 5 times slower
(03, section 8).
D8. No result cache in the first version (agent, 2026-11-11)#
The tool does not skip a test because its inputs look unchanged.
--changed is the only mode that leaves tests out, and the user
asks for it by name.
Reason. A wrong cache hit reports a pass that did not happen. That is the worst outcome under D3.
D9. One process for each test file (user, 2026-11-11)#
This is the only isolation mode in the first version. It is the same choice that tap made. Worker threads and shared processes are faster and isolate less. They can come later as options.
D10. Bun support through source counters (user, 2026-11-11)#
Bun is a required platform. It grows fast in agent workflows. Bun
has no usable API for per-test coverage, so the Bun collector adds
counters to the source (04-architecture.md, section 4). When that
works, a separate project can send patches to Bun for a real API.
D11. The speed goal (user, 2026-11-11)#
Go as fast as possible, so that CI timeouts stop and the edit-test
loop feels immediate. bun test is a very high bar, and it is
acceptable not to reach it.
D12. Minimum runtime versions (user, 2026-11-11)#
Set the minimum version that the wanted features need. Do not give up a feature to support an old version. The current minimum is Node 24.
D13. Name and package shape (user, 2026-11-11)#
The package is named ubqtest. The executable is named ubq
(each letter of tap plus 1). The user changed the package name
from ubq on 2026-11-11, because ubq is too close to the names
of existing packages. Test files import from 'ubqtest'. The state
folder is .ubq/. The source can have as many logical parts as it
needs. The end state is one package, a small bundle, and no
dependencies.
D14. Hang reruns are a switch, on by default (user and agent, 2026-11-11)#
The user asked for a config switch and left the default to the agent. The default is on: the runner runs a timed-out test again with async tracking, up to 3 tests for each run.
Reason for the default. D3: the tool must not depend on the agent to remember a step. The extra wait applies only to a run that already has a timeout failure.
D15. No t object, free assertion functions (user and agent, 2026-11-11)#
Test files import test, describe, and assert. The test
function receives only { signal }. See 05-api.md.
Reason. Tap had t to carry test state before the language had
promises. AsyncLocalStorage does that job now. Imported assertion
functions can narrow types, and methods of a callback argument
cannot.
D16. No subtests inside a test (user and agent, 2026-11-11)#
describe is the only grouping. It adds a name prefix and options.
Reason. Subtests help humans, but they lead coding agents to overcomplicate tests, most of all when the context window is full.
D17. Zero assertions is a failure, with no opt-out (user, 2026-11-11)#
Reason. It removes a full class of uncertainty and of wrong passes.
D18. testdir folders stay after a failure (user, 2026-11-11)#
A testdir folder is removed when its test passes. It stays when
the test fails. Its path comes from the test ID, under
.ubq/testdir/, so that one .gitignore line covers it and an
agent can predict it.
D19. One name pattern for test files (agent, 2026-11-11)#
A test file has a name that ends in .test.ts, .test.mts,
.test.cts, .test.tsx, .test.js, .test.mjs, .test.cjs, or
.test.jsx. ubq with no arguments runs all such files in the
project, outside node_modules and outside folders whose names
start with a dot. A file of any name runs if it is an argument.
Reason. One rule (D2). .test. is the name that Vitest, Jest,
and Bun users already write. A folder rule (test/) would also
match helper files and fixtures.
D20. The run number is in the summary line (agent, 2026-11-11)#
Runs have numbers that count up in each project store: (run 42).
A number is short to type in ubq failures 42.
D21. Parser for source counters: acorn with a TypeScript plugin (agent, 2026-11-11)#
The instrumenter uses acorn and @sveltejs/acorn-typescript.
Both are development dependencies. The build puts them into
dist/bun-preload.js, so the package has no dependencies at
install time (D13). Only a Bun test process loads that file.
Reason. The counters go into the original TypeScript text, and Bun compiles the result. This needs positions in the original source and no code generator. Parsers that are native modules cannot go into one bundle.
D22. The instrumenter is separate from Bun (agent, 2026-11-11)#
src/instrument.ts takes source text and returns changed text and
regions. It knows nothing about Bun. Only src/bun-preload.ts uses
the Bun plugin API.
Reason. Source counters could replace V8 snapshots on Node
later (04-architecture.md, section 12). A snapshot of counters
costs microseconds, and a V8 snapshot costs 0.43 ms for each test.
D23. A folder with its own package.json is a different project (user and agent, 2026-11-12)#
The search for test files does not go into a folder that has its
own package.json. At the root of a monorepo, ubq runs only the
tests of the root.
Reason. ubq already takes the nearest package.json as the
project, and each project has its own store. With no rule, a
repository with experiments, fixtures, or packages runs tests that
are not its own. This repository had that fault.
D24. Configuration is the ubq key of package.json (user, 2026-11-12)#
As little configuration as possible. The keys are include,
exclude, and timeout (08-config.md). There is no config file.
Reason. package.json is already the mark of a project for
package managers, and ubq reads it in any case. Some users do not
like tool fields in package.json. That is acceptable if the
field is small and only projects with special layouts need it.
D25. Source counters on Node, with V8 as the reference (user and agent, 2026-11-12)#
Node gets source counters in three steps:
- Build the cache and a Node preload. Done:
ubqtest/counters-preload. - A test in this repository compares the answers of counters and
V8 on a fixture project, and fails on a difference that is not
in its list of accepted ones. Done:
test/counters.test.ts. - Switch the runner to counters. Done (2026-11-12): the user
chose to switch now, because the cost of V8 coverage is high
and a switch back is possible.
UBQ_COVERAGE=v8makes the runner use the V8 collector.
V8 stays the reference in test/counters.test.ts. A plain node file.ts has no preload, so it stays on V8.
Reason. Counters are faster (03-measurements.md, section
12). The user's experience with nyc and istanbul was that
source counters were flaky. A new instrumenter must earn trust
against a method that is known to be correct.
D26. --baseline hides failures in the text, not in the result (agent, 2026-11-12)#
ubq --baseline <commit> runs the tests of the commit in a clean
checkout, and then the tests of the working tree. The text shows
a failure block only for a failure that the commit does not have.
The other failures are a list of test IDs. The summary line, the
exit code, and the store have all the failures.
The checkout is a git worktree in .ubq/baseline/ of the project.
It is removed after the run. The baseline runs each time: its
results are not kept for the next command.
Reason. A test that fails is a failure, also when it failed
before (D2). An exit code of 0 for "no new failures" would let an
agent stop while the suite is red. The line baseline ...: 0 new failures is the answer to "did I break this?". The checkout is in
the project so that its code finds the node_modules of the
project, with no install step and no links. No kept results, for
the reason that 04-architecture.md, section 6, gives for the
result cache: a kept result that is wrong hides a difference.
The user accepted these points (2026-11-12).
D27. A run record has the git commit, not the state of the tree (user, 2026-11-12)#
Each run record has the hash of the commit that is checked out.
ubq reads it from the files of git, with no git process. The
record does not say if the tree had changes.
Reason. git status takes 5 ms or more, and the budget for a
run of one file is 58 ms. The digest of the loaded code
(test.code) already tells if two runs ran the same code.
D28. Test files run in a random order. Tests in a file do not (user, 2026-11-12)#
The runner starts the test files in a random order. The seed is in
the run record, and a run with a failure prints it. ubq --seed <n> gives the order of that seed. The tests of one file run in
the order of the file.
Reason. A fixed order of files hides a test file that depends on a different file. A random order of the tests in a file is a common source of confusion for people who read the file.
Result. The runner no longer starts the slowest files first.
D29. A test must put back the environment and the working directory (user, 2026-11-12)#
A test that ends with a different process.env or a different
working directory fails (kind leak). ubq puts the old values
back, so that the next test is not affected. There is no check
for globals and no check for listeners.
Reason. The cost (0.1 to 0.25 ms for each test) is not material. Libraries set globals and add listeners at their first call, so a check for these would fail tests that did nothing wrong.
D30. The fake clock is an object that belongs to its test (user, 2026-11-12)#
fakeClock() returns a clock object for the current test. The
object has a method that ends it (restore), and it ends with the
test. It takes over all the timers and clocks that it can.
Reason. A test contains all that it needs (05-api.md,
section 2), and nothing needs a reset in the next test. A clock
that takes over only some functions gives code that is half in
fake time, which is hard to understand.
D31. Snapshots: assert.snapshot, one file for each test file, explicit update (user, 2026-11-12)#
assert.snapshot(value) compares with saved text. Each test file
has one snapshot file, as in tap. Only ubq --update with the
names of tests writes it.
Reason. The user's experience with tap: this design did not
have to change much. An update with no list of tests accepts
changes that nobody looked at (01, section 7).
The agent chose the place (ubq-snapshots/<test file path>.snap,
as tap-snapshots/ in tap) and the form of the file.
D32. The build makes the package in dist/, and the package is published from there (agent, 2026-11-12)#
npm run build writes all files of the published package to
dist/: the bundled and minified JavaScript, the type declarations
(types/), and a package.json whose exports and bin name the
built files. npm publish ./dist publishes it.
Reason. In this repository, ubqtest must resolve to the
source (src/index.ts), so that the tests run with no build. Node
does not strip types from a file in node_modules, so the
published package must name JavaScript files. One package.json
cannot do the two jobs. test/package.test.ts builds the package
into a new project and uses it there.
D33. Coverage on Deno is source counters only (agent, 2026-11-12)#
On Deno, the V8 collector is off. The runner gives each Deno test
process the counters preload. A plain deno run -A test/foo.ts
records coverage none, as on Bun.
Reason. Deno compiles TypeScript to JavaScript with different
positions, and V8 coverage on Deno has the positions of that
JavaScript. ubq uncovered printed wrong lines for a plain Deno
run of the coverage fixture ('A' (+2 lines) at src/calc.ts:5:12
in place of return 'fail' at 8:3). Counters have the positions
of the source on all runtimes.
D34. No worker threads and no shared processes. Order of the later work (user, 2026-11-12)#
ubq will not run test files in worker threads or in shared processes. One process for each test file stays the only mode (D9).
Reason. These modes have too many edge cases, and the goal is that the tool is correct with no setting. A mode that is faster and can leak state between files is against that goal.
The order of the later work is: per-test coverage on Bun, then mutation checks, then the background server.
D35. The mutation check: changes in the test process, exit code 0 (agent, 2026-11-12)#
ubq mutate <file> makes one small change to the file at a time,
runs the tests that ran the changed code, and lists each change
that no test failed for.
- The file on disk does not change. The load hook of each test
process makes the change (
UBQ_MUTANT). A check that stops in the middle leaves nothing to put back, and two agents in one project do not see changes of each other. - The check does not write runs to the store. A failure there is not a failure of the project.
- The exit code is 0, also with survivors. Some changes cannot
make a test fail, because the changed code does the same thing
(a change of
<to<=in a loop over an array with one more element at its end). An exit code of 1 would tell an agent to "correct" code that is correct. The summary line has the count. - A test file runs one change at a time. Two processes of one
test file can fail only because they run at the same time (a
port, a
testdirfolder). Different test files run at the same time. - The time limit of a test is 10 times its last time, and 1000 ms or more. A change can make a loop that does not end.
The command needs the name of a file. A check of all files of a project can take a long time, and the agent knows which file it changed.
D36. The background server gives out test processes. It does not run tests (agent, 2026-11-12)#
ubq server keeps test processes that are started, and prepares
the counters of files that change. The runner stays the process
that the user started. 11-server.md has the design.
Reason. The plan requires the same command, output, and exit
code with and without the server (01, section 13). If the
runner is the same code in the two cases, that is true by
construction. A server that runs the tests itself needs a runner
that can serve many requests in one process, with the working
directory, the environment, and the configuration of each
client. Each of those is a place for a difference.
The server does not watch files to run tests before the user asks. A result from before the command is a cached result, and D8 does not permit one.
The server does not start by itself.
D37. No MCP interface and no HTTP interface (agent, on a question from the user, 2026-11-12)#
The interface for agents is the command and its text. The reasons
are in 11-server.md. The user asked the agent to consider MCP
and HTTP, and can overrule this.
D38. Each run of a TypeScript project checks the types (user, 2026-11-12)#
A project is a TypeScript project if it has tsconfig.json in its
root and the typescript package. Each run of its tests also runs
tsc --noEmit --incremental, at the same time as the tests. There
is no option that turns the check on or off. A type error fails
the run with exit code 1, the same as a failed test.
The checker keeps its data for the next run in .ubq/types/.
Reason. A test tool that does not check types lets a wrong type through until a different tool finds it. The cost is small: TypeScript 7 checks this project in 40 ms with the data of the run before. A run of all files takes no more time. A run of one small file takes 82 ms, and 51 ms with no type check.
This is the answer to Q7.
D39. Bun coverage: an issue to Bun, and inspector coverage as an option (user, 2026-11-12)#
The answer to Q8 has two parts.
- The proposal of
10-bun-coverage-api.mdis an issue on the Bun project. The agent does not work on a patch for Bun until the Bun project accepts the issue. The user tells the agent when to look. "bunInspectorCoverage": truein theubqkey turns on inspector coverage for a test file that runs as a plain program on Bun. It is off by default, because it can record more coverage, but cannot be correct in all cases.
How part 2 works (src/bun-coverage.ts): the transpiler of Bun
with target: 'bun' makes almost the same JavaScript as the Bun
runtime. The collector matches the words of that JavaScript with
the words of the source, and changes each offset with the match.
What it gives, measured on Bun 1.4.3:
- Each file that a test ran is in the store, so
--changedis correct. - Functions with a branch get ranges, on the correct line in the samples.
- A function with no branch can be absent.
- The blocks of class methods got places in different functions of the same file.
- A
.tsxor.jsxfile gets only a mark that it ran. - Code that never ran is not listed, so
ubq uncoveredis not complete. The mode of the test file ispartial. - With
NODE_COMPILE_CACHEset, Bun gives no ranges for code that comes from the cache, so the second run records nothing.
Under the runner, all coverage comes from counters, with or without the option.
D40. JSX files get counters: in the source on Bun, in the compiled JavaScript on Deno (agent, on a request from the user, 2026-11-12)#
The user asked for coverage of .tsx and .jsx files with no
setup, and permitted experiments.
Bun. The parser of ubq has a JSX mode. The counters go into
the JSX source, as for a .ts file, and Bun compiles the result.
A counter is an expression, and each expression of JSX is in
{...}, so no new rule was necessary.
Deno. Deno compiles a file before a load hook sees it, and the hook must return JavaScript. Three options:
- Counters in the source, then a JSX compiler in ubq. ubq has none, and one in the package is a large dependency.
- Counters in the JavaScript of Deno, and the inline source map of Deno for the places. Chosen. It needs a reader for source maps (60 lines) and no dependency.
- No coverage for JSX on Deno.
Option 2 is less exact than counters in the source. A source map has the start of each token, not the end of a region. So a region ends at the end of its last token that is in the map. The lines were correct in the checks.
A mutation check needs a change in the source before the compile
step, so ubq mutate of a JSX file on Deno is an error.
Node does not run JSX. A project can add a load hook that compiles it. Option 2 could work there too, if that hook gives an inline source map. This is not built and not tried.
D41. Commands that work on a change: --changed by range, and --diff (agent, on a request from the user, 2026-11-12)#
ubq --changed selects tests, not test files. The store keeps
the content of each file that has coverage, under its hash
(.ubq/sources/). --changed finds the part of a file that is
different (the text between the equal start and the equal end of
the old and the new content), and selects the tests that ran a
range in that part.
- A test file runs all its tests if the changed code ran while
the file loaded, if the test file changed, or if its coverage
is not
full. - A test that did not run the changed part does not run. Its
coverage in the store gets the places of the new content, so
ubq covers,ubq uncovered, andubq mutatestay correct. - Two changes in one file give one part, from the first change to the last. That selects more tests, never fewer.
Reason. A test that ran none of the changed code takes the same path through the same code, so its result is the same. The type check of the run finds a change that is only in types.
--diff is the difference from a git commit. ubq uncovered --diff and ubq mutate --diff use the lines of git diff -U0,
and all lines of a file that git does not have. --diff is
HEAD. --diff=<commit> names a different commit. These
commands start git. A run of tests does not.
ubq uncovered --diff also lists each changed file with code that
no test loads. Its exit code is 0, as for ubq uncovered and
ubq mutate (D35).
D42. An import of ./a.js finds ./a.ts (agent, 2026-11-12)#
Much TypeScript source imports ./a.js for the file ./a.ts,
because that is the name after a build. Bun finds the file. Node
and Deno do not. The preload of the runner has a resolve hook: if
a relative import that ends in .js, .mjs, .cjs, or .jsx
names no file, the hook tries .ts (then .tsx), .mts, .cts,
or .tsx.
Reason. A project that moves to ubq must not have to change each import of its source first (D24: as little configuration as possible, and the tool guesses correctly).
A test file that runs as a plain program on Node (node test/a.test.ts) has no preload, so it needs the .ts names.
D43. Conversion from a different test tool: a command, a guide, and a skill (agent, on a request from the user, 2026-11-12)#
Three parts, each for a different kind of work:
ubq convert <files or folders>makes the changes that are certain: imports,ittotest, assertions with one form in ubq,t.end(),awaitforrejects. It knows tap,node:testwithnode:assert, vitest, jest, and mocha withnode:assert. For each statement that needs a decision, it writes a// TODO(ubq): ...comment with the instruction, and lists it in its report. Source:src/convert.ts.ubq guide convert(CONVERT.md) has the steps of a conversion, the tables of forms, the cases that need a decision (hooks, subtests, module mocks, loose comparisons), and a table of the failures that come after a conversion, with the cause of each.- The
use-ubqskill (skills/use-ubq/SKILL.md, andubq skillto copy it into a project) tells an agent to read the two guides of the installed package, and gives the order of the work.ubq skillwrites to each.<name>/skillsfolder that the project has, and to the folder of the agent that runs the command, which it knows from the variables that the agent sets (the list of pnpm). With none, it writes to.claude/skills.ubq skill <agent or folder>writes only there (user, 2026-11-13).
Reason. A program does the certain changes faster and with fewer errors than an agent. An agent does the changes that need the sense of the test. The guide is in the package, so it is correct for the installed version, and an agent with no skill system can read it with one command.
The command changes files in place and does not make a copy: the project is in git. It reads the result with the parser before it writes, so it does not write text that is not code.
Not built: chai, sinon, ava, tape, jasmine.
D44. The web site is a static build from the documents (agent, 2026-11-12)#
npm run site (scripts/site.ts) makes the site in _site/.
The folder is not in git.
- The home page is
site/home.html. The style is insite/static/. It follows node-tap.org: a light gray ground, a white bar at the top, rings behind the title, round buttons, and the same heading font. - Each document page comes from a markdown file of the
repository. The list is
PAGESin the script. A new document needs one row there. - Each page has a markdown copy at
<page>.md.llms.txtis the index for agents.llms-full.txthas the user documents in one file. - Each link is relative, so the site works at any URL.
- In the HTML, a path in code text is a link. A document of the site goes to its page. A different file or folder that git tracks goes to the repository on GitHub.
- In the HTML, the number of a decision (D14) is a link to the heading of that decision. Not in a heading, and not in code text.
- The page has no script.
markedmakes the HTML. The colors of code come from a small function in the script.
Reason. The documents are already the source of ubq guide
and the README. A site that is made from the same files cannot
say something different. An agent reads markdown with fewer
tokens than HTML.
Not built: search. The upload to a host is D46.
The site is at https://ubq.dev. Each page has that address in
its link preview tags (og:url, og:image), because a preview
needs full URLs. The image is site/static/logo-1200x630.png
(the middle of logo-1200x1200.png, in the shape of a wide
card). All other links of the site stay relative.
D45. Front-end tests: Playwright with Chromium, one function (user and agent, 2026-11-12)#
import { browser } from 'ubqtest/browser'. await browser()
gives a Page of Playwright in a new context. The answers of the
user to Q9 set points 2, 3, and 5.
- Driver: Playwright (
playwright-core), with the Chromium headless shell. Agents know its API, so they guess less. It works on Node, Bun, and Deno (03-measurements.md, section 13). It is an optional peer dependency: the browser is a 100 MB download for each system, too large for the package. - The user says what runs.
ubqhas no opinion on the server. With no configuration,page.goto('/path')loads a file of the project from the disk, through a route of Playwright athttp://project.localhost/. A page from afile:address cannot load a module script, and this address can. Withbrowser.url, the path is from that URL. Withbrowser.command, the runner starts the server if a test file of the run uses the browser and nothing answers at the URL. - The download is a command:
ubq browser install. A test run does not download. The version of the browser is the version that theplaywright-coreof the project needs.PLAYWRIGHT_BROWSERS_PATHsets the folder (a variable of Playwright).UBQ_BROWSERis the path of a program to use in place of the download. - A browser for each test file, a context for each test. The
browser is a separate server process (
launchServerof Playwright). The cleanup functions of the file kill it. On Windows, on a machine with much work, Playwright took more than 5 seconds to stop the browser and remove its folder, and ubq then reported a cleanup that did not end. So ubq kills the process itself first, and that cleanup has a limit of 60 seconds. - The API is the
Pageof Playwright, plus one assertion.assert.eventually(fn, pattern)isassert.matchfor a value that changes with time: it callsfnuntil the value matches, for 2000 ms at most. It is in the core API, because it is also correct for other async code. ubq adds no names for the page. - An error that the page does not catch fails the test. A
console.errorof the page does not: a test of an error path gets one from the browser for each failed request. - An action has a limit of 2000 ms, not the 30 seconds of Playwright, so the failure comes before the time limit of the test and names the element.
Coverage of code that runs in the browser is D47. Not built: Firefox and WebKit. A compile step for TypeScript that a static page loads.
A fault of Deno 2.9.6 that this work found: with a load hook, a
JSON file that require loads has no format, and Deno reads it as
JavaScript. The load hook of the counters sets the format.
D46. The site is a Cloudflare Worker, deployed by a workflow that the maintainer starts (user, 2026-11-12)#
The host is a Cloudflare Worker with only static files.
wrangler.jsonc names the Worker (ubqtest-site) and the folder
_site/. A missing address gets 404.html with status 404.
.github/workflows/site.yml makes the site and runs wrangler deploy. It starts only by hand (gh workflow run site.yml). Its
environment is site, with the secrets CLOUDFLARE_API_TOKEN and
CLOUDFLARE_ACCOUNT_ID.
The site and npm. The release workflow does not publish: a
maintainer approves the staged version later. A deploy in that
workflow would put the site ahead of npm. So the deploy is a
separate step of the release, after the approval. With no input,
the workflow reads the version that npm has and makes the site
from the tag of that version. The input ref names a different
tag or branch.
Checked with wrangler deploy --dry-run and wrangler dev: the
pages, the markdown copies (text/markdown), llms.txt, and the
404 page. A real deploy is not tested.
D47. Coverage of code that runs in a page (user and agent, 2026-11-12)#
The Page that browser() gives records coverage in Chromium
(page.coverage of Playwright). When the test ends, the ranges of
each script go into the coverage event of the test, with the
ranges of the test process. The store and the commands do not
know the difference.
- The file of a script is the file at the path of its URL,
from the project root. The URL must start with
browser.url, or be a file that ubq gave to the page. - The script is the file: the positions are the same.
- A server made the script from the file: the script must end
with a source map that has one source.
src/source-map.tschanges the ranges to positions of the file. This is the case of a development server that compiles each module (Vite). - No coverage: a bundle of many files, a script with a source
map in a separate file, a script in the text of the HTML, a
file in
node_modules, and a page that the test did not get frombrowser()(a popup). - If the test process and a page run the same file, the counts
are added (
mergeRangesinsrc/coverage.ts).
Reason. A browser test with no coverage cannot be selected by
--changed, and uncovered lists all front-end code. The data
is there in Chromium at almost no cost.
A defect that this work found: under ubq on Node, a function
that a test gave to page.evaluate had counters of the test file
in its text, and failed in the page (__ubqc is not defined).
Each page now has an object with that name that keeps nothing.
D48. Tests of a Cloudflare Worker run in workerd (user and agent, 2026-11-13)#
A test file that the workers key of the configuration selects
runs in workerd, the runtime of Workers, with the local bindings
of a wrangler config. The user decided the four points of Q10:
the config key, a small ubqtest/workers module with new names,
no preload key, and a first version with known limits.
- The config key:
"workers": [{ "config": "wrangler.jsonc", "include": ["test/**/*.test.ts"] }]. ubq does not find Workers tests from the text of a file: a test file that imports only source can need workerd too. - The process: the runner starts
node workers-host.js <test file>in place of the test file (src/workers-host.ts). The host is the test process for the runner: the same variables, the same event pipe, the same process group. It always runs on Node, also if the runner is Bun or Deno, because wrangler needs Node. One host and one workerd for each test file, so each file has its own storage. - The bindings: from
unstable_getMiniflareWorkerOptionsof thewranglerpackage of the project, given tominiflare, which wrangler depends on.wrangleris an optional peer dependency of ubqtest. - Modules: workerd asks the host for each module that it does
not have (the module fallback service). The host reads the
file, adds the source counters, and removes the types. It finds
files as a bundler does (
./aand./a.jsfora.ts), and packages with the conditionsworkerd,worker,browser. Thealiaskey of the wrangler config applies. There is no bundle step. - A CommonJS file is two modules. workerd gives a CommonJS
module only a default export, and many ES module packages import
names from a CommonJS package (
13-agent-feedback-0.0.4.md, item 1).importof the file gets an ES module with the named exports, whichcjs-module-lexerfinds as Node does. That module imports the file itself, which has a second name (<path>.ubq.cjs).requiregets the file itself. The default export ismodule.exports, as on Node. - Vars: the
varsof the wrangler config, thenvarsof theworkersentry. Not.dev.varsand not.env: wrangler reads them forwrangler dev, and they can have real secrets. - A service binding to a Worker that is not in the run gets a function that answers 503. With no such function, workerd does not start.
- Containers are off (
enableContainers: false): a container needs an image, and a test run builds none. - The binding for events (
__UBQ_HOST) is removed fromenvbefore the test file loads, so the tests see only the bindings of the Worker. - The main module of the Worker is an export of the module that workerd starts, so the classes that the config names (Durable Objects, Workflows) exist.
- The test API is the same code (
src/index.ts). A global (src/workerd.ts) tells it that it is in workerd. It gets the project root and the configuration from the host, because workerd has no file system. - The body of the test file runs in a function. workerd does
not let a module do I/O while it loads, and a test file does
that at its top level (
await migrate('DB')). The host puts the code after the lastimportof the test file in a function, with no change to line numbers, and the entry module calls it in its request. A test file with anexportis not changed. - Events go from workerd to the host in a request that workerd waits for, before each test starts and when the file ends. So the runner knows that a test started before the test can block the thread. A response stream did not give that.
- Coverage: workerd sends the counts. The host has the regions, and makes the ranges.
ubqtest/workers:executionContext()(withsettled()),scheduledController(options), andmigrate(binding).envis the export ofcloudflare:workers, not of ubq.ubqadds thenodejs_compatflag, because the test API uses modules of Node.
Limits of the first version (the user accepted them as known
gaps): testdir, mockImport, and assert.snapshot throw in
workerd. A timeout has no list of open things and no hang rerun.
A stuck test has no stack. No JSX files. A CommonJS package gives
only its default export (corrected after the first report: see
"A CommonJS file is two modules"). No SELF fetcher, and no
storage for each test. A module that the test file imports
cannot do I/O when it loads: workerd does not permit it, also
after an await and also for a promise that the request
resolves. fakeClock does not replace the names of
node:timers.
Reason. The report in 12-agent-feedback-0.0.3.md: a Workers
project could not convert. Workers are a common place to run
code, and the tests of a Worker are worth most in the real
runtime: they find the
differences from Node. A run on Node with stubs does not. The
trial (03-measurements.md, section 14) showed that workerd can
load the files and run the test API with little new code.
Not chosen: a preload key for a Node hook that maps
cloudflare:workers to a stub (the tests would run on Node);
the name cloudflare:test answered by ubq (it would promise the
whole API of the vitest plugin); code that runs through an eval
binding, as the vitest plugin does (more code, and positions that
need maps).
D49. The type check covers each tsconfig of the run, and a selection fails only for its own files (user, 2026-11-13)#
Two changes to D38, from three reports of one conversion
(14-agent-feedback-0.0.5.md, item 6). The user chose both from
Q11.
- Each config. The runner starts the checker for the
tsconfig.jsonof the project root, and for eachtsconfig.jsonin a folder from a test file of the run up to the root. The checkers run at the same time. An error that two of them report shows one time. Each config keeps its own data in.ubq/types/. - A selection. A run is a selection if the command names
files, lines, or IDs, or has
--changedor--failed. Such a run reports the type errors of the files that it used: its test files, and the files in its coverage events. An error with no file is always reported. The others are one line with their count (otherTypesin therun-endevent). They are not events, they are not in the store, and they do not change the exit code. A run of all tests does not change: each type error fails it.
Reason. With one config, ubq said that a project had 12 type
errors when it had 17: the second Worker has its own config. An
agent that trusts the last line then reports a clean result that
is not clean. And in a run of one file, the errors of files that
the agent has not reached yet hid the result of the file, and
made each run end with fail.
Cost. A file that only has types (no code that a test runs) is not in the coverage of a run. Its errors show in a selection only through the files that use those types, and in a run of all tests.
D50. isDeepEqual is an export (user, 2026-11-13)#
isDeepEqual(a, b) gives true or false by the rules of
assert.deepEqual. It is not an assertion, and it does not count
as one in a test.
Reason. "The list has an element that equals this value" had
no form in ubq (toContainEqual of jest and vitest). Three
reports asked for it. One name covers that case, not, and
arrayContaining with objects:
assert(list.some(item => isDeepEqual(item, expected))). ubq convert writes that line.
Not chosen: a form of assert.match for an element of an
array. It covers one case, and it changes what a pattern means.
D51. A test helper is not code of the program (user and agent, 2026-11-13)#
A test helper is a file that is part of the tests and is not a test file: a setup module, or the scenario of an end-to-end test that starts the environment for the tests.
- Which files. With no configuration: each file in a folder
with the name
test,tests, or__tests__, at each depth. Thehelperskey of the configuration is a list of glob patterns that replaces this rule. An empty list means that no file is a helper. ubq uncoveredleaves helpers out, also with--diff. The summary line has their count. A file that the command names is in the list in each case.ubq uncoveredexits with 1 if it lists code, or a changed file that no test loads. A project can use it as the check for full coverage.ubq mutate --diffdoes not change a helper.ubq mutate <file>with a helper as the argument does.- No change: coverage of a helper is recorded.
ubq --changedruns the tests that loaded a helper that changed, andubq coversanswers for its lines.
Reason. A project that wants full coverage reads the list of
ubq uncovered as work to do. With helpers in the list, an agent
writes tests for the tests, and then tests for those. The user
had to stop an agent in that loop. The scenario of an end-to-end
test is part of the test, and its coverage has no use.
Why a default. The agents that go into that loop do not know
that a key exists. The folder names cover the common layout. A
project with code of the program in a folder named test sets
the key.
Not chosen: to find helpers from the code (a file that
imports ubqtest, or a file that only test files import). A
setup file often imports nothing from ubq, and ubq has no import
graph.
D52. The files of ubq are project files in the repository of ubq (user, 2026-11-13)#
The collectors left out each file in the folder of the library.
That rule is gone. A file of ubq is not a project file for a
user: it is in node_modules, or outside the project root
through a link, and both were left out before this rule. In the
repository of ubq the files are in the root, so they have
coverage now. This is the rule of tap: its own files count only
in its own project.
Failure reports still leave out the stack frames of the library,
in each project (src/stack.ts).
The parser is a project file here, and the load hook gets it
while the hook loads the parser. For that time the hook gives no
counters (loadingParser in src/counter-collector.ts).
Reason. ubq must be able to check its own tests with ubq uncovered. The rule did not protect a user from the sources of
ubq: the two older rules do that.
Not complete. Most of ubq runs in child processes of its
tests (03-measurements.md, section 15). That needs coverage of
child processes.
D53. A test has the coverage of its child processes (user and agent, 2026-11-13)#
The code of the project that runs in a process below a test
process is coverage of the test that runs then. The user decided
that this is on in each run, with no setting: with it off, ubq --changed and ubq uncovered give wrong answers for a project
whose tests start a command or a server, and many do.
- The method: source counters, through a preload module
(
src/child-preload.ts) that each child loads fromNODE_OPTIONS(--import) orBUN_OPTIONS(--preload). Not V8 dumps (NODE_V8_COVERAGE): they slow all code of the process, 2.5 to 14 times, and each process writes 100 to 370 kB (03-measurements.md, section 15). The counters cost 4 ms for each child process, and nothing after that for code that is not of the project. - The environment: a test process with source counters
(
announceinsrc/child-coverage.ts) sets the two option variables andUBQ_CHILD_COVERAGE: a JSON list of pairs, the root of a project and a folder for the counts (.ubq/children/<pid of the test process>). - A child with a new environment: on Node, the module changes
ChildProcess.prototype.spawnand the three sync spawn functions, so that each environment gets the variables. Without this, such a child has no coverage, and nothing says so. tap does the same. On Bun and Deno the functions do not have that form, so only a child that gets the environment of its parent counts. - What a child does: it adds counters to the files of each project of the list (a load hook on Node, a plugin on Bun), and writes its total counts to the folder: when it exits, when it gets SIGTERM, SIGINT, or SIGHUP, and each 200 ms. For a signal, it writes and then ends with the same signal. If the program has a handler of its own, the module does nothing.
- What the test process does: when a test ends, and when the
file ends, it reads the folder (
src/child-read.ts), takes the difference from the last read, and adds the ranges to thecoverageevent, as for a page of a browser (D47). The regions of a file come from the cache entry that the child made. - A list of projects, because a test of one project can start
the tests of a second one. That is each test of ubq that runs
ubqin a fixture project. The nested processes count the files of the fixture for their own runner, and the files of ubq for the outer test. The two sets have different keys. A file that the collector of a test process has gets no second set of counters.
Limits.
- A child on Deno has no coverage: see "Deno children" below.
- SIGKILL, and each kill on Windows, give no notice. The counts since the last write (200 ms at most) are lost.
- Counts that arrive after a test ended go to the next test, or to the file.
- A Node that is too old for
module.registerHooksruns with no counters. - The columns of an error stack that a child prints are those of the code with counters.
- A statement after a call of
process.exitin the same block counts as run if the block ran. - The folders in
.ubq/children/of a test process that was killed stay.
Deno children: a path forward. Deno has --preload <file> as
an argument, and no variable for it. The spawn functions that this
module changes can add the argument: if the program of the child
is deno (or its full path) and the first argument is run,
test, or serve, put --preload <file> after that argument.
This fails for a script that starts Deno through a shell or a
wrapper, and it must not run for other subcommands. The collector
in the child is the load hook of Node, which Deno has. A variable
of Deno for a preload module would be the complete answer.
Coverage of ubq itself. With D52 and this, ubq uncovered in
the repository of ubq sees 50 of the 53 source files. It cannot
see code that runs only on a different runtime (the Bun preload,
the module that workerd runs). The plan for that is coverage from
more than one run, merged: see 07-status.md.
D54. Coverage from more than one environment, in one store (user and agent, 2026-11-13)#
An environment is a runtime and a system: node-linux,
bun-darwin, deno-win32. One run in one environment cannot run
each line of a project that has code for more than one. The store
keeps the coverage of each, and the commands that list coverage
read all of it.
- The table
coveragedoes not change: the last run of each test, whichubq --changedneeds. - The table
other_coveragehas the coverage of a test from a different environment, with the content hash of each file.- A run in a new environment replaces the rows of a test in
coverage. The old rows go toother_coveragefirst, with their environment. - A run removes the rows of its own environment from
other_coveragefor the tests that it ran: they are older. ubq merge <store>...copies thecoveragerows and theother_coveragerows of other stores there.
- A run in a new environment replaces the rows of a test in
ubq uncoveredandubq coversreadcoverageand the rows ofother_coveragewhose hash is that of the file as it is now. A file with no row that counts gets a line that says so, and exit code 1.ubq mutateselects tests with the same rows. A test of a different environment can be in the selection.
Reason. The user wants ubq to check its own tests with no code marked as not measured. ubq has code for Bun, for Deno, and for Windows. A project with a branch for each system has the same need.
Why a second table, and not a column. ubq --changed reads
coverage with one row for each test and file, and moves the
ranges when a file changes. Rows of other environments there
would change that logic. Old rows of other environments are
safe only if they are read with a hash check, which the second
table does.
In CI of ubq: each job keeps .ubq/runs.db as an artifact.
The job coverage merges them and prints the list. It does not
fail for the list yet.
Limits.
- Coverage of a file from a different environment is lost when the file changes, until the tests run there again.
- A store of a different ubq version is refused.
- The systems must give a file the same bytes. A Windows checkout
with CRLF line ends has other hashes. This repository has
eol=lfin.gitattributes.
D55. Build output is not source, and a source map gives its coverage to the source (user, 2026-11-13)#
Two rules for a project that has built files in its root.
1. The build key. A list of glob patterns for files that are
not source of the program: the output of a build, and the tools
that make it. With no key: dist/** and scripts/**, the common
names. ubq uncovered leaves these files out and has their count
in its summary. ubq mutate --diff does not change them. The
collectors still count them: ubq --changed needs that.
2. A built file with a source map. If a project file that a
process loads has a sourceMappingURL line (a map at its end, or
a file next to it), and the map names source files of the project,
the counters of the built file count for those source files
(builtSources in src/source-map.ts). The run then has no
coverage of the built file itself.
- The regions are those of the source file, from the parser, and not those of the built file. A bundler removes code that nothing uses. Such code is code that no test ran, and it must be in the list.
- Which counter counts for which region: a counter of the built file counts for the smallest region of the source that has the place of its first token. The module of a source ran if the built file has code of it.
- The result is the same for a test that loads the source, a
test that loads a bundle, and a test that loads a bundle with
minify(checked intest/built.test.ts). - A source that changed after the build: the map has the hash
of each source text. If a source file does not have that
content now, the run has no coverage of that file, and the test
file has coverage
partial. Wrong places would be worse than no places. The user accepted this limit: build again before the tests run. - The cache entry of a built file has the regions of its sources, so a child process and the test process that reads its counts agree. The text of the map is part of the cache key.
Reason. In CI of ubq, the Deno jobs run the built runner, and
dist/instrument.js was in the merged list of ubq uncovered.
Each project with a build folder in its root has the same
problem. Most coverage tools use the source map, and with it the
choice between tests of src and tests of dist does not change
the coverage.
For the tests of ubq: scripts/build.ts makes source maps if
UBQ_SOURCEMAP is set, and CI sets it. The published package has
no maps.
How an agent finds the way to merge runs (the user asked):
ubq uncovered prints a note if a range is on a line that names
a runtime or a system. The note names the section of the guide,
which has the commands for one machine and a workflow for CI. The
skill says not to fake the platform in a test.
Later changes to D53 (2026-11-13).
- Deno children have coverage. On Node, the spawn functions
add
--preload=<module>after the subcommand, if the program isdenoand the subcommand isrun,test, orserve. The module uses the load hook of Node, which Deno has (src/load-hook.ts). Not covered: a Deno that starts through a shell or a script, and a child of a test process that is Bun or Deno. From the source of ubq, Deno cannot load the parser, so a file gets counters there only if a Node process made its cache entry. The built package has no such limit. - A child writes its counts after the
exitlisteners of the program, and before them. A listener can run code of the project. - The files of ubq that workerd runs have counters in the repository of ubq, for the test process and for the test that started it.
- The package tests build the package in the repository, with
source maps (
.ubq/package-test-<pid>), and run its command from the real path. So the runner on Bun, on Deno, and from the built files is coverage ofsrc/.
D56. A comment marks code as not measured, with a reason (user and agent, 2026-11-13)#
The user agreed to a comment that marks code as not measured, for the ranges that stay after merged runs (D54) and tests.
- Form.
// ubq: not measured: <reason>, or the same text in/* */. A comment with no reason does not count: the reader of the code must see why no test runs it. - Place. A line with only the comment, above the range. Or the
first line of the range, or its second line: the range of a
block starts at
{, and the comment goes on the first line in the block. A comment at the end of a line does not mark the range on the next line. - Effect.
ubq uncovereddoes not list the range, and does not count it for its exit code. It prints how many ranges it left out. The store and the other commands do not change.
Reason. Some code cannot run in a test: a fallback for a value that is never absent, or, in the repository of ubq, the preload modules, which load before the hooks that add counters. With no mark, the list never gets to 0, and a real gap is hard to see in it. A count in the output keeps the marks visible.