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