Go to the main content

Automating profiles via the CDP

Octo Mobile supports managing browser profiles using Playwright or Puppeteer via the Chrome DevTools Protocol (CDP).

Learn here how to connect to a profile, which CDP methods and events are supported, and what limitations you need to consider when migrating automation scenarios from a desktop browser to Octo Mobile.

All the scenarios described have been tested in the application running on an actual device. If a limitation is related to the browser engine and cannot be bypassed using Octo Mobile, this is noted separately.

Connecting to a profile

The app starts an HTTP server on port 9222 (or the next available port if that one is in use). The address is shown in the account details; tapping the row copies it.

MethodEndpointDescription
Get/profilesList of profiles and their status
Get/profiles/{profileId}Get one profile; a running profile has webSocketDebuggerUrl
POST/profiles/{profileId}/startLaunch the profile. Respond after it stabilizes; return 202 if startup is still in progress
POST/profiles/{profileId}/stopStop the profile and synchronize its data
Get/profiles/{profileId}/tabsGet a list of tabs: id, title, url, active
POST/profiles/{profileId}/tabs/{tabId}/activateSelect a tab and display the profile on the screen

Each running profile has its own WebSocket endpoint, provided in webSocketDebuggerUrl. Each profile uses its own port, which is intentionally unpredictable; only the HTTP port is fixed. One endpoint corresponds to one profile, and one profile corresponds to one browser context.

The beginning of the script in its entirety:

import { chromium } from 'playwright-core';

const server = 'http://192.168.1.10:9222';
const profileId = '2724a5ab459a4417b4bf4c627a1c4650';

await fetch(`${server}/profiles/${profileId}/start`, { method: 'POST' });
const status = await (await fetch(`${server}/profiles/${profileId}`)).json();

const browser = await chromium.connectOverCDP(status.webSocketDebuggerUrl);
const context = browser.contexts()[0];
const page = context.pages()[0] ?? await context.newPage();

// Display this tab on the device screen to watch the run live
await page.bringToFront();

The activate HTTP route is intended for a different scenario: when there is no CDP connection and the tab needs to be activated through an external request, for example from a shell script.

Tab and profile identifiers are consistent across all interfaces:

  • Tab — The UUID targetId in bare-hex format. The targetId obtained through CDP can be passed directly to the HTTP route without conversion.
  • Profile — the browserContextId value.

What has been implemented

DomainMethods
BrowsergetVersion
TargetgetTargets, getTargetInfo, getBrowserContexts, setDiscoverTargets, setAutoAttach, attachToTarget, attachToBrowserTarget, detachFromTarget, createTarget, closeTarget
Pagenavigate, reload, stopLoading, bringToFront, getFrameTree, getNavigationHistory, getLayoutMetrics, captureScreenshot, printToPDF, createIsolatedWorld, addScriptToEvaluateOnNewDocument
Runtimeenable, evaluate, callFunctionOn, getProperties, releaseObject, addBinding, runIfWaitingForDebugger
DOMenable, getDocument, describeNode, resolveNode, focus, getBoxModel, getContentQuads, scrollIntoViewIfNeeded
InputdispatchMouseEvent, dispatchKeyEvent, insertText
Networkenable, disable, getCookies, getAllCookies, setCookie, setCookies, deleteCookies, clearBrowserCookies
StoragegetCookies

Events:

  • Target.targetCreated
  • Target.targetDestroyed
  • Target.attachedToTarget
  • Target.detachedFromTarget
  • Page.frameNavigated
  • Page.frameStartedLoading
  • Page.frameStoppedLoading
  • Page.lifecycleEvent
  • Page.loadEventFired
  • Runtime.executionContextCreated
  • Runtime.executionContextsCleared
  • Runtime.consoleAPICalled
  • Runtime.bindingCalled
  • Network.requestWillBeSent
  • Network.responseReceived
  • Network.loadingFinished

All methods not listed in the table above return an empty result. This behavior is intentional.

What doesn't work and how to adapt the script

page.evaluate on a website with strict CSP

On websites with a strict Content Security Policy (CSP), the page.evaluate method may return an EvalError. It is not possible to bypass this restriction at the page level.

