Creating RStudio Playwright Tests
Most of what you need is in e2e/rstudio/README.md (basic structure,
conventions, selector hierarchy, Ace interactions, cross-platform shortcuts,
sandbox, tags, package deps) and in the existing tests under
e2e/rstudio/tests/. The fixture (@fixtures/rstudio.fixture) handles
RStudio launch/shutdown, save-dialog dismissal, and buffer cleanup -- author
tests as if the IDE starts clean.
This file covers RStudio-specific gotchas that aren't in the README.
Before writing
- Skim
e2e/rstudio/tests/for an existing similar test. - Skim
e2e/rstudio/pages/,actions/, andutils/for existing helpers. - Extend
PageObject(panes) orFramePageObject(iframe-hosted UI) frompages/page_object_base_classes.tswhen adding a page object.
Universal rules
pressSequentially()for GWT text inputs whose handlers fire per keystroke -- console, editor, and mostinput.gwt-TextBoxfields in dialogs/wizards (where typing a character enables OK, triggers autocomplete, etc.).fill()doesn't fire GWT key events in those cases. Inputs driven by a discrete trigger likepress('Enter')or a button click (e.g., the console Find bar) work fine withfill(), which has the bonus of replacing text instead of appending. Start withfill(); switch topressSequentially()only if the handler doesn't fire. For GWT type-ahead widgets (e.g. the Open File dialog's file list) evenpressSequentially()can outrace the per-keystroke handler -- usetypeSlowly(@utils/constants, 200ms/char).Force-click Ace textareas:
await locator.click({ force: true }). Anace_contentdiv overlays the hidden textarea and intercepts normal clicks.focus()is also unreliable -- keystrokes can land in the wrong pane.Derive selectors from source, never use
gwt-uid-XXXX(those change every restart):src/gwt/.../commands/Commands.cmd.xml-- command IDs map to menu items at#rstudio_label_<sanitized_id>and toolbar buttons at#rstudio_tb_<sanitized_id>(seeElementIds.javafor the sanitizer).src/gwt/.../core/client/theme/DocTabLayoutPanel.java-- tab structure (.gwt-TabLayoutPanelTab,.gwt-TabLayoutPanelTab-selected, etc.).
Invoke RStudio commands and prefs via the
window.rstudiobridge -- import helpers from@utils/commands(executeCommand,setPref, etc.). Don't use.rs.api.executeCommand(...)(slow console roundtrip) orwindow.desktopHooks.invokeCommand(...)(Electron-only, crashes on Server). The full bridge surface is documented ine2e/rstudio/CLAUDE.md.Decision order for triggering an action: GUI button/menu/shortcut, then
executeCommand(page, id), thenpage.evaluate()as a last resort. Clicking a button tests the real user path; the helper is for setup/teardown or when the UI path is slow/flaky/tangential to what's being tested.RStudio binds some shortcuts to plain Ctrl on every platform (including macOS) -- e.g.,
Ctrl+EnterRun Line,Ctrl+LClear Console,Ctrl+SpaceAutocomplete. Use Playwright's plainControlfor those, notControlOrMeta. If you're unsure, check what RStudio's keyboard-shortcut UI shows for the binding.Tests must work on Desktop and Server. Prefer stable IDs over wrapper selectors. Tag mode-specific tests
@desktop_onlyor@server_onlyrather than runtime-branching ontestInfo.project.name.
Waits and markers that lie
Console markers can false-pass off the command echo. The command you type travels the same output/event stream you're checking, so
output.includes(marker)can match the echo ofcat("<marker>")even when R never ran it. Split the marker acrosscat()args (cat("[pw:", "ready]", sep = "")) or build probe strings withpaste0().Closing a project on Server reloads the page.
waitForLoadState, console-visible, and console-idle can all satisfy against the old page; the late reload then breaks the next test. UsecloseProjectIfOpen(@utils/project), which blocks onwindow.rstudio.project.isActive() === false-- a signal only the post-reload page can produce. Never hand-roll it.Focus needs re-dispatch, not dispatch-once + poll. Other UI (e.g. the Assistant iframe reloading after a project open) can steal focus after your command ran.
focusConsole(page)(pages/console_pane.page.ts) re-issuesactivateConsoleinside its poll loop. Retrying helpers are for setup; keep raw one-shot dispatch only where single-dispatch behavior is itself under test.Session restarts: use the helpers, respect the timing. rserver can hold undeliverable console input up to ~30s, and a suspended session's relaunch can exceed 30s.
waitForSessionRestart/restartSessionWithSentinel(@utils/project) already encode both -- reuse them.ConsolePaneActions.restartSession()is the third option, for when the test needs.rs.api.restartSession()itself (e.g. itsclean:argument).Size timeouts to what gates the UI. The Python-interpreters modal opens only after a machine-wide scan (60s), not a flat 15s. Before tagging a test
@desktop_onlyfor "server-specific" behavior, check whether it's just slow.External services: skip on service error, fail on silent nothing. Poll a three-state outcome (matched/error/pending);
test.skipon error, but a timeout with neither must still fail so a regression that renders nothing isn't masked. Worked example invisual-editor.md.Document paths come back home-aliased (
~/sub/file.R) when the file is under the rsession's home. ReuseopenFile/waitForActiveDocument, which handle it -- don't comparedoc.pathto an absolute path yourself.Bracket async-dispatched console jobs with
waitForConsoleBusy. A command dispatched off-tick (e.g.executeCurrentChunk) may not have started when a follow-up idle-wait samples the console, which then reads spuriously idle. Wait for busy first (waitForConsoleBusy,pages/console_pane.page.ts), then for idle.A console prompt does not mean R-side change detection has run. The session queues
kConsolePrompt, wakes the waitingget_eventspoller (ClientEventQueue::addends innotify_all), and callsonDetectChangesonly after that (SessionConsoleInput.cpp). So the prompt is ordered ahead of every change-detection event, and it can reach the client first.executeInConsoleresolves on the prompt counter. At that moment a plot that raises the Plots pane, a package-list refresh, or a file change can be unqueued, undelivered, or not yet rendered. If you wait on the prompt and then measure, the assertion passes whether or not the effect happened. Wait on the effect itself, for example thearia-selectedvalue of the Plots tab. Pick a signal that reads differently on a broken build.Client state reaches the server on a passive 5s timer.
persistClientState()firesPushClientStateEventwithactive=false, soClientStateUpdaterreschedules instead of pushing now (PASSIVE_INTERVAL_MILLIS). A test that changes persisted state and then reloads normally outruns the save and restores nothing. It passes without exercising the restore. First wait for theset_client_stateRPC that carries your value:page.waitForResponse(r => r.url().includes('set_client_state') && (r.request().postData() ?? '').includes('<YourKey>')). All state values go in one RPC, so this also waits for state from other components. Then make sure the RPC succeeded.waitForResponsealso resolves for a rejected response, and a rejected RPC returns HTTP 200 with anerrormember, becauseHttpConnection::sendJsonRpcErroruses the normalsendJsonRpcResponsepath.response.ok()alone does not prove the write landed. Look for"error"in the body.window.rstudio.readyis the earliest usable post-reload signal. It is not a state-applied gate. It is better than a pane selector: panes attach at construction, sowaitForSelector('#rstudio_TabSet1_pane')returns while startup state handling is still pending. Butreadydoes not mean startup finished, and it does not even mean the panes exist. Two reasons:
Application.javasetsreadyininitializeAgent(), then callsinitializeWorkbench()in the same task, so you cannot observe the gap. Work that startup defers to a later task is still pending:Scheduler.scheduleDeferredwork, and timers such as the 200ms timer inPaneManager.ZoomedTabStateValue.onInit.initializeWorkbench()returns early with aReloadEventwhen the UI-language cookie, or the web-dialogs cookie on Electron, disagrees with its pref. It returns before it builds the workbench, and the reload it fires is delayed.
On the re-join path of a page.reload() the R session stays up, so
sessionInfo.deferred_init_completed is already true and
DeferredInitCompletedEvent does not fire again -- which is why ready is
set in initializeAgent() at all. Gate on ready, then let the assertion
wait for the element and for the timing (see the next entry).
expect.pollcannot assert that something never happens. It returns on the first sample that passes. When the starting state is also the passing state -- the pane is not zoomed, no dialog appeared -- the poll succeeds at once, and the test is green before the bad state arrives. Itstimeoutvalue does not help in that direction, and a fixedsleepin front of it only races the product timer. For a must-not-happen assertion, sample the predicate over a window that outlasts the deferred work, and fail on the first violation. Start that window after the first sample passes: a slow first sample can otherwise consume the whole window and check the state once. This is entry 9's trap in the other direction. Pick the signal by asking what a broken build does.A stray modal reads as "intercepts pointer events". Any GWT modal renders a
gwt-PopupPanelGlassoverlay, so an unexpected dialog (e.g. "Error Listing Packages" after resume) surfaces as an opaquegwt-PopupPanelGlass intercepts pointer eventstimeout at an unrelated click. CalldismissBlockingModals(pages/modals.page.ts) after suspend/resume/restart; stable dialog-button ids are exported there (CONFIRM_BTN=#rstudio_dlg_ok,YES_BTN,NO_BTN,CANCEL_BTN).
Server mode and the sandbox
Use the
@utils/fileshelpers for test-file operations, not raw Nodefs.writeAndOpenFile/seedSandboxFile/removeSandboxFile/closeAndDeleteSandboxFilesauto-detect whether the workdir is writable by the test process and fall back to R-consolewriteLines/unlinkwhen it isn't (Server's rsession runs as a different user; remote rsessions have a different filesystem), staying byte-identical tofs.writeFileSync(sep="", useBytes=TRUE). Git operations go through the R console, with inline-c user.name=... -c user.email=...since CI runners have no global git config. When interpolating a test-computed value into an R command, userStringLiteral/rPathLiteral(@utils/r) -- never hand-build"${path}".@utils/heredocsends multi-line content cleanly.Components that store secrets via the OS keychain need it disabled in the fixture. Under the sandboxed HOME, macOS has no login keychain, so a keytar-based write throws a blocking "Keychain Not Found" modal; on Windows the write would instead land in the host's real Credential Manager, leaking test state onto the developer's machine.
Techniques
Asserting on real RPC/event traffic is a legitimate technique, not just mocking it.
page.waitForResponsecan pin that a specific RPC fired and inspect its body (e.g. confirmasyncHandleis set, proving it registered as async rather than blocking).page.on('response')filtered toget_eventsURLs, withexpect.pollon the body text, can confirm how the backend delivered something (e.g. one batched event vs. many per-file events) -- detach the listener infinally. When substring-matching event names, include the JSON quotes ('"files_changed"'), since an unquoted match can also hit a similarly-named event.Fixture setup should fail loudly, not as a mysterious timeout. Verify both the subprocess's exit status AND the setup script's own boolean result (a COM
Save()or an AppleScript bookmark write can fail with exit code 0), then assert a unique sentinel line in console output. This turns an environment problem into one clear setup-failure message instead of a visibility timeout in every downstream test.Replay a hidden-tab/iframe race deterministically instead of chasing timing. Dispatch synthetic events into the hidden iframe (
el.dispatchEvent(new Event('scroll'))) or call the component's lifecycle hook directly frompage.evaluate(e.g.iframe.contentWindow.onActivate()) to reproduce the race on demand, with no flaky timing needed.Capturing a satellite (popout) window: register
page.context().waitForEvent('page')before issuing the command that opens it, then assert the new page's URL contains the expectedview=<name>marker. Works the same way for Desktop (Electron satellite) and Server.Drive Ace through the
AceEditorpage object (pages/ace_editor.page.ts) rather than ad-hocpage.evaluate. An empty marker (new AceEditor(page, '')) resolves the active editor via the bridge -- prefer it. A non-empty marker does a.ace_editorDOM walk that can land on stale editors left after a tab close; use it only to target a non-active tab. Typed Ace bindings live in@utils/ace-- extend them there instead of casting at the call site.Probe R-side state with
evalRLogical(actions/console_pane.actions.ts): runs an R expression returning one logical and readsTRUE/FALSEback from the console -- e.g.evalRLogical('requireNamespace("dplyr", quietly = TRUE)').
Feature-specific patterns
When working in these areas, also read the corresponding file:
- Code suggestions / Copilot / NES (
tests/panes/editor/code_suggestions.test.ts,edit_suggestions.test.ts): seecode-suggestions.md. - Chat pane / Posit Assistant / RPC interception
(
tests/panes/posit-assistant-chat/): seechat-pane.md. - Visual editor / citations (
tests/panes/editor/citations.test.ts, and any test that drives the panmirror visual editor): seevisual-editor.md. - Files pane / Open File dialog (
tests/panes/files/): seefiles-pane.md. - Terminal pane (
tests/panes/terminal/): seeterminal.md. - Auth setup / AI credentials (
tests/auth.setup.ts,utils/auth.ts, or anything touching credential provisioning): seeauth-credentials.md.