ohos-playwright 0.3.4 → 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 +17 -2
- package/dist/config.mjs +14 -2
- package/dist/fixture.d.mts +5 -0
- package/dist/fixture.mjs +203 -200
- package/dist/loader.d.mts +2 -15
- package/dist/loader.mjs +2 -3
- package/dist/parallel.d.mts +17 -0
- package/dist/parallel.mjs +48 -0
- package/dist/register.mjs +3 -2
- package/dist/setup.d.mts +0 -1
- package/dist/setup.mjs +24 -25
- package/dist/teardown.mjs +6 -3
- package/package.json +3 -2
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
|
|
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
|
|
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
|
-
//
|
|
18
|
-
workers
|
|
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.
|
package/dist/fixture.d.mts
CHANGED
|
@@ -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,6 +16,207 @@ 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) => {
|
|
@@ -72,210 +273,12 @@ export const test = base.extend({
|
|
|
72
273
|
const page = info.openedNewTab
|
|
73
274
|
? ([...pages].reverse().find((p) => p.url() === (info.launchUrl ?? 'about:blank')) ?? pages[pages.length - 1])
|
|
74
275
|
: (pages.find((p) => p.url().startsWith('http://localhost')) ?? pages[0]);
|
|
75
|
-
const
|
|
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
|
-
};
|
|
276
|
+
const cleanup = await installPageWrappers(page, context, testInfo.project.use.baseURL);
|
|
260
277
|
try {
|
|
261
278
|
await use(page);
|
|
262
279
|
}
|
|
263
280
|
finally {
|
|
264
|
-
|
|
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;
|
|
281
|
+
await cleanup({ navigateTo: info.openedNewTab ? 'about:blank' : undefined });
|
|
279
282
|
}
|
|
280
283
|
},
|
|
281
284
|
emulateDevice: async ({ page }, use) => {
|
package/dist/loader.d.mts
CHANGED
|
@@ -1,15 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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',
|
|
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
|
|
13
|
+
const tcpArg = `tcp:${info.port}`;
|
|
14
|
+
const sockArg = `localabstract:${info.socket}`;
|
|
14
15
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
"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"
|