Skip to main content

Parallel execution

Several tests, workers and applications at once: the engine supports it. Nothing in SPART-Desk serialises callers, and a Desktop is self-contained, with a helper process of its own. Windows adds one limit that no library can remove: an interactive session has one foreground window and one mouse cursor, so actions that need real input cannot run in parallel on one machine. This page lists what runs in parallel, how to lay a suite out around that limit and how to check that a suite stays on the parallel side of it. Provisioning the runners themselves is covered in ci.md.

What the engine guarantees​

ConcernFact
Concurrent operationsCallers are never serialised: concurrent locator operations, waits and cancellations are independent, on one application or on several, and one application can be closed while another is mid-operation.
Shared stateA Desktop owns its runtime, backend driver, actions, trace and the helper processes it starts (the text reader, the Java Access Bridge host); there is no module-level state. Several desktops in one process and one desktop per worker process both work.
Addressingdesktop.launch() addresses the application by the pid it started, so two workers launching the same executable never see each other. desktop.attach() by process name or window title matches every instance of that name; avoid it in a parallel suite.
Backend processesEvery Desktop keeps a helper process of its own, and every resolution is a whole read of a window through it, so parallel workers are parallel helpers reading in parallel; CPU contention raises the cost of each read.
One sender of real inputReal input is sent by one run at a time per desktop (the input lease); a worker whose action waited for the lease past its timeout fails with input_busy. Reads through UI Automation do not wait for each other.
Failure evidenceScreenshots and UI trees captured on failure carry a time-plus-random stamp, so workers may share one diagnostics.directory.

The limit: one foreground window per session​

Windows delivers injected input only to the foreground window of an interactive, unlocked session, and a session has exactly one. When an action needs it, the desktop brings the target's window to the front and acts once more; when the window is still not in front, the action fails with BackendError code: "foreground_required" (input-engine.md, actions.md). Two tests injecting real input into two applications on the same desktop therefore compete for the one window that is in front.

Within one action they take turns: every real-input command, and a window brought forward together with the action run again, holds the input lease, a lock file every run of the user shares (%TEMP%\spart-desk\input.lock). Another run waits for it, up to 5 s per try and then within the action's timeout, and fails with BackendError input_busy naming the holder when it never comes free. So several desktops clicking at once into windows that overlap each land their click in their own window.

Between actions they do not: another worker's action can take the foreground after yours, and what your next action relies on being on screen (a hover's tooltip, a menu one real click opened) may be gone. Each action brings its own window forward again, but no worker count makes a sequence of actions safe; only another interactive session (another VM or machine) does.

A person at the desk is not held back. When someone uses the mouse or the keyboard while the helper sends a click, a gesture or typed text, or another window comes to the front as a click is sent, the action's record says the input may not have gone where it was sent.

Needs the foreground: one worker per sessionParallel-safe: works on background windows
click() on a control without an Invoke, Toggle, SelectionItem or ExpandCollapse pattern (input.mouse.click); dblclick(); right click; hover(); scroll({ wheel })click(), check(), expand() through uia.invoke; scroll() through uia.scroll
fill() on a control without a Value pattern (input.keyboard.type); pressSequentially(); press() with modifier chordsfill() and clear() through uia.set-value
app.window().mouse.*, app.window().keyboard.*textContent(), isVisible(), isEnabled(), waitFor(), expectDesk(...), snapshot(), controls(), screenshot()
Actions on vision-resolved controls (real clicks at coordinates)Vision resolution itself (screenshot plus inference)

Between the two columns: focus(), and press() with plain keys, which focuses the control before its keys are posted. Moving the keyboard focus can bring the control's window to the front, and the helper confirms that the focus arrived: when another control keeps it, the action fails with focus_not_moved and tries again. They inject no real input, but in a parallel suite they can take the foreground from another worker's window, so they belong in the input project.

Experimental

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

The Windows Sandbox runtime adds isolation, not capacity: Windows allows one sandbox at a time, and a sandbox that is already running is reused (runtime-windows-sandbox.md). A question in the guest also costs a round trip to the helper there. Real input inside the guest needs the connected client window in front on the host, which is the same rule again.

  • One Desktop per worker, one application per test. Create the desktop in a worker-scoped fixture and dispose it when the worker ends; launch the application in a test-scoped fixture and close it afterwards. Desktop.create() reads spart-desk.config.ts in every worker, so the defaults stay in one place.
  • Two projects. A UIA-only project runs fully parallel with as many workers as the CPU affords, and also on hosted runners without an interactive desktop. An input project runs with one worker on an interactive runner (ci.md). Scaling the input project means one interactive session per worker: separate VMs or machines, each running one shard.
  • Sequential inside a test. Do not fire concurrent operations at the same application from one test: the desktop serialises its requests to the helper and nothing is gained (backend-windows.md).

