ohos-playwright 0.3.4 → 0.4.0

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
@@ -77,7 +77,7 @@ The following Playwright APIs have been validated on ArkWeb / HarmonyOS 6.1 (Chr
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'
@@ -122,6 +122,21 @@ Coordinates are CSS pixels relative to the viewport (same as `touchscreen.tap`).
122
122
 
123
123
  - **Chromium only.** firefox and webkit aren't available on HarmonyOS.
124
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
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.
@@ -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,14 +24,13 @@ 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>;
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>;
34
34
  saveStorageState: (origin?: string) => Promise<StorageState>;
35
35
  loadStorageState: (state: StorageState) => Promise<void>;
36
36
  emulateLocale: (locale: string) => Promise<void>;
package/dist/fixture.mjs CHANGED
@@ -16,6 +16,165 @@ 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
+ // connectOverCDP reuses an existing tab — Playwright has no record of its
22
+ // viewport size and viewportSize() returns null. Pre-fetch via CDP.
23
+ const session = await context.newCDPSession(page);
24
+ try {
25
+ try {
26
+ const { cssVisualViewport } = await session.send('Page.getLayoutMetrics');
27
+ const cached = {
28
+ width: Math.round(cssVisualViewport.clientWidth),
29
+ height: Math.round(cssVisualViewport.clientHeight),
30
+ };
31
+ const origViewportSize = page.viewportSize.bind(page);
32
+ page.viewportSize = () => origViewportSize() ?? cached;
33
+ }
34
+ catch {
35
+ // Non-critical — viewportSize() will still return null if CDP call fails.
36
+ }
37
+ }
38
+ finally {
39
+ await session.detach();
40
+ }
41
+ // Override goBack: Page.navigateToHistoryEntry hangs in ArkWeb (never resolves).
42
+ // ArkWeb also does not emit Page.frameNavigated for history navigation, so waitForURL
43
+ // never fires. Poll Page.getNavigationHistory.currentIndex instead.
44
+ ;
45
+ page.goBack = async (options) => {
46
+ const timeout = options?.timeout ?? 30000;
47
+ const s = await page.context().newCDPSession(page);
48
+ try {
49
+ const nav = await s.send('Page.getNavigationHistory');
50
+ const prevIndex = nav.currentIndex;
51
+ if (prevIndex <= 0)
52
+ return null;
53
+ await page.evaluate(() => history.back());
54
+ const deadline = Date.now() + timeout;
55
+ while (Date.now() < deadline) {
56
+ const nav2 = await s.send('Page.getNavigationHistory');
57
+ if (nav2.currentIndex < prevIndex)
58
+ break;
59
+ await new Promise(r => setTimeout(r, 80));
60
+ }
61
+ if (Date.now() >= deadline)
62
+ throw new Error(`page.goBack: Timeout ${timeout}ms exceeded`);
63
+ }
64
+ finally {
65
+ await s.detach();
66
+ }
67
+ return null;
68
+ };
69
+ page.goForward = async (options) => {
70
+ const timeout = options?.timeout ?? 30000;
71
+ const s = await page.context().newCDPSession(page);
72
+ try {
73
+ const nav = await s.send('Page.getNavigationHistory');
74
+ const prevIndex = nav.currentIndex;
75
+ if (prevIndex >= nav.entries.length - 1)
76
+ return null;
77
+ await page.evaluate(() => history.forward());
78
+ const deadline = Date.now() + timeout;
79
+ while (Date.now() < deadline) {
80
+ const nav2 = await s.send('Page.getNavigationHistory');
81
+ if (nav2.currentIndex > prevIndex)
82
+ break;
83
+ await new Promise(r => setTimeout(r, 80));
84
+ }
85
+ if (Date.now() >= deadline)
86
+ throw new Error(`page.goForward: Timeout ${timeout}ms exceeded`);
87
+ }
88
+ finally {
89
+ await s.detach();
90
+ }
91
+ return null;
92
+ };
93
+ // Override locator().hover() to use page.mouse.move() directly so CSS :hover
94
+ // activates. Playwright's built-in hover() goes through a visibility check path
95
+ // that can hang on ArkWeb; bypassing it is safe because mouse.move() has been
96
+ // verified to deliver both DOM events and :hover activation (ab-hover-css probe).
97
+ const savedLocator = page['locator'];
98
+ const origLocator = page.locator.bind(page);
99
+ page.locator = (...args) => {
100
+ const loc = origLocator(...args);
101
+ loc.hover = async (_options) => {
102
+ const box = await loc.boundingBox();
103
+ if (!box)
104
+ return;
105
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
106
+ };
107
+ return loc;
108
+ };
109
+ // ArkWeb's new tab from window.open() is invisible to CDP (Target.createTarget hangs,
110
+ // Target.targetCreated never fires). Intercept via an init script:
111
+ // - queue the URL for our poller
112
+ // - return null (Window object hangs CDP serialization if returned)
113
+ // Guard against multiple addInitScript calls across tests accumulating overrides.
114
+ const origEvaluate = page.evaluate.bind(page);
115
+ const alreadyPatched = page['__ohosPopupPatched'];
116
+ if (!alreadyPatched) {
117
+ ;
118
+ page['__ohosPopupPatched'] = true;
119
+ await page.addInitScript(() => {
120
+ if (window['__ohosPopupPatched'])
121
+ return;
122
+ window['__ohosPopupPatched'] = true;
123
+ window['__ohosPopupQueue'] = [];
124
+ window.open = (url) => {
125
+ ;
126
+ window['__ohosPopupQueue'].push({ url: String(url ?? '') });
127
+ return null; // Window object hangs CDP serialization — return null instead
128
+ };
129
+ });
130
+ }
131
+ const popupPoller = setInterval(async () => {
132
+ try {
133
+ const pending = await origEvaluate(() => {
134
+ const q = window['__ohosPopupQueue'];
135
+ window['__ohosPopupQueue'] = [];
136
+ return q;
137
+ });
138
+ for (const { url } of pending ?? []) {
139
+ // context.newPage() calls Target.createTarget which hangs in ArkWeb.
140
+ // Emit a minimal stub — satisfies waitForLoadState / url / close.
141
+ const stub = {
142
+ waitForLoadState: async () => { },
143
+ url: () => url,
144
+ close: async () => { },
145
+ };
146
+ ctxEmit('page', stub);
147
+ }
148
+ }
149
+ catch { }
150
+ }, 150);
151
+ // evaluate() exceptions reject the promise but never become pageerror events
152
+ // (CDP catches them before they become uncaught). Intercept and re-emit.
153
+ // Save and restore to prevent wrapper accumulation across tests on the same page object.
154
+ const savedEvaluate = page['evaluate'];
155
+ page.evaluate = async (fn, arg) => {
156
+ try {
157
+ return await origEvaluate(fn, arg);
158
+ }
159
+ catch (e) {
160
+ const err = e instanceof Error ? e : new Error(String(e));
161
+ page.emit('pageerror', err);
162
+ }
163
+ };
164
+ return async (opts) => {
165
+ clearInterval(popupPoller);
166
+ page.evaluate = savedEvaluate;
167
+ page.locator = savedLocator;
168
+ if (opts?.navigateTo) {
169
+ // Reset the shared tab to a neutral state — page.close() would terminate
170
+ // the ArkWeb DevTools socket so we navigate instead.
171
+ try {
172
+ await page.goto(opts.navigateTo);
173
+ }
174
+ catch { }
175
+ }
176
+ };
177
+ }
19
178
  export const test = base.extend({
20
179
  browser: [
21
180
  async ({}, use) => {
@@ -72,210 +231,12 @@ export const test = base.extend({
72
231
  const page = info.openedNewTab
73
232
  ? ([...pages].reverse().find((p) => p.url() === (info.launchUrl ?? 'about:blank')) ?? pages[pages.length - 1])
74
233
  : (pages.find((p) => p.url().startsWith('http://localhost')) ?? pages[0]);
75
- const ctxEmit = context.emit.bind(context);
76
- // Patch baseURL — save and restore to prevent wrapper accumulation across
77
- // tests that share the same page object.
78
- const savedGoto = page['goto'];
79
- const baseURL = testInfo.project.use.baseURL;
80
- if (baseURL) {
81
- const root = baseURL.replace(/\/+$/, '');
82
- const origGoto = page.goto.bind(page);
83
- page.goto = ((url, opts) => origGoto((url.startsWith('/') && !url.startsWith('//')) ? root + url : url, opts));
84
- }
85
- // connectOverCDP reuses an existing tab — Playwright has no record of its
86
- // viewport size and viewportSize() returns null. Pre-fetch via CDP.
87
- const session = await context.newCDPSession(page);
88
- try {
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
- }
113
- }
114
- finally {
115
- await session.detach();
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
- };
205
- // ArkWeb's new tab from window.open() is invisible to CDP (Target.createTarget hangs,
206
- // Target.targetCreated never fires). Intercept via an init script:
207
- // - queue the URL for our poller
208
- // - return null (Window object hangs CDP serialization if returned)
209
- // Guard against multiple addInitScript calls across tests accumulating overrides.
210
- const origEvaluate = page.evaluate.bind(page);
211
- const alreadyPatched = page['__ohosPopupPatched'];
212
- if (!alreadyPatched) {
213
- ;
214
- page['__ohosPopupPatched'] = true;
215
- await page.addInitScript(() => {
216
- if (window['__ohosPopupPatched'])
217
- return;
218
- window['__ohosPopupPatched'] = true;
219
- window['__ohosPopupQueue'] = [];
220
- window.open = (url) => {
221
- ;
222
- window['__ohosPopupQueue'].push({ url: String(url ?? '') });
223
- return null; // Window object hangs CDP serialization — return null instead
224
- };
225
- });
226
- }
227
- const popupPoller = setInterval(async () => {
228
- try {
229
- const pending = await origEvaluate(() => {
230
- const q = window['__ohosPopupQueue'];
231
- window['__ohosPopupQueue'] = [];
232
- return q;
233
- });
234
- for (const { url } of pending ?? []) {
235
- // context.newPage() calls Target.createTarget which hangs in ArkWeb.
236
- // Emit a minimal stub — satisfies waitForLoadState / url / close.
237
- const stub = {
238
- waitForLoadState: async () => { },
239
- url: () => url,
240
- close: async () => { },
241
- };
242
- ctxEmit('page', stub);
243
- }
244
- }
245
- catch { }
246
- }, 150);
247
- // evaluate() exceptions reject the promise but never become pageerror events
248
- // (CDP catches them before they become uncaught). Intercept and re-emit.
249
- // Save and restore to prevent wrapper accumulation across tests on the same page object.
250
- const savedEvaluate = page['evaluate'];
251
- page.evaluate = async (fn, arg) => {
252
- try {
253
- return await origEvaluate(fn, arg);
254
- }
255
- catch (e) {
256
- const err = e instanceof Error ? e : new Error(String(e));
257
- page.emit('pageerror', err);
258
- }
259
- };
234
+ const cleanup = await installPageWrappers(page, context, testInfo.project.use.baseURL);
260
235
  try {
261
236
  await use(page);
262
237
  }
263
238
  finally {
264
- clearInterval(popupPoller);
265
- page.evaluate = savedEvaluate;
266
- page.locator = savedLocator;
267
- // Reset the test tab to about:blank so it's clean for the next test.
268
- // page.close() sends Target.closeTarget which terminates the ArkWeb
269
- // DevTools socket — use goto instead to keep the connection alive.
270
- if (info.openedNewTab) {
271
- try {
272
- await page.goto('about:blank');
273
- }
274
- catch { }
275
- }
276
- // Restore goto to prevent wrapper accumulation across tests.
277
- ;
278
- page.goto = savedGoto;
239
+ await cleanup({ navigateTo: info.openedNewTab ? 'about:blank' : undefined });
279
240
  }
280
241
  },
281
242
  emulateDevice: async ({ page }, use) => {
@@ -339,45 +300,6 @@ export const test = base.extend({
339
300
  }
340
301
  });
341
302
  },
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
- },
381
303
  saveStorageState: async ({ page, context }, use) => {
382
304
  await use(async (origin) => {
383
305
  const derivedOrigin = origin ?? new URL(page.url()).origin;
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,12 @@
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
+ saveStorageState: (origin?: string) => Promise<import("./fixture.mts").StorageState>;
6
+ loadStorageState: (state: import("./fixture.mts").StorageState) => Promise<void>;
7
+ emulateLocale: (locale: string) => Promise<void>;
8
+ } & {
9
+ context: BrowserContext;
10
+ page: Page;
11
+ }, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
12
+ 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.
@@ -35,5 +36,5 @@ if (process.platform === 'openharmony') {
35
36
  // recordHar. Users who need multi-context set it explicitly:
36
37
  // process.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1'
37
38
  // before importing @playwright/test.
38
- register('./loader.mjs', import.meta.url);
39
+ registerHooks({ resolve });
39
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.4",
3
+ "version": "0.4.0",
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"