Playwright adapter for HarmonyOS / OpenHarmony ArkWeb (Chromium 132-based) — runs your existing Playwright e2e suite against the device's system browser over CDP, no bundled browser binaries needed. Spec files don't change.
The MCP server counterpart lives at ohos-playwright-mcp.
Playwright's bundled browsers don't ship for the openharmony platform, and ArkWeb's sandbox blocks the AF_UNIX sockets Playwright uses internally — running stock Playwright on a HarmonyOS PC just crashes. This adapter skips the binary download, uses hdc to attach to the browser that's already on the device, and drives it through the Chrome DevTools Protocol over a TCP forward. If the browser isn't running it's launched automatically; when the test run ends the hdc forward is cleaned up but the browser stays open.
On Windows / macOS / Linux withOpenHarmony is a no-op and the config passes through to stock Playwright.
pnpm add -D ohos-playwright @playwright/testNode ≥ 24. hdc must be on PATH and an OpenHarmony / HarmonyOS device reachable.
import { defineConfig } from '@playwright/test'
import { withOpenHarmony } from 'ohos-playwright/config'
export default defineConfig(withOpenHarmony({ /* your config */ })){ "scripts": { "test:e2e": "ohos-playwright test" } }The following Playwright APIs have been validated on ArkWeb / HarmonyOS 6.1 (Chromium 132):
| Category | APIs |
|---|---|
| Network interception | page.route(), context.route(), route.fulfill(), route.abort(), page.unroute() — page.route takes priority over context.route |
| Network headers | page.setExtraHTTPHeaders() — custom headers reach the server |
| Network conditions | context.setOffline(true / false) — actually applies (ERR_INTERNET_DISCONNECTED ↔ reachable) |
| Network events | page.on('request'), page.on('response'), page.on('requestfinished'), page.on('requestfailed') |
| Network intercept wait | page.waitForRequest(), page.waitForResponse() — string URL, glob, and predicate all work |
| Screenshot | page.screenshot({ type: 'jpeg' | 'png' }), locator.screenshot() |
page.pdf() — Chromium-only API, implemented by ArkWeb (produces a valid %PDF document) |
|
| Tracing | context.tracing.start() / stop({ path }) — works under connectOverCDP; the resulting zip contains trace + screenshots + source maps |
| Coverage | page.coverage.startJSCoverage() / stopJSCoverage(), startCSSCoverage() / stopCSSCoverage() — returns per-page entries with ranges |
| Geolocation | context.setGeolocation(), context.grantPermissions(['geolocation']) |
| Device emulation | emulateDevice fixture (see below — isMobile: false for a precise viewport) |
| Input | locator.fill(), locator.type(), keyboard.press(), page.selectOption() |
| Keyboard combos | keyboard.press('Control+a'), 'Shift+Tab', 'Control+z', 'Shift+ArrowRight', 'Alt+ArrowLeft' — modifier combinations work |
| Checkbox | locator.check(), locator.uncheck(), locator.setChecked() — disabled elements correctly rejected |
| Drag & drop | locator.dragTo() — triggers a drop event |
| Scroll | page.mouse.wheel() — scrolls correctly; page.evaluate(() => el.scrollTo(...)) also works |
| File upload | page.setInputFiles() — fires change, file content readable |
| Cookies | context.addCookies(), context.cookies(), context.clearCookies() |
| Dialog | page.on('dialog'), dialog.accept(), dialog.dismiss(), dialog.message(), dialog.type() |
| Popup | context.waitForEvent('page') + window.open() — real Page with full API. Requires PW_CHROMIUM_ATTACH_TO_OTHER=1. Falls back to idle-tab proxy or minimal stub when Target.createTarget is unavailable. |
| Page events | page.on('pageerror'), page.on('console'), page.on('download') |
| Script / style injection | page.addScriptTag({ content | path | type:'module' }), page.addStyleTag({ content }) |
| Init script | page.addInitScript() — function or string, persists across goto() navigations |
| Expose to page | page.exposeFunction(), page.exposeBinding() — persists across navigations; handle mode not supported |
| Navigation wait | page.waitForURL() — string, glob, RegExp, and history.pushState client-side navigation |
| Frames | page.frames(), page.mainFrame(), frame.url() |
| Viewport | page.viewportSize() (pre-fetched via Page.getLayoutMetrics for reused CDP tabs), page.setViewportSize() (applies precisely) |
| Media emulation | page.emulateMedia({ colorScheme, reducedMotion, forcedColors, media }) — all four options work; combine freely |
| Visual snapshot | expect(page).toHaveScreenshot(), expect(locator).toHaveScreenshot() — use SNAPSHOT_ENGINE=<tag> to separate baselines when comparing ArkWeb vs another engine (both produce *-linux.png filenames) |
| API request | request fixture (page-independent HTTP client), playwright.request.newContext() — both work |
| Locator handler | page.addLocatorHandler(), page.removeLocatorHandler() — auto-dismisses overlays before Playwright's action retry |
| HTTP credentials | browser.newContext({ httpCredentials: { username, password } }) — auto-injects Basic Auth. ctx.newPage() requires PW_CHROMIUM_ATTACH_TO_OTHER=1; cookie/storage operations work without it. Note: do not combine with page.route() on the same page — both use Fetch.enable internally and credentials will not be injected when a route is active. |
| Web workers | page.workers() — returns the list of active workers |
| WebSocket | page.routeWebSocket() (requires Playwright ≥ 1.48) — intercepts WebSocket connections |
| Accessibility (CDP) | newCDPSession + Accessibility.getFullAXTree — returns the full AX node tree |
| Navigation history | page.goBack(), page.goForward() — implemented via history.back/forward() + CDP polling; returns when the history index changes |
| Hover events | locator.hover() — fires mouseover / mouseenter event listeners and activates the CSS :hover pseudo-class via boundingBox() + page.mouse.move() (real Input.dispatchMouseEvent path) |
| Locale (partial) | emulateLocale(tag) fixture — rewrites navigator.language / navigator.languages via addInitScript; does not affect HTTP Accept-Language or browser UI locale |
| User-Agent | emulateDevice({ userAgent }) — overrides both navigator.userAgent and the outgoing HTTP User-Agent header; call before page.goto() for the override to take effect. (context.setExtraHTTPHeaders({ 'User-Agent': ... }) does not override UA — ArkWeb preserves the browser default there.) |
| Service Workers | navigator.serviceWorker.register() — works on HTTPS pages; navigator.serviceWorker is undefined on non-secure origins (data:, about:blank) as in all browsers |
| Clipboard | navigator.clipboard.writeText() / readText() — works on HTTPS pages after context.grantPermissions(['clipboard-read', 'clipboard-write']); unavailable on non-secure origins |
Because ctx.newPage() requires PW_CHROMIUM_ATTACH_TO_OTHER=1 (see "Multi-context" in Limitations), device emulation is exposed as a Playwright fixture parameter backed by CDP Emulation.* commands.
import { test, expect } from '@playwright/test'
import type { DeviceDescriptor } from 'ohos-playwright/fixture'
test('precise viewport', async ({ page, emulateDevice }) => {
// isMobile: false applies the viewport precisely (window.innerWidth === 375).
await emulateDevice({
viewport: { width: 375, height: 812 },
deviceScaleFactor: 3,
isMobile: false,
})
expect(await page.evaluate(() => window.innerWidth)).toBe(375)
})emulateDevice settings persist for the lifetime of the page. Call it again with { viewport: { width: 1280, height: 720 }, isMobile: false } to restore defaults.
⚠️ isMobile: truedoes not produce a precise viewport on ArkWeb. WhenEmulation.setDeviceMetricsOverrideis called withmobile: true, ArkWeb enables its mobile layout-viewport compatibility path and renders at the 980px default mobile layout viewport — the passedwidth/heightare effectively ignored (window.innerWidthreads 980 regardless).deviceScaleFactorhas no effect on this. UseisMobile: falsewhen you need an exact pixel viewport.
userAgentoverride applies to both JS and HTTP layers. Callawait emulateDevice({ userAgent: '...' })beforepage.goto(url)—navigator.userAgenton the destination page will reflect the override, and the outgoing HTTPUser-Agentheader is rewritten as well. UseemulateDevicerather thancontext.setExtraHTTPHeaders({ 'User-Agent': ... })— the latter does not override UA on ArkWeb.
ArkWeb fully implements touch input via CDP Input.dispatchTouchEvent, but Playwright's page.touchscreen.tap() refuses to run unless the context was created with hasTouch: true — impossible in single-context reuse mode. The tap fixture exposes a CDP-backed tap that works regardless:
import { test, expect } from '@playwright/test'
test('tap a button', async ({ page, tap }) => {
await page.goto('http://localhost:5173/')
const box = await page.locator('#submit').boundingBox()
await tap(box.x + box.width / 2, box.y + box.height / 2)
await expect(page.locator('#result')).toHaveText('Done')
})Coordinates are CSS pixels relative to the viewport (same as touchscreen.tap). Each call issues a touchStart + touchEnd pair, which also synthesises a click event on the targeted element — so it works for both touch handlers and click handlers.
-
Chromium only. firefox and webkit aren't available on HarmonyOS.
-
browser.newContext()works;ctx.newPage()requires opt-in.browser.newContext()is available without any opt-in — use it freely foraddCookies/storageState()/clearCookies(). However,ctx.newPage()on ArkWeb throws Playwright's naturalCannot read properties of undefined (reading '_page')because ArkWeb'sTarget.createTargetreturnstype: 'other'(not'page'), causing Playwright'scrBrowser._onAttachedToTargetto skip the new target. To opt in to fullctx.newPage()support, setprocess.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1'before importing@playwright/test— Playwright's upstream escape hatch then treats'other'targets as pages. Trade-off: ArkWeb's internal'other'targets (shared workers, etc.) also get treated as pages, which can perturb tests that usetouchscreen.tap()orcontext.recordHar(). For single-context tests (the common case) leave the env unset and isolate withlocalStorage.clear()+page.reload(). For device emulation use theemulateDevicefixture instead ofbrowser.newContext({ ...device }).v0.6.0 breaking change: Browser mode is now MainAbility (multi-tab) by default. CustomTabAbility (single-page) is available via
OHOS_PW_CUSTOM_TAB=1. MainAbility natively supportswindow.open,target=_blankclicks, andTarget.createTargetwithout tab destruction. See README for compatibility notes.v0.5.0 breaking change: Previously
browser.newContext()threw a friendly error whenPW_CHROMIUM_ATTACH_TO_OTHERwas unset. Now it succeeds for cookie/storage operations;ctx.newPage()will throw Playwright's natural_page undefinederror instead.Multi-worker mode. To run tests in parallel across multiple workers, two things are required together:
- Set
PW_CHROMIUM_ATTACH_TO_OTHER=1(enablesnewContext()as above). - Import
testfromohos-playwright/parallelinstead ofohos-playwright:
import { test, expect } from 'ohos-playwright/parallel'
The
ohos-playwright/parallelfixture opens one ArkWeb context per test viabrowser.newContext()(test-scoped) and one page viactx.newPage(). Both are closed after each test. Cookies and localStorage are fully isolated between tests. All ArkWeb workarounds (goBack/goForward, popup interception, hover, evaluate→pageerror propagation) apply identically.Trade-offs to be aware of:
newContext()serialises internally in ArkWeb. ConcurrentnewContext()calls on the same CDP connection are safe (~100 ms for 3 at once). The apparent ~3.4 s bottleneck was from concurrentconnectOverCDPcalls (connection handshakes serialise), not context creation itself. More than ~4 workers may not yield additional throughput.- The default
ohos-playwrightfixture is not parallel-safe. Itscontextfixture always returnsbrowser.contexts()[0]— all workers would race on the same ArkWeb tab. If you setworkers > 1withPW_CHROMIUM_ATTACH_TO_OTHER=1but keep the defaulttestimport,withOpenHarmony()will warn at startup.
- Set
-
HTTP
User-Agentheader can be changed via CDP, but not viasetExtraHTTPHeaders.Emulation.setUserAgentOverride(sent byemulateDevice({ userAgent })) rewrites bothnavigator.userAgentand the outgoing HTTP UA header — call it beforepage.goto()so it applies to the destination page.context.setExtraHTTPHeaders({ 'User-Agent': '...' })does not override UA on ArkWeb (the header is preserved as browser default). -
locator.hover()activates CSS:hover. The override callsboundingBox()+page.mouse.move()(realInput.dispatchMouseEventpath).page.mouse.move()/page.mouse.down()/page.mouse.up()deliver DOM events on ArkWeb identically to stock Chromium. -
emulateDevice({ isMobile: true })does not apply the viewport — see the note in theemulateDevicefixture section above. -
context.recordVideois not supported. ArkWeb does not implementPage.startScreencast(the CDP command Playwright uses for video recording). When running against a remote Chromium viaOHOS_PW_CDP_URL, Playwright's ffmpeg binary also cannot execute on HarmonyOS without signing. Usepage.screenshot()for visual verification instead. -
launchOptions.proxyis not applicable inconnectOverCDPmode — there is no launch phase where proxy settings can be injected. Usepage.route()to intercept and rewrite requests at the Playwright layer. -
exposeBinding({ handle: true })is not supported. Playwright 1.60's publicexposeBindingsignature is(name, callback)— the third{ handle }argument is silently ignored. The callback receives a serialized form of the argument (DOM nodes arrive as the string"ref: <Node>"), not a JSHandle. UseexposeFunctionor a plainexposeBindingcallback that reads element properties directly and returns a by-value object. -
process.platformreads'linux'during the run — Playwright'scalculatePlatform()only branches on linux/darwin/win32 (falls through to<unknown>on openharmony), and 20+ other sites inplaywright-corereadprocess.platformdirectly (UA string assembly, headful window insets, modifier keys, registry). The adapter patchesprocess.platformto'linux'and pinsPLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-arm64forcalculatePlatform(). For real platform checks useprocess.env.OHOS_PW_HOST.
| Variable | Default |
|---|---|
OHOS_PW_BUNDLE |
com.huawei.hmos.browser |
OHOS_PW_LAUNCH_URL |
about:blank (set to your dev server URL, e.g. http://localhost:5173) |
OHOS_PW_HDC |
/data/service/hnp/bin/hdc |
OHOS_PW_AUTO_CONNECT |
auto (set 0 to skip device auto-connect) |
OHOS_PW_INFO_PATH |
<tmpdir>/ohos-playwright-cdp.json |
OHOS_PW_UI_HOST |
0.0.0.0 — used when --ui is passed without --ui-host on an OHOS device |
OHOS_PW_UI_PORT |
8765 — used when --ui is passed without --ui-port on an OHOS device |
OHOS_PW_CDP_URL |
unset — when set, overrides the hdc-derived CDP endpoint entirely (e.g. http://192.168.1.10:9222) — used for LAN Chrome A/B comparison runs |
OHOS_PW_CDP_URL lets you point the probe runner at an arbitrary CDP endpoint — useful for confirming whether a behaviour is an ArkWeb-specific gap or a general Chromium/CDP constraint.
Windows Chrome setup (PowerShell / cmd):
chrome.exe --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0 ^
--user-data-dir=C:\cdp-profile --remote-allow-origins=* about:blank
Binding to the LAN IP directly (e.g.
--remote-debugging-address=192.168.1.10) is more reliable than0.0.0.0with Chrome ≥ M111. SSH tunnel fallback:ssh -L 9222:127.0.0.1:9222 <windows-host>then usehttp://127.0.0.1:9222.
Running the Chrome leg — must be from a non-OpenHarmony host so the adapter loader does not apply ArkWeb-specific fixture overrides:
OHOS_PW_CDP_URL=http://192.168.1.10:9222 npx playwright test probes/ab-baseline.spec.tsArkWeb leg (from the HarmonyPC host, as usual):
./dist/cli.mjs test --config=probes/playwright.config.ts probes/ab-baseline.spec.tsCompare the [PROBE ab-baseline] log lines between the two runs to isolate ArkWeb-specific behaviour.
Playwright's bundled Chromium cannot exec inside the OHOS app sandbox, which breaks the local windows that --ui and --debug normally open.
--uiauto-injects--ui-host=0.0.0.0 --ui-port=8765when run on OHOS, so the UI server starts as HTTP-only. Openhttp://<device-ip>:8765in any browser on your LAN.--debughas no equivalent escape hatch; running it on OHOS exits with guidance. Useawait page.pause()inside a test, or run--debugfrom a host (Linux/macOS/Windows) connected to the device via hdc.
| Required | |
|---|---|
| Node | ≥ 24 |
| Playwright | ≥ 1.59 |
| Verified browser | com.huawei.hmos.browser (Chromium 132) |
Cannot find module 'ohos-playwright/config' — not installed, or pnpm install not run.
defineConfig(...) has no type hints — set moduleResolution to bundler or nodenext in tsconfig.
未发现设备 / no device found — on the device, enable Developer Options → Wireless Debugging and make sure it's on the same Wi-Fi as the host. In CI, run hdc tconn <ip:port> manually before tests.
Failed to launch the browser — bundle not installed or wrong bundle name. List installed browsers with:
hdc shell "bm dump -a" | grep -iE "browser|webview|chrom|arkweb"
Then set OHOS_PW_BUNDLE (e.g. OHOS_PW_BUNDLE=com.quark.ohosbrowser).
DevTools socket not found — browser doesn't expose CDP, or hasn't finished loading. Usually a retry after a few seconds works; persistent failure means this browser doesn't support CDP.
CDP probe failed — leftover hdc forward rule from a prior crashed run. hdc fport ls to inspect, hdc fport rm tcp:<port> localabstract:<socket> to clear.
page.goto('/foo') doesn't prepend baseURL — Playwright's standard behavior is /foo → http://localhost:5173/foo. The adapter injects baseURL directly into the context's internal options so Playwright's own URL resolution applies. If it's not working, verify use.baseURL is set in playwright.config.ts and that withOpenHarmony() wraps the config.
MIT © 2026 social4hyq