diff --git a/README.md b/README.md index a99a776..cfa1a31 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,8 @@ Example output for `agent status`: Inspect and interact with embedded webviews (`WKWebView` on iOS, `android.webkit.WebView` on Android) running inside native apps. +On Android the same commands also drive the tabs of Chrome when it is the foreground app, over the Chrome DevTools Protocol. Chrome freezes the tabs it is not showing, so only the visible tab (`isVisible: true`) answers `eval` and the commands built on it. + On iOS, real devices and simulators alike, the same commands also drive the tabs of Safari when it is the foreground app, through the device's Web Inspector service. A real device needs **Settings > Apps > Safari > Advanced > Web Inspector** turned on. Safari does not keep the tabs it is not showing running: a command on such a tab fails after 2 seconds, so use the tab listed with `isVisible: true`. ```bash diff --git a/cli/webview.go b/cli/webview.go index 43efac7..d4b7d9c 100644 --- a/cli/webview.go +++ b/cli/webview.go @@ -14,7 +14,7 @@ var webviewCmd = &cobra.Command{ var webviewListCmd = &cobra.Command{ Use: "list", Short: "List embedded webviews on a device", - Long: `Returns all embedded webviews currently visible in the foreground app. When the foreground app is Safari on iOS, returns its tabs instead. Chrome is not included.`, + Long: `Returns all embedded webviews currently visible in the foreground app. When the foreground app is Chrome on Android or Safari on iOS, returns its tabs instead.`, RunE: func(cmd *cobra.Command, args []string) error { return runViaDaemon("cli.webview.list", commands.WebViewListRequest{ DeviceID: deviceId, diff --git a/devices/android_cdp.go b/devices/android_cdp.go new file mode 100644 index 0000000..9dd140d --- /dev/null +++ b/devices/android_cdp.go @@ -0,0 +1,358 @@ +package devices + +import ( + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "regexp" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/mobile-next/mobilecli/utils" +) + +// A browser cannot take the injected agent: it is not a debuggable build. It +// does serve the Chrome DevTools Protocol on an abstract unix socket, so its +// tabs are driven through that instead, behind the same webview commands. + +var errWebViewNotFound = errors.New("webview not found") + +const ( + cdpCommandTimeout = 30 * time.Second + // the discovery endpoints are plain local http, and a socket that is not a + // browser after all must not hold up the search for the one that is + cdpDiscoveryTimeout = 5 * time.Second + // a tab that does not answer is reported as hidden instead of holding up the list + cdpVisibilityTimeout = 2 * time.Second + cdpLoadStatePollEvery = 200 * time.Millisecond +) + +// devtoolsSocketPattern matches the abstract sockets Chromium names after its +// DevTools server, e.g. chrome_devtools_remote or webview_devtools_remote_. +var devtoolsSocketPattern = regexp.MustCompile(`@(\S*_devtools_remote(?:_\d+)?)$`) + +// devtoolsSocketNames picks the DevTools sockets out of /proc/net/unix. +func devtoolsSocketNames(procNetUnix string) []string { + names := []string{} + for _, line := range strings.Split(procNetUnix, "\n") { + if match := devtoolsSocketPattern.FindStringSubmatch(strings.TrimSpace(line)); match != nil { + names = append(names, match[1]) + } + } + return names +} + +// cdpTarget is one entry of the browser's /json/list. +type cdpTarget struct { + ID string `json:"id"` + Type string `json:"type"` + URL string `json:"url"` + Title string `json:"title"` +} + +// cdpBrowser is a DevTools server reachable at addr (host:port), and drives +// its tabs as webviews. +type cdpBrowser struct { + addr string +} + +func (b cdpBrowser) getJSON(path string, into any) error { + client := &http.Client{Timeout: cdpDiscoveryTimeout} + resp, err := client.Get("http://" + b.addr + path) + if err != nil { + return fmt.Errorf("devtools %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("devtools %s: unexpected status %s", path, resp.Status) + } + if err := json.NewDecoder(resp.Body).Decode(into); err != nil { + return fmt.Errorf("parse devtools %s: %w", path, err) + } + return nil +} + +// packageName returns the Android package the DevTools server belongs to. +func (b cdpBrowser) packageName() (string, error) { + var version struct { + AndroidPackage string `json:"Android-Package"` + } + if err := b.getJSON("/json/version", &version); err != nil { + return "", err + } + return version.AndroidPackage, nil +} + +// call sends one protocol command to a tab and returns its result. +func (b cdpBrowser) call(webviewID, method string, params any, timeout time.Duration) (json.RawMessage, error) { + dialer := websocket.Dialer{HandshakeTimeout: timeout} + conn, resp, err := dialer.Dial("ws://"+b.addr+"/devtools/page/"+webviewID, nil) + if err != nil { + // the browser answers the handshake with an http error for a tab it does not have + if resp != nil { + return nil, fmt.Errorf("%w: %s", errWebViewNotFound, webviewID) + } + return nil, fmt.Errorf("connect to webview %s: %w", webviewID, err) + } + defer func() { _ = conn.Close() }() + + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + _ = conn.SetWriteDeadline(time.Now().Add(timeout)) + + const commandID = 1 + command := map[string]any{"id": commandID, "method": method} + if params != nil { + command["params"] = params + } + if err := conn.WriteJSON(command); err != nil { + return nil, fmt.Errorf("send %s: %w", method, err) + } + + // events arrive on the same connection; skip everything that is not our reply + for { + var message struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := conn.ReadJSON(&message); err != nil { + if netErr := net.Error(nil); errors.As(err, &netErr) && netErr.Timeout() { + return nil, fmt.Errorf("webview %s did not answer %s within %s; a browser freezes the tabs it is not showing", webviewID, method, timeout.Round(time.Millisecond)) + } + return nil, fmt.Errorf("read %s reply: %w", method, err) + } + if message.ID != commandID { + continue + } + if message.Error != nil { + return nil, fmt.Errorf("%s: %s", method, message.Error.Message) + } + return message.Result, nil + } +} + +func (b cdpBrowser) ListWebViews() ([]WebViewInfo, error) { + packageName, err := b.packageName() + if err != nil { + return nil, err + } + var targets []cdpTarget + if err := b.getJSON("/json/list", &targets); err != nil { + return nil, err + } + + webviews := []WebViewInfo{} + for _, target := range targets { + if target.Type != "page" { + continue + } + webviews = append(webviews, WebViewInfo{ + ID: target.ID, + URL: target.URL, + Title: target.Title, + BundleID: packageName, + ProcessName: packageName, + }) + } + + // the tab list does not say which tab is in front, so ask each one + var wg sync.WaitGroup + for i := range webviews { + wg.Add(1) + go func(webview *WebViewInfo) { + defer wg.Done() + webview.IsVisible = b.isVisible(webview.ID) + }(&webviews[i]) + } + wg.Wait() + + return webviews, nil +} + +func (b cdpBrowser) isVisible(webviewID string) bool { + visible, err := b.evaluate(webviewID, "return document.visibilityState === 'visible'", nil, cdpVisibilityTimeout) + return err == nil && visible == true +} + +func (b cdpBrowser) WebViewGoto(webviewID, url string) error { + raw, err := b.call(webviewID, "Page.navigate", map[string]any{"url": url}, cdpCommandTimeout) + if err != nil { + return err + } + var navigation struct { + ErrorText string `json:"errorText"` + } + if err := json.Unmarshal(raw, &navigation); err != nil { + return fmt.Errorf("parse Page.navigate result: %w", err) + } + if navigation.ErrorText != "" { + return fmt.Errorf("navigate to %s: %s", url, navigation.ErrorText) + } + return nil +} + +func (b cdpBrowser) WebViewReload(webviewID string) error { + _, err := b.call(webviewID, "Page.reload", nil, cdpCommandTimeout) + return err +} + +func (b cdpBrowser) WebViewGoBack(webviewID string) error { + return b.navigateHistory(webviewID, -1) +} + +func (b cdpBrowser) WebViewGoForward(webviewID string) error { + return b.navigateHistory(webviewID, 1) +} + +// navigateHistory moves offset entries through the tab's history, and like an +// embedded WebView does nothing when there is no such entry. +func (b cdpBrowser) navigateHistory(webviewID string, offset int) error { + raw, err := b.call(webviewID, "Page.getNavigationHistory", nil, cdpCommandTimeout) + if err != nil { + return err + } + var history struct { + CurrentIndex int `json:"currentIndex"` + Entries []struct { + ID int `json:"id"` + } `json:"entries"` + } + if err := json.Unmarshal(raw, &history); err != nil { + return fmt.Errorf("parse navigation history: %w", err) + } + + index := history.CurrentIndex + offset + if index < 0 || index >= len(history.Entries) { + return nil + } + _, err = b.call(webviewID, "Page.navigateToHistoryEntry", map[string]any{"entryId": history.Entries[index].ID}, cdpCommandTimeout) + return err +} + +func (b cdpBrowser) WebViewContent(webviewID string) (string, error) { + result, err := b.WebViewEvaluate(webviewID, "return document.documentElement.outerHTML", nil) + if err != nil { + return "", err + } + content, ok := result.(string) + if !ok { + return "", fmt.Errorf("unexpected content type %T", result) + } + return content, nil +} + +func (b cdpBrowser) WebViewEvaluate(webviewID, expression string, args []any) (any, error) { + return b.evaluate(webviewID, expression, args, cdpCommandTimeout) +} + +// evaluate runs expression the way the injected agent does: as the body of a +// function called with args, whose returned promise is awaited. +func (b cdpBrowser) evaluate(webviewID, expression string, args []any, timeout time.Duration) (any, error) { + if args == nil { + args = []any{} + } + argsJSON, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("marshal evaluate args: %w", err) + } + + raw, err := b.call(webviewID, "Runtime.evaluate", map[string]any{ + "expression": "(function(){\n" + ensureReturnExpression(expression) + "\n}).apply(null, " + string(argsJSON) + ")", + "awaitPromise": true, + "returnByValue": true, + }, timeout) + if err != nil { + return nil, err + } + + var evaluation struct { + Result struct { + Value any `json:"value"` + } `json:"result"` + ExceptionDetails *struct { + Text string `json:"text"` + Exception struct { + Description string `json:"description"` + Value any `json:"value"` + } `json:"exception"` + } `json:"exceptionDetails"` + } + if err := json.Unmarshal(raw, &evaluation); err != nil { + return nil, fmt.Errorf("parse evaluate result: %w", err) + } + + if details := evaluation.ExceptionDetails; details != nil { + // the description of an Error carries its stack; the first line is the message + if description, _, _ := strings.Cut(details.Exception.Description, "\n"); description != "" { + return nil, errors.New(description) + } + if details.Exception.Value != nil { + return nil, fmt.Errorf("%v", details.Exception.Value) + } + return nil, errors.New(details.Text) + } + return evaluation.Result.Value, nil +} + +func (b cdpBrowser) WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error { + if state == "" { + state = "load" + } + if timeoutMs <= 0 { + timeoutMs = webViewAgentDefaultTimeoutMs + } + hasLoaded := "return document.readyState === 'complete'" + if state == "domcontentloaded" { + hasLoaded = "return document.readyState === 'interactive' || document.readyState === 'complete'" + } + + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + for { + // a poll on a tab that never answers must not outlive the wait + pollTimeout := min(time.Until(deadline), cdpCommandTimeout) + if pollTimeout <= 0 { + return fmt.Errorf("waitForLoadState timed out waiting for '%s'", state) + } + // an evaluation fails while the tab swaps documents mid-navigation, so + // only an unknown tab ends the wait early + loaded, err := b.evaluate(webviewID, hasLoaded, nil, pollTimeout) + if errors.Is(err, errWebViewNotFound) { + return err + } + if err == nil && loaded == true { + return nil + } + if time.Now().After(deadline) { + if err != nil { + return fmt.Errorf("waitForLoadState timed out waiting for '%s': %w", state, err) + } + return fmt.Errorf("waitForLoadState timed out waiting for '%s'", state) + } + time.Sleep(cdpLoadStatePollEvery) + } +} + +// findDevtoolsBrowser returns the DevTools server that belongs to pkg. The +// socket names do not say which app serves them, so each is connected and +// asked; connections to other apps are released again. +func findDevtoolsBrowser(pkg string, sockets []string, connect func(socket string) (cdpBrowser, func(), error)) (cdpBrowser, bool) { + for _, socket := range sockets { + browser, release, err := connect(socket) + if err != nil { + utils.Verbose("failed to connect to devtools socket %s: %v", socket, err) + continue + } + if owner, err := browser.packageName(); err == nil && owner == pkg { + return browser, true + } + release() + } + return cdpBrowser{}, false +} diff --git a/devices/android_cdp_test.go b/devices/android_cdp_test.go new file mode 100644 index 0000000..b377891 --- /dev/null +++ b/devices/android_cdp_test.go @@ -0,0 +1,555 @@ +package devices + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +type fakeChromeCall struct { + TargetID string + Method string + Params map[string]any +} + +// fakeChrome serves the DevTools endpoints of a browser on a device: the http +// discovery ones, and one websocket per tab that answers protocol commands. +type fakeChrome struct { + t *testing.T + server *httptest.Server + packageName string + targets []cdpTarget + // answer returns the result of a protocol command, or a protocol error message + answer func(call fakeChromeCall) (result any, protocolError string) + + mu sync.Mutex + calls []fakeChromeCall +} + +func startFakeChrome(t *testing.T, targets ...cdpTarget) *fakeChrome { + t.Helper() + chrome := &fakeChrome{t: t, packageName: "com.android.chrome", targets: targets} + chrome.answer = func(fakeChromeCall) (any, string) { return map[string]any{}, "" } + + mux := http.NewServeMux() + mux.HandleFunc("/json/version", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{"Android-Package": chrome.packageName, "Browser": "Chrome/154.0"}) + }) + mux.HandleFunc("/json/list", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(chrome.targets) + }) + mux.HandleFunc("/devtools/page/", chrome.serveTab) + + chrome.server = httptest.NewServer(mux) + t.Cleanup(chrome.server.Close) + return chrome +} + +func (c *fakeChrome) serveTab(w http.ResponseWriter, r *http.Request) { + targetID := strings.TrimPrefix(r.URL.Path, "/devtools/page/") + if !c.hasTarget(targetID) { + http.Error(w, "No such target id: "+targetID, http.StatusNotFound) + return + } + + upgrader := websocket.Upgrader{} + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + for { + var command struct { + ID int `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if err := conn.ReadJSON(&command); err != nil { + return + } + + call := fakeChromeCall{TargetID: targetID, Method: command.Method, Params: command.Params} + c.mu.Lock() + c.calls = append(c.calls, call) + c.mu.Unlock() + + // a real browser interleaves events with replies, so send one first + _ = conn.WriteJSON(map[string]any{"method": "Page.frameNavigated", "params": map[string]any{}}) + + result, protocolError := c.answer(call) + reply := map[string]any{"id": command.ID, "result": result} + if protocolError != "" { + reply = map[string]any{"id": command.ID, "error": map[string]any{"code": -32000, "message": protocolError}} + } + _ = conn.WriteJSON(reply) + } +} + +func (c *fakeChrome) hasTarget(id string) bool { + for _, target := range c.targets { + if target.ID == id { + return true + } + } + return false +} + +func (c *fakeChrome) browser() cdpBrowser { + return cdpBrowser{addr: strings.TrimPrefix(c.server.URL, "http://")} +} + +func (c *fakeChrome) callsOf(method string) []fakeChromeCall { + c.mu.Lock() + defer c.mu.Unlock() + var matching []fakeChromeCall + for _, call := range c.calls { + if call.Method == method { + matching = append(matching, call) + } + } + return matching +} + +func (c *fakeChrome) onlyCallOf(method string) fakeChromeCall { + c.t.Helper() + calls := c.callsOf(method) + if len(calls) != 1 { + c.t.Fatalf("expected exactly one %s call, got %d", method, len(calls)) + } + return calls[0] +} + +// evaluatesTo makes every Runtime.evaluate return the given javascript value +func (c *fakeChrome) evaluatesTo(value any) { + c.answer = func(fakeChromeCall) (any, string) { + return map[string]any{"result": map[string]any{"type": "object", "value": value}}, "" + } +} + +func (c *fakeChrome) hasHistory(currentIndex int, entryIDs ...int) { + entries := []map[string]any{} + for _, id := range entryIDs { + entries = append(entries, map[string]any{"id": id}) + } + c.answer = func(call fakeChromeCall) (any, string) { + if call.Method == "Page.getNavigationHistory" { + return map[string]any{"currentIndex": currentIndex, "entries": entries}, "" + } + return map[string]any{}, "" + } +} + +var exampleTab = cdpTarget{ID: "TAB1", Type: "page", URL: "https://example.com/", Title: "Example Domain"} + +func TestDevtoolsSocketsAreFoundAmongTheUnixSocketsOfTheDevice(t *testing.T) { + procNetUnix := `Num RefCount Protocol Flags Type St Inode Path +0000000000000000: 00000002 00000000 00010000 0001 01 452744 @chrome_devtools_remote +0000000000000000: 00000002 00000000 00010000 0001 01 383406 @stetho_com.example_devtools_remote +0000000000000000: 00000002 00000000 00010000 0001 01 11111 @webview_devtools_remote_4242 +0000000000000000: 00000003 00000000 00000000 0001 03 22222 /dev/socket/logdw +0000000000000000: 00000002 00000000 00010000 0001 01 33333 @jdwp-control +` + + sockets := devtoolsSocketNames(procNetUnix) + + expected := []string{"chrome_devtools_remote", "stetho_com.example_devtools_remote", "webview_devtools_remote_4242"} + if !reflect.DeepEqual(sockets, expected) { + t.Fatalf("expected %v, got %v", expected, sockets) + } +} + +func TestBrowserReportsTheAndroidPackageItBelongsTo(t *testing.T) { + chrome := startFakeChrome(t) + + packageName, err := chrome.browser().packageName() + + if err != nil { + t.Fatalf("packageName: %v", err) + } + if packageName != "com.android.chrome" { + t.Fatalf("unexpected package %q", packageName) + } +} + +func TestListingWebViewsReturnsTheTabsOfTheBrowser(t *testing.T) { + serviceWorker := cdpTarget{ID: "SW1", Type: "service_worker", URL: "https://example.com/sw.js"} + chrome := startFakeChrome(t, exampleTab, serviceWorker) + chrome.evaluatesTo(true) + + webviews, err := chrome.browser().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + expected := []WebViewInfo{{ + ID: "TAB1", + URL: "https://example.com/", + Title: "Example Domain", + BundleID: "com.android.chrome", + ProcessName: "com.android.chrome", + IsVisible: true, + }} + if !reflect.DeepEqual(webviews, expected) { + t.Fatalf("expected %+v, got %+v", expected, webviews) + } +} + +func TestBackgroundTabIsListedAsNotVisible(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.evaluatesTo(false) + + webviews, err := chrome.browser().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + if len(webviews) != 1 || webviews[0].IsVisible { + t.Fatalf("expected one hidden tab, got %+v", webviews) + } +} + +func TestBrowserWithoutTabsListsNoWebViews(t *testing.T) { + chrome := startFakeChrome(t) + + webviews, err := chrome.browser().ListWebViews() + + if err != nil { + t.Fatalf("ListWebViews: %v", err) + } + if webviews == nil || len(webviews) != 0 { + t.Fatalf("expected an empty list, got %#v", webviews) + } +} + +func TestEvaluateReturnsTheJavascriptValue(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.evaluatesTo(map[string]any{"title": "Example Domain"}) + + value, err := chrome.browser().WebViewEvaluate("TAB1", "return {title: document.title}", nil) + + if err != nil { + t.Fatalf("WebViewEvaluate: %v", err) + } + if !reflect.DeepEqual(value, map[string]any{"title": "Example Domain"}) { + t.Fatalf("unexpected value %#v", value) + } +} + +// The agent runs the expression as a function body and awaits what it returns, +// so the same expression has to mean the same thing in a browser tab. +func TestEvaluateRunsTheExpressionAsAFunctionBodyAndAwaitsIt(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + + _, err := chrome.browser().WebViewEvaluate("TAB1", "document.title", []any{"first", 2}) + + if err != nil { + t.Fatalf("WebViewEvaluate: %v", err) + } + params := chrome.onlyCallOf("Runtime.evaluate").Params + expectedExpression := "(function(){\nreturn (document.title)\n}).apply(null, [\"first\",2])" + if params["expression"] != expectedExpression { + t.Fatalf("unexpected expression %q", params["expression"]) + } + if params["awaitPromise"] != true || params["returnByValue"] != true { + t.Fatalf("expected the promise to be awaited and returned by value, got %v", params) + } +} + +func TestEvaluateOfUndefinedReturnsNil(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.answer = func(fakeChromeCall) (any, string) { + return map[string]any{"result": map[string]any{"type": "undefined"}}, "" + } + + value, err := chrome.browser().WebViewEvaluate("TAB1", "return undefined", nil) + + if err != nil || value != nil { + t.Fatalf("expected nil without error, got %#v, %v", value, err) + } +} + +func TestEvaluateFailsWithTheMessageOfTheThrownError(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.answer = func(fakeChromeCall) (any, string) { + return map[string]any{ + "result": map[string]any{"type": "object", "subtype": "error"}, + "exceptionDetails": map[string]any{ + "text": "Uncaught", + "exception": map[string]any{"description": "Error: boom\n at :2:7"}, + }, + }, "" + } + + _, err := chrome.browser().WebViewEvaluate("TAB1", "throw new Error('boom')", nil) + + if err == nil || err.Error() != "Error: boom" { + t.Fatalf("expected the thrown error, got %v", err) + } +} + +func TestCommandOnAnUnknownWebViewFailsAsNotFound(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + + err := chrome.browser().WebViewReload("NO-SUCH-TAB") + + if !errors.Is(err, errWebViewNotFound) || !strings.Contains(err.Error(), "NO-SUCH-TAB") { + t.Fatalf("expected a not found error naming the webview, got %v", err) + } +} + +func TestProtocolErrorFailsTheCommand(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.answer = func(fakeChromeCall) (any, string) { return nil, "Not allowed" } + + err := chrome.browser().WebViewReload("TAB1") + + if err == nil || !strings.Contains(err.Error(), "Not allowed") { + t.Fatalf("expected the protocol error, got %v", err) + } +} + +func TestGotoNavigatesTheTabToTheUrl(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + + err := chrome.browser().WebViewGoto("TAB1", "https://mobilewright.dev/") + + if err != nil { + t.Fatalf("WebViewGoto: %v", err) + } + if url := chrome.onlyCallOf("Page.navigate").Params["url"]; url != "https://mobilewright.dev/" { + t.Fatalf("navigated to %v", url) + } +} + +func TestGotoFailsWhenTheBrowserCannotStartTheNavigation(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.answer = func(fakeChromeCall) (any, string) { + return map[string]any{"frameId": "F1", "errorText": "net::ERR_NAME_NOT_RESOLVED"}, "" + } + + err := chrome.browser().WebViewGoto("TAB1", "https://no-such-host.invalid/") + + if err == nil || !strings.Contains(err.Error(), "net::ERR_NAME_NOT_RESOLVED") { + t.Fatalf("expected the navigation error, got %v", err) + } +} + +func TestReloadReloadsTheTab(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + + err := chrome.browser().WebViewReload("TAB1") + + if err != nil { + t.Fatalf("WebViewReload: %v", err) + } + chrome.onlyCallOf("Page.reload") +} + +func TestGoBackNavigatesToThePreviousHistoryEntry(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.hasHistory(1, 10, 20, 30) + + err := chrome.browser().WebViewGoBack("TAB1") + + if err != nil { + t.Fatalf("WebViewGoBack: %v", err) + } + if entry := chrome.onlyCallOf("Page.navigateToHistoryEntry").Params["entryId"]; entry != float64(10) { + t.Fatalf("navigated to history entry %v", entry) + } +} + +func TestGoForwardNavigatesToTheNextHistoryEntry(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.hasHistory(1, 10, 20, 30) + + err := chrome.browser().WebViewGoForward("TAB1") + + if err != nil { + t.Fatalf("WebViewGoForward: %v", err) + } + if entry := chrome.onlyCallOf("Page.navigateToHistoryEntry").Params["entryId"]; entry != float64(30) { + t.Fatalf("navigated to history entry %v", entry) + } +} + +// An embedded WebView ignores goBack when there is nowhere to go back to. +func TestGoBackOnTheFirstHistoryEntryDoesNothing(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.hasHistory(0, 10, 20) + + err := chrome.browser().WebViewGoBack("TAB1") + + if err != nil { + t.Fatalf("WebViewGoBack: %v", err) + } + if navigations := chrome.callsOf("Page.navigateToHistoryEntry"); len(navigations) != 0 { + t.Fatalf("expected no navigation, got %v", navigations) + } +} + +func TestGoForwardOnTheLastHistoryEntryDoesNothing(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.hasHistory(1, 10, 20) + + err := chrome.browser().WebViewGoForward("TAB1") + + if err != nil { + t.Fatalf("WebViewGoForward: %v", err) + } + if navigations := chrome.callsOf("Page.navigateToHistoryEntry"); len(navigations) != 0 { + t.Fatalf("expected no navigation, got %v", navigations) + } +} + +func TestContentReturnsTheHtmlOfTheDocument(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.evaluatesTo("hi") + + content, err := chrome.browser().WebViewContent("TAB1") + + if err != nil { + t.Fatalf("WebViewContent: %v", err) + } + if content != "hi" { + t.Fatalf("unexpected content %q", content) + } +} + +func TestWaitReturnsOnceTheDocumentHasLoaded(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + polls := 0 + chrome.answer = func(fakeChromeCall) (any, string) { + polls++ + return map[string]any{"result": map[string]any{"type": "boolean", "value": polls >= 3}}, "" + } + + err := chrome.browser().WebViewWaitForLoadState("TAB1", "load", 5000) + + if err != nil { + t.Fatalf("WebViewWaitForLoadState: %v", err) + } + if polls != 3 { + t.Fatalf("expected to stop polling once loaded, polled %d times", polls) + } +} + +func TestWaitForDomContentLoadedAlsoAcceptsAnInteractiveDocument(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.evaluatesTo(true) + + err := chrome.browser().WebViewWaitForLoadState("TAB1", "domcontentloaded", 5000) + + if err != nil { + t.Fatalf("WebViewWaitForLoadState: %v", err) + } + expression := chrome.onlyCallOf("Runtime.evaluate").Params["expression"].(string) + if !strings.Contains(expression, "'interactive'") { + t.Fatalf("expected the interactive state to be accepted, evaluated %q", expression) + } +} + +func TestWaitTimesOutWhenTheDocumentNeverLoads(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + chrome.evaluatesTo(false) + + err := chrome.browser().WebViewWaitForLoadState("TAB1", "load", 300) + + if err == nil || err.Error() != "waitForLoadState timed out waiting for 'load'" { + t.Fatalf("expected a timeout, got %v", err) + } +} + +func TestWaitOnAnUnknownWebViewFailsWithoutWaitingForTheTimeout(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + + err := chrome.browser().WebViewWaitForLoadState("NO-SUCH-TAB", "load", 60_000) + + if !errors.Is(err, errWebViewNotFound) { + t.Fatalf("expected a not found error, got %v", err) + } +} + +// Several apps on a device can serve DevTools at once, and the socket names do +// not say which app each belongs to, so the browser itself is asked. +func TestTheDevtoolsSocketOfTheForegroundAppIsChosen(t *testing.T) { + messages := startFakeChrome(t) + messages.packageName = "com.google.android.apps.messaging" + chrome := startFakeChrome(t, exampleTab) + servers := map[string]*fakeChrome{"stetho_messaging_devtools_remote": messages, "chrome_devtools_remote": chrome} + var released []string + connect := func(socket string) (cdpBrowser, func(), error) { + return servers[socket].browser(), func() { released = append(released, socket) }, nil + } + + browser, found := findDevtoolsBrowser("com.android.chrome", []string{"stetho_messaging_devtools_remote", "chrome_devtools_remote"}, connect) + + if !found || browser != chrome.browser() { + t.Fatalf("expected the chrome socket to be chosen, got %+v (found=%v)", browser, found) + } + if !reflect.DeepEqual(released, []string{"stetho_messaging_devtools_remote"}) { + t.Fatalf("expected only the other app's socket to be released, got %v", released) + } +} + +func TestNoDevtoolsBrowserIsFoundWhenNoSocketBelongsToTheApp(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + connect := func(socket string) (cdpBrowser, func(), error) { + return chrome.browser(), func() {}, nil + } + + _, found := findDevtoolsBrowser("com.example.release", []string{"chrome_devtools_remote"}, connect) + + if found { + t.Fatal("expected no browser for an app that serves no devtools") + } +} + +// Chrome freezes the tabs it is not showing, and a frozen tab accepts a +// command without ever answering it. +func TestCommandOnATabThatNeverAnswersFailsSayingSo(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + tabIsFrozen := make(chan struct{}) + t.Cleanup(func() { close(tabIsFrozen) }) + chrome.answer = func(fakeChromeCall) (any, string) { + <-tabIsFrozen + return map[string]any{}, "" + } + + _, err := chrome.browser().evaluate("TAB1", "return 1", nil, 200*time.Millisecond) + + if err == nil || err.Error() != "webview TAB1 did not answer Runtime.evaluate within 200ms; a browser freezes the tabs it is not showing" { + t.Fatalf("expected an explanation of the timeout, got %v", err) + } +} + +// A frozen tab never answers, and a poll on it must not outlive the wait. +func TestWaitOnATabThatNeverAnswersEndsAtTheTimeout(t *testing.T) { + chrome := startFakeChrome(t, exampleTab) + tabIsFrozen := make(chan struct{}) + t.Cleanup(func() { close(tabIsFrozen) }) + chrome.answer = func(fakeChromeCall) (any, string) { + <-tabIsFrozen + return map[string]any{}, "" + } + + start := time.Now() + err := chrome.browser().WebViewWaitForLoadState("TAB1", "load", 300) + + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("the wait took %s for a 300ms timeout", elapsed) + } + expected := "waitForLoadState timed out waiting for 'load': webview TAB1 did not answer Runtime.evaluate within 300ms; a browser freezes the tabs it is not showing" + if err == nil || err.Error() != expected { + t.Fatalf("expected the timeout to say why, got %v", err) + } +} diff --git a/devices/android_webview.go b/devices/android_webview.go index 3f31630..493654a 100644 --- a/devices/android_webview.go +++ b/devices/android_webview.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "strings" @@ -26,6 +27,8 @@ type WebViewInfo struct { const agentSubDir = "mobilecli" +var errAppNotDebuggable = errors.New("webview injection requires a debug build") + // pushTempFile writes data to a host temp file then pushes it to the device // at remotePath using adb push. func (d *AndroidDevice) pushTempFile(data []byte, remotePath string) (err error) { @@ -230,7 +233,7 @@ func (d *AndroidDevice) ensureAgentReady(pkg string) (int, error) { // no existing forward: full setup if !d.isAppDebuggable(pkg) { - return 0, fmt.Errorf("webview injection requires a debug build: %s is not debuggable", pkg) + return 0, fmt.Errorf("%w: %s is not debuggable", errAppNotDebuggable, pkg) } agentDir, err := d.installWebViewKit(pkg) @@ -272,9 +275,9 @@ func (d *AndroidDevice) attachAgentAndWait(pkg string, port int, agentDir string return nil } -// getWebViewPort resolves the foreground app and ensures the agent is ready, -// returning the local TCP port to use for RPC calls. -func (d *AndroidDevice) getWebViewPort() (int, error) { +// webViews resolves the foreground app and returns what drives its webviews: +// the agent injected into a debuggable app, or the DevTools server of a browser. +func (d *AndroidDevice) webViews() (WebViewable, error) { // Foreground detection can momentarily fail right after a launch or in-app // navigation — mCurrentFocus is briefly null during the window transition — // so retry for a short while instead of failing on the first miss. @@ -287,89 +290,165 @@ func (d *AndroidDevice) getWebViewPort() (int, error) { break } if time.Now().After(deadline) { - return 0, fmt.Errorf("could not determine foreground app: %w", err) + return nil, fmt.Errorf("could not determine foreground app: %w", err) } time.Sleep(150 * time.Millisecond) } - return d.ensureAgentReady(foreground.PackageName) + + port, err := d.ensureAgentReady(foreground.PackageName) + if err == nil { + return agentWebViews{port: port}, nil + } + if errors.Is(err, errAppNotDebuggable) { + if browser, found := d.devtoolsBrowserOf(foreground.PackageName); found { + return browser, nil + } + } + return nil, err } -func (d *AndroidDevice) ListWebViews() ([]WebViewInfo, error) { - port, err := d.getWebViewPort() +// devtoolsBrowserOf returns the DevTools server of pkg, if it runs one. +func (d *AndroidDevice) devtoolsBrowserOf(pkg string) (cdpBrowser, bool) { + out, err := d.runAdbCommand("shell", "cat", "/proc/net/unix") if err != nil { - return nil, err + utils.Verbose("failed to list unix sockets: %s: %v", strings.TrimSpace(string(out)), err) + return cdpBrowser{}, false } - result, err := agentRequest(port, "device.webview.list", nil) + return findDevtoolsBrowser(pkg, devtoolsSocketNames(string(out)), d.connectDevtoolsSocket) +} + +// connectDevtoolsSocket forwards a local port to a DevTools socket, reusing an +// existing forward. release undoes a forward that this call created. +func (d *AndroidDevice) connectDevtoolsSocket(socket string) (cdpBrowser, func(), error) { + target := "localabstract:" + socket + if port := d.findForward(target); port != 0 { + return cdpBrowser{addr: fmt.Sprintf("127.0.0.1:%d", port)}, func() {}, nil + } + port, err := d.addForward(target) if err != nil { - return nil, err + return cdpBrowser{}, nil, err } - var webviews []WebViewInfo - if err := json.Unmarshal(result, &webviews); err != nil { - return nil, fmt.Errorf("parse webview list: %w", err) + return cdpBrowser{addr: fmt.Sprintf("127.0.0.1:%d", port)}, func() { d.removeForward(port) }, nil +} + +func (d *AndroidDevice) ListWebViews() ([]WebViewInfo, error) { + webviews, err := d.webViews() + if err != nil { + return nil, err } - return webviews, nil + return webviews.ListWebViews() } func (d *AndroidDevice) WebViewGoto(webviewID, url string) error { - port, err := d.getWebViewPort() + webviews, err := d.webViews() if err != nil { return err } - _, err = agentRequest(port, "device.webview.goto", map[string]any{"id": webviewID, "url": url}) - return err + return webviews.WebViewGoto(webviewID, url) } func (d *AndroidDevice) WebViewReload(webviewID string) error { - port, err := d.getWebViewPort() + webviews, err := d.webViews() if err != nil { return err } - _, err = agentRequest(port, "device.webview.reload", map[string]any{"id": webviewID}) - return err + return webviews.WebViewReload(webviewID) } func (d *AndroidDevice) WebViewGoBack(webviewID string) error { - port, err := d.getWebViewPort() + webviews, err := d.webViews() if err != nil { return err } - _, err = agentRequest(port, "device.webview.goBack", map[string]any{"id": webviewID}) - return err + return webviews.WebViewGoBack(webviewID) } func (d *AndroidDevice) WebViewGoForward(webviewID string) error { - port, err := d.getWebViewPort() + webviews, err := d.webViews() if err != nil { return err } - _, err = agentRequest(port, "device.webview.goForward", map[string]any{"id": webviewID}) - return err + return webviews.WebViewGoForward(webviewID) } func (d *AndroidDevice) WebViewContent(webviewID string) (string, error) { - result, err := d.WebViewEvaluate(webviewID, "return document.documentElement.outerHTML", nil) + webviews, err := d.webViews() if err != nil { return "", err } - content, ok := result.(string) - if !ok { - return "", fmt.Errorf("unexpected content type %T", result) - } - return content, nil + return webviews.WebViewContent(webviewID) } func (d *AndroidDevice) WebViewEvaluate(webviewID, expression string, args []any) (any, error) { - port, err := d.getWebViewPort() + webviews, err := d.webViews() if err != nil { return nil, err } - return webViewEvaluate(port, webviewID, expression, args) + return webviews.WebViewEvaluate(webviewID, expression, args) } func (d *AndroidDevice) WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error { - port, err := d.getWebViewPort() + webviews, err := d.webViews() if err != nil { return err } - return webViewWaitForLoadState(port, webviewID, state, timeoutMs) + return webviews.WebViewWaitForLoadState(webviewID, state, timeoutMs) +} + +// agentWebViews drives the embedded webviews of a debuggable app through the +// agent injected into it, which listens on a forwarded local port. +type agentWebViews struct { + port int +} + +func (a agentWebViews) ListWebViews() ([]WebViewInfo, error) { + result, err := agentRequest(a.port, "device.webview.list", nil) + if err != nil { + return nil, err + } + var webviews []WebViewInfo + if err := json.Unmarshal(result, &webviews); err != nil { + return nil, fmt.Errorf("parse webview list: %w", err) + } + return webviews, nil +} + +func (a agentWebViews) WebViewGoto(webviewID, url string) error { + _, err := agentRequest(a.port, "device.webview.goto", map[string]any{"id": webviewID, "url": url}) + return err +} + +func (a agentWebViews) WebViewReload(webviewID string) error { + _, err := agentRequest(a.port, "device.webview.reload", map[string]any{"id": webviewID}) + return err +} + +func (a agentWebViews) WebViewGoBack(webviewID string) error { + _, err := agentRequest(a.port, "device.webview.goBack", map[string]any{"id": webviewID}) + return err +} + +func (a agentWebViews) WebViewGoForward(webviewID string) error { + _, err := agentRequest(a.port, "device.webview.goForward", map[string]any{"id": webviewID}) + return err +} + +func (a agentWebViews) WebViewContent(webviewID string) (string, error) { + result, err := a.WebViewEvaluate(webviewID, "return document.documentElement.outerHTML", nil) + if err != nil { + return "", err + } + content, ok := result.(string) + if !ok { + return "", fmt.Errorf("unexpected content type %T", result) + } + return content, nil +} + +func (a agentWebViews) WebViewEvaluate(webviewID, expression string, args []any) (any, error) { + return webViewEvaluate(a.port, webviewID, expression, args) +} + +func (a agentWebViews) WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error { + return webViewWaitForLoadState(a.port, webviewID, state, timeoutMs) } diff --git a/docs/openrpc.json b/docs/openrpc.json index a55fe20..f68df3e 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -1514,7 +1514,7 @@ { "name": "device.webview.list", "summary": "List webviews", - "description": "Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Safari on iOS, returns its tabs instead. Chrome is not included.", + "description": "Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Chrome on Android or Safari on iOS, returns its tabs instead.", "params": [ { "name": "deviceId", diff --git a/docs/openrpc.md b/docs/openrpc.md index 912ebc4..0a1aef7 100644 --- a/docs/openrpc.md +++ b/docs/openrpc.md @@ -1664,7 +1664,7 @@ Operation result **List webviews** -Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Safari on iOS, returns its tabs instead. Chrome is not included. +Returns all embedded webviews currently visible in the foreground app on the device. When the foreground app is Chrome on Android or Safari on iOS, returns its tabs instead. #### Parameters