Electron applications and WebView2 hosts
SPART-Desk tests applications whose interface is a web page with the same locators, actions and assertions as any other Windows application: Electron applications such as VS Code, and WebView2 hosts — Windows Forms and WinUI 3 applications that embed a WebView2, Blazor Hybrid among them. Chromium reports the page to UI Automation, so the tree is the page's, and both work with the window in the background. A page in Internet Explorer's WebBrowser control is reported by another engine, in its own ways: see ie-webbrowser-apps.md.
import { Desktop, expectDesk } from "@spartaficial/spart-desk";
await using desktop = await Desktop.create();
const code = await desktop.launch({
executable: "C:/Users/me/AppData/Local/Programs/Microsoft VS Code/Code.exe",
args: ["--user-data-dir", profile, "--disable-extensions", "--new-window", workspace],
});
const explorer = code.getByRole("tree", { name: "Files Explorer" });
await explorer.window().getByRole("treeitem", { name: "src", exact: true }).expand();
await explorer.window().getByRole("treeitem", { name: "main.ts", exact: true }).click(); // opens it
await expectDesk(code.getByRole("tab", { name: /^main\.ts/ })).toBeVisible();
await expectDesk(code.getByAutomationId("workbench.parts.statusbar")).toBeVisible();
What the tree holds
| UI Automation reports | |
|---|---|
| The page | a document whose AutomationId is RootWebArea, inside the application's window. The window is Chromium's (Chrome_WidgetWin_1) for Electron, and the host's own for WebView2 (a WindowsForms10.* or WPF window). |
| HTML elements | their ARIA roles: button, textbox, checkbox, list/listitem, tree/treeitem, tab, menuitem, status. An element's HTML id is its AutomationId. |
| The first look after a launch | only the window frame: Chromium turns its accessibility on when the first UI Automation client asks. The driver looks again, within a second, until the page shows; each window once. No launch switch is needed. |
| Canvases (charts, the Monaco minimap) | pixels: reading the screen finds their text |
Finding controls
- A page is a scope of its own.
main.webPage()is the page,webPage({ url, title })one of several (a page's frames and VS Code's webviews are pages inside its page); its address and title are read withcurrentUrl()andcurrentTitle(), andexpectDesk(page).toHaveUrl(…)waits for a navigation. An HTML dialog in a page is a window to UI Automation, inside the page:close()refuses it, and its own buttons answer it. - Headings and landmarks have roles of their own:
getByRole("heading", { level: 2 }),getByRole("navigation", { name: "Main" }),main,banner,contentinfo,complementary,form,region,search. They are still texts and groups to a locator that asks for those. - VS Code's parts have stable AutomationIds. For example
workbench.parts.statusbarandworkbench.parts.editor, andstatus.*for status bar items. - VS Code's list and tree rows have ids like
list_id_1_2. These are places, not identities: they move when a folder opens, and two rows can share one. SPART-Desk never suggests them (isStableAutomationIdisfalse); find rows by role and name. - Command-palette and Quick Open items carry more than the command. Their
names go on with the key binding or "recently used"
(
Terminal: Create New Terminal, Control+Shift+``), so match them withname: /^Terminal: Create New Terminal(,|$)/. Menu items carry their shortcut the same way:/^Search Ctrl/`. - A page's container reads as the text it holds. A status line or a
paragraph has no name of its own, so its text is the text inside it:
expectDesk(app.window().getByRole("status")).toHaveText("Saved"). - Menus work, although Chromium says otherwise. Chromium clips a drop-down menu by the menu bar it opens from, and reports every item of VS Code's menus but the first off screen. An element that lies wholly within a menu the page reports on screen counts as visible.
Acting
| Operation | How | Foreground |
|---|---|---|
click() on a button, a list or tree item, a tab, a menu item | uia.invoke: the page runs its click handler. A control that reports no Invoke pattern (VS Code's menu bar) is invoked through the patterns it has, before a real click. | not needed |
check() on a check box | uia.invoke --action toggle-on; the page reports its new state a moment later, which is waited for | not needed |
expand() / collapse() on a tree item | invoke --action expand. A page that keeps its own state (VS Code's trees) ignores that, so the driver checks the state. When it did not change, the item is selected, which such a page takes as a click, then invoked. When neither changed it, the item is clicked. An item already selected is not selected again, so a page that opens and closes a folder only on a click (not VS Code) closes a selected folder only with a real click. | not needed, unless the click is |
fill() on an <input> or a <textarea> | uia.set-value. On web content the value is read back. | not needed |
fill() on a rich-text editor (an editable region: Slate, Lexical, ProseMirror, Draft, Quill) | Never set-value, which would change the page and not the editor's own model. Its text, if any, is selected through its Text pattern, and the new text posted after UI Automation focus, Enter between lines; an empty value is a Backspace. Read back. Real input (select all, type) when that fails. | not needed |
fill() on a Monaco editor (VS Code's editors and search boxes) | Monaco takes no value through UI Automation and shows none to read back since the EditContext API, so what the editor holds, empty or not, is replaced with real input: a click where the editor is drawn, select all, type. pressSequentially() and named keys reach it in the background. | needed |
pressSequentially() | characters posted to the window once UI Automation gave the control the focus. In a WebView2 host they are posted to whichever of the page's windows has the focus, and some never arrive: there the value is read back, characters that changed nothing are posted once more, and then typed with real input. | not needed (usually, in a WebView2 host) |
press("Enter") and other named keys | posted after UI Automation focus | not needed |
chords (Control+P) | real input: a page takes posted chords as plain keys | needed |
click({ button: "right" }) | real input in front. Behind another window, the control is asked for its context menu through UI Automation: the page hears contextmenu on it and shows its own menu, and the application brings its window forward to show it (uia.context-menu) | not needed |
click() on a Monaco editor | a real click where the editor is drawn | needed |
A Monaco editor's input has no place of its own. The element it takes
text through (class native-edit-context) reports 0×0, with the focus or
without it. SPART-Desk gives it the place of the editor around it, so it is
visible and typing reaches it through UI Automation focus, whether or not the
editor has the focus. A search result cannot tell where it is drawn, so such a
locator is resolved over the tree.
VS Code's editor reports its text only with "editor.accessibilitySupport": "on", set in the profile you launch it with. Without it, the text reads "The
editor is not accessible at this time".
Launching
- Give a launched VS Code a profile of its own. Pass
--user-data-dir <folder>and--extensions-dir <folder>. It then runs as a separate instance beside any VS Code already open.close()stops only what the launch started. Without a profile of its own,Code.exehands the launch to the running instance. - An inherited
ELECTRON_RUN_AS_NODEis never passed on. A runner started from an editor extension (an MCP server, a test run from VS Code) inherits it, and every Electron application it launched started as Node.js and exited at once with "bad option". Name it inenvto pass it anyway. - CEF applications (an embedded Chromium that is not Electron) read as
Electron does once Chromium turns its accessibility on for the client that
asked; at the first look the page may still be an empty pane. A launch can
pass
--force-renderer-accessibilityinargs, so that the page is there from the start.
Speed
A working window with a large workspace holds thousands of controls and is
cut off at inspectDepth (20).
A chained locator is matched against the whole tree the helper read, as a
root locator is: scoping it under a container
(getByRole("tree", { name: "Files Explorer" }).getByRole("treeitem")) cuts
ambiguity, not the read's cost, which is one read of each window per
resolution. What lies below inspectDepth is not matched: raise it for a
deep page rather than scoping.
app.snapshot({ root }) (and the agent's desktop_snapshot with root)
observes that control alone, with its window, and the snapshot's within
says so. It is for looking at one control, and for reaching below the depth
cut-off, not for speed: the control is found again first, then read.