diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e912aa..afed5009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The crosshair on a session browser's toolbar lets you leave notes on the page for the agent. The element under the pointer is outlined and named, a click opens a note beside it, and each note pins its element with a number, following it as the page scrolls. Notes wait in a tray floating over the bottom of the page, which never resizes under you. Hovering a note there lights its element, a click edits it, and Send (or Ctrl+Enter) types them into the session as one message: which element, a selector the agent's `browser_click` takes, the React component that drew it in a development build, and your note. Ctrl+Shift+E (Cmd+Shift+E on macOS) turns the crosshair on and off, and Esc leaves it. A send the session refuses keeps the notes and says why in the tray. - A session's browser downloads files. A download the page starts, from a link, a button, a new tab or a script, lands in your Downloads folder under the name the page gives it, with ` (1)` added rather than overwriting a file already there. The agent can also open a file's URL directly. The agent's click, key press or open answers with the file's path and size, and the window shows a toast naming the file, with a button that opens its folder. Before, a download button in the session's browser did nothing. - The agent can wait for its browser page to change instead of reading it again in a loop. `browser_info` takes `wait_for` (text that should appear) and `wait_gone` (text that should disappear), matched anywhere on the page, and answers as soon as the page shows it, or after 20 seconds with the page as it stands and a line saying the wait timed out. +- The agent hears when the page in its session's browser breaks. Each page it reads, clicks or presses a key on answers with the console errors since its last look, once each: `console.error` calls, uncaught exceptions and resources that failed to load, cut to one short line, the first five and how many more. Warnings and other messages stay out. ### Fixed diff --git a/api/browser.go b/api/browser.go index 01503d7e..bd7a5d0d 100644 --- a/api/browser.go +++ b/api/browser.go @@ -267,6 +267,11 @@ type BrowserPageInfo struct { // WaitTimedOut says the text a read waited for never came (or never // went): the page is the one standing when the wait gave up. WaitTimedOut bool `json:"waitTimedOut,omitempty"` + // ConsoleErrors are the errors the page's console showed since the + // agent's last page answer, each one line cut short; ConsoleErrorsMore + // counts those past the cap. + ConsoleErrors []string `json:"consoleErrors,omitempty"` + ConsoleErrorsMore int `json:"consoleErrorsMore,omitempty"` } // BrowserPointOptions is a point of session ID's page, in CSS pixels. diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index 9e3542d1..c6e83c7b 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -343,6 +343,8 @@ BrowserPageInfo { truncated bool downloads,omitempty []BrowserDownload waitTimedOut,omitempty bool + consoleErrors,omitempty []string + consoleErrorsMore,omitempty int } BrowserPointOptions { diff --git a/docs/browser-tab.md b/docs/browser-tab.md index a72f53d1..d6e51428 100644 --- a/docs/browser-tab.md +++ b/docs/browser-tab.md @@ -59,6 +59,18 @@ page only answers what is where (`browser.ElementAt`, `browser.Locate`); the outline, the pins and the note are the window's, so nothing is injected into the page. +## Console errors + +Each page answer (`browser_info`, `browser_click`, `browser_press`) names the +errors the page's console showed since the agent's last one, once each, as +`console error:` lines: a `console.error` call, an uncaught exception with +where it was thrown, or an error Chrome logs itself, such as a resource that +failed to load. Each is one line cut to 200 characters; past the first five, a +`(N more console errors)` line counts the rest. Warnings, info and log +messages are left out. The text is what the page logs, so a page that logs a +form's value hands it to the agent; lich reads nothing else. These lines are +English, outside the prompt-language catalogs, like the rest of the page answer. + ## Downloads A file the page downloads lands in the user's Downloads folder diff --git a/internal/browser/actions.go b/internal/browser/actions.go index 490b6180..48191340 100644 --- a/internal/browser/actions.go +++ b/internal/browser/actions.go @@ -76,6 +76,7 @@ func (s *Service) Info(id string) (PageInfo, error) { s.remember(sess, info.URL, info.Title) s.agentActed(sess, api.BrowserAgentAct{}) info.Downloads = s.untold(sess) + info.ConsoleErrors, info.ConsoleErrorsMore = s.consoleUntold(sess) return info, nil } diff --git a/internal/browser/browser.go b/internal/browser/browser.go index ce4443e6..02da3b10 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -77,6 +77,10 @@ type session struct { // downloadBegun is when the last one began. downloads []*download downloadBegun time.Time + // consoleErrors are the page's since the agent's last page answer, the + // first consoleErrorMax of them; consoleMore counts the rest. + consoleErrors []string + consoleMore int page Page lastUsed time.Time } @@ -269,7 +273,7 @@ func (s *Service) start(spec launchSpec) (Handle, error) { id: newID(), owner: spec.owner, dir: spec.dir, url: spec.url, headed: spec.headed, byAgent: spec.byAgent, page: page, lastUsed: s.now(), } - page.Listen(s.frameSink(sess), s.infoSink(sess), s.downloadSink(sess)) + page.Listen(s.frameSink(sess), s.infoSink(sess), s.downloadSink(sess), s.consoleSink(sess)) if err := runPage(func(ctx context.Context) error { if len(spec.cookies) > 0 { if err := page.SetCookies(ctx, spec.cookies); err != nil { diff --git a/internal/browser/chrome_live_test.go b/internal/browser/chrome_live_test.go index f51f3c2c..8c938436 100644 --- a/internal/browser/chrome_live_test.go +++ b/internal/browser/chrome_live_test.go @@ -148,7 +148,7 @@ func TestChromeScreencastSendsFramesAndPageInfo(t *testing.T) { case titles <- title: default: } - }, func(string, api.BrowserDownload) {}) + }, func(string, api.BrowserDownload) {}, func(string) {}) ctx, cancel := context.WithTimeout(context.Background(), pageWait) defer cancel() if err := p.Navigate(ctx, srv.URL); err != nil { @@ -225,7 +225,7 @@ func TestChromeLinkToANewTabLoadsInThePageAndKeepsItResponding(t *testing.T) { t.Cleanup(srv.Close) p := pg.(*chromePage) urls := make(chan string, 8) - p.Listen(func(string) {}, func(url, _ string) { urls <- url }, func(string, api.BrowserDownload) {}) + p.Listen(func(string) {}, func(url, _ string) { urls <- url }, func(string, api.BrowserDownload) {}, func(string) {}) ctx, cancel := context.WithTimeout(context.Background(), pageWait) defer cancel() if err := p.Navigate(ctx, srv.URL+"/"); err != nil { diff --git a/internal/browser/chrome_view.go b/internal/browser/chrome_view.go index dc936d7c..f15a7852 100644 --- a/internal/browser/chrome_view.go +++ b/internal/browser/chrome_view.go @@ -31,7 +31,7 @@ type listeners struct { onInfo func(url, title string) } -func (p *chromePage) Listen(onFrame func(data string), onInfo func(url, title string), onDownload func(guid string, d api.BrowserDownload)) { +func (p *chromePage) Listen(onFrame func(data string), onInfo func(url, title string), onDownload func(guid string, d api.BrowserDownload), onConsoleError func(text string)) { p.listen.mu.Lock() p.listen.onFrame, p.listen.onInfo = onFrame, onInfo p.listen.mu.Unlock() @@ -45,6 +45,10 @@ func (p *chromePage) Listen(onFrame func(data string), onInfo func(url, title st } case *page.EventNavigatedWithinDocument, *page.EventDomContentEventFired: go p.reportInfo() + default: + if text, ok := consoleError(ev); ok { + onConsoleError(text) + } } }) p.keepOneTab() diff --git a/internal/browser/console.go b/internal/browser/console.go new file mode 100644 index 00000000..1cab0e82 --- /dev/null +++ b/internal/browser/console.go @@ -0,0 +1,118 @@ +package browser + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/chromedp/cdproto/log" + "github.com/chromedp/cdproto/runtime" +) + +// consoleErrorMax and consoleTextMax keep a page answer lean: a page that +// throws in a loop names its first errors and how many more, not every one. +const ( + consoleErrorMax = 5 + consoleTextMax = 200 +) + +// consoleSink keeps each error the page's console shows until the agent's +// next page answer. +func (s *Service) consoleSink(sess *session) func(text string) { + return func(text string) { + s.mu.Lock() + defer s.mu.Unlock() + if len(sess.consoleErrors) < consoleErrorMax { + sess.consoleErrors = append(sess.consoleErrors, consoleLine(text)) + } else { + sess.consoleMore++ + } + } +} + +// consoleUntold is the errors the agent has not heard, which it now has. +func (s *Service) consoleUntold(sess *session) ([]string, int) { + s.mu.Lock() + defer s.mu.Unlock() + out, more := sess.consoleErrors, sess.consoleMore + sess.consoleErrors, sess.consoleMore = nil, 0 + return out, more +} + +// consoleLine is text on one line, cut to consoleTextMax runes. +func consoleLine(text string) string { + text = strings.Join(strings.Fields(text), " ") + if r := []rune(text); len(r) > consoleTextMax { + return string(r[:consoleTextMax]) + "…" + } + return text +} + +// consoleError is the text of a CDP event that is a console error: a +// console.error call, an uncaught exception, or an error Chrome logs itself, +// such as a resource that failed to load. Warnings and the rest are not. +func consoleError(ev any) (string, bool) { + switch e := ev.(type) { + case *runtime.EventConsoleAPICalled: + return consoleCallText(e), e.Type == runtime.APITypeError + case *runtime.EventExceptionThrown: + return exceptionText(e.ExceptionDetails), e.ExceptionDetails != nil + case *log.EventEntryAdded: + return logEntryText(e.Entry), e.Entry != nil && e.Entry.Level == log.LevelError + } + return "", false +} + +func consoleCallText(e *runtime.EventConsoleAPICalled) string { + parts := make([]string, len(e.Args)) + for i, a := range e.Args { + parts[i] = objectText(a) + } + return strings.Join(parts, " ") +} + +// objectText is how the console prints one argument, first line only: an +// Error's description carries its stack after it. +func objectText(o *runtime.RemoteObject) string { + var s string + switch { + case len(o.Value) > 0: + if json.Unmarshal(o.Value, &s) != nil { + s = string(o.Value) + } + case o.UnserializableValue != "": + s = string(o.UnserializableValue) + case o.Description != "": + s = o.Description + default: + s = string(o.Type) + } + first, _, _ := strings.Cut(s, "\n") + return first +} + +// exceptionText is "Uncaught TypeError: … (url:line)", the way the console +// shows it, the line 1-based. +func exceptionText(d *runtime.ExceptionDetails) string { + if d == nil { + return "" + } + text := d.Text + if d.Exception != nil { + text += " " + objectText(d.Exception) + } + if d.URL != "" { + text += fmt.Sprintf(" (%s:%d)", d.URL, d.LineNumber+1) + } + return text +} + +func logEntryText(e *log.Entry) string { + if e == nil { + return "" + } + if e.URL == "" { + return e.Text + } + return e.Text + " " + e.URL +} diff --git a/internal/browser/console_live_test.go b/internal/browser/console_live_test.go new file mode 100644 index 00000000..8bb0c08e --- /dev/null +++ b/internal/browser/console_live_test.go @@ -0,0 +1,99 @@ +package browser + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "testing" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/chromium" +) + +// consolePage logs one error and one warning as it loads, asks for an image +// that is not there, and throws when its button is clicked. +const consolePage = `
+
+`
+
+func TestChromeNamesTheConsoleErrorsOnceInTheNextPageAnswer(t *testing.T) {
+ if _, err := chromium.FindBrowser(exec.LookPath); err != nil {
+ t.Skip("no Chromium-family browser on this machine")
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Chrome asks for a favicon on its own, at a moment of its choosing:
+ // its 404 would land in whichever answer comes next.
+ if r.URL.Path == "/favicon.ico" {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, consolePage)
+ }))
+ t.Cleanup(srv.Close)
+ // The profile's folder is made before the service so that its removal,
+ // a cleanup, runs after Cleanup closed Chrome: Windows refuses to delete
+ // a file Chrome still holds.
+ profile := t.TempDir()
+ s := newService(chromeLaunch, func(string, string) (string, error) { return profile, nil }, time.Now, idleAfter)
+ t.Cleanup(func() {
+ if err := s.Cleanup(); err != nil {
+ t.Error(err)
+ }
+ })
+ if _, err := s.Open("s", srv.URL+"/"); err != nil {
+ t.Fatal(err)
+ }
+ loaded := readConsole(t, s, 2)
+ if !hasError(loaded, "load failed: 42") || !hasError(loaded, "/missing.png") || hasError(loaded, "warning") {
+ t.Fatalf("the page answer named %q, want the error and the missing image, no warning", loaded)
+ }
+ clicked, err := s.ClickAt(t.Context(), api.BrowserClickOptions{ID: "s", BrowserTarget: api.BrowserTarget{Selector: "#throw"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(clicked.ConsoleErrors) != 1 || !strings.Contains(clicked.ConsoleErrors[0], "Uncaught TypeError") {
+ t.Fatalf("the click answered %q, want the one uncaught exception", clicked.ConsoleErrors)
+ }
+ again, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(again.ConsoleErrors) != 0 {
+ t.Fatalf("a read after the click repeated %q", again.ConsoleErrors)
+ }
+}
+
+// readConsole reads the page until its answers have named want errors: the
+// image's failure arrives when Chrome gets the 404, not with the document.
+func readConsole(t *testing.T, s *Service, want int) []string {
+ t.Helper()
+ var got []string
+ deadline := time.Now().Add(pageWait)
+ for len(got) < want && time.Now().Before(deadline) {
+ info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got = append(got, info.ConsoleErrors...)
+ time.Sleep(downloadPoll)
+ }
+ return got
+}
+
+func hasError(errs []string, part string) bool {
+ for _, e := range errs {
+ if strings.Contains(e, part) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/browser/console_test.go b/internal/browser/console_test.go
new file mode 100644
index 00000000..f54d18bf
--- /dev/null
+++ b/internal/browser/console_test.go
@@ -0,0 +1,137 @@
+package browser
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/chromedp/cdproto/log"
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/lichdotdev/lich/api"
+)
+
+func TestReadNamesTheConsoleErrorsSinceTheLastAnswerOnce(t *testing.T) {
+ s, launch, _ := viewService(t)
+ if _, err := s.Open("s", "https://example.com"); err != nil {
+ t.Fatal(err)
+ }
+ p := launch.pages[0]
+ p.onConsoleError("Uncaught TypeError: x is undefined")
+ p.onConsoleError("boom")
+ info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"Uncaught TypeError: x is undefined", "boom"}
+ if !reflect.DeepEqual(info.ConsoleErrors, want) || info.ConsoleErrorsMore != 0 {
+ t.Fatalf("read reported %q (+%d), want %q", info.ConsoleErrors, info.ConsoleErrorsMore, want)
+ }
+ again, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(again.ConsoleErrors) != 0 {
+ t.Fatalf("a second read repeated %q", again.ConsoleErrors)
+ }
+}
+
+func TestClickAndPressAnswerWithTheConsoleErrorsTheyCaused(t *testing.T) {
+ s, launch, _ := viewService(t)
+ if _, err := s.Open("s", "https://example.com"); err != nil {
+ t.Fatal(err)
+ }
+ p := launch.pages[0]
+ p.onClick = func() { p.onConsoleError("from click") }
+ clicked, err := s.ClickAt(t.Context(), api.BrowserClickOptions{ID: "s", BrowserTarget: api.BrowserTarget{Selector: "#b"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(clicked.ConsoleErrors, []string{"from click"}) {
+ t.Fatalf("click reported %q", clicked.ConsoleErrors)
+ }
+ p.onConsoleError("before press")
+ pressed, err := s.PressKey(t.Context(), api.BrowserPressOptions{ID: "s", Key: "Enter"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(pressed.ConsoleErrors, []string{"before press"}) {
+ t.Fatalf("press reported %q", pressed.ConsoleErrors)
+ }
+}
+
+func TestConsoleErrorsPastTheCapAreCountedNotKept(t *testing.T) {
+ s, launch, _ := viewService(t)
+ if _, err := s.Open("s", "https://example.com"); err != nil {
+ t.Fatal(err)
+ }
+ p := launch.pages[0]
+ for i := range consoleErrorMax + 3 {
+ p.onConsoleError(fmt.Sprintf("e%d", i))
+ }
+ info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(info.ConsoleErrors) != consoleErrorMax || info.ConsoleErrors[0] != "e0" || info.ConsoleErrorsMore != 3 {
+ t.Fatalf("read reported %q (+%d), want the first %d and 3 more", info.ConsoleErrors, info.ConsoleErrorsMore, consoleErrorMax)
+ }
+ again, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.ConsoleErrorsMore != 0 {
+ t.Fatalf("the count of more errors was reported again: %d", again.ConsoleErrorsMore)
+ }
+}
+
+func TestConsoleTextIsOneLineCutToItsLimit(t *testing.T) {
+ long := strings.Repeat("é", consoleTextMax+10)
+ got := consoleLine(long)
+ if n := len([]rune(got)); n != consoleTextMax+1 || !strings.HasSuffix(got, "…") {
+ t.Fatalf("cut to %d runes (%q...), want %d and an ellipsis", n, got[:8], consoleTextMax+1)
+ }
+ if got := consoleLine(" a\n\tb c "); got != "a b c" {
+ t.Fatalf("got %q, want the whitespace folded", got)
+ }
+}
+
+func TestConsoleAPIErrorJoinsItsArguments(t *testing.T) {
+ ev := &runtime.EventConsoleAPICalled{Type: runtime.APITypeError, Args: []*runtime.RemoteObject{
+ {Type: runtime.TypeString, Value: []byte(`"failed: save"`)},
+ {Type: runtime.TypeNumber, Value: []byte(`42`)},
+ {Type: runtime.TypeObject, Description: "Error: nope\n at f (app.js:1:2)"},
+ {Type: runtime.TypeNumber, UnserializableValue: "NaN"},
+ {Type: runtime.TypeUndefined},
+ }}
+ if got, want := consoleCallText(ev), "failed: save 42 Error: nope NaN undefined"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+}
+
+func TestExceptionTextNamesTheErrorAndWhereItWasThrown(t *testing.T) {
+ d := &runtime.ExceptionDetails{
+ Text: "Uncaught", URL: "http://x/app.js", LineNumber: 11,
+ Exception: &runtime.RemoteObject{Description: "TypeError: a is null\n at g (app.js:12:3)"},
+ }
+ if got, want := exceptionText(d), "Uncaught TypeError: a is null (http://x/app.js:12)"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+ thrown := &runtime.ExceptionDetails{Text: "Uncaught", Exception: &runtime.RemoteObject{Value: []byte(`"plain"`)}}
+ if got := exceptionText(thrown); got != "Uncaught plain" {
+ t.Fatalf("a thrown string read %q", got)
+ }
+ if got := exceptionText(&runtime.ExceptionDetails{Text: "Uncaught SyntaxError"}); got != "Uncaught SyntaxError" {
+ t.Fatalf("an exception without its object read %q", got)
+ }
+}
+
+func TestLogEntryNamesTheResourceThatFailed(t *testing.T) {
+ e := &log.Entry{Level: log.LevelError, Text: "Failed to load resource: 404 (Not Found)", URL: "http://x/a.png"}
+ if got, want := logEntryText(e), "Failed to load resource: 404 (Not Found) http://x/a.png"; got != want {
+ t.Fatalf("got %q, want %q", got, want)
+ }
+ if got := logEntryText(&log.Entry{Text: "CSP"}); got != "CSP" {
+ t.Fatalf("got %q", got)
+ }
+}
diff --git a/internal/browser/fake_test.go b/internal/browser/fake_test.go
index b2137877..97e98dda 100644
--- a/internal/browser/fake_test.go
+++ b/internal/browser/fake_test.go
@@ -13,25 +13,26 @@ import (
// fakePage is a Page that never talks to Chrome. Tests inject it through
// Launcher so the suite stays off the OS boundary.
type fakePage struct {
- mu sync.Mutex
- url, title string
- closed bool
- navigations []string
- clicks []Target
- typed []typed
- pressed []pressed
- reloads int
- backs int
- forwards int
- scrolled []int
- scrolledAt []*point
- focused int
- cookies []*network.CookieParam
- setCookies []*network.CookieParam
- clickAt [2]float64
- onFrame func(string)
- onInfo func(url, title string)
- onDownload func(guid string, d api.BrowserDownload)
+ mu sync.Mutex
+ url, title string
+ closed bool
+ navigations []string
+ clicks []Target
+ typed []typed
+ pressed []pressed
+ reloads int
+ backs int
+ forwards int
+ scrolled []int
+ scrolledAt []*point
+ focused int
+ cookies []*network.CookieParam
+ setCookies []*network.CookieParam
+ clickAt [2]float64
+ onFrame func(string)
+ onInfo func(url, title string)
+ onDownload func(guid string, d api.BrowserDownload)
+ onConsoleError func(text string)
// onClick runs after a click, as what the page does in answer to it;
// onNavigate as a navigation starts.
onClick func()
@@ -229,10 +230,10 @@ func (p *fakePage) SetCookies(_ context.Context, cookies []*network.CookieParam)
return nil
}
-func (p *fakePage) Listen(onFrame func(string), onInfo func(url, title string), onDownload func(string, api.BrowserDownload)) {
+func (p *fakePage) Listen(onFrame func(string), onInfo func(url, title string), onDownload func(string, api.BrowserDownload), onConsoleError func(string)) {
p.mu.Lock()
defer p.mu.Unlock()
- p.onFrame, p.onInfo, p.onDownload = onFrame, onInfo, onDownload
+ p.onFrame, p.onInfo, p.onDownload, p.onConsoleError = onFrame, onInfo, onDownload, onConsoleError
}
func (p *fakePage) Screencast(_ context.Context, v Viewport) error {
diff --git a/internal/browser/page.go b/internal/browser/page.go
index 0161597a..6a9af374 100644
--- a/internal/browser/page.go
+++ b/internal/browser/page.go
@@ -72,8 +72,11 @@ type PageInfo struct {
Text string `json:"text"`
Interactive []Interactive `json:"interactive"`
Truncated bool `json:"truncated"`
- // Downloads is the service's to fill, never the page script's.
- Downloads []api.BrowserDownload `json:"-"`
+ // Downloads and the console errors are the service's to fill, never the
+ // page script's.
+ Downloads []api.BrowserDownload `json:"-"`
+ ConsoleErrors []string `json:"-"`
+ ConsoleErrorsMore int `json:"-"`
}
// Page is one Chromium tab the service drives. chrome.go is the real one;
@@ -104,9 +107,9 @@ type Page interface {
Cookies(ctx context.Context) ([]*network.CookieParam, error)
SetCookies(ctx context.Context, cookies []*network.CookieParam) error
// Listen hands the page what to call with each screencast frame (base64
- // JPEG), with its URL and title whenever they change, and with each step
- // of a download it starts.
- Listen(onFrame func(data string), onInfo func(url, title string), onDownload func(guid string, d api.BrowserDownload))
+ // JPEG), with its URL and title whenever they change, with each step of a
+ // download it starts, and with each error its console shows.
+ Listen(onFrame func(data string), onInfo func(url, title string), onDownload func(guid string, d api.BrowserDownload), onConsoleError func(text string))
Screencast(ctx context.Context, v Viewport) error
StopScreencast(ctx context.Context) error
Resize(ctx context.Context, v Viewport) error
diff --git a/internal/browser/rpc.go b/internal/browser/rpc.go
index e529a752..34109e71 100644
--- a/internal/browser/rpc.go
+++ b/internal/browser/rpc.go
@@ -78,6 +78,7 @@ func (s *Service) pageAfter(id string) (api.BrowserPageInfo, error) {
}
s.remember(sess, info.URL, info.Title)
info.Downloads = s.downloadsAfter(sess, downloadWait)
+ info.ConsoleErrors, info.ConsoleErrorsMore = s.consoleUntold(sess)
return infoAPI(info), nil
}
@@ -147,7 +148,8 @@ func handleAPI(h Handle) api.BrowserHandle {
}
func infoAPI(p PageInfo) api.BrowserPageInfo {
- out := api.BrowserPageInfo{URL: p.URL, Title: p.Title, Text: p.Text, Truncated: p.Truncated, Downloads: p.Downloads}
+ out := api.BrowserPageInfo{URL: p.URL, Title: p.Title, Text: p.Text, Truncated: p.Truncated, Downloads: p.Downloads,
+ ConsoleErrors: p.ConsoleErrors, ConsoleErrorsMore: p.ConsoleErrorsMore}
if p.Interactive == nil {
return out
}
diff --git a/internal/cli/browser_page.go b/internal/cli/browser_page.go
index 2f210133..081563f9 100644
--- a/internal/cli/browser_page.go
+++ b/internal/cli/browser_page.go
@@ -24,6 +24,12 @@ func pageText(info api.BrowserPageInfo) string {
b.WriteString(downloadLine(d))
b.WriteByte('\n')
}
+ for _, e := range info.ConsoleErrors {
+ fmt.Fprintf(&b, "console error: %s\n", e)
+ }
+ if info.ConsoleErrorsMore > 0 {
+ fmt.Fprintf(&b, "(%d more console errors)\n", info.ConsoleErrorsMore)
+ }
if info.Text != "" {
fmt.Fprintf(&b, "text: %s\n", info.Text)
}
diff --git a/internal/cli/browser_page_test.go b/internal/cli/browser_page_test.go
index 2faa65d4..b597a569 100644
--- a/internal/cli/browser_page_test.go
+++ b/internal/cli/browser_page_test.go
@@ -78,3 +78,11 @@ func TestPageTextSaysAWaitTimedOutBeforeThePage(t *testing.T) {
t.Fatalf("got\n%s\nwant\n%s", got, want)
}
}
+
+func TestPageTextNamesConsoleErrorsAndHowManyMore(t *testing.T) {
+ info := api.BrowserPageInfo{URL: "about:blank", ConsoleErrors: []string{"Uncaught Error: x", "boom"}, ConsoleErrorsMore: 4}
+ want := "url: about:blank\ntitle: \nconsole error: Uncaught Error: x\nconsole error: boom\n(4 more console errors)"
+ if got := pageText(info); got != want {
+ t.Fatalf("got\n%s\nwant\n%s", got, want)
+ }
+}
diff --git a/internal/cli/mcp_browser.go b/internal/cli/mcp_browser.go
index 667b8b43..c7887638 100644
--- a/internal/cli/mcp_browser.go
+++ b/internal/cli/mcp_browser.go
@@ -42,9 +42,9 @@ var browserTools = []mcpTool{
"one line per interactive element there: index, tag, label and link. Form values are " +
"never included — (filled)/(checked) say that typing landed. click and press already " +
"answer with this, so call it only to look again without acting. Also names a download " +
- "that finished since the page was last read. wait_for and wait_gone hold the read, up " +
- "to 20 seconds, until that text is on the page or off it, instead of reading again in " +
- "a loop. Read-only.",
+ "that finished, and each console error, since the page was last read. wait_for and " +
+ "wait_gone hold the read, up to 20 seconds, until that text is on the page or off it, " +
+ "instead of reading again in a loop. Read-only.",
Schema: schema(map[string]any{
"wait_for": property("string", "Wait until this text is on the page (case-sensitive, the whole page)."),
"wait_gone": property("string", "Wait until this text is no longer on the page."),
@@ -59,7 +59,8 @@ var browserTools = []mcpTool{
"coordinates and must be inside it. Sends a real mouse event, not element.click(). " +
"Answers with the page afterwards, as browser_info reads it, once a navigation the " +
"click started has its document. A file the click downloads is saved to the user's " +
- "Downloads folder; the answer names its path in a downloaded: line.",
+ "Downloads folder; the answer names its path in a downloaded: line. Console errors " +
+ "and uncaught exceptions since the last page answer come as console error: lines.",
Schema: schema(map[string]any{
"index": property("integer", "Index from the last browser_info."),
"selector": property("string", "CSS selector."),
@@ -105,7 +106,7 @@ var browserTools = []mcpTool{
"Real key event, not typed text — use browser_type for that. Optional target " +
"focuses first, as for browser_type. Answers with the page afterwards, once a " +
"navigation the key started (Enter on a form) has its document, and names a file it " +
- "downloaded as browser_click does.",
+ "downloaded and console errors as browser_click does.",
Schema: schema(map[string]any{
"key": property("string", "Named key: Enter, Tab, Escape, …"),
"index": property("integer", "Index from the last browser_info."),