From 8111630a3a3979dd73372b2614a9aa0df4ccb19c Mon Sep 17 00:00:00 2001 From: omartelo Date: Tue, 27 Oct 2026 20:31:44 -0300 Subject: [PATCH 1/2] feat(browser): browser_info waits for text to appear or go browser_info takes wait_for and wait_gone: a case-sensitive substring of the page's visible text (document.body.innerText, the whole page). The read polls every 100 ms for up to 20 s, then answers the page anyway, with a line saying the wait timed out, so the agent stops re-reading in a loop after a click that starts a fetch. browser.Info now takes BrowserInfoOptions instead of SessionIDOptions; the three tests calling Service.Read follow that contract change. --- CHANGELOG.md | 1 + api/browser.go | 14 ++- api/testdata/contract.golden | 9 +- internal/browser/browser.go | 24 +++-- internal/browser/chrome.go | 10 ++ internal/browser/downloads_live_test.go | 4 +- internal/browser/fake_test.go | 19 ++++ internal/browser/page.go | 3 + internal/browser/rpc.go | 13 ++- internal/browser/session_downloads_test.go | 4 +- internal/browser/wait.go | 64 +++++++++++++ internal/browser/wait_live_test.go | 60 ++++++++++++ internal/browser/wait_test.go | 103 +++++++++++++++++++++ internal/cli/browser.go | 2 +- internal/cli/browser_page.go | 3 + internal/cli/browser_page_test.go | 8 ++ internal/cli/mcp_browser.go | 15 ++- 17 files changed, 332 insertions(+), 24 deletions(-) create mode 100644 internal/browser/wait.go create mode 100644 internal/browser/wait_live_test.go create mode 100644 internal/browser/wait_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a8d712..c0e912aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,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. ### Fixed diff --git a/api/browser.go b/api/browser.go index 1ac0e13b..01503d7e 100644 --- a/api/browser.go +++ b/api/browser.go @@ -3,7 +3,7 @@ package api var ( BrowserShow = define[SessionIDOptions, BrowserHandle]("browser.Show") BrowserOpen = define[BrowserOpenOptions, BrowserHandle]("browser.Open") - BrowserInfo = define[SessionIDOptions, BrowserPageInfo]("browser.Info") + BrowserInfo = define[BrowserInfoOptions, BrowserPageInfo]("browser.Info") // Click and Press answer with the page as it stands after them, so an // agent reads what its action did without a browser.Info of its own. Type // does not: typing into a field seldom changes the page, and a page per @@ -176,6 +176,15 @@ type BrowserOpenOptions struct { URL string `json:"url"` } +// BrowserInfoOptions reads session ID's page. WaitFor and WaitGone, when set, +// hold the read until that text is on the page's visible text, or off it +// (case-sensitive substring, the whole page, not only the viewport). +type BrowserInfoOptions struct { + ID string `json:"id"` + WaitFor string `json:"waitFor,omitempty"` + WaitGone string `json:"waitGone,omitempty"` +} + // BrowserTarget picks an element. Index is the one page-info stamped; // selector is a CSS query; x/y are viewport coordinates. Coordinates win when // set, then selector, then index. @@ -255,6 +264,9 @@ type BrowserPageInfo struct { // state: what its last action saved, or one it saw start and that ended // since. Downloads []BrowserDownload `json:"downloads,omitempty"` + // 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"` } // 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 dfe014c1..9e3542d1 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -10,7 +10,7 @@ browser.Close(SessionIDOptions) None browser.Detach(SessionIDOptions) None browser.ElementAt(BrowserPointOptions) BrowserElement browser.Forward(SessionIDOptions) None -browser.Info(SessionIDOptions) BrowserPageInfo +browser.Info(BrowserInfoOptions) BrowserPageInfo browser.Key(BrowserKeyOptions) None browser.List(SessionIDOptions) []BrowserHandle browser.Locate(BrowserLocateOptions) []BrowserRect @@ -342,6 +342,7 @@ BrowserPageInfo { interactive []BrowserInteractive truncated bool downloads,omitempty []BrowserDownload + waitTimedOut,omitempty bool } BrowserPointOptions { @@ -360,6 +361,12 @@ BrowserElement { box BrowserRect } +BrowserInfoOptions { + id string + waitFor,omitempty string + waitGone,omitempty string +} + BrowserKeyOptions { id string type string diff --git a/internal/browser/browser.go b/internal/browser/browser.go index 413a0809..ce4443e6 100644 --- a/internal/browser/browser.go +++ b/internal/browser/browser.go @@ -59,6 +59,9 @@ type Service struct { stopped bool // emit pushes to the window's /events (events.Hub.Emit). emit func(name string, data any) + // textWait is maxTextWait, which the suite shortens to test a wait that + // gives up. + textWait time.Duration } type session struct { @@ -90,16 +93,17 @@ func New(emit func(name string, data any)) *Service { func newService(launch Launcher, mkTmp func(string, string) (string, error), now func() time.Time, idle time.Duration) *Service { return &Service{ - launch: launch, - mkTmp: mkTmp, - now: now, - idle: idle, - quiet: downloadQuiet, - byOwner: make(map[string]*session), - byID: make(map[string]*session), - owners: make(map[string]*sync.Mutex), - stop: make(chan struct{}), - emit: func(string, any) {}, + launch: launch, + mkTmp: mkTmp, + now: now, + idle: idle, + quiet: downloadQuiet, + textWait: maxTextWait, + byOwner: make(map[string]*session), + byID: make(map[string]*session), + owners: make(map[string]*sync.Mutex), + stop: make(chan struct{}), + emit: func(string, any) {}, } } diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index 6ee91a4c..e2a05d49 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -192,6 +192,16 @@ func (p *chromePage) Info(ctx context.Context) (PageInfo, error) { return info, nil } +func (p *chromePage) HasText(ctx context.Context, text string) (bool, error) { + literal, err := json.Marshal(text) + if err != nil { + return false, err + } + var has bool + script := fmt.Sprintf(hasTextScript, literal) + return has, p.run(ctx, chromedp.Evaluate(script, &has)) +} + func (p *chromePage) Click(ctx context.Context, t Target) (x, y float64, err error) { pt, err := p.resolve(ctx, t) if err != nil { diff --git a/internal/browser/downloads_live_test.go b/internal/browser/downloads_live_test.go index 0290b8ad..7f41f031 100644 --- a/internal/browser/downloads_live_test.go +++ b/internal/browser/downloads_live_test.go @@ -74,7 +74,7 @@ func TestChromeDownloadsLandInDownloadsAndThePageStays(t *testing.T) { // and a read names a download still running, then again once // it ends. time.Sleep(100 * time.Millisecond) - read, err := s.Read(t.Context(), api.SessionIDOptions{ID: "s"}) + read, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"}) if err != nil { t.Fatal(err) } @@ -88,7 +88,7 @@ func TestChromeDownloadsLandInDownloadsAndThePageStays(t *testing.T) { if b, err := os.ReadFile(want); err != nil || string(b) != id+".csv,1" { t.Fatalf("%s holds %q (%v)", want, b, err) } - read, err := s.Read(t.Context(), api.SessionIDOptions{ID: "s"}) + read, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"}) if err != nil { t.Fatal(err) } diff --git a/internal/browser/fake_test.go b/internal/browser/fake_test.go index 0a4b483e..b2137877 100644 --- a/internal/browser/fake_test.go +++ b/internal/browser/fake_test.go @@ -3,6 +3,7 @@ package browser import ( "context" "os" + "strings" "sync" "github.com/chromedp/cdproto/network" @@ -54,6 +55,11 @@ type fakePage struct { shotErr error navErr error infoErr error + // pageText is what HasText searches; onTextRead runs before each search, + // as the page changing between two reads. + pageText string + textReads int + onTextRead func(reads int) } type typed struct { @@ -120,6 +126,19 @@ func (p *fakePage) Info(context.Context) (PageInfo, error) { return info, nil } +func (p *fakePage) HasText(_ context.Context, text string) (bool, error) { + p.mu.Lock() + p.textReads++ + reads, then := p.textReads, p.onTextRead + p.mu.Unlock() + if then != nil { + then(reads) + } + p.mu.Lock() + defer p.mu.Unlock() + return strings.Contains(p.pageText, text), nil +} + func (p *fakePage) Click(_ context.Context, t Target) (x, y float64, err error) { p.mu.Lock() if p.clickErr != nil { diff --git a/internal/browser/page.go b/internal/browser/page.go index 622d8e4a..0161597a 100644 --- a/internal/browser/page.go +++ b/internal/browser/page.go @@ -81,6 +81,9 @@ type PageInfo struct { type Page interface { Navigate(ctx context.Context, url string) error Info(ctx context.Context) (PageInfo, error) + // HasText says whether text is in the page's visible text, the whole + // document's and not only the viewport's. + HasText(ctx context.Context, text string) (bool, error) // Settle waits until a navigation the last action may have started has // its document, bounded by domSettle. Settle(ctx context.Context) error diff --git a/internal/browser/rpc.go b/internal/browser/rpc.go index 56d39e2f..e529a752 100644 --- a/internal/browser/rpc.go +++ b/internal/browser/rpc.go @@ -26,10 +26,17 @@ func (s *Service) OpenURL(_ context.Context, opts api.BrowserOpenOptions) (api.B return s.withDownloads(handleAPI(h)), nil } -// Read returns the page the agent is looking at. -func (s *Service) Read(_ context.Context, opts api.SessionIDOptions) (api.BrowserPageInfo, error) { +// Read returns the page the agent is looking at, once the text it waits for +// came or went. A wait that gives up still answers with the page. +func (s *Service) Read(_ context.Context, opts api.BrowserInfoOptions) (api.BrowserPageInfo, error) { + timedOut, err := s.waitText(opts) + if err != nil { + return api.BrowserPageInfo{}, err + } info, err := s.Info(opts.ID) - return infoAPI(info), err + out := infoAPI(info) + out.WaitTimedOut = timedOut + return out, err } // ClickAt clicks in this session's browser. diff --git a/internal/browser/session_downloads_test.go b/internal/browser/session_downloads_test.go index 11f91638..cbbee613 100644 --- a/internal/browser/session_downloads_test.go +++ b/internal/browser/session_downloads_test.go @@ -32,7 +32,7 @@ func TestClickReportsTheFileItDownloadedAndTellsTheWindowOnce(t *testing.T) { if len(done) != 1 || done[0].(api.BrowserDownload).Owner != "s" || done[0].(api.BrowserDownload).Path != "/dl/report.csv" { t.Fatalf("window heard %+v, want the one completed download, owned by s", done) } - again, err := s.Read(t.Context(), api.SessionIDOptions{ID: "s"}) + again, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"}) if err != nil { t.Fatal(err) } @@ -79,7 +79,7 @@ func TestADownloadStillRunningIsReportedAgainWhenItEnds(t *testing.T) { t.Fatalf("got %+v after %v: a download already reported in progress must not hold the next action", got, time.Since(start)) } p.onDownload("g1", api.BrowserDownload{Path: "/dl/big.iso", Bytes: 9, State: api.DownloadCompleted}) - info, err := s.Read(t.Context(), api.SessionIDOptions{ID: "s"}) + info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"}) if err != nil { t.Fatal(err) } diff --git a/internal/browser/wait.go b/internal/browser/wait.go new file mode 100644 index 00000000..7bc60bc4 --- /dev/null +++ b/internal/browser/wait.go @@ -0,0 +1,64 @@ +package browser + +import ( + "context" + "time" + + "github.com/lichdotdev/lich/api" +) + +// textPoll is how often a wait reads the page's text again: short enough that +// the read follows the page by a frame or so, long enough not to keep the +// renderer busy answering. +const textPoll = 100 * time.Millisecond + +// maxTextWait bounds a wait for text, after which the page is read as it +// stands. 20 seconds leaves that read its own pageWait under the client's +// 60-second openCall. +const maxTextWait = 20 * time.Second + +// hasTextScript takes the text as a JSON string literal. +const hasTextScript = `(document.body ? document.body.innerText : '').includes(%s)` + +// waitText holds a read until opts.WaitFor is on the page and opts.WaitGone +// is off it, and says whether it gave up first. +func (s *Service) waitText(opts api.BrowserInfoOptions) (timedOut bool, err error) { + if opts.WaitFor == "" && opts.WaitGone == "" { + return false, nil + } + sess, err := s.live(opts.ID) + if err != nil { + return false, err + } + ctx, cancel := context.WithTimeout(context.Background(), s.textWait) + defer cancel() + tick := time.NewTicker(textPoll) + defer tick.Stop() + // A failed read is the page between two documents, which a click that + // navigates leaves for a moment: the wait reads again. A Chrome that died + // stops it, and the page read after reports that. + for sess.page.Alive() { + if ok, err := textSettled(ctx, sess.page, opts); err == nil && ok { + return false, nil + } + select { + case <-ctx.Done(): + return true, nil + case <-tick.C: + } + } + return false, nil +} + +func textSettled(ctx context.Context, p Page, opts api.BrowserInfoOptions) (bool, error) { + if opts.WaitFor != "" { + if has, err := p.HasText(ctx, opts.WaitFor); err != nil || !has { + return false, err + } + } + if opts.WaitGone == "" { + return true, nil + } + has, err := p.HasText(ctx, opts.WaitGone) + return !has, err +} diff --git a/internal/browser/wait_live_test.go b/internal/browser/wait_live_test.go new file mode 100644 index 00000000..a689eb6b --- /dev/null +++ b/internal/browser/wait_live_test.go @@ -0,0 +1,60 @@ +package browser + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "testing" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/chromium" +) + +// lateTextPage swaps Loading for Saved a moment after it loads, below the +// fold so the wait is seen to read the whole page and not the viewport. +const lateTextPage = `Late
+

