Skip to main content

Assertions

expectDesk is SPART-Desk's expect: retrying assertions that work with any test runner, or none. It asserts on three kinds of subject:

CallAsserts onRetries
expectDesk(locator).toBeVisible()a control, through the auto-waiting loop actions useuntil it holds or the timeout elapses
expectDesk(table).toHaveRows([...])a table's rows, read again on every attemptuntil they match or the timeout elapses
expectDesk.poll(() => value).toBe(3)a value a callback readsuntil the matcher holds
expectDesk(async () => { … }).toPass()a callback that throws until things are rightwhile it throws
expectDesk(value).toBe(3)a value you already havenever: one shot

Every matcher has a .not. Every failure is an AssertionError.

import { Desktop, expectDesk } from "@spartaficial/spart-desk";
const main = app.window();

await expectDesk(main.getByText("Completed")).toBeVisible();
await expectDesk(main.getByLabel("Name")).toHaveValue("Alex Example", { timeout: 10_000 });
await expectDesk(main.getByRole("button", { name: "Save" })).not.toBeEnabled();
await expectDesk(main.getByRole("dialog", { name: "Progress" }), "the progress dialog closes").not.toBeVisible();

DesktopLocator assertions​

AssertionHolds when.not holds when
toBeVisible()exactly one control matches and it is not hiddennothing matches, or the single match is hidden
toBeHidden()nothing matches, or the single match is hiddenexactly one control matches and it is visible
toExist()exactly one control matches (hidden or not)nothing matches
toBeEnabled() / toBeDisabled()the single match is enabled / disabledthe single match is disabled / enabled
toBeChecked({ checked? })the single match reports the toggle state (checked: false expects unchecked)it reports the other state
toBeExpanded()the single match reports an expanded stateit reports collapsed
toBeFocused()the single match has the keyboard focus, as the tree reports itit does not
toBeEditable()the single match is enabled and not read-only (its Value or RangeValue pattern, asked anew every attempt as details() asks it): what fill() waits forit is disabled or read-only
toBeSelected()the single match is the selected item of its list, tab strip, tree or combo box's list, as isSelected() reads itit is not selected
toHaveText(expected, { ignoreCase? })the text (value, else name) equals the string after whitespace normalisation, or matches the RegExpthe single match has another text
toContainText(expected, { ignoreCase? })the text contains the string after whitespace normalisation (case-sensitive unless ignoreCase), or matches the RegExpthe single match's text does not contain it
toHaveValue(expected, { ignoreCase? })the observed value equals the string or matches the RegExpthe single match has another value
toHaveTitle(expected, { ignoreCase? })the window's title equals the string or matches the RegExp, read anew every attempt: a window of app.windows(), app.window({ title }), or a locator that finds a window; or a web page's own title (webPage())the window or page has another title; a locator that finds neither is refused
toHaveUrl(expected, { ignoreCase? }), on a WebPage onlythe page's address equals the string or matches the RegExp, read anew every attempt: it waits for a navigation toothe page has another address
toHaveCount(n)exactly n controls match (0 included)another number matches

A negated state or text assertion still needs exactly one control: not.toBeEnabled() on a control that is not there keeps waiting and fails with not found. Absence is not.toExist(), not.toBeVisible() or toHaveCount(0); a negated assertion never passes merely because nothing matches.

An absence answered by reading the screen is confirmed. Text the tree does not report is looked for on the screen, which is read at most once a second. not.toBeVisible(), toBeHidden(), not.toExist() and toHaveCount(0) pass on the screen's word only once three reads in a row, over at least a second, have all missed it. A flashing indicator, off every other half second, is not taken as gone, and a passing assertion of this kind takes about a second. A control that reports no toggle, expansion, focus, editable or selection state satisfies neither toBeChecked() nor not.toBeChecked(), nor toBeFocused(), toBeEditable() or toBeSelected() and their negations; the failure says no checked/unchecked state, no focus state, no editable state or no selection state. Strict mode applies as for actions: the same several matches for about a second fail with StrictModeViolationError, for the absence assertions too, except for toHaveCount, which is the assertion about how many there are.

Each assertion accepts { timeout, intervals, signal } for one call and is recorded as an expect.<name> (expect.not.<name> when negated) operation with the observe strategy in desktop.actions and the trace.

Golden pictures and trees​

Two assertions compare a control with a file kept from an earlier run:

