Wren a web browser where WebAssembly runs everything, JavaScript included

Remote control: wren ctl

Drive a running Wren from a script: navigate, wait, run script, send mouse and keyboard input, read the console and the network log, and capture the window without the screen. The client is a sub-command of wren.exe itself (one file, so client and server always speak the same protocol version).

PowerShell
build\wren.exe ctl run --size 1280x800 --shot out.png https://example.com/     # one call: start, load, capture, close

About 0.5 s for the whole call on a development build (process start 55 ms, first frame and load 215 ms, capture 70 ms, close 110 ms), against 2 to 3 s for starting Wren, waiting a fixed time and capturing the screen. No window is shown, no desktop is needed, and the monitor, its scale and the windows above do not matter.

Seeing what Wren does without launching it forty times

You wantDo
a picture of a pagewren ctl run --shot out.png <url> (--size WxH, --dpi 144, --wait idle)
several things on one pagewren ctl start, then nav, shot, eval, click... with WREN_REMOTE=<name>, then wren ctl stop <name>
the same from a programthe typed API of src/remote/api (wren ctl is its command line)
a page as a PNG, headlessbuild\wrentool capture -Url <url> -Out f.png
to compare two buildswren ctl snapshot out\a with each build (.png, .html, .json of boxes), then wren ctl diff a.png b.png --out d.png
to know what a window showswren ctl shot of the page (--tab), a rectangle (--rect x,y,w,h) or an element (--selector css)
a Wren the user (or another agent) already runsdo not touch it: start your own (run / start use a throwaway profile and a name of their own)
many scenarios, one after the other (tests)one start, then wren ctl reset between two scenarios (see "Reusing one Wren" below): a start and a close are 0.6 s and the CPU of a start-up that has to settle

wren ctl help lists the commands, wren ctl help <command> gives one's usage. wren ctl cmds lists the browser's commands (the registry that the menu, the palette and the shortcuts use) and wren ctl cmd <name> runs one: everything the user can trigger in the interface (open the developer tools, change the dock, zoom, Setup, a new tab...) can be triggered the same way.

Commands

