ohos-playwright 0.3.3 → 0.3.5

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
@@ -69,15 +69,15 @@ The following Playwright APIs have been validated on ArkWeb / HarmonyOS 6.1 (Chr
69
69
  | WebSocket | `page.routeWebSocket()` (requires Playwright ≥ 1.48) — intercepts WebSocket connections |
70
70
  | Accessibility (CDP) | `newCDPSession` + `Accessibility.getFullAXTree` — returns the full AX node tree |
71
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; CSS `:hover` pseudo-class is **not** activated (adapter uses JS dispatch, not a real pointer move) |
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
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 (partial) | `emulateDevice({ userAgent })` — overrides `navigator.userAgent` for page scripts; call before `page.goto()` for the override to take effect. HTTP `User-Agent` request headers are not changed. |
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
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
76
  | Clipboard | `navigator.clipboard.writeText()` / `readText()` — works on HTTPS pages after `context.grantPermissions(['clipboard-read', 'clipboard-write'])`; unavailable on non-secure origins |
77
77
 
78
78
  ### `emulateDevice` fixture
79
79
 
80
- Because `newContext()` is not supported in connectOverCDP mode, device emulation is exposed as a Playwright fixture parameter backed by CDP `Emulation.*` commands.
80
+ Because `newContext()` is not available in default single-context mode (see "Multi-context / multi-page" in Limitations), device emulation is exposed as a Playwright fixture parameter backed by CDP `Emulation.*` commands.
81
81
 
82
82
  ```ts
83
83
  import { test, expect } from '@playwright/test'
@@ -99,7 +99,7 @@ test('precise viewport', async ({ page, emulateDevice }) => {
99
99
  > **⚠️ `isMobile: true` does not produce a precise viewport on ArkWeb.**
100
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
101
  >
102
- > **`userAgent` override is JS-layer only.** Call `await emulateDevice({ userAgent: '...' })` before `page.goto(url)` — `navigator.userAgent` on the destination page will reflect the override. The HTTP `User-Agent` request header is not changed (ArkWeb does not honour CDP UA overrides for outgoing headers).
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.
103
103
 
104
104
  ### `tap` fixture
105
105
 
@@ -121,10 +121,25 @@ Coordinates are CSS pixels relative to the viewport (same as `touchscreen.tap`).
121
121
  ## Limitations
122
122
 
123
123
  - **Chromium only.** firefox and webkit aren't available on HarmonyOS.
124
- - **`newPage()` after `browser.newContext()` throws.** The raw `browser.newContext()` call succeeds (returns an empty context, 0 pages) but `ctx.newPage()` fails with `Cannot read properties of undefined (reading '_page')`. ArkWeb's CDP `Target.createBrowserContext` / `Target.createTarget` aren't usable in `connectOverCDP` mode. Tests share one context and one page isolate with `localStorage.clear()` + `page.reload()`. For device emulation use the `emulateDevice` fixture instead of `browser.newContext({ ...device })`.
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
+
126
+ **Multi-worker mode.** To run tests in parallel across multiple workers, two things are required together:
127
+
128
+ 1. Set `PW_CHROMIUM_ATTACH_TO_OTHER=1` (enables `newContext()` as above).
129
+ 2. Import `test` from `ohos-playwright/parallel` instead of `ohos-playwright`:
130
+
131
+ ```ts
132
+ import { test, expect } from 'ohos-playwright/parallel'
133
+ ```
134
+
135
+ The `ohos-playwright/parallel` fixture opens one ArkWeb context per **test** via `browser.newContext()` (test-scoped) and one page via `ctx.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) apply identically.
136
+
137
+ Trade-offs to be aware of:
138
+ - **`newContext()` serialises internally in ArkWeb.** Concurrent `newContext()` calls on the same CDP connection are safe (~100 ms for 3 at once). The apparent ~3.4 s bottleneck was from concurrent `connectOverCDP` calls (connection handshakes serialise), not context creation itself. More than ~4 workers may not yield additional throughput.
139
+ - **The default `ohos-playwright` fixture is not parallel-safe.** Its `context` fixture always returns `browser.contexts()[0]` — all workers would race on the same ArkWeb tab. If you set `workers > 1` with `PW_CHROMIUM_ATTACH_TO_OTHER=1` but keep the default `test` import, `withOpenHarmony()` will warn at startup.
125
140
  - **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
141
  - **`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 narrow edge case (data: URL with embedded newlines AND a shared function reference registered for multiple event types on the same element) was previously documented; reaudit could not reproduce it. The `mouseMove` / `mouseDown` / `mouseUp` fixtures remain available as JS-dispatch fallbacks for unusual cases.
142
+ - **`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.
128
143
  - **`emulateDevice({ isMobile: true })` does not apply the viewport** — see the note in the `emulateDevice` fixture section above.
