← All tools
nebelwolfi ·v1.0.0 ·updated 3 days ago ·3 downloads screenshotbrowserui
browser_shot
VerifiedAutomates browser screenshots
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.
browser_shot.lua · v1.0.0 · 34 KB · md5 8263277943982f08b5ca830f6d1c26cb Download
-- 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
- 1.0.0 3 days ago · 34 KB · download
md5 8263277943982f08b5ca830f6d1c26cb