Loading

+` + +func TestChromeReadWaitsForTextThePageWritesLater(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, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, lateTextPage) + })) + 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) + } + }) + // One browser per case: each reads the page's text change from its load. + for _, opts := range []api.BrowserInfoOptions{ + {ID: "for", WaitFor: "Saved"}, + {ID: "gone", WaitGone: "Loading"}, + } { + if _, err := s.Open(opts.ID, srv.URL); err != nil { + t.Fatal(err) + } + info, err := s.Read(t.Context(), opts) + if err != nil { + t.Fatal(err) + } + if info.WaitTimedOut { + t.Fatalf("%+v timed out, want the page's late text seen", opts) + } + } + s.textWait = 3 * textPoll + info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "for", WaitFor: "saved"}) + if err != nil { + t.Fatal(err) + } + if !info.WaitTimedOut || info.Title != "Late" { + t.Fatalf("got timed out %v on %q, want the page answered with the wait timed out", info.WaitTimedOut, info.Title) + } +} diff --git a/internal/browser/wait_test.go b/internal/browser/wait_test.go new file mode 100644 index 00000000..a75c25d0 --- /dev/null +++ b/internal/browser/wait_test.go @@ -0,0 +1,103 @@ +package browser + +import ( + "testing" + "time" + + "github.com/lichdotdev/lich/api" +) + +func waitService(t *testing.T) (*Service, *fakePage) { + t.Helper() + launch := &fakeLauncher{} + s := testService(t, launch) + if _, err := s.Open("s", "https://example.com"); err != nil { + t.Fatal(err) + } + return s, launch.pages[0] +} + +func TestReadWaitsForTextToAppear(t *testing.T) { + s, p := waitService(t) + p.onTextRead = func(reads int) { + if reads == 3 { + p.mu.Lock() + p.pageText = "Saved 3 rows" + p.mu.Unlock() + } + } + info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s", WaitFor: "Saved"}) + if err != nil { + t.Fatal(err) + } + if info.WaitTimedOut || p.textReads != 3 { + t.Fatalf("timed out %v after %d reads, want the third read to find it", info.WaitTimedOut, p.textReads) + } + if info.URL != "https://example.com" { + t.Fatalf("answered %q, want the page", info.URL) + } +} + +func TestReadWaitsForTextToGo(t *testing.T) { + s, p := waitService(t) + p.pageText = "Loading…" + p.onTextRead = func(reads int) { + if reads == 2 { + p.mu.Lock() + p.pageText = "Done" + p.mu.Unlock() + } + } + info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s", WaitGone: "Loading"}) + if err != nil { + t.Fatal(err) + } + if info.WaitTimedOut || p.textReads != 2 { + t.Fatalf("timed out %v after %d reads, want the second read to miss it", info.WaitTimedOut, p.textReads) + } +} + +func TestReadThatGivesUpStillAnswersThePage(t *testing.T) { + s, p := waitService(t) + s.textWait = 3 * textPoll + p.pageText = "saved" + start := time.Now() + info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s", WaitFor: "Saved"}) + if err != nil { + t.Fatal(err) + } + if !info.WaitTimedOut || info.URL != "https://example.com" { + t.Fatalf("got %+v, want the page marked as timed out (the match is case-sensitive)", info) + } + if took := time.Since(start); took > time.Second { + t.Fatalf("took %v, want the wait bounded by textWait", took) + } +} + +func TestReadWithoutWaitReadsNoText(t *testing.T) { + s, p := waitService(t) + info, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s"}) + if err != nil { + t.Fatal(err) + } + if info.WaitTimedOut || p.textReads != 0 { + t.Fatalf("timed out %v after %d text reads, want a plain read", info.WaitTimedOut, p.textReads) + } +} + +func TestReadStopsWaitingWhenTheBrowserDies(t *testing.T) { + s, p := waitService(t) + p.onTextRead = func(reads int) { + if reads == 2 { + p.mu.Lock() + p.dead = true + p.mu.Unlock() + } + } + if _, err := s.Read(t.Context(), api.BrowserInfoOptions{ID: "s", WaitFor: "never"}); err == nil { + t.Fatal("a browser that died answered a page") + } + if p.textReads != 2 { + t.Fatalf("read the text %d times, want the wait to stop once Chrome died", p.textReads) + } +} diff --git a/internal/cli/browser.go b/internal/cli/browser.go index 7fa72cc8..002227b9 100644 --- a/internal/cli/browser.go +++ b/internal/cli/browser.go @@ -73,7 +73,7 @@ func (c *client) browserInfo(args []string) error { if err != nil { return err } - info, err := callMethod(context.Background(), c, api.BrowserInfo, api.SessionIDOptions{ID: id}, openCall) + info, err := callMethod(context.Background(), c, api.BrowserInfo, api.BrowserInfoOptions{ID: id}, openCall) if err != nil { return err } diff --git a/internal/cli/browser_page.go b/internal/cli/browser_page.go index 3027899a..2f210133 100644 --- a/internal/cli/browser_page.go +++ b/internal/cli/browser_page.go @@ -17,6 +17,9 @@ import ( func pageText(info api.BrowserPageInfo) string { var b strings.Builder fmt.Fprintf(&b, "url: %s\ntitle: %s\n", info.URL, info.Title) + if info.WaitTimedOut { + b.WriteString("wait: timed out, this is the page as it stands\n") + } for _, d := range info.Downloads { b.WriteString(downloadLine(d)) b.WriteByte('\n') diff --git a/internal/cli/browser_page_test.go b/internal/cli/browser_page_test.go index 7681d331..2faa65d4 100644 --- a/internal/cli/browser_page_test.go +++ b/internal/cli/browser_page_test.go @@ -70,3 +70,11 @@ func TestPageTextNamesTheDownloadsAfterTheTitle(t *testing.T) { t.Fatalf("got\n%s\nwant\n%s", got, want) } } + +func TestPageTextSaysAWaitTimedOutBeforeThePage(t *testing.T) { + info := api.BrowserPageInfo{URL: "https://x.test/", Title: "X", Text: "Loading", WaitTimedOut: true} + want := "url: https://x.test/\ntitle: X\nwait: timed out, this is the page as it stands\ntext: Loading" + 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 6d0109a9..667b8b43 100644 --- a/internal/cli/mcp_browser.go +++ b/internal/cli/mcp_browser.go @@ -42,8 +42,13 @@ 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. Read-only.", - Schema: schema(map[string]any{}), + "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.", + 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."), + }), ReadOnly: true, Run: mcpBrowserInfo, }, @@ -269,12 +274,14 @@ func encodeJSON(v any) (string, error) { return string(b), nil } -func mcpBrowserInfo(ctx context.Context, c *client, _ mcpArgs) (string, error) { +func mcpBrowserInfo(ctx context.Context, c *client, a mcpArgs) (string, error) { id, err := c.browserOwner() if err != nil { return "", err } - info, err := callMethod(ctx, c, api.BrowserInfo, api.SessionIDOptions{ID: id}, openCall) + info, err := callMethod(ctx, c, api.BrowserInfo, api.BrowserInfoOptions{ + ID: id, WaitFor: a.text("wait_for"), WaitGone: a.text("wait_gone"), + }, openCall) if err != nil { return "", err } From df9ef633bd00342a1d562e60ba3129f6fe533388 Mon Sep 17 00:00:00 2001 From: omartelo Date: Tue, 27 Oct 2026 20:40:17 -0300 Subject: [PATCH 2/2] test(browser): remove the wait test's Chrome profiles after Chrome exits On Windows the TempDir cleanup registered inside mkTmp ran before Service.Cleanup closed Chrome, and failed on a file Chrome still held. --- internal/browser/wait_live_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/browser/wait_live_test.go b/internal/browser/wait_live_test.go index a689eb6b..d0e8654b 100644 --- a/internal/browser/wait_live_test.go +++ b/internal/browser/wait_live_test.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "os/exec" "testing" "time" @@ -27,7 +28,10 @@ func TestChromeReadWaitsForTextThePageWritesLater(t *testing.T) { fmt.Fprint(w, lateTextPage) })) t.Cleanup(srv.Close) - s := newService(chromeLaunch, func(string, string) (string, error) { return t.TempDir(), nil }, time.Now, idleAfter) + // The profiles' root is made before Cleanup is registered, so it is + // removed after Chrome exits: Windows refuses to delete a file it holds. + root := t.TempDir() + s := newService(chromeLaunch, func(_, pattern string) (string, error) { return os.MkdirTemp(root, pattern) }, time.Now, idleAfter) t.Cleanup(func() { if err := s.Cleanup(); err != nil { t.Error(err)