Assertions, operation records, traces and failure diagnostics
What SPART-Desk leaves behind about a run, and how to read it: the retrying assertions, the record every operation writes, the trace and its viewer, and what is collected when something fails. For anyone who has to find out why a test did what it did.
Assertions
expectDesk is an optional, test-runner-independent assertion API with
retrying matchers. DesktopLocator assertions retry with the same auto-waiting
loop actions use (no second synchronization mechanism, no sleeps) and honour
strict mode; expectDesk.poll and toPass retry callbacks and resolve with
their value; expectDesk(value) asserts once. The matchers, .not,
configure, messages and timeouts are described in
assertions.md.
import { Desktop, expectDesk } from "@spartaficial/spart-desk";
const main = app.window();
await expectDesk(main.getByText("Completed")).toBeVisible();
await expectDesk(main.getByRole("button", { name: "Save" })).not.toBeEnabled();
await expectDesk(main.getByText("Status")).toHaveText(/done/i, { timeout: 10_000 });
const rows = await expectDesk.poll(() => countRows(app)).toBeGreaterThan(0);
A failed assertion throws AssertionError:
Assertion toHaveText failed for getByText("Status") after 5000ms.
Expected: "Completed"
Actual: "In progress"
Last observed state: text is "In progress"
1) text "In progress" (10,10 100x20)
Application: pid=39144 process="charmap"
Resolver: windows/uia
Attachments: C:\Users\...\spart-desk\failure-….png, C:\Users\...\spart-desk\failure-….ui-tree.json
Properties: assertion, locator (or the subject poll(), callback,
value), timeout, customMessage, expected, actual, lastState,
candidates, application, backend, resolver, visionBackend,
attachments; cause is the underlying TimeoutError (or the last error a
toPass callback threw). More than one matching control raises
StrictModeViolationError, exactly as for actions.
Operation records
Every operation — actions, read-only observations (textContent,
boundingBox, isEnabled, waitFor), mouse/keyboard primitives and
assertions (expect.*) — becomes an ActionRecord in desktop.actions:
| Field | Meaning |
|---|---|
action | click, fill, textContent, mouse.click, expect.toBeVisible, … |
locator, application | what was targeted |
resolvedBy | which resolver found the control: uia, java, ocr, image, grounding (experimental) or region |
backend, visionBackend | windows (or windows@sandbox in the experimental sandbox runtime, fake) and, when a screen provider answered, its name (windows, paddle, or your grounding provider) |
strategy, fallbackFrom | how it was executed: uia.invoke, uia.set-value, input.mouse.click, input.keyboard.type, …, or observe for read-only operations |
control, candidates | the control acted on and the candidates of the last resolution |
attempts, resolutionDurationMs, actionDurationMs, durationMs | timings |
screenAttempts | how many of the observations looked at the screen |
work | what resolving — and checking a control found on screen before acting on it — did across every observation, counted by kind (see below) |
resolvers | what each resolver of the plan did in the last observation: status, matches, duration and its own work |
ok, result, error | outcome |
work counts calls that did real work; a result reused from pixels already
read, or a capture shared by two resolvers, counts nothing:
| Counter | Counts |
|---|---|
treeQueries | queries the backend answered from the accessibility tree |
treeObservations | full observations of the control tree (a screen resolver needs one to place what it saw) |
windowQueries | listings of the application's windows |
captures | screenshots taken for the screen resolvers |
textReads, textPixels | reads by the text provider, and the pixels they covered |
imageSearches | searches for a picture, one per scale |
groundingCalls | requests to the grounding provider (experimental) |
backendCalls | requests the backend made of the helper (a window's tree, a control read alone, a picture) |
revalidations | checks, right before acting, that a control found only on screen is still where it was seen (each takes a screenshot, counted in captures) |
rowsInspected | rows of a grid, or items of a list, a row or listitem step looked at, on every page and by every resolver that ran the step |
Counts are exact and the same on every run, which makes them the right thing to compare when a change is meant to make resolution cheaper; durations depend on the machine.
Backend naming in diagnostics: the resolver and backend are rendered as
windows/uia or ocr/windows; strategies distinguish uia.* from
input.* (native input).
Trace
desktop.trace is a runner-agnostic Trace collecting:
Entry type | When | Content |
|---|---|---|
operation | every operation (mirrors desktop.actions) | the ActionRecord |
vision | whenever a grounding provider (experimental) inspected a screenshot | all detections, the matched ones, the vision backend |
snapshot | on failure (diagnostics.snapshotOnFailure) | the UI tree as a DesktopSnapshot: every control with its key, depth and parentId |
screenshot | on failure (diagnostics.screenshotOnFailure) | the PNG's path and size |
error | every failed operation and every failed diagnostics step | serialized error (name, message, stack, code) and attachments |
helper | whenever a helper process stopped on its own and was started again (event: "restarted"), or stopped too often to be ("lost") | which helper, how many restarts, how it stopped (its exit code and the requests it was answering), and what was not restored |
Storage and export are pluggable: trace.subscribe(listener) for live
events, trace.pipe(sink) to forward entries to any TraceSink
({ write(entry) }) — a JSON-lines file, a reporter adapter, a network
collector. Sink failures are swallowed so a broken exporter never breaks
automation. trace.of("screenshot") filters by type; trace.clear() resets.
Nothing in the trace writes files itself; only screenshots are written, by
the backend, into diagnostics.directory — unless a trace file is named.
The trace file
Desktop.create({ diagnostics: { traceFile: "test-results/trace.jsonl" } })
(or diagnostics.traceFile in the configuration file, relative to it) appends
every trace entry to that file as one JSON line, as it happens, so a run that
is killed still leaves what happened until then. The first line is the header
{ "format": "spart-desk-trace", "version": 1, "startedAt": … }; a file that
already exists is appended to without a second header. parseTraceFile(text)
reads one back, skipping a line a killed run left half written, and
createTraceFileSink(path) is the same sink for trace.pipe when the file
should be chosen at run time. The file holds locators, control names and
values observed: treat it as the trace itself (security.md).
A picture of each step, and the report of a run
diagnostics: { traceFile, steps: "on" } takes a picture of the window after
each step: every action, and every action or assertion that fails. A read or
an assertion that passed changes nothing to see, so it gets none. The
pictures go beside the trace file, in <trace name>.steps/. Each one is a
screenshot entry of the trace with a step (TraceStep): the operation
it follows and when it started, whether it succeeded, the screen rectangle
the picture covers (region) and where the control acted on lies
(target). Nothing is drawn into the pixels.
"on-failure"keeps the latest 20 pictures in memory and writes them only when an action or an assertion fails: the steps that led up to it, and the failure itself.- The cost: a picture costs about a fifth of a second. It is taken after
the action's record, so the action's own duration is unchanged. A step that could not be pictured, because its
window had gone or did not paint within 2 s, is an
errorentry (diagnostics.step). A desktop takes at most 1 000 pictures. - Needs a trace file. Without
traceFile,stepsis refused.
spart-desk report <trace.jsonl> [--out <file.html>] [--since <ms>], or
writeTraceReport(traceFile, { out?, since?, title? }), writes one HTML file
of the run: every operation in order, with its strategy, timings, attempts,
detail and error, the picture after it with the control boxed, the
helper's restarts and the diagnostics' errors. The pictures are embedded. The
page loads nothing and runs no script, and every name the application gave
is escaped. renderTraceReport is the same page as a string. since keeps
what happened from a time on: one test's part of a trace file several append
to. With Vitest, a failed test can carry its report:
import { afterEach, beforeEach } from "vitest";
import { writeTraceReport } from "@spartaficial/spart-desk";
let since = 0;
beforeEach(() => {
since = Date.now();
});
afterEach(async (context) => {
if (context.task.result?.state !== "fail") return;
const report = await writeTraceReport("test-results/trace.jsonl", { since, out: `test-results/${context.task.name}.html` });
await context.annotate("SPART-Desk report", { path: report, contentType: "text/html" });
});
The pictures show whatever the windows showed, typed values included, which the trace itself keeps only as lengths.
A trace of each test, in one zip
A trace keeps the whole run in one trace.zip, read in one page.
It is off by default. diagnostics.trace switches it on, in
Desktop.create or in spart-desk.config.ts:
export default defineConfig({
diagnostics: { trace: "retain-on-failure" }, // or "on"; "off" is the default
});
Each step keeps:
- the operation's record;
- a picture of the window before it;
- a picture after it, when it changed something or failed;
- the tree it resolved against;
- each round of its wait, with what that round saw (
not visible,2 controls matched); - the helper's requests, with how long each took and how long it queued;
- its errors, with the failure's picture and tree.
A step made of others (a table's or a list's operation) holds them as nested steps. Only the outer step is pictured.
When a trace runs. The engine knows a desktop's lifetime, not a test's.
- By the mode. With
diagnostics.traceon, one trace runs fromDesktop.createtodispose(). - One per test. A test's own hooks can split the run, whichever runner
runs it.
desktop.tracing.start({ name })ends the trace running and starts one with that name.await desktop.tracing.stop()ends it and returns the zip it wrote ({ path, steps, failed, bytes }), orundefinedwhen the trace was not kept or could not be written. - An explicit
start()records even whendiagnostics.traceis"off". It is then kept unlessstop({ keep: false })says otherwise. - A trace that cannot be written (the disk full, its place taken by a
file, the zip locked by another program) never fails a step, nor the
stop()in a test's own hook. ASPART_DESK_TRACEwarning says why, the trace stops writing, and the nextstart()starts a new one. Starts and stops asked at once run one after another.
Kept or not. "on" keeps every trace. "retain-on-failure" keeps a trace
when an action or an assertion of the engine failed in it: one the test called
itself, not one inside another (a mask's bounds asked inside
toHaveScreenshot, which goes on without the mask). A failure the test caught
and went on from still keeps it. The engine does not see a failure of the
runner's own assertions, so stop({ keep }) takes the test's own result when
you have it:
import { afterEach, beforeEach, expect } from "vitest";
beforeEach(async () => {
await desktop.tracing.start({ name: expect.getState().currentTestName });
});
afterEach(async (context) => {
const kept = await desktop.tracing.stop({ keep: context.task.result?.state === "fail" });
if (kept !== undefined) await context.annotate("SPART-Desk trace", { path: kept.path, contentType: "application/zip" });
});
Where traces go:
<traceDirectory>/<calling file>/<name>/trace.zip
test-results/spart-desk/tests/login.test.ts/signs-in-with-a-valid-user/trace.zip start({ name }) in a test
test-results/spart-desk/tests/support/desktop.ts/2026-10-29T10-15-02-123/trace.zip the desktop's own trace
traceDirectory: defaults totest-results/spart-desk. It is relative to the configuration file when set there, else to the working directory.- The calling file: the file that called
start, or for the desktop's own trace the file that calledDesktop.create, relative to the working directory. A desktop created in a shared fixture file is traced under that file, not under the test. - The name: lower case, with every run of characters other than letters,
digits,
.and_turned into one-. A name longer than 80 characters is cut and ends in a short hash of the whole. A trace started without a name is named after the time it started. - A name used again (a retried test) gets
-2,-3, …. stop({ path })writes the zip there instead.
Inside the zip:
manifest.json:spart-desk-trace-archiveversion 1, with every file's size and SHA-256.environment.json: the engine, Node.js and Windows versions, the runtime and the backend.trace.jsonl: one line per event, each with thestepIdit belongs to.pictures/: the pictures of each step.trees/: the trees, each written once however many steps saw it.
While a trace runs, its files are written to a folder in
diagnostics.directory. That folder is zipped when the trace ends, and
removed whether the trace is kept or not.
The page. Each kept trace puts trace-viewer.html, one self-contained file,
at the top of traceDirectory (test-results/spart-desk/trace-viewer.html).
An older copy there is replaced. A trace kept at a path of its own places no
page; spart-desk trace viewer [--out <dir|file.html>] writes it anywhere.
Open the page from disk, then:
- drop a
trace.zipon it, or choose one; - or drop a folder on it, or choose one: the folder the page is in lists every
trace.zipunder it, newest first, each marked passed or failed. Escape goes back to the list.
A page opened from disk may not read the files beside it, so the trace or the folder is handed to it this way. Nothing is hosted and nothing is uploaded: the page reads the zips itself, and its content security policy lets no request leave it. A browser may still ask whether to "upload" a chosen folder; the files stay in the page.
The page shows:
- the steps as a strip of pictures and as a list, nested steps indented and failed ones marked;
- the pictures before and after each step and of its failure (keys 1, 2, 3), with the control boxed;
- the step's tree, with the control it acted on marked; pointing at another control boxes it on the picture;
- the step's waits, the helper's requests as bars in time, its errors, and where the run ran.
The arrow keys move through the steps.
spart-desk trace <trace.zip> [--json] says the same in the terminal, and
exits 1 when the trace failed.
The cost. Tracing takes a picture before each step and one after it, each
about 35 ms on a small window. Both are taken outside the operation's own
duration. A step that acts takes about 70 ms longer with tracing on, and a read
that passed (pictured before only) about 35 ms. With tracing off, nothing is
added. The trees cost no request: they are the ones the waits already
read. Each trace takes at most 1 000 pictures. With diagnostics.redact, the
trace is redacted as the rest of the evidence is. As with step pictures, the
pictures show what the windows showed.
Transcripts of the backend
For a support bundle or a recorded fixture, the raw conversation with the
backend can be kept beside the trace: a runtime takes
transcript: { onTraffic } (createLocalRuntime, and createSandboxRuntime,
which is experimental), and onTraffic hears the requests, answers and events
of the helper executable's long-lived process (HostTraffic), on this machine
or in the sandbox guest. It changes nothing of what runs.
Failure diagnostics
When an action or assertion times out (or hits a strict-mode violation), SPART-Desk collects, best effort:
- a screenshot of the application (attachment
screenshot, trace entry), - the UI tree (attachment
ui-tree.json, tracesnapshotentry), written next to the screenshot asfailure-<id>.ui-tree.json; without a screenshot it is attached in memory only (attachment.data) and not named in the message, - the locator, last candidates, resolver/backend and strategy (already in the error and the operation record),
- grounding detections when a grounding provider ran (experimental; trace
visionentries).
Each step is independent; a failing step is recorded as a
diagnostics.screenshot / diagnostics.snapshot trace error and the
original failure is thrown unchanged. Configure with
Desktop.create({ diagnostics: { screenshotOnFailure, snapshotOnFailure, directory } }).
DiagnosticAttachments carry either a path (screenshot) or inline data
(UI tree JSON); TimeoutError.attachments and AssertionError.attachments
list them.
Redaction
diagnostics: { redact: true } redacts what is written the way a support
bundle is redacted:
in the trace file and every sink piped to desktop.trace, in the failure
tree (failure-<id>.ui-tree.json and the attachment's data), and in the
failure picture, the step pictures and so the report. Every text that is
data (a value, a cell, a row, an item, a document, a tooltip, text read off
the screen) becomes a token, «t1», «t2», the same token for the same
text within one desktop, and its place is painted over in the pictures.
Captions, roles, AutomationIds, class names and bounds stay, as they are
what locators are made of; { patterns: ["^Customer .*"] } redacts titles and
captions matching a pattern too (a string is a regular expression, matched
without regard to case; one that could take exponential time is refused).
A step picture then costs one tree read more. What stays in memory
(desktop.trace.entries, desktop.actions) and the error that is thrown
are not redacted: a test asserts on them, and a message must say what it
saw. A report of a trace file written without redaction is not redacted
either. Off by default; turn it on in spart-desk.config.ts for a CI job
whose artifacts leave the machine.