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.
| Method | Endpoint | Description |
|---|---|---|
| Get | /profiles | List of profiles and their status |
| Get | /profiles/{profileId} | Get one profile; a running profile has webSocketDebuggerUrl |
| POST | /profiles/{profileId}/start | Launch the profile. Respond after it stabilizes; return 202 if startup is still in progress |
| POST | /profiles/{profileId}/stop | Stop the profile and synchronize its data |
| Get | /profiles/{profileId}/tabs | Get a list of tabs: id, title, url, active |
| POST | /profiles/{profileId}/tabs/{tabId}/activate | Select 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
targetIdin bare-hex format. ThetargetIdobtained through CDP can be passed directly to the HTTP route without conversion. - Profile — the
browserContextIdvalue.
What has been implemented
| Domain | Methods |
|---|---|
| Browser | getVersion |
| Target | getTargets, getTargetInfo, getBrowserContexts, setDiscoverTargets, setAutoAttach, attachToTarget, attachToBrowserTarget, detachFromTarget, createTarget, closeTarget |
| Page | navigate, reload, stopLoading, bringToFront, getFrameTree, getNavigationHistory, getLayoutMetrics, captureScreenshot, printToPDF, createIsolatedWorld, addScriptToEvaluateOnNewDocument |
| Runtime | enable, evaluate, callFunctionOn, getProperties, releaseObject, addBinding, runIfWaitingForDebugger |
| DOM | enable, getDocument, describeNode, resolveNode, focus, getBoxModel, getContentQuads, scrollIntoViewIfNeeded |
| Input | dispatchMouseEvent, dispatchKeyEvent, insertText |
| Network | enable, disable, getCookies, getAllCookies, setCookie, setCookies, deleteCookies, clearBrowserCookies |
| Storage | getCookies |
Events:
Target.targetCreatedTarget.targetDestroyedTarget.attachedToTargetTarget.detachedFromTargetPage.frameNavigatedPage.frameStartedLoadingPage.frameStoppedLoadingPage.lifecycleEventPage.loadEventFiredRuntime.executionContextCreatedRuntime.executionContextsClearedRuntime.consoleAPICalledRuntime.bindingCalledNetwork.requestWillBeSentNetwork.responseReceivedNetwork.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 withoutfullPagewill return the entire page. If you need an actual screen capture, callpage.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 usekeyboard.press(), just as you would after a regular click; - if the
mousedownhandler callspreventDefault(), 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.