ohos-playwright 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,24 +37,43 @@ The following Playwright APIs have been validated on ArkWeb / HarmonyOS 6.1 (Chr
37
37
 
38
38
  | Category | APIs |
39
39
  |---|---|
40
- | Network interception | `page.route()`, `route.fulfill()`, `route.abort()`, `page.unroute()` |
40
+ | Network interception | `page.route()`, `context.route()`, `route.fulfill()`, `route.abort()`, `page.unroute()` — `page.route` takes priority over `context.route` |
41
41
  | Network headers | `page.setExtraHTTPHeaders()` — custom headers reach the server |
42
42
  | Network conditions | `context.setOffline(true / false)` — actually applies (`ERR_INTERNET_DISCONNECTED` ↔ reachable) |
43
+ | Network events | `page.on('request')`, `page.on('response')`, `page.on('requestfinished')`, `page.on('requestfailed')` |
44
+ | Network intercept wait | `page.waitForRequest()`, `page.waitForResponse()` — string URL, glob, and predicate all work |
43
45
  | Screenshot | `page.screenshot({ type: 'jpeg' \| 'png' })`, `locator.screenshot()` |
44
46
  | PDF | `page.pdf()` — Chromium-only API, implemented by ArkWeb (produces a valid `%PDF` document) |
45
47
  | Tracing | `context.tracing.start()` / `stop({ path })` — works under `connectOverCDP`; the resulting zip contains trace + screenshots + source maps |
48
+ | Coverage | `page.coverage.startJSCoverage()` / `stopJSCoverage()`, `startCSSCoverage()` / `stopCSSCoverage()` — returns per-page entries with ranges |
46
49
  | Geolocation | `context.setGeolocation()`, `context.grantPermissions(['geolocation'])` |
47
50
  | Device emulation | `emulateDevice` fixture (see below — `isMobile: false` for a precise viewport) |
48
51
  | Input | `locator.fill()`, `locator.type()`, `keyboard.press()`, `page.selectOption()` |
52
+ | Keyboard combos | `keyboard.press('Control+a')`, `'Shift+Tab'`, `'Control+z'`, `'Shift+ArrowRight'`, `'Alt+ArrowLeft'` — modifier combinations work |
53
+ | Checkbox | `locator.check()`, `locator.uncheck()`, `locator.setChecked()` — disabled elements correctly rejected |
49
54
  | Drag & drop | `locator.dragTo()` — triggers a `drop` event |
55
+ | Scroll | `page.mouse.wheel()` — scrolls correctly; `page.evaluate(() => el.scrollTo(...))` also works |
50
56
  | File upload | `page.setInputFiles()` — fires `change`, file content readable |
51
57
  | Cookies | `context.addCookies()`, `context.cookies()`, `context.clearCookies()` |
52
58
  | Dialog | `page.on('dialog')`, `dialog.accept()`, `dialog.dismiss()`, `dialog.message()`, `dialog.type()` |
53
59
  | Popup | `context.waitForEvent('page')` + `window.open()` — stub Page with `url()`, `waitForLoadState()`, `close()` |
54
60
  | Page events | `page.on('pageerror')`, `page.on('console')`, `page.on('download')` |
61
+ | Script / style injection | `page.addScriptTag({ content \| path \| type:'module' })`, `page.addStyleTag({ content })` |
62
+ | Init script | `page.addInitScript()` — function or string, persists across `goto()` navigations |
63
+ | Expose to page | `page.exposeFunction()`, `page.exposeBinding()` — persists across navigations; `handle` mode not supported |
64
+ | Navigation wait | `page.waitForURL()` — string, glob, RegExp, and `history.pushState` client-side navigation |
55
65
  | Frames | `page.frames()`, `page.mainFrame()`, `frame.url()` |
56
66
  | Viewport | `page.viewportSize()` (pre-fetched via `Page.getLayoutMetrics` for reused CDP tabs), `page.setViewportSize()` (applies precisely) |
57
67
  | Media emulation | `page.emulateMedia({ colorScheme })` |
68
+ | Web workers | `page.workers()` — returns the list of active workers |
69
+ | WebSocket | `page.routeWebSocket()` (requires Playwright ≥ 1.48) — intercepts WebSocket connections |
70
+ | Accessibility (CDP) | `newCDPSession` + `Accessibility.getFullAXTree` — returns the full AX node tree |
71
+ | Navigation history | `page.goBack()`, `page.goForward()` — implemented via `history.back/forward()` + CDP polling; returns when the history index changes |
72
+ | Hover events | `locator.hover()` — fires `mouseover` / `mouseenter` event listeners **and** activates the CSS `:hover` pseudo-class via the real `Input.dispatchMouseEvent` path. Falls back to JS-only dispatch (DOM events without `:hover`) if Playwright's `boundingBox()` hangs for more than 5 s. |
73
+ | Locale (partial) | `emulateLocale(tag)` fixture — rewrites `navigator.language` / `navigator.languages` via `addInitScript`; does not affect HTTP `Accept-Language` or browser UI locale |
74
+ | 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.) |
75
+ | Service Workers | `navigator.serviceWorker.register()` — works on HTTPS pages; `navigator.serviceWorker` is `undefined` on non-secure origins (`data:`, `about:blank`) as in all browsers |
76
+ | Clipboard | `navigator.clipboard.writeText()` / `readText()` — works on HTTPS pages after `context.grantPermissions(['clipboard-read', 'clipboard-write'])`; unavailable on non-secure origins |
58
77
 