await page.evaluate(() => document.title);   // EvalError on such a site

Both Playwright and Puppeteer pass the function to the page as a string, which then needs to be converted into executable code. If a site uses require-trusted-types-for 'script' or script-src without unsafe-eval, this execution is blocked.

The Chrome DevTools Protocol performs similar operations with inspector privileges, so evaluate() continues to work in regular Chrome. On iOS, this privilege is not available.

The limitation applies to all APIs that pass a user-defined function for execution in the page context, including:

  • page.evaluate()
  • page.$$eval()
  • page.$eval()
  • page.evaluateHandle()
  • locator.allTextContents()

If your task can be solved using locators, use them instead of passing a function to the page:

// instead of page.$$eval('.row', els => els.map(e => e.textContent))
; const rows = page.locator('.row');
; const texts = [];
for (let i = 0; i < await rows.count(); i++) texts.push(await rows.nth(i).textContent());

On a regular page without such a policy, evaluate and $$eval work as expected. On pages with a strict policy, use locators: they are sufficient, among other things, for automating real-world registration scenarios with Google and Microsoft.

data: and about:blank documents may inherit the CSP of the document from which they were opened. If a run starts immediately after a session on a site using Trusted Types, the policy will carry over to your test page. Open a new tab for testing, as it does not inherit the CSP of the previous document.

Frames

page.frames() returns only the main frame, while frameLocator() waits until the timeout expires. The contents of \<iframe> are inaccessible. There is no workaround: scenarios that rely on frames cannot be automated here yet.

Intercepting requests

page.route() is set up without any errors but then never fires—requests are ignored. Do not use page.route() for scenarios that require blocking, modifying, or spoofing HTTP requests. Instead, check the result that is actually rendered on the page.

browser.newContext()

It fails with an explicit error. The browser context here is the Octo profile, and profiles are created through the Octo API, rather than through the protocol. A second profile and a second connection are required.

Screenshots and PDFs

page.screenshot(), page.screenshot({ fullPage: true }), clip, and page.pdf() work. Full-page screenshots capture the entire page, including canvas elements and images. Differences from the desktop browser version:

  • Screenshots are returned at device-pixel resolution, with three times the resolution of a CSS pixel. The scale: 'css' option in Playwright does not affect this.
  • To limit memory usage, screenshot resolution may be reduced automatically. The requested page area is still captured in full.
  • The page.pdf() print parameters (landscape, paperWidth, paperHeight, scale, pageRanges, margins, and headers and footers) are ignored. The PDF is generated based on the page size itself, as a single sheet.
  • For a tab created using newPage() that is not displayed on the screen, there is no actual viewport: a screenshot taken without fullPage will return the entire page. If you need an actual screen capture, call page.bringToFront() first.

waitForLoadState('networkidle')

It is supported, but works differently than in other browsers. The network idle state is determined solely based on completed requests. Requests that never complete (SSE, long-poll, hung XHR) are not taken into account here. Idle may be reported while the connection is still open. For normal page loads, the signal is correct.

It is better to wait for a specific condition:

await page.locator('#results').waitFor();  // instead of
await page.waitForLoadState('networkidle');

Input events and their reliability

Text input is performed using the iOS system input mechanism. As a result, the beforeinput and input events have isTrusted: true. This matches the behavior of actual user input.

The keydown, keypress, and keyup keyboard events are synthetic. Hardware keystrokes reach the browser through a system channel that is inaccessible to the application. Therefore, a website that checks event.isTrusted for keyboard events may identify such input as synthetic.

Mouse events are also synthetic, but their behavior matches normal interactions in those aspects that affect page state:

  • clicking an element under the cursor moves focus to it;
  • after click(), you can use keyboard.press(), just as you would after a regular click;
  • if the mousedown handler calls preventDefault(), focus is not transferred to the element, just as it would be in a regular browser.

The following input methods are supported:

  • locator.fill()
  • locator.pressSequentially()
  • keyboard.type()
  • keyboard.press()

Keyboard modifiers are supported. For example, the Shift key is used correctly to type uppercase characters.