-- browser_shot: drive a disposable headless Chromium browser (Chrome, Edge or -- Chromium) through the DevTools protocol and save PNG screenshots. -- -- One call = one browser: a fresh temporary profile, the steps, the -- screenshots, then Browser.close and girl's own preview stop (the browser's -- tree by recorded identity, the profile removed) - also when a step fails. -- Nothing outlives the call. The browser is started by girl's -- platform.preview_chrome (a kill-on-close job, the profile under the session -- scratch, never in the workspace); the DevTools protocol runs over a small RFC -- 6455 WebSocket client on LuaXE's `socket` module, so the tool needs no Node, -- no npm and no driver binary: only an installed Chromium-family browser. -- -- Effects (all declared by the `browser.drive` permission and listed in the -- approval before anything runs): starts a local browser process, loads the -- given URL(s), performs the steps (clicks and `eval` can change what the page -- talks to), writes PNG files to the session scratch (or `out_dir` inside the -- workspace). local fs = require("platform.fs") local path = require("platform.path") local json = require("platform.json") local Preview = require("platform.preview_chrome") local DEFAULT_WIDTH, DEFAULT_HEIGHT = 1440, 900 local STEP_TIMEOUT_MS = 15000 local LAUNCH_TIMEOUT_MS = 20000 -- ── small helpers ────────────────────────────────────────────────────────── local function now_ms(socket) return socket.gettime() * 1000 end local function js_string(value) return json.encode(tostring(value)) end local function plain(value) -- json.null -> nil, recursively if value == json.null then return nil end if type(value) ~= "table" then return value end local out = {} for k, v in pairs(value) do out[k] = plain(v) end return out end local function parse_size(text) if text == nil then return nil end local w, h = tostring(text):match("^%s*(%d+)%s*[xX]%s*(%d+)%s*$") w, h = tonumber(w), tonumber(h) if not w or not h or w < 100 or h < 100 or w > 7680 or h > 7680 then error("size must look like 1440x900 (100..7680 per side), got " .. tostring(text), 0) end return w, h end local SCHEMES = { light = true, dark = true, ["no-preference"] = true } local function check_url(url, label) if type(url) ~= "string" or url == "" then error((label or "url") .. " is required", 0) end local scheme = url:match("^(%a[%w+.-]*):") scheme = scheme and scheme:lower() if scheme ~= "http" and scheme ~= "https" and scheme ~= "file" and scheme ~= "about" then error((label or "url") .. " must be http, https, file or about: " .. url, 0) end return url end -- ── base64 (screenshots arrive base64-encoded) ───────────────────────────── local B64 = {} do local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" for i = 1, #alphabet do B64[alphabet:byte(i)] = i - 1 end end local function base64_decode(text) text = text:gsub("[^%w%+/]", "") local out, n = {}, 0 local chunk = {} for i = 1, #text, 4 do local a, b, c, d = text:byte(i, i + 3) local v = B64[a] * 262144 + (b and B64[b] or 0) * 4096 + (c and B64[c] or 0) * 64 + (d and B64[d] or 0) local x = math.floor(v / 65536) local y = math.floor(v / 256) % 256 local z = v % 256 n = n + 1 if d then chunk[n] = string.char(x, y, z) elseif c then chunk[n] = string.char(x, y) else chunk[n] = string.char(x) end if n == 4096 then out[#out + 1] = table.concat(chunk); chunk = {}; n = 0 end end out[#out + 1] = table.concat(chunk, "", 1, n) return table.concat(out) end local function png_size(bytes) if #bytes < 24 or bytes:sub(1, 8) ~= "\137PNG\r\n\26\n" then return nil end local function u32(i) local a, b, c, d = bytes:byte(i, i + 3) return ((a * 256 + b) * 256 + c) * 256 + d end return u32(17), u32(21) end -- ── where the files go ───────────────────────────────────────────────────── -- The session scratch (girl's tool context), else the configured session dir, -- else the OS temp directory. local function scratch_of(context) local scratch = type(context) == "table" and context.scratch if type(scratch) == "string" and scratch ~= "" then return scratch end local config = type(context) == "table" and context.config local spec = type(config) == "table" and config.scratch if type(spec) == "table" and type(spec.session_dir) == "string" and spec.session_dir ~= "" then return spec.session_dir end return nil end local function default_out_dir(context) local scratch = scratch_of(context) if scratch then return path.join(scratch, "browser_shot") end local tmp = os.getenv("TEMP") or os.getenv("TMP") or os.getenv("TMPDIR") or "/tmp" return path.join(tmp, "girl-browser_shot") end -- ── a minimal WebSocket client (RFC 6455, text frames, client-masked) ────── local function ws_connect(socket, port, ws_path) local conn = assert(socket.tcp()) conn:settimeout(10) local ok, err = conn:connect("127.0.0.1", port) if not ok then error("cannot reach the browser's DevTools port " .. port .. ": " .. tostring(err), 0) end local key = {} for i = 1, 16 do key[i] = string.char(math.random(0, 255)) end local function b64(s) -- encoder for the 16-byte key only local chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" local out = {} for i = 1, #s, 3 do local a, b, c = s:byte(i, i + 2) local v = a * 65536 + (b or 0) * 256 + (c or 0) local function at(shift) local idx = math.floor(v / shift) % 64 + 1 return chars:sub(idx, idx) end out[#out + 1] = at(262144) .. at(4096) .. (b and at(64) or "=") .. (c and at(1) or "=") end return table.concat(out) end conn:send("GET " .. ws_path .. " HTTP/1.1\r\nHost: 127.0.0.1:" .. port .. "\r\nUpgrade: websocket\r\n" .. "Connection: Upgrade\r\nSec-WebSocket-Key: " .. b64(table.concat(key)) .. "\r\nSec-WebSocket-Version: 13\r\n\r\n") local status = conn:receive("*l") if not status or not status:find(" 101 ", 1, true) then conn:close() error("the browser refused the DevTools WebSocket: " .. tostring(status), 0) end repeat local line = conn:receive("*l") until line == nil or line == "" local bit = require("bit") local ws = { conn = conn } function ws.send(text) local n = #text local head if n < 126 then head = string.char(0x81, 0x80 + n) elseif n < 65536 then head = string.char(0x81, 0x80 + 126, math.floor(n / 256), n % 256) else local bytes = {} local rest = n for i = 8, 1, -1 do bytes[i] = rest % 256; rest = math.floor(rest / 256) end head = string.char(0x81, 0x80 + 127, unpack(bytes)) end local m = { math.random(0, 255), math.random(0, 255), math.random(0, 255), math.random(0, 255) } local out, chunk, c = { head, string.char(m[1], m[2], m[3], m[4]) }, {}, 0 for i = 1, n do c = c + 1 chunk[c] = string.char(bit.bxor(text:byte(i), m[(i - 1) % 4 + 1])) if c == 8192 then out[#out + 1] = table.concat(chunk); chunk = {}; c = 0 end end out[#out + 1] = table.concat(chunk, "", 1, c) local sent, err = conn:send(table.concat(out)) if not sent then error("the DevTools connection dropped while sending: " .. tostring(err), 0) end end -- One whole message, or nil plus "timeout"/"closed". function ws.receive(timeout_s) conn:settimeout(timeout_s) local parts = {} while true do local h, err = conn:receive(2) if not h then return nil, err end conn:settimeout(10) -- the rest of a frame that has started arrives promptly local b1, b2 = h:byte(1, 2) local fin, opcode = b1 >= 128, b1 % 16 local len = b2 % 128 if len == 126 then local e = assert(conn:receive(2)); len = e:byte(1) * 256 + e:byte(2) elseif len == 127 then local e = assert(conn:receive(8)); len = 0 for i = 1, 8 do len = len * 256 + e:byte(i) end end local payload = len > 0 and conn:receive(len) or "" if payload == nil then return nil, "closed" end if opcode == 8 then return nil, "closed" elseif opcode == 9 then -- ping -> pong (masked, same payload) local mask = string.char(0, 0, 0, 0) conn:send(string.char(0x8A, 0x80 + #payload) .. mask .. payload) elseif opcode == 1 or opcode == 0 or opcode == 2 then parts[#parts + 1] = payload if fin then return table.concat(parts) end end end end function ws.close() pcall(function() conn:close() end) end return ws end -- ── the DevTools session ────────────────────────────────────────────────── local function http_get(socket, port, route) local conn = assert(socket.tcp()) conn:settimeout(5) if not conn:connect("127.0.0.1", port) then conn:close() return nil end conn:send("GET " .. route .. " HTTP/1.1\r\nHost: 127.0.0.1:" .. port .. "\r\nConnection: close\r\n\r\n") local parts = {} while true do local chunk, _, partial = conn:receive(8192) parts[#parts + 1] = chunk or partial if not chunk then break end end conn:close() local text = table.concat(parts) return text:match("\r\n\r\n(.*)$") end local function new_cdp(socket, ws) local cdp = { ws = ws, next_id = 0, console = {}, events = {} } local function note(event) local params = event.params or {} if event.method == "Runtime.consoleAPICalled" and (params.type == "error" or params.type == "assert" or params.type == "warning") then local words = {} for _, arg in ipairs(params.args or {}) do local v = arg.value if v == nil or v == json.null then v = arg.description or arg.type end words[#words + 1] = type(v) == "table" and json.encode(plain(v)) or tostring(v) end cdp.console[#cdp.console + 1] = params.type .. ": " .. table.concat(words, " ") elseif event.method == "Runtime.exceptionThrown" then local d = params.exceptionDetails or {} local ex = d.exception or {} cdp.console[#cdp.console + 1] = "exception: " .. tostring(ex.description or d.text or "?") .. (d.url and d.url ~= json.null and (" @ " .. tostring(d.url) .. ":" .. tostring(d.lineNumber)) or "") elseif event.method == "Log.entryAdded" then local e = params.entry or {} if e.level == "error" then cdp.console[#cdp.console + 1] = "log: " .. tostring(e.text) .. (e.url and e.url ~= json.null and (" @ " .. tostring(e.url)) or "") end end cdp.events[#cdp.events + 1] = event.method if #cdp.events > 200 then table.remove(cdp.events, 1) end end -- Send one command and wait for ITS reply; events seen meanwhile are noted. function cdp.call(method, params, session, timeout_ms) cdp.next_id = cdp.next_id + 1 local id = cdp.next_id local message = { id = id, method = method, params = params or json.object() } if session then message.sessionId = session end ws.send(json.encode(message)) local deadline = now_ms(socket) + (timeout_ms or STEP_TIMEOUT_MS) while true do local left = (deadline - now_ms(socket)) / 1000 if left <= 0 then error(method .. " timed out", 0) end local text, err = ws.receive(left) if not text then if err == "timeout" then error(method .. " timed out", 0) end error("the browser closed the DevTools connection during " .. method, 0) end local reply = json.decode(text) if reply.id == id then if reply.error and reply.error ~= json.null then error(method .. ": " .. tostring(reply.error.message or json.encode(plain(reply.error))), 0) end return reply.result or {} elseif reply.method then note(reply) if cdp.waiting and reply.method == cdp.waiting then cdp.seen = true end end end end -- Drain events until `method` arrives (or time runs out). Returns true if seen. function cdp.wait_event(method, timeout_ms) if cdp.seen and cdp.waiting == method then cdp.seen = false; cdp.waiting = nil; return true end local deadline = now_ms(socket) + timeout_ms while true do local left = (deadline - now_ms(socket)) / 1000 if left <= 0 then return false end local text = ws.receive(left) if not text then return false end local event = json.decode(text) if event.method then note(event) if event.method == method then return true end end end end return cdp end -- ── steps ────────────────────────────────────────────────────────────────── local KEYS = { Enter = { code = "Enter", vk = 13, text = "\r" }, Tab = { code = "Tab", vk = 9 }, Escape = { code = "Escape", vk = 27 }, Backspace = { code = "Backspace", vk = 8 }, Delete = { code = "Delete", vk = 46 }, Space = { code = "Space", vk = 32, text = " ", key = " " }, ArrowUp = { code = "ArrowUp", vk = 38 }, ArrowDown = { code = "ArrowDown", vk = 40 }, ArrowLeft = { code = "ArrowLeft", vk = 37 }, ArrowRight = { code = "ArrowRight", vk = 39 }, Home = { code = "Home", vk = 36 }, End = { code = "End", vk = 35 }, PageUp = { code = "PageUp", vk = 33 }, PageDown = { code = "PageDown", vk = 34 }, } local STEP_KINDS = { "goto", "wait", "wait_js", "click", "hover", "type", "key", "eval", "scroll", "size", "color_scheme", "shot" } -- Normalize one step to { kind, ... } and validate it; never touches the browser. local function parse_step(step, index) if type(step) ~= "table" then error("step " .. index .. " must be an object", 0) end local kind for _, name in ipairs(STEP_KINDS) do if step[name] ~= nil then if kind then error("step " .. index .. " names two actions (" .. kind .. ", " .. name .. ")", 0) end kind = name end end if not kind then error("step " .. index .. " has no action (one of: " .. table.concat(STEP_KINDS, ", ") .. ")", 0) end local v = step[kind] local out = { kind = kind, timeout_ms = tonumber(step.timeout_ms) or STEP_TIMEOUT_MS } if kind == "goto" then out.url = check_url(v, "step " .. index .. " goto") elseif kind == "wait" then if type(v) == "number" then out.ms = math.max(0, math.min(v, 60000)) elseif type(v) == "string" and v ~= "" then out.selector = v else error("step " .. index .. " wait takes a selector or milliseconds", 0) end elseif kind == "wait_js" or kind == "eval" then if type(v) ~= "string" or v == "" then error("step " .. index .. " " .. kind .. " takes a JavaScript expression", 0) end out.js = v elseif kind == "click" or kind == "hover" or kind == "scroll" then if type(v) ~= "string" or v == "" then error("step " .. index .. " " .. kind .. " takes a CSS selector", 0) end out.selector = v elseif kind == "type" then if type(v) ~= "table" or type(v[1]) ~= "string" or v[2] == nil then error("step " .. index .. ' type takes ["selector", "text"]', 0) end out.selector, out.text = v[1], tostring(v[2]) out.clear = step.clear ~= false elseif kind == "key" then if type(v) ~= "string" or v == "" then error("step " .. index .. " key takes a key name such as Enter", 0) end out.key = v elseif kind == "size" then out.width, out.height = parse_size(v) elseif kind == "color_scheme" then if not SCHEMES[v] then error("step " .. index .. " color_scheme is light, dark or no-preference", 0) end out.scheme = v elseif kind == "shot" then local name = type(v) == "string" and v or "" if name == "" or v == true then name = "shot-" .. index .. ".png" end name = path.basename(name) if not name:lower():match("%.png$") then name = name .. ".png" end if name:find("[^%w%._%-]") then error("step " .. index .. " shot name may use letters, digits, . _ -", 0) end out.name = name out.selector = type(step.selector) == "string" and step.selector ~= "" and step.selector or nil out.full_page = step.full_page == true out.padding = tonumber(step.padding) or 0 end return out end local function describe(step) local k = step.kind if k == "goto" then return "goto " .. step.url end if k == "wait" then return step.ms and ("wait " .. step.ms .. " ms") or ("wait for " .. step.selector) end if k == "wait_js" then return "wait until JS: " .. step.js end if k == "eval" then return "eval JS: " .. step.js end if k == "type" then return "type " .. json.encode(step.text) .. " into " .. step.selector end if k == "key" then return "press " .. step.key end if k == "size" then return "viewport " .. step.width .. "x" .. step.height end if k == "color_scheme" then return "prefers-color-scheme: " .. step.scheme end if k == "shot" then return "screenshot " .. step.name .. (step.selector and (" of " .. step.selector) or "") .. (step.full_page and " (full page)" or "") end return k .. " " .. tostring(step.selector) end -- The whole request, resolved and validated, without touching the browser. -- `pure` (the approval prompt) also reads no file: a scenario is named, not -- expanded, there. local function plan(args, context, pure) local scenario = {} if type(args.scenario) == "string" and args.scenario ~= "" then if args.steps ~= nil then error("pass steps OR a scenario file, not both", 0) end if pure then scenario = { steps = {}, url = args.url or "about:blank" } else local text = fs.read(context.workspace, args.scenario) local ok, decoded = pcall(json.decode, text) if not ok or type(decoded) ~= "table" then error("scenario " .. args.scenario .. " is not a JSON object", 0) end scenario = plain(decoded) end end local url = check_url(args.url or scenario.url, "url") local width, height = parse_size(args.size or scenario.size) width, height = width or DEFAULT_WIDTH, height or DEFAULT_HEIGHT local scheme = args.color_scheme or scenario.color_scheme if scheme ~= nil and not SCHEMES[scheme] then error("color_scheme is light, dark or no-preference", 0) end local raw = args.steps or scenario.steps or {} if type(raw) ~= "table" then error("steps must be an array", 0) end local steps = {} for i, step in ipairs(raw) do steps[i] = parse_step(step, i) end local has_shot = false for _, s in ipairs(steps) do if s.kind == "shot" then has_shot = true end end if not has_shot then steps[#steps + 1] = parse_step({ shot = "page.png" }, #steps + 1) end local out_dir if type(args.out_dir) == "string" and args.out_dir ~= "" then local resolved, why = path.resolve_inside(context.workspace, args.out_dir) if not resolved then error("out_dir must be inside the workspace (" .. tostring(why) .. ")", 0) end out_dir = resolved else out_dir = default_out_dir(context) end return { url = url, width = width, height = height, scheme = scheme, steps = steps, out_dir = out_dir, browser = args.browser, settle_ms = tonumber(args.settle_ms) or 150, } end -- JavaScript run in the page. Each returns a value by JSON. local function js_rect(selector) return "(()=>{const el=document.querySelector(" .. js_string(selector) .. ");if(!el)return null;" .. "el.scrollIntoView({block:'center',inline:'center'});const r=el.getBoundingClientRect();" .. "return {x:r.left,y:r.top,w:r.width,h:r.height,sx:window.scrollX,sy:window.scrollY}})()" end local function run_steps(cdp, session, req, socket, shots, results, context) local function call(method, params, timeout) return cdp.call(method, params, session, timeout) end local function eval(js, timeout) local r = call("Runtime.evaluate", { expression = js, awaitPromise = true, returnByValue = true }, timeout) if r.exceptionDetails and r.exceptionDetails ~= json.null then local ex = r.exceptionDetails.exception or {} error("JavaScript threw: " .. tostring(ex.description or r.exceptionDetails.text), 0) end return plain(r.result and r.result.value) end local function settle() -- two animation frames, then the settle pause: a click's repaint lands eval("new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>r(true))))", 5000) if req.settle_ms > 0 then socket.sleep(req.settle_ms / 1000) end end local function poll(js, timeout_ms, what) local deadline = now_ms(socket) + timeout_ms while true do local value = eval(js, timeout_ms) if value then return value end if now_ms(socket) > deadline then error("timed out after " .. timeout_ms .. " ms waiting for " .. what, 0) end socket.sleep(0.1) end end local function rect_of(selector, timeout_ms) return poll(js_rect(selector), timeout_ms, selector) end local function mouse(kind, x, y) call("Input.dispatchMouseEvent", { type = kind, x = x, y = y, button = "left", clickCount = 1 }) end local function navigate(url, timeout_ms) cdp.waiting = "Page.loadEventFired"; cdp.seen = false local r = call("Page.navigate", { url = url }, timeout_ms) if r.errorText and r.errorText ~= json.null then error("could not load " .. url .. ": " .. tostring(r.errorText), 0) end if not cdp.wait_event("Page.loadEventFired", timeout_ms) then error("timed out loading " .. url, 0) end cdp.waiting = nil end local function viewport(w, h) call("Emulation.setDeviceMetricsOverride", { width = w, height = h, deviceScaleFactor = 1, mobile = false }) end viewport(req.width, req.height) if req.scheme then call("Emulation.setEmulatedMedia", { features = { { name = "prefers-color-scheme", value = req.scheme } } }) end navigate(req.url, STEP_TIMEOUT_MS * 2) settle() for i, step in ipairs(req.steps) do local k, t = step.kind, step.timeout_ms local ok, err = pcall(function() if k == "goto" then navigate(step.url, t) elseif k == "wait" then if step.ms then socket.sleep(step.ms / 1000) else rect_of(step.selector, t) end elseif k == "wait_js" then poll(step.js, t, "JS " .. step.js) elseif k == "eval" then results[#results + 1] = { step = i, value = eval(step.js, t) } elseif k == "click" or k == "hover" then local r = rect_of(step.selector, t) local x, y = r.x + r.w / 2, r.y + r.h / 2 mouse("mouseMoved", x, y) if k == "click" then mouse("mousePressed", x, y); mouse("mouseReleased", x, y) end elseif k == "scroll" then rect_of(step.selector, t) elseif k == "type" then rect_of(step.selector, t) eval("(()=>{const el=document.querySelector(" .. js_string(step.selector) .. ");el.focus();" .. (step.clear and "if('value' in el){el.value='';el.dispatchEvent(new Event('input',{bubbles:true}))}" or "") .. "return true})()") call("Input.insertText", { text = step.text }) elseif k == "key" then local spec = KEYS[step.key] if not spec and #step.key == 1 then spec = { key = step.key, code = "", vk = step.key:upper():byte(), text = step.key } end if not spec then error("unknown key " .. step.key, 0) end local base = { key = spec.key or step.key, code = spec.code, windowsVirtualKeyCode = spec.vk, nativeVirtualKeyCode = spec.vk } local down = { type = spec.text and "keyDown" or "rawKeyDown", text = spec.text } for kk, vv in pairs(base) do down[kk] = vv end call("Input.dispatchKeyEvent", down) local up = { type = "keyUp" } for kk, vv in pairs(base) do up[kk] = vv end call("Input.dispatchKeyEvent", up) elseif k == "size" then viewport(step.width, step.height) elseif k == "color_scheme" then call("Emulation.setEmulatedMedia", { features = { { name = "prefers-color-scheme", value = step.scheme } } }) elseif k == "shot" then local params = { format = "png", captureBeyondViewport = step.full_page or step.selector ~= nil } if step.selector then local r = rect_of(step.selector, t) settle() r = eval(js_rect(step.selector)) local pad = step.padding params.clip = { x = math.max(0, r.x + r.sx - pad), y = math.max(0, r.y + r.sy - pad), width = math.max(1, r.w + 2 * pad), height = math.max(1, r.h + 2 * pad), scale = 1 } elseif step.full_page then local m = call("Page.getLayoutMetrics") local size = m.cssContentSize or m.contentSize params.clip = { x = 0, y = 0, width = size.width, height = size.height, scale = 1 } end local shot = call("Page.captureScreenshot", params, math.max(t, 30000)) local bytes = base64_decode(shot.data or "") if not fs.ensure_dir(req.out_dir) then error("cannot create " .. req.out_dir, 0) end local file = path.join(req.out_dir, step.name) local handle = io.open(file, "wb") if not handle then error("cannot write " .. file, 0) end handle:write(bytes) handle:close() local w, h = png_size(bytes) shots[#shots + 1] = { name = step.name, path = file, width = w, height = h } end if k ~= "wait" and k ~= "shot" and k ~= "eval" then settle() end end) if not ok then return i, err end end return nil end -- ── the tool ────────────────────────────────────────────────────────────── local function execute(args, context) local req = plan(args, context) local socket = import("socket") math.randomseed(os.time() + math.floor((socket.gettime() % 1) * 1e6)) -- girl's preview launcher: the profile under the session scratch (never -- the workspace), the browser inside a kill-on-close job, its pid recorded -- with its identity. Port 0: the browser picks one and writes it into the -- profile's DevToolsActivePort. local browser, why = Preview.launch({ scratch = scratch_of(context), workspace = context.workspace, config = context.config, port = 0, executable = req.browser, url = "about:blank", args = { "--disable-extensions", "--disable-background-networking", "--disable-sync", "--disable-component-update", "--mute-audio" }, }) if not browser then error(tostring(why), 0) end local result = { ok = false, url = req.url, browser = browser.executable, size = req.width .. "x" .. req.height, out_dir = req.out_dir, shots = {}, results = {}, console = {} } local ws, cdp local ok, err = pcall(function() local port local deadline = now_ms(socket) + LAUNCH_TIMEOUT_MS while not port do local handle = io.open(path.join(browser.profile, "DevToolsActivePort"), "rb") if handle then port = tonumber((handle:read("*a") or ""):match("^(%d+)")) handle:close() end if not port then if now_ms(socket) > deadline then error("the browser did not open a DevTools port within " .. LAUNCH_TIMEOUT_MS .. " ms", 0) end socket.sleep(0.1) end end local version = http_get(socket, port, "/json/version") or "" local ws_path = version:match('"webSocketDebuggerUrl"%s*:%s*"ws://[^/]+(/[^"]+)"') if not ws_path then error("the browser's DevTools answered no WebSocket URL", 0) end ws = ws_connect(socket, port, ws_path) cdp = new_cdp(socket, ws) local target = cdp.call("Target.createTarget", { url = "about:blank" }) local attached = cdp.call("Target.attachToTarget", { targetId = target.targetId, flatten = true }) local session = attached.sessionId for _, domain in ipairs({ "Page.enable", "Runtime.enable", "Log.enable" }) do cdp.call(domain, nil, session) end local failed_step, step_error = run_steps(cdp, session, req, socket, result.shots, result.results, context) if failed_step then result.failed_step = failed_step result.error = "step " .. failed_step .. " (" .. describe(req.steps[failed_step]) .. "): " .. tostring(step_error) else result.ok = true end end) if not ok then result.error = tostring(err) end if cdp then for _, line in ipairs(cdp.console) do result.console[#result.console + 1] = line end end -- Cleanup, whatever happened above: ask the browser to close, then girl's -- preview stop - the tree by its recorded identity (never by name), the -- profile removed, and the process table read back. if cdp then pcall(cdp.call, "Browser.close", nil, nil, 5000) end if ws then ws.close() end local stopped, stop_error = Preview.stop(browser) if not stopped then result.cleanup = "the browser cleanup did not finish: " .. tostring(stop_error) end if #result.console == 0 then result.console = nil end if #result.results == 0 then result.results = nil end return result end return function(registry) registry:register { name = "browser_shot", description = "Open a URL in a disposable headless Chromium browser (Chrome, Edge or Chromium must be installed), " .. "optionally run interaction steps (click, type, key, wait, eval, resize, color scheme), and save PNG " .. "screenshots of the viewport, the full page or one element. Returns the screenshot paths (view them " .. "with read_file), eval results and the page's console errors. Use it to check how a web UI really " .. "renders. Each call starts and closes its own browser.", input_schema = { type = "object", properties = { url = { type = "string", description = "Page to open first (http, https, file or about)." }, steps = { type = "array", description = "Actions in order; each object names ONE action. " .. '{"goto":url} {"wait":"css"|ms} {"wait_js":"expr"} {"click":"css"} {"hover":"css"} ' .. '{"scroll":"css"} {"type":["css","text"],"clear":true} {"key":"Enter|Escape|Tab|ArrowDown|a"} ' .. '{"eval":"js expr (awaited; value returned)"} {"size":"390x844"} {"color_scheme":"dark"} ' .. '{"shot":"name.png","selector":"css (element clip)","full_page":true,"padding":8}. ' .. "Any step may carry timeout_ms (default 15000). Without a shot step one viewport shot is taken at the end.", items = { type = "object" }, }, scenario = { type = "string", description = "Workspace path of a JSON file {url?, size?, color_scheme?, steps} (instead of steps)." }, size = { type = "string", description = "Viewport WIDTHxHEIGHT, default 1440x900." }, color_scheme = { type = "string", enum = { "light", "dark", "no-preference" }, description = "Emulated prefers-color-scheme." }, out_dir = { type = "string", description = "Workspace-relative folder for the PNGs; default: the session scratch (browser_shot/)." }, browser = { type = "string", description = "Browser executable path or name; default: auto-detect Chrome, Edge, Chromium." }, settle_ms = { type = "integer", description = "Pause after each action before the next one (default 150)." }, }, additionalProperties = false, }, permission = "browser.drive", resource = function(args) local url = type(args) == "table" and args.url if type(url) == "string" and url ~= "" then return url end return type(args) == "table" and args.scenario and ("scenario " .. tostring(args.scenario)) or "browser" end, approval_details = function(args, context) args, context = args or {}, context or {} local ok, req = pcall(plan, args, context, true) if not ok then return { lines = { "invalid request: " .. tostring(req) } } end local lines = { "headless browser → " .. (args.scenario and not args.url and "(url from the scenario)" or req.url) .. " (" .. req.width .. "x" .. req.height .. (req.scheme and (", " .. req.scheme) or "") .. ")", } if args.scenario then lines[#lines + 1] = "steps: from the scenario file " .. tostring(args.scenario) else for i, step in ipairs(req.steps) do lines[#lines + 1] = string.format("%2d. %s", i, describe(step)) end end lines[#lines + 1] = "screenshots → " .. req.out_dir return { lines = lines } end, run = execute, } end