Skip to main content

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 pagea 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 elementstheir 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 launchonly 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 with currentUrl() and currentTitle(), and expectDesk(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.statusbar and workbench.parts.editor, and status.* 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 (isStableAutomationId is false); 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 with name: /^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​

OperationHowForeground
click() on a button, a list or tree item, a tab, a menu itemuia.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 boxuia.invoke --action toggle-on; the page reports its new state a moment later, which is waited fornot needed
expand() / collapse() on a tree iteminvoke --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 keysposted after UI Automation focusnot needed
chords (Control+P)real input: a page takes posted chords as plain keysneeded
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 editora real click where the editor is drawnneeded

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.exe hands the launch to the running instance.
  • An inherited ELECTRON_RUN_AS_NODE is 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 in env to 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-accessibility in args, 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.