feat(browser): drive an installed Chromium over its debugging protocol

A kernel package for an agent that has to see and operate web pages, with no
frontend attached: the tools and the Studio panel sit on top of it.

It finds a Chrome, Edge or Chromium already on the machine (never downloads
one), runs it on a profile of its own, and drives it over
--remote-debugging-pipe; Windows cannot hand a child descriptors 3 and 4, so
there it falls back to a loopback port. One process per profile is shared by
every session through a Pool, since a profile admits one process.

A page is rendered from the accessibility tree, one element per line, with a
ref the package issues, never reuses, and retires when its document navigates
away — so a stale ref fails as browser.stale_ref instead of landing on
whatever replaced the element. Inputs are real pointer and keyboard events; a
pointer input whose point another element would receive is refused as
browser.covered, naming that element. A JavaScript dialog stops the page from
answering anything that runs on it, so page-bound commands fail at once while
one is open and are abandoned when one opens mid-call. Every failure carries a
Code.

checkURL bounds a page to http(s), about:blank and files inside the workspace
once symlinks resolve; a page reached past that by a link is refused when read
or acted on. The package is declared sensitive, and a utility-layer leaf.

Live tests (REASONIX_LIVE_BROWSER=<executable>) cover both transports, forms,
selects, covering elements, dialogs, popups, stale refs and screenshot
coordinates; they skip where no browser is installed.
This commit is contained in:
esengine committed 2026-09-17 07:47:37 -07:00
1 parent f7f473a433
commit 20a7a0cf6b
20 files changed
+3326 -1

No files matched your search

