Skip to main content

SPART-Desk API

The reference for @spartaficial/spart-desk: every class, method, option, error and exported type, by area, for anyone who writes tests or tools on it. The guides explain how to use them; this page gives the signatures. All examples assume import { Desktop } from "@spartaficial/spart-desk".

Desktop​

MemberDescription
Desktop.create(options?)Creates a desktop bound to a runtime and prepares it (a sandbox is started here). The project configuration file (spart-desk.config.ts in the working directory, or options.configFile; false skips it) supplies defaults underneath the options given here; see configuration.md. options.runtime is "local" (default, current Windows session), "sandbox" (experimental: an isolated Windows Sandbox guest, see runtime-windows-sandbox.md) or a DesktopRuntime instance. options.timeout (default 5000 ms) and options.intervals (pauses between two observations while waiting, default [100, 250, 500, 1000], the last entry repeating) set the auto-waiting defaults; options.expect ({ timeout?, toPass?: { timeout?, intervals? } }) the expectDesk defaults. options.ocr configures reading text off the screen ({ enabled?, languages?, minimumConfidence?, provider? }; on Windows a reader needs no installation and is configured for you), options.grounding (experimental) supplies a GroundingProvider ({ provider, minimumConfidence? }) and options.resolution ({ screen?: { interval?, upscale?, parkPointer? } }) paces and prepares the resolvers that read pixels: how often the screen is read while a locator waits, how small text is enlarged, and whether the pointer is taken off a window before its picture is read. Which resolver answers is the locator's own choice: the accessibility tree, unless a step names the screen (read(), getByOcr, getByImage, getByVision); see resolution.md.
desktop.groundingNameName of the grounding provider, or undefined when none is configured.
desktop.textNameName of the provider that reads text off the screen ("windows" by default on Windows, the name ocr.provider gives it; several readers as "windows → paddle"), or undefined when none is configured.
desktop.launch(options)Starts an application ({ executable, args?, cwd?, env?, target?, joinExisting?, windowTimeout?, java?, signal? }), waits for its first window and returns an Application. joinExisting: true lets a single-instance application hand the launch over to an instance of target.processName, or one showing a window of target.windowTitle, that was already running; without it such a launch fails with LaunchError naming the running instance. signal cancels the launch: the wait for the window ends with CancelledError, and what the launch started is stopped, as it is when no window appears. Without a target, a launcher stub that starts the application in a process of its own (launch4j, jpackage, a script host) is followed: when the spawned process shows no window, the processes it started are watched too, and the application becomes the one that shows it. target: { processName } names the process a launcher hands off to when it is not one it started (notepad.exe → Notepad). target: { windowTitle } waits for a window of that title, and follows what the launcher starts and what those start: a launcher or an updater that hands over and exits keeps the wait going while anything it started runs; a window of the title open before the launch is not taken for the launch's own, and joined only with joinExisting: true. When no window appears, everything the launch started is stopped before LaunchError is thrown. A .jar runs as javaw -jar; for Java applications the Java Access Bridge is turned on through JAVA_TOOL_OPTIONS when their runtime has it, java: true declares a Java application (the launch fails unless its window can be read) and java: false leaves the launch alone — see java-apps.md. { appId, args?, windowTimeout?, keepRunning?, signal? } starts an installed Store app or packaged application by its Application User Model ID (PackageFamilyName!AppId, e.g. Microsoft.WindowsCalculator_8wekyb3d8bbwe!App; the Start menu's apps are listed with theirs in shell:AppsFolder) the way the Start menu does, and addresses the process Windows started for it; args are what it is activated with, joined into one argument string as a program reads its command line (a packaged desktop application reads it there, a UWP application in its launch activation's arguments), and a .lnk to a packaged app passes its own arguments and the launch's the same way. { uri, appId?, target?, joinExisting?, windowTimeout?, keepRunning?, signal? } opens a URI by its protocol (ms-settings:display, calculator:, mailto:…, an application's own scheme), as a link does: with appId, the packaged application is activated for it (a UWP application through Windows' activation manager, a packaged desktop application through the shell once the scheme is its own; another application's scheme is refused) and the launch addresses its process; without it, a packaged handler of the scheme is activated for the URI the same way and any other handler started through the shell, and the launch addresses the process named, or, when the shell names none (an application that took the URI into its running instance), the target given: { processName }, whose new processes are the launch's, or { windowTitle }, whose window must not have been open before (joinExisting: true joins a running instance, as for an executable). A text that is no URI (no scheme, whitespace, over 2083 characters) and a scheme nothing handles are refused with LaunchError before or as Windows refuses them, with no dialog. Local and sandbox runtimes. A UWP application's window is ApplicationFrameHost's frame, which SPART-Desk attributes to the application. In the sandbox the executable is deployed into the guest first. A .lnk shortcut starts what it names, read as the shell reads it: a program with the shortcut's arguments before the launch's, in the shortcut's folder unless cwd names one, or the packaged app it names; one that asks to run as administrator is refused. A .bat or .cmd runs in cmd.exe with its console hidden, and the application it starts is followed; an argument holding %, !, ^, &, |, <, >, parentheses, quotes or a line break is refused, since cmd.exe would read it as a command. Local runtime only: the sandbox refuses both. A .appref-ms, a ClickOnce application's Start menu reference, is opened as the shell opens it (rundll32.exe dfshim.dll,ShOpenVerbShortcut), and the ClickOnce service starts the application in a process of its own, which target: { processName } (or { windowTitle }) must name; a reference with arguments or without such a target is refused at once, and the sandbox refuses a reference as it refuses a shortcut (installers.md).
desktop.attach(target)Wraps an application that is already running in the runtime. target is { pid?, processName?, windowTitle?, windowHandle? }. A windowTitle beside a processName (and no pid, which is exact already) names one window of that process (the one whose title holds the text, ignoring case) and the windows it owns: locators, snapshots and close() reach that window only; several such windows are ambiguous_target. A Store app hosted by ApplicationFrameHost is found through its frame, by any of these (its process name is the application's, e.g. CalculatorApp). A windowTitle alone names the processes showing a window with exactly that title. A RegExp title (desktop.attach({ windowTitle: /^Invoice \d+/ })) is looked for in the title wherever a title is matched: alone, beside a processName (one window, as a text there), and in a Store app's frame. Records, traces and bundles write it as /source/flags, and app.target holds the desktop's copy of it. The sandbox stops it through the processes whose windows it matches when closed.
desktop.shell({ display? })The Windows shell as an Application. It covers a display's taskbar: Start, the pinned and running applications, and the tray with buttons such as "Show Hidden Icons" and "Notifications". display is 0 (default) for the primary display's, 1, 2, … for another's in the order Windows lists them, or "all" for every one (a further display's taskbar repeats Start and the clock). It also covers the tray's overflow and, while they are open, the notification centre, quick settings, Start and Search. Its locators search every one of those surfaces that is showing. A flyout closes by clicking its taskbar button again (it ignores a posted Escape); a notification's text may read "Private". Its close(), and desktop.dispose(), only let go of it: the shell is never stopped. File Explorer's own windows are not part of it; attach to those. Local and sandbox runtimes.
desktop.run(command, args?, { cwd?, env?, input?, timeout?, signal? })Runs a program to its end on the machine the applications run on (this one for the local runtime, the guest for the Windows Sandbox, as the applications' user), for what a test arranges and cleans up: files, registry values, a process to end (RunOptions). No shell reads it: each argument goes as one; a batch file or a shell's own command runs through cmd.exe /d /s /c. Answers a RunResult: { exitCode, stdout, stderr, durationMs }, output read as UTF-8; a non-zero exit code is a result, not an error. Fails with BackendError command_not_found, command_not_started, command_timeout (after 60 s by default, the command stopped with what it started; not retryable) or output_too_large, and not_supported on a runtime without run. The action log records the program's name, how many arguments and the exit code, never the arguments.
desktop.dispose()Closes every application this desktop launched (one it attached to is left running; app.close() stops it) and releases the runtime (stops a sandbox SPART-Desk started). Idempotent; every cleanup step runs even if one fails. Symbol.asyncDispose enables await using desktop = await Desktop.create(...).
desktop.windows({ signal? })Every top-level window a user can see on the desktop the applications run in, topmost first (DesktopWindow[]): handle, title, process id and name, class, and, where the backend knows them (the helper executable's census), bounds, display and whether the window lies above this process's integrity level. What a tool picks an application to attach to from; app.windows() lists one application's. Empty where the backend observes one application at a time.
desktop.screenshot({ path?, display?, signal? })A picture of the desktop the applications run on, not of one window (DesktopScreenshotOptions): every display together by default, or display (a number as desktop.windows() gives them, the primary one 0). What the screen shows, whatever window draws it: other applications, the taskbar, a notification; in the Windows Sandbox runtime, the guest's desktop. Resolves with the PNG written (Screenshot). session_locked (and the other session codes) where the screen cannot be copied, element_not_found for a display that is not connected, RangeError for a display that is not a whole number from 0.
desktop.session({ signal? })The interactive session the applications run in, as a SessionState ({ locked, reason?, remote, disconnected, sessionId?, interactive? }): locked (Windows' lock screen is up: Win+L, an idle timeout, a policy's inactivity limit), a Remote Desktop session, one whose client disconnected, or one that is not interactive at all (interactive: false: session 0, where services run, or a window station no display shows, as a scheduled task's). reason says which sign gave a lock away: "lock-flag" (Windows reports it), "screen-saver" (a screen saver Windows started is running: real input would end it, not reach an application), "input-desktop" (another desktop takes the input: the lock screen's, or a UAC prompt's) or "lock-screen" (the lock screen's window is in front); the session_locked message names it too. On such a session, actions through UI Automation and window messages still work; real input, focus(), hover and screen captures fail with BackendError session_locked, session_disconnected or session_non_interactive. undefined where the backend cannot tell (off Windows, without the helper executable). In the sandbox it is the guest's session. npx spart-desk doctor prints it too.
desktop.configThe resolved DesktopConfig: { timeout, expect: { timeout, toPass: { timeout, intervals } }, intervals, resolution, ocr, images, grounding, diagnostics, foreground }.
desktop.setDefaultTimeout(ms)Changes the default budget of every operation that starts afterwards, on this desktop and its applications: actions and waits, and expectDesk assertions alike (toPass keeps its own); config reflects it. Desktop.create({ timeout }) sets the first; a suite narrows it for steps it expects to fail and widens it again after. A timeout given to one call still wins. RangeError for anything but a whole number of milliseconds up to MAX_TIMEOUT_MS.
desktop.actionsActionLog of every operation performed through this desktop (actions, observations, expect.* assertions): records, last (the operation that finished most recently, failed ones included), subscribe(listener), clear(), and dropped: the log keeps its latest 10 000 records and counts the older ones it forgot; a listener that throws is reported once and ignored. Each ActionRecord names the operation, resolvedBy (the ControlSource that found the control), backend/visionBackend, the strategy that ran (uia.invoke, input.mouse.click, observe, ...), the fallback, broughtToFront (its window was brought to the front for it), locator, control, candidates, the number of observations (attempts), what each resolver of the plan did (resolvers), resolution/action durations and success/error. See assertions-diagnostics.md.
desktop.traceRunner-agnostic Trace: operations, vision detections, failure snapshots, screenshots and errors. entries, of(type), subscribe(listener), pipe(sink), clear(). It keeps its latest 10 000 entries and counts the older ones it forgot in dropped; a trace file (diagnostics.traceFile) keeps them all. A listener that throws is reported once and ignored, never failing the operation.
options.expect, options.diagnosticsDefaults for expectDesk (expect.timeout falls back to timeout) and what to capture on failure ({ screenshotOnFailure, snapshotOnFailure, directory, redact? }; redact: true | { patterns } (RedactionSettings) redacts what is written as a bundle is redacted); diagnostics.traceFile names a file every trace entry is appended to as one JSON line, as it happens (see assertions-diagnostics.md).
options.foreground"activate" (default): an action that needs real input and has no way in the background left brings its window to the front and runs once more; its record has broughtToFront: true. "require": it fails with BackendError foreground_required and leaves every window where it is.
desktop.runtimeName, desktop.backendNameNames for diagnostics, e.g. "local" / "windows" or "sandbox" / "windows@sandbox".
desktop.capabilitiesWhat this desktop can actually do, decided when it was created: uia, java (Java Swing/AWT windows through the Java Access Bridge), nativeInput, screenshot, text, imageMatch, elements, grounding. Each is { available: true } or { available: false, reason, hint? }, so a caller can branch instead of discovering a limit through a failure. Named by ability, never by provider. npx spart-desk doctor prints the same vocabulary for the machine.

Runtimes can also be constructed explicitly to pass options:

import { Desktop, createLocalRuntime, WindowsDriver } from "@spartaficial/spart-desk";

await Desktop.create({ runtime: createLocalRuntime({ driver: new WindowsDriver({ inspectDepth: 12 }) }) });
Experimental

The Windows Sandbox runtime may change in minor versions until its prerequisites are validated against a live Windows Sandbox.

import { Desktop, createSandboxRuntime } from "@spartaficial/spart-desk";

await Desktop.create({ runtime: createSandboxRuntime({ keepRunning: true, driver: { inspectDepth: 12 } }) });

Application​

An application is its windows, and what observes, listens to and closes it. It makes no locators itself: they start from a Window, the main window app.window() or another one. Keys, the mouse and pictures go to a window too; the clipboard and a snapshot are the application's.

const main = app.window();
await main.getByRole("button", { name: "Open…" }).click();
await main.getByRole("dialog", { name: "Open report" }).getByRole("button", { name: "OK" }).click();
MemberDescription
app.targetThe ApplicationTarget the backend uses to address the application.
app.clipboardThe clipboard of the application's session: text, readText() (undefined when it holds no text), writeText(text) and waitForText(action) (the text an action made the application copy, waited for until the clipboard changed after it, without opening it meanwhile); pictures, readImage() (PNG bytes: the PNG an application put there whole, else its bitmap encoded as one; undefined when it holds no picture) and writeImage(png) (as a PNG and as a bitmap with its alpha, from which Windows gives older formats to applications that ask; invalid_request for bytes that are no PNG). Records keep only the text's length.
app.automation({ progId } | { document })The application's own automation interface, as an AutomationObject (AutomationSource): the object it registered under a ProgID, or holding a document by its full path, used only when the application's own processes serve it. get(path), set(path, value) and call(path, args?) a member along a path such as Workbooks(1).Sheets("Orders").Range("A1:C3").Value; at(path) is a place further along; describe(). Values are AutomationValues (a block of cells as rows, dates as Date, objects as AutomationObjectValue); AutomationOptions takes timeout (AUTOMATION_TIMEOUT_MS, 30 s) and signal. Errors: automation_unavailable, not_application, automation_failed; a get that runs out is timeout, a set or call delivery_unknown. A backend implements automation?(context, request) with an AutomationRequest (automation-interfaces.md).
app.windows()The application's top-level windows, observed now: each a Window, a locator of that one window that carries its WindowInfo (with bounds/scale/dpi when observed by inspection, and ownerHandle for an owned window such as a dialog) as it was when listed. This includes windows other processes show for the application. For a launched application, those are windows of the processes it started, such as an updater or a helper process with a window of its own. For any application, including one attached to by pid, name or title, they are windows any process shows owned by one of its windows, such as an out-of-process dialog or a credential prompt (when the runner is elevated, Windows gives the credential prompt no owner, and it counts when the helper ties it to the application). Each is listed under its own processId. Its locators, snapshots and actions reach them too. Windows' print dialog is an application of its own: desktop.attach({ processName: "PrintDialog" }).
desktop.launch({ ..., keepRunning: true })Leaves the application running when the desktop is disposed; app.close() closes it all the same. The local runtime honours it; in the Windows Sandbox runtime (experimental) an application ends with the sandbox.
app.credentialPrompt()The credential prompt this application raised (Windows Security, shown by CredentialUIBroker; on Windows 10, for a caller that is not elevated, by the application's own process), as a scope of its own: locators, snapshots and actions inside it. Only a prompt Windows ties to the application counts, never another application's, even when that is the only one showing. Its fields have language-neutral AutomationIds: EditField_0, PasswordField_1, OkButton, CancelButton. Without elevation its text is read off the screen and actions fail with elevation_required; elevated.md. Closing it stops nothing. A backend that cannot tell which application raised a prompt refuses it (BackendError not_supported).
app.window(), app.window({ title?, exact?, className? } | { handle })One window of the application as a Window, found again whenever it is used. Without an argument, the main window: the one no window of the application owns, of those the application's own process shows when another process shows one of its own (an updater's); several (a second document window, a second instance) are an ambiguity a wait settles and reports with StrictModeViolationError, naming each, never a guess. For the shell, every surface that is showing. Its getBy* look inside the window, the windows it owns (its dialogs and message boxes, however deep) and the menus, drop-downs and tooltips open over them (untitled windows lying over them); close(), minimize(), bringToFront(), keyboard and mouse act on it. title is a substring (case-insensitive) unless exact, or a RegExp; className is the window's class, the whole name without regard to case, as Windows compares class names, or a RegExp, alone or with a title: Windows' own dialogs (#32770) whatever their language, and windows without a title. Renders as code: app.window(), app.window({ title: "Find customer" }), app.window({ className: "#32770" }).
app.onWindow({ title?, exact?, times? }, handler)Handles windows that open from now on: an error box, a confirmation. At the start of every attempt of an action, a wait or an assertion, each handler runs once for every window that opened since it was added and whose title matches, before the attempt goes on, so a dialog that blocks the next click is dismissed by that click's wait. Nothing runs in the background; what a handler does waits as any step does, without running handlers; a handler's error is the step's. Untitled windows are never handled. Resolves to a function that takes the handler away. Options: WindowHandlerOptions.
app.waitForWindow(action?, { title?, exact?, className?, timeout?, signal? })Waits for a window of the application to open and returns it as a Window. With an action, the window it opened (one not open before it ran); without, one open now or before the timeout. A window without a title (a menu, a drop-down) is never the one, unless className names its class (a splash screen, a palette); of several, the one in front. TimeoutError when none opens in time. Options: WaitForWindowOptions.
app.waitForAnnouncement(action?, text, { exact?, timeout?, signal? })Waits for the application to announce something through UI Automation, as it tells a screen reader of what it did ("Saved 3 orders"): a notification no tree keeps. Returns the Announcement (text, kind, activityId, source, at). The application is listened to from the first call on, through the helper executable; with an action, listening starts before it runs. Without one, an announcement after the application's last action counts when it was listened to then. TimeoutError naming what was heard; not_supported where announcements cannot be heard. Options: WaitForAnnouncementOptions.
app.waitForIdle({ cpuBelow?, quietMs?, timeout?, signal? })Waits until the application is idle: its windows answer, the pointer shows no wait cursor over them, and its processes use less than cpuBelow percent of one processor (5) for quietMs in a row (500). Returns IdleState (cpu over the quiet period where the backend measures it, waitedMs). TimeoutError naming why it is still busy; application_ended when it ended. Actions wait themselves for a window that answers and shows no wait cursor (app_busy at their timeout); this also waits for the processor load, which an action does not look at. Options: WaitForIdleOptions.
app.snapshot({ enrich?, root?, tables?, signal? })Observes the application's current state (DesktopSnapshot): the same controls as a tree (roots) and as a flat list in document order (controls, each with depth, parentId and key), plus the application observed and an enrichment record. Extends ControlSnapshot (takenAt, windows, roots). Handy for debugging locators. enrich asks for more than structure: "text" also reports the text painted on screen that the tree does not expose (naming a control the tree could not name, and standing on its own where it named nothing), "elements" also the tables, input boxes and buttons drawn where the tree reports no control (source region). root limits that extra observation to one control's bounds, by an id from an earlier observation. Where the backend can look at one control alone (the Windows backend, through the helper), root also limits the observation itself to that control and what it holds, and the snapshot's within names the control. Structure costs no pixels; anything more takes one screenshot. Asking for more than this machine can give returns what it can and says so in enrichment. tables lays tables defined from locators out in the snapshot. Every grid's control carries its columns and every row's its cells, aligned to the columns.
app.close({ force? })Asks the application's windows to close, as the close button does, and stops its processes when they are still there 3 s later or could not be asked (force: true stops them at once); also via Symbol.asyncDispose / await using. Returns once its processes have left the process table; a process that is still there 5 s after being stopped is reported with LaunchError. A process id Windows gave to another process since the application was attached is never stopped. Idempotent, and a no-op once the application has exited on its own. A launch terminates only the processes it created — the one it spawned and the processes that descend from it (a launcher stub's application, a process the application started since), or those it created under target: { processName }, or, for any other target, those the launcher handed the application over to, a broker's client whose window appeared since included — never an instance that was already open, and never a process that merely showed a window owned by the application's (a broker's dialog) (app.attachedToExisting is true when the launch joined one and so created nothing); an attached application is terminated through the processes owning its windows, restricted to the named process when the target carries one. A Store app is terminated through its own process. ApplicationFrameHost, which draws every Store app's frame, is never terminated unless it was targeted explicitly (closing a frame whose application the helper executable cannot tell is refused with LaunchError).

Window​

One window of an application: the scope its locators start from, and the window its own operations act on. app.window() is the main window and app.window({ title }) one found again by its title; both are found again whenever they are used, like any locator. app.windows() lists each open window and app.waitForWindow() returns the one that opened as a ListedWindow: a Window held by its handle for as long as it is open, which carries the fields of WindowInfo (handle, title, processId, processName, className, foreground, bounds, scale, dpi, ownerHandle) as they were when it was listed. A Window is a DesktopLocator of that window, so every locator method works on it.

MemberWhat it does
window.getByRole(…), getByLabel(…), getByText(…), …Locators inside the window, the windows it owns (its dialogs and message boxes, however deep) and the menus, drop-downs and tooltips open over them. A window no window of the application owns is a window of its own: three windows alike are three windows, told apart by their handles or titles, never by position.
window.close(), minimize(), maximize(), restore(), bringToFront()The window's own operations, as on any window locator.
window.isOpen({ signal? })Whether the window is open: the application lists it (a listed window), or a window answers for it. Does not wait.
window.currentTitle({ signal? })The window's title now; element_not_found when no such window is open, StrictModeViolationError when several answer for the main one. window.title is a listed window's title at the latest listing, and expectDesk(window).toHaveTitle(…) waits for one.
window.currentBounds({ signal? })Where the window's visible frame is now, in physical screen pixels (Bounds): what window.mouse's points start from and a picture of it covers, without the invisible resize borders. Does not wait.
window.setBounds({ x?, y?, width?, height? }, { timeout?, intervals?, signal? })Places the window's frame there, any side left out staying as it is, and resolves with where the frame is now. A maximized or minimized window is restored first without coming to the front; nothing is brought forward, and a modal dialog over it does not stop it. It waits for the window and for a busy application, as any action does. A window may keep a size it cannot go below, which the record says. RangeError when nothing is asked, a value is not a whole number or a size is below 1; BackendError not_supported when the window cannot be moved or resized (a dialog without a sizing border), as a user could not either. Strategy window.place.
window.keyboardReal keys to this window (WindowKeyboard): type(text), press(key). The window is brought to the front first, unless it or a window it owns (a dialog it opened) is there already, so the keys reach what has the focus in its scope; the operation's record says when it was brought there (broughtToFront). With foreground: "require" nothing is brought forward, and keys to a window behind are refused. Prefer locator.fill() and locator.press().
window.mouseReal mouse input in this window (WindowMouse): click(point, { button?, clickCount?, modifiers? }) (clickCount: 2 double-clicks), drag(from, to, { button?, hold?, steps?, modifiers? }), move(point, { modifiers? }), down(point, { button?, modifiers? }), up(point, { button?, modifiers? }), wheel(point, { deltaY?, deltaX?, modifiers? }) (notches; positive scrolls down or right). modifiers ("Control", "Shift", "Alt", at least one, none twice) are held through that one gesture, pressed before its first event and released after its last inside one helper request, so that none stays held when it fails: Control and the wheel zoom a drawn diagram, Shift and a drag draw a band. A point is in physical pixels from the window's top-left corner where Windows draws it now: a pixel of window.screenshot(), not a screen point (a locator's boundingBox() is the screen's). Where another window lies over the point, the window under it comes to the front when the input needs it, as for any real click. Prefer locator actions, and locator.click({ position }) for a place in a control; see input-engine.md. button is "left" (the default), "right", "middle", "back" or "forward" (the side buttons, XBUTTON1 and XBUTTON2; MouseButton).
window.touchscreenTouch input in this window (WindowTouchscreen, a Touchscreen): tap(point, { count?, hold?, pressure?, size? }) (count: 2 double-taps; a hold of 800 ms is a long press). Each tap is one whole gesture from a synthetic touch device: the finger lifts by its end. There are no swipes or several fingers at once: a swipe never scrolled a list the same distance twice, and Windows' own gestures take three fingers or more. Points are the window's, as for window.mouse; pressure is 0 to 1 (0.5 by default), size a finger's width in pixels (8). Real input, the window brought to the front; RangeError for anything out of range; BackendError not_supported on a Windows before 10 1809. Strategy input.touch.gesture.
window.penPen input in this window (WindowPen, a Pen): tap(point, PenOptions), stroke(points, PenOptions) (each PenPoint may carry its own pressure, tiltX, tiltY, twist), hover(point | points, PenOptions) (in range, not touching). PenOptions: pressure 0 to 1 (0.5), tiltX and tiltY -90 to 90 degrees, twist 0 to 359, barrel (the barrel button held), eraser (the pen turned over), duration (milliseconds; 60 for a tap, 300 for a stroke or hover). The pen comes into range before it touches and leaves after it lifts. Strategy input.pen.gesture.
window.snapshot({ enrich?, signal? })What the window holds, observed now (DesktopSnapshot): the window, the windows it owns and the menus, drop-downs and tooltips open over them, as its locators see them, at the same enrich levels as app.snapshot(). Its windows are those windows alone. Empty once the window closed.
window.screenshot({ path? })A PNG of this window alone, as Windows draws it, also in the background where the backend can; { path, width, height }.

The application gives one ListedWindow per open window: every listing and wait returns the same object for it, its fields refreshed, so windows compare with === and key a Map; a window that closed is forgotten, and a handle Windows gives to a later window names a new one.

A closed window is a control that is not there: what is asked of it waits and then times out, and it never falls through to another window. A window is a scope a test holds, never a mode the application is switched into. An owner the listing does not show (Delphi's TApplication window, which has no area) is stood for by the titled windows it owns directly: their forms are one scope, and the window itself is ambiguous while a dialog is open.

Web pages​

A web page shown in a window: an Electron, CEF or WebView2 page, or one in Internet Explorer's WebBrowser control. webPage() on any locator finds it by its address and its title, and returns a WebPage: a locator whose own control is the page's document. What is found inside it is a plain DesktopLocator: a page's controls are found, acted on and asserted as any others are, and every DesktopLocator member works on them. A page's controls are found from the window too; the page is the scope to use where its controls' names repeat outside it, or where a window shows several pages.

MemberWhat it does
locator.webPage({ url?, title?, exact?, frames? })The outermost page inside the locator that matches url (the address) and title, substrings (case-insensitive) or RegExps, whole and case-sensitive with exact (WebPageOptions). A page's frames, and the webviews a page shows, are pages inside it: page.webPage() finds them, and so does an address only they have; frames: false leaves them out of what the steps after it look in. Strict when several pages match, as while an application's own page shows in a dialog of its own. An empty url or title, or exact with neither, is a TypeError.
page.currentUrl(options?)Waits for the page, then reads its address as its engine reports it: file:///C:/… from Chromium, file://C:\… from Internet Explorer, https://…, about:blank for a page written from a string.
page.currentTitle(options?)Waits for the page, then reads its own title, the document's; "" for a page with none. A window that shows a page may or may not take its title.
expectDesk(page).toHaveUrl(expected, { ignoreCase? })Retrying (WebPageMatchers): the page's address is expected (whole, whitespace-normalised) or matches the pattern. It waits for a navigation too. Since engines write addresses their own way, a pattern on the file name holds on each: toHaveUrl(/orders\.html$/).
expectDesk(page).toHaveTitle(expected)Retrying: the page's own title.

A page's address is read the same way on every engine, which textContent() is not: Chromium's document holds its address as its value, Internet Explorer's page none. close(), minimize(), maximize() and restore() act on windows only: a dialog drawn inside a page (an HTML dialog) is refused with not_supported and answered with its own buttons, since what a window's title bar would tell reaches the window that holds the page.

DesktopLocator​

Locators are lazy and immutable. Creating or chaining a locator never touches the backend; every operation resolves the locator afresh against the live UI. Resolved controls are never stored inside a locator.

Creating locators​

MethodMatches
getByRole(role, { name?, exact?, checked?, expanded?, disabled?, column?, level? })Controls of a semantic role ("button", "textbox", "menuitem", ...), optionally filtered by accessible name and by state: checked (check boxes, toggle buttons, radios), expanded (combo boxes, tree items, menus), disabled. A state option only matches controls that report that state. "dialog" also matches plain windows because UIA does not distinguish them, and "table" also matches a grid: both hold the same rows and cells. A web page's headings are "heading", with level (1 to 9, "heading" only) for their level, and its landmarks "banner", "navigation", "main", "complementary", "contentinfo", "form", "region" and "search"; "text" still matches a heading and "group" a landmark. column (role "cell" only) matches a grid cell by the column header it sits under: the header reads as the whole column name (whitespace-normalised, case-insensitive; case-sensitive with exact), as getByColumn asks, so "Name" is not also "First name"; a RegExp is tested as given; see dotnet-apps.md.
getByColumn(column, { exact? })The cells under the column header that reads as the whole column (whitespace-normalised, case-insensitive; exact for case-sensitive; or a RegExp): a row's cell in that column, every cell of the column in a grid. See tables.md.
getByText(text, { exact? })Any control whose name or value contains the text. A field (text box, combo box, list, tree, grid) matches by its value only: its name is the label beside it, which is found as the text it is. A match inside another match is left out (a WPF button and the text it repeats inside it are one match: the button). Answered by the accessibility tree alone; text only the screen shows is getByOcr's.
getByLabel(text, { exact? })Input-like controls (text boxes, combo boxes, check boxes, radios, sliders, ...) whose label/name contains the text, and a button that reads as its label and what it shows on lines of their own (a Flutter drop-down field, "Colour\nRed") by its first line.
getByAutomationId(id)The control with exactly this AutomationId, or a Qt control whose object name is id (Qt's AutomationId is the path of object names).
getByClassName(name)Controls with exactly this native or framework class name (SysListView32, TPanel, a WPF control's type name). From the tree alone; a Java window's controls carry none.
getByTitle(title, { exact? })Windows, dialogs and panes by title.
webPage({ url?, title?, exact? })A web page shown inside the controls this locator matches (an Electron, CEF or WebView2 page, or one in Internet Explorer's WebBrowser control), by its address and its title, as a WebPage; the outermost page that matches, strict among several (WebPageOptions; see Web pages).
getByImage(image, { threshold?, scales?, colour? })Wherever a picture appears on screen, as a ScreenLocator. image is a file name resolved against images.directory, an absolute path, or PNG bytes. threshold is the minimum correlation (default images.threshold, 0.9); scales replaces the DPI-derived size with explicit multiples of the template. Matching runs in grey; a picture that is itself coloured also has to agree in colour where it matched (a red icon is not a green one), unless colour: false.
rightOf(anchor) / leftOf / above / below / near(anchor, { maxDistance? })Narrowing steps: keep the controls matched so far that sit in that direction from any match of anchor, ordered nearest first. The four directional ones also require the two to share a row or a column. Like nth and filter, they never descend.
getByVision(description, { exact? }) (experimental)Advanced. A control matched by how it looks, as a ScreenLocator, resolved through the configured grounding provider only, never through the tree or read text. For interfaces that expose nothing to UI Automation and paint no text either — prefer the semantic locators above whenever possible.
getByOcr(text, { exact?, fuzzy?, language? })Text read off the screen inside the controls this locator matches: a line of its own, or a control named after the text drawn inside it. Returns a ScreenLocator. Where the tree knows an invokable control at the place the text was read, an action goes through it. fuzzy matches text a reader may misread. language (a BCP 47 tag such as "nl-NL") reads in that language instead of the desktop's ocr.languages; a reader with no recognizer for it fails saying which it has, and the next reader configured reads.
read({ language? })Reads the screen inside the controls this locator matches, in language when given (as getByOcr's; one locator reads in one language), so the steps after it may use what was read: a button named after the text drawn inside it, a field named by the label painted beside it, a grid laid out from what was read where its tree reports nothing (main.read().getByLabel("Name")). Without it, every step is answered by the accessibility tree alone.
locator(query)A selector object ({ type: "role", role: "button", name: "Save" }) or a string (grammar below).
locator({ type: "controlId", id })The control with exactly this DesktopControl.id from an earlier snapshot() (byControlId("…")). A tree control's id is valid until the UI changes. A control found on screen is named by what it is (ocr:Save@12:dlg#2, see screen ids) and is found again by the resolver that found it, wherever the window has moved. Used by the MCP layer to act on semantic references.
filter({ hasText?, hasNotText?, has?, hasNot?, visible?, exact? })Narrows the matched controls to those whose subtree (the control included) has a name or value containing hasText (string or RegExp; exact for a whole-string comparison), lacks hasNotText, contains a match of the has locator, or contains none of hasNot; visible: true keeps those the backend reports visible, false the hidden ones (a hidden tab page's copy of a button is told from the one shown). Like nth, it never descends: getByRole("listitem").filter({ hasText: "Order 42" }).getByRole("button", { name: "Delete" }).
and(other)Narrows the matched controls to those other matches too: a role and a text at once. other is a locator of the same application, matched in full over the same observation from the application (or window) it belongs to, as a relative anchor is, never inside the controls matched so far. Never descends; renders as .and(…); no string form.
or(other)The matched controls and those other matches, in document order, each once: either of two windows a step may open. Strict as any locator: when both are there, an operation that needs one control fails with StrictModeViolationError. Never descends; renders as .or(…); no string form.
nth(index), first(), last()Narrows a locator that matches several controls to one by position in document order (nth(-1) is the last). Evaluated at resolution time like every other step; later steps scope into the picked control. Use it for deliberately repeated controls (list rows, tabs) — a more specific locator, or filter, is always preferable.

A locator names what answers it: the accessibility tree, unless a step of the chain names the screen — read() and getByOcr the reader, getByImage correlation, getByVision a grounding provider — and nothing else runs (see resolution.md). A chain that names two ways of looking at the screen is refused as it is written, with LocatorSyntaxError.

text and name accept a string or a RegExp. Strings match as a case-insensitive, whitespace-normalised substring; { exact: true } switches to a case-sensitive whole-string comparison. { fuzzy: true } (on getByRole, getByText, getByLabel and getByTitle) also accepts a string that differs by up to a quarter of its length, with characters text recognition confuses counting half, for text read off the screen (resolution.md).

Chaining scopes the child within the parent's matches:

const main = app.window();
const dialog = main.getByRole("dialog", { name: "Customer" });
await dialog.getByLabel("First name").fill("Alex");
await dialog.getByRole("button", { name: "Save" }).click();

String locators​

role=button any button
role=button[name=Save] name contains "save" (case-insensitive)
role=button[name="Save"] name is exactly "Save"
role=checkbox[name=Advanced][checked=true] state attributes: checked, expanded, disabled
role=heading[level=2] a web page's heading of that level
text=Welcome label=First name title=Customer automationId=btnSave className=TStringGrid
ocr=Save text read off the screen (ocr="Save" is exact)
read >> label=First name the steps after read may use what the screen shows
vision=blue floppy disk save icon (experimental: grounding provider only)
controlId=3935002:103 (exact control id from a snapshot)
role=listitem >> nth=-1 (positional: 0 = first, -1 = last)
role=listitem >> hasText=Order 42 >> role=button[name=Delete] (filter: narrows, then descends)
role=dialog[name="Customer"] >> label=First name chained

Quoted values ("..." or '...') match exactly; bare values match as substrings. nth, hasText and hasNotText narrow the controls matched by the previous step instead of descending into them; has/hasNot exist in the object form only.

Inspecting a locator​

MemberDescription
locator.queryThe frozen LocatorQuery ({ steps: LocatorSelector[] }).
locator.describe() / String(locator)e.g. getByRole("dialog", { name: "Customer" }) >> getByLabel("First name").
locator.targetThe application identity the locator belongs to.
describeQuery(query)The same rendering for a query (getByRole("button", { name: "Save" }).first()).
screenResolverNamed(query)The screen resolver a query names anywhere in its chain ("ocr" for read() and getByOcr, "image", "grounding" (experimental), or the minter of a control id found on the screen), or undefined for a query the accessibility tree answers alone; throws LocatorSyntaxError for a chain that names two.

Suggesting a locator from a snapshot​

const main = app.window();
const { roots } = await app.snapshot();
const report = candidatesFor(roots, control.id, { capabilities: desktop.capabilities });
report?.recommended?.code; // 'getByLabel("Name")'
report?.candidates; // every candidate, ranked: query, code, class, resolvers, matches, reasons
const all = recommendLocators(roots); // ReadonlyMap<controlId, LocatorCandidate>
const shown = recommendLocators(roots, { ids: ["ok", "cancel"] }); // only those: each costs about 0.9 ms on a big window
const matched = matchQuery(roots, main.getByRole("tree").query); // what a query matches in an observation, without the application

matchQuery(roots, query) applies the engine's matching rules to an observation and returns the controls a query matches there, in document order: what a tool that holds a snapshot (the agent's defined tables) asks without resolving anything again.

candidatesFor lists every locator that could address a control, ranked best first: unique candidates before ambiguous ones, those without a position (nth) before those with one, fewer steps first, then by class — the label beside a field or a role with its name (semantic), a stable AutomationId (identifier), text the control shows (text), the role alone (structural), text read off the screen or a picture (visual), nth (positional), a description for a grounding provider (experimental; vision). Each candidate says how many controls it matches under the engine's own matching rules, which resolvers may answer it under the strategy and capabilities given, what evidence it rests on, and why, as reasons with a good, warn or bad verdict. When no single step is unique, the nearest ancestor with a unique step of its own becomes the scope, then the row, list item or tree item that a text it holds tells from its siblings (getByRole("row").filter({ hasText: "42" }) >> …), and nth is the last resort. recommended is the first unique candidate; recommendLocators computes it for every control with one index of the tree. app.window().locator(step) accepts every suggested step; renderQuery renders a query as code.

Rendering a query as code, and a recording as a test​

const main = app.window();
renderQuery(locator.query, { root: "app" }); // 'main.getByRole("row").filter({ hasText: "42" }).getByRole("button", { name: "Edit" })'
renderTest({ start: { kind: "attach", target: { processName: "CustomerApp" } }, steps }, { title: "edits a customer", runner: "vitest" });

renderQuery differs from describeQuery in that every step comes out as the call that builds it (an anchor inside filter or a relative step as a nested chain from the same root), and a query with no code form — a control id from an observation, a picture given as bytes — renders as undefined. renderTest renders a Recording (how the application was started and the RecordedSteps taken, each with its locator) as a test file in the shape spart-desk init writes, for Vitest, node:test or Playwright Test (runner), as the steps alone (mode: "snippet"), or with the controls the steps use as getters of a page class the test acts through (mode: "page"); maskValues keeps typed values out of the source, and a step marked secret is always masked. A RecordedStep is an action on a control (click, fill, press, check, expand, hover, dragTo, scroll, clear, focus, tap, …), on a window (window, setBounds, keyboardPress, keyboardType) or on the application (clipboardWrite), a wait (waitFor), or an assertion rendered as expectDesk (expectVisible, expectHidden, expectEnabled, expectChecked, expectExpanded, expectFocused, expectEditable, expectSelected, expectText and expectValue whole, as a part or as a pattern, expectTitle, expectRowCount, expectItemCount, expectRows, expectScreenshot). The agent package's desktop_export_test renders through it.

A picture is named by its name (getByImage("crm/main/save")). Given the image folder's typed index, both write the index's expression instead:

  • renderQuery's imageExpression(name) returns the code for a name (images.crm.main.save), or undefined to keep the name in quotes.
  • renderTest's pictures: { expression, importFrom } does the same for every step, and adds import { images } from "<importFrom>"; to a file that uses one. A snippet gets no imports.

writeSupportBundle({ app, out, redact?, notes?, properties?, cwd? }) (SupportBundleOptions) writes a support bundle of a running application, as npx spart-desk support capture --app writes one: its tree, pictures of its windows, what SPART-Desk saw and was weak on, redacted unless redact is false. It answers a SupportBundleResult (directory, windows, notes).

Operations and auto-waiting​

Every operation except isVisible auto-waits: it re-resolves the locator at the polling interval until the control satisfies the operation's requirements or the timeout elapses. No fixed sleeps are involved.

MethodWaits until the single match is ...Then (strategy: UIA first, real input as fallback)
click({ button?, mouse?, modifiers?, position?, until?, sends?, timeout?, signal? })visible, enabled, stable, and its window not blocked by a modal dialoga push button of Windows' own is pressed through its window (window.press: its click runs after the action returned, so a handler that shows a modal dialog holds nothing up); otherwise uia.invoke when the control is invokable, else input.mouse.click on the control (button is "left" or "right"). A plain left click on an item of a native combo box's drop-down list (Win32, Windows Forms, Delphi VCL) chooses it as selectOption does: the combo box is set to the item's text and told its selection changed, since invoking the item would double-click where it is. A plain click on an item of another toolkit's open drop-down list is sent as it is, then the combo box is asked to close the list, as a user's click leaves it (WPF, Avalonia, JavaFX and web pages leave it open otherwise). mouse: true clicks with the real mouse at the control's middle, as a user does: what only a real click runs (a WPF check box's Click handler and bound command) runs too. modifiers (ClickModifier[]: "Control", "Shift", "Alt", at least one, none twice) holds those keys while a real click is sent, down before it and up after it whatever it did; position ({ x, y }, whole pixels from the control's top-left corner, as dragTo's sourcePosition) clicks there with the real mouse, checked at that place as a click at the middle is. Either makes the click real input, needing the window in front as mouse: true does.
dblclick({ button?, modifiers?, position?, until?, sends?, timeout?, signal? })visible, enabled, stableinput.mouse.dblclick; modifiers and position as for click
check({ mouse? }), uncheck({ mouse? }), setChecked(checked, { mouse? })visible, enabled, stable, and reporting a toggle statenothing when the control already is in that state (recorded as observe); otherwise one click (uia.invoke / input.mouse.click; with mouse: true a real click), then the wait continues until the backend reports the new state; a check box is never toggled twice. A control without a toggle state fails with BackendError not_supported. expand()/collapse() work alike, and ask once more another way (the arrow key on a tree item) when the application ignored the first.
expand(), collapse()visible, enabled, stable, and reporting an expansion statethe same, on the expansion state of combo boxes, tree items and menus. A drop-down that reports no expansion state (Flutter's, a painted one read off the screen), or reports itself collapsed while its list shows (Qt Quick's), is pressed once by expand() and counts as expanded when its items show around it
selectOption(option, { exact?, timeout?, signal? })visible, enabled; a combo box or a listchooses the item whose text is option (a string or a RegExp; an item whose whole text is the option wins): opens a combo box when needed, uia.invoke on the item, uia.set-value on the combo box when that did not take, closes it (a Delphi VCL combo box is told of the choice as a user's would tell it, DesktopDriver.commitSelection); nothing when the option is already shown. Anything else fails with BackendError not_supported.
fill(value, { until?, sends?, timeout?, signal? })visible, enableduia.set-value, otherwise focus + Ctrl+A + input.keyboard.type; a Delphi data-aware field (TDBEdit) is typed into through its own window (window.type)
clear({ timeout?, signal? })visible, enabledfill(""), recorded as clear
pressSequentially(text, { timeout?, signal? })visible, enableduia.focus + input.keyboard.type key by key at the caret, without clearing; for controls that reject a programmatic value. After a fill the caret is wherever the control left it (a RichEdit box puts it at the start): press("End") first to append.
press(key, { until?, sends?, timeout?, signal? })visible, enabledinput.keyboard.press — window messages for plain keys, real input for chords ("Control+S")
focus({ timeout?, signal? })visibleuia.focus; a click at the centre for vision controls. A control its provider reports not keyboard-focusable is refused after one try with BackendError not_supported (not retryable), naming the nearest control that can take the focus; press() and pressSequentially() refuse it the same way
hover({ timeout?, signal? })visible, stableinput.mouse.hover
tap({ position?, count?, hold?, pressure?, size?, timeout?, signal? })visible, enabled, stable, and its window not blocked by a modal dialog nor the application busyinput.touch.gesture: a finger at the control's middle (or position from its top-left), checked to land on the control as a click is; count: 2 double-taps, hold milliseconds down (60 by default) makes a long press. TapOptions
dragTo(target, { hold?, steps?, sourcePosition?, targetPosition?, modifiers?, timeout?, signal? })this control visible and stable, and the target on the screen, each one control, within one timeout; a target that stays ambiguous fails with StrictModeViolationError, one never found with a TimeoutError, both naming the target; the press is checked to land on this control, where it was observed and enabled, with no dialog or busy application in the way, as a click isinput.mouse.drag: a real press on this control's middle (or sourcePosition from its top-left), hold milliseconds held, steps moves to the target's middle (or targetPosition), and the release there; modifiers are held from before the press to after the release (Control at the drop copies where a plain drop moves)
scroll({ direction? | to? | wheel?, modifiers?, timeout?, signal? })visible, stableuia.scroll (ScrollPattern), otherwise / with wheel input.mouse.wheel; wheel is whole notches from -1000 to 1000 other than 0, positive scrolling down, as mouse.wheel's deltaY (which also takes part of a notch). modifiers are held while the wheel turns (Control and the wheel zoom a drawn diagram), and only with wheel: a pattern holds no key (RangeError otherwise)
scrollIntoView({ timeout?, signal? })attached (paging a list or grid the locator scopes into while nothing matches)nothing when visible; uia.scroll-into-view, or uia.invoke on a cell, row or item its container will not scroll; then waits until it is visible
textContent({ timeout?, signal? })attachedreturns value (a rich text box's paragraphs through its Text pattern), falling back to name
screenshot({ path?, mask?, timeout?, signal? })visiblea picture of the control, cut out of its window's, with the places of the mask locators painted over; returns Screenshot (the PNG file's path, width, height). LocatorScreenshotOptions.
boundingBox({ timeout?, signal? })attachedreturns physical screen Bounds (or undefined): the screen's pixels, where window.mouse takes the window's
isEnabled({ timeout?, signal? })attachedreturns whether it is enabled
isChecked({ timeout?, signal? })attachedreturns whether it is checked; BackendError not_supported when it reports no toggle state, rather than false
isFocused({ timeout?, signal? })attachedreturns whether it has the keyboard focus (DesktopControl.focused); BackendError not_supported when the backend reports no focus state
isSelected({ timeout?, signal? })attachedreturns whether the item is the selected one of its list, tab strip, tree or combo box's list; BackendError not_supported when it reports no selection state, rather than false. An item without UI Automation's selection that MSAA calls selectable answers through MSAA (Flutter's list items). A tab found on the screen under read(), painted over the pages a control reports (Krypton's navigator), answers as the screen shows it: the tab whose page is shown
details({ all?, timeout?, signal? })attachedreturns ControlDetails (DetailsOptions): what the control says of itself beyond its name, value and states, read when asked (one read of the control, never part of a tree read); all: true adds properties, every property the control's provider says it may support by UI Automation's name as a person reads it, and patterns, every pattern (a driver gets it as DetailsRequest). helpText (what a WPF or UWP tooltip shows), fullDescription, itemStatus, acceleratorKey, accessKey, password, required, readOnly, selected, framework ("WPF", "WinForm", "Win32", "XAML", "Chrome", "Java"), localizedRole, orientation, range (ControlRange: value, minimum, maximum, smallChange, largeChange, readOnly) and msaa (MsaaDetails: what MSAA says, name, value, description — a Windows Forms control's AccessibleDescription —, help, keyboardShortcut, defaultAction, role and states in MSAA's words). A field the toolkit does not give is absent. A Java control gives its description as helpText and a slider's range. BackendError not_supported for a control found on the screen, or a backend without controlDetails
bringToFront() ({ timeout?, signal? })attachedthe window that holds the control brought to the front through the window manager (window.activate; DesktopDriver.activate), where real input reaches it; actions that need the front do this themselves
close(), minimize(), maximize(), restore() ({ timeout?, signal? })attached, and a windowthe window's title-bar button (uia.invoke), found by AutomationId; minimize() leaves a minimized window alone, maximize()/restore() press Maximize/Restore again when the window was already in the wanted state; restore() of a minimized window without a Restore button (a Store app's empty frame) restores it as its taskbar button does (window.restore); DesktopDriver.windowAction is the backend's side
isVisible({ signal? })— (no waiting)false when nothing matches, else visibility of the single match
count({ signal? })— (no waiting, never strict)how many controls match right now
all({ signal? })— (no waiting, never strict)one positional locator (nth(0), nth(1), ...) per control matching right now
allTextContents({ signal? })— (no waiting, never strict)the text (value, else name) of every match, from one observation
waitFor({ state?, timeout?, signal? })"visible" (default), "attached", "hidden" or "detached"; a ScreenLocator takes "visible" or "hidden" only (ScreenWaitForOptions)returns

click, dblclick, fill and press take a postcondition (Postcondition): until is a check that throws while what the action should have done has not happened (an expectDesk assertion with a short timeout), and the action is sent again, up to sends times in all (3; 1 to 5), within the action's timeout. Each send is recorded on its own. An action whose delivery is unknown is never sent again, and AssertionError (assertion: "until") says how many sends were made when the check never held:

const main = app.window();
const saved = main.getByText(/^Saved/);
await main.getByRole("button", { name: "Save" }).click({ until: () => expectDesk(saved).toBeVisible({ timeout: 1_000 }) });

Actions (not observations or assertions) also bring their control on screen: one that is off screen is scrolled into view once, and when a locator that scopes into a list or grid finds nothing, that container is paged, one page per attempt (the mouse wheel in the foreground, the scroll pattern in the background). Where the lines it shows are in order by what the locator asks for (a grid sorted by the column of where(column).equals(value), a list of "Item 1" … "Item 200" asked for Item 150), the view is set to where the line should be instead, as dragging the scroll bar there does, and adjusted by what the next look shows; see actions.md.

The fallback is taken only when the backend reports the pattern as unsupported. Real input requires the target window to be in the foreground: when an action has no background way left, the desktop brings its window to the front for it (broughtToFront: true on the record), unless it was created with foreground: "require"; when the window stays behind, the action fails with BackendError foreground_required and a hint. Every action is recorded in desktop.actions.

"Stable" means the control's bounds did not change between two consecutive observations. A control that is actionable at first sight is used immediately; only one that was seen moving needs a second, unchanged observation. Real input is checked once more the instant before it is sent: the helper refuses a control that is elsewhere than it was observed (element_moved, read as moving: the wait looks again until two looks agree) or disabled by then (element_not_enabled), and nothing is sent.

timeout overrides the desktop default for one call, and so does intervals, the pauses between two observations ([50, 100]: 50 ms, then 100 ms between every further round; the desktop default is [100, 250, 500, 1000]). signal is a standard AbortSignal: aborting it stops the wait and any in-flight backend call with a CancelledError.

Transient backend failures whose retryable flag is set (the application is still starting, the control vanished between resolve and act) are retried within the same timeout; any other backend failure is thrown immediately.

Strictness​

Operations that act on one control keep waiting while the locator matches more than one control and the set of matches keeps changing (a dialog replaced by its successor, a list refreshing). Once the same controls have matched on three attempts in a row spanning at least a second, the failure is a StrictModeViolationError that lists every candidate and, as hints, what may narrow the locator: how many candidates are hidden, and a locator of its own for each:

StrictModeViolationError
getByText("Save") resolved to 3 controls.
1) menuitem "Save"
2) button "Save" [automationId=btnSave] (615,591 113x33) disabled
3) text "Save and close"
Hint: to pick 1): getByRole("menuitem", { name: "Save" })
Hint: to pick 2): getByAutomationId("btnSave")
Hint: to pick 3): getByText("Save and close")

isVisible is strict immediately (it does not wait). waitFor({ state: "hidden" | "detached" }), toBeHidden, not.toBeVisible and not.toExist are strict too: several matches that persist are an ambiguity, not an absence. nth(), first() and last() opt out of strictness for one locator by picking a position; count(), all(), allTextContents() and expectDesk(...).toHaveCount() are about several controls and are never strict.

Timeout errors​

TimeoutError
Timeout 5000ms exceeded waiting for getByRole("button", { name: "Save" }) to be visible, enabled, stable.
Last observed state: disabled
1) button "Save" [automationId=btnSave] (615,591 113x33) disabled
Application: pid=39144 process="charmap" · windows: "Character Map" (hwnd 3935002)
Backend: windows

Properties: locator, expectation, timeout, lastState, candidates, application, windows, backend, resolver, visionBackend, resolvers, why and attachments (the screenshot and the UI tree captured on failure, when diagnostics are enabled). A last state of not found also names what may be meant instead: a control of the same name under another role, or a name a typo away under the same role, each as the locator that finds it. why, printed as a Why: line, says why nothing was found when that can be told: the application's windows report next to nothing to UI Automation, no control of the role asked for is in its tree at all, no text names the field asked for by its label, or no control carries an identifier of its own (only window handles, or ones its toolkit made up). A control that is not found is still waited for to its timeout: it may yet appear. StrictModeViolationError carries locator, candidates, attachments and hints.

ScreenLocator​

getByOcr, getByImage and getByVision (experimental) return a ScreenLocator: a locator of a control found on the screen rather than in an accessibility tree. It holds nothing, so no step locates inside it (hit.getByText(...) does not compile, and locator("image=x >> text=y") throws LocatorSyntaxError as the chain is built); it reports no state, so it has no check, expand, focus, selectOption, scrollIntoView, isEnabled, isChecked, details or window operation; and it holds nothing to look into, so it has no filter and no and. What remains is what a place on the screen can take: click, dblclick, tap, hover, fill, clear, press, pressSequentially, dragTo, scroll({ wheel }) (WheelScrollOptions); textContent and allTextContents (the text read there: the whole line a getByOcr pattern matched, which is how a painted value is read; a picture or a grounded match reads as its name), screenshot, boundingBox, isVisible, count, all; waitFor for visible or hidden (ScreenWaitForOptions: a hit is visible whenever it is found); and the narrowing steps nth, first, last, rightOf, leftOf, above, below, near, or, each returning a ScreenLocator again, so one of several identical icons is the one beside a label, and a number read off the screen is the one right of its caption: main.getByOcr(/^\d+$/).rightOf(main.getByText("Total")). Where the accessibility tree knows an invokable control at the place a hit was found, an action goes through it. A ScreenLocator is an anchor like any other, so a control the tree reports is found beside it: main.getByRole("textbox").rightOf(main.getByOcr("Name")).

Both kinds share LocatorBase, the abstract class that holds everything a locator of either kind can do; AnyLocator is DesktopLocator | ScreenLocator, what anchors, and, or, filter({ has }), dragTo and screenshot masks accept (main.getByRole("button").and(main.getByOcr("Save")) is the button whose painted caption reads Save). A DesktopLocator is never assignable to a ScreenLocator, nor the other way round, so a page object says which it returns.

Tables​

window.table(...) and locator.table(...) return a Table: a grid's rows found by what their cells hold and its cells by their column, in the words a test uses. Lazy and immutable, like a locator. The guide is tables.md.

MemberDescription
table.locatorThe grid itself, a DesktopLocator: scroll(), scrollIntoView(), expectDesk(table.locator).toBeVisible().
table.row(conditions?)The rows, as a RowQuery. conditions maps column names to equals (a string) or matches (a RegExp) conditions, for data-driven tests.
table.getByColumn(column, { exact? })Every cell of the column, top to bottom (a DesktopLocator).
table.columns({ timeout?, intervals?, signal? })The header texts, left to right. Waits for the table to exist, as read() does, so an empty list is a table without headers. Recorded as a columns operation.
table.read({ scroll?, maxRows?, maxPages?, timeout?, intervals?, signal? })The table as data: one TableRecord per row, each cell's text keyed by its column's header (a cell under no named header by its position, a repeated header by its rank: "Name (2)"). Waits for the table to exist, then reads the rows it shows from one observation and never pages; with scroll: true takes the grid to its top and pages it to its end, stitching the pages, and fails with TimeoutError rather than return part of a grid it could not finish in time. A grid that cannot be taken to its top is read from where it is, its first view included. maxRows and maxPages stop a paged read early, which its record says. A cell's text is its value, else its name, else what it holds. Recorded as a read operation.
table.describe()table("Customers"), or the definition as written.
query.where(column) / query.and(column)A condition on the cell under the column, completed with .equals(text) (whole text, whitespace-normalised, case-sensitive), .contains(text) (case-insensitive substring), .matches(regexp), a number (.equals(n), .greaterThan(n), .lessThan(n), .between(min, max), each with NumberOptions { locale? }: the one number the cell shows, its separators and currency set aside) or .has((cell) => cell.getBy…(…)) (a control the cell holds); each returns a new RowQuery. The cell's text is its name or value, or that of anything inside it.
query.first(), query.last(), query.nth(index)The row at that position among the matches, in document order; with pages: "all" among the matches of every page.
query.getByColumn(column, { exact? })The cell under the column in the row the query finds, a lazy DesktopLocator: an action on it pages to the row as an action on the row does, and is strict as the row is.
query.find({ scroll?, strict?, maxPages?, timeout?, intervals?, signal? })Waits (within one timeout for all it does) until exactly one row matches, paging a virtualised grid to it (scroll: false looks only at the rows shown), and returns the row: a Row, which is a DesktopLocator whose getByColumn takes the table's column names. Strict: several matching rows fail with StrictModeViolationError; none in time with TimeoutError, whose lastState lists the columns seen, the rows shown and how many rows each condition matched on its own. strict: "all-pages" also looks at every page of the grid for another matching row; with scroll: false it is a TypeError. Recorded as a find operation.
query.all({ pages?, maxPages?, timeout?, signal? })The matching rows shown now, one Row each; no waiting, no paging, never strict. With pages: "all" (CountOptions) the grid is paged to its end, and each matching row is a Row for that row by every cell it showed, which an action pages the grid back to (a read sees it only where the grid shows it); recorded as an all operation.
query.count({ pages?, maxPages?, timeout?, signal? })How many rows match now; no waiting, no paging. With pages: "all" (CountOptions), the table is waited for (none in time is a TimeoutError), the grid is paged to its end and each matching row counted once; a grid the helper reads whole is not paged, whether or not a row matches.
query.isEmpty({ timeout?, intervals?, signal? })Whether no row the grid shows matches. Waits for the table (exactly one: none in time is a TimeoutError, several a StrictModeViolationError), not for its rows; no paging. Recorded as an isEmpty operation.
query.locatorThe DesktopLocator the query compiles to: the table's rows with one filter({ has: getByColumn(...).filter({ hasText }) }) per condition, and the position. expectDesk(query).toHaveCount(n) waits for a number of rows (the query asserts as its locator).
query.describe()The query as written: table("Customers").row().where("Status").equals("Active").
table.sortBy(column, { descending?, timeout?, intervals?, signal? })Clicks the column's header until the rows shown are in order by it (numbers as numbers), at most three times; rows already in that order are left alone; a column that does not sort is a not_supported error, and so is a grid that shows fewer than two rows with a value in the column; a column the headers read do not name is an element_not_found error at once, not at the end of the timeout. Recorded as a sortBy operation.

Types: TableDefinition<C> ({ name?, root?, rows?, cells?, header?, pageDown?, columns?: readonly C[] | { [K in C]: ColumnDefinition } }, the parts functions from the locator they locate from), ColumnDefinition ({ index }, { locator } or { header }, each with an optional value: a CellValueSource, "text" | "value" | "name" | "checked", where its cells take the text where(), read() and getByColumn() see), FindOptions (ActionOptions plus scroll, strict and maxPages), ReadOptions (ActionOptions plus scroll, maxRows and maxPages), CountOptions ({ pages?: "all", maxPages?, timeout?, signal? }, what count() and all() take), NumberOptions ({ locale? }), SortOptions (ActionOptions plus descending), ColumnOptions ({ exact? }), Row<C> (a DesktopLocator with getByColumn(column: C)), TableRecord<C> (Readonly<Record<C, string>>); the classes Table<C>, RowQuery<C> and ColumnCondition<C>. C is inferred from columns, and is string without it. Assertions on a table: expectDesk(table) below. A table defined from locators, or by its root, compiles to a table step (TableStepOptions, TableColumnStep) followed by the same steps.

Lists​

window.list(...) and locator.list(...) return a List: a list's items found by what they say. The guide is tables.md.

MemberDescription
list.locatorThe list itself, a DesktopLocator.
list.item()The items, as an ItemQuery.
list.read({ scroll?, maxRows?, maxPages?, timeout?, intervals?, signal? })The items shown now as text (value, else name, else what it holds); waits for the list to exist. With scroll: true the list is read to its end page by page, joined by the items' places where the backend knows them, else by what they show, which the record then says; maxRows (items) and maxPages stop it early. Recorded as a read operation.
list.describe()list("Order sets"), or the definition as written.
query.named(text, { exact? })Items whose whole text is text (case-sensitive; exact: false for a case-insensitive substring).
query.containing(text), query.matches(regexp)Items whose text contains text (case-insensitive), or matches the pattern.
query.first(), query.last(), query.nth(index)The item at that position among the matches.
query.find({ scroll?, strict?, maxPages?, timeout?, intervals?, signal? })Waits until exactly one item matches, paging a virtualised list to it, and returns it as a DesktopLocator. Strict; strict: "all-pages" also looks at every page of the list for another matching item. Recorded as a find operation.
query.all({ pages?, maxPages?, timeout?, signal? }), query.count({ pages?, maxPages?, timeout?, signal? })The matching items shown now, or how many; no waiting, no paging. With pages: "all" the list is paged to its end and each matching item counted once; all gives each as a locator by its whole text.
query.isEmpty({ timeout?, intervals?, signal? })Whether no item the list shows matches; waits for the list to exist first. Recorded as an isEmpty operation.
query.locator, query.describe()The DesktopLocator the query compiles to, and the query as written.

Types: ListDefinition ({ name?, root?, items?, pageDown? }); the classes List and ItemQuery.

Trees​

window.tree(...) and locator.tree(...) return a Tree: a tree's items by their path. The guide is locators.md.

MemberDescription
window.tree(name | { name?, root? })A tree: the tree with that accessible name, or the items a root holds. Also on a locator.
tree.node(name, { exact? })A top item, as a TreeNode: a DesktopLocator whose name is the whole text by default (exact: false for a substring).
node.node(name, { exact? })A child item of this one: one the node holds, not one its children hold.
node.describe()The path as written: tree("Folders").node("Customers").node("Active").
tree.locator, tree.describe()The tree itself, and the tree as written.

Each level is a tree item with that exact name that no other tree item holds below where the step starts ([outermost=true] in the locator grammar, and outermost: true in the locator() object form; getByRole takes no such option), so a path names one item whether the tree nests its items directly or in groups. Such a step is answered from the tree only. An action on a node (a wait that reveals) expands a collapsed item along the path first, once per wait, through its pattern or its expander button; the record says so. Resolving alone (count(), all()) opens nothing. A tree that marks levels only by aria-level, with its items as siblings, is not nested for a path.

Types: TreeDefinition ({ name?, root? }), TreeNodeOptions ({ exact? }); the classes Tree and TreeNode.

Assertions: expectDesk​

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

await expectDesk(main.getByText("Completed")).toBeVisible();
await expectDesk(main.getByLabel("Name"), { timeout: 10_000 }).toHaveValue("Alex Example");
await expectDesk(main.getByRole("button", { name: "Save" })).not.toBeEnabled();
const rows = await expectDesk.poll(() => countRows(app)).toBeGreaterThan(0); // resolves with the value
const id = await expectDesk(async () => readOrderId(app)).toPass(); // retries while it throws; resolves with the return value
expectDesk(id).toMatch(/^ORD-/); // one-shot

expectDesk of a ScreenLocator (what getByOcr, getByImage and getByVision (experimental) return) gives ScreenLocatorMatchers: toBeVisible, toBeHidden, toHaveText, toContainText, toHaveCount, toHaveScreenshot and .not. A control found on the screen is visible whenever it is found, so there is no toExist beside toBeVisible; it reports no state, so toBeChecked, toBeEnabled, toBeFocused, toHaveValue and toHaveTitle are not there to call. toHaveText on a getByOcr pattern is how a painted value is asserted: await expectDesk(main.getByOcr(/^Count: /)).toHaveText("Count: 3 matches").

MemberDescription
expectDesk(locator, messageOrOptions?)Retrying locator assertions: toBeVisible, toBeHidden, toExist, toBeEnabled, toBeDisabled, toBeChecked({ checked? }), toBeExpanded, toBeFocused (the keyboard focus, as the tree reports it), toBeEditable (enabled and not read-only, what fill waits for; the Value or RangeValue pattern asked each attempt as details() asks it), toBeSelected (the chosen item of its list, tab strip, tree or combo box, as isSelected() reads it), toHaveText(expected, { ignoreCase? }), toContainText(expected, { ignoreCase? }), toHaveValue(expected, { ignoreCase? }), toHaveTitle(expected, { ignoreCase? }) (a window's title, or a web page's), toHaveCount(n), each with .not and { timeout, intervals, signal } per call; toHaveScreenshot(name, ScreenshotAssertionOptions) and toMatchTreeSnapshot(name, TreeSnapshotOptions) compare with golden files (SnapshotsDefaults, SnapshotUpdateMode; never negated; assertions.md). They run the action wait loop until expect.timeout (or the per-expect / per-call timeout), pausing per intervals, honour strict mode (except toHaveCount, which counts) and record an expect.* / expect.not.* operation. A state or value the control cannot report is refused with BackendError not_supported once that has settled (about a second), not at the timeout: toBeChecked, toBeExpanded, toBeFocused, toBeEditable and toBeSelected on a control with no such state (read off the screen, or without the pattern), toBeDisabled and not.toBeEnabled on a control read off the screen (which toBeEnabled takes as enabled, as actions do), and toHaveValue on a slider, spin box, combo box or progress bar that holds no value (never compared by its name); the refusal says what the control lacks, why, and what to assert on instead.
expectDesk(rowQuery | itemQuery, messageOrOptions?)A table's row query or a list's item query asserts as its locator (LocatorMatchers): expectDesk(table.row().where("Status").equals("Open")).toHaveCount(3) waits for three matching rows.
expectDesk(list, messageOrOptions?)Retrying assertions on a list's items (ListMatchers): toHaveItems(items) (exactly these items, in this order), toContainItems(items) (each matches an item of its own, in any order), toHaveItemCount(n) and toBeEmpty(), each with .not and PagedAssertionOptions (pages: "all" reads the whole list on each attempt). An item is a whole text (whitespace aside) or a RegExp. The items are read again on every attempt; a failure lists the items expected and the items last read.
expectDesk(table, messageOrOptions?)Retrying assertions on a table's rows (TableMatchers): toHaveRows(rows) (exactly these rows, in this order), toContainRows(rows) (each matches a row of its own, in any order), toHaveRowCount(n) (that many rows shown; a count that is not a whole number of 0 or more rejects with RangeError) and toBeEmpty() (no rows shown), toHaveColumns(columns) (the headers, in order; none of the rows read), toBeSortedBy(column, { descending? }) (the rows in that order by the column, as sortBy tells one, two rows at least; SortedAssertionOptions) and toHaveColumnValues(column, values) (one column's cells in order: texts, RegExps or numbers), each with .not and PagedAssertionOptions { timeout, intervals, signal, pages?, maxPages? } (toHaveColumns takes AssertionOptions): with pages: "all" each attempt reads the whole grid page by page, a grid the helper reads whole once; a read maxPages stopped before the end decides only what its pages can, and a column the grid does not have passes neither an assertion nor its negation. A grid read off the screen is compared with what readers confuse set aside, as a list's items are. An expected row (ExpectedRow) names only the columns it cares about, each by its header with case and spaces aside: a string is the cell's whole text, a RegExp is tested as given, a number is the one number the cell shows (en-US). The rows are read again on every attempt (table.read() without paging); a failure lists the rows expected, the rows last read in the columns named, and the first difference. Recorded as expect.toHaveRows / expect.not.toContainRows. See tables.md.
expectDesk.poll(callback, { timeout?, intervals?, message?, signal? })Calls the callback until a value matcher holds; resolves with that value. A thrown error propagates at once.
expectDesk(callback, messageOrOptions?).toPass({ timeout?, intervals?, signal? })Retries while the callback throws; resolves with its return value. Defaults, in order: the call's options, configure's toPass, configure's timeout/intervals, the project configuration's expect.toPass.timeout (falls back to expect.timeout) and expect.toPass.intervals (falls back to intervals), the built-ins. A desktop's own, as Desktop.create({ expect }) set them, reach it through expectDesk.configure({ ...desktop.config.expect, intervals: desktop.config.intervals }).
expectDesk(value, messageOrOptions?)One-shot value matchers with .not: toBe, toEqual, toBeTruthy, toBeFalsy, toBeDefined, toBeUndefined, toBeNull, toContain, toHaveLength, toMatch, toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual, toBeInstanceOf (the same set poll offers).
expectDesk.configure({ timeout?, intervals?, toPass?, message?, signal? })A new expectDesk with these defaults (ExpectOptions); toPass: { timeout?, intervals? } applies to toPass alone. messageOrOptions is a string or the same options; the message is the first line of a failure.

Every failure is an AssertionError (expected/actual, last state, and for locators the candidates, resolver/backend and failure attachments). Details, precedence rules and the semantics of .not: assertions.md. Optional: the engine works without it.

Configuration file​

spart-desk.config.ts (export default defineConfig({ ... })) holds the Desktop.create options. Exports: defineConfig(config), loadConfig({ cwd?, file?, env? }) (→ { path, config } or undefined), findConfigFile, CONFIG_ENV (SPART_DESK_CONFIG), the types SpartDeskConfig, ExpectDefaults, LoadConfigOptions, LoadedConfig, and ConfigError. See configuration.md.

Errors​

ClassWhen
AssertionErrorAn expectDesk expectation did not hold: a locator assertion in time, a poll()/toPass() within its budget, or a one-shot value assertion; assertion (not. prefix when negated), locator (or poll(), callback, value), timeout (0 for one-shot), customMessage, expected, actual, lastState, history (what the wait saw, round by round, printed as Waited:; empty for a one-shot assertion), candidates, resolver, backend, why (why the locator found nothing, when that can be told), attachments, cause (the TimeoutError, or the last error of toPass).
ConfigErrorspart-desk.config.ts cannot be loaded or is invalid (unknown setting, no object exported); file names it.
TimeoutErrorAn auto-waiting operation did not reach its state in time; carries lastState, history (what the wait saw before it, round by round, the same state on rounds in a row as one stretch, printed as Waited:), resolver, visionBackend, why (as on AssertionError) and failure attachments (screenshot, UI tree).
StrictModeViolationErrorA single-target operation matched the same several controls for about a second; candidates, attachments and hints (what may narrow the locator: how many candidates are hidden, a locator of its own for each, printed as Hint: lines).
CancelledErrorAn AbortSignal fired.
BackendErrorThe backend reported a failure; carries backend, code, retryable, details and, for input/capture commands, a hint naming the environment requirement. Notable codes: not_supported (no pattern / capability; a state, a value or an action the control cannot give, refused once that has settled, its message naming what the control lacks and why, its hint what to do instead), foreground_required (real input refused because the window is not in front), off_display (a click at a point that lies on no display, where real input reaches no window), session_locked (the same on a locked session, where no window comes to the front: unlock it, or keep it from locking while tests run), session_disconnected (the same on a Remote Desktop session whose client disconnected: reconnect it), session_non_interactive (the same in a session that takes no real input at all, session 0 or a window station no display shows: run the tests in a user's logged-on desktop, ci.md), input_busy (retryable: another run held the right to send real input to this desktop for 5 s, named in the message; waited out within the action's timeout, then the action's error), reserved_key (Ctrl+Alt+Del and Win+L, which Windows keeps for itself), elevation_required (the window runs above the test runner's integrity level — as administrator, or Windows' credential prompt — so Windows keeps it out of reach; run the runner elevated, elevated.md), focus_not_moved (retryable: another control kept the keyboard focus, so no key was sent; waited out within the action's timeout, then the action's error, naming that control), read_only (the field takes no value; nothing was typed), window_blocked (a modal dialog blocks the control's window, named in the message: waited out within the action's timeout, then the action's error, no longer retryable; also when the helper could not tell), app_busy (the application is busy: the control's window took no message within half a second, or the pointer shows the wait or app-starting cursor over it; waited out the same way, then the action's error; a pointer that rests where the desktop's own last click left it is first moved a pixel aside and back, once, as a window that appeared under it shows a stale cursor until it moves), occluded (something else inside the window lies over the control under a real click's point, named in the message: waited out, then the action's error), element_moved (retryable: the control is elsewhere than it was observed the instant before real input, so nothing was sent; the wait reads it as moving and acts once two looks find it in one place), application_ended (none of the application's processes runs any more: the action fails at once, and a wait on an application whose window was observed fails at once too, rather than timing out on the locator), element_not_enabled (retryable: the control became disabled between the tree read and the act, as UI Automation reported it; it is observed again and reported disabled, or acted on once enabled, never clicked at its old place), delivery_unknown (a call that delivers something was stopped before it answered: never sent again), helper_unavailable and helper_protocol (the helper executable is missing, blocked, or from another build: the first action fails at once), automation_unavailable, not_application and automation_failed (app.automation), command_not_found, command_not_started, command_timeout and output_too_large (desktop.run), helper_exited (a question stopped the helper, then the restarted one too: not asked a third time) and helper_lost (the helper stopped four times within five minutes and is not started again for this desktop), ambiguous_selector, application_not_found, element_not_found. A wait on an application whose window takes no messages ends at its timeout with the last state "the application is not responding". Real input on a locked session is refused before it is sent (session_locked).
BackendUnavailableErrorThe backend cannot run here (e.g. the helper executable native/spart-desk-host.exe is missing beside the package, or blocked).
SandboxUnavailableErrorruntime: "sandbox" (experimental) cannot run; reason is wsb-missing, start-failed, not-ready, not-running or disposed. Never falls back to the host.
ScreenErrorA provider that works from the screen failed; provider and code (unavailable, startup_failed, timeout, protocol, provider, cancelled, capture_failed, decode_failed, plain_picture: a picture of one colour, give or take, refused before it is looked for, disposed, exited: its process stopped while answering, and is started again for the next request; lost: it stopped too often to be). Thrown immediately, never silently ignored.
CapabilityUnavailableErrorSomething was asked for that this desktop cannot do; capability names it, reason says why and hint what to do about it.
LaunchErrorAn application could not be started or terminated.
LocatorSyntaxErrorA locator string could not be parsed.

All extend SpartDeskError.

Types​

  • DesktopControl – backend-neutral control description (id, source, role, name?, automationId?, className?, bounds?, enabled?, visible?, value?, checked?, expanded?, focused?, focusable?, invokable?, windowHandle?, parentId?, depth?, key?, url?). url is the address of the web page the control is the document of, as its engine reports it, set only on a page shown in a window: what webPage() finds. focused is true or false when the backend reads the keyboard focus with the tree, absent otherwise; renderControlTree leaves it out, so a golden tree does not change with the caret. focusable is the provider's word on whether the control can take the keyboard focus (UI Automation's IsKeyboardFocusable), absent when it gives none; nothing is refused on that word alone, only after a try that gave the control no focus, and renderControlTree leaves it out too. source is uia or java for a control an accessibility tree reported (isTreeSource(source)), otherwise the screen resolver that saw it. isStableAutomationId(automationId) is false for an AutomationId that is a number above 65535: a window handle, which Delphi VCL, C++Builder and Windows Forms report as the id of a control without one of its own, and which changes every run. Such an id is never suggested as a locator.
  • DesktopScreenshotOptions – what desktop.screenshot() takes: path, display (a number or "all"), signal.
  • WaitHistoryEntry – one stretch of what a wait saw, on TimeoutError.history and AssertionError.history: atMs after the wait started, the state seen (as lastState words it), on how many rounds in a row. At most 24 are kept, the first and the latest, and a failure prints them as its Waited: line when there is more than one round to tell of.
  • ControlDetails, ControlRange, MsaaDetails – what locator.details() returns: a control's further properties, each optional.
  • ControlSnapshot – { takenAt, windows, roots, notes? } where roots are ControlNodes (DesktopControl plus children); what a backend reports. notes says what the observation could not see and why (a Java window whose JVM runs without the Java Access Bridge, a tree cut short at a limit), and, after snapshot({ enrich: "text" }), a control whose text the screen shows outside where UI Automation places it.
  • DesktopSnapshot – ControlSnapshot plus application, controls (SnapshotControl[]: the same controls flat, in document order, each with a depth and a key) and enrichment ({ requested, applied, resolvers }, where the level is structure, text or elements). Returned by app.snapshot().
  • ResolutionAttempt – what one resolver did during one resolution (resolver, status, matches, durationMs, work, note). Every ActionRecord and TimeoutError carries the list as resolvers.
  • ResolutionWork – what resolving cost, counted by kind: treeQueries, treeObservations, windowQueries, captures, textReads, textPixels, imageSearches, groundingCalls (experimental), backendCalls, revalidations. Per resolver on ResolutionAttempt.work, and summed over a whole wait on ActionRecord.work (next to ActionRecord.screenAttempts).
  • OcrProvider / OcrRequest / OcrLine / OcrWord – reading text off the screen: a provider is handed a picture (and optionally a region and a scale) and answers with lines and words in the coordinates of the picture it was given. It never sees a locator. Its optional version (a model, the system it comes with) is named in a support bundle, since another version may read the same picture differently. createWindowsOcrProvider(options?) is the one SPART-Desk configures on Windows. PaddleOCR's PP-OCRv6 is another, in the optional package @spartaficial/spart-desk-ocr-paddle (createPaddleOcrProvider). ocr.provider takes one reader or a list tried in order (the next reads only when the text was not found), each a provider object or a name (OcrReaderName: "windows", "paddle", the latter loaded from its package when first used). A provider's optional reading (OcrReading) says which of the engine's re-reads it wants. See resolution.md.
  • ScreenImage – a capture with an identity for its content (key), when it was taken (observedAt) and pixels decoded only if something asks (pixels()): what an OcrProvider is handed. One screenshot is taken per observation and shared by every resolver that reads it.
  • Screen ids – the id of a control found on screen names the resolver that found it, the evidence it was found with (the text read, the picture's name, the description grounded), the accessibility-tree control it sits in and which occurrence it is there, in reading order — ocr:Save@12:dlg#2, image:save.png@12:toolbar#1, grounding:save%20icon@12:#1; words within a line add /Edit. Only the resolver that minted an id finds it again, and moving the window does not change it.
  • CaptureHints – what the engine hands DesktopDriver.capture when it already knows where the application's windows are ({ windows }), so a backend need not look them up again, and which one window to capture ({ window }, a handle), so each window is read from its own picture. The CapturedImage a driver returns names that window and, as scale, the window's display scale, which decides how much its text is enlarged. As stretch, it says how many times Windows stretched the window's picture when the window draws at a lower DPI than its monitor's (a DPI-unaware application on a 150 % display). A small area of such a picture is enlarged by the stretch as well.
  • matchTemplate(image, template, { threshold, limit? }) / TemplateMatch – zero-mean normalised cross-correlation over a shrunken copy then the full picture, returning every place the template appears, best first: the matching getByImage does, for a tool of your own. toGray(raw) turns a decoded picture into the one-channel GrayImage it works on. The pictures getByImage refers to are decoded once and kept until the file changes; a coloured one must agree in colour too, and a picture saved over under the same name is looked for again. A control found by a picture carries the picture's name as its description.
  • decodePng(bytes) / RawImage – the dependency-free PNG decoder every screen resolver reads through: 8- and 16-bit greyscale, truecolour and palette images, with or without alpha, expanded to RGBA. Interlaced images are refused by name.
  • Capability / CapabilityState / Capabilities – the ability vocabulary (CAPABILITIES lists it at runtime), one state per capability, and the map of all of them: desktop.capabilities, derived from what the backend declares (DriverCapabilities) and the providers configured. An operation that needs a missing one fails with CapabilityUnavailableError.
  • key – identity derived from what a control is (AutomationId scoped to its window, else role and accessible name below the parent, else position among same-role siblings), so the same control keeps the same key across observations. Opaque: compare it, never parse it.
  • WindowInfo – { handle, title, processId?, processName?, className?, foreground?, bounds?, scale?, dpi?, ownerHandle? }. A Window carries these fields; a snapshot's windows and desktop.windows() are plain data.
  • DesktopRole – the semantic role vocabulary; DESKTOP_ROLES lists it at runtime.
  • LocatorQuery / LocatorSelector – the immutable query representation.
  • DesktopConfig – the resolved { timeout, expect, intervals, resolution, ocr, images, grounding, diagnostics, foreground }.
  • SpartDeskConfig – what spart-desk.config.ts exports: DesktopOptions without configFile.

Every other export, by area​

The types and helpers below are exported for the APIs above: an option bag names the call it is for, a …Config is the resolved form desktop.config holds, and a …Settings/…Options/…Thresholds is what a configuration file or Desktop.create gives.

AreaExports
Desktop and runtimesRuntimeName ("local" | "sandbox", the latter experimental); MAX_TIMEOUT_MS (the longest timeout a timer can hold, 2^31 − 1 ms: every timeout, windowTimeout, interval and intervals entry must be a whole number of milliseconds within it, or the call fails with RangeError at once); OcrOptions and GroundingOptions (the ocr and grounding (experimental) options of Desktop.create, a provider among them), PaddleReaderOptions (ocr.paddle: how the reader named "paddle" is made); ShellOptions, ShellDisplay (desktop.shell); ForegroundPolicy ("activate" | "require"); LaunchOptions (ExecutableLaunchOptions, AppLaunchOptions or ProtocolLaunchOptions: an executable, a Store app's or packaged application's app ID, or a URI opened by its protocol); LocalRuntimeOptions (createLocalRuntime); WindowsSandboxRuntime (experimental; the runtime createSandboxRuntime returns) and SandboxRuntimeOptions (what it is created with); ApplicationState (whether an application runs, answers or ended, as applicationState reports it; "unknown" with a reason when the backend could not tell, which a wait treats as still unanswered); SessionLockSign ("lock-flag" | "screen-saver" | "input-desktop" | "lock-screen", which sign gave a locked session away in desktop.session().reason).
ConfigurationOne input type per section of Desktop.create's options and one resolved type (desktop.config) with its defaults applied: ExpectDefaults, ToPassDefaults, ExpectConfig; OcrConfig; ImagesOptions, ImagesConfig; ResolutionConfig, ResolutionOptions, ScreenConfig, ScreenOptions; GroundingConfig; DiagnosticsConfig, DiagnosticsOptions; CONFIG_ENV (SPART_DESK_CONFIG, the environment variable naming a configuration file) and findConfigFile (the file loadConfig would read).
Locators and actionsSnapshotOptions (app.snapshot), Screenshot (what screenshot() returns: the PNG file's path and its size), ScreenshotOptions, EnrichmentLevel ("structure" | "text" | "elements"), SnapshotEnrichment (what an observation was asked for and got); renderControlTree(node, { depth? }) (TreeTextOptions: a control and what it holds as text, one control a line, as toMatchTreeSnapshot keeps it), LocatorScreenshotOptions; ClickOptions, ClickModifier ("Control" | "Shift" | "Alt", a key click holds), FillOptions, PressOptions, Postcondition (until and sends: an action sent again until what it should have done holds), CheckOptions, ScrollOptions, WheelScrollOptions (what a ScreenLocator's scroll takes), SelectOptionOptions, DragToOptions, WaitForOptions, WaitForState, ScreenWaitForOptions (what a ScreenLocator's waitFor takes: visible or hidden); FilterOptions and FilterSelectorOptions (filter); RoleOptions, TextOptions, TextMatch (a string or a RegExp), ImageOptions (getByImage), OcrLocatorOptions (getByOcr: its text options and language), ScreenReadOptions (read({ language })); WebPageOptions (webPage); RelativeOptions, RelativeDirection (rightOf, leftOf, above, below, near).
InputMouse, Keyboard, Clipboard (the classes behind window.mouse, window.keyboard and app.clipboard; WindowMouse and WindowKeyboard are a window's); SignalOptions (the signal every call that does not wait takes), MouseButton, MouseButtonOptions, MouseClickOptions, MouseDragOptions, MouseMoveOptions, MouseWheelOptions, HeldKeysOptions (the modifiers every mouse gesture takes), ClipboardWaitOptions (clipboard.waitForText()); Touchscreen, Pen (the classes behind window.touchscreen and window.pen; WindowTouchscreen and WindowPen are a window's), TouchOptions, TouchTapOptions, PenPoint, PenOptions, TapOptions (locator.tap()); Point, WindowGeometry, beside CoordinateMapper; ScreenPoint (a point in physical pixels of one CoordinateSpace, "host-screen" or "sandbox-screen": the only kind of point a driver takes).
AssertionsLocatorMatchers, ScreenLocatorMatchers (what expectDesk(locator) returns for a DesktopLocator and for a ScreenLocator: the latter has no state assertion, no toHaveValue and no toHaveTitle), WebPageMatchers (what it returns for a WebPage: every locator matcher and toHaveUrl), TableMatchers and ListMatchers (what expectDesk(table) and expectDesk(list) return, with PagedAssertionOptions, and SortedAssertionOptions for toBeSortedBy); expectDesk(rowQuery) and expectDesk(itemQuery) return the LocatorMatchers of the query's locator; ApplicationMatchers and NegatedApplicationMatchers (what expectDesk(app) returns: toAnnounce, not.toAnnounce, toBeIdle); Announcement, AnnouncementKind, AnnouncementListener (DesktopDriver.announcements); ExpectDesk (the type of expectDesk), ExpectOptions (a subject's defaults and custom message, what configure and poll take); AssertionOptions (per call, also what toPass takes), TextAssertionOptions, CheckedAssertionOptions; LocatorMatchers, ValueMatchers, ValueAssertions; PollAssertions, FunctionAssertions.
Records, traces and errorsActionListener (desktop.actions.subscribe); ActionOutcome (what a driver says it did: its ActionStrategy, a detail, a fallback) and ActionStrategy (uia.invoke, input.mouse.click, java.select, …); ScreenNames (which provider read for a screen resolver); TraceEntry (one entry of a trace), TraceSink (where entries go), TraceListener, TraceError (an error as a trace stores it), TraceStep (where a step's picture stands: its operation, the region it covers and the control's place); the report of a run: writeTraceReport(traceFile, { out?, since?, title? }) (TraceReportOptions: one HTML file with every step, its picture embedded and its control boxed), renderTraceReport(entries, pictures, page) (the same page as a string; ReportPictures maps a picture's path to its data: URL); StepPictures (diagnostics.steps: "off" | "on" | "on-failure"); a trace of each test in one zip: TraceMode (diagnostics.trace: "off" | "on" | "retain-on-failure"), Tracing (desktop.tracing: start(TracingStartOptions) with a name, stop(TracingStopOptions) with keep and path, running), KeptTrace (what stop wrote: path, steps, failed, bytes), TRACE_ARCHIVE_FORMAT and TRACE_ARCHIVE_VERSION (what the zip's manifest.json says it is); DiagnosticAttachment (the screenshots and tree files a failure attaches); trace files: createTraceFileSink (writes a trace as JSON lines), parseTraceFile (reads one back), TRACE_FILE_FORMAT, TRACE_FILE_VERSION, TraceFileHeader, TraceFileLine, TraceFileSink; unsafePattern (why a redaction pattern given as text would be refused, or undefined when it is taken: longer than 200 characters, not a regular expression, a backreference, or a repeated group holding a quantifier or an alternation, which can take time without end; the check diagnostics.redact.patterns and support --redact-pattern make, and the agent's --redact-pattern); error details: AssertionErrorDetails, BackendErrorDetails, TimeoutErrorDetails, SandboxUnavailableReason, ScreenErrorCode.
Resolution and the screenControlSource (where a control was found: "uia" | "java" | "ocr" | "image" | "grounding" | "region"); GroundingRequest and GroundingMatch (what a GroundingProvider is asked and answers); isDesktopRole; MatchTemplateOptions (picture matching); OcrReaderName ("windows" | "paddle", a reader by name in ocr.provider), OcrReading (which of the engine's re-reads a reader wants), WindowsOcrOptions (createWindowsOcrProvider).
Suggestions and test codematchQuery (what a query matches in an observation); CandidateOptions, CandidateReport, LocatorCandidate (one suggested locator: its query, its code and why it ranks where it does), CandidateReason, LocatorClass (candidatesFor); RecommendOptions (recommendLocators: the capabilities, and ids, the controls to recommend a locator for); RenderQueryOptions (renderQuery: root, and scope, what an operand that starts from no window hangs off); RenderTestOptions, Located, InWindow (the window raw keys and mouse input went to, its points relative to that window), RecordedStart, TestRunner ("vitest" | "node" | "playwright") and defaultTitle (renderTest); recordedWindowOf (the window a recorded step names, Located.window, when it is not the main one and its title is the only one of its kind). A generated test names each window once and acts through it: const main = app.window();, const findCustomer = app.window({ title: "Find customer" }).
Drivers and helpersDriverCapabilities (what a backend declares it can do beyond the tree), DriverContext (the application and abort signal every driver call gets), DriverResolution (what resolve answers), ResolvedControl (a control as the engine acts on it), DesktopAction (what perform is asked to do), InputAction (what performInput is asked to do; a "touch.gesture" or "pen.gesture" is a whole gesture's PointerFrames, each a time and every PointerContact's point, whether it is down, its pressure, size, tilt, twist and buttons); ActivationTarget, ActivationOutcome (DesktopDriver.activate), WindowAction; ProcessTime (DesktopDriver.processTime: an application's processor time); HostTraffic (what a runtime's transcript.onTraffic hears of a helper process); WindowsDriver, WindowsDriverOptions, ExecutionTarget (the Windows backend, through the helper: the host session or the sandbox guest).

@spartaficial/spart-desk/testing​

ExportWhat it is
FakeDriver, FakeRuntime, FakeControl, FakeDriverOptions, PerformedActionAn in-memory backend over a tree of FakeControls; performed lists every PerformedAction; setApplicationState and setProcessTime script what applicationState and processTime answer; announce(text, { kind?, activityId?, source? }) makes the fake application announce something; FakeDriverOptions.capabilities changes what it declares it can do (real input and capturing the screen, but no screenshot files and no Java windows, by default).
FakeOcrProvider, FakeOcrLine, FakeOcrCallA reader that answers the lines it was given, and records each call.
FakeGroundingProvider, FakeGroundingMatch, FakeGroundingCallA grounding provider (experimental) that answers the matches it was given, and records each call.
replayBundle, BundleReplay, ReplayCaseResult, ReplayScreenshot, ReplaySeam, ReplayTarget, SeamResult, FixtureFile, FixtureCase, CaseExpectationReplaying a support bundle's cases against the engine, and the fixture.json format they are kept in.

@spartaficial/spart-desk/install​

The check behind npx spart-desk doctor, for tools that provision machines themselves; the package itself is the installation (distribution.md).

AreaExports
The checkdoctor (→ DoctorReport: the platform (with the windowsBuild; supported from Windows 10 1809 on), the helper (HelperStatus: its path, whether it runs, the problem when it does not, and the session as it reads it), the Windows Sandbox CLI (experimental; wsb, and unsupported: why it cannot be had before Windows 11 24H2) and the staging folder, the Java runtimes (JavaRuntimeStatus), the capability tier and the capability map, and problems), DoctorOptions (env, platform, cacheDir, a helperProbe for tests), CapabilityTier.
Folders and executablesdefaultCacheDir, ENV (SPART_DESK_CACHE_DIR); findOnPath, findWsbOnPath, presentSync.

@spartaficial/spart-desk/studio​

Studio as a library: the Inspector and the Recorder over a desktop you already have, for a tool or a harness that opens the window where it likes. spart-desk studio runs the same server; see studio.md.

import { startStudio } from "@spartaficial/spart-desk/studio";

const studio = await startStudio({ desktop, application: app, view: "recorder" });
console.log(studio.url); // a one-time link; studio.linkFor("inspector") makes another
await studio.closed; // "idle" once no window was connected for 15 s, "closed" after close()
ExportWhat it is
startStudio, StudioOptionsStarts Studio on 127.0.0.1 over desktop, optionally attached to application. cwd and allowPaths bound what it saves; view, record, support, port, closeGraceMs, onLog. native (default on the local runtime) picks and captures input through the helper executable. readOnly names why nothing acts in the session (a bundle opened): starting, acting, asserting, recording and sharing are refused with it, and the page says so. The desktop stays yours: closing Studio does not dispose it. A bundle exported from it carries no backend transcripts.
openBundle, OpenedBundle, OPENED_FROM_BUNDLEOpens a support bundle as a desktop of its own: the tree of its latest saved state with the picture taken of it, over the fake backend. Gives desktop, the target to attach, the observation and state shown and notes for what is missing. Pass the desktop to startStudio with readOnly: OPENED_FROM_BUNDLE; spart-desk studio --bundle <dir> does the same.
StudioA running Studio: url, port, closed, linkFor(view) and close().
StudioView"inspector", "recorder" or "pictures".
StudioCloseReasonWhy closed resolved: "idle", "closed" or "interrupted".

Extending SPART-Desk​

DesktopDriver and DesktopRuntime are the extension points. Most members of both are required; an optional one has a fallback its documentation names (an answer as if nothing were there, or not_supported for the operation that needs it), and a driver that wraps another passes every optional member on. A driver says what it cannot do in two ways:

  • capabilities (DriverCapabilities) declares the abilities beyond the tree: nativeInput (real mouse and keyboard input, performInput), screenshot (a PNG written to a file), capture (pixels for the engine to read, which reading the screen, getByImage and grounding (experimental) need) and java. desktop.capabilities is derived from it, and the engine never calls the method behind an ability declared unavailable;
  • otherwise the answer says it: a question the backend cannot answer returns undefined (inspectWithin, scrollInfo, gridInfo, isSelected, commitSelection, session), applicationState answers { state: "unknown", reason }, desktopWindows answers [], and an action it cannot take throws BackendError not_supported (windowAction, activate, readClipboard, writeClipboard).

Beside windows, inspect, resolve, perform and readText, a driver has coordinateSpace, credentialPrompts (whether it serves app.credentialPrompt()), gridInfo (GridInfo: how many rows and columns a grid holds and every column's header), controlDetails (optional: what locator.details() reads, not_supported without it), setWindowBounds (optional: what window.setBounds() sends, answering a WindowPlaced, { outcome, bounds }; not_supported without it), captureDesktop, readClipboardImage and writeClipboardImage (optional: desktop.screenshot() and the clipboard's pictures, a ClipboardPicture being { png, width, height, format }; not_supported without them), complete (optional: fills in per match what no observation carries, a radio button's checked state asked of its selection, for the matches the reader or the picture matcher found in the driver's tree under read(); resolve fills its own in) and dispose (a mouse button it pressed and never released, what it remembered about processes and windows), which the runtime's dispose calls.

A runtime implements prepare, launch, terminate and dispose. launch returns a LaunchResult, { target, attachedToExisting }: how to address the application, and whether the launch handed over to an instance that was already running. terminate(target, { since, force? }) (TerminateOptions) gets when the handle was taken, so that a recycled process id is not stopped, and whether to stop at once rather than ask the windows to close first. A runtime may offer run(command, args, options) for desktop.run, answering { exitCode, stdout, stderr }. A runtime that keeps helper processes running may offer onHelper(listener): it tells a HelperEvent ({ helper, event: "restarted" | "lost", restarts, reason, note? }) whenever one stopped on its own and was started again, or stopped too often to be, and Desktop adds each to its trace as a helper entry.

A query may hold className and table steps, which a driver that evaluates queries itself must handle. A runtime provides a driver and knows how to launch and terminate applications in its environment. @spartaficial/spart-desk/testing ships FakeDriver and FakeRuntime for unit tests; FakeDriver.failNextResolve / failNextPerform simulate backend failures, and new FakeDriver(roots, { capabilities }) (FakeDriverOptions) declares an ability unavailable, for a test of what the engine does without it.

The Windows backend can be configured explicitly:

import { Desktop, createLocalRuntime, WindowsDriver } from "@spartaficial/spart-desk";

const desktop = await Desktop.create({
runtime: createLocalRuntime({ driver: new WindowsDriver({ inspectDepth: 12 }) }),
timeout: 10_000,
});