From f1974e9f882d6f3ff7a1a14d201a28e95eec4282 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 13 Aug 2026 00:53:56 +0530 Subject: [PATCH] fix(web): settle on dom quiescence instead of returning at body ready --- internal/driver/chrome/driver.go | 56 +++++++++++++++++++- internal/driver/chrome/driver_test.go | 73 +++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index a56c985..4a75826 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -536,10 +536,62 @@ func (d *Driver) RecentLogs(_ context.Context, since time.Time, minLevel string) return result, nil } -func (d *Driver) WaitForIdle(ctx context.Context, _ time.Duration) error { +// domQuietPeriod is how long the DOM must stop changing before the page counts +// as settled. Compose for Web syncs its accessibility DOM off the frame loop: +// measured at ~136 ms behind an InputText on the folio wasm build, so waiting +// for frames alone (~16 ms each) returns while the app still reports the old +// text, and the next step types into a field it believes is still empty. +const domQuietPeriod = 150 * time.Millisecond + +func (d *Driver) WaitForIdle(ctx context.Context, timeout time.Duration) error { runCtx, cancel := d.runCtx(ctx) defer cancel() - return chromedp.Run(runCtx, chromedp.WaitReady("body", chromedp.ByQuery)) + // Leave the caller's deadline some room: returning late by our own doing + // would surface as a context cancellation instead of a settled page. + budget := timeout - 100*time.Millisecond + if budget < domQuietPeriod { + budget = domQuietPeriod + } + script := fmt.Sprintf(settleScript, domQuietPeriod.Milliseconds(), budget.Milliseconds()) + return chromedp.Run(runCtx, + chromedp.WaitReady("body", chromedp.ByQuery), + chromedp.Evaluate(script, nil, awaitPromise), + ) +} + +// settleScript resolves once the document has gone quiet for %d ms, or after +// %d ms whatever happens. Shadow roots get their own observer: a canvas app +// keeps its whole accessibility tree inside one, and mutations there do not +// reach an observer on the document. +const settleScript = ` +new Promise(resolve => { + const quietMillis = %d, budgetMillis = %d; + const observers = []; + let timer = null; + const finish = () => { + clearTimeout(timer); + for (const observer of observers) observer.disconnect(); + resolve(); + }; + const restart = () => { + clearTimeout(timer); + timer = setTimeout(finish, quietMillis); + }; + const watch = (root) => { + const observer = new MutationObserver(restart); + observer.observe(root, {subtree: true, childList: true, attributes: true, characterData: true}); + observers.push(observer); + for (const element of root.querySelectorAll('*')) { + if (element.shadowRoot) watch(element.shadowRoot); + } + }; + watch(document); + setTimeout(finish, budgetMillis); + restart(); +})` + +func awaitPromise(params *runtime.EvaluateParams) *runtime.EvaluateParams { + return params.WithAwaitPromise(true) } func (d *Driver) Health(_ context.Context) (driver.Health, error) { diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go index 84fee8d..83c3e27 100644 --- a/internal/driver/chrome/driver_test.go +++ b/internal/driver/chrome/driver_test.go @@ -590,3 +590,76 @@ func TestHierarchy_ScreenFallsBackToThePathname(t *testing.T) { }) } } + +// TestWaitForIdle_WaitsForWorkTheActionKickedOff pins the settle the runner +// relies on between acting and observing. WaitForIdle used to return the moment +// existed, which is true before the app has reacted at all: measured on +// the folio wasm build, Compose's accessibility DOM lands ~136 ms after an +// InputText, so the next step read the pre-action text and typed into a field +// it believed was still empty. +func TestWaitForIdle_WaitsForWorkTheActionKickedOff(t *testing.T) { + const page = `
` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(page)) + })) + defer server.Close() + + d := New() + defer d.Terminate(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := d.Launch(ctx, server.URL, false, nil); err != nil { + t.Fatalf("Launch: %v", err) + } + if err := d.Tap(ctx, 40, 40); err != nil { + t.Fatalf("Tap: %v", err) + } + if err := d.WaitForIdle(ctx, time.Second); err != nil { + t.Fatalf("WaitForIdle: %v", err) + } + + var shown string + script := `document.getElementById("app").shadowRoot.getElementById("out").textContent` + if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil { + t.Fatalf("read: %v", err) + } + if shown != "settled" { + t.Errorf("observed %q; WaitForIdle returned before the tap's own work landed", shown) + } +} + +// TestWaitForIdle_ReturnsOnABusyPage is the other half: a page that never stops +// mutating (an animation, a polling widget) must not hold the step loop open. +func TestWaitForIdle_ReturnsOnABusyPage(t *testing.T) { + const page = `
0
` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(page)) + })) + defer server.Close() + + d := New() + defer d.Terminate(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := d.Launch(ctx, server.URL, false, nil); err != nil { + t.Fatalf("Launch: %v", err) + } + start := time.Now() + if err := d.WaitForIdle(ctx, time.Second); err != nil { + t.Fatalf("WaitForIdle: %v", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("WaitForIdle took %s on a busy page; it must return inside its budget", elapsed) + } +}