Test queue and test selector reference
nwave-ai tests run runs one test command of a project through the test queue of
the account. nwave-ai tests select lists the tests a change selects. Both read
what the project declares; neither names a language, a test runner or a
version-control tool. The only requirement is Python 3.11 or later.
python3 -m nwave_ai.testrun ... is the same command without an installed
nwave-ai: it needs the interpreter and the nWave tree.
Synopsis
nwave-ai tests run [options] NAME [ARG ...]
nwave-ai tests run [options] -- COMMAND [ARG ...]
nwave-ai tests select --use NAME [options] [PATH ...]
| Option | Verb | Meaning |
|---|---|---|
--root DIR |
both | the project tree; default: the current directory |
--declaration FILE |
both | read the declaration from FILE instead of the tests block of .nwave/config.json |
--class NAME |
run | the class of the run; default: the declared command's class, else the last declared class |
--timeout SECONDS |
run | stop the command after this long; default: the declared command's limit, else none |
--receipt FILE |
run | also write the receipt to FILE |
--use NAME |
select | the declared use whose depth applies; required |
--add TEST |
select | also select this test; may be repeated |
--json |
select | print one JSON document instead of the list |
-h, --help |
both | print the usage |
nwave-ai tests run
NAME [ARG ...] runs a command the project declares, with the further arguments
appended. -- COMMAND [ARG ...] runs any command as given. The command inherits the
standard streams and the environment, and runs in the project tree.
The queue
- One queue per account:
<nWave home>/.nwave/test-queue/, where the nWave home isNWAVE_AGENTS_HOMEwhen set, else the account's home directory. Runs of every project and every worktree of the account wait in it. Two accounts on one machine have two queues and do not coordinate; neither do two nWave homes. - Order: the classes the project declares, the first class first; inside a class,
arrival order. A run of a later class that has waited
aging_secondsgoes before every run that has not, and nothing behind it starts first. - Places: the lowest of the declared ceiling
max_concurrent, the usable cores divided bycores_per_run, and the available memory divided bymemory_per_run_mb; never fewer than one. When cores or memory cannot be read on the platform, one run at a time. A running run of a project with a lower ceiling keeps the lower ceiling for as long as it runs. - The time limit starts when the command starts. The wait in the queue is measured apart and never counted in it. At the limit the command and every process it started are stopped.
- A run whose process dies leaves the queue: the next run that looks removes its ticket.
- A run whose process is alive keeps its place however long it runs: the queue
never stops a run. The wait for a place is bounded instead, by the declared
max_wait_seconds. A run that reaches it leaves the queue, runs nothing and endsindeterminatewith exit 75 (see below). - A run started inside a running run (the environment carries
NWAVE_TEST_QUEUE_TICKET) starts at once: it already holds a place.
A waiting run prints one line on standard error:
[nwave tests] waiting: ticket 12, class long, 1 running, 2 waiting; gives up after 1800 s
Each ticket of the queue directory (tickets/<n>.json) records the class of the
run, the process that holds it (pid), its command, when it arrived
(enqueued_at) and when it started (started_at).
A run that waits too long
When the wait reaches max_wait_seconds the run leaves the queue and prints, on
standard error, an INDETERMINATE: line and then WHAT:, WHY: and HOW::
INDETERMINATE: this run is neither a pass nor a failure
WHAT: the run waited 1800.0 s for a place, reached the limit queue.max_wait_seconds of 1800 s and did not start; ahead of it: ticket 12, pid 4242, running for 2710 s: <command>
WHY: no test ran, so this is neither a pass nor a failure; a run that never ends keeps its place, and the queue does not stop another run
HOW: if a run ahead is stuck, stop its process (pid 4242): its place is free as soon as that process is gone; if it is only long, declare a larger queue.max_wait_seconds; then run this command again
WHAT names each run ahead: its ticket, its process, whether it is running or
waiting and for how long, and its command on one line. The runs ahead are the
running runs; when nothing runs, the run at the head of the line. The run ahead is
not touched. The figures above are an example.
A run whose process is killed
A run does not start its command itself. It starts a supervisor process, in a session of its own and held by a pipe, and the supervisor starts the command in a process group of its own. When the pipe closes, the supervisor stops that group. The pipe closes when the command's time limit is reached, when the run is asked to end (interrupt, termination), and when the run's process disappears, whatever killed it and at whatever moment: before, while or after the command starts.
When the supervisor itself disappears, the run stops the command's group and
ends indeterminate with exit 75: the exit of the command is unknown, so it is
neither a pass nor a failure.
Limits of this:
- it needs process groups (POSIX). Elsewhere the run starts the command itself
and prints
NOTE: this platform has no process groups: if the process of this run is killed, its command keeps running; it prints the same note, with the reason, when the supervisor cannot be started; - a command that moves itself to another process group or session is not stopped;
- when the run and its supervisor are both killed, nothing stops the command;
- the place of the killed run is free as soon as its process is gone, which can be an instant before its command has ended.
The receipt
Every run ends with one line on standard error, [nwave tests] receipt: {...}, and
the same JSON document in the --receipt file when one is named.
| Field | Meaning |
|---|---|
schema |
version of the receipt, 1 |
ticket |
the number of the run in the queue; for a nested run, its parent's |
class |
the class the run waited in |
root |
the project tree, resolved |
command |
the exact command, word by word |
candidate |
the identity of what was tested, one entry per declared reader; a reader that failed gives {"unavailable": "<reason>"} and a NOTE line; long or multi-line output is recorded as its SHA-256 |
outcome |
completed, timed-out, not-started, refused or indeterminate |
exit |
the command's exit code; null unless completed |
wait_seconds |
time spent waiting for a place |
duration_seconds |
time the command ran |
time_limit_seconds |
the limit applied, or null |
peak_memory_kb |
the largest resident set of the command's process, or of a process it waited for, as the system reports it for a finished child; it is not the sum over the processes of the command. null with peak_memory_note where the platform reports none |
events |
the queue's numbers of arrival, start and end: enqueued < started < finished, comparable across runs; null for a run that never started |
concurrency |
the number of places the run was admitted under |
nested |
true when the run started inside a running run |
refusal |
what, why, how when the run was refused, could not start or left the queue at its wait limit |
Exit codes
| Code | Meaning |
|---|---|
| the command's own | the command ended; a command ended by signal N gives 128 + N |
2 |
the run was refused: nothing ran |
75 |
the run left the queue at its wait limit: nothing ran, nothing was judged (indeterminate) |
124 |
the time limit ended the command |
127 |
the command could not be started |
Refusals
A refusal prints three lines, WHAT:, WHY: and HOW:, and runs nothing. The run is
refused when the declaration is malformed (every defect is named at once), when the
class or the named command is not declared, when the time limit is not a positive
number, when the queue directory cannot be made or locked, and when the tool under
test resolves outside the tree:
- the project declares
tool_resolution.argv, a command that prints the path or paths the tool under test resolves to; - a path outside the project tree refuses the run (the known case: an editable
installation that points at another checkout), and
HOWcarries the declaredrepair; - a check that cannot run, exits non-zero or prints nothing also refuses: a check that cannot decide does not count as a pass.
nwave-ai tests select
Prints the selected test files, sorted, one per line. The list is a function of the
changed paths, the declaration and the tree: the same change gives the same list, in
any order of the paths and with any repetition. --add joins tests to the list;
nothing removes one.
Rules, per changed path; a path may match several, and their union decides it:
- a test file selects itself;
- another file inside a test root selects the tests of its directory and below;
- a path under a declared
assetsprefix selects the tests of the mapped directories; - a path the
dependentscommand covers selects the tests that depend on it, through any number of files, up to the depth of the use; - a path under a declared
no_testsprefix selects nothing, by declaration; - a path no rule decides makes the selection INDETERMINATE.
The depth of a use bounds the dependency hops from a changed file outside the test
roots: 1 is the files that depend on it directly. A changed file inside a test
root is followed to every test that depends on it.
Standard error carries what the list alone does not say:
INDETERMINATE: <path>: <reason>for each undecided path. The list is then the widest group the project declares (widest), united with what the decided paths reach. With nowidestdeclared the selection is refused.NOTE: no test depends on <path>for a decided path that reaches no test.OVER LOCAL CEILING: ...when the list is longer thanlocal_ceiling: the list is complete and is meant for the place that runs the wide groups, not for the machine.
--json prints outcome (selected or indeterminate), use, depth, tests,
reached (what the decided paths reach, with the added tests), added,
undecided (path, reason), uncovered, local_ceiling and
over_local_ceiling.
The dependents command
Which files depend on which is answered by a command the project declares, in
selection.dependents: {"argv": [...], "covers": [...]}. argv runs from the project
root, without a shell. covers lists the path patterns the command answers for: a
pattern with a slash matches the whole path, one without matches the file name.
The selector asks the command in rounds, and only when a path needs dependents: each
round names the files it must answer for. The command gets one JSON object on standard
input, {"files": [...], "targets": [...]}: the tree-relative paths of the project and
the targets. It prints one line per dependency on a target, TARGET<TAB>DEPENDENT, where
DEPENDENT depends directly on TARGET. The selector follows those lines through any
number of files, up to the depth of the use.
Exit status 0 is an answer. Any other status, a command that cannot start, or a line
that is not of that form is could-not-verify: the affected paths are INDETERMINATE,
with what, why and how on standard error, and the widest group is listed. A changed
file that no covers pattern, assets prefix or test root decides is undecided: the
selector does not know its language and does not guess.
This repository declares its own Python command, scripts/hooks/python_dependents.py
(imports, modules started with -m, paths named in text, the implicit files). The
selector names no language; another repository declares its own command, in any
language, and needs no change to the tool.
Each call of the command is bounded by selection.dependents.timeout_seconds: default
120 seconds, ceiling 600. A call still running at its bound is killed, with the whole
process group it started, and gives no answer: every path that needs it is
INDETERMINATE with a TIMEOUT refusal, and the widest group is listed. A timeout is
reported as a timeout, not as a failure of the command's answer.
The command answers for every listed file, and refuses (non-zero exit, so the paths that need it are INDETERMINATE) when a listed file cannot be read, or when a target names no path of the tree. A deleted target is not refused: its dependents are found by its name. A non-Python file is a dependent when it names the target by its path, or by the target's file name inside the target's own directory.
What the command does not see, accepted limits: a name built at run time (a dynamic
import, a path assembled in a string), and symbolic links (none exist in this tree). A
file that names a target only in a way the command does not match is not a dependent,
and nothing says so: the selection is then short. nWave's declaration covers *.py
only; every other changed file is mapped (assets) or declared no_tests, by search of
the test sources, and any other file widens loudly.
The declaration
The tests block of .nwave/config.json: the project's file, else the global one
(the project block replaces the global block whole). --declaration FILE reads a
file whose root object is that block. Every key is optional; an unknown key is a
defect, and all defects are reported together.
{
"tests": {
"queue": {
"classes": ["short", "long"],
"aging_seconds": 1200,
"max_wait_seconds": 1800,
"max_concurrent": 1,
"cores_per_run": 1,
"memory_per_run_mb": 2048
},
"commands": {
"focused": {"argv": ["<runner>", "<arguments>"], "class": "short", "timeout_seconds": 300}
},
"candidate": {"commit": ["<command that prints the commit>"]},
"tool_resolution": {
"argv": ["<command that prints where the tool resolves>"],
"repair": "<how to make it resolve inside the tree>"
},
"selection": {
"test_roots": ["tests"],
"test_files": ["test_*.py"],
"dependents": {"argv": ["<command>", "<arguments>"], "covers": ["<path pattern>"]},
"assets": {"<path prefix>": ["<test directory>"]},
"no_tests": ["docs/"],
"widest": ["tests"],
"local_ceiling": 200,
"uses": {"commit": 1, "review": "transitive"},
"ignore": ["<directory name not to read>"]
}
}
}
| Key | Default | Meaning |
|---|---|---|
queue.classes |
["short", "long"] |
the classes, in the order they go |
queue.aging_seconds |
1200 |
wait after which a run of a later class goes first |
queue.max_wait_seconds |
1800 |
wait after which a run leaves the queue unrun (indeterminate, exit 75). The default is longer than the aging threshold, so an aged run still gets its turn, and short enough that a run which never ends costs a waiting run minutes, not a working session; a project whose runs are longer declares more |
queue.max_concurrent |
1 |
ceiling of runs at once |
queue.cores_per_run |
1 |
cores one run needs |
queue.memory_per_run_mb |
2048 |
memory one run needs |
commands.<name> |
none | a command run by name: argv, optional class and timeout_seconds |
candidate.<name> |
none | a command whose output identifies what is tested |
tool_resolution |
none | argv and optional repair; absent, nothing is checked |
selection |
none | absent, select is refused |
selection.test_roots |
required | directories that hold the tests |
selection.test_files |
required | name patterns of test files |
selection.uses |
required | depth per use: a whole number of hops or "transitive" |
selection.dependents |
none | the command that answers which files depend on a file: argv and covers (see below) |
selection.assets |
none | path prefix to the test directories that assert on it |
selection.no_tests |
none | path prefixes from which no test derives |
selection.widest |
none | directories of the widest group, the fallback when indeterminate |
selection.local_ceiling |
none | list length above which the list is flagged as not for this machine |
selection.ignore |
none | directory names not read; hidden directories are never read |
With no tests block the queue still works: classes short and long, one run at
a time, no check and no candidate identity.
What this does not do
- The queue orders and bounds the runs that go through it. A test run started another way is neither seen nor counted.
- The locks are advisory file locks on the queue directory: a file system that does not honour them across processes does not serialise the runs.
- The selector lists test files, not single tests.
- The selector reads files; it does not run the project. A dependency that exists only at run time is outside the list.
- The queue never stops a run. A run that never ends keeps its place until its
owner stops it; the runs behind it give up at
max_wait_secondsand name it. - The queue is one per account. Two accounts on one machine, or two nWave homes, have two queues: their runs are neither ordered nor counted together.
- A command that leaves its process group outlives a killed run; see "A run whose process is killed".
- A low depth does not reach a test that exercises a changed file through other
files, for example through a command's entry point. The list of a use that
declares
"transitive"does.