Runner example​

SPART-Desk depends on no test runner; the fixtures below are consumer code for Vitest, and the same shape works with any runner that offers file- or worker-scoped setup.

// fixtures.ts
import { test as base } from "vitest";
import { Desktop, type Application } from "@spartaficial/spart-desk";

export const test = base.extend<{ desktop: Desktop; app: Application }>({
desktop: [
async ({}, use) => {
const desktop = await Desktop.create(); // reads spart-desk.config.ts
await use(desktop);
await desktop.dispose();
},
{ scope: "file" },
],
app: async ({ desktop }, use) => {
const app = await desktop.launch({ executable: "C:\\Windows\\System32\\charmap.exe" });
await use(app);
await app.close();
},
});

export { expectDesk } from "@spartaficial/spart-desk";
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
projects: [
{ test: { name: "uia", include: ["tests/**/*.uia.test.ts"], env: { SPART_DESK_UIA_ONLY: "1" } } },
{ test: { name: "input", include: ["tests/**/*.input.test.ts"], fileParallelism: false } },
],
},
});
// characters.uia.test.ts
import { expectDesk, test } from "./fixtures";
const main = app.window();

test("the copy box takes a value through UIA", async ({ app }) => {
const chars = main.getByLabel("Characters to copy");
await chars.fill("abc"); // uia.set-value: works on a background window
await expectDesk(chars).toHaveValue("abc");
});

Vitest runs the tests of one file in sequence and each file in its own worker, so the file-scoped desktop never has two tests acting through it at once; the app fixture keeps each test on its own process. The uia project runs its files in parallel, the input project one at a time (fileParallelism: false) on the one interactive session.

Auditing a suite for real input​

Every operation leaves an ActionRecord in desktop.actions (actions.md). An action needed the foreground when its strategy starts with input. (real input, or keys posted to a control whose window was brought to the front to focus it) or is uia.focus. A fallbackFrom value means a UIA pattern was tried first and the control did not expose it. A fixture can collect the records of each test and fail the UIA-only project when one of them needed the foreground, which catches a test that only passes because its window happened to be in front:

// fixtures.ts: the app fixture with the audit; the desktop fixture is unchanged
import { expect } from "vitest";
import type { ActionRecord } from "@spartaficial/spart-desk";

function needsForeground(record: ActionRecord): boolean {
return record.strategy.startsWith("input.") || record.strategy === "uia.focus";
}

app: async ({ desktop }, use) => {
const app = await desktop.launch({ executable: "C:\\Windows\\System32\\charmap.exe" });
const records: ActionRecord[] = [];
const unsubscribe = desktop.actions.subscribe((record) => records.push(record));
await use(app);
unsubscribe();
await app.close();
if (process.env["SPART_DESK_UIA_ONLY"] === "1") { // set by the uia project in vitest.config.ts
const offenders = records.filter(needsForeground).map((r) => `${r.action} via ${r.strategy}`);
expect(offenders, "real input in a UIA-only test").toEqual([]);
}
},

Rules of thumb​

  • Bound the worker count by CPU, not by test count: every resolution is a whole read of a window by the worker's helper, and a saturated CPU makes every wait slower. Raise timeout under load rather than shortening intervals (auto-waiting.md).
  • launch, not attach by name, in anything that runs in parallel.
  • One Desktop per worker rather than per test: it keeps the helper processes it has started (the text reader, the Java Access Bridge host) running between tests.
  • One shared diagnostics.directory is fine; keep it in the job artifacts either way (assertions-diagnostics.md).
  • Keep the input project off hosted runners: they have no interactive desktop, so every real-input action fails there (ci.md).
  • Two desktops in one process are fine too (a test that drives two applications, for example); they still share the one foreground window.

What is not provided​

  • No lease across actions. The input lease keeps one action's input together; workers on one desktop still take the foreground from each other's windows between their actions. One interactive session per real-input worker is the model.
  • No per-test sandbox (experimental runtime): Windows runs one sandbox at a time.
  • No shared helper between workers: each worker's desktop starts and keeps a helper process of its own.