+1
View File
@@ -339,6 +339,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/pluginpkg/**
- sensitive: internal/netclient/**
- sensitive: internal/redirectguard/**
- sensitive: internal/browser/**
One declaration, two effects: the same list is also the coverage gate's subject.
`make coverage-gate` holds each path to the coverage it already has, because
+1 -1
View File
@@ -18,6 +18,7 @@ require (
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.8
github.com/godbus/dbus/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/kevinburke/ssh_config v1.6.0
github.com/larksuite/oapi-sdk-go/v3 v3.11.0
@@ -60,7 +61,6 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
+549
View File
@@ -0,0 +1,549 @@
package browser
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
const (
objectGroup = "reasonix"
maxWait = 30 * time.Second
defaultWaitFor = 5 * time.Second
)
// Step is one input. X and Y are in the pixels of the tab's latest screenshot.
type Step struct {
Action string `json:"action"`
Ref string `json:"ref,omitempty"`
Text string `json:"text,omitempty"`
Key string `json:"key,omitempty"`
Values []string `json:"values,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
DeltaY float64 `json:"delta_y,omitempty"`
Ms int `json:"ms,omitempty"`
Accept *bool `json:"accept,omitempty"`
}
// ActResult is what a run of steps did before it finished or stopped.
type ActResult struct {
Tab TabInfo
Done int // steps that completed
Notes []string // one per completed step
Logs []LogEntry // errors and warnings the page reported meanwhile
Opened []string // tabs the page opened meanwhile
Dialog *Dialog
FailedAt int // index of the step that failed, or -1
}
// Act runs steps in order on a tab and stops at the first that fails. The
// returned error is that step's failure; the result says what came before it.
func (s *Session) Act(ctx context.Context, tabID string, steps []Step) (ActResult, error) {
t, err := s.tab(tabID)
if err != nil {
return ActResult{FailedAt: 0}, err
}
if err := t.checkCurrentURL(); err != nil {
return ActResult{FailedAt: 0}, err
}
_, logMark := t.logsAfter(1 << 62)
t.mu.Lock()
popupMark := len(t.popups)
t.mu.Unlock()
res := ActResult{FailedAt: -1}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), closeTimeout)
_ = t.call(releaseCtx, "Runtime.releaseObjectGroup", map[string]any{"objectGroup": objectGroup}, nil)
cancel()
}()
var stepErr error
for i, step := range steps {
note, err := s.runStep(ctx, t, step)
if err != nil {
res.FailedAt, stepErr = i, err
break
}
res.Done++
res.Notes = append(res.Notes, note)
}
entries, _ := t.logsAfter(logMark)
for _, e := range entries {
if e.Level != "info" {
res.Logs = append(res.Logs, e)
}
}
t.mu.Lock()
res.Opened = append(res.Opened, t.popups[popupMark:]...)
t.mu.Unlock()
active := s.activeTab()
if active == nil {
active = t
}
res.Tab = active.info(true)
res.Dialog = active.currentDialog()
return res, stepErr
}
func (s *Session) runStep(ctx context.Context, t *tab, step Step) (string, error) {
action := strings.ToLower(strings.TrimSpace(step.Action))
if d := t.currentDialog(); d != nil && action != "dialog" {
return "", dialogFailure(d)
}
before := t.navigationCount()
switch action {
case "click", "double_click":
x, y, err := s.point(ctx, t, step)
if err != nil {
return "", err
}
clicks := 1
if action == "double_click" {
clicks = 2
}
if err := t.click(ctx, x, y, clicks); err != nil {
return "", err
}
t.settle(ctx, before)
return fmt.Sprintf("%s %s", action, target(step)), nil
case "hover":
x, y, err := s.point(ctx, t, step)
if err != nil {
return "", err
}
if err := t.mouse(ctx, "mouseMoved", x, y, 0); err != nil {
return "", err
}
return "hover " + target(step), nil
case "type", "fill":
if step.Ref != "" {
if err := s.focus(ctx, t, step.Ref, action == "fill"); err != nil {
return "", err
}
}
if step.Text == "" && action == "fill" {
if err := t.press(ctx, "Backspace"); err != nil {
return "", err
}
} else if err := t.call(ctx, "Input.insertText", map[string]any{"text": step.Text}, nil); err != nil {
return "", engineFailure(err)
}
return fmt.Sprintf("%s %d characters into %s", action, len([]rune(step.Text)), target(step)), nil
case "press":
if err := t.press(ctx, step.Key); err != nil {
return "", err
}
t.settle(ctx, before)
return "press " + step.Key, nil
case "select":
return s.selectOptions(ctx, t, step)
case "scroll":
x, y, err := s.scrollPoint(ctx, t, step)
if err != nil {
return "", err
}
if err := t.call(ctx, "Input.dispatchMouseEvent", map[string]any{"type": "mouseWheel", "x": x, "y": y, "deltaX": 0, "deltaY": step.DeltaY}, nil); err != nil {
return "", engineFailure(err)
}
return fmt.Sprintf("scroll %v px", step.DeltaY), nil
case "wait":
d := min(time.Duration(step.Ms)*time.Millisecond, maxWait)
select {
case <-time.After(d):
case <-ctx.Done():
return "", ctx.Err()
}
return fmt.Sprintf("wait %s", d), nil
case "wait_for":
return t.waitForText(ctx, step)
case "dialog":
return t.answerDialog(ctx, step)
case "back":
return t.back(ctx)
}
return "", fail(CodeBadStep, "unknown action %q", step.Action)
}
func target(step Step) string {
if step.Ref != "" {
return step.Ref
}
if step.X != nil && step.Y != nil {
return fmt.Sprintf("(%v,%v)", *step.X, *step.Y)
}
return "the focused element"
}
// point is where an input for step lands, in CSS pixels. For a ref it is the
// middle of the element's visible part, and the element must be what a real
// pointer there would reach.
func (s *Session) point(ctx context.Context, t *tab, step Step) (float64, float64, error) {
if step.Ref == "" {
if step.X == nil || step.Y == nil {
return 0, 0, fail(CodeBadStep, "a %s step needs a ref, or x and y from a screenshot", step.Action)
}
scale := t.screenshotScale()
return *step.X * scale, *step.Y * scale, nil
}
node, err := s.node(ctx, t, step.Ref)
if err != nil {
return 0, 0, err
}
if err := t.call(ctx, "DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": node}, nil); err != nil {
if isProtocolError(err) {
return 0, 0, &Failure{Code: CodeNotVisible, Ref: step.Ref, Detail: fmt.Sprintf("%s has no box on the page (hidden or not rendered)", step.Ref)}
}
return 0, 0, engineFailure(err)
}
x, y, err := t.visibleCenter(ctx, node, step.Ref)
if err != nil {
return 0, 0, err
}
if err := s.checkHit(ctx, t, node, step.Ref, x, y); err != nil {
return 0, 0, err
}
return x, y, nil
}
// node resolves a ref on t to a backend node that still exists.
func (s *Session) node(ctx context.Context, t *tab, ref string) (int64, error) {
target, err := s.refs.resolve(ref)
if err != nil {
return 0, err
}
if target.tab != t.id {
return 0, &Failure{Code: CodeUnknownRef, Ref: ref, Detail: fmt.Sprintf("%s is on tab %s; switch to it first", ref, target.tab)}
}
if err := t.call(ctx, "DOM.describeNode", map[string]any{"backendNodeId": target.node}, nil); err != nil {
if isProtocolError(err) {
return 0, &Failure{Code: CodeStaleRef, Ref: ref, Detail: fmt.Sprintf("%s is no longer on the page; take a new snapshot", ref)}
}
return 0, engineFailure(err)
}
return target.node, nil
}
func (t *tab) visibleCenter(ctx context.Context, node int64, ref string) (float64, float64, error) {
var quads struct {
Quads [][]float64 `json:"quads"`
}
if err := t.call(ctx, "DOM.getContentQuads", map[string]any{"backendNodeId": node}, &quads); err != nil && !isProtocolError(err) {
return 0, 0, engineFailure(err)
}
var metrics struct {
Viewport struct {
Width float64 `json:"clientWidth"`
Height float64 `json:"clientHeight"`
} `json:"cssLayoutViewport"`
}
if err := t.call(ctx, "Page.getLayoutMetrics", nil, &metrics); err != nil {
return 0, 0, engineFailure(err)
}
for _, q := range quads.Quads {
if len(q) != 8 {
continue
}
minX, maxX := min(q[0], q[2], q[4], q[6]), max(q[0], q[2], q[4], q[6])
minY, maxY := min(q[1], q[3], q[5], q[7]), max(q[1], q[3], q[5], q[7])
minX, minY = max(minX, 0), max(minY, 0)
maxX, maxY = min(maxX, metrics.Viewport.Width), min(maxY, metrics.Viewport.Height)
if maxX-minX >= 1 && maxY-minY >= 1 {
return (minX + maxX) / 2, (minY + maxY) / 2, nil
}
}
return 0, 0, &Failure{Code: CodeNotVisible, Ref: ref, Detail: fmt.Sprintf("%s has no visible area in the viewport", ref)}
}
// checkHit refuses an input whose point would land on another element, such
// as a banner drawn over the target.
func (s *Session) checkHit(ctx context.Context, t *tab, node int64, ref string, x, y float64) error {
var hit struct {
BackendNodeID int64 `json:"backendNodeId"`
}
if err := t.call(ctx, "DOM.getNodeForLocation", map[string]any{"x": int(x), "y": int(y), "includeUserAgentShadowDOM": false, "ignorePointerEventsNone": true}, &hit); err != nil {
if isProtocolError(err) {
return nil
}
return engineFailure(err)
}
if hit.BackendNodeID == 0 || hit.BackendNodeID == node {
return nil
}
targetObj, err := t.resolveObject(ctx, node)
if err != nil {
return err
}
hitObj, err := t.resolveObject(ctx, hit.BackendNodeID)
if err != nil {
return err
}
var within struct {
Result struct {
Value bool `json:"value"`
} `json:"result"`
}
if err := t.call(ctx, "Runtime.callFunctionOn", map[string]any{
"objectId": targetObj,
"functionDeclaration": "function(n){for(;n;n=n.parentNode||n.host){if(n===this)return true}return false}",
"arguments": []map[string]any{{"objectId": hitObj}},
"returnByValue": true,
}, &within); err != nil {
return engineFailure(err)
}
if within.Result.Value {
return nil
}
cover := s.refs.refFor(t.id, hit.BackendNodeID)
return &Failure{Code: CodeCovered, Ref: ref, Detail: fmt.Sprintf("%s is covered by %s %s at that point; deal with that element first", ref, t.describe(ctx, hit.BackendNodeID), cover)}
}
func (t *tab) resolveObject(ctx context.Context, node int64) (string, error) {
var r struct {
Object struct {
ObjectID string `json:"objectId"`
} `json:"object"`
}
if err := t.call(ctx, "DOM.resolveNode", map[string]any{"backendNodeId": node, "objectGroup": objectGroup}, &r); err != nil {
if isProtocolError(err) {
return "", fail(CodeStaleRef, "the element is no longer on the page")
}
return "", engineFailure(err)
}
return r.Object.ObjectID, nil
}
// describe names a DOM element by its tag and identifying attributes.
func (t *tab) describe(ctx context.Context, node int64) string {
var d struct {
Node struct {
LocalName string `json:"localName"`
Attributes []string `json:"attributes"`
} `json:"node"`
}
if t.call(ctx, "DOM.describeNode", map[string]any{"backendNodeId": node}, &d) != nil {
return "another element"
}
b := strings.Builder{}
b.WriteString("<" + d.Node.LocalName)
for i := 0; i+1 < len(d.Node.Attributes); i += 2 {
switch d.Node.Attributes[i] {
case "id", "class", "role", "aria-label":
fmt.Fprintf(&b, " %s=%q", d.Node.Attributes[i], clip(d.Node.Attributes[i+1], 60))
}
}
b.WriteString(">")
return b.String()
}
func (t *tab) mouse(ctx context.Context, kind string, x, y float64, clicks int) error {
params := map[string]any{"type": kind, "x": x, "y": y}
if clicks > 0 {
params["button"], params["clickCount"] = "left", clicks
}
return engineFailure(t.call(ctx, "Input.dispatchMouseEvent", params, nil))
}
func (t *tab) click(ctx context.Context, x, y float64, clicks int) error {
if err := t.mouse(ctx, "mouseMoved", x, y, 0); err != nil {
return err
}
for c := 1; c <= clicks; c++ {
if err := t.mouse(ctx, "mousePressed", x, y, c); err != nil {
return err
}
if err := t.mouse(ctx, "mouseReleased", x, y, c); err != nil {
return err
}
}
return nil
}
func (s *Session) focus(ctx context.Context, t *tab, ref string, selectAll bool) error {
node, err := s.node(ctx, t, ref)
if err != nil {
return err
}
if err := t.call(ctx, "DOM.focus", map[string]any{"backendNodeId": node}, nil); err != nil {
if isProtocolError(err) {
return &Failure{Code: CodeNotVisible, Ref: ref, Detail: fmt.Sprintf("%s cannot take focus", ref)}
}
return engineFailure(err)
}
if !selectAll {
return nil
}
obj, err := t.resolveObject(ctx, node)
if err != nil {
return err
}
return engineFailure(t.call(ctx, "Runtime.callFunctionOn", map[string]any{
"objectId": obj,
"functionDeclaration": `function(){
if (typeof this.select === "function") { this.select(); return; }
if (this.isContentEditable) {
const r = document.createRange(); r.selectNodeContents(this);
const s = getSelection(); s.removeAllRanges(); s.addRange(r);
}
}`,
}, nil))
}
func (t *tab) press(ctx context.Context, chord string) error {
def, modifiers, ok := parseKey(chord)
if !ok {
return fail(CodeBadStep, "%q is not a key this can press; use a name like Enter, Tab, ArrowDown, or a single character, with modifiers joined by +", chord)
}
down := map[string]any{
"type": "keyDown", "key": def.key, "code": def.code, "modifiers": modifiers,
"windowsVirtualKeyCode": def.vk, "nativeVirtualKeyCode": def.vk,
}
if def.text != "" {
down["text"], down["unmodifiedText"] = def.text, def.text
} else {
down["type"] = "rawKeyDown"
}
if err := t.call(ctx, "Input.dispatchKeyEvent", down, nil); err != nil {
return engineFailure(err)
}
return engineFailure(t.call(ctx, "Input.dispatchKeyEvent", map[string]any{
"type": "keyUp", "key": def.key, "code": def.code, "modifiers": modifiers,
"windowsVirtualKeyCode": def.vk, "nativeVirtualKeyCode": def.vk,
}, nil))
}
func (s *Session) selectOptions(ctx context.Context, t *tab, step Step) (string, error) {
node, err := s.node(ctx, t, step.Ref)
if err != nil {
return "", err
}
obj, err := t.resolveObject(ctx, node)
if err != nil {
return "", err
}
var r struct {
Result struct {
Value json.RawMessage `json:"value"`
} `json:"result"`
}
if err := t.call(ctx, "Runtime.callFunctionOn", map[string]any{
"objectId": obj,
"functionDeclaration": `function(values){
if (!(this instanceof HTMLSelectElement)) return -1;
const want = new Set(values); let hit = 0;
for (const o of this.options) {
const on = want.has(o.value) || want.has(o.label);
if (on || !this.multiple) o.selected = on;
if (on) hit++;
}
if (hit) { this.dispatchEvent(new Event("input", {bubbles: true})); this.dispatchEvent(new Event("change", {bubbles: true})); }
return hit;
}`,
"arguments": []map[string]any{{"value": step.Values}},
"returnByValue": true,
}, &r); err != nil {
return "", engineFailure(err)
}
var hit int
_ = json.Unmarshal(r.Result.Value, &hit)
switch {
case hit < 0:
return "", &Failure{Code: CodeNotSelectable, Ref: step.Ref, Detail: fmt.Sprintf("%s is not a <select>; click it and choose from what opens instead", step.Ref)}
case hit == 0:
return "", &Failure{Code: CodeNoSuchOption, Ref: step.Ref, Detail: fmt.Sprintf("%s has no option with value or label %q", step.Ref, step.Values)}
}
return fmt.Sprintf("select %d option(s) in %s", hit, step.Ref), nil
}
func (s *Session) scrollPoint(ctx context.Context, t *tab, step Step) (float64, float64, error) {
if step.Ref != "" || (step.X != nil && step.Y != nil) {
return s.point(ctx, t, step)
}
var metrics struct {
Viewport struct {
Width float64 `json:"clientWidth"`
Height float64 `json:"clientHeight"`
} `json:"cssLayoutViewport"`
}
if err := t.call(ctx, "Page.getLayoutMetrics", nil, &metrics); err != nil {
return 0, 0, engineFailure(err)
}
return metrics.Viewport.Width / 2, metrics.Viewport.Height / 2, nil
}
func (t *tab) waitForText(ctx context.Context, step Step) (string, error) {
if step.Text == "" {
return "", fail(CodeBadStep, "a wait_for step needs text to wait for")
}
limit := defaultWaitFor
if step.Ms > 0 {
limit = min(time.Duration(step.Ms)*time.Millisecond, maxWait)
}
quoted, _ := json.Marshal(step.Text)
expr := fmt.Sprintf("!!document.body && document.body.innerText.includes(%s)", quoted)
deadline := time.Now().Add(limit)
for {
var r struct {
Result struct {
Value bool `json:"value"`
} `json:"result"`
}
if err := t.call(ctx, "Runtime.evaluate", map[string]any{"expression": expr, "returnByValue": true}, &r); err != nil && !isProtocolError(err) {
return "", engineFailure(err)
}
if r.Result.Value {
return fmt.Sprintf("found %q", step.Text), nil
}
if time.Now().After(deadline) {
return "", fail(CodeWaitTimeout, "%q did not appear within %s", step.Text, limit)
}
select {
case <-time.After(200 * time.Millisecond):
case <-ctx.Done():
return "", ctx.Err()
}
}
}
func (t *tab) answerDialog(ctx context.Context, step Step) (string, error) {
d := t.currentDialog()
if d == nil {
return "", fail(CodeNoDialog, "no dialog is open")
}
accept := step.Accept == nil || *step.Accept
params := map[string]any{"accept": accept}
if step.Text != "" {
params["promptText"] = step.Text
}
if err := t.call(ctx, "Page.handleJavaScriptDialog", params, nil); err != nil {
return "", engineFailure(err)
}
verb := "dismissed"
if accept {
verb = "accepted"
}
return fmt.Sprintf("%s the %s dialog", verb, d.Type), nil
}
func (t *tab) back(ctx context.Context) (string, error) {
var h struct {
CurrentIndex int `json:"currentIndex"`
Entries []struct {
ID int `json:"id"`
} `json:"entries"`
}
if err := t.call(ctx, "Page.getNavigationHistory", nil, &h); err != nil {
return "", engineFailure(err)
}
if h.CurrentIndex <= 0 || h.CurrentIndex >= len(h.Entries) {
return "", fail(CodeNavigationFailed, "there is no earlier page in this tab")
}
before := t.navigationCount()
if err := t.call(ctx, "Page.navigateToHistoryEntry", map[string]any{"entryId": h.Entries[h.CurrentIndex-1].ID}, nil); err != nil {
return "", engineFailure(err)
}
t.settle(ctx, before)
url, _ := t.location()
return "back to " + url, nil
}
+290
View File
@@ -0,0 +1,290 @@
package browser
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/testenv"
)
func TestCheckURL(t *testing.T) {
root := testenv.TempDir(t)
inside := filepath.Join(root, "index.html")
if err := os.WriteFile(inside, []byte("<p>hi</p>"), 0o644); err != nil {
t.Fatal(err)
}
outside := filepath.Join(testenv.TempDir(t), "secret.html")
if err := os.WriteFile(outside, []byte("<p>no</p>"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "escape.html")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
fileURL := func(p string) string { return "file://" + filepath.ToSlash(p) }
cases := []struct {
raw string
want Code
}{
{"https://example.com/a?b=c", ""},
{"http://127.0.0.1:5173/", ""},
{"about:blank", ""},
{fileURL(inside), ""},
{fileURL(outside), CodeURLRefused},
{fileURL(link), CodeURLRefused},
{"chrome://settings", CodeURLRefused},
{"javascript:alert(1)", CodeURLRefused},
{"data:text/html,<p>x</p>", CodeURLRefused},
{"example.com", CodeURLRefused},
{"https://", CodeURLRefused},
}
for _, tc := range cases {
_, err := checkURL(tc.raw, []string{root})
if CodeOf(err) != tc.want {
t.Errorf("checkURL(%q) = %v, want code %q", tc.raw, err, tc.want)
}
}
}
func TestParseKey(t *testing.T) {
cases := []struct {
chord string
key, text string
modifiers int
ok bool
}{
{"Enter", "Enter", "\r", 0, true},
{"shift+tab", "Tab", "", 8, true},
{"Control+a", "a", "", 2, true},
{"x", "x", "x", 0, true},
{"Meta+Shift+Z", "Z", "", 12, true},
{"Hyper+a", "", "", 0, false},
{"Enterr", "", "", 0, false},
}
for _, tc := range cases {
def, modifiers, ok := parseKey(tc.chord)
if ok != tc.ok || (ok && (def.key != tc.key || def.text != tc.text || modifiers != tc.modifiers)) {
t.Errorf("parseKey(%q) = %+v, %d, %v", tc.chord, def, modifiers, ok)
}
}
}
func TestRefsAreNeverReusedAndRetireWithTheirDocument(t *testing.T) {
var r refTable
a := r.refFor("t1", 10)
if again := r.refFor("t1", 10); again != a {
t.Fatalf("the same element got %s then %s", a, again)
}
other := r.refFor("t2", 10)
if other == a {
t.Fatal("a node id on another tab reused a ref")
}
r.retireTab("t1")
if _, err := r.resolve(a); CodeOf(err) != CodeStaleRef {
t.Fatalf("a retired ref resolved as %v, want %s", err, CodeStaleRef)
}
if fresh := r.refFor("t1", 10); fresh == a {
t.Fatal("a node on the next document reused a retired ref")
}
if _, err := r.resolve(other); err != nil {
t.Fatalf("retiring t1 touched t2: %v", err)
}
if _, err := r.resolve("e999"); CodeOf(err) != CodeUnknownRef {
t.Fatalf("an unissued ref resolved as %v, want %s", err, CodeUnknownRef)
}
}
func axJSON(t *testing.T, nodes string) []axNode {
t.Helper()
var out []axNode
if err := json.Unmarshal([]byte(nodes), &out); err != nil {
t.Fatal(err)
}
return out
}
func TestSnapshotRendering(t *testing.T) {
nodes := axJSON(t, `[
{"nodeId":"1","role":{"value":"RootWebArea"},"name":{"value":"Page"},"childIds":["2","6","9"],"backendDOMNodeId":1},
{"nodeId":"2","parentId":"1","role":{"value":"generic"},"childIds":["3","4"],"backendDOMNodeId":2},
{"nodeId":"3","parentId":"2","role":{"value":"StaticText"},"name":{"value":"Hello"},"childIds":["5"]},
{"nodeId":"5","parentId":"3","role":{"value":"InlineTextBox"},"name":{"value":"Hello"}},
{"nodeId":"4","parentId":"2","role":{"value":"StaticText"},"name":{"value":"world"}},
{"nodeId":"6","parentId":"1","role":{"value":"button"},"name":{"value":"Save"},"childIds":["7"],"backendDOMNodeId":6,
"properties":[{"name":"disabled","value":{"value":true}},{"name":"focusable","value":{"value":true}}]},
{"nodeId":"7","parentId":"6","role":{"value":"StaticText"},"name":{"value":"Save"}},
{"nodeId":"9","parentId":"1","ignored":true,"role":{"value":"none"},"childIds":["10"]},
{"nodeId":"10","parentId":"9","role":{"value":"textbox"},"name":{"value":"Email"},"value":{"value":"[email protected]"},"backendDOMNodeId":10,
"properties":[{"name":"focused","value":{"value":true}}]}
]`)
s := NewSession(Config{})
r := renderer{s: s, tab: "t1", nodes: map[string]*axNode{}}
for i := range nodes {
r.nodes[nodes[i].NodeID] = &nodes[i]
}
r.children(&nodes[0], 0, "Page")
got := strings.Join(r.lines, "\n")
want := strings.Join([]string{
`- text: "Hello world"`,
`- button "Save" [e1] disabled`,
`- textbox "Email" [e2] value="[email protected]" focused`,
}, "\n")
if got != want {
t.Fatalf("rendering:\n%s\nwant:\n%s", got, want)
}
}
// fakeTransport answers commands from a script and can push events.
type fakeTransport struct {
in chan []byte
answer func(msg wireMessage) []byte
closed chan struct{}
}
func newFakeTransport(answer func(wireMessage) []byte) *fakeTransport {
return &fakeTransport{in: make(chan []byte, 16), answer: answer, closed: make(chan struct{})}
}
func (f *fakeTransport) read() ([]byte, error) {
select {
case msg := <-f.in:
return msg, nil
case <-f.closed:
return nil, io.EOF
}
}
func (f *fakeTransport) write(raw []byte) error {
var msg wireMessage
_ = json.Unmarshal(raw, &msg)
if reply := f.answer(msg); reply != nil {
f.in <- reply
}
return nil
}
func (f *fakeTransport) close() error {
select {
case <-f.closed:
default:
close(f.closed)
}
return nil
}
func TestConnRoutesRepliesErrorsAndEvents(t *testing.T) {
ft := newFakeTransport(func(msg wireMessage) []byte {
switch msg.Method {
case "Ok.method":
b, _ := json.Marshal(map[string]any{"id": msg.ID, "result": map[string]any{"value": 7}})
return b
case "Bad.method":
b, _ := json.Marshal(map[string]any{"id": msg.ID, "error": map[string]any{"code": -32000, "message": "No node"}})
return b
}
return nil
})
c := newConn(ft)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var out struct {
Value int `json:"value"`
}
if err := c.call(ctx, "", "Ok.method", nil, &out); err != nil || out.Value != 7 {
t.Fatalf("call = %v, value %d", err, out.Value)
}
if err := c.call(ctx, "S1", "Bad.method", nil, nil); !isProtocolError(err) {
t.Fatalf("a protocol error came back as %v", err)
}
got := make(chan string, 2)
stop := c.subscribe("S1", func(ev event) { got <- ev.Method })
defer stop()
ft.in <- []byte(`{"method":"Other.event","sessionId":"S2"}`)
ft.in <- []byte(`{"method":"Page.loadEventFired","sessionId":"S1"}`)
select {
case m := <-got:
if m != "Page.loadEventFired" {
t.Fatalf("session S1 received %s", m)
}
case <-ctx.Done():
t.Fatal("the event never arrived")
}
pending := make(chan error, 1)
go func() { pending <- c.call(ctx, "", "Never.answered", nil, nil) }()
time.Sleep(50 * time.Millisecond)
_ = ft.close()
if err := <-pending; !errors.Is(err, errConnClosed) {
t.Fatalf("a call pending when the browser went away returned %v", err)
}
}
func TestPipeTransportFramesOnNUL(t *testing.T) {
r, w := io.Pipe()
p := newPipeTransport(r, w)
go func() {
_ = p.write([]byte(`{"id":1}`))
_ = p.write([]byte(`{"id":2}`))
}()
for _, want := range []string{`{"id":1}`, `{"id":2}`} {
got, err := p.read()
if err != nil || string(got) != want {
t.Fatalf("read = %q, %v; want %q", got, err, want)
}
}
}
func TestReadActivePort(t *testing.T) {
path := filepath.Join(testenv.TempDir(t), "DevToolsActivePort")
if _, _, ok := readActivePort(path); ok {
t.Fatal("a missing file read as a port")
}
if err := os.WriteFile(path, []byte("9222\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, ok := readActivePort(path); ok {
t.Fatal("a half-written file read as a port")
}
if err := os.WriteFile(path, []byte("9222\n/devtools/browser/abc\n"), 0o600); err != nil {
t.Fatal(err)
}
if port, wsPath, ok := readActivePort(path); !ok || port != "9222" || wsPath != "/devtools/browser/abc" {
t.Fatalf("readActivePort = %q %q %v", port, wsPath, ok)
}
}
func TestDiscoverUsesOnlyTheConfiguredBrowserWhenSet(t *testing.T) {
if _, err := Discover(filepath.Join(testenv.TempDir(t), "nope")); CodeOf(err) != CodeEngineMissing {
t.Fatalf("a configured path that does not exist = %v, want %s", err, CodeEngineMissing)
}
env := map[string]string{"HOME": "/Users/me", "ProgramFiles": `C:\Program Files`}
get := func(k string) string { return env[k] }
if mac := installedCandidates("darwin", get); !strings.HasSuffix(mac[0], "Google Chrome.app/Contents/MacOS/Google Chrome") {
t.Fatalf("darwin candidates start with %q", mac[0])
}
if win := installedCandidates("windows", get); len(win) != 2 || !strings.HasSuffix(win[1], `msedge.exe`) {
t.Fatalf("windows candidates = %v", win)
}
}
func TestDiffLinesListsWhatAppearedAndWhatLeft(t *testing.T) {
before := []string{"- a", "- b", "- b", "- c"}
after := []string{"- a", "- b", "- d", "- c", "- b"}
c := diffLines(before, after)
if strings.Join(c.Added, "|") != "- d" || len(c.Removed) != 0 {
t.Fatalf("changes = %+v", c)
}
c = diffLines(after, before)
if len(c.Added) != 0 || strings.Join(c.Removed, "|") != "- d" {
t.Fatalf("reverse changes = %+v", c)
}
}
+86
View File
@@ -0,0 +1,86 @@
package browser
import (
"bytes"
"context"
"encoding/base64"
"image"
"strings"
"reasonix/internal/visionimage"
)
// Screenshot captures what a tab's viewport shows, sized for a vision model.
// Steps that give x and y afterwards are read in this image's pixels.
func (s *Session) Screenshot(ctx context.Context, tabID string) (string, TabInfo, error) {
t, err := s.tab(tabID)
if err != nil {
return "", TabInfo{}, err
}
if err := t.checkCurrentURL(); err != nil {
return "", TabInfo{}, err
}
var shot struct {
Data string `json:"data"`
}
if err := t.call(ctx, "Page.captureScreenshot", map[string]any{"format": "jpeg", "quality": 80}, &shot); err != nil {
return "", TabInfo{}, engineFailure(err)
}
raw, err := base64.StdEncoding.DecodeString(shot.Data)
if err != nil {
return "", TabInfo{}, fail(CodeEngineFailed, "the screenshot was not valid base64")
}
fitted, mime, err := visionimage.Fit(raw, "image/jpeg")
if err != nil {
return "", TabInfo{}, fail(CodeEngineFailed, "%v", err)
}
rawCfg, _, rawErr := image.DecodeConfig(bytes.NewReader(raw))
cfg, _, err := image.DecodeConfig(bytes.NewReader(fitted))
if rawErr != nil || err != nil || cfg.Width == 0 {
return "", TabInfo{}, fail(CodeEngineFailed, "the screenshot could not be measured")
}
var dpr struct {
Result struct {
Value float64 `json:"value"`
} `json:"result"`
}
if err := t.call(ctx, "Runtime.evaluate", map[string]any{"expression": "devicePixelRatio", "returnByValue": true}, &dpr); err != nil {
return "", TabInfo{}, engineFailure(err)
}
if dpr.Result.Value > 0 {
t.mu.Lock()
t.shotScale = float64(rawCfg.Width) / float64(cfg.Width) / dpr.Result.Value
t.mu.Unlock()
}
var b strings.Builder
b.WriteString("data:" + mime + ";base64,")
b.WriteString(base64.StdEncoding.EncodeToString(fitted))
return b.String(), t.info(true), nil
}
// screenshotScale converts screenshot pixels to CSS pixels. Before any
// screenshot the two are taken to be the same.
func (t *tab) screenshotScale() float64 {
t.mu.Lock()
defer t.mu.Unlock()
if t.shotScale <= 0 {
return 1
}
return t.shotScale
}
// Logs returns what a tab reported since the last call that read them.
func (s *Session) Logs(tabID string) ([]LogEntry, TabInfo, error) {
t, err := s.tab(tabID)
if err != nil {
return nil, TabInfo{}, err
}
t.mu.Lock()
mark := t.logRead
t.mu.Unlock()
entries, newest := t.logsAfter(mark)
t.mu.Lock()
t.logRead = newest
t.mu.Unlock()
return entries, t.info(false), nil
}
+237
View File
@@ -0,0 +1,237 @@
package browser
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
)
// errConnClosed is the connection to the browser ending, as opposed to the
// browser answering a command with a protocol error.
var errConnClosed = errors.New("browser connection closed")
// protocolError is the browser refusing one command. It is a distinct type so
// a caller can tell "this node no longer exists" from "the browser is gone".
type protocolError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *protocolError) Error() string { return fmt.Sprintf("cdp %d: %s", e.Code, e.Message) }
func isProtocolError(err error) bool {
_, ok := errors.AsType[*protocolError](err)
return ok
}
type wireMessage struct {
ID int64 `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *protocolError `json:"error,omitempty"`
}
type event struct {
Method string
Params json.RawMessage
}
// conn multiplexes commands and events for the browser and every attached
// target over one transport. Events are delivered per session, in order, on a
// goroutine of their own: a handler may issue commands, whose replies arrive on
// the reader this would otherwise block.
type conn struct {
tr transport
mu sync.Mutex
next int64
pending map[int64]chan wireMessage
queues map[string]*eventQueue
done chan struct{}
}
func newConn(tr transport) *conn {
c := &conn{
tr: tr,
pending: map[int64]chan wireMessage{},
queues: map[string]*eventQueue{},
done: make(chan struct{}),
}
go c.readLoop()
return c
}
func (c *conn) readLoop() {
defer c.shutdown()
for {
raw, err := c.tr.read()
if err != nil {
return
}
var msg wireMessage
if json.Unmarshal(raw, &msg) != nil {
continue
}
c.mu.Lock()
if msg.ID != 0 {
reply, ok := c.pending[msg.ID]
delete(c.pending, msg.ID)
c.mu.Unlock()
if ok {
reply <- msg
}
continue
}
q := c.queues[msg.SessionID]
c.mu.Unlock()
if q != nil {
q.push(event{Method: msg.Method, Params: msg.Params})
}
}
}
func (c *conn) shutdown() {
c.mu.Lock()
defer c.mu.Unlock()
select {
case <-c.done:
return
default:
}
close(c.done)
for id, reply := range c.pending {
close(reply)
delete(c.pending, id)
}
for _, q := range c.queues {
q.stop()
}
_ = c.tr.close()
}
func (c *conn) closed() <-chan struct{} { return c.done }
// call sends one command and decodes its result into out when out is non-nil.
func (c *conn) call(ctx context.Context, sessionID, method string, params, out any) error {
body := map[string]any{"method": method}
if params != nil {
body["params"] = params
}
if sessionID != "" {
body["sessionId"] = sessionID
}
reply := make(chan wireMessage, 1)
c.mu.Lock()
select {
case <-c.done:
c.mu.Unlock()
return errConnClosed
default:
}
c.next++
id := c.next
body["id"] = id
c.pending[id] = reply
c.mu.Unlock()
raw, err := json.Marshal(body)
if err != nil {
c.forget(id)
return err
}
if err := c.tr.write(raw); err != nil {
c.forget(id)
return errConnClosed
}
select {
case msg, ok := <-reply:
if !ok {
return errConnClosed
}
if msg.Error != nil {
return msg.Error
}
if out != nil && len(msg.Result) > 0 {
return json.Unmarshal(msg.Result, out)
}
return nil
case <-ctx.Done():
c.forget(id)
return ctx.Err()
}
}
func (c *conn) forget(id int64) {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}
// subscribe delivers every event for sessionID ("" is the browser itself) to
// handle, in arrival order, until unsubscribe or the connection ends.
func (c *conn) subscribe(sessionID string, handle func(event)) (unsubscribe func()) {
q := newEventQueue(handle)
c.mu.Lock()
select {
case <-c.done:
q.stop()
default:
c.queues[sessionID] = q
}
c.mu.Unlock()
return func() {
c.mu.Lock()
if c.queues[sessionID] == q {
delete(c.queues, sessionID)
}
c.mu.Unlock()
q.stop()
}
}
// eventQueue is unbounded so the reader never waits on a slow handler; a
// browser emits events in bursts, not without end.
type eventQueue struct {
mu sync.Mutex
items []event
wake chan struct{}
stopped chan struct{}
once sync.Once
}
func newEventQueue(handle func(event)) *eventQueue {
q := &eventQueue{wake: make(chan struct{}, 1), stopped: make(chan struct{})}
go func() {
for {
q.mu.Lock()
batch := q.items
q.items = nil
q.mu.Unlock()
for _, ev := range batch {
handle(ev)
}
select {
case <-q.wake:
case <-q.stopped:
return
}
}
}()
return q
}
func (q *eventQueue) push(ev event) {
q.mu.Lock()
q.items = append(q.items, ev)
q.mu.Unlock()
select {
case q.wake <- struct{}{}:
default:
}
}
func (q *eventQueue) stop() { q.once.Do(func() { close(q.stopped) }) }
+77
View File
@@ -0,0 +1,77 @@
package browser
import (
"os"
"os/exec"
"path/filepath"
"runtime"
)
// Discover finds a Chromium-family browser already installed on this machine:
// Chrome, then Edge, then Chromium. It never downloads one. configured, when
// set, is used as given and is the only candidate.
func Discover(configured string) (string, error) {
if configured != "" {
if isExecutableFile(configured) {
return configured, nil
}
return "", fail(CodeEngineMissing, "the configured browser %q is not an executable file", configured)
}
for _, candidate := range installedCandidates(runtime.GOOS, os.Getenv) {
if filepath.IsAbs(candidate) {
if isExecutableFile(candidate) {
return candidate, nil
}
continue
}
if found, err := exec.LookPath(candidate); err == nil {
return found, nil
}
}
return "", fail(CodeEngineMissing, "no Chrome, Edge or Chromium is installed; install one, or set [browser] executable to its path")
}
func installedCandidates(goos string, getenv func(string) string) []string {
switch goos {
case "darwin":
home := getenv("HOME")
var out []string
for _, app := range []string{
"Google Chrome.app/Contents/MacOS/Google Chrome",
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"Chromium.app/Contents/MacOS/Chromium",
} {
out = append(out, filepath.Join("/Applications", app))
if home != "" {
out = append(out, filepath.Join(home, "Applications", app))
}
}
return out
case "windows":
var out []string
for _, base := range []string{getenv("ProgramFiles"), getenv("ProgramFiles(x86)"), getenv("LocalAppData")} {
if base == "" {
continue
}
out = append(out,
filepath.Join(base, `Google\Chrome\Application\chrome.exe`),
filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`),
)
}
return out
default:
return []string{
"google-chrome", "google-chrome-stable",
"microsoft-edge", "microsoft-edge-stable",
"chromium", "chromium-browser",
}
}
}
func isExecutableFile(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
return runtime.GOOS == "windows" || info.Mode().Perm()&0o111 != 0
}
+22
View File
@@ -0,0 +1,22 @@
// Package browser drives a Chromium-family browser the machine already has,
// for an agent that needs to see and operate web pages.
//
// The host owns the meaning of everything it hands the model. A page is shown
// as its accessibility tree with a ref on each element; refs are issued by this
// package, never reused, and retired when their document navigates away, so a
// stale one fails as browser.stale_ref instead of landing on whatever took its
// place. Inputs are real pointer and keyboard events, and a pointer input is
// refused as browser.covered when another element would receive it. Every
// failure carries a Code, and the detail beside it is for the reader.
//
// A Session is one agent's tabs. Sessions on the same profile share one
// browser process through a Pool, because a profile admits one process at a
// time. The browser runs on a profile of its own, never the person's everyday
// one, and is driven over --remote-debugging-pipe where the platform lets a
// child inherit the pipe descriptors; elsewhere (Windows) it listens on a
// loopback port, which any local process can reach while it runs.
//
// checkURL bounds what a page may be: http and https, about:blank, and files
// inside the workspace roots once symlinks resolve. A page reached by a link
// past that boundary is refused when it is read or acted on.
package browser
+304
View File
@@ -0,0 +1,304 @@
package browser
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"reasonix/internal/proc"
)
const (
launchTimeout = 20 * time.Second
closeTimeout = 3 * time.Second
// A headless tab otherwise gets a viewport too small for most layouts.
viewportWidth = 1280
viewportHeight = 900
)
// LaunchSpec is what one browser process starts with. ProfileDir is its own:
// a browser never runs on the person's everyday profile.
type LaunchSpec struct {
Executable string
ProfileDir string
Headless bool
}
// usePipe is false where a child cannot inherit descriptors 3 and 4. The port
// endpoint it falls back to is reachable by any local process while it runs.
var usePipe = runtime.GOOS != "windows"
// engine is one running browser and the connection that drives it.
type engine struct {
spec LaunchSpec
cmd *exec.Cmd
job *proc.TrackedJob
conn *conn
exited chan struct{}
mu sync.Mutex
listeners map[int]func(event)
nextID int
unsub func()
}
func launchArgs(spec LaunchSpec) []string {
args := []string{
"--user-data-dir=" + spec.ProfileDir,
"--no-first-run",
"--no-default-browser-check",
"--disable-sync",
"--disable-features=Translate,MediaRouter",
fmt.Sprintf("--window-size=%d,%d", viewportWidth, viewportHeight),
}
switch runtime.GOOS {
case "darwin":
args = append(args, "--use-mock-keychain")
case "linux":
args = append(args, "--password-store=basic")
}
if spec.Headless {
args = append(args, "--headless=new")
}
if usePipe {
args = append(args, "--remote-debugging-pipe")
} else {
args = append(args, "--remote-debugging-port=0")
}
return append(args, "about:blank")
}
func launch(ctx context.Context, spec LaunchSpec) (*engine, error) {
if err := os.MkdirAll(spec.ProfileDir, 0o700); err != nil {
return nil, fail(CodeEngineFailed, "create the browser profile directory: %v", err)
}
ctx, cancel := context.WithTimeout(ctx, launchTimeout)
defer cancel()
activePort := filepath.Join(spec.ProfileDir, "DevToolsActivePort")
cmd := exec.Command(spec.Executable, launchArgs(spec)...)
proc.HideWindow(cmd)
var tr transport
var parentEnds []*os.File
if usePipe {
toBrowser, commands, err := os.Pipe()
if err != nil {
return nil, fail(CodeEngineFailed, "create the command pipe: %v", err)
}
replies, fromBrowser, err := os.Pipe()
if err != nil {
_ = toBrowser.Close()
_ = commands.Close()
return nil, fail(CodeEngineFailed, "create the reply pipe: %v", err)
}
cmd.ExtraFiles = []*os.File{toBrowser, fromBrowser}
parentEnds = []*os.File{toBrowser, fromBrowser}
tr = newPipeTransport(replies, commands, commands, replies)
} else {
_ = os.Remove(activePort)
}
job, err := proc.StartTracked(cmd)
for _, f := range parentEnds {
_ = f.Close()
}
if err != nil {
if tr != nil {
_ = tr.close()
}
return nil, fail(CodeEngineFailed, "start %s: %v", spec.Executable, err)
}
e := &engine{spec: spec, cmd: cmd, job: job, exited: make(chan struct{}), listeners: map[int]func(event){}}
go func() {
_ = cmd.Wait()
close(e.exited)
}()
if tr == nil {
ws, err := e.dialActivePort(ctx, activePort)
if err != nil {
e.kill()
return nil, e.launchFailure(err)
}
tr = ws
}
e.conn = newConn(tr)
e.unsub = e.conn.subscribe("", e.dispatch)
if err := e.conn.call(ctx, "", "Browser.getVersion", nil, nil); err != nil {
e.kill()
return nil, e.launchFailure(err)
}
if err := e.conn.call(ctx, "", "Target.setDiscoverTargets", map[string]any{"discover": true}, nil); err != nil {
e.kill()
return nil, e.launchFailure(err)
}
return e, nil
}
// dialActivePort waits for the browser to write the port it chose, then
// connects to it.
func (e *engine) dialActivePort(ctx context.Context, path string) (*wsTransport, error) {
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
for {
if port, wsPath, ok := readActivePort(path); ok {
return dialWS(ctx, "ws://127.0.0.1:"+port+wsPath)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-e.exited:
return nil, errConnClosed
case <-tick.C:
}
}
}
func readActivePort(path string) (port, wsPath string, ok bool) {
f, err := os.Open(path)
if err != nil {
return "", "", false
}
defer f.Close()
sc := bufio.NewScanner(f)
var lines []string
for sc.Scan() {
lines = append(lines, strings.TrimSpace(sc.Text()))
}
if sc.Err() != nil || len(lines) < 2 || lines[0] == "" || !strings.HasPrefix(lines[1], "/devtools/browser/") {
return "", "", false
}
return lines[0], lines[1], true
}
// launchFailure names why a start did not reach a working connection. A
// browser that exits at once on a profile another browser holds leaves that
// browser's lock behind, which is the only cause the host can see.
func (e *engine) launchFailure(err error) error {
if _, statErr := os.Lstat(filepath.Join(e.spec.ProfileDir, "SingletonLock")); statErr == nil {
select {
case <-e.exited:
return fail(CodeProfileBusy, "another browser is already running on %s", e.spec.ProfileDir)
default:
}
}
return fail(CodeEngineFailed, "%s did not accept a debugging connection: %v", e.spec.Executable, err)
}
func (e *engine) alive() bool {
select {
case <-e.conn.closed():
return false
case <-e.exited:
return false
default:
return true
}
}
// listen registers a handler for browser-level events, which every session on
// this engine shares.
func (e *engine) listen(handle func(event)) (stop func()) {
e.mu.Lock()
id := e.nextID
e.nextID++
e.listeners[id] = handle
e.mu.Unlock()
return func() {
e.mu.Lock()
delete(e.listeners, id)
e.mu.Unlock()
}
}
func (e *engine) dispatch(ev event) {
e.mu.Lock()
handlers := make([]func(event), 0, len(e.listeners))
for _, h := range e.listeners {
handlers = append(handlers, h)
}
e.mu.Unlock()
for _, h := range handlers {
h(ev)
}
}
// close asks the browser to quit and kills it when it does not.
func (e *engine) close() {
if e.conn != nil && e.alive() {
ctx, cancel := context.WithTimeout(context.Background(), closeTimeout)
_ = e.conn.call(ctx, "", "Browser.close", nil, nil)
cancel()
select {
case <-e.exited:
case <-time.After(closeTimeout):
}
}
e.kill()
}
func (e *engine) kill() {
if e.unsub != nil {
e.unsub()
}
e.job.Kill(e.cmd)
if e.conn != nil {
e.conn.shutdown()
}
}
// Pool shares one browser per profile among the sessions that use it: a
// profile directory admits one browser process at a time.
type Pool struct {
mu sync.Mutex
engines map[string]*pooledEngine
}
type pooledEngine struct {
eng *engine
refs int
}
func (p *Pool) acquire(ctx context.Context, spec LaunchSpec) (*engine, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.engines == nil {
p.engines = map[string]*pooledEngine{}
}
if pe := p.engines[spec.ProfileDir]; pe != nil {
if pe.eng.alive() {
pe.refs++
return pe.eng, nil
}
delete(p.engines, spec.ProfileDir)
go pe.eng.kill()
}
eng, err := launch(ctx, spec)
if err != nil {
return nil, err
}
p.engines[spec.ProfileDir] = &pooledEngine{eng: eng, refs: 1}
return eng, nil
}
func (p *Pool) release(eng *engine) {
p.mu.Lock()
pe := p.engines[eng.spec.ProfileDir]
last := false
if pe != nil && pe.eng == eng {
pe.refs--
if pe.refs <= 0 {
delete(p.engines, eng.spec.ProfileDir)
last = true
}
}
p.mu.Unlock()
if last || pe == nil || pe.eng != eng {
eng.close()
}
}
+65
View File
@@ -0,0 +1,65 @@
package browser
import (
"errors"
"fmt"
)
// Code is the identity of a browser failure. The model is shown it verbatim,
// and a caller tells failures apart by it rather than by the detail beside it.
type Code string
const (
CodeEngineMissing Code = "browser.engine_missing"
CodeEngineFailed Code = "browser.engine_failed"
CodeProfileBusy Code = "browser.profile_busy"
CodeURLRefused Code = "browser.url_refused"
CodeNavigationFailed Code = "browser.navigation_failed"
CodeNavigationTimeout Code = "browser.navigation_timeout"
CodeNoTab Code = "browser.no_tab"
CodeUnknownRef Code = "browser.unknown_ref"
CodeStaleRef Code = "browser.stale_ref"
CodeNotVisible Code = "browser.not_visible"
CodeCovered Code = "browser.covered"
CodeNotSelectable Code = "browser.not_selectable"
CodeNoSuchOption Code = "browser.no_such_option"
CodeDialogOpen Code = "browser.dialog_open"
CodeNoDialog Code = "browser.no_dialog"
CodeWaitTimeout Code = "browser.wait_timeout"
CodeBadStep Code = "browser.bad_step"
CodeScriptFailed Code = "browser.script_failed"
)
// Failure is a browser operation that did not happen, with the host's account
// of why. Ref names the element it concerns when there is one.
type Failure struct {
Code Code
Detail string
Ref string
}
func (f *Failure) Error() string {
if f.Detail == "" {
return string(f.Code)
}
return fmt.Sprintf("%s: %s", f.Code, f.Detail)
}
// Is matches another Failure by code alone, so errors.Is(err, &Failure{Code: c})
// answers whether err is that kind of failure however it was wrapped.
func (f *Failure) Is(target error) bool {
other, ok := errors.AsType[*Failure](target)
return ok && other.Code == f.Code
}
func fail(code Code, format string, args ...any) *Failure {
return &Failure{Code: code, Detail: fmt.Sprintf(format, args...)}
}
// CodeOf reports the failure code err carries, or "" when it carries none.
func CodeOf(err error) Code {
if f, ok := errors.AsType[*Failure](err); ok {
return f.Code
}
return ""
}
+66
View File
@@ -0,0 +1,66 @@
package browser
import (
"strings"
"unicode/utf8"
)
type keyDef struct {
key string
code string
vk int
text string
}
var namedKeys = map[string]keyDef{
"enter": {"Enter", "Enter", 13, "\r"},
"tab": {"Tab", "Tab", 9, ""},
"escape": {"Escape", "Escape", 27, ""},
"backspace": {"Backspace", "Backspace", 8, ""},
"delete": {"Delete", "Delete", 46, ""},
"space": {" ", "Space", 32, " "},
"arrowleft": {"ArrowLeft", "ArrowLeft", 37, ""},
"arrowup": {"ArrowUp", "ArrowUp", 38, ""},
"arrowright": {"ArrowRight", "ArrowRight", 39, ""},
"arrowdown": {"ArrowDown", "ArrowDown", 40, ""},
"home": {"Home", "Home", 36, ""},
"end": {"End", "End", 35, ""},
"pageup": {"PageUp", "PageUp", 33, ""},
"pagedown": {"PageDown", "PageDown", 34, ""},
}
var modifierBits = map[string]int{"alt": 1, "control": 2, "ctrl": 2, "meta": 4, "cmd": 4, "shift": 8}
// parseKey reads a chord such as "Enter", "Shift+Tab" or "Control+a".
func parseKey(chord string) (keyDef, int, bool) {
parts := strings.Split(chord, "+")
modifiers := 0
for _, m := range parts[:len(parts)-1] {
bit, ok := modifierBits[strings.ToLower(strings.TrimSpace(m))]
if !ok {
return keyDef{}, 0, false
}
modifiers |= bit
}
last := strings.TrimSpace(parts[len(parts)-1])
if def, ok := namedKeys[strings.ToLower(last)]; ok {
return def, modifiers, true
}
if utf8.RuneCountInString(last) != 1 {
return keyDef{}, 0, false
}
r, _ := utf8.DecodeRuneInString(last)
def := keyDef{key: last, text: last}
switch {
case r >= 'a' && r <= 'z':
def.code, def.vk = "Key"+strings.ToUpper(last), int(r-'a'+'A')
case r >= 'A' && r <= 'Z':
def.code, def.vk = "Key"+last, int(r)
case r >= '0' && r <= '9':
def.code, def.vk = "Digit"+last, int(r)
}
if modifiers&(2|4) != 0 {
def.text = ""
}
return def, modifiers, true
}
+221
View File
@@ -0,0 +1,221 @@
package browser
import (
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"reasonix/internal/testenv"
)
// liveSession starts a real browser when REASONIX_LIVE_BROWSER names one (or
// is "1" to discover an installed one). Normal runs skip: CI machines differ in
// whether any Chromium is installed.
func liveSession(t *testing.T, headless bool) *Session {
t.Helper()
want := os.Getenv("REASONIX_LIVE_BROWSER")
if want == "" {
t.Skip("set REASONIX_LIVE_BROWSER=1 or to a browser executable to run live browser tests")
}
configured := want
if want == "1" {
configured = ""
}
exe, err := Discover(configured)
if err != nil {
t.Fatalf("discover: %v", err)
}
root := testenv.TempDir(t)
s := NewSession(Config{
Launch: LaunchSpec{Executable: exe, ProfileDir: testenv.TempDir(t), Headless: headless},
Roots: []string{root},
})
t.Cleanup(s.Close)
return s
}
func liveServer(t *testing.T, pages map[string]string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
for path, body := range pages {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(body))
})
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestLiveOpenLoadsAPage(t *testing.T) {
s := liveSession(t, true)
srv := liveServer(t, map[string]string{"/": `<title>Hello</title><h1>Hi</h1>`})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
info, err := s.Open(ctx, srv.URL+"/", "", false)
if err != nil {
t.Fatalf("Open: %v", err)
}
if info.Title != "Hello" || info.ID != "t1" {
t.Fatalf("info = %+v", info)
}
}
func findRef(t *testing.T, snap Snapshot, needle string) string {
t.Helper()
for _, line := range snap.Lines {
if strings.Contains(line, needle) {
start := strings.Index(line, "[e")
end := strings.Index(line[start:], "]")
if start >= 0 && end > 0 {
return line[start+1 : start+end]
}
}
}
t.Fatalf("no line containing %q with a ref in:\n%s", needle, strings.Join(snap.Lines, "\n"))
return ""
}
const formPage = `<!doctype html><title>Form</title>
<h1>Sign up</h1>
<label>Email <input id="email" type="email"></label>
<label>Plan <select id="plan"><option value="free">Free</option><option value="pro">Pro</option></select></label>
<button id="go" onclick="document.getElementById('out').textContent = 'hello ' + document.getElementById('email').value + ' ' + document.getElementById('plan').value; console.error('boom')">Submit</button>
<p id="out"></p>
<div style="position:relative;height:40px"><button id="under">Hidden</button><div style="position:absolute;inset:0;background:#fff" class="veil"></div></div>
<a href="/next">Next page</a>
<button onclick="window.open('/next')">Pop</button>
<button onclick="document.getElementById('out').textContent = confirm('sure?') ? 'yes' : 'no'">Ask</button>`
func TestLiveSnapshotAndAct(t *testing.T) {
s := liveSession(t, true)
srv := liveServer(t, map[string]string{"/": formPage, "/next": `<title>Next</title><h1>Arrived</h1>`})
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if _, err := s.Open(ctx, srv.URL+"/", "", false); err != nil {
t.Fatalf("Open: %v", err)
}
snap, err := s.Snapshot(ctx, "", "")
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
t.Logf("snapshot:\n%s", strings.Join(snap.Lines, "\n"))
email := findRef(t, snap, `textbox "Email"`)
plan := findRef(t, snap, `combobox "Plan"`)
submit := findRef(t, snap, `button "Submit"`)
res, err := s.Act(ctx, "", []Step{
{Action: "fill", Ref: email, Text: "李雷@example.com"},
{Action: "select", Ref: plan, Values: []string{"Pro"}},
{Action: "click", Ref: submit},
{Action: "wait_for", Text: "hello 李雷@example.com pro"},
})
if err != nil {
t.Fatalf("Act: %v (done %d, notes %v)", err, res.Done, res.Notes)
}
if len(res.Logs) == 0 || !strings.Contains(res.Logs[0].Text, "boom") {
t.Fatalf("the console error did not come back with the act: %+v", res.Logs)
}
hidden := findRef(t, snap, `button "Hidden"`)
_, err = s.Act(ctx, "", []Step{{Action: "click", Ref: hidden}})
if CodeOf(err) != CodeCovered || !strings.Contains(err.Error(), "veil") {
t.Fatalf("clicking under a veil = %v, want %s naming the veil", err, CodeCovered)
}
ask := findRef(t, snap, `button "Ask"`)
res, err = s.Act(ctx, "", []Step{{Action: "click", Ref: ask}})
if err != nil || res.Dialog == nil || res.Dialog.Type != "confirm" {
t.Fatalf("confirm dialog: err=%v dialog=%+v", err, res.Dialog)
}
_, err = s.Act(ctx, "", []Step{{Action: "click", Ref: submit}})
if CodeOf(err) != CodeDialogOpen {
t.Fatalf("acting under an open dialog = %v, want %s", err, CodeDialogOpen)
}
no := false
if _, err = s.Act(ctx, "", []Step{{Action: "dialog", Accept: &no}, {Action: "wait_for", Text: "no"}}); err != nil {
t.Fatalf("dismiss dialog: %v", err)
}
pop := findRef(t, snap, `button "Pop"`)
res, err = s.Act(ctx, "", []Step{{Action: "click", Ref: pop}, {Action: "wait", Ms: 500}})
if err != nil || len(res.Opened) != 1 {
t.Fatalf("popup: err=%v opened=%v", err, res.Opened)
}
if err := s.CloseTab(ctx, res.Opened[0]); err != nil {
t.Fatalf("close popup: %v", err)
}
next := findRef(t, snap, `link "Next page"`)
res, err = s.Act(ctx, "t1", []Step{{Action: "click", Ref: next}})
if err != nil || !strings.HasSuffix(res.Tab.URL, "/next") || res.Tab.Title != "Next" {
t.Fatalf("link: err=%v tab=%+v", err, res.Tab)
}
_, err = s.Act(ctx, "t1", []Step{{Action: "click", Ref: submit}})
if CodeOf(err) != CodeStaleRef {
t.Fatalf("a ref from the previous page = %v, want %s", err, CodeStaleRef)
}
res, err = s.Act(ctx, "t1", []Step{{Action: "back"}})
if err != nil || strings.HasSuffix(res.Tab.URL, "/next") {
t.Fatalf("back: err=%v tab=%+v", err, res.Tab)
}
}
func TestLiveScreenshotCoordinatesLandOnTheElement(t *testing.T) {
s := liveSession(t, true)
srv := liveServer(t, map[string]string{"/": `<title>Canvas</title><body style="margin:0">
<div style="position:absolute;left:600px;top:400px;width:80px;height:60px;background:red" onclick="document.title='hit'"></div>`})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if _, err := s.Open(ctx, srv.URL+"/", "", false); err != nil {
t.Fatalf("Open: %v", err)
}
url, _, err := s.Screenshot(ctx, "")
if err != nil || !strings.HasPrefix(url, "data:image/") {
t.Fatalf("Screenshot: %v", err)
}
scale := s.activeTab().screenshotScale()
x, y := 640/scale, 430/scale
res, err := s.Act(ctx, "", []Step{{Action: "click", X: &x, Y: &y}})
if err != nil || res.Tab.Title != "hit" {
t.Fatalf("coordinate click: err=%v title=%q scale=%v", err, res.Tab.Title, scale)
}
}
func TestLiveRefusesFilesOutsideTheWorkspace(t *testing.T) {
s := liveSession(t, true)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := s.Open(ctx, "file:///etc/hosts", "", false); CodeOf(err) != CodeURLRefused {
t.Fatalf("file outside the workspace = %v, want %s", err, CodeURLRefused)
}
if _, err := s.Open(ctx, "chrome://settings", "", false); CodeOf(err) != CodeURLRefused {
t.Fatalf("browser page = %v, want %s", err, CodeURLRefused)
}
}
// The port endpoint is what Windows uses; driving it here keeps that path
// exercised on machines that can run the pipe.
func TestLiveWebSocketTransport(t *testing.T) {
was := usePipe
usePipe = false
t.Cleanup(func() { usePipe = was })
s := liveSession(t, true)
srv := liveServer(t, map[string]string{"/": `<title>Socket</title><button>Go</button>`})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
info, err := s.Open(ctx, srv.URL+"/", "", false)
if err != nil || info.Title != "Socket" {
t.Fatalf("Open over the port endpoint: info=%+v err=%v", info, err)
}
snap, err := s.Snapshot(ctx, "", "")
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
findRef(t, snap, `button "Go"`)
}
+81
View File
@@ -0,0 +1,81 @@
package browser
import (
"net/url"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/fileutil"
)
// checkURL decides whether a page may be loaded. http and https go anywhere
// the network does; about:blank is the empty page; a file must lie inside one
// of roots once symlinks resolve. Every other scheme — the browser's own
// pages, script and data URLs — is refused.
func checkURL(raw string, roots []string) (string, error) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" {
return "", fail(CodeURLRefused, "%q is not an absolute URL; include the scheme, e.g. https://", raw)
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
if u.Host == "" {
return "", fail(CodeURLRefused, "%q names no host", raw)
}
return u.String(), nil
case "about":
if u.Opaque == "blank" {
return "about:blank", nil
}
case "file":
path, err := url.PathUnescape(u.Path)
if err != nil || !fileWithin(path, roots) {
return "", fail(CodeURLRefused, "%q is outside the workspace; a page may only be a local file inside it", raw)
}
return u.String(), nil
}
return "", fail(CodeURLRefused, "the %s: scheme is not a page the agent may open", u.Scheme)
}
func fileWithin(path string, roots []string) bool {
if path == "" {
return false
}
if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' {
path = path[1:]
}
path = filepath.FromSlash(path)
if resolved, err := filepath.EvalSymlinks(path); err == nil {
path = resolved
}
for _, root := range roots {
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
if fileutil.AtOrUnder(path, root) {
return true
}
}
return false
}
// OriginOf reduces a URL to what a site grant names. It answers "" for the
// empty page and anything that is not a page an agent may open.
func OriginOf(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
if u.Host == "" {
return ""
}
return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host)
case "file":
return "file://"
}
return ""
}
+74
View File
@@ -0,0 +1,74 @@
package browser
import (
"fmt"
"strings"
"sync"
)
// refTable names elements for the model. A ref is issued once and never
// reused, so one the model read earlier cannot come to mean a different
// element; the same element keeps its ref across snapshots of one document.
type refTable struct {
mu sync.Mutex
next int
byRef map[string]refTarget
byNode map[refKey]string
}
type refKey struct {
tab string
node int64
}
type refTarget struct {
tab string
node int64
retired bool
}
func (r *refTable) refFor(tab string, node int64) string {
r.mu.Lock()
defer r.mu.Unlock()
if r.byRef == nil {
r.byRef, r.byNode = map[string]refTarget{}, map[refKey]string{}
}
key := refKey{tab, node}
if ref, ok := r.byNode[key]; ok {
return ref
}
r.next++
ref := fmt.Sprintf("e%d", r.next)
r.byNode[key] = ref
r.byRef[ref] = refTarget{tab: tab, node: node}
return ref
}
func (r *refTable) resolve(ref string) (refTarget, error) {
ref = strings.TrimSpace(ref)
r.mu.Lock()
defer r.mu.Unlock()
target, ok := r.byRef[ref]
switch {
case !ok:
return refTarget{}, &Failure{Code: CodeUnknownRef, Ref: ref, Detail: fmt.Sprintf("%s was never issued; refs come from a snapshot", ref)}
case target.retired:
return refTarget{}, &Failure{Code: CodeStaleRef, Ref: ref, Detail: fmt.Sprintf("%s belonged to a page that has since navigated away; take a new snapshot", ref)}
}
return target, nil
}
// retireTab ends every ref issued on a tab's current document.
func (r *refTable) retireTab(tab string) {
r.mu.Lock()
defer r.mu.Unlock()
for key, ref := range r.byNode {
if key.tab != tab {
continue
}
delete(r.byNode, key)
target := r.byRef[ref]
target.retired = true
r.byRef[ref] = target
}
}
+351
View File
@@ -0,0 +1,351 @@
package browser
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
)
const navigationTimeout = 30 * time.Second
// Config is what a Session needs from its host. Launch.Executable is the
// configured browser, or empty to find one when the browser is first needed;
// Roots bound which local files a page may be.
type Config struct {
Launch LaunchSpec
Roots []string
Pool *Pool
}
// Session is one agent's view of the browser: the tabs it opened and the refs
// it has been shown. Its browser starts on first use, is shared with other
// sessions on the same profile, and is released by Close.
type Session struct {
cfg Config
mu sync.Mutex
eng *engine
stopEng func()
tabs []*tab
active *tab
nextTab int
refs refTable
closed bool
openedBy map[string]string // popup target id → opener tab id
}
func NewSession(cfg Config) *Session {
if cfg.Pool == nil {
cfg.Pool = &Pool{}
}
return &Session{cfg: cfg, openedBy: map[string]string{}}
}
// TabInfo is what a tab is showing.
type TabInfo struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Active bool `json:"active"`
}
// ensureEngine returns a live browser, starting one when this session has none
// or the one it had is gone. Tabs on a browser that went away go with it.
func (s *Session) ensureEngine(ctx context.Context) (*engine, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil, fail(CodeEngineFailed, "the browser session is closed")
}
if s.eng != nil && s.eng.alive() {
return s.eng, nil
}
if s.eng != nil {
s.dropEngineLocked()
}
spec := s.cfg.Launch
exe, err := Discover(spec.Executable)
if err != nil {
return nil, err
}
spec.Executable = exe
eng, err := s.cfg.Pool.acquire(ctx, spec)
if err != nil {
return nil, err
}
s.eng = eng
s.stopEng = eng.listen(s.onBrowserEvent)
return eng, nil
}
func (s *Session) dropEngineLocked() {
if s.stopEng != nil {
s.stopEng()
s.stopEng = nil
}
for _, t := range s.tabs {
t.detach()
}
s.tabs, s.active = nil, nil
eng := s.eng
s.eng = nil
go s.cfg.Pool.release(eng)
}
// Close closes this session's tabs and releases its browser.
func (s *Session) Close() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
eng, tabs := s.eng, s.tabs
s.mu.Unlock()
if eng == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), closeTimeout)
defer cancel()
for _, t := range tabs {
_ = eng.conn.call(ctx, "", "Target.closeTarget", map[string]any{"targetId": t.targetID}, nil)
t.detach()
}
s.mu.Lock()
if s.stopEng != nil {
s.stopEng()
}
s.eng = nil
s.mu.Unlock()
s.cfg.Pool.release(eng)
}
// Origin is the scheme, host and port of the page a tab shows ("" is the
// active tab), or "" when it shows no page. A local file's origin is file://.
func (s *Session) Origin(tabID string) string {
t, err := s.tab(tabID)
if err != nil {
return ""
}
raw, _ := t.location()
return OriginOf(raw)
}
// Tabs lists this session's tabs in the order they opened.
func (s *Session) Tabs() []TabInfo {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]TabInfo, 0, len(s.tabs))
for _, t := range s.tabs {
url, title := t.location()
out = append(out, TabInfo{ID: t.id, URL: url, Title: title, Active: t == s.active})
}
return out
}
// tab resolves a tab id, "" meaning the active tab.
func (s *Session) tab(id string) (*tab, error) {
s.mu.Lock()
defer s.mu.Unlock()
if id == "" {
if s.active == nil {
return nil, fail(CodeNoTab, "no page is open; open one first")
}
return s.active, nil
}
for _, t := range s.tabs {
if t.id == id {
return t, nil
}
}
return nil, fail(CodeNoTab, "no tab %s in this session", id)
}
// Open navigates a tab to rawURL and waits for it to load. newTab opens a fresh
// tab; otherwise the named tab, or the active one, navigates.
func (s *Session) Open(ctx context.Context, rawURL, tabID string, newTab bool) (TabInfo, error) {
target, err := checkURL(rawURL, s.cfg.Roots)
if err != nil {
return TabInfo{}, err
}
eng, err := s.ensureEngine(ctx)
if err != nil {
return TabInfo{}, err
}
var t *tab
if newTab || (tabID == "" && s.activeTab() == nil) {
t, err = s.createTab(ctx, eng)
} else {
t, err = s.tab(tabID)
}
if err != nil {
return TabInfo{}, err
}
s.activate(t)
if err := t.navigate(ctx, target); err != nil {
return t.info(true), err
}
return t.info(true), nil
}
func (s *Session) activeTab() *tab {
s.mu.Lock()
defer s.mu.Unlock()
return s.active
}
func (s *Session) activate(t *tab) {
s.mu.Lock()
s.active = t
s.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), closeTimeout)
defer cancel()
_ = t.call(ctx, "Page.bringToFront", nil, nil)
}
// Switch makes a tab the active one.
func (s *Session) Switch(tabID string) (TabInfo, error) {
t, err := s.tab(tabID)
if err != nil {
return TabInfo{}, err
}
s.activate(t)
return t.info(true), nil
}
// CloseTab closes one tab; the most recently opened remaining tab becomes active.
func (s *Session) CloseTab(ctx context.Context, tabID string) error {
t, err := s.tab(tabID)
if err != nil {
return err
}
_ = t.eng.conn.call(ctx, "", "Target.closeTarget", map[string]any{"targetId": t.targetID}, nil)
s.removeTab(t)
return nil
}
func (s *Session) createTab(ctx context.Context, eng *engine) (*tab, error) {
var created struct {
TargetID string `json:"targetId"`
}
if err := eng.conn.call(ctx, "", "Target.createTarget", map[string]any{"url": "about:blank"}, &created); err != nil {
return nil, engineFailure(err)
}
return s.attach(ctx, eng, created.TargetID)
}
func (s *Session) attach(ctx context.Context, eng *engine, targetID string) (*tab, error) {
var attached struct {
SessionID string `json:"sessionId"`
}
if err := eng.conn.call(ctx, "", "Target.attachToTarget", map[string]any{"targetId": targetID, "flatten": true}, &attached); err != nil {
return nil, engineFailure(err)
}
s.mu.Lock()
s.nextTab++
t := newTab(s, eng, fmt.Sprintf("t%d", s.nextTab), targetID, attached.SessionID)
s.tabs = append(s.tabs, t)
s.mu.Unlock()
if err := t.enable(ctx); err != nil {
s.removeTab(t)
return nil, engineFailure(err)
}
return t, nil
}
func (s *Session) removeTab(t *tab) {
t.detach()
s.mu.Lock()
defer s.mu.Unlock()
for i, candidate := range s.tabs {
if candidate == t {
s.tabs = append(s.tabs[:i], s.tabs[i+1:]...)
break
}
}
if s.active == t {
s.active = nil
if n := len(s.tabs); n > 0 {
s.active = s.tabs[n-1]
}
}
s.refs.retireTab(t.id)
}
// onBrowserEvent follows tabs this session did not open itself: a page one of
// its tabs opened, and any of its tabs closing.
func (s *Session) onBrowserEvent(ev event) {
switch ev.Method {
case "Target.targetCreated":
var p struct {
TargetInfo struct {
TargetID string `json:"targetId"`
Type string `json:"type"`
OpenerID string `json:"openerId"`
} `json:"targetInfo"`
}
if json.Unmarshal(ev.Params, &p) != nil || p.TargetInfo.Type != "page" || p.TargetInfo.OpenerID == "" {
return
}
opener := s.tabByTarget(p.TargetInfo.OpenerID)
if opener == nil {
return
}
s.mu.Lock()
eng := s.eng
s.mu.Unlock()
if eng == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), launchTimeout)
defer cancel()
if popup, err := s.attach(ctx, eng, p.TargetInfo.TargetID); err == nil {
s.mu.Lock()
s.openedBy[popup.targetID] = opener.id
s.active = popup
s.mu.Unlock()
opener.notePopup(popup.id)
}
case "Target.targetDestroyed":
var p struct {
TargetID string `json:"targetId"`
}
if json.Unmarshal(ev.Params, &p) != nil {
return
}
if t := s.tabByTarget(p.TargetID); t != nil {
s.removeTab(t)
}
}
}
func (s *Session) tabByTarget(targetID string) *tab {
s.mu.Lock()
defer s.mu.Unlock()
for _, t := range s.tabs {
if t.targetID == targetID {
return t
}
}
return nil
}
// engineFailure attributes an error from the connection itself. A protocol
// error is the browser refusing a command and stays as it is.
func engineFailure(err error) error {
switch {
case err == nil:
return nil
case CodeOf(err) != "":
return err
case errors.Is(err, errConnClosed):
return fail(CodeEngineFailed, "the browser closed; open a page again to restart it")
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
return err
default:
return fail(CodeEngineFailed, "%v", err)
}
}
+272
View File
@@ -0,0 +1,272 @@
package browser
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"unicode/utf8"
)
const (
maxNameRunes = 160
maxTextRunes = 300
)
// Snapshot is a page as its accessibility tree presents it: one line per
// element, indented by nesting, each actionable element carrying a ref.
type Snapshot struct {
Tab TabInfo
Lines []string
Dialog *Dialog
}
type axValue struct {
Value json.RawMessage `json:"value"`
}
func (v *axValue) String() string {
if v == nil || len(v.Value) == 0 {
return ""
}
var s string
if json.Unmarshal(v.Value, &s) == nil {
return s
}
return string(v.Value)
}
type axNode struct {
NodeID string `json:"nodeId"`
Ignored bool `json:"ignored"`
Role *axValue `json:"role"`
Name *axValue `json:"name"`
Value *axValue `json:"value"`
ChildIDs []string `json:"childIds"`
ParentID string `json:"parentId"`
BackendDOMNodeID int64 `json:"backendDOMNodeId"`
Properties []struct {
Name string `json:"name"`
Value axValue `json:"value"`
} `json:"properties"`
}
// Snapshot renders a tab's page. scope, when set, is a ref whose subtree alone
// is rendered.
func (s *Session) Snapshot(ctx context.Context, tabID, scope string) (Snapshot, error) {
t, err := s.tab(tabID)
if err != nil {
return Snapshot{}, err
}
if err := t.checkCurrentURL(); err != nil {
return Snapshot{}, err
}
var tree struct {
Nodes []axNode `json:"nodes"`
}
if err := t.call(ctx, "Accessibility.getFullAXTree", nil, &tree); err != nil {
return Snapshot{}, engineFailure(err)
}
r := renderer{s: s, tab: t.id, nodes: make(map[string]*axNode, len(tree.Nodes))}
var root *axNode
for i := range tree.Nodes {
n := &tree.Nodes[i]
r.nodes[n.NodeID] = n
if n.ParentID == "" && root == nil {
root = n
}
}
if scope != "" {
target, err := s.refs.resolve(scope)
if err != nil {
return Snapshot{}, err
}
root = nil
for i := range tree.Nodes {
if tree.Nodes[i].BackendDOMNodeID == target.node {
root = &tree.Nodes[i]
break
}
}
if root == nil {
return Snapshot{}, &Failure{Code: CodeStaleRef, Ref: scope, Detail: fmt.Sprintf("%s is no longer on the page", scope)}
}
r.node(root, 0, "")
} else if root != nil {
r.children(root, 0, root.Name.String())
t.mu.Lock()
t.seen = seenSnapshot{lines: r.lines, document: t.navigated, valid: true}
t.mu.Unlock()
}
return Snapshot{Tab: t.info(true), Lines: r.lines, Dialog: t.currentDialog()}, nil
}
// Changes is how a page differs from the last snapshot the agent saw of it.
// NewDocument means there is nothing to compare: the tab navigated since, or
// the agent never saw it whole.
type Changes struct {
NewDocument bool
Added []string
Removed []string
}
// Refresh snapshots a tab and reports what changed since the agent's last look.
func (s *Session) Refresh(ctx context.Context, tabID string) (Snapshot, Changes, error) {
t, err := s.tab(tabID)
if err != nil {
return Snapshot{}, Changes{}, err
}
t.mu.Lock()
prev := t.seen
t.mu.Unlock()
snap, err := s.Snapshot(ctx, t.id, "")
if err != nil {
return Snapshot{}, Changes{}, err
}
t.mu.Lock()
document := t.navigated
t.mu.Unlock()
if !prev.valid || prev.document != document {
return snap, Changes{NewDocument: true}, nil
}
return snap, diffLines(prev.lines, snap.Lines), nil
}
// diffLines lists lines that appeared and disappeared, each in page order. A
// line that moved is neither.
func diffLines(before, after []string) Changes {
remaining := make(map[string]int, len(before))
for _, l := range before {
remaining[l]++
}
var c Changes
for _, l := range after {
if remaining[l] > 0 {
remaining[l]--
continue
}
c.Added = append(c.Added, l)
}
for _, l := range before {
if remaining[l] > 0 {
remaining[l]--
c.Removed = append(c.Removed, l)
}
}
return c
}
type renderer struct {
s *Session
tab string
nodes map[string]*axNode
lines []string
}
func role(n *axNode) string {
if n.Role == nil {
return ""
}
return n.Role.String()
}
// transparent roles carry no meaning of their own when unnamed: their children
// are rendered in their place.
var transparentRoles = map[string]bool{"generic": true, "none": true, "presentation": true, "": true}
func (r *renderer) children(n *axNode, depth int, parentName string) {
var text []string
flush := func() {
if len(text) == 0 {
return
}
joined := strings.Join(text, " ")
text = nil
if joined == parentName {
return
}
r.lines = append(r.lines, indent(depth)+"- text: "+strconv.Quote(clip(joined, maxTextRunes)))
}
for _, id := range n.ChildIDs {
child := r.nodes[id]
if child == nil {
continue
}
if !child.Ignored && role(child) == "StaticText" {
if s := strings.TrimSpace(child.Name.String()); s != "" {
text = append(text, s)
}
continue
}
flush()
r.node(child, depth, parentName)
}
flush()
}
func (r *renderer) node(n *axNode, depth int, parentName string) {
kind := role(n)
name := strings.TrimSpace(n.Name.String())
switch {
case kind == "InlineTextBox" || kind == "LineBreak":
return
case n.Ignored || (transparentRoles[kind] && name == ""):
r.children(n, depth, parentName)
return
}
line := indent(depth) + "- " + kind
if name != "" {
line += " " + strconv.Quote(clip(name, maxNameRunes))
}
if n.BackendDOMNodeID > 0 {
line += " [" + r.s.refs.refFor(r.tab, n.BackendDOMNodeID) + "]"
}
if v := strings.TrimSpace(n.Value.String()); v != "" && v != name {
line += " value=" + strconv.Quote(clip(v, maxNameRunes))
}
line += properties(n)
if kind == "Iframe" {
line += " (frame content is not included)"
}
r.lines = append(r.lines, line)
r.children(n, depth+1, name)
}
func properties(n *axNode) string {
var b strings.Builder
for _, p := range n.Properties {
v := p.Value.String()
switch p.Name {
case "focused", "disabled", "required", "readonly", "multiselectable":
if v == "true" {
b.WriteString(" " + p.Name)
}
case "checked", "pressed", "selected", "expanded":
if v == "true" || v == "false" || v == "mixed" {
b.WriteString(" " + p.Name + "=" + v)
}
case "invalid":
if v != "" && v != "false" {
b.WriteString(" invalid")
}
case "level":
b.WriteString(" level=" + v)
case "url":
if v != "" {
b.WriteString(" → " + clip(v, maxNameRunes))
}
}
}
return b.String()
}
func indent(depth int) string { return strings.Repeat(" ", depth) }
func clip(s string, limit int) string {
if utf8.RuneCountInString(s) <= limit {
return s
}
runes := []rune(s)
return string(runes[:limit]) + "…"
}
+540
View File
@@ -0,0 +1,540 @@
package browser
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
const (
maxLogEntries = 300
maxOpenRequests = 2000
)
// LogEntry is one thing a page reported: console output, an uncaught
// exception, or a request that failed.
type LogEntry struct {
Seq int64 `json:"seq"`
Kind string `json:"kind"` // console | exception | network
Level string `json:"level"` // error | warning | info
Text string `json:"text"`
}
// Dialog is a JavaScript dialog blocking the page.
type Dialog struct {
Type string `json:"type"`
Message string `json:"message"`
}
type tab struct {
s *Session
eng *engine
id string
targetID string
sessionID string
mu sync.Mutex
unsub func()
url string
title string
mainFrame string
loaded map[string]bool // loader ids whose load event has fired
changed chan struct{} // closed and replaced on every load or navigation
dialog *Dialog
logs []LogEntry
logSeq int64
requests map[string]string
popups []string
navigated int64 // main-frame cross-document navigations so far
logRead int64 // newest log seq a Logs call has returned
shotScale float64 // CSS pixels per pixel of the latest screenshot
seen seenSnapshot
}
// seenSnapshot is the last whole-page snapshot the agent was given, and which
// document it was of.
type seenSnapshot struct {
lines []string
document int64
valid bool
}
func newTab(s *Session, eng *engine, id, targetID, sessionID string) *tab {
t := &tab{
s: s, eng: eng, id: id, targetID: targetID, sessionID: sessionID,
loaded: map[string]bool{}, changed: make(chan struct{}), requests: map[string]string{},
}
t.unsub = eng.conn.subscribe(sessionID, t.onEvent)
return t
}
// call sends a command to this tab. A JavaScript dialog stops the page from
// answering anything that runs on it, so such a command fails at once while a
// dialog is open and is abandoned when one opens while it waits.
func (t *tab) call(ctx context.Context, method string, params, out any) error {
input := strings.HasPrefix(method, "Input.")
if !input && !waitsOnPage(method) {
return t.eng.conn.call(ctx, t.sessionID, method, params, out)
}
t.mu.Lock()
dialog, changed := t.dialog, t.changed
t.mu.Unlock()
if dialog != nil {
return dialogFailure(dialog)
}
callCtx, cancel := context.WithCancel(ctx)
defer cancel()
watching := make(chan struct{})
defer close(watching)
go func() {
for {
select {
case <-changed:
case <-watching:
return
}
t.mu.Lock()
open := t.dialog != nil
changed = t.changed
t.mu.Unlock()
if open {
cancel()
return
}
}
}()
err := t.eng.conn.call(callCtx, t.sessionID, method, params, out)
if err != nil && ctx.Err() == nil && callCtx.Err() != nil {
if d := t.currentDialog(); d != nil {
if input {
return nil // the input was delivered; the dialog is what it did
}
return dialogFailure(d)
}
}
return err
}
// waitsOnPage reports whether a command is answered by the page itself rather
// than by the browser around it.
func waitsOnPage(method string) bool {
for _, prefix := range []string{"Runtime.", "DOM.", "Accessibility.", "Page.captureScreenshot", "Page.getLayoutMetrics"} {
if strings.HasPrefix(method, prefix) {
return true
}
}
return false
}
func dialogFailure(d *Dialog) *Failure {
return &Failure{Code: CodeDialogOpen, Detail: fmt.Sprintf("a %s dialog is open (%q); answer it with a dialog step first", d.Type, d.Message)}
}
func (t *tab) enable(ctx context.Context) error {
for _, method := range []string{"Page.enable", "Runtime.enable", "Network.enable"} {
if err := t.call(ctx, method, nil, nil); err != nil {
return err
}
}
if err := t.call(ctx, "Page.setLifecycleEventsEnabled", map[string]any{"enabled": true}, nil); err != nil {
return err
}
var tree struct {
FrameTree struct {
Frame struct {
ID string `json:"id"`
URL string `json:"url"`
} `json:"frame"`
} `json:"frameTree"`
}
if err := t.call(ctx, "Page.getFrameTree", nil, &tree); err != nil {
return err
}
t.mu.Lock()
t.mainFrame, t.url = tree.FrameTree.Frame.ID, tree.FrameTree.Frame.URL
t.mu.Unlock()
return nil
}
func (t *tab) detach() {
t.mu.Lock()
unsub := t.unsub
t.unsub = nil
t.mu.Unlock()
if unsub != nil {
unsub()
}
}
func (t *tab) location() (url, title string) {
t.mu.Lock()
defer t.mu.Unlock()
return t.url, t.title
}
// info reads the title fresh when asked to, since pages set it after load.
func (t *tab) info(refreshTitle bool) TabInfo {
if refreshTitle {
ctx, cancel := context.WithTimeout(context.Background(), closeTimeout)
var r struct {
Result struct {
Value string `json:"value"`
} `json:"result"`
}
if t.call(ctx, "Runtime.evaluate", map[string]any{"expression": "document.title", "returnByValue": true}, &r) == nil {
t.mu.Lock()
t.title = r.Result.Value
t.mu.Unlock()
}
cancel()
}
url, title := t.location()
return TabInfo{ID: t.id, URL: url, Title: title, Active: t.s.activeTab() == t}
}
func (t *tab) signalLocked() {
close(t.changed)
t.changed = make(chan struct{})
}
func (t *tab) onEvent(ev event) {
switch ev.Method {
case "Page.frameNavigated":
var p struct {
Frame struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
URL string `json:"url"`
URLFragment string `json:"urlFragment"`
} `json:"frame"`
}
if json.Unmarshal(ev.Params, &p) != nil || p.Frame.ParentID != "" {
return
}
t.mu.Lock()
t.mainFrame, t.url = p.Frame.ID, p.Frame.URL+p.Frame.URLFragment
t.navigated++
t.signalLocked()
t.mu.Unlock()
t.s.refs.retireTab(t.id)
case "Page.navigatedWithinDocument":
var p struct {
FrameID string `json:"frameId"`
URL string `json:"url"`
}
if json.Unmarshal(ev.Params, &p) != nil {
return
}
t.mu.Lock()
if p.FrameID == t.mainFrame {
t.url = p.URL
t.signalLocked()
}
t.mu.Unlock()
case "Page.lifecycleEvent":
var p struct {
FrameID string `json:"frameId"`
LoaderID string `json:"loaderId"`
Name string `json:"name"`
}
if json.Unmarshal(ev.Params, &p) != nil || p.Name != "load" {
return
}
t.mu.Lock()
if p.FrameID == t.mainFrame {
t.loaded[p.LoaderID] = true
t.signalLocked()
}
t.mu.Unlock()
case "Page.javascriptDialogOpening":
var d Dialog
if json.Unmarshal(ev.Params, &d) != nil {
return
}
t.mu.Lock()
t.dialog = &d
t.signalLocked()
t.mu.Unlock()
case "Page.javascriptDialogClosed":
t.mu.Lock()
t.dialog = nil
t.signalLocked()
t.mu.Unlock()
case "Runtime.consoleAPICalled":
t.onConsole(ev.Params)
case "Runtime.exceptionThrown":
var p struct {
ExceptionDetails struct {
Text string `json:"text"`
Exception struct {
Description string `json:"description"`
} `json:"exception"`
} `json:"exceptionDetails"`
}
if json.Unmarshal(ev.Params, &p) != nil {
return
}
text := p.ExceptionDetails.Exception.Description
if text == "" {
text = p.ExceptionDetails.Text
}
t.log("exception", "error", text)
default:
t.onNetwork(ev)
}
}
// onNetwork records requests that failed or answered with an error status.
func (t *tab) onNetwork(ev event) {
switch ev.Method {
case "Network.requestWillBeSent":
var p struct {
RequestID string `json:"requestId"`
Request struct {
Method string `json:"method"`
URL string `json:"url"`
} `json:"request"`
}
if json.Unmarshal(ev.Params, &p) != nil {
return
}
t.mu.Lock()
if len(t.requests) < maxOpenRequests {
t.requests[p.RequestID] = p.Request.Method + " " + p.Request.URL
}
t.mu.Unlock()
case "Network.responseReceived":
var p struct {
RequestID string `json:"requestId"`
Response struct {
URL string `json:"url"`
Status int `json:"status"`
} `json:"response"`
}
if json.Unmarshal(ev.Params, &p) != nil || p.Response.Status < 400 {
return
}
t.log("network", "warning", fmt.Sprintf("%s → HTTP %d", t.requestName(p.RequestID, p.Response.URL), p.Response.Status))
case "Network.loadingFailed":
var p struct {
RequestID string `json:"requestId"`
ErrorText string `json:"errorText"`
Canceled bool `json:"canceled"`
}
if json.Unmarshal(ev.Params, &p) != nil {
return
}
name := t.requestName(p.RequestID, "")
t.forgetRequest(p.RequestID)
if !p.Canceled {
t.log("network", "error", fmt.Sprintf("%s failed: %s", name, p.ErrorText))
}
case "Network.loadingFinished":
var p struct {
RequestID string `json:"requestId"`
}
if json.Unmarshal(ev.Params, &p) == nil {
t.forgetRequest(p.RequestID)
}
}
}
func (t *tab) onConsole(params json.RawMessage) {
var p struct {
Type string `json:"type"`
Args []struct {
Type string `json:"type"`
Value json.RawMessage `json:"value"`
Description string `json:"description"`
} `json:"args"`
}
if json.Unmarshal(params, &p) != nil {
return
}
parts := make([]string, 0, len(p.Args))
for _, a := range p.Args {
var s string
switch {
case a.Type == "string" && json.Unmarshal(a.Value, &s) == nil:
parts = append(parts, s)
case len(a.Value) > 0:
parts = append(parts, string(a.Value))
default:
parts = append(parts, a.Description)
}
}
level := "info"
switch p.Type {
case "error", "assert":
level = "error"
case "warning":
level = "warning"
}
t.log("console", level, strings.Join(parts, " "))
}
func (t *tab) requestName(id, fallback string) string {
t.mu.Lock()
defer t.mu.Unlock()
if name, ok := t.requests[id]; ok {
return name
}
return fallback
}
func (t *tab) forgetRequest(id string) {
t.mu.Lock()
delete(t.requests, id)
t.mu.Unlock()
}
func (t *tab) log(kind, level, text string) {
t.mu.Lock()
defer t.mu.Unlock()
t.logSeq++
t.logs = append(t.logs, LogEntry{Seq: t.logSeq, Kind: kind, Level: level, Text: text})
if n := len(t.logs); n > maxLogEntries {
t.logs = append([]LogEntry(nil), t.logs[n-maxLogEntries:]...)
}
}
// logsAfter returns entries newer than seq, and the newest seq it has seen.
func (t *tab) logsAfter(seq int64) ([]LogEntry, int64) {
t.mu.Lock()
defer t.mu.Unlock()
var out []LogEntry
for _, e := range t.logs {
if e.Seq > seq {
out = append(out, e)
}
}
return out, t.logSeq
}
func (t *tab) notePopup(id string) {
t.mu.Lock()
t.popups = append(t.popups, id)
t.mu.Unlock()
}
func (t *tab) currentDialog() *Dialog {
t.mu.Lock()
defer t.mu.Unlock()
if t.dialog == nil {
return nil
}
d := *t.dialog
return &d
}
// navigate loads url in this tab and waits for its load event.
func (t *tab) navigate(ctx context.Context, url string) error {
var r struct {
LoaderID string `json:"loaderId"`
ErrorText string `json:"errorText"`
}
if err := t.call(ctx, "Page.navigate", map[string]any{"url": url}, &r); err != nil {
return engineFailure(err)
}
if r.ErrorText != "" {
return fail(CodeNavigationFailed, "%s: %s", url, r.ErrorText)
}
if r.LoaderID == "" {
return nil
}
return t.waitLoaded(ctx, r.LoaderID, navigationTimeout)
}
func (t *tab) waitLoaded(ctx context.Context, loaderID string, limit time.Duration) error {
deadline := time.NewTimer(limit)
defer deadline.Stop()
for {
t.mu.Lock()
done, changed, dialog := t.loaded[loaderID], t.changed, t.dialog
t.mu.Unlock()
if done {
return nil
}
if dialog != nil {
return dialogFailure(dialog)
}
select {
case <-changed:
case <-deadline.C:
return fail(CodeNavigationTimeout, "the page did not finish loading within %s; it may still be usable", limit)
case <-ctx.Done():
return ctx.Err()
case <-t.eng.conn.closed():
return engineFailure(errConnClosed)
}
}
}
// settle waits for a navigation an input may have started. Nothing starting
// within the grace period is the common case and not a failure.
func (t *tab) settle(ctx context.Context, before int64) {
grace := time.NewTimer(150 * time.Millisecond)
defer grace.Stop()
for {
t.mu.Lock()
navigated, changed := t.navigated > before, t.changed
t.mu.Unlock()
if navigated {
_ = t.waitAnyLoad(ctx, navigationTimeout)
return
}
select {
case <-changed:
case <-grace.C:
return
case <-ctx.Done():
return
}
}
}
// waitAnyLoad waits until the document the main frame is now showing has loaded.
func (t *tab) waitAnyLoad(ctx context.Context, limit time.Duration) error {
var r struct {
Result struct {
Value string `json:"value"`
} `json:"result"`
}
poll := time.NewTicker(100 * time.Millisecond)
defer poll.Stop()
deadline := time.NewTimer(limit)
defer deadline.Stop()
for {
if t.call(ctx, "Runtime.evaluate", map[string]any{"expression": "document.readyState", "returnByValue": true}, &r) == nil && r.Result.Value == "complete" {
return nil
}
select {
case <-poll.C:
case <-deadline.C:
return fail(CodeNavigationTimeout, "the page did not finish loading within %s", limit)
case <-ctx.Done():
return ctx.Err()
}
}
}
func (t *tab) navigationCount() int64 {
t.mu.Lock()
defer t.mu.Unlock()
return t.navigated
}
// checkCurrentURL refuses a page the agent could not have opened itself, which
// a link from an allowed local file can still reach. The browser's own error
// page is what a failed load leaves behind and stays readable.
func (t *tab) checkCurrentURL() error {
url, _ := t.location()
if url == "" || strings.HasPrefix(url, "about:") || strings.HasPrefix(url, "chrome-error:") {
return nil
}
_, err := checkURL(url, t.s.cfg.Roots)
return err
}
+87
View File
@@ -0,0 +1,87 @@
package browser
import (
"bufio"
"bytes"
"context"
"io"
"sync"
"github.com/gorilla/websocket"
)
// transport carries whole CDP messages. Chrome speaks the same JSON over a
// pair of pipes or a WebSocket; only the framing differs.
type transport interface {
read() ([]byte, error)
write([]byte) error
close() error
}
// pipeTransport frames messages with a trailing NUL, the --remote-debugging-pipe
// protocol. Nothing listens on a port, so no other local process can drive the
// browser.
type pipeTransport struct {
r *bufio.Reader
w io.Writer
closers []io.Closer
wmu sync.Mutex
}
func newPipeTransport(r io.Reader, w io.Writer, closers ...io.Closer) *pipeTransport {
return &pipeTransport{r: bufio.NewReaderSize(r, 1<<20), w: w, closers: closers}
}
func (p *pipeTransport) read() ([]byte, error) {
msg, err := p.r.ReadBytes(0)
if err != nil {
return nil, err
}
return bytes.TrimSuffix(msg, []byte{0}), nil
}
func (p *pipeTransport) write(msg []byte) error {
p.wmu.Lock()
defer p.wmu.Unlock()
_, err := p.w.Write(append(msg, 0))
return err
}
func (p *pipeTransport) close() error {
var first error
for _, c := range p.closers {
if err := c.Close(); err != nil && first == nil {
first = err
}
}
return first
}
// wsTransport is the port-based endpoint, used where a child cannot inherit
// extra pipe descriptors.
type wsTransport struct {
c *websocket.Conn
wmu sync.Mutex
}
func dialWS(ctx context.Context, url string) (*wsTransport, error) {
dialer := websocket.Dialer{ReadBufferSize: 1 << 16, WriteBufferSize: 1 << 16}
c, _, err := dialer.DialContext(ctx, url, nil)
if err != nil {
return nil, err
}
return &wsTransport{c: c}, nil
}
func (t *wsTransport) read() ([]byte, error) {
_, msg, err := t.c.ReadMessage()
return msg, err
}
func (t *wsTransport) write(msg []byte) error {
t.wmu.Lock()
defer t.wmu.Unlock()
return t.c.WriteMessage(websocket.TextMessage, msg)
}
func (t *wsTransport) close() error { return t.c.Close() }
+1
View File
@@ -1,4 +1,5 @@
{
"internal/browser/**": 21.7,
"internal/control/approval.go": 94.3,
"internal/control/approval_bridge.go": 77,
"internal/control/approval_orchestration.go": 90.7,
+1
View File
@@ -26,6 +26,7 @@ var leaves = []string{
"internal/agentgraph",
"internal/agentpreset",
"internal/billing",
"internal/browser",
"internal/diff",
"internal/extension/rpcwire",
"internal/execgraph",