AssertionHolds when
toHaveScreenshot(name, { mask?, threshold?, maxDiffPixels?, maxDiffPixelRatio?, path? })the control's picture holds still for two looks in a row, and differs from the golden picture in no more pixels than allowed (0 by default; a pixel differs when its colour is further than threshold, 0.2, from the kept one's)
toMatchTreeSnapshot(name, { depth?, path? })the control and what it holds read as the golden tree does: one control a line, its role, name, value and states (renderControlTree), without bounds or ids
const main = app.window();
await expectDesk(main.getByRole("toolbar")).toHaveScreenshot("toolbar");
await expectDesk(main.getByRole("pane", { name: "Chart" })).toHaveScreenshot("chart", { mask: [main.getByText(/^Updated /)] });
await expectDesk(main.getByRole("group", { name: "Shipping" })).toMatchTreeSnapshot("shipping");
  • Where the golden files are: <name>-<scale %>.png and <name>.tree.txt, in <test file>-snapshots beside the test that asserts. The test file is read from the call stack, so this works under any test runner. expect.snapshots.directory puts them in one folder instead, and path names one file.
  • Which are written: a missing golden file is written and the assertion passes, saying so in its record. Set SPART_DESK_UPDATE_SNAPSHOTS (or expect.snapshots.update) to change that:
    • all writes every one anew;
    • none writes nothing, so a missing one fails at once. It is the default when the CI environment variable is set.
  • What changes from run to run: an idle control's picture is the same pixel for pixel from one look to the next; a focused text box differs by its blinking caret. Paint over what changes with mask (a clock, a date), or allow for it with maxDiffPixels.
  • On failure: the actual, expected and diff pictures (or the actual tree) are written to the diagnostics folder and attached to the AssertionError. The error names how many pixels differ, or the first line of the tree that differs.
  • Not negated: neither is offered with .not.
  • Per display scale: a picture is kept per scale, so a test run at 100 % and at 150 % keeps one golden picture for each.

locator.screenshot({ path?, mask? }) takes the same picture into a file, without comparing it.

Table assertions​

expectDesk(app.window().table("Orders")) asserts on the rows a table shows, read again on every attempt (see tables.md):

AssertionHolds when.not holds when
toHaveRows(rows)the table shows exactly these rows, in this orderit does not
toContainRows(rows)every expected row matches a row of its own, in any order; other rows may be thereat least one expected row matches none

An expected row names only the columns it cares about; a string is the cell's whole text (whitespace-normalised, case-sensitive), a RegExp is tested as given:

const main = app.window();
await expectDesk(main.table("Orders")).toHaveRows([{ Id: "1", Status: "Open" }, { Id: "2", Status: /closed/i }]);
await expectDesk(main.table("Orders")).not.toContainRows([{ Status: "Error" }]);

Application assertions​

expectDesk(app) asserts on an application as a whole:

AssertionHolds when
toAnnounce(text, { exact? })the application announces something that matches through UI Automation, as it tells a screen reader of what it did ("Saved 3 orders"); not.toAnnounce holds when nothing matching is heard within the timeout
toBeIdle({ cpuBelow?, quietMs? })its windows answer, the pointer shows no wait cursor over them, and its processes use less than cpuBelow percent of a processor for quietMs in a row, as app.waitForIdle() waits
const main = app.window();
const save = main.getByRole("button", { name: "Save" });
await app.waitForAnnouncement(() => save.click(), /^Saved \d+ orders$/);
await save.click();
await expectDesk(app).toAnnounce(/^Saved \d+ orders$/);
await expectDesk(app).toBeIdle();

The application is listened to from the first waitForAnnouncement or toAnnounce on, for as long as it is open. An announcement that came after the application's last action counts, when it was listened to then. So the first wait passes the step as its action, and listening starts before the step runs. Announcements are heard through the helper executable, on Windows 10 1709 and later. Applications raise them from Windows Forms (AccessibleObject.RaiseAutomationNotification), WPF (AutomationPeer.RaiseNotificationEvent), WinUI and the web.

Values: poll, toPass and one-shot assertions​

poll and toPass resolve with the value the callback produced, so a test does not read the same thing twice:

const main = app.window();
const count = await expectDesk.poll(() => readOrderCount(app), { intervals: [100, 250, 500] }).toBeGreaterThan(0);

const orderId = await expectDesk(async () => {
await main.getByRole("button", { name: "Refresh" }).click();
return await main.getByRole("cell", { name: /^ORD-/ }).textContent(); // throws while the row is missing
}).toPass({ timeout: 30_000 });

