From 871a56d148da6f8218dbfa939ddfa38af847bec5 Mon Sep 17 00:00:00 2001 From: PJ Date: Thu, 13 Aug 2026 00:45:57 +0530 Subject: [PATCH] fix(web): select the focused field inside a shadow root before typing --- internal/driver/chrome/driver.go | 35 +++++++++----- internal/driver/chrome/driver_test.go | 70 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index 363e9b2..002e35e 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -256,12 +256,7 @@ func (d *Driver) InputText(callerCtx context.Context, text string) error { defer cancel() return chromedp.Run(runCtx, chromedp.ActionFunc(func(ctx context.Context) error { - // Select any existing content so InsertText replaces rather than appends. - if err := chromedp.Evaluate(` - (function() { - const el = document.activeElement; - if (el && typeof el.select === 'function') el.select(); - })()`, nil).Do(ctx); err != nil { + if err := selectFocusedText(ctx); err != nil { return err } return input.InsertText(text).Do(ctx) @@ -269,6 +264,28 @@ func (d *Driver) InputText(callerCtx context.Context, text string) error { ) } +// selectAllScript selects everything in the focused field so the InsertText +// that follows replaces rather than appends. +// +// document.activeElement stops at a shadow boundary: it names the HOST, not the +// focused node inside. Compose for Web focuses a hidden inside the +// shadow root it mounts, so the host answer has no select() and the selection +// never happened - every InputText appended to the last one, and a fuzzer that +// types into the same field twice built up garbage it could never clear. +// Descending activeElement through each shadow root finds the real field. +const selectAllScript = ` + (function() { + let el = document.activeElement; + while (el && el.shadowRoot && el.shadowRoot.activeElement) { + el = el.shadowRoot.activeElement; + } + if (el && typeof el.select === 'function') el.select(); + })()` + +func selectFocusedText(ctx context.Context) error { + return chromedp.Evaluate(selectAllScript, nil).Do(ctx) +} + // ReplacesTextOnInput reports that InputText replaces existing content via // select-all, so the runner skips its pre-erase. func (d *Driver) ReplacesTextOnInput() bool { @@ -282,11 +299,7 @@ func (d *Driver) EraseText(callerCtx context.Context, _ int) error { defer cancel() return chromedp.Run(runCtx, chromedp.ActionFunc(func(ctx context.Context) error { - if err := chromedp.Evaluate(` - (function() { - const el = document.activeElement; - if (el && typeof el.select === 'function') el.select(); - })()`, nil).Do(ctx); err != nil { + if err := selectFocusedText(ctx); err != nil { return err } return input.InsertText("").Do(ctx) diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go index d561f6f..f779ed3 100644 --- a/internal/driver/chrome/driver_test.go +++ b/internal/driver/chrome/driver_test.go @@ -474,3 +474,73 @@ func TestLaunch_KeepsBrowserAliveAfterCallerContextEnds(t *testing.T) { t.Fatalf("second Launch after the first caller context ended: %v", err) } } + +// TestInputText_ReplacesTextInsideAShadowRoot pins ReplacesTextOnInput's promise +// on the shape a canvas app actually has. Compose for Web draws its text fields +// on a canvas and routes typing through a hidden INSIDE the shadow root +// it mounts, and document.activeElement stops at a shadow boundary: it names the +// host. The select-all therefore ran against a
with no select(), every +// InputText appended to the last, and a fuzzer typing twice into one field built +// up text it could never clear (observed on the folio wasm build as +// "0.0000001" -> "0.0000001\t-1"). +func TestInputText_ReplacesTextInsideAShadowRoot(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.InputText(ctx, "alpha"); err != nil { + t.Fatalf("InputText: %v", err) + } + if err := d.InputText(ctx, "beta"); err != nil { + t.Fatalf("InputText: %v", err) + } + + var shown string + script := `document.getElementById("app").shadowRoot.getElementById("field").textContent` + if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil { + t.Fatalf("read field: %v", err) + } + if shown != "beta" { + t.Errorf("field holds %q, want %q; the second InputText appended instead of replacing", shown, "beta") + } + + if err := d.EraseText(ctx, len("beta")); err != nil { + t.Fatalf("EraseText: %v", err) + } + if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &shown)); err != nil { + t.Fatalf("read field: %v", err) + } + if shown != "" { + t.Errorf("field holds %q after EraseText, want empty", shown) + } +}