Skip to main content

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:

FieldMeaning
actionclick, fill, textContent, mouse.click, expect.toBeVisible, …
locator, applicationwhat was targeted
resolvedBywhich resolver found the control: uia, java, ocr, image, grounding (experimental) or region
backend, visionBackendwindows (or windows@sandbox in the experimental sandbox runtime, fake) and, when a screen provider answered, its name (windows, paddle, or your grounding provider)
strategy, fallbackFromhow it was executed: uia.invoke, uia.set-value, input.mouse.click, input.keyboard.type, …, or observe for read-only operations
control, candidatesthe control acted on and the candidates of the last resolution
attempts, resolutionDurationMs, actionDurationMs, durationMstimings
screenAttemptshow many of the observations looked at the screen
workwhat resolving — and checking a control found on screen before acting on it — did across every observation, counted by kind (see below)
resolverswhat each resolver of the plan did in the last observation: status, matches, duration and its own work
ok, result, erroroutcome

work counts calls that did real work; a result reused from pixels already read, or a capture shared by two resolvers, counts nothing:

CounterCounts
treeQueriesqueries the backend answered from the accessibility tree
treeObservationsfull observations of the control tree (a screen resolver needs one to place what it saw)
windowQuerieslistings of the application's windows
capturesscreenshots taken for the screen resolvers
textReads, textPixelsreads by the text provider, and the pixels they covered
imageSearchessearches for a picture, one per scale
groundingCallsrequests to the grounding provider (experimental)
backendCallsrequests the backend made of the helper (a window's tree, a control read alone, a picture)
revalidationschecks, right before acting, that a control found only on screen is still where it was seen (each takes a screenshot, counted in captures)
rowsInspectedrows 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 typeWhenContent
operationevery operation (mirrors desktop.actions)the ActionRecord
visionwhenever a grounding provider (experimental) inspected a screenshotall detections, the matched ones, the vision backend
snapshoton failure (diagnostics.snapshotOnFailure)the UI tree as a DesktopSnapshot: every control with its key, depth and parentId
screenshoton failure (diagnostics.screenshotOnFailure)the PNG's path and size
errorevery failed operation and every failed diagnostics stepserialized error (name, message, stack, code) and attachments
helperwhenever 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 error entry (diagnostics.step). A desktop takes at most 1 000 pictures.
  • Needs a trace file. Without traceFile, steps is 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.trace on, one trace runs from Desktop.create to dispose().
  • 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 }), or undefined when the trace was not kept or could not be written.
  • An explicit start() records even when diagnostics.trace is "off". It is then kept unless stop({ 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. A SPART_DESK_TRACE warning says why, the trace stops writing, and the next start() 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 to test-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 called Desktop.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-archive version 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 the stepId it 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.zip on it, or choose one;
  • or drop a folder on it, or choose one: the folder the page is in lists every trace.zip under 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, trace snapshot entry), written next to the screenshot as failure-<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 vision entries).

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.