129
144
  - **`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
145
  - **`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`.
@@ -190,7 +205,7 @@ Playwright's bundled Chromium cannot exec inside the OHOS app sandbox, which bre
190
205
 
191
206
  **`defineConfig(...)` has no type hints** — set `moduleResolution` to `bundler` or `nodenext` in tsconfig.
192
207
 
193
- **`未发现设备` / no device found** — on the device, enable Developer Options → Wireless Debugging, make sure it's on the same Wi-Fi as the host, and allow inbound UDP:8710 (used by `hdc discover` broadcast). In CI, run `hdc tconn <ip:port>` manually before tests.
208
+ **`未发现设备` / 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.
194
209
 
195
210
  **`Failed to launch` the browser** — bundle not installed or wrong bundle name. List installed browsers with:
196
211
 
package/dist/config.mjs CHANGED
@@ -12,10 +12,22 @@ export function withOpenHarmony(config) {
12
12
  // overwritten platform to 'linux' by the time this function runs.
13
13
  if (!process.env.OHOS_PW_HOST)
14
14
  return config;
15
+ const multiContextOk = process.env.PW_CHROMIUM_ATTACH_TO_OTHER === '1';
16
+ const configuredWorkers = typeof config.workers === 'number' ? config.workers : 0;
17
+ if (multiContextOk && configuredWorkers > 1) {
18
+ console.warn('[ohos-playwright] workers > 1 with PW_CHROMIUM_ATTACH_TO_OTHER=1: ' +
19
+ 'make sure you import { test } from \'ohos-playwright/parallel\' — ' +
20
+ 'the default fixture shares contexts()[0] across workers and will race. ' +
21
+ 'See README "Multi-worker mode".');
22
+ }
15
23
  return {
16
24
  ...config,
17
- // Single ArkWeb instance via CDP workers must be 1.
18
- workers: 1,
25
+ // Without PW_CHROMIUM_ATTACH_TO_OTHER, all workers connect to the same
26
+ // endpoint and share contexts()[0].pages()[0] — parallel workers would
27
+ // race on the same page. Force workers:1. With the opt-in env, each
28
+ // worker can browser.newContext() + ctx.newPage() independently via
29
+ // ohos-playwright/parallel, so respect the user's workers setting.
30
+ workers: multiContextOk ? config.workers : 1,
19
31
  globalSetup: 'ohos-playwright/setup',
20
32
  globalTeardown: 'ohos-playwright/teardown',
21
33
  // ArkWeb only speaks Chromium CDP; drop firefox/webkit projects.
@@ -1,3 +1,4 @@
1
+ import type { BrowserContext, Page } from '@playwright/test';
1
2
  export interface DeviceDescriptor {
2
3
  viewport: {
3
4
  width: number;
@@ -23,6 +24,10 @@ export interface StorageState {
23
24
  }[];
24
25
  }[];
25
26
  }
27
+ export type PageCleanup = (opts?: {
28
+ navigateTo?: string;
29
+ }) => Promise<void>;
30
+ export declare function installPageWrappers(page: Page, context: BrowserContext, baseURL: string | undefined): Promise<PageCleanup>;
26
31
  export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
27
32
  emulateDevice: (descriptor: DeviceDescriptor) => Promise<void>;
28
33
  tap: (x: number, y: number) => Promise<void>;
package/dist/fixture.mjs CHANGED
@@ -16,28 +16,235 @@ function readInfo() {
16
16
  return JSON.parse(readFileSync(INFO_PATH, 'utf8'));
17
17
  }
18
18
  function readEndpoint() { return readInfo().endpoint; }
19
+ export async function installPageWrappers(page, context, baseURL) {
20
+ const ctxEmit = context.emit.bind(context);
21
+ // Patch baseURL — save and restore to prevent wrapper accumulation across
22
+ // tests that share the same page object.
23
+ const savedGoto = page['goto'];
24
+ if (baseURL) {
25
+ const root = baseURL.replace(/\/+$/, '');
26
+ const origGoto = page.goto.bind(page);
27
+ page.goto = ((url, opts) => origGoto((url.startsWith('/') && !url.startsWith('//')) ? root + url : url, opts));
28
+ }
29
+ // connectOverCDP reuses an existing tab — Playwright has no record of its
30
+ // viewport size and viewportSize() returns null. Pre-fetch via CDP.
31
+ const session = await context.newCDPSession(page);
32
+ try {
33
+ try {
34
+ const { cssVisualViewport } = await session.send('Page.getLayoutMetrics');
35
+ const cached = {
36
+ width: Math.round(cssVisualViewport.clientWidth),
37
+ height: Math.round(cssVisualViewport.clientHeight),
38
+ };
39
+ const origViewportSize = page.viewportSize.bind(page);
40
+ page.viewportSize = () => origViewportSize() ?? cached;
41
+ }
42
+ catch {
43
+ // Non-critical — viewportSize() will still return null if CDP call fails.
44
+ }
45
+ // Bring the tab to foreground so Input.dispatchMouseEvent reaches DOM listeners.
46
+ // page.mouse.move/down/up events are silently dropped when the tab is not active.
47
+ try {
48
+ const targets = await session.send('Target.getTargets');
49
+ const pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url === page.url());
50
+ if (pageTarget) {
51
+ await session.send('Target.activateTarget', { targetId: pageTarget.targetId });
52
+ }
53
+ }
54
+ catch {
55
+ // Non-fatal: some ArkWeb versions may not support Target.activateTarget.
56
+ }
57
+ }
58
+ finally {
59
+ await session.detach();
60
+ }
61
+ // Override goBack: Page.navigateToHistoryEntry hangs in ArkWeb (never resolves).
62
+ // ArkWeb also does not emit Page.frameNavigated for history navigation, so waitForURL
63
+ // never fires. Poll Page.getNavigationHistory.currentIndex instead.
64
+ ;
65
+ page.goBack = async (options) => {
66
+ const timeout = options?.timeout ?? 30000;
67
+ const s = await page.context().newCDPSession(page);
68
+ try {
69
+ const nav = await s.send('Page.getNavigationHistory');
70
+ const prevIndex = nav.currentIndex;
71
+ if (prevIndex <= 0)
72
+ return null;
73
+ await page.evaluate(() => history.back());
74
+ const deadline = Date.now() + timeout;
75
+ while (Date.now() < deadline) {
76
+ const nav2 = await s.send('Page.getNavigationHistory');
77
+ if (nav2.currentIndex < prevIndex)
78
+ break;
79
+ await new Promise(r => setTimeout(r, 80));
80
+ }
81
+ if (Date.now() >= deadline)
82
+ throw new Error(`page.goBack: Timeout ${timeout}ms exceeded`);
83
+ }
84
+ finally {
85
+ await s.detach();
86
+ }
87
+ return null;
88
+ };
89
+ page.goForward = async (options) => {
90
+ const timeout = options?.timeout ?? 30000;
91
+ const s = await page.context().newCDPSession(page);
92
+ try {
93
+ const nav = await s.send('Page.getNavigationHistory');
94
+ const prevIndex = nav.currentIndex;
95
+ if (prevIndex >= nav.entries.length - 1)
96
+ return null;
97
+ await page.evaluate(() => history.forward());
98
+ const deadline = Date.now() + timeout;
99
+ while (Date.now() < deadline) {
100
+ const nav2 = await s.send('Page.getNavigationHistory');
101
+ if (nav2.currentIndex > prevIndex)
102
+ break;
103
+ await new Promise(r => setTimeout(r, 80));
104
+ }
105
+ if (Date.now() >= deadline)
106
+ throw new Error(`page.goForward: Timeout ${timeout}ms exceeded`);
107
+ }
108
+ finally {
109
+ await s.detach();
110
+ }
111
+ return null;
112
+ };
113
+ // Override locator().hover() to bypass Playwright's internal visibility check
114
+ // (which can hang on ArkWeb), but still go through the real Input.dispatchMouseEvent
115
+ // path so the pointer position is set and CSS :hover activates. The earlier
116
+ // JS-dispatch workaround was disproved by the 2026-06-27 reaudit — ab-hover-css
117
+ // shows the native path delivers both DOM events and :hover activation.
118
+ const savedLocator = page['locator'];
119
+ const origLocator = page.locator.bind(page);
120
+ page.locator = (...args) => {
121
+ const loc = origLocator(...args);
122
+ loc.hover = async (_options) => {
123
+ // Try the real Input.dispatchMouseEvent path first (activates :hover).
124
+ // Some pages (e.g. ones with MutationObservers that re-enter layout) can
125
+ // make Playwright's evaluate/boundingBox hang on ArkWeb; fall back to a
126
+ // JS-only dispatch with a tight timeout so hover() at least returns and
127
+ // DOM listeners fire.
128
+ const viaRealMouse = await Promise.race([
129
+ (async () => {
130
+ const box = await loc.boundingBox();
131
+ if (!box)
132
+ return false;
133
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
134
+ return true;
135
+ })(),
136
+ new Promise(r => setTimeout(() => r(false), 5000)),
137
+ ]);
138
+ if (!viaRealMouse) {
139
+ await Promise.race([
140
+ loc.evaluate((el) => {
141
+ el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }));
142
+ }),
143
+ new Promise(r => setTimeout(r, 2000)),
144
+ ]).catch(() => { });
145
+ }
146
+ };
147
+ return loc;
148
+ };
149
+ // ArkWeb's new tab from window.open() is invisible to CDP (Target.createTarget hangs,
150
+ // Target.targetCreated never fires). Intercept via an init script:
151
+ // - queue the URL for our poller
152
+ // - return null (Window object hangs CDP serialization if returned)
153
+ // Guard against multiple addInitScript calls across tests accumulating overrides.
154
+ const origEvaluate = page.evaluate.bind(page);
155
+ const alreadyPatched = page['__ohosPopupPatched'];
156
+ if (!alreadyPatched) {
157
+ ;
158
+ page['__ohosPopupPatched'] = true;
159
+ await page.addInitScript(() => {
160
+ if (window['__ohosPopupPatched'])
161
+ return;
162
+ window['__ohosPopupPatched'] = true;
163
+ window['__ohosPopupQueue'] = [];
164
+ window.open = (url) => {
165
+ ;
166
+ window['__ohosPopupQueue'].push({ url: String(url ?? '') });
167
+ return null; // Window object hangs CDP serialization — return null instead
168
+ };
169
+ });
170
+ }
171
+ const popupPoller = setInterval(async () => {
172
+ try {
173
+ const pending = await origEvaluate(() => {
174
+ const q = window['__ohosPopupQueue'];
175
+ window['__ohosPopupQueue'] = [];
176
+ return q;
177
+ });
178
+ for (const { url } of pending ?? []) {
179
+ // context.newPage() calls Target.createTarget which hangs in ArkWeb.
180
+ // Emit a minimal stub — satisfies waitForLoadState / url / close.
181
+ const stub = {
182
+ waitForLoadState: async () => { },
183
+ url: () => url,
184
+ close: async () => { },
185
+ };
186
+ ctxEmit('page', stub);
187
+ }
188
+ }
189
+ catch { }
190
+ }, 150);
191
+ // evaluate() exceptions reject the promise but never become pageerror events
192
+ // (CDP catches them before they become uncaught). Intercept and re-emit.
193
+ // Save and restore to prevent wrapper accumulation across tests on the same page object.
194
+ const savedEvaluate = page['evaluate'];
195
+ page.evaluate = async (fn, arg) => {
196
+ try {
197
+ return await origEvaluate(fn, arg);
198
+ }
199
+ catch (e) {
200
+ const err = e instanceof Error ? e : new Error(String(e));
201
+ page.emit('pageerror', err);
202
+ }
203
+ };
204
+ return async (opts) => {
205
+ clearInterval(popupPoller);
206
+ page.evaluate = savedEvaluate;
207
+ page.locator = savedLocator;
208
+ if (opts?.navigateTo) {
209
+ // Reset the shared tab before restoring goto — keeps the connection alive.
210
+ // page.close() would terminate the ArkWeb DevTools socket.
211
+ try {
212
+ await page.goto(opts.navigateTo);
213
+ }
214
+ catch { }
215
+ }
216
+ ;
217
+ page.goto = savedGoto;
218
+ };
219
+ }
19
220
  export const test = base.extend({
20
221
  browser: [
21
222
  async ({}, use) => {
22
223
  const browser = await chromium.connectOverCDP(readEndpoint());
23
- // ArkWeb CDP does not implement Target.createBrowserContext — connectOverCDP
24
- // reuses the single existing context. browser.newContext() returns without
25
- // throwing but produces an empty shell (0 pages) that silently fails every
26
- // subsequent operation. Intercept and throw an explicit, actionable error
27
- // so users aren't misled by the false-positive success.
28
- const origNewContext = browser.newContext.bind(browser);
29
- browser.newContext = (() => {
30
- throw new Error('browser.newContext() is not supported in ArkWeb CDP mode (single context only). ' +
31
- 'Tests share one context and one page — isolate with localStorage.clear() + page.reload(). ' +
32
- 'See ohos-playwright README "Limitations" section.');
33
- });
34
- try {
35
- await use(browser);
224
+ // ArkWeb's Target.createTarget returns type='other', so Playwright's
225
+ // crBrowser._onAttachedToTarget skips it and ctx.newPage() throws
226
+ // "Cannot read properties of undefined (reading '_page')". The upstream
227
+ // PW_CHROMIUM_ATTACH_TO_OTHER=1 escape hatch fixes newContext/newPage
228
+ // but also makes Playwright treat internal "other" targets as pages
229
+ // (perturbs touchscreen / recordHar). When the user has not opted in,
230
+ // intercept browser.newContext() with a friendly, actionable error.
231
+ if (!process.env.PW_CHROMIUM_ATTACH_TO_OTHER) {
232
+ const origNewContext = browser.newContext.bind(browser);
233
+ browser.newContext = (() => {
234
+ throw new Error('browser.newContext() is not supported on ArkWeb unless you opt in via ' +
235
+ 'process.env.PW_CHROMIUM_ATTACH_TO_OTHER=\'1\' before importing @playwright/test. ' +
236
+ 'See ohos-playwright README "Limitations" → "Multi-context" for trade-offs.');
237
+ });
238
+ try {
239
+ await use(browser);
240
+ }
241
+ finally {
242
+ ;
243
+ browser.newContext = origNewContext;
244
+ }
36
245
  }
37
- finally {
38
- // Restore in case the browser object is reused across workers.
39
- ;
40
- browser.newContext = origNewContext;
246
+ else {
247
+ await use(browser);
41
248
  }
42
249
  },
43
250
  { scope: 'worker' },
@@ -66,210 +273,12 @@ export const test = base.extend({
66
273
  const page = info.openedNewTab
67
274
  ? ([...pages].reverse().find((p) => p.url() === (info.launchUrl ?? 'about:blank')) ?? pages[pages.length - 1])
68
275
  : (pages.find((p) => p.url().startsWith('http://localhost')) ?? pages[0]);
69
- const ctxEmit = context.emit.bind(context);
70
- // Patch baseURL — save and restore to prevent wrapper accumulation across
71
- // tests that share the same page object.
72
- const savedGoto = page['goto'];
73
- const baseURL = testInfo.project.use.baseURL;
74
- if (baseURL) {
75
- const root = baseURL.replace(/\/+$/, '');
76
- const origGoto = page.goto.bind(page);
77
- page.goto = ((url, opts) => origGoto((url.startsWith('/') && !url.startsWith('//')) ? root + url : url, opts));
78
- }
79
- // connectOverCDP reuses an existing tab — Playwright has no record of its
80
- // viewport size and viewportSize() returns null. Pre-fetch via CDP.
81
- const session = await context.newCDPSession(page);
82
- try {
83
- try {
84
- const { cssVisualViewport } = await session.send('Page.getLayoutMetrics');
85
- const cached = {
86
- width: Math.round(cssVisualViewport.clientWidth),
87
- height: Math.round(cssVisualViewport.clientHeight),
88
- };
89
- const origViewportSize = page.viewportSize.bind(page);
90
- page.viewportSize = () => origViewportSize() ?? cached;
91
- }
92
- catch {
93
- // Non-critical — viewportSize() will still return null if CDP call fails.
94
- }
95
- // Bring the tab to foreground so Input.dispatchMouseEvent reaches DOM listeners.
96
- // page.mouse.move/down/up events are silently dropped when the tab is not active.
97
- try {
98
- const targets = await session.send('Target.getTargets');
99
- const pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url === page.url());
100
- if (pageTarget) {
101
- await session.send('Target.activateTarget', { targetId: pageTarget.targetId });
102
- }
103
- }
104
- catch {
105
- // Non-fatal: some ArkWeb versions may not support Target.activateTarget.
106
- }
107
- }
108
- finally {
109
- await session.detach();
110
- }
111
- // Override goBack: Page.navigateToHistoryEntry hangs in ArkWeb (never resolves).
112
- // ArkWeb also does not emit Page.frameNavigated for history navigation, so waitForURL
113
- // never fires. Poll Page.getNavigationHistory.currentIndex instead.
114
- ;
115
- page.goBack = async (options) => {
116
- const timeout = options?.timeout ?? 30000;
117
- const s = await page.context().newCDPSession(page);
118
- try {
119
- const nav = await s.send('Page.getNavigationHistory');
120
- const prevIndex = nav.currentIndex;
121
- if (prevIndex <= 0)
122
- return null;
123
- await page.evaluate(() => history.back());
124
- const deadline = Date.now() + timeout;
125
- while (Date.now() < deadline) {
126
- const nav2 = await s.send('Page.getNavigationHistory');
127
- if (nav2.currentIndex < prevIndex)
128
- break;
129
- await new Promise(r => setTimeout(r, 80));
130
- }
131
- if (Date.now() >= deadline)
132
- throw new Error(`page.goBack: Timeout ${timeout}ms exceeded`);
133
- }
134
- finally {
135
- await s.detach();
136
- }
137
- return null;
138
- };
139
- page.goForward = async (options) => {
140
- const timeout = options?.timeout ?? 30000;
141
- const s = await page.context().newCDPSession(page);
142
- try {
143
- const nav = await s.send('Page.getNavigationHistory');
144
- const prevIndex = nav.currentIndex;
145
- if (prevIndex >= nav.entries.length - 1)
146
- return null;
147
- await page.evaluate(() => history.forward());
148
- const deadline = Date.now() + timeout;
149
- while (Date.now() < deadline) {
150
- const nav2 = await s.send('Page.getNavigationHistory');
151
- if (nav2.currentIndex > prevIndex)
152
- break;
153
- await new Promise(r => setTimeout(r, 80));
154
- }
155
- if (Date.now() >= deadline)
156
- throw new Error(`page.goForward: Timeout ${timeout}ms exceeded`);
157
- }
158
- finally {
159
- await s.detach();
160
- }
161
- return null;
162
- };
163
- // Override locator().hover() to bypass Playwright's internal visibility check
164
- // (which can hang on ArkWeb), but still go through the real Input.dispatchMouseEvent
165
- // path so the pointer position is set and CSS :hover activates. The earlier
166
- // JS-dispatch workaround was disproved by the 2026-06-27 reaudit — ab-hover-css
167
- // shows the native path delivers both DOM events and :hover activation.
168
- const savedLocator = page['locator'];
169
- const origLocator = page.locator.bind(page);
170
- page.locator = (...args) => {
171
- const loc = origLocator(...args);
172
- loc.hover = async (_options) => {
173
- // Try the real Input.dispatchMouseEvent path first (activates :hover).
174
- // Some pages (e.g. ones with MutationObservers that re-enter layout) can
175
- // make Playwright's evaluate/boundingBox hang on ArkWeb; fall back to a
176
- // JS-only dispatch with a tight timeout so hover() at least returns and
177
- // DOM listeners fire.
178
- const viaRealMouse = await Promise.race([
179
- (async () => {
180
- const box = await loc.boundingBox();
181
- if (!box)
182
- return false;
183
- await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
184
- return true;
185
- })(),
186
- new Promise(r => setTimeout(() => r(false), 5000)),
187
- ]);
188
- if (!viaRealMouse) {
189
- await Promise.race([
190
- loc.evaluate((el) => {
191
- el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true }));
192
- }),
193
- new Promise(r => setTimeout(r, 2000)),
194
- ]).catch(() => { });
195
- }
196
- };
197
- return loc;
198
- };
199
- // ArkWeb's new tab from window.open() is invisible to CDP (Target.createTarget hangs,
200
- // Target.targetCreated never fires). Intercept via an init script:
201
- // - queue the URL for our poller
202
- // - return null (Window object hangs CDP serialization if returned)
203
- // Guard against multiple addInitScript calls across tests accumulating overrides.
204
- const origEvaluate = page.evaluate.bind(page);
205
- const alreadyPatched = page['__ohosPopupPatched'];
206
- if (!alreadyPatched) {
207
- ;
208
- page['__ohosPopupPatched'] = true;
209
- await page.addInitScript(() => {
210
- if (window['__ohosPopupPatched'])
211
- return;
212
- window['__ohosPopupPatched'] = true;
213
- window['__ohosPopupQueue'] = [];
214
- window.open = (url) => {
215
- ;
216
- window['__ohosPopupQueue'].push({ url: String(url ?? '') });
217
- return null; // Window object hangs CDP serialization — return null instead
218
- };
219
- });
220
- }
221
- const popupPoller = setInterval(async () => {
222
- try {
223
- const pending = await origEvaluate(() => {
224
- const q = window['__ohosPopupQueue'];
225
- window['__ohosPopupQueue'] = [];
226
- return q;
227
- });
228
- for (const { url } of pending ?? []) {
229
- // context.newPage() calls Target.createTarget which hangs in ArkWeb.
230
- // Emit a minimal stub — satisfies waitForLoadState / url / close.
231
- const stub = {
232
- waitForLoadState: async () => { },
233
- url: () => url,
234
- close: async () => { },
235
- };
236
- ctxEmit('page', stub);
237
- }
238
- }
239
- catch { }
240
- }, 150);
241
- // evaluate() exceptions reject the promise but never become pageerror events
242
- // (CDP catches them before they become uncaught). Intercept and re-emit.
243
- // Save and restore to prevent wrapper accumulation across tests on the same page object.
244
- const savedEvaluate = page['evaluate'];
245
- page.evaluate = async (fn, arg) => {
246
- try {
247
- return await origEvaluate(fn, arg);
248
- }
249
- catch (e) {
250
- const err = e instanceof Error ? e : new Error(String(e));
251
- page.emit('pageerror', err);
252
- }
253
- };
276
+ const cleanup = await installPageWrappers(page, context, testInfo.project.use.baseURL);
254
277
  try {
255
278
  await use(page);
256
279
  }
257
280
  finally {
258
- clearInterval(popupPoller);
259
- page.evaluate = savedEvaluate;
260
- page.locator = savedLocator;
261
- // Reset the test tab to about:blank so it's clean for the next test.
262
- // page.close() sends Target.closeTarget which terminates the ArkWeb
263
- // DevTools socket — use goto instead to keep the connection alive.
264
- if (info.openedNewTab) {
265
- try {
266
- await page.goto('about:blank');
267
- }
268
- catch { }
269
- }
270
- // Restore goto to prevent wrapper accumulation across tests.
271
- ;
272
- page.goto = savedGoto;
281
+ await cleanup({ navigateTo: info.openedNewTab ? 'about:blank' : undefined });
273
282
  }
274
283
  },
275
284
  emulateDevice: async ({ page }, use) => {
package/dist/loader.d.mts CHANGED
@@ -1,15 +1,2 @@
1
- interface ResolveContext {
2
- parentURL?: string;
3
- [key: string]: unknown;
4
- }
5
- interface NextResolve {
6
- (specifier: string, context: ResolveContext): {
7
- url: string;
8
- } | Promise<{
9
- url: string;
10
- }>;
11
- }
12
- export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): Promise<{
13
- url: string;
14
- }>;
15
- export {};
1
+ import type { ResolveHookContext, ResolveFnOutput } from 'node:module';
2
+ export declare function resolve(specifier: string, context: ResolveHookContext, nextResolve: (specifier: string, context?: Partial<ResolveHookContext>) => ResolveFnOutput): ResolveFnOutput;
package/dist/loader.mjs CHANGED
@@ -10,13 +10,12 @@ const FIXTURE_HELPER = /\bfixtures?\.[mc]?[tj]sx?$/;
10
10
  const OWN_DIST_URL = pathToFileURL(resolvePath(import.meta.dirname) + '/').href;
11
11
  const PACKAGE_ROOT_URL = pathToFileURL(resolvePath(import.meta.dirname, '..') + '/').href;
12
12
  const PROJECT_ANCHOR = pathToFileURL(resolvePath(process.cwd(), 'noop.mjs')).href;
13
- export async function resolve(specifier, context, nextResolve) {
13
+ export function resolve(specifier, context, nextResolve) {
14
14
  if (specifier === TARGET) {
15
15
  const parent = context.parentURL ?? '';
16
16
  const isFromOwnDist = parent.startsWith(OWN_DIST_URL);
17
17
  if (TEST_FILE.test(parent) || (FIXTURE_HELPER.test(parent) && !isFromOwnDist)) {
18
- const result = await nextResolve(FIXTURE_URL, context);
19
- return { url: result.url };
18
+ return nextResolve(FIXTURE_URL, context);
20
19
  }
21
20
  if (parent.startsWith(PACKAGE_ROOT_URL)) {
22
21
  return nextResolve(specifier, { ...context, parentURL: PROJECT_ANCHOR });
@@ -0,0 +1,17 @@
1
+ import type { BrowserContext, Page } from '@playwright/test';
2
+ export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
3
+ emulateDevice: (descriptor: import("./fixture.mts").DeviceDescriptor) => Promise<void>;
4
+ tap: (x: number, y: number) => Promise<void>;
5
+ mouseMove: (x: number, y: number, opts?: {
6
+ steps?: number;
7
+ }) => Promise<void>;
8
+ mouseDown: (x: number, y: number) => Promise<void>;
9
+ mouseUp: (x: number, y: number) => Promise<void>;
10
+ saveStorageState: (origin?: string) => Promise<import("./fixture.mts").StorageState>;
11
+ loadStorageState: (state: import("./fixture.mts").StorageState) => Promise<void>;
12
+ emulateLocale: (locale: string) => Promise<void>;
13
+ } & {
14
+ context: BrowserContext;
15
+ page: Page;
16
+ }, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
17
+ export { expect } from '@playwright/test';
@@ -0,0 +1,48 @@
1
+ import { test as base, installPageWrappers } from "./fixture.mjs";
2
+ // Parallel-safe test fixture for ohos-playwright.
3
+ //
4
+ // Requirements:
5
+ // - PW_CHROMIUM_ATTACH_TO_OTHER=1 must be set before importing @playwright/test
6
+ // - Use withOpenHarmony() in your playwright.config.ts as usual
7
+ //
8
+ // Differences from the default 'ohos-playwright' fixture:
9
+ // - Each test opens its own ArkWeb context via browser.newContext() (test-scoped).
10
+ // Cookies and localStorage are fully isolated between tests.
11
+ // - Each test gets ctx.newPage() and both page and context are closed after the
12
+ // test. All ArkWeb workarounds (goBack, goForward, popup, hover,
13
+ // evaluate→pageerror) are applied identically to the default fixture.
14
+ // - newContext() in ArkWeb costs ~100 ms; concurrent creation is safe.
15
+ //
16
+ // Usage:
17
+ // import { test, expect } from 'ohos-playwright/parallel'
18
+ export const test = base.extend({
19
+ context: async ({ browser }, use, testInfo) => {
20
+ if (!process.env.PW_CHROMIUM_ATTACH_TO_OTHER) {
21
+ throw new Error('[ohos-playwright/parallel] PW_CHROMIUM_ATTACH_TO_OTHER=\'1\' must be set before ' +
22
+ 'importing @playwright/test when using this fixture. ' +
23
+ 'See ohos-playwright README "Multi-worker mode".');
24
+ }
25
+ const baseURL = testInfo.project.use.baseURL;
26
+ const ctx = await browser.newContext(baseURL ? { baseURL } : {});
27
+ try {
28
+ await use(ctx);
29
+ }
30
+ finally {
31
+ await ctx.close().catch(() => { });
32
+ }
33
+ },
34
+ page: async ({ context }, use) => {
35
+ const page = await context.newPage();
36
+ // baseURL is set on the context via newContext({ baseURL }), so Playwright
37
+ // resolves relative URLs natively — pass undefined to skip the goto patch.
38
+ const cleanup = await installPageWrappers(page, context, undefined);
39
+ try {
40
+ await use(page);
41
+ }
42
+ finally {
43
+ await cleanup();
44
+ await page.close().catch(() => { });
45
+ }
46
+ },
47
+ });
48
+ export { expect } from '@playwright/test';
package/dist/register.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { register } from 'node:module';
1
+ import { registerHooks } from 'node:module';
2
+ import { resolve } from './loader.mjs';
2
3
  // Adapter only activates on OpenHarmony — elsewhere this file is a no-op so
3
4
  // the same ohos-playwright entry point and the same playwright.config.ts can
4
5
  // run on Windows / Linux / macOS with stock Playwright.
@@ -21,5 +22,19 @@ if (process.platform === 'openharmony') {
21
22
  // process. Safe because we connect over CDP and never touch Playwright's
22
23
  // bundled browser binaries. Clean fix requires upstream openharmony branch.
23
24
  Object.defineProperty(process, 'platform', { value: 'linux' });
24
- register('./loader.mjs', import.meta.url);
25
+ // ArkWeb's Target.createTarget returns a target with type='other', not
26
+ // 'page'. Playwright's crBrowser._onAttachedToTarget (crBrowser.ts:191)
27
+ // only registers 'page' targets into _crPages — 'other' targets get
28
+ // detached and ctx.newPage() throws "Cannot read properties of undefined
29
+ // (reading '_page')".
30
+ //
31
+ // PW_CHROMIUM_ATTACH_TO_OTHER (crBrowser.ts:181) is playwright's upstream
32
+ // escape hatch: treat type='other' as page so newContext/newPage work.
33
+ // It's opt-in (not set here by default) because it also makes Playwright
34
+ // treat ArkWeb's internal "other" targets (shared workers, etc.) as pages,
35
+ // which perturbs page-list assumptions in tests that use touchscreen /
36
+ // recordHar. Users who need multi-context set it explicitly:
37
+ // process.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1'
38
+ // before importing @playwright/test.
39
+ registerHooks({ resolve });
25
40
  }
package/dist/setup.d.mts CHANGED
@@ -7,7 +7,6 @@ export declare function retry<T>(fn: () => T | Promise<T>, { max, interval, labe
7
7
  export declare function findBrowserPid(): number | null;
8
8
  export declare function countCdpPages(listJson: string): number;
9
9
  export declare function hasDeviceConnected(): boolean;
10
- export declare function discoverDevices(): string[];
11
10
  export declare function tryLocalDevice(): boolean;
12
11
  export declare function ensureHdcKey(): boolean;
13
12
  export declare function ensureDeviceConnected(): Promise<void>;
package/dist/setup.mjs CHANGED
@@ -107,13 +107,34 @@ function pickFreePort() {
107
107
  srv.on('error', rej);
108
108
  });
109
109
  }
110
+ function parseFportRules(lsOutput) {
111
+ const rules = [];
112
+ for (const line of lsOutput.split('\n')) {
113
+ const m = line.match(/tcp:(\d+)\s+(localabstract:\S+)/);
114
+ if (m)
115
+ rules.push({ port: m[1], socket: m[2] });
116
+ }
117
+ return rules;
118
+ }
110
119
  function setupForward(port, socketName) {
111
- const ruler = `tcp:${port} localabstract:${socketName}`;
120
+ // Remove all existing rules for this socket (any port) before creating a new
121
+ // one — prevents rule accumulation from crashed runs. hdc fport rm requires
122
+ // separate arguments; a single "tcp:PORT localabstract:SOCKET" string is
123
+ // silently ignored by hdc.
112
124
  try {
113
- hdc(['fport', 'rm', ruler]);
125
+ const ls = hdc(['fport', 'ls']);
126
+ const target = `localabstract:${socketName}`;
127
+ for (const { port: p, socket: s } of parseFportRules(ls)) {
128
+ if (s === target) {
129
+ try {
130
+ hdc(['fport', 'rm', `tcp:${p}`, s]);
131
+ }
132
+ catch { }
133
+ }
134
+ }
114
135
  }
115
136
  catch { }
116
- hdc(['fport', 'tcp:' + port, 'localabstract:' + socketName]);
137
+ hdc(['fport', `tcp:${port}`, `localabstract:${socketName}`]);
117
138
  }
118
139
  function cdpGet(port, path) {
119
140
  return new Promise((res) => {
@@ -142,18 +163,6 @@ export function hasDeviceConnected() {
142
163
  const t = listTargets();
143
164
  return t.length > 0 && t !== '[Empty]';
144
165
  }
145
- // discoverDevices timeout reduced from 6s to 3s — LAN broadcast on local
146
- // network should respond within 1-2s; longer wait is unlikely to help.
147
- export function discoverDevices() {
148
- let out = '';
149
- try {
150
- out = hdc(['discover'], { timeout: 3000 });
151
- }
152
- catch (e) {
153
- out = e.stdout?.toString() ?? '';
154
- }
155
- return out.split('\n').map(s => s.trim()).filter(s => IP_PORT_RE.test(s));
156
- }
157
166
  // 已连接时 hdc tconn 返回 "Target is connected, repeat operation",也视作成功。
158
167
  function tconn(addr) {
159
168
  try {
@@ -175,7 +184,6 @@ const CONNECT_HELP = [
175
184
  ' 1) 进入「设置 → 关于本机」连点版本号开启「开发者选项」',
176
185
  ' 2) 进入「开发者选项」启用「无线调试」',
177
186
  ' 3) 确认设备与本机在同一 Wi-Fi 下',
178
- ' 4) 防火墙放行本机 UDP:8710 入站(hdc discover 广播用)',
179
187
  '也可手动跑 `hdc tconn <ip:port>` 后重新启动测试。',
180
188
  '若不希望自动连接,设 OHOS_PW_AUTO_CONNECT=0 跳过。',
181
189
  ].join('\n');
@@ -264,15 +272,6 @@ export async function ensureDeviceConnected() {
264
272
  return;
265
273
  if (tryLocalDevice())
266
274
  return;
267
- console.log('[ohos-playwright] no local device, broadcasting (hdc discover)...');
268
- const found = discoverDevices();
269
- for (const addr of found) {
270
- console.log(`[ohos-playwright] hdc tconn ${addr}`);
271
- if (tconn(addr) && hasDeviceConnected())
272
- return;
273
- }
274
- if (found.length > 0)
275
- console.warn('[ohos-playwright] discovered devices but none connected');
276
275
  if (!process.stdin.isTTY)
277
276
  throw new Error(CONNECT_HELP);
278
277
  console.log(CONNECT_HELP);
package/dist/teardown.mjs CHANGED
@@ -10,10 +10,13 @@ export default async function globalTeardown() {
10
10
  catch {
11
11
  return;
12
12
  }
13
- const ruler = `tcp:${info.port} localabstract:${info.socket}`;
13
+ const tcpArg = `tcp:${info.port}`;
14
+ const sockArg = `localabstract:${info.socket}`;
14
15
  try {
15
- execFileSync(HDC, ['fport', 'rm', ruler], { stdio: ['ignore', 'pipe', 'pipe'] });
16
- console.log(`[ohos-playwright] removed fport ${ruler}`);
16
+ // hdc fport rm requires separate arguments a single combined string is
17
+ // silently ignored.
18
+ execFileSync(HDC, ['fport', 'rm', tcpArg, sockArg], { stdio: ['ignore', 'pipe', 'pipe'] });
19
+ console.log(`[ohos-playwright] removed fport ${tcpArg} ${sockArg}`);
17
20
  }
18
21
  catch (e) {
19
22
  const msg = e instanceof Error ? e.message?.split('\n')[0] : String(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ohos-playwright",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Playwright adapter for OpenHarmony / ArkWeb via hdc + CDP",
5
5
  "license": "MIT",
6
6
  "author": "social4hyq",
@@ -38,7 +38,8 @@
38
38
  "./teardown": "./dist/teardown.mjs",
39
39
  "./register": "./dist/register.mjs",
40
40
  "./loader": "./dist/loader.mjs",
41
- "./config": "./dist/config.mjs"
41
+ "./config": "./dist/config.mjs",
42
+ "./parallel": "./dist/parallel.mjs"
42
43
  },
43
44
  "bin": {
44
45
  "ohos-playwright": "./dist/cli.mjs"