From 4641f1783cae282786bf29345dbab067d0840e4c Mon Sep 17 00:00:00 2001 From: omartelo Date: Tue, 27 Oct 2026 20:36:16 -0300 Subject: [PATCH 1/2] feat(browser): name the page's console errors in the agent's next page answer --- CHANGELOG.md | 1 + api/browser.go | 5 + api/testdata/contract.golden | 2 + docs/browser-tab.md | 12 +++ internal/browser/actions.go | 1 + internal/browser/browser.go | 6 +- internal/browser/chrome_live_test.go | 4 +- internal/browser/chrome_view.go | 6 +- internal/browser/console.go | 118 ++++++++++++++++++++++ internal/browser/console_live_test.go | 95 ++++++++++++++++++ internal/browser/console_test.go | 137 ++++++++++++++++++++++++++ internal/browser/fake_test.go | 43 ++++---- internal/browser/page.go | 13 ++- internal/browser/rpc.go | 4 +- internal/cli/browser_page.go | 6 ++ internal/cli/browser_page_test.go | 8 ++ internal/cli/mcp_browser.go | 11 ++- 17 files changed, 436 insertions(+), 36 deletions(-) create mode 100644 internal/browser/console.go create mode 100644 internal/browser/console_live_test.go create mode 100644 internal/browser/console_test.go 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..347aed2e --- /dev/null +++ b/internal/browser/console_live_test.go @@ -0,0 +1,95 @@ +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 = `Console + + +` + +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) + s := newService(chromeLaunch, func(string, string) (string, error) { return t.TempDir(), 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."), From 7e2b869fa24f025c1ed859c94cc34876b5b04db2 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 28 Oct 2026 10:21:22 -0300 Subject: [PATCH 2/2] test(browser): close Chrome before removing the console test's profile --- internal/browser/console_live_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/browser/console_live_test.go b/internal/browser/console_live_test.go index 347aed2e..8bb0c08e 100644 --- a/internal/browser/console_live_test.go +++ b/internal/browser/console_live_test.go @@ -39,7 +39,11 @@ func TestChromeNamesTheConsoleErrorsOnceInTheNextPageAnswer(t *testing.T) { fmt.Fprint(w, consolePage) })) t.Cleanup(srv.Close) - s := newService(chromeLaunch, func(string, string) (string, error) { return t.TempDir(), nil }, time.Now, idleAfter) + // 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)