expectDesk(orderId).toMatch(/^ORD-\d+$/); // one shot, no retry
  • expectDesk.poll(callback, { timeout?, intervals?, message?, signal? }) calls the callback until a matcher holds. An error thrown by the callback propagates at once; use toPass when the callback may throw.
  • expectDesk(callback).toPass({ timeout?, intervals?, signal? }) retries while the callback throws and fails with the last error as cause. There is no test timeout to fall back on, so it defaults to expectDesk.configure({ toPass }), then the configuration file's expect.toPass.timeout (or expect.timeout). A desktop's own defaults, those given to Desktop.create({ expect }), reach it through expectDesk.configure({ ...desktop.config.expect, intervals: desktop.config.intervals }).
  • expectDesk(value) asserts once, synchronously. A Promise is a value like any other: await it first.

Value matchers, each with .not: toBe (Object.is), toEqual (deep and strict: undefined properties count), toBeTruthy, toBeFalsy, toBeDefined, toBeUndefined, toBeNull, toContain (substring, or array element), toHaveLength, toMatch (RegExp or substring; strings only), toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual, toBeInstanceOf.

intervals is the backoff between attempts in milliseconds; the last entry repeats. It defaults to the desktop's intervals ([100, 250, 500, 1000]; expect.toPass.intervals narrows it for toPass).

Timeouts, messages and configure​

WhereWhat it sets
spart-desk.config.ts: intervals and expect: { timeout, toPass: { timeout, intervals } } (configuration.md), or Desktop.create({ intervals, expect })the defaults for the locator assertions of that desktop and, from the file, for poll and toPass through the plain expectDesk
expectDesk.configure({ ...desktop.config.expect, intervals: desktop.config.intervals })an expectDesk with that desktop's expect.timeout, intervals and expect.toPass, for poll and toPass as well as locator assertions; expectDesk itself knows no desktop
expectDesk.configure({ timeout, intervals, toPass, message, signal })a new expectDesk with these defaults for everything it asserts; toPass: { timeout, intervals } applies to toPass alone, before timeout and intervals
expectDesk(subject, { timeout, intervals, message, signal }) or expectDesk(subject, "message")one subject
toBeVisible({ timeout, intervals, signal }), poll(fn, { timeout, intervals }), toPass({ timeout, intervals })one call

Later rows win. expect.timeout defaults to the action timeout (5000 ms). A custom message becomes the first line of the failure.

const main = app.window();
const unhurried = expectDesk.configure({ timeout: 30_000, message: "the report takes a while" });
await unhurried(main.getByText("Report ready")).toBeVisible();
const total = await unhurried.poll(() => readTotal(app)).toBeGreaterThan(0);

Not provided: expect.soft and expect.extend. Both need a test runner to collect failures and register matchers; use your runner's expect for them.

Failures​

An assertion that does not hold in time throws AssertionError with the assertion name, the subject, expected and actual values, the last observed state and, for locator assertions, the candidates, resolver and backend, the TimeoutError as cause, and the failure attachments when diagnostics are enabled:

the progress dialog closes
Assertion not.toBeVisible failed for getByRole("dialog", { name: "Progress" }) after 5000ms.
Expected: not to be visible
Last observed state: visible
Waited: 0 ms visible ×9
1) dialog "Progress" (120,88 400x240)
Application: pid=39144 process="charmap"
Resolver: windows/uia
Attachments: C:\Users\me\AppData\Local\Temp\spart-desk\failure-….png, C:\Users\me\AppData\Local\Temp\spart-desk\failure-….ui-tree.json

Waited: is what the assertion saw from its start, round by round, the same state on rounds in a row as one stretch: here the dialog was visible on every one of nine rounds. A one-shot assertion, or a wait of one round, has no such line (auto-waiting.md).

Properties: assertion (toHaveText, not.toBe, toPass), locator (the locator, or poll(), callback, value), timeout (0 for a one-shot assertion), customMessage, expected, actual, lastState, history (the stretches, as WaitHistoryEntry; empty for a one-shot assertion), candidates, application, backend, resolver, visionBackend, attachments, and cause (the TimeoutError, or the last error a toPass callback threw).

Using a test runner's assertions​

Nothing stops you from mixing: read state with textContent() / isEnabled() / boundingBox() and assert with your runner's expect. The difference is retrying: expectDesk waits for eventual consistency, a plain expect(await locator.textContent()) asserts the state at one instant. When you need the value and the wait, expectDesk.poll gives you both.