59
78
  ### `emulateDevice` fixture
60
79
 
@@ -78,7 +97,9 @@ test('precise viewport', async ({ page, emulateDevice }) => {
78
97
  `emulateDevice` settings persist for the lifetime of the page. Call it again with `{ viewport: { width: 1280, height: 720 }, isMobile: false }` to restore defaults.
79
98
 
80
99
  > **⚠️ `isMobile: true` does not produce a precise viewport on ArkWeb.**
81
- > When `Emulation.setDeviceMetricsOverride` is called with `mobile: true`, ArkWeb enables its mobile layout-viewport compatibility path and renders at the 980px default mobile layout viewport — the passed `width`/`height` are effectively ignored (`window.innerWidth` reads 980 regardless). `deviceScaleFactor` has no effect on this. Use `isMobile: false` when you need an exact pixel viewport. Note that `userAgent` is also not applied (`Emulation.setUserAgentOverride` is acked but ignored by ArkWeb); the browser UA cannot be changed via CDP.
100
+ > When `Emulation.setDeviceMetricsOverride` is called with `mobile: true`, ArkWeb enables its mobile layout-viewport compatibility path and renders at the 980px default mobile layout viewport — the passed `width`/`height` are effectively ignored (`window.innerWidth` reads 980 regardless). `deviceScaleFactor` has no effect on this. Use `isMobile: false` when you need an exact pixel viewport.
101
+ >
102
+ > **`userAgent` override applies to both JS and HTTP layers.** Call `await emulateDevice({ userAgent: '...' })` before `page.goto(url)` — `navigator.userAgent` on the destination page will reflect the override, and the outgoing HTTP `User-Agent` header is rewritten as well. Use `emulateDevice` rather than `context.setExtraHTTPHeaders({ 'User-Agent': ... })` — the latter does not override UA on ArkWeb.
82
103
 
83
104
  ### `tap` fixture
84
105
 
@@ -100,16 +121,13 @@ Coordinates are CSS pixels relative to the viewport (same as `touchscreen.tap`).
100
121
  ## Limitations
101
122
 
102
123
  - **Chromium only.** firefox and webkit aren't available on HarmonyOS.
103
- - **One context, one page.** `newContext()` / `newPage()` aren't supported (both throw an explicit error). Isolate tests with `localStorage.clear()` + `page.reload()`. For device emulation use the `emulateDevice` fixture instead of `browser.newContext({ ...device })`.
104
- - **`locator.hover()` hangs** on ArkWeb (CDP `Input.dispatchMouseEvent` mouseMoved blocks until the Playwright timeout). Use `:focus`-driven styles or a direct `click()` instead of hover-driven assertions.
105
- - **`page.goBack()` / `page.goForward()` hang** (CDP history navigation never resolves). Re-navigate with `page.goto()` instead.
106
- - **`Emulation.setUserAgentOverride` is ignored** the command is acked but `navigator.userAgent` is unchanged. The browser UA cannot be changed via CDP.
107
- - **`mouse.wheel()` is a no-op** — the command succeeds but `scrollTop` stays 0. Scroll via `page.evaluate(() => el.scrollTo(...))`.
108
- - **Service Workers unavailable** — `navigator.serviceWorker` is `undefined` on ArkWeb; PWA / SW-based tests are not possible.
109
- - **`page.workers()` returns empty** for web workers — CDP does not auto-attach worker targets. The workers themselves run fine (messages reach the main page), only the listing is affected.
110
- - **Clipboard is a false positive** — `navigator.clipboard.writeText/readText` do not throw but `readText` returns `undefined`. Don't assert on clipboard contents.
124
+ - **Multi-context / multi-page is opt-in.** By default the adapter intercepts `browser.newContext()` with a friendly error. The root cause: ArkWeb's `Target.createTarget` returns a target with `type: 'other'`, not `'page'`, so Playwright's `crBrowser._onAttachedToTarget` skips it and `ctx.newPage()` throws `Cannot read properties of undefined (reading '_page')`. To opt in, set `process.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1'` **before** importing `@playwright/test` — Playwright's upstream escape hatch then treats `'other'` targets as pages and `browser.newContext()` / `ctx.newPage()` work normally. Trade-off: ArkWeb's internal `'other'` targets (shared workers, etc.) also get treated as pages, which can perturb tests that use `touchscreen.tap()` or `context.recordHar()`. For single-context tests (the common case) leave the env unset and isolate with `localStorage.clear()` + `page.reload()`. For device emulation use the `emulateDevice` fixture instead of `browser.newContext({ ...device })`.
125
+ - **HTTP `User-Agent` header can be changed via CDP, but not via `setExtraHTTPHeaders`.** `Emulation.setUserAgentOverride` (sent by `emulateDevice({ userAgent })`) rewrites both `navigator.userAgent` and the outgoing HTTP UA header — call it before `page.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).
126
+ - **`locator.hover()` activates CSS `:hover` on typical pages.** The fixture goes through the real `Input.dispatchMouseEvent` path via `page.mouse.move`. On pages where Playwright's `boundingBox()` hangs (e.g. some MutationObserver configurations), it falls back to a JS `mouseover` dispatch after a 5 s timeout — DOM listeners still fire but `:hover` will not activate in that fallback path.
127
+ - **`page.mouse.move()` / `page.mouse.down()` / `page.mouse.up()` work normally.** DOM listeners receive `mousemove` / `mousedown` / `mouseup` / `click` in ArkWeb just as they do on stock Chromium. A previously documented narrow edge case (data: URL with embedded newlines AND a shared function reference registered for multiple event types) could not be reproduced in the v0.3.3 reaudit. The `mouseMove` / `mouseDown` / `mouseUp` fixtures remain in the API surface for backward compatibility but are no longer needed for normal use.
111
128
  - **`emulateDevice({ isMobile: true })` does not apply the viewport** — see the note in the `emulateDevice` fixture section above.
112
- - **`process.platform` reads `'linux'`** during the run we patch it because Playwright's hostPlatform detection only branches on linux/darwin/win32 and falls through to `<unknown>` on openharmony. For real platform checks use `process.env.OHOS_PW_HOST`.
129
+ - **`exposeBinding({ handle: true })` is not supported.** Playwright 1.60's public `exposeBinding` signature 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. Use `exposeFunction` or a plain `exposeBinding` callback that reads element properties directly and returns a by-value object.
130
+ - **`process.platform` reads `'linux'`** during the run — Playwright's `calculatePlatform()` only branches on linux/darwin/win32 (falls through to `<unknown>` on openharmony), and 20+ other sites in `playwright-core` read `process.platform` directly (UA string assembly, headful window insets, modifier keys, registry). The adapter patches `process.platform` to `'linux'` and pins `PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-arm64` for `calculatePlatform()`. For real platform checks use `process.env.OHOS_PW_HOST`.
113
131
 
114
132
  ## Environment variables
115
133
 
@@ -122,6 +140,34 @@ Coordinates are CSS pixels relative to the viewport (same as `touchscreen.tap`).
122
140
  | `OHOS_PW_INFO_PATH` | `<tmpdir>/ohos-playwright-cdp.json` |
123
141
  | `OHOS_PW_UI_HOST` | `0.0.0.0` — used when `--ui` is passed without `--ui-host` on an OHOS device |
124
142
  | `OHOS_PW_UI_PORT` | `8765` — used when `--ui` is passed without `--ui-port` on an OHOS device |
143
+ | `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 |
144
+
145
+ ### LAN Chrome A/B comparison
146
+
147
+ `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.
148
+
149
+ **Windows Chrome setup** (PowerShell / cmd):
150
+
151
+ ```
152
+ chrome.exe --remote-debugging-port=9222 --remote-debugging-address=0.0.0.0 ^
153
+ --user-data-dir=C:\cdp-profile --remote-allow-origins=* about:blank
154
+ ```
155
+
156
+ > Binding to the LAN IP directly (e.g. `--remote-debugging-address=192.168.1.10`) is more reliable than `0.0.0.0` with Chrome ≥ M111. SSH tunnel fallback: `ssh -L 9222:127.0.0.1:9222 <windows-host>` then use `http://127.0.0.1:9222`.
157
+
158
+ **Running the Chrome leg** — must be from a **non-OpenHarmony host** so the adapter loader does not apply ArkWeb-specific fixture overrides:
159
+
160
+ ```bash
161
+ OHOS_PW_CDP_URL=http://192.168.1.10:9222 npx playwright test probes/ab-baseline.spec.ts
162
+ ```
163
+
164
+ **ArkWeb leg** (from the HarmonyPC host, as usual):
165
+
166
+ ```bash
167
+ ./dist/cli.mjs test --config=probes/playwright.config.ts probes/ab-baseline.spec.ts
168
+ ```
169
+
170
+ Compare the `[PROBE ab-baseline]` log lines between the two runs to isolate ArkWeb-specific behaviour.
125
171
 
126
172
  ### `--ui` and `--debug` on an OHOS device
127
173
 
@@ -26,7 +26,13 @@ export interface StorageState {
26
26
  export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
27
27
  emulateDevice: (descriptor: DeviceDescriptor) => Promise<void>;
28
28
  tap: (x: number, y: number) => Promise<void>;
29
+ mouseMove: (x: number, y: number, opts?: {
30
+ steps?: number;
31
+ }) => Promise<void>;
32
+ mouseDown: (x: number, y: number) => Promise<void>;
33
+ mouseUp: (x: number, y: number) => Promise<void>;
29
34
  saveStorageState: (origin?: string) => Promise<StorageState>;
30
35
  loadStorageState: (state: StorageState) => Promise<void>;
36
+ emulateLocale: (locale: string) => Promise<void>;
31
37
  }, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
32
38
  export { expect } from '@playwright/test';
package/dist/fixture.mjs CHANGED
@@ -2,6 +2,17 @@ import { readFileSync } from 'node:fs';
2
2
  import { test as base, chromium } from '@playwright/test';
3
3
  import { INFO_PATH } from "./info-path.mjs";
4
4
  function readInfo() {
5
+ const cdpUrl = process.env.OHOS_PW_CDP_URL;
6
+ if (cdpUrl) {
7
+ return {
8
+ port: 0,
9
+ pid: 0,
10
+ socket: '',
11
+ endpoint: cdpUrl,
12
+ openedNewTab: false,
13
+ launchUrl: process.env.OHOS_PW_LAUNCH_URL ?? 'about:blank',
14
+ };
15
+ }
5
16
  return JSON.parse(readFileSync(INFO_PATH, 'utf8'));
6
17
  }
7
18
  function readEndpoint() { return readInfo().endpoint; }
@@ -9,24 +20,30 @@ export const test = base.extend({
9
20
  browser: [
10
21
  async ({}, use) => {
11
22
  const browser = await chromium.connectOverCDP(readEndpoint());
12
- // ArkWeb CDP does not implement Target.createBrowserContext — connectOverCDP
13
- // reuses the single existing context. browser.newContext() returns without
14
- // throwing but produces an empty shell (0 pages) that silently fails every
15
- // subsequent operation. Intercept and throw an explicit, actionable error
16
- // so users aren't misled by the false-positive success.
17
- const origNewContext = browser.newContext.bind(browser);
18
- browser.newContext = (() => {
19
- throw new Error('browser.newContext() is not supported in ArkWeb CDP mode (single context only). ' +
20
- 'Tests share one context and one page — isolate with localStorage.clear() + page.reload(). ' +
21
- 'See ohos-playwright README "Limitations" section.');
22
- });
23
- try {
24
- await use(browser);
23
+ // ArkWeb's Target.createTarget returns type='other', so Playwright's
24
+ // crBrowser._onAttachedToTarget skips it and ctx.newPage() throws
25
+ // "Cannot read properties of undefined (reading '_page')". The upstream
26
+ // PW_CHROMIUM_ATTACH_TO_OTHER=1 escape hatch fixes newContext/newPage
27
+ // but also makes Playwright treat internal "other" targets as pages
28
+ // (perturbs touchscreen / recordHar). When the user has not opted in,
29
+ // intercept browser.newContext() with a friendly, actionable error.
30
+ if (!process.env.PW_CHROMIUM_ATTACH_TO_OTHER) {
31
+ const origNewContext = browser.newContext.bind(browser);
32
+ browser.newContext = (() => {
33
+ throw new Error('browser.newContext() is not supported on ArkWeb unless you opt in via ' +
34
+ 'process.env.PW_CHROMIUM_ATTACH_TO_OTHER=\'1\' before importing @playwright/test. ' +
35
+ 'See ohos-playwright README "Limitations" → "Multi-context" for trade-offs.');
36
+ });
37
+ try {
38
+ await use(browser);
39
+ }
40
+ finally {
41
+ ;
42
+ browser.newContext = origNewContext;
43
+ }
25
44
  }
26
- finally {
27
- // Restore in case the browser object is reused across workers.
28
- ;
29
- browser.newContext = origNewContext;
45
+ else {
46
+ await use(browser);
30
47
  }
31
48
  },
32
49
  { scope: 'worker' },
@@ -69,20 +86,122 @@ export const test = base.extend({
69
86
  // viewport size and viewportSize() returns null. Pre-fetch via CDP.
70
87
  const session = await context.newCDPSession(page);
71
88
  try {
72
- const { cssVisualViewport } = await session.send('Page.getLayoutMetrics');
73
- const cached = {
74
- width: Math.round(cssVisualViewport.clientWidth),
75
- height: Math.round(cssVisualViewport.clientHeight),
76
- };
77
- const origViewportSize = page.viewportSize.bind(page);
78
- page.viewportSize = () => origViewportSize() ?? cached;
79
- }
80
- catch {
81
- // Non-critical — viewportSize() will still return null if CDP call fails.
89
+ try {
90
+ const { cssVisualViewport } = await session.send('Page.getLayoutMetrics');
91
+ const cached = {
92
+ width: Math.round(cssVisualViewport.clientWidth),
93
+ height: Math.round(cssVisualViewport.clientHeight),
94
+ };
95
+ const origViewportSize = page.viewportSize.bind(page);
96
+ page.viewportSize = () => origViewportSize() ?? cached;
97
+ }
98
+ catch {
99
+ // Non-critical — viewportSize() will still return null if CDP call fails.
100
+ }
101
+ // Bring the tab to foreground so Input.dispatchMouseEvent reaches DOM listeners.
102
+ // page.mouse.move/down/up events are silently dropped when the tab is not active.
103
+ try {
104
+ const targets = await session.send('Target.getTargets');
105
+ const pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url === page.url());
106
+ if (pageTarget) {
107
+ await session.send('Target.activateTarget', { targetId: pageTarget.targetId });
108
+ }
109
+ }
110
+ catch {
111
+ // Non-fatal: some ArkWeb versions may not support Target.activateTarget.
112
+ }
82
113
  }
83
114
  finally {
84
115
  await session.detach();
85
116
  }
117
+ // Override goBack: Page.navigateToHistoryEntry hangs in ArkWeb (never resolves).
118
+ // ArkWeb also does not emit Page.frameNavigated for history navigation, so waitForURL
119
+ // never fires. Poll Page.getNavigationHistory.currentIndex instead.
120
+ ;
121
+ page.goBack = async (options) => {
122
+ const timeout = options?.timeout ?? 30000;
123
+ const s = await page.context().newCDPSession(page);
124
+ try {
125
+ const nav = await s.send('Page.getNavigationHistory');
126
+ const prevIndex = nav.currentIndex;
127
+ if (prevIndex <= 0)
128
+ return null;
129
+ await page.evaluate(() => history.back());
130
+ const deadline = Date.now() + timeout;
131
+ while (Date.now() < deadline) {
132
+ const nav2 = await s.send('Page.getNavigationHistory');
133
+ if (nav2.currentIndex < prevIndex)
134
+ break;
135
+ await new Promise(r => setTimeout(r, 80));
136
+ }
137
+ if (Date.now() >= deadline)
138
+ throw new Error(`page.goBack: Timeout ${timeout}ms exceeded`);
139
+ }
140
+ finally {
141
+ await s.detach();
142
+ }
143
+ return null;
144
+ };
145
+ page.goForward = async (options) => {
146
+ const timeout = options?.timeout ?? 30000;
147
+ const s = await page.context().newCDPSession(page);
148
+ try {
149
+ const nav = await s.send('Page.getNavigationHistory');
150
+ const prevIndex = nav.currentIndex;
151
+ if (prevIndex >= nav.entries.length - 1)
152
+ return null;
153
+ await page.evaluate(() => history.forward());
154
+ const deadline = Date.now() + timeout;
155
+ while (Date.now() < deadline) {
156
+ const nav2 = await s.send('Page.getNavigationHistory');
157
+ if (nav2.currentIndex > prevIndex)
158
+ break;
159
+ await new Promise(r => setTimeout(r, 80));
160
+ }
161
+ if (Date.now() >= deadline)
162
+ throw new Error(`page.goForward: Timeout ${timeout}ms exceeded`);
163
+ }
164
+ finally {
165
+ await s.detach();
166
+ }
167
+ return null;
168
+ };
169
+ // Override locator().hover() to bypass Playwright's internal visibility check
170
+ // (which can hang on ArkWeb), but still go through the real Input.dispatchMouseEvent
171
+ // path so the pointer position is set and CSS :hover activates. The earlier
172
+ // JS-dispatch workaround was disproved by the 2026-06-27 reaudit — ab-hover-css
173
+ // shows the native path delivers both DOM events and :hover activation.
174
+ const savedLocator = page['locator'];
175
+ const origLocator = page.locator.bind(page);
176
+ page.locator = (...args) => {
177
+ const loc = origLocator(...args);
178
+ loc.hover = async (_options) => {
179
+ // Try the real Input.dispatchMouseEvent path first (activates :hover).
180
+ // Some pages (e.g. ones with MutationObservers that re-enter layout) can
181
+ // make Playwright's evaluate/boundingBox hang on ArkWeb; fall back to a
182
+ // JS-only dispatch with a tight timeout so hover() at least returns and
183
+ // DOM listeners fire.
184
+ const viaRealMouse = await Promise.race([
185
+ (async () => {
186
+ const box = await loc.boundingBox();
187
+ if (!box)
188
+ return false;
189
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
190
+ return true;
191
+ })(),
192
+ new Promise(r => setTimeout(() => r(false), 5000)),
193
+ ]);
194
+ if (!viaRealMouse) {
195
+ await Promise.race([
196
+ loc.evaluate((el) => {
197
+ el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }));
198
+ }),
199
+ new Promise(r => setTimeout(r, 2000)),
200
+ ]).catch(() => { });
201
+ }
202
+ };
203
+ return loc;
204
+ };
86
205
  // ArkWeb's new tab from window.open() is invisible to CDP (Target.createTarget hangs,
87
206
  // Target.targetCreated never fires). Intercept via an init script:
88
207
  // - queue the URL for our poller
@@ -144,6 +263,7 @@ export const test = base.extend({
144
263
  finally {
145
264
  clearInterval(popupPoller);
146
265
  page.evaluate = savedEvaluate;
266
+ page.locator = savedLocator;
147
267
  // Reset the test tab to about:blank so it's clean for the next test.
148
268
  // page.close() sends Target.closeTarget which terminates the ArkWeb
149
269
  // DevTools socket — use goto instead to keep the connection alive.
@@ -171,7 +291,9 @@ export const test = base.extend({
171
291
  'default mobile layout viewport; the passed width/height will NOT apply. ' +
172
292
  'Use isMobile: false for a precise viewport.');
173
293
  }
174
- // ArkWeb note: setUserAgentOverride is acked but ignored UA cannot be changed via CDP.
294
+ // ArkWeb note: setUserAgentOverride takes effect after the next page.goto() the override
295
+ // applies to the destination page's navigator.userAgent but not the currently-loaded page.
296
+ // The HTTP User-Agent header is also not changed by ArkWeb's UA override.
175
297
  await session.send('Emulation.setDeviceMetricsOverride', {
176
298
  width: descriptor.viewport.width,
177
299
  height: descriptor.viewport.height,
@@ -217,6 +339,45 @@ export const test = base.extend({
217
339
  }
218
340
  });
219
341
  },
342
+ mouseMove: async ({ page }, use) => {
343
+ // ArkWeb CDP limitation: events dispatched via locator.evaluate() only reach
344
+ // page-script listeners if the listener body contains no closure references and
345
+ // the element has only a single addEventListener call. Any script complexity
346
+ // (outer variable declarations, multiple listeners) causes ArkWeb to route the
347
+ // callback into an isolated CDP execution context where closures are inaccessible,
348
+ // silently suppressing the event. For typical web applications this fixture
349
+ // will not deliver events. Prefer locator.click() / locator.fill() where possible.
350
+ await use(async (x, y, opts) => {
351
+ const steps = Math.max(1, opts?.steps ?? 1);
352
+ for (let i = 0; i < steps; i++) {
353
+ await page.locator(':root').evaluate((root, [x, y]) => {
354
+ const el = root.ownerDocument.elementFromPoint(x, y);
355
+ if (el)
356
+ el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: x, clientY: y }));
357
+ }, [x, y]);
358
+ }
359
+ });
360
+ },
361
+ mouseDown: async ({ page }, use) => {
362
+ await use(async (x, y) => {
363
+ await page.locator(':root').evaluate((root, [x, y]) => {
364
+ const el = root.ownerDocument.elementFromPoint(x, y);
365
+ if (el)
366
+ el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0, buttons: 1 }));
367
+ }, [x, y]);
368
+ });
369
+ },
370
+ mouseUp: async ({ page }, use) => {
371
+ await use(async (x, y) => {
372
+ await page.locator(':root').evaluate((root, [x, y]) => {
373
+ const el = root.ownerDocument.elementFromPoint(x, y);
374
+ if (el) {
375
+ el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }));
376
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }));
377
+ }
378
+ }, [x, y]);
379
+ });
380
+ },
220
381
  saveStorageState: async ({ page, context }, use) => {
221
382
  await use(async (origin) => {
222
383
  const derivedOrigin = origin ?? new URL(page.url()).origin;
@@ -251,6 +412,20 @@ export const test = base.extend({
251
412
  return { cookies, origins: [{ origin: derivedOrigin, localStorage }] };
252
413
  });
253
414
  },
415
+ emulateLocale: async ({ page }, use) => {
416
+ await use(async (locale) => {
417
+ await page.addInitScript((loc) => {
418
+ try {
419
+ Object.defineProperty(navigator, 'language', { get: () => loc, configurable: true });
420
+ }
421
+ catch { }
422
+ try {
423
+ Object.defineProperty(navigator, 'languages', { get: () => [loc], configurable: true });
424
+ }
425
+ catch { }
426
+ }, locale);
427
+ });
428
+ },
254
429
  loadStorageState: async ({ page, context }, use) => {
255
430
  await use(async (state) => {
256
431
  if (state.cookies?.length)
package/dist/loader.mjs CHANGED
@@ -3,12 +3,18 @@ import { pathToFileURL } from 'node:url';
3
3
  const FIXTURE_URL = pathToFileURL(resolvePath(import.meta.dirname, 'fixture.mjs')).href;
4
4
  const TARGET = '@playwright/test';
5
5
  const TEST_FILE = /\.(spec|test)\.[mc]?[tj]sx?$/;
6
+ // Also intercept from fixture helper files (e.g. fixtures.ts, fixtures.mts) that
7
+ // live outside our own compiled dist/ — this covers the common pattern where spec
8
+ // files delegate to a shared fixtures.ts that itself imports @playwright/test.
9
+ const FIXTURE_HELPER = /\bfixtures?\.[mc]?[tj]sx?$/;
10
+ const OWN_DIST_URL = pathToFileURL(resolvePath(import.meta.dirname) + '/').href;
6
11
  const PACKAGE_ROOT_URL = pathToFileURL(resolvePath(import.meta.dirname, '..') + '/').href;
7
12
  const PROJECT_ANCHOR = pathToFileURL(resolvePath(process.cwd(), 'noop.mjs')).href;
8
13
  export async function resolve(specifier, context, nextResolve) {
9
14
  if (specifier === TARGET) {
10
15
  const parent = context.parentURL ?? '';
11
- if (TEST_FILE.test(parent)) {
16
+ const isFromOwnDist = parent.startsWith(OWN_DIST_URL);
17
+ if (TEST_FILE.test(parent) || (FIXTURE_HELPER.test(parent) && !isFromOwnDist)) {
12
18
  const result = await nextResolve(FIXTURE_URL, context);
13
19
  return { url: result.url };
14
20
  }
package/dist/register.mjs CHANGED
@@ -7,11 +7,33 @@ if (process.platform === 'openharmony') {
7
7
  // downstream (notably withOpenHarmony in the user's config) should consult
8
8
  // OHOS_PW_HOST instead of process.platform, which is about to read 'linux'.
9
9
  process.env.OHOS_PW_HOST = '1';
10
- // Playwright's hostPlatform detection only branches on linux/darwin/win32.
11
- // On OpenHarmony it falls through to "<unknown>" and various code paths
12
- // break. We connect over CDP and never touch Playwright's bundled browser
13
- // binaries, so it's safe to advertise linux for the duration of this
14
- // process.
10
+ // Playwright's calculatePlatform() (hostPlatform.ts:40) only branches on
11
+ // linux/darwin/win32 — on OpenHarmony it falls through to "<unknown>" and
12
+ // registry/hostPlatform consumers break. PLAYWRIGHT_HOST_PLATFORM_OVERRIDE
13
+ // (hostPlatform.ts:41) is the upstream escape hatch; pin to ubuntu24.04-arm64
14
+ // to skip the distro probing that would also reach "<unknown>".
15
+ process.env.PLAYWRIGHT_HOST_PLATFORM_OVERRIDE ??= 'ubuntu24.04-arm64';
16
+ // Beyond calculatePlatform(), playwright-core has 20+ direct process.platform
17
+ // reads on hot paths (userAgent.ts:39 UA string, crPage.ts:940 headful
18
+ // insets, input.ts:182 modifier key, registry/index.ts:479 Unsupported
19
+ // platform, tracing.ts:120 trace metadata). The env override above cannot
20
+ // reach those — we still must advertise 'linux' for the duration of this
21
+ // process. Safe because we connect over CDP and never touch Playwright's
22
+ // bundled browser binaries. Clean fix requires upstream openharmony branch.
15
23
  Object.defineProperty(process, 'platform', { value: 'linux' });
24
+ // ArkWeb's Target.createTarget returns a target with type='other', not
25
+ // 'page'. Playwright's crBrowser._onAttachedToTarget (crBrowser.ts:191)
26
+ // only registers 'page' targets into _crPages — 'other' targets get
27
+ // detached and ctx.newPage() throws "Cannot read properties of undefined
28
+ // (reading '_page')".
29
+ //
30
+ // PW_CHROMIUM_ATTACH_TO_OTHER (crBrowser.ts:181) is playwright's upstream
31
+ // escape hatch: treat type='other' as page so newContext/newPage work.
32
+ // It's opt-in (not set here by default) because it also makes Playwright
33
+ // treat ArkWeb's internal "other" targets (shared workers, etc.) as pages,
34
+ // which perturbs page-list assumptions in tests that use touchscreen /
35
+ // recordHar. Users who need multi-context set it explicitly:
36
+ // process.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1'
37
+ // before importing @playwright/test.
16
38
  register('./loader.mjs', import.meta.url);
17
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ohos-playwright",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Playwright adapter for OpenHarmony / ArkWeb via hdc + CDP",
5
5
  "license": "MIT",
6
6
  "author": "social4hyq",