Assertions
expectDesk is SPART-Desk's expect: retrying assertions that work with
any test runner, or none. It asserts on three kinds of subject:
| Call | Asserts on | Retries |
|---|---|---|
expectDesk(locator).toBeVisible() | a control, through the auto-waiting loop actions use | until it holds or the timeout elapses |
expectDesk(table).toHaveRows([...]) | a table's rows, read again on every attempt | until they match or the timeout elapses |
expectDesk.poll(() => value).toBe(3) | a value a callback reads | until the matcher holds |
expectDesk(async () => { … }).toPass() | a callback that throws until things are right | while it throws |
expectDesk(value).toBe(3) | a value you already have | never: 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
| Assertion | Holds when | .not holds when |
|---|---|---|
toBeVisible() | exactly one control matches and it is not hidden | nothing matches, or the single match is hidden |
toBeHidden() | nothing matches, or the single match is hidden | exactly one control matches and it is visible |
toExist() | exactly one control matches (hidden or not) | nothing matches |
toBeEnabled() / toBeDisabled() | the single match is enabled / disabled | the 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 state | it reports collapsed |
toBeFocused() | the single match has the keyboard focus, as the tree reports it | it 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 for | it 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 it | it is not selected |
toHaveText(expected, { ignoreCase? }) | the text (value, else name) equals the string after whitespace normalisation, or matches the RegExp | the single match has another text |
toContainText(expected, { ignoreCase? }) | the text contains the string after whitespace normalisation (case-sensitive unless ignoreCase), or matches the RegExp | the single match's text does not contain it |
toHaveValue(expected, { ignoreCase? }) | the observed value equals the string or matches the RegExp | the 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 only | the page's address equals the string or matches the RegExp, read anew every attempt: it waits for a navigation too | the 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:
| Assertion | Holds 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 %>.pngand<name>.tree.txt, in<test file>-snapshotsbeside the test that asserts. The test file is read from the call stack, so this works under any test runner.expect.snapshots.directoryputs them in one folder instead, andpathnames 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(orexpect.snapshots.update) to change that:allwrites every one anew;nonewrites nothing, so a missing one fails at once. It is the default when theCIenvironment 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 withmaxDiffPixels. - 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):
| Assertion | Holds when | .not holds when |
|---|---|---|
toHaveRows(rows) | the table shows exactly these rows, in this order | it does not |
toContainRows(rows) | every expected row matches a row of its own, in any order; other rows may be there | at 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:
| Assertion | Holds 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; usetoPasswhen the callback may throw.expectDesk(callback).toPass({ timeout?, intervals?, signal? })retries while the callback throws and fails with the last error ascause. There is no test timeout to fall back on, so it defaults toexpectDesk.configure({ toPass }), then the configuration file'sexpect.toPass.timeout(orexpect.timeout). A desktop's own defaults, those given toDesktop.create({ expect }), reach it throughexpectDesk.configure({ ...desktop.config.expect, intervals: desktop.config.intervals }).expectDesk(value)asserts once, synchronously. APromiseis 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
| Where | What 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.