santoku-cdp
View on LuaRocksChrome DevTools Protocol client: attach to a page, navigate, evaluate JavaScript, wait for elements and take screenshots, plus a toku-browser command
santoku-cdp drives a Chromium browser from Lua over the Chrome DevTools Protocol: attach to a page, navigate, evaluate JavaScript, wait for an element and take screenshots. It speaks the protocol through santoku-socket's WebSocket client, so it runs in CLIs and scripts with native sockets rather than in this page, and the examples are shown for reading. The toku-browser command exposes the same operations from a shell, and toku browser reaches it.
A browser with a DevTools port
santoku-cdp attaches to a browser started with --remote-debugging-port. targets(opts) and version(opts) read the browser's /json/list and /json/version; opts.host defaults to 127.0.0.1 and opts.port to 9222. A failed request raises "cdp: GET failed" with the path and status.
$ chromium --headless=new --remote-debugging-port=9222 &
$ toku browser targets
3F2A... page about:blank about:blank
Screenshots and waiting for an element
screenshot(s[, opts]) returns PNG bytes of the viewport. With opts.full it sizes the capture to the page's content, so the whole page comes back in one image; opts.ms bounds the capture, 30000 by default. wait_for(s, selector[, ms]) checks for a CSS match every 200 ms while it keeps reading events, and raises "cdp: no element matches" with the selector after ms, 10000 by default.
local fs = require("santoku.fs")
local page = require("santoku.cdp.page")
local s = page.open({ port = 9222 })
page.navigate(s, "https://santoku.dev/")
page.wait_for(s, "nav a", 5000)
fs.writefile("site.png", page.screenshot(s, { full = true }))
s.close()
Passkeys and console capture
santoku.cdp.webauthn gives a page a virtual authenticator, so a test can sign in with a passkey. authenticator(s[, opts]) adds one (CTAP2, internal, resident keys, user verification, PRF) and returns its id. credentials(s, id) returns what WebAuthn.getCredentials reports, and load(s, json[, opts]) adds a fresh authenticator and those credentials back in a later run, taking { credentials = [...] } or a bare list, and returns the id and the count. A reloaded passkey signs in, but its PRF output comes back null, so it can't stand in for a PRF-based unlock. santoku.cdp.auth.basic(s, url_pattern, user, pass) answers HTTP basic-auth challenges for URLs matching url_pattern, only from the request's own origin, declines proxy challenges, and returns a function that counts the challenges it answered. Event handlers run inside the socket read, so they send with s.send(method, params), which drops the reply, instead of the blocking s.call. santoku.cdp.log.watch(s) discards console entries buffered before it ran, enables Runtime and Network, and returns a drain function; each call returns the lines collected since the last one: console calls, exceptions, failed requests, HTTP statuses of 400 and up, and WebAuthn credential events. toku-browser takes --passkey-env VAR to load credentials JSON from an environment variable before any command, --logs to print those lines on stderr, and --basic-env USER_VAR,PASS_VAR with --basic-pattern URLPATTERN for basic auth.
local cjson = require("cjson")
local page = require("santoku.cdp.page")
local webauthn = require("santoku.cdp.webauthn")
local log = require("santoku.cdp.log")
local s = page.open({ port = 9222 })
local drain = log.watch(s)
local id = webauthn.authenticator(s)
page.navigate(s, "http://localhost:8080/")
page.wait_until(s, "document.body.dataset.signedIn", 10000)
local saved = cjson.encode(webauthn.credentials(s, id))
for _, line in ipairs(drain()) do
print(line)
end
s.close()
The session: protocol calls and events
cdp.page(opts) returns a session on the page's WebSocket, and cdp.connect(url, opts) opens one on any webSocketDebuggerUrl, such as the browser endpoint that version() reports; opts.connect_timeout_ms and opts.max_message pass through to the WebSocket client. call(method[, params[, ms]]) sends one command and returns its result, waiting 10000 ms by default. It raises "cdp: no reply" with the method and the transport's reason, or "cdp: call failed" with Chrome's message. on(method, fn) registers an event handler, called with the event's params and sessionId. wait(fn, ms) reads the socket and dispatches events until fn returns true or ms passes. cdp.session(conn) builds the same session over any object with send, wait and close, which is how its specs run without a browser.
local cdp = require("santoku.cdp")
local s = cdp.page({ port = 9222 })
s.on("Network.requestWillBeSent", function (params)
print(params.request.url)
end)
s.call("Network.enable")
s.call("Page.navigate", { url = "https://santoku.dev/" })
s.wait(function () return false end, 2000)
s.close()
toku-browser from a shell
The rock installs a toku-browser command. --host, --port and --id pick the browser and the page. shot and eval load the URL first; pass - to act on the current page instead. eval prints the value as JSON and waits at most 10000 ms for it, with no --ms option, so a longer promise needs page.eval(s, expr, ms) from Lua. The rest act on the current page: wait for a selector, until for a JavaScript condition, type into a selector, click a selector or an x,y point (--right, --double), key, text, drag between two points as touch, and call for one raw DevTools method. toku browser works through toku's command dispatch: santoku-cli 3.3.0 and later run toku-<name> from PATH for any name that is not a built-in command.
$ toku browser shot https://santoku.dev/ site.png --full
site.png 1136328 bytes
$ toku browser eval https://santoku.dev/ 'document.links.length'
54
$ toku browser wait 'nav a' --ms 5000
$ toku browser type '#q' komodo
$ toku browser until "document.querySelector('td') && document.querySelector('td').textContent"
"Komodo Island"
toku-browser run: one wasm spec in a fresh Chromium
run starts its own headless browser and ignores --host and --port. It runs $TOKU_CHROMIUM with a temporary profile and --remote-debugging-port=0, reads the port Chromium picked, and serves the spec through DevTools request interception at http://localhost/, so no server or fixed port is involved. The page relays console output to stdout and errors to stderr. The spec is done when emscripten has set its exit status and no timer is pending; an uncaught error or rejection fails it, and TOKU_BROWSER_TIMEOUT_MS (60000 by default) bounds it. The command exits with the spec's status, then stops only the Chromium it started and deletes the profile. TOKU_CHROMIUM_FLAGS adds browser flags. toku test --wasm-browser uses it as the spec interpreter; santoku.cdp.run.run(spec, opts) is the same thing from Lua.
$ TOKU_CHROMIUM=chromium-browser toku browser run build/default-wasm/test/test-browser/test/spec/my_lib.js
$ echo $?
0
$ TOKU_CHROMIUM=chromium TOKU_CHROMIUM_FLAGS=--no-sandbox toku browser run spec.js