run [--profile dir] [--size WxH] [--dpi n] [--wait load|idle|stable|none] [--eval js] [--shot file [--tab|--rect x,y,w,h|--selector css] [--scale n]] [--visible] [--timing] [--pref n=v] [url]
start [--name n] [--profile dir] [--size WxH] [--dpi n] [--visible] [url]      stop <name>
nav <url> [--wait load|idle|stable|none]     reload   back   forward   cancel
shot <file.png> [--tab|--window|--rect x,y,w,h|--selector css] [--scale n] [--settle frames] [--count n [--interval ms]]
eval <js> [--await]                          click x,y | --selector css [--right] [--double] [--window]
drag x,y x2,y2 [--steps n]                   scroll <notches> [x,y]     type <text>     key <combo>... [--repeat n]
drop [files...] --at x,y [--text t] [--url u] [--html h] [--leave]   (another application's drag, through the drop target)
wait load|idle|stable|quiet | selector <css> [--visible] | function <js> | frames [n]   [--timeout ms] [--idle-ms ms] [--quiet-ms ms] [--max-cpu ms]
console [--clear] [--since n]   network [--clear]   stats   tabs   tab new|activate|close|move   windows
pref get <n> | set <n> <v> [--override] | reset <n> | reset --all | list [prefix]
reset [--keep tabs,data,prefs,window,history] [--url address] [--wait ...]
checkpoint save|restore|delete <name> | list
window [--size WxH] [--pos x,y] [--state normal|minimized|maximized|fullscreen]    cmds [filter]   cmd <name>
devtools [console|elements|network|none] [--dock ...]   setup   html [sel]   query sel   box sel   style sel [props]
snapshot <prefix>   record start|stop <file>   replay <file> [--speed n]   diff a.png b.png [--tolerance n] [--out d.png] [--max-percent p]
quit [--code n]     raw '<json request>'     ping     version
stacks <process id> [--out file.txt] [--dump file.dmp]

stacks needs no Wren and reads any process of this user (a test tool too): what each thread is doing, named with the program's PDB, read from outside, for what hangs and leaves nothing behind. A thread that says "cannot suspend" under "ending with code n" belongs to a process that has already been told to end, and that the system is still taking down. quit prints the same when the Wren does not end, and the shutdown watchdog leaves it in shutdown.log.stacks.txt before it ends a browser whose teardown is stuck.

Options of every command: --remote <name> (or WREN_REMOTE), --profile <dir> (finds the Wren by its profile folder), --timeout <ms>, --tab-id <id>, --json (the raw answer), --quiet. Exit codes: 0 done, 1 failed (an error answer, a script exception), 2 timed out, 3 no Wren answers or the connection ended, 4 wrong usage.

ctl is a command only as the first argument. To open a file or a host named ctl, write ./ctl.

Reusing one Wren: reset, checkpoint, wait quiet

A Wren that is started for every scenario costs its start (about 0.3 s to a first frame), its close (0.3 s), and the CPU of a start-up that must settle before anything at rest can be measured. A program that drives many scenarios starts one and puts it back between them:

wren ctl start --size 1000x700    # the window it has now is the one `reset` returns to
wren ctl nav $a; ...; wren ctl reset   # scenario 1, then a clean Wren
wren ctl nav $b; ...; wren ctl reset   # scenario 2
wren ctl stop

reset [--keep tabs,data,prefs,window,history] [--url address] [--wait load|idle|stable|quiet|none] (about 60 ms)

It doesUnless --keep names
ends the commands other clients are waiting on, with the error "cancelled by a reset"
closes developer tools (docked or in their window), the search bar, the command palette, the element picker, full screen, downloads in progress; stops a recording
leaves one blank tab (or --url): every other tab closes, with its zoom, scroll, history, console and network logtabs
empties cookies, localStorage, sessionStorage, IndexedDB, the HTTP cache, HSTS and the CORS preflight cachedata
empties the browsing history (about:history) and the list of closed tabshistory
puts every preference back as it was when the Wren started (the user layer, the command line layer, the defaults; policy is never touched)prefs
puts the window back (size, place, state) as it was when start / run had set --sizewindow

It prints what was removed (--json: cancelledOps, closedTabs, cookies, sites, cacheEntries, historyVisits, prefsRestored, windowRestored, tabId) and answers once the blank tab has loaded. stats shows the same counts (cookies, storageSites, cacheEntries, historyVisits, closedTabs, devtools...), which is how a test sees that a reset worked. checkpoint save initial makes the present window the one reset returns to (the preferences are those of the start of the run). Not reset: the blocker's per-site settings, the favicon cache, the files a download already wrote, anything a page left on the disk outside the profile.

checkpoint save <name> keeps the preferences, the window, the tabs (address, zoom, which is current), the cookies (session ones too) and the localStorage of every origin, in memory (at most browser.remote.max_checkpoints); checkpoint restore <name> closes the tabs, puts all that back, opens the tabs again and waits for the current one to load. IndexedDB, the HTTP cache and sessionStorage are not part of a checkpoint (a restore empties them); a restored tab loads its address again, so what a page kept only in memory is not back. checkpoint list, checkpoint delete <name>.

pref set refuses a value that --pref fixed when the Wren started ("set by policy or on the command line for this run"): --override changes that layer too. pref reset <name> (or --all) puts a preference back as it was at the start.

wait quiet waits for the process to be at rest, which a script cannot see without sampling its CPU for seconds: nothing loading in any tab, and at most browser.remote.quiet_cpu_ms (20) of CPU time, all threads, during browser.remote.quiet_ms (400); --quiet-ms and --max-cpu for one call. It answers with how long it waited and the CPU it saw. (wait idle is about the network of a page; --idle-ms n shortens or lengthens its quiet time for one call.) To measure an idle process, read stats twice: cpuTimeMs is the process's CPU time (the system adds it up in ticks of about 15.6 ms); threads, handles, gdiObjects, userObjects, uptimeMs and pid are there too. loop is the loop's own account: turns, sleeps, barren (wakes that drew nothing), frames, causes (each frame under the first of Input, Task, Page, Asked, Interface_Animation, Interface_Wake, Repaint) and reasons, the last 32 frames and barren wakes with who asked, a place in the sources: a browser at rest draws nothing, and what does draw says why (wrentool bench idle measures it from outside, with the wakes of each thread).

shot <file> --count n [--interval ms] takes n pictures over the one connection (a %d in the name takes the number, else -<n> goes before the extension) instead of a process, and its 100 ms of start, for each.

Starting a Wren that listens

The channel is off by default; a Wren that was not asked for it has no pipe, no thread and no timer.

  • wren --remote or wren --remote=<name>, or WREN_REMOTE=<name> in the environment, or the preference browser.remote.enabled (about:config, applied at the next start).
  • wren ctl run and wren ctl start start a Wren of this same executable with --remote=<name> --hidden --no-single-instance (--headless with wren ctl start --headless) and a throwaway profile (removed afterwards; a profile given by --profile is never removed). A throwaway profile starts with blocker.update.enabled=false (a Wren that lives 15 s would download the content blocker's lists, 24 MB, from the internet), unless --pref blocker.update.enabled=... says otherwise.
  • --hidden: a window that exists and is never shown: what wren ctl run / start use by default, for what needs a handle (UI Automation, the system's drag and hit test). It needs a desktop session.
  • --headless: frames are made as soon as a client waits for them, not at a monitor's refresh; a second window and the developer tools' window of their own have no HWND either.
  • --headless: a window with no HWND at all, not a hidden one, so it runs where there is no desktop session (a service in session 0, an SSH login, a CI runner). It renders to an offscreen target the capture reads; its size, place, state and DPI are emulated and resize the target (window --size WxH --dpi n); input comes through the same synthetic-event path as a real device's (platform.inject_event). wren --headless <url> and wren ctl run --headless / start use it. (wren ctl stats reports headless: true for such a window, and window / windows carry it.)
  • A driven Wren never hands its addresses to the user's running Wren (--remote implies --no-single-instance).
  • A status bar item "remote" shows that the channel listens (browser.remote.indicator).

The pipe is \\.\pipe\wren-remote-<name> (on Linux the Unix socket $XDG_RUNTIME_DIR/wren/remote-<name>.sock, else /tmp/wren-<uid>/...: a folder only this user may enter, the socket 0600, a client of another user closed at once). The name is given, or p + a hash of the profile folder, so the same folder always gives the same name and two instances with different profiles never meet. <profile>\remote.json has the name, the pipe, the pid and the protocol version; a client does not need it (wren ctl ping answers with the pid).

The traps

A program without a console window. wren.exe is a Windows GUI program. Started from cmd or PowerShell the plain way, it gives the prompt back at once: a script sees neither its end nor its exit code, and a redirection needs care. wren ctl writes to the standard handles when the caller redirected them (a file or a pipe: never around the redirection), else to the console of the parent process. Reliable form for scripts: the console launcher wren.com beside wren.exe (docs/console-launcher.md), which waits for ctl, hands over its own standard handles and returns the exit code. A program does not parse any of it: it uses the typed API of src/remote/api.

Relative paths are the caller's: wren ctl resolves them against its own current folder. In PowerShell, Set-Location does not change the process's folder: give absolute paths.

Sizes. A capture is in the renderer's pixels: window is the client area, which window --size WxH (and run --size) sets in pixels; the page area, a rectangle or an element are parts of it. The interface scale follows the display (--dpi 144 sets browser.ui.scale=1.5 for that run). shot --scale 0.5 averages, --scale 2 repeats pixels.

Settling. A capture is taken after browser.remote.settle_frames frames (2) so that hover, focus and transitions started by the previous command are in it; --settle n changes that for one call. For a page that loads things, nav waits for the load, then wait idle (network quiet for browser.remote.idle_ms) or wait stable (idle, no frame wanted, every image in).

One capture at a time per window; others queue. A real minimized window is restored without taking the focus for the capture and minimized again (a headless window renders whatever its emulated state, so there is nothing to restore). Both render backends read their frame back now: shot works with Direct3D 11 and with wgpu.

Elements are found with the document's own selector engine, with no script engine involved (a Wasm-only page works); eval, style and the readyState check need the page's JavaScript.

Security

  • The pipe's DACL has one entry: the current user's SID. Other users, other machines (PIPE_REJECT_REMOTE_CLIENTS) and sandboxed processes cannot open it. The first instance is created with FILE_FLAG_FIRST_PIPE_INSTANCE: a name taken by another process is an error reported on stderr, in the console of the first tab and by a notice, not a second server.
  • A page cannot reach the channel: it has no way to open a pipe or to send a line. The results of scripts that the channel runs come back through the page's console, on a line that starts with a mark (\x01wren-remote:), taken before the tab's console sees it. That line goes through the page's own console.log, which a page can replace: it sees every line, so the mark is not a secret (a secret prefix was read by such a page, which then answered in place of the script). What makes an answer count is where it comes from: the tab the question was asked in, an id that still waits, the first answer only. The value is checked to be one JSON value on one line before it goes into a reply (anything else is an error), so a page cannot add a line to the protocol. What stays: a page can lie about the value of what it is asked (a script of the page runs in the page: it can as well redefine document.title); nothing it writes reaches another tab or the client's own state.
  • A request line longer than browser.remote.max_message_bytes (8 MiB) is answered with an error and the connection is closed. At most browser.remote.max_clients (8) are served at once.
  • Everything a client does is what the user could do in the window; there is no way into the file system but the paths a client gives to shot / snapshot (written with the user's rights).

Protocol

One JSON object per line (UTF-8, \n), over the pipe.

→ {"id": 1, "method": "Page.navigate", "params": {"url": "https://example.com/", "wait": "load"}}
← {"id": 1, "result": {"tabId": 1, "url": "https://example.com/"}}
← {"id": 2, "error": {"code": -32001, "message": "timed out waiting for load"}}
← {"event": "Page.loadEventFired", "params": {"tabId": 1, "url": "..."}}      (after Remote.subscribe)

id is any JSON number or string, given back as it came. Answers of different requests may come in another order (a wait answers when its condition holds). Codes: -32700 parse error, -32600 invalid request, -32601 unknown method, -32602 invalid parameters, -32000 the command failed, -32001 a wait timed out.

Commands run on the interface thread, between frames. The server's threads only read and write the pipe (overlapped I/O, blocked in the kernel) and wake the event loop with host.post: nothing runs while no client sends anything. Waits are looked at after each frame and by one timer that exists only while a wait or a capture needs looking at.

Names follow the Chrome DevTools Protocol where Wren has the same thing, so that an adapter that speaks CDP or WebDriver BiDi to a test framework can be put in front of this channel later without redrawing it (it translates; it does not add a second control path). The rest is in the Wren. domain.

MethodParameters → result
Remote.ping→ {protocol, version, pid}
Remote.subscribe / unsubscribeevents Page.loadEventFired {tabId, url}, Runtime.consoleAPICalled {tabId, type, text}
Browser.getVersion→ product, revision, toolkit, protocol
Browser.close{exitCode}
Browser.getWindowBounds / setWindowBounds{left, top, width, height, windowState}: width and height are the client area in pixels; state normal, minimized, maximized, fullscreen
Target.getTargets / createTarget {url, wait, background} / activateTarget {targetId} / closeTarget {targetId}tabs
Page.navigate {url, wait, tabId, timeout}wait: none, load, idle, stable
Page.reload {ignoreCache, wait} / goBack / goForward / stopLoading
Page.captureScreenshot {path, bytes, target, selector, clip, scale, settle}target window, page, rect (clip x,y,width,height in window pixels) or selector; → {path, width, height, size, data?} (PNG; data is base64 when bytes or no path)
Runtime.evaluate {expression, awaitPromise, tabId}→ {result: {type, value}}, or exceptionDetails with the stack
DOM.getOuterHTML {selector} / querySelectorAll {selector, limit} / getBoxModel {selector}no script engine needed
CSS.getComputedStyleForNode {selector, properties}needs the page's JavaScript
Input.dispatchMouseEvent {type, x, y, button, modifiers, deltaX, deltaY, notches, space}type mouseMoved, mousePressed, mouseReleased, mouseWheel; x and y in page CSS pixels, "space":"window" for window pixels
Input.dispatchKeyEvent {type, key, text, modifiers} / Input.insertText {text}through the same events as a real keyboard
Wren.wait {for, selector, expression, visible, frames, timeout, idleMs, quietMs, maxCpuMs}for: load, idle, stable, quiet, selector, function, frames; quiet answers {waitedMs, cpuMs}
Wren.click {x, y | selector, button, count}an element is scrolled into view (the boxes that scroll around it, a dialog's, then the page), then clicked in the middle
Wren.drag, Wren.type {text}, Wren.key {keys, repeat}
Wren.stats, Wren.console {since, clear}, Wren.network {clear}the status bar's numbers and the process's (pid, uptimeMs, cpuTimeMs, threads, handles...); the tab's console and request logs
Wren.getPref {name} / setPref {name, value, override} / resetPref {name | all} / listPrefs {prefix}about:config
Wren.reset {keep, url, wait} / Wren.checkpoint {action: save|restore|list|delete, name, wait}put the running Wren back to a clean or a saved state (see above)
Wren.listCommands / runCommand {name} / openDevTools {panel, dock} / openSetupthe command registry
Wren.snapshot {path}<path>.png, .html, .json (url, size, the box of every element)
Wren.recordInput {action} / replayInput {events, speed}macros of the input a window receives (real or sent), with times
Wren.timeline {action: start | stop | status, scale, max, target, dir, cols, all}every frame the renderer draws (full and chrome frames) as pictures, a contact sheet and a JSON with each frame's time, scroll, kind and how much it differs from the one before (wren ctl timeline)

Input events are queued with platform.inject_event (toolkit 0.22.0) and read by the next poll_events: hover, focus, shortcuts, text input and the page's DOM events behave as for a real device. A command that sends input answers after the frames that carry it have been drawn.

Capture

The toolkit's render.capture_request(r, rect) marks the next frame; frame_end copies that frame's render target (or a part of it) just before the present — into a staging texture on Direct3D 11, into a mappable buffer on wgpu — and render.capture_poll maps it once the GPU is done, without waiting (a timer looks again every browser.remote.poll_ms). The copy costs one GPU command in that one frame. It works on both backends, with the window covered, off the screen, hidden, minimized or headless (no window at all), and does not depend on the monitor's DPI.

Preferences

browser.remote.enabled, .name, .max_message_bytes, .max_clients, .indicator, .timeout_ms (30 s, waits), .settle_frames (2), .poll_ms (4), .idle_ms (400), .quiet_ms (400), .quiet_cpu_ms (20), .max_checkpoints (16).

Driving Wren with Puppeteer, Playwright or Chrome DevTools

The channel above is Wren's own, over a pipe. For standard tools there is a Chrome DevTools Protocol / WebDriver BiDi adapter: a local WebSocket server that translates onto this same command engine. Turn it on with --remote-debugging-port=N (or the preference remote.cdp.enabled), then connect a tool to ws://127.0.0.1:N. It is off by default, binds 127.0.0.1 only, and refuses any handshake whose Host or Origin is not loopback. See docs/cdp.md for the endpoints, the methods covered and how to connect in one line.

Tests

src\tools\e2e (steps of test_all): over the channel, remote_channel.odin (off by default and idle at no cost, the protocol, vanishing and oversized clients, the pipe's ACL, two instances, a taken name, quit with a client connected, wren ctl run), remote_state.odin (reset: everything it names is back, a page sees no cookie or storage; the checkpoints), ctl_stop.odin (wren ctl stop), and on the check build remote_check.odin (a session that must exit with nothing allocated); in the test's process, through the same procedures, driven.odin (pages driven, captures and their pixels, a window off the screen and minimized), video_layers.odin, page_apis.odin, remote_page.odin. src/remote (protocol; the server against raw pipe clients), src/remote/client, src/browser/remote_test.odin. src\tools\e2e\remote_close_load.odin saturates every logical processor and checks that quit (with a client connected) and a WM_CLOSE to the window, each with a video playing, end within 8 s without the watchdog forcing the exit; test_all -Perf runs it last and alone.

Edit this page on GitHub