ohos-playwright 0.2.0 → 0.2.2

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
@@ -1,12 +1,12 @@
1
1
  # ohos-playwright
2
2
 
3
- 让 Playwright 在 OpenHarmony 上跑 e2e —— 接管设备上的系统浏览器(ArkWeb,Chromium 内核)。同一份 `playwright.config.ts` 和 spec 在 Windows / Linux / macOS 上仍按原版 Playwright 跑,**不需要写两份**。
3
+ 让 Playwright 在 OpenHarmony / ArkWeb 上跑 e2e,同一份 `playwright.config.ts` 和 spec 在 Windows / Linux / macOS 上仍按原版 Playwright 跑,**不需要写两份**。
4
4
 
5
5
  ## 它解决什么问题
6
6
 
7
- OpenHarmony 上没有官方的 Playwright:没有适配的浏览器二进制可下载,Playwright 自己的平台检测在 OpenHarmony 上直接崩。
7
+ OpenHarmony 上没有官方的 Playwright:没有适配的浏览器二进制,Playwright 的平台检测在 `openharmony` 上直接崩。
8
8
 
9
- ohos-playwright 换了个思路 —— **不让 Playwright 自己启动浏览器**,而是通过 `hdc` 把设备上正在运行的系统浏览器的调试通道转发回本机,让 Playwright "接管"它。spec 文件一行不动,`playwright.config.ts` 只多一层包装;CI / 本地 / 鸿蒙开发机共用同一份配置。
9
+ ohos-playwright **不让 Playwright 自己启动浏览器**,而是通过 `hdc` 把设备上正在运行的系统浏览器的 CDP 调试通道转发回本机,让 Playwright "接管"它。spec 一行不动,config 只多一层包装。
10
10
 
11
11
  ## 快速开始
12
12
 
@@ -16,7 +16,7 @@ ohos-playwright 换了个思路 —— **不让 Playwright 自己启动浏览器
16
16
  pnpm add -D ohos-playwright @playwright/test
17
17
  ```
18
18
 
19
- > `@playwright/test` 是 peer dependency,版本 `>=1.59.0`。
19
+ > `@playwright/test` 是 peer dependency,版本 `>=1.59.0`。Node ≥ 24。
20
20
 
21
21
  ### 2. 包一层 config
22
22
 
@@ -30,23 +30,22 @@ export default defineConfig(withOpenHarmony({
30
30
  use: { baseURL: 'http://localhost:5173' },
31
31
  projects: [
32
32
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
33
- // firefox / webkit 在 OpenHarmony 上会被自动裁掉,留着也不影响其他主机
33
+ // firefox / webkit 在 OpenHarmony 上会被自动裁掉
34
34
  ],
35
- // ...其他正常的 Playwright 配置
36
35
  }))
37
36
  ```
38
37
 
39
- 非 OpenHarmony 主机上 `withOpenHarmony` 原样返回 config;OpenHarmony 上它会注入 setup/teardown、把 workers 1、过滤掉 firefox/webkit
38
+ `withOpenHarmony` 在非 OH 主机上原样返回 config;在 OH 上注入 setup/teardown、锁 workers=1、过滤非 chromium project
40
39
 
41
- ### 3. npm script
40
+ ### 3. npm script
42
41
 
43
42
  ```json
44
43
  { "scripts": { "test:e2e": "ohos-playwright test" } }
45
44
  ```
46
45
 
47
- `ohos-playwright` 这个 bin 在非 OpenHarmony 上等价于 `playwright`,所有参数透传。
46
+ `ohos-playwright` 在非 OH 上等价于 `playwright`,所有参数透传。
48
47
 
49
- ### 4. spec 文件不用动
48
+ ### 4. spec 不用动
50
49
 
51
50
  ```ts
52
51
  import { test, expect } from '@playwright/test'
@@ -63,56 +62,101 @@ test('something', async ({ page }) => {
63
62
  pnpm test:e2e
64
63
  ```
65
64
 
66
- 第一次跑会自动找设备、拉起系统浏览器、转发调试端口、让 Playwright 接管。
65
+ 首次跑会自动找设备、拉起浏览器、转发 CDP 端口。如果本机就是 OpenHarmony 设备,会通过 `param get persist.hdc.port` 自动发现端口并连接,无需手动配置。
67
66
 
68
67
  ## 工作原理
69
68
 
70
- **非 OpenHarmony 主机**:什么都不做,等价于 stock Playwright,所以配置可以跨平台共用。
69
+ **非 OpenHarmony 主机**:什么都不做,等价于 stock Playwright
71
70
 
72
71
  **OpenHarmony 主机**:
73
72
 
74
- - **复用而非启动**:Playwright 不再自己起浏览器,而是接管设备上正在跑的系统浏览器,通过 `hdc` 把它的调试通道转发到本机。
75
- - **Spec 无感**:你写的还是 `@playwright/test`,底层透明替换为走 CDP 复用设备上现有 page 的实现。
76
- - **配置自动收紧**:`workers` 锁 1、firefox / webkit project 自动裁掉、globalSetup / globalTeardown 自动注入 —— 都由 `withOpenHarmony` 在 OpenHarmony 上完成,其他主机不受影响。
77
- - **设备没连会自救**:测试启动时如果设备没连,先 LAN 广播找设备并自动连上,找不到再弹提示。
73
+ - **复用而非启动**:Playwright 不再自己起浏览器,接管设备上正在跑的系统浏览器,通过 `hdc` 把 CDP 通道转发到本机。
74
+ - **Spec 无感**:底层透明替换 `@playwright/test` 导入,走 CDP 复用设备上现有 page
75
+ - **配置自动收紧**:`workers` 锁 1、firefox/webkit 裁掉、globalSetup/Teardown 自动注入。
76
+ - **智能设备发现**(v0.2.0):
77
+ 1. 已有连接 → 直接复用
78
+ 2. 本机就是 OH 设备 → 读 `persist.hdc.port` 秒连
79
+ 3. LAN 广播 `hdc discover` → 找独立设备
80
+ 4. 找不到 → TTY 提示手动输入 / CI 直接报错
81
+
82
+ ## 设备连接
83
+
84
+ ### 本机即设备(一台 OH 机器跑全部)
85
+
86
+ 无需额外配置,`ensureDeviceConnected()` 会自动通过 `param get persist.hdc.port` 连上本机 hdc 守护进程。
87
+
88
+ ### 固定无线调试端口(一劳永逸)
89
+
90
+ 无线调试每次开关端口都会变。设固定端口后不再需要看屏幕:
91
+
92
+ ```sh
93
+ # 先连上(USB 或看屏幕临时端口)
94
+ hdc tconn 127.0.0.1:<临时端口>
95
+
96
+ # 设固定端口(会重启设备)
97
+ hdc tmode port 5555
98
+
99
+ # 之后永远用固定端口
100
+ hdc tconn 127.0.0.1:5555
101
+ ```
102
+
103
+ 设好后 `param get persist.hdc.port` 也返回固定值,ohos-playwright 自动识别。
104
+
105
+ ### 跳过自动连接
106
+
107
+ ```sh
108
+ OHOS_PW_AUTO_CONNECT=0 pnpm test:e2e
109
+ ```
78
110
 
79
111
  ## 环境变量
80
112
 
81
- | 变量 | 默认 | 何时需要设 |
82
- | --- | --- | --- |
83
- | `OHOS_PW_HDC` | `/data/service/hnp/bin/hdc` | `hdc` 不在默认路径 |
84
- | `OHOS_PW_BUNDLE` | `com.huawei.hmos.browser` | 接管别的浏览器 bundle |
85
- | `OHOS_PW_LAUNCH_URL` | `http://localhost:5173` | 浏览器**首次启动**时导航到的 URL(dev server 端口不是 5173 必设) |
86
- | `OHOS_PW_INFO_PATH` | `os.tmpdir()/ohos-playwright-cdp.json` | 想固定 CDP info 文件位置(CI 多 job 隔离等) |
87
- | `OHOS_PW_AUTO_CONNECT` | (未设) | `0` 跳过自动 discover / tconn 流程 |
113
+ | 变量 | 默认值 | 说明 |
114
+ |---|---|---|
115
+ | `OHOS_PW_HDC` | `/data/service/hnp/bin/hdc` | hdc 二进制路径 |
116
+ | `OHOS_PW_BUNDLE` | `com.huawei.hmos.browser` | 接管的目标浏览器 bundle |
117
+ | `OHOS_PW_LAUNCH_URL` | `http://localhost:5173` | 浏览器首次启动时的导航 URL |
118
+ | `OHOS_PW_INFO_PATH` | `os.tmpdir()/ohos-playwright-cdp.json` | CDP info 文件位置 |
119
+ | `OHOS_PW_AUTO_CONNECT` | (未设) | 设为 `0` 跳过自动 discover/tconn 流程 |
88
120
  | `OHOS_PW_HOST` | 自动 | 内部标志位,**不要手动设** |
89
121
 
90
122
  ## 约束
91
123
 
92
- 设备和 ArkWeb 本身带来的硬约束,不是 ohos-playwright 能解决的:
124
+ 设备和 ArkWeb 本身的硬约束:
93
125
 
94
- - **`workers: 1`**:设备上只有一个浏览器实例,多 worker 会互相抢同一份 localStorage / URL。
95
- - **不能并发 project**:firefox / webkit 在 ArkWeb 上跑不了,会被自动裁掉。
96
- - **不能 `newContext` / `newPage`**:全程复用一个 context、一个 page。要做用例隔离,用 `localStorage.clear()` + `page.reload()` 代替独立 context。
97
- - **测试结束不关浏览器**:浏览器进程归 OS 管,不归测试管。
98
- - **`process.platform` 在测试进程里被改成 `'linux'`**:如果你在 spec / fixture 里读 `process.platform` 做平台分支,会拿到 `'linux'`。判断真实主机身份请用 `process.env.OHOS_PW_HOST`。
99
- - **设备必须开发者模式 + 无线调试或 USB 调试**:自动连接依赖 `hdc discover`(UDP:8710),CI 上推荐预先 `hdc tconn`。
126
+ - **`workers: 1`**:设备上只有一个浏览器实例。
127
+ - **不能并发 project**:firefox/webkit 在 ArkWeb 上跑不了,自动裁掉。
128
+ - **不能 `newContext` / `newPage`**:全进程复用一个 context、一个 page。用例隔离用 `localStorage.clear()` + `page.reload()`。
129
+ - **测试结束不关浏览器**:浏览器进程归 OS 管。
130
+ - **`process.platform` 被改成 `'linux'`**:判断真实主机用 `process.env.OHOS_PW_HOST`。
131
+ - **设备需要开发者模式 + 无线调试或 USB 调试**。
100
132
 
101
133
  ## 排错
102
134
 
103
- | 报错 | 怎么处理 |
104
- | --- | --- |
105
- | `Cannot find module 'ohos-playwright/config'` | 没装或没 install,按"快速开始"重做 |
135
+ | 报错 | 处理 |
136
+ |---|---|
137
+ | `Cannot find module 'ohos-playwright/config'` | 没装或没 install |
106
138
  | `defineConfig({...})` 没有类型提示 | `tsconfig` 的 `moduleResolution` 改成 `bundler` 或 `nodenext` |
107
- | `未发现设备...`(中文) | 设备上开「开发者选项 → 无线调试」;本机防火墙放行 UDP:8710;CI 上预先手动 `hdc tconn <ip:port>` |
108
- | `Failed to launch com.huawei.hmos.browser` | 设备上没装该浏览器,或 bundle name 不对,设 `OHOS_PW_BUNDLE` |
109
- | `DevTools socket not found for pid` | 该浏览器没暴露 CDP(不是所有 OpenHarmony 浏览器都启用了 devtools) |
110
- | `CDP probe failed` | `hdc fport` 转发没起来,`hdc fport ls` 看现有 ruler、`hdc fport rm` 清残留 |
111
- | `await page.goto('/foo')` 不拼 baseURL | 检查 `use.baseURL` 是否设了非空值 |
139
+ | `未发现设备...` | 设备上开「开发者选项 → 无线调试」;本机防火墙放行 UDP:8710;CI 预先 `hdc tconn` |
140
+ | `Failed to launch com.huawei.hmos.browser` | 设备上没装该浏览器,或设 `OHOS_PW_BUNDLE` |
141
+ | `DevTools socket not found for pid` | 浏览器没暴露 CDP,或等几秒重试 |
142
+ | `CDP probe failed` | `hdc fport ls` 查现 ruler,`hdc fport rm` 清残留 |
143
+ | `await page.goto('/foo')` 不拼 baseURL | 检查 `use.baseURL` 是否非空 |
144
+
145
+ ## TypeScript
146
+
147
+ 项目源码使用 TypeScript(`.mts`),Node 24 原生支持类型剥离,无需编译步骤。
148
+
149
+ 支持类型检查:
150
+
151
+ ```sh
152
+ npm run typecheck # tsc --noEmit
153
+ ```
154
+
155
+ `withOpenHarmony` 从 `ohos-playwright/config` 导入时有完整的 `PlaywrightTestConfig` 类型提示(直接从源码推断,无需 `.d.ts` 文件)。
112
156
 
113
157
  ## 兼容性
114
158
 
115
- - **Playwright**:`>=1.59.0`(测试覆盖 1.60)
159
+ - **Playwright**:`>=1.59.0`
116
160
  - **Node**:`>=24`(OpenHarmony 上只支持 Node 24+)
117
161
  - **OpenHarmony**:`hdc` 可用 + 系统浏览器暴露 CDP。已验证 `com.huawei.hmos.browser`(华为浏览器,Chromium 132)
118
162
  - **其他主机**:Windows / Linux / macOS 上自动降级为 stock Playwright,无副作用
package/dist/cli.d.mts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.mjs ADDED
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { dirname, resolve } from 'node:path';
6
+ // Resolve @playwright/test from the consumer's project root (process.cwd()),
7
+ // not from this package's own directory (which has no node_modules/ for its
8
+ // peer dependencies when installed via a file: symlink).
9
+ // noop.mjs doesn't need to exist on disk -- createRequire uses it only to
10
+ // anchor the module resolution base path.
11
+ const req = createRequire(resolve(process.cwd(), 'noop.mjs'));
12
+ // @playwright/test's exports map blocks direct subpath resolution to cli.js,
13
+ // so resolve the main entry and walk up to the package root, then append it.
14
+ let pwEntry;
15
+ try {
16
+ pwEntry = req.resolve('@playwright/test');
17
+ }
18
+ catch {
19
+ console.error(`[ohos-playwright] Cannot find @playwright/test from ${process.cwd()}.\n` +
20
+ 'Make sure it is installed in the current project or an ancestor:\n' +
21
+ ' npm install -D @playwright/test\n' +
22
+ ' pnpm add -D @playwright/test');
23
+ process.exit(1);
24
+ }
25
+ let pkgRoot = dirname(pwEntry);
26
+ let levels = 0;
27
+ while (!existsSync(resolve(pkgRoot, 'package.json')) && levels++ < 50) {
28
+ pkgRoot = dirname(pkgRoot);
29
+ }
30
+ if (!existsSync(resolve(pkgRoot, 'package.json'))) {
31
+ console.error(`[ohos-playwright] Cannot find @playwright/test package.json (walked up from ${dirname(pwEntry)}). ` +
32
+ 'Please verify @playwright/test is correctly installed.');
33
+ process.exit(1);
34
+ }
35
+ const playwrightCli = resolve(pkgRoot, 'cli.js');
36
+ // Node 24 has native TypeScript support; register.mts is resolved directly.
37
+ const register = resolve(import.meta.dirname, 'register.mjs');
38
+ const child = spawn(process.execPath, ['--import', register, playwrightCli, ...process.argv.slice(2)], { stdio: 'inherit' });
39
+ child.on('exit', (code, signal) => {
40
+ if (signal)
41
+ process.kill(process.pid, signal);
42
+ else
43
+ process.exit(code ?? 1);
44
+ });
@@ -0,0 +1,2 @@
1
+ import type { PlaywrightTestConfig } from '@playwright/test';
2
+ export declare function withOpenHarmony(config: PlaywrightTestConfig): PlaywrightTestConfig;
@@ -0,0 +1,24 @@
1
+ // Wrap a base Playwright config so it transparently turns into an ArkWeb/CDP
2
+ // run on OpenHarmony and stays stock everywhere else. Usage:
3
+ //
4
+ // import { defineConfig, devices } from '@playwright/test'
5
+ // import { withOpenHarmony } from 'ohos-playwright/config'
6
+ //
7
+ // export default defineConfig(withOpenHarmony({ ...baseConfig }))
8
+ //
9
+ // On non-OpenHarmony hosts the input config is returned unchanged.
10
+ export function withOpenHarmony(config) {
11
+ // Consult OHOS_PW_HOST, not process.platform — register.mjs has already
12
+ // overwritten platform to 'linux' by the time this function runs.
13
+ if (!process.env.OHOS_PW_HOST)
14
+ return config;
15
+ return {
16
+ ...config,
17
+ // Single ArkWeb instance via CDP — workers must be 1.
18
+ workers: 1,
19
+ globalSetup: 'ohos-playwright/setup',
20
+ globalTeardown: 'ohos-playwright/teardown',
21
+ // ArkWeb only speaks Chromium CDP; drop firefox/webkit projects.
22
+ projects: config.projects?.filter((p) => p.name === 'chromium') ?? config.projects,
23
+ };
24
+ }
@@ -0,0 +1,2 @@
1
+ export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
2
+ export { expect } from '@playwright/test';
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { test as base, chromium } from '@playwright/test';
3
+ import { INFO_PATH } from "./info-path.mjs";
4
+ function readEndpoint() {
5
+ return JSON.parse(readFileSync(INFO_PATH, 'utf8')).endpoint;
6
+ }
7
+ export const test = base.extend({
8
+ browser: [
9
+ async ({}, use) => {
10
+ const browser = await chromium.connectOverCDP(readEndpoint());
11
+ await use(browser);
12
+ },
13
+ { scope: 'worker' },
14
+ ],
15
+ context: async ({ browser }, use, testInfo) => {
16
+ const ctx = browser.contexts()[0];
17
+ const baseURL = testInfo.project.use.baseURL;
18
+ if (baseURL) {
19
+ try {
20
+ const opts = ctx._options;
21
+ if (opts && typeof opts === 'object')
22
+ opts.baseURL = baseURL;
23
+ }
24
+ catch (e) {
25
+ console.warn(`[ohos-playwright] Failed to inject baseURL: ${e instanceof Error ? e.message : e}`);
26
+ }
27
+ }
28
+ await use(ctx);
29
+ },
30
+ page: async ({ context }, use, testInfo) => {
31
+ const pages = context.pages();
32
+ if (pages.length === 0)
33
+ throw new Error('No pages in ArkWeb CDP context. Open a tab first.');
34
+ const page = pages.find((p) => p.url().startsWith('http://localhost')) ?? pages[0];
35
+ const baseURL = testInfo.project.use.baseURL;
36
+ if (baseURL) {
37
+ const root = baseURL.replace(/\/+$/, '');
38
+ const origGoto = page.goto.bind(page);
39
+ page.goto = ((url, opts) => origGoto((url.startsWith('/') && !url.startsWith('//')) ? root + url : url, opts));
40
+ }
41
+ await use(page);
42
+ },
43
+ });
44
+ export { expect } from '@playwright/test';
@@ -0,0 +1,7 @@
1
+ export declare const INFO_PATH: string;
2
+ export interface CdpInfo {
3
+ port: number;
4
+ pid: number;
5
+ socket: string;
6
+ endpoint: string;
7
+ }
@@ -0,0 +1,5 @@
1
+ import { tmpdir } from 'node:os';
2
+ import { resolve } from 'node:path';
3
+ // Setup writes the CDP endpoint info here; fixture and teardown read from it.
4
+ // Override with OHOS_PW_INFO_PATH if you need a deterministic location.
5
+ export const INFO_PATH = process.env.OHOS_PW_INFO_PATH ?? resolve(tmpdir(), 'ohos-playwright-cdp.json');
@@ -0,0 +1,15 @@
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 {};
@@ -0,0 +1,20 @@
1
+ import { resolve as resolvePath } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ const FIXTURE_URL = pathToFileURL(resolvePath(import.meta.dirname, 'fixture.mjs')).href;
4
+ const TARGET = '@playwright/test';
5
+ const TEST_FILE = /\.(spec|test)\.[mc]?[tj]sx?$/;
6
+ const PACKAGE_ROOT_URL = pathToFileURL(resolvePath(import.meta.dirname, '..') + '/').href;
7
+ const PROJECT_ANCHOR = pathToFileURL(resolvePath(process.cwd(), 'noop.mjs')).href;
8
+ export async function resolve(specifier, context, nextResolve) {
9
+ if (specifier === TARGET) {
10
+ const parent = context.parentURL ?? '';
11
+ if (TEST_FILE.test(parent)) {
12
+ const result = await nextResolve(FIXTURE_URL, context);
13
+ return { url: result.url };
14
+ }
15
+ if (parent.startsWith(PACKAGE_ROOT_URL)) {
16
+ return nextResolve(specifier, { ...context, parentURL: PROJECT_ANCHOR });
17
+ }
18
+ }
19
+ return nextResolve(specifier, context);
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import { register } from 'node:module';
2
+ // Adapter only activates on OpenHarmony — elsewhere this file is a no-op so
3
+ // the same ohos-playwright entry point and the same playwright.config.ts can
4
+ // run on Windows / Linux / macOS with stock Playwright.
5
+ if (process.platform === 'openharmony') {
6
+ // Mark the run as OpenHarmony before we lie about the platform — any code
7
+ // downstream (notably withOpenHarmony in the user's config) should consult
8
+ // OHOS_PW_HOST instead of process.platform, which is about to read 'linux'.
9
+ process.env.OHOS_PW_HOST = '1';
10
+ // Playwright's hostPlatform detection only branches on linux/darwin/win32.
11
+ // On OpenHarmony it falls through to "<unknown>" and various code paths
12
+ // break. We connect over CDP and never touch Playwright's bundled browser
13
+ // binaries, so it's safe to advertise linux for the duration of this
14
+ // process.
15
+ Object.defineProperty(process, 'platform', { value: 'linux' });
16
+ register('./loader.mjs', import.meta.url);
17
+ }
@@ -0,0 +1,13 @@
1
+ interface RetryOptions {
2
+ max?: number;
3
+ interval?: number;
4
+ label?: string;
5
+ }
6
+ export declare function retry<T>(fn: () => T | Promise<T>, { max, interval, label }?: RetryOptions): Promise<T>;
7
+ export declare function findBrowserPid(): number | null;
8
+ export declare function hasDeviceConnected(): boolean;
9
+ export declare function discoverDevices(): string[];
10
+ export declare function tryLocalDevice(): boolean;
11
+ export declare function ensureDeviceConnected(): Promise<void>;
12
+ export default function globalSetup(): Promise<void>;
13
+ export {};
package/dist/setup.mjs ADDED
@@ -0,0 +1,212 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { writeFileSync, mkdirSync } from 'node:fs';
3
+ import { createServer } from 'node:net';
4
+ import { dirname } from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+ import http from 'node:http';
7
+ import { INFO_PATH } from "./info-path.mjs";
8
+ const HDC = process.env.OHOS_PW_HDC ?? '/data/service/hnp/bin/hdc';
9
+ const BUNDLE = process.env.OHOS_PW_BUNDLE ?? 'com.huawei.hmos.browser';
10
+ const LAUNCH_URL = process.env.OHOS_PW_LAUNCH_URL ?? 'http://localhost:5173';
11
+ const HDC_OPTS = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] };
12
+ function hdc(args, opts) {
13
+ return String(execFileSync(HDC, args, { ...HDC_OPTS, ...opts })).trim();
14
+ }
15
+ function shellOnDevice(cmd) { return hdc(['shell', cmd]); }
16
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
17
+ // Exponential backoff: 100ms → 200ms → 400ms … capped at `interval` (default 1000ms).
18
+ export async function retry(fn, { max = 10, interval = 1000, label = '' } = {}) {
19
+ for (let i = 0; i < max; i++) {
20
+ try {
21
+ const r = await fn();
22
+ if (r)
23
+ return r;
24
+ }
25
+ catch { }
26
+ if (i < max - 1) {
27
+ const delay = Math.min(100 * Math.pow(2, i), interval);
28
+ await sleep(delay);
29
+ }
30
+ }
31
+ throw new Error(label ? `${label}: exhausted retries (${max} attempts)` : `retry exhausted after ${max} attempts`);
32
+ }
33
+ // Batch ps + /proc/net/unix into a single hdc shell call — avoids two
34
+ // subprocess spawns and transfers.
35
+ function fetchDeviceState() {
36
+ const raw = shellOnDevice('ps -o pid,args; echo "---SOCKET---"; cat /proc/net/unix');
37
+ const [ps, unix] = raw.split('---SOCKET---');
38
+ return { ps: ps || '', unix: unix || '' };
39
+ }
40
+ export function findBrowserPid() {
41
+ const { ps } = fetchDeviceState();
42
+ for (const line of ps.split('\n')) {
43
+ const t = line.trim();
44
+ if (!t)
45
+ continue;
46
+ const s = t.indexOf(' ');
47
+ if (s === -1)
48
+ continue;
49
+ if (t.slice(s + 1).includes(BUNDLE)) {
50
+ const pid = parseInt(t.slice(0, s), 10);
51
+ if (!Number.isNaN(pid))
52
+ return pid;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ function launchBrowser() {
58
+ shellOnDevice(`aa start -b ${BUNDLE} -m entry -a MainAbility -U ${LAUNCH_URL}`);
59
+ }
60
+ function findDevToolsSocket(pid, cachedUnix) {
61
+ const unix = cachedUnix ?? shellOnDevice('cat /proc/net/unix');
62
+ const name = `webview_devtools_remote_${pid}`;
63
+ return unix.includes(`@${name}`) ? name : null;
64
+ }
65
+ function pickFreePort() {
66
+ return new Promise((res, rej) => {
67
+ const srv = createServer();
68
+ srv.listen(0, '127.0.0.1', () => {
69
+ const a = srv.address();
70
+ if (a && typeof a === 'object')
71
+ srv.close((e) => e ? rej(e) : res(a.port));
72
+ else
73
+ srv.close(() => rej(new Error('Failed to get port')));
74
+ });
75
+ srv.on('error', rej);
76
+ });
77
+ }
78
+ function setupForward(port, socketName) {
79
+ const ruler = `tcp:${port} localabstract:${socketName}`;
80
+ try {
81
+ hdc(['fport', 'rm', ruler]);
82
+ }
83
+ catch { }
84
+ hdc(['fport', 'tcp:' + port, 'localabstract:' + socketName]);
85
+ }
86
+ function probeCdp(port) {
87
+ return new Promise((res) => {
88
+ const req = http.get(`http://127.0.0.1:${port}/json/version`, (r) => {
89
+ let b = '';
90
+ r.on('data', (c) => (b += c));
91
+ r.on('end', () => res({ ok: r.statusCode === 200, body: b }));
92
+ });
93
+ req.on('error', (e) => res({ ok: false, err: e.code }));
94
+ req.setTimeout(2000, () => { req.destroy(); res({ ok: false, err: 'TIMEOUT' }); });
95
+ });
96
+ }
97
+ const IP_PORT_RE = /^(\d{1,3}\.){3}\d{1,3}:\d+$/;
98
+ function listTargets() { return hdc(['list', 'targets']); }
99
+ export function hasDeviceConnected() {
100
+ const t = listTargets();
101
+ return t.length > 0 && t !== '[Empty]';
102
+ }
103
+ // discoverDevices timeout reduced from 6s to 3s — LAN broadcast on local
104
+ // network should respond within 1-2s; longer wait is unlikely to help.
105
+ export function discoverDevices() {
106
+ let out = '';
107
+ try {
108
+ out = hdc(['discover'], { timeout: 3000 });
109
+ }
110
+ catch (e) {
111
+ out = e.stdout?.toString() ?? '';
112
+ }
113
+ return out.split('\n').map(s => s.trim()).filter(s => IP_PORT_RE.test(s));
114
+ }
115
+ function tconn(addr) {
116
+ try {
117
+ return hdc(['tconn', addr], { timeout: 10000 }).includes('Connect OK');
118
+ }
119
+ catch {
120
+ return false;
121
+ }
122
+ }
123
+ function promptAddress() {
124
+ return new Promise((res) => {
125
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
126
+ rl.question('[ohos-playwright] paste device ip:port (Enter to abort): ', (a) => { rl.close(); res(a.trim()); });
127
+ });
128
+ }
129
+ const CONNECT_HELP = [
130
+ '[ohos-playwright] 未发现设备。请在设备上:',
131
+ ' 1) 进入「设置 → 关于本机」连点版本号开启「开发者选项」',
132
+ ' 2) 进入「开发者选项」启用「无线调试」',
133
+ ' 3) 确认设备与本机在同一 Wi-Fi 下',
134
+ ' 4) 防火墙放行本机 UDP:8710 入站(hdc discover 广播用)',
135
+ '也可手动跑 `hdc tconn <ip:port>` 后重新启动测试。',
136
+ '若不希望自动连接,设 OHOS_PW_AUTO_CONNECT=0 跳过。',
137
+ ].join('\n');
138
+ export function tryLocalDevice() {
139
+ try {
140
+ const raw = String(execFileSync('param', ['get', 'persist.hdc.port'], { ...HDC_OPTS, timeout: 3000 })).trim();
141
+ const port = parseInt(raw, 10);
142
+ if (!port || port < 1 || port > 65535)
143
+ return false;
144
+ console.log(`[ohos-playwright] local device port from param: ${port}`);
145
+ const addr = `127.0.0.1:${port}`;
146
+ if (tconn(addr) && hasDeviceConnected()) {
147
+ console.log(`[ohos-playwright] connected: ${addr}`);
148
+ return true;
149
+ }
150
+ console.warn(`[ohos-playwright] tconn ${addr} failed`);
151
+ }
152
+ catch { }
153
+ return false;
154
+ }
155
+ export async function ensureDeviceConnected() {
156
+ if (process.env.OHOS_PW_AUTO_CONNECT === '0')
157
+ return;
158
+ if (hasDeviceConnected())
159
+ return;
160
+ if (tryLocalDevice())
161
+ return;
162
+ console.log('[ohos-playwright] no local device, broadcasting (hdc discover)...');
163
+ const found = discoverDevices();
164
+ for (const addr of found) {
165
+ console.log(`[ohos-playwright] hdc tconn ${addr}`);
166
+ if (tconn(addr) && hasDeviceConnected())
167
+ return;
168
+ }
169
+ if (found.length > 0)
170
+ console.warn('[ohos-playwright] discovered devices but none connected');
171
+ if (!process.stdin.isTTY)
172
+ throw new Error(CONNECT_HELP);
173
+ console.log(CONNECT_HELP);
174
+ const addr = await promptAddress();
175
+ if (!addr)
176
+ throw new Error('[ohos-playwright] no device address provided; aborting.');
177
+ if (!IP_PORT_RE.test(addr))
178
+ throw new Error(`[ohos-playwright] "${addr}" is not a valid ip:port.`);
179
+ if (!tconn(addr))
180
+ throw new Error(`[ohos-playwright] hdc tconn ${addr} failed.`);
181
+ if (!hasDeviceConnected())
182
+ throw new Error('[ohos-playwright] tconn reported OK but list targets still empty.');
183
+ console.log(`[ohos-playwright] connected: ${addr}`);
184
+ }
185
+ export default async function globalSetup() {
186
+ await ensureDeviceConnected();
187
+ console.log(`[ohos-playwright] locating ${BUNDLE}...`);
188
+ // Batch ps + /proc/net/unix into a single hdc call.
189
+ let pid = findBrowserPid();
190
+ if (!pid) {
191
+ console.log('[ohos-playwright] browser not running, launching...');
192
+ launchBrowser();
193
+ // Backoff: 100, 200, 400, 800, 1000, 1000, … (max 20 attempts ≈ 10s total)
194
+ pid = await retry(findBrowserPid, { max: 20, interval: 1000, label: `Failed to launch ${BUNDLE}` });
195
+ }
196
+ console.log(`[ohos-playwright] browser pid=${pid}`);
197
+ // Wait for the DevTools socket to appear — each retry fetches fresh
198
+ // /proc/net/unix since the socket appears asynchronously after launch.
199
+ const socket = await retry(() => findDevToolsSocket(pid), { max: 10, interval: 500, label: `DevTools socket not found for pid ${pid}` });
200
+ console.log(`[ohos-playwright] socket=${socket}`);
201
+ const port = await pickFreePort();
202
+ setupForward(port, socket);
203
+ console.log(`[ohos-playwright] hdc fport tcp:${port} -> localabstract:${socket}`);
204
+ const probe = await probeCdp(port);
205
+ if (!probe.ok)
206
+ throw new Error(`CDP probe failed: ${probe.err || probe.body}`);
207
+ const info = JSON.parse(probe.body);
208
+ console.log(`[ohos-playwright] CDP ready: ${info.Browser}`);
209
+ mkdirSync(dirname(INFO_PATH), { recursive: true });
210
+ writeFileSync(INFO_PATH, JSON.stringify({ port, pid, socket, endpoint: `http://127.0.0.1:${port}` }, null, 2));
211
+ console.log(`[ohos-playwright] wrote ${INFO_PATH}`);
212
+ }
@@ -0,0 +1 @@
1
+ export default function globalTeardown(): Promise<void>;
@@ -0,0 +1,31 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync, unlinkSync } from 'node:fs';
3
+ import { INFO_PATH } from "./info-path.mjs";
4
+ const HDC = process.env.OHOS_PW_HDC ?? '/data/service/hnp/bin/hdc';
5
+ export default async function globalTeardown() {
6
+ let info;
7
+ try {
8
+ info = JSON.parse(readFileSync(INFO_PATH, 'utf8'));
9
+ }
10
+ catch {
11
+ return;
12
+ }
13
+ const ruler = `tcp:${info.port} localabstract:${info.socket}`;
14
+ try {
15
+ execFileSync(HDC, ['fport', 'rm', ruler], { stdio: ['ignore', 'pipe', 'pipe'] });
16
+ console.log(`[ohos-playwright] removed fport ${ruler}`);
17
+ }
18
+ catch (e) {
19
+ const msg = e instanceof Error ? e.message?.split('\n')[0] : String(e);
20
+ console.warn(`[ohos-playwright] fport rm failed (non-fatal): ${msg}`);
21
+ }
22
+ try {
23
+ unlinkSync(INFO_PATH);
24
+ }
25
+ catch (e) {
26
+ const err = e;
27
+ if (err.code !== 'ENOENT') {
28
+ console.warn(`[ohos-playwright] Failed to remove ${INFO_PATH}: ${err.message}`);
29
+ }
30
+ }
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ohos-playwright",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Playwright adapter for OpenHarmony / ArkWeb via hdc + CDP",
5
5
  "license": "MIT",
6
6
  "author": "social4hyq",
@@ -24,6 +24,7 @@
24
24
  ],
25
25
  "type": "module",
26
26
  "scripts": {
27
+ "build": "tsc && node scripts/fix-extensions.mjs",
27
28
  "test": "node --test src/*.test.mts",
28
29
  "typecheck": "tsc --noEmit"
29
30
  },
@@ -31,19 +32,19 @@
31
32
  "node": ">=24"
32
33
  },
33
34
  "exports": {
34
- "./fixture": "./src/fixture.mts",
35
- "./setup": "./src/setup.mts",
36
- "./teardown": "./src/teardown.mts",
37
- "./register": "./src/register.mts",
38
- "./loader": "./src/loader.mts",
39
- "./config": "./src/config.mts"
35
+ "./fixture": "./dist/fixture.mjs",
36
+ "./setup": "./dist/setup.mjs",
37
+ "./teardown": "./dist/teardown.mjs",
38
+ "./register": "./dist/register.mjs",
39
+ "./loader": "./dist/loader.mjs",
40
+ "./config": "./dist/config.mjs"
40
41
  },
41
42
  "bin": {
42
- "ohos-playwright": "./src/cli.mts"
43
+ "ohos-playwright": "./dist/cli.mjs"
43
44
  },
44
45
  "files": [
45
- "src",
46
- "!src/*.test.mts"
46
+ "dist",
47
+ "!dist/*.test.mjs"
47
48
  ],
48
49
  "peerDependencies": {
49
50
  "@playwright/test": ">=1.59.0"
package/src/cli.mts DELETED
@@ -1,53 +0,0 @@
1
- #!/usr/bin/env node
2
- import { spawn } from 'node:child_process'
3
- import { existsSync } from 'node:fs'
4
- import { createRequire } from 'node:module'
5
- import { dirname, resolve } from 'node:path'
6
-
7
- // Resolve @playwright/test from the consumer's project root (process.cwd()),
8
- // not from this package's own directory (which has no node_modules/ for its
9
- // peer dependencies when installed via a file: symlink).
10
- // noop.mjs doesn't need to exist on disk -- createRequire uses it only to
11
- // anchor the module resolution base path.
12
- const req = createRequire(resolve(process.cwd(), 'noop.mjs'))
13
-
14
- // @playwright/test's exports map blocks direct subpath resolution to cli.js,
15
- // so resolve the main entry and walk up to the package root, then append it.
16
- let pwEntry: string
17
- try {
18
- pwEntry = req.resolve('@playwright/test')
19
- } catch {
20
- console.error(
21
- `[ohos-playwright] Cannot find @playwright/test from ${process.cwd()}.\n` +
22
- 'Make sure it is installed in the current project or an ancestor:\n' +
23
- ' npm install -D @playwright/test\n' +
24
- ' pnpm add -D @playwright/test',
25
- )
26
- process.exit(1)
27
- }
28
- let pkgRoot = dirname(pwEntry)
29
- let levels = 0
30
- while (!existsSync(resolve(pkgRoot, 'package.json')) && levels++ < 50) {
31
- pkgRoot = dirname(pkgRoot)
32
- }
33
- if (!existsSync(resolve(pkgRoot, 'package.json'))) {
34
- console.error(
35
- `[ohos-playwright] Cannot find @playwright/test package.json (walked up from ${dirname(pwEntry)}). ` +
36
- 'Please verify @playwright/test is correctly installed.',
37
- )
38
- process.exit(1)
39
- }
40
- const playwrightCli = resolve(pkgRoot, 'cli.js')
41
-
42
- // Node 24 has native TypeScript support; register.mts is resolved directly.
43
- const register = resolve(import.meta.dirname!, 'register.mts')
44
-
45
- const child = spawn(
46
- process.execPath,
47
- ['--import', register, playwrightCli, ...process.argv.slice(2)],
48
- { stdio: 'inherit' },
49
- )
50
- child.on('exit', (code, signal) => {
51
- if (signal) process.kill(process.pid, signal)
52
- else process.exit(code ?? 1)
53
- })
package/src/config.mts DELETED
@@ -1,25 +0,0 @@
1
- import type { PlaywrightTestConfig } from '@playwright/test'
2
-
3
- // Wrap a base Playwright config so it transparently turns into an ArkWeb/CDP
4
- // run on OpenHarmony and stays stock everywhere else. Usage:
5
- //
6
- // import { defineConfig, devices } from '@playwright/test'
7
- // import { withOpenHarmony } from 'ohos-playwright/config'
8
- //
9
- // export default defineConfig(withOpenHarmony({ ...baseConfig }))
10
- //
11
- // On non-OpenHarmony hosts the input config is returned unchanged.
12
- export function withOpenHarmony(config: PlaywrightTestConfig): PlaywrightTestConfig {
13
- // Consult OHOS_PW_HOST, not process.platform — register.mjs has already
14
- // overwritten platform to 'linux' by the time this function runs.
15
- if (!process.env.OHOS_PW_HOST) return config
16
- return {
17
- ...config,
18
- // Single ArkWeb instance via CDP — workers must be 1.
19
- workers: 1,
20
- globalSetup: 'ohos-playwright/setup',
21
- globalTeardown: 'ohos-playwright/teardown',
22
- // ArkWeb only speaks Chromium CDP; drop firefox/webkit projects.
23
- projects: config.projects?.filter((p) => p.name === 'chromium') ?? config.projects,
24
- }
25
- }
package/src/fixture.mts DELETED
@@ -1,59 +0,0 @@
1
- import { readFileSync } from 'node:fs'
2
- import { test as base, chromium } from '@playwright/test'
3
- import type { Browser, BrowserContext, Page } from '@playwright/test'
4
- import { INFO_PATH, type CdpInfo } from './info-path.mts'
5
-
6
- function readEndpoint(): string {
7
- return (JSON.parse(readFileSync(INFO_PATH, 'utf8')) as CdpInfo).endpoint
8
- }
9
-
10
- export const test = base.extend({
11
- browser: [
12
- async ({}, use: (b: Browser) => Promise<void>) => {
13
- const browser = await chromium.connectOverCDP(readEndpoint())
14
- await use(browser)
15
- },
16
- { scope: 'worker' as const },
17
- ],
18
-
19
- context: async (
20
- { browser }: { browser: Browser },
21
- use: (c: BrowserContext) => Promise<void>,
22
- testInfo: { project: { use: { baseURL?: string } } },
23
- ) => {
24
- const ctx = browser.contexts()[0]
25
- const baseURL = testInfo.project.use.baseURL
26
- if (baseURL) {
27
- try {
28
- const opts = (ctx as unknown as Record<string, unknown>)._options as Record<string, unknown> | undefined
29
- if (opts && typeof opts === 'object') opts.baseURL = baseURL
30
- } catch (e: unknown) {
31
- console.warn(`[ohos-playwright] Failed to inject baseURL: ${e instanceof Error ? e.message : e}`)
32
- }
33
- }
34
- await use(ctx)
35
- },
36
-
37
- page: async (
38
- { context }: { context: BrowserContext },
39
- use: (p: Page) => Promise<void>,
40
- testInfo: { project: { use: { baseURL?: string } } },
41
- ) => {
42
- const pages = context.pages()
43
- if (pages.length === 0) throw new Error('No pages in ArkWeb CDP context. Open a tab first.')
44
- const page = pages.find((p) => p.url().startsWith('http://localhost')) ?? pages[0]
45
-
46
- const baseURL = testInfo.project.use.baseURL
47
- if (baseURL) {
48
- const root = baseURL.replace(/\/+$/, '')
49
- const origGoto = page.goto.bind(page)
50
- page.goto = ((url: string, opts?: Record<string, unknown>) =>
51
- origGoto((url.startsWith('/') && !url.startsWith('//')) ? root + url : url, opts)
52
- ) as typeof page.goto
53
- }
54
-
55
- await use(page)
56
- },
57
- })
58
-
59
- export { expect } from '@playwright/test'
package/src/info-path.mts DELETED
@@ -1,14 +0,0 @@
1
- import { tmpdir } from 'node:os'
2
- import { resolve } from 'node:path'
3
-
4
- // Setup writes the CDP endpoint info here; fixture and teardown read from it.
5
- // Override with OHOS_PW_INFO_PATH if you need a deterministic location.
6
- export const INFO_PATH: string =
7
- process.env.OHOS_PW_INFO_PATH ?? resolve(tmpdir(), 'ohos-playwright-cdp.json')
8
-
9
- export interface CdpInfo {
10
- port: number
11
- pid: number
12
- socket: string
13
- endpoint: string
14
- }
package/src/loader.mts DELETED
@@ -1,31 +0,0 @@
1
- import { resolve as resolvePath } from 'node:path'
2
- import { pathToFileURL } from 'node:url'
3
-
4
- const FIXTURE_URL = pathToFileURL(resolvePath(import.meta.dirname!, 'fixture.mts')).href
5
- const TARGET = '@playwright/test'
6
- const TEST_FILE = /\.(spec|test)\.[mc]?[tj]sx?$/
7
- const PACKAGE_ROOT_URL = pathToFileURL(resolvePath(import.meta.dirname!, '..') + '/').href
8
- const PROJECT_ANCHOR = pathToFileURL(resolvePath(process.cwd(), 'noop.mjs')).href
9
-
10
- interface ResolveContext { parentURL?: string; [key: string]: unknown }
11
- interface NextResolve {
12
- (specifier: string, context: ResolveContext): { url: string } | Promise<{ url: string }>
13
- }
14
-
15
- export async function resolve(
16
- specifier: string,
17
- context: ResolveContext,
18
- nextResolve: NextResolve,
19
- ): Promise<{ url: string }> {
20
- if (specifier === TARGET) {
21
- const parent = context.parentURL ?? ''
22
- if (TEST_FILE.test(parent)) {
23
- const result = await nextResolve(FIXTURE_URL, context)
24
- return { url: result.url }
25
- }
26
- if (parent.startsWith(PACKAGE_ROOT_URL)) {
27
- return nextResolve(specifier, { ...context, parentURL: PROJECT_ANCHOR })
28
- }
29
- }
30
- return nextResolve(specifier, context)
31
- }
package/src/register.mts DELETED
@@ -1,20 +0,0 @@
1
- import { register } from 'node:module'
2
-
3
- // Adapter only activates on OpenHarmony — elsewhere this file is a no-op so
4
- // the same ohos-playwright entry point and the same playwright.config.ts can
5
- // run on Windows / Linux / macOS with stock Playwright.
6
- if ((process.platform as string) === 'openharmony') {
7
- // Mark the run as OpenHarmony before we lie about the platform — any code
8
- // downstream (notably withOpenHarmony in the user's config) should consult
9
- // OHOS_PW_HOST instead of process.platform, which is about to read 'linux'.
10
- process.env.OHOS_PW_HOST = '1'
11
-
12
- // Playwright's hostPlatform detection only branches on linux/darwin/win32.
13
- // On OpenHarmony it falls through to "<unknown>" and various code paths
14
- // break. We connect over CDP and never touch Playwright's bundled browser
15
- // binaries, so it's safe to advertise linux for the duration of this
16
- // process.
17
- Object.defineProperty(process, 'platform', { value: 'linux' })
18
-
19
- register('./loader.mts', import.meta.url)
20
- }
package/src/setup.mts DELETED
@@ -1,224 +0,0 @@
1
- import { execFileSync, type ExecFileSyncOptions } from 'node:child_process'
2
- import { writeFileSync, mkdirSync } from 'node:fs'
3
- import { createServer } from 'node:net'
4
- import { dirname } from 'node:path'
5
- import { createInterface } from 'node:readline'
6
- import http from 'node:http'
7
- import { INFO_PATH } from './info-path.mts'
8
-
9
- const HDC: string = process.env.OHOS_PW_HDC ?? '/data/service/hnp/bin/hdc'
10
- const BUNDLE: string = process.env.OHOS_PW_BUNDLE ?? 'com.huawei.hmos.browser'
11
- const LAUNCH_URL: string = process.env.OHOS_PW_LAUNCH_URL ?? 'http://localhost:5173'
12
-
13
- const HDC_OPTS: ExecFileSyncOptions = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } as const
14
-
15
- function hdc(args: string[], opts?: Partial<ExecFileSyncOptions>): string {
16
- return String(execFileSync(HDC, args, { ...HDC_OPTS, ...opts })).trim()
17
- }
18
-
19
- function shellOnDevice(cmd: string): string { return hdc(['shell', cmd]) }
20
-
21
- const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
22
-
23
- interface RetryOptions { max?: number; interval?: number; label?: string }
24
-
25
- // Exponential backoff: 100ms → 200ms → 400ms … capped at `interval` (default 1000ms).
26
- export async function retry<T>(
27
- fn: () => T | Promise<T>,
28
- { max = 10, interval = 1000, label = '' }: RetryOptions = {},
29
- ): Promise<T> {
30
- for (let i = 0; i < max; i++) {
31
- try { const r = await fn(); if (r) return r } catch {}
32
- if (i < max - 1) {
33
- const delay = Math.min(100 * Math.pow(2, i), interval)
34
- await sleep(delay)
35
- }
36
- }
37
- throw new Error(label ? `${label}: exhausted retries (${max} attempts)` : `retry exhausted after ${max} attempts`)
38
- }
39
-
40
- // Batch ps + /proc/net/unix into a single hdc shell call — avoids two
41
- // subprocess spawns and transfers.
42
- function fetchDeviceState(): { ps: string; unix: string } {
43
- const raw = shellOnDevice('ps -o pid,args; echo "---SOCKET---"; cat /proc/net/unix')
44
- const [ps, unix] = raw.split('---SOCKET---')
45
- return { ps: ps || '', unix: unix || '' }
46
- }
47
-
48
- export function findBrowserPid(): number | null {
49
- const { ps } = fetchDeviceState()
50
- for (const line of ps.split('\n')) {
51
- const t = line.trim()
52
- if (!t) continue
53
- const s = t.indexOf(' ')
54
- if (s === -1) continue
55
- if (t.slice(s + 1).includes(BUNDLE)) {
56
- const pid = parseInt(t.slice(0, s), 10)
57
- if (!Number.isNaN(pid)) return pid
58
- }
59
- }
60
- return null
61
- }
62
-
63
- function launchBrowser(): void {
64
- shellOnDevice(`aa start -b ${BUNDLE} -m entry -a MainAbility -U ${LAUNCH_URL}`)
65
- }
66
-
67
- function findDevToolsSocket(pid: number, cachedUnix?: string): string | null {
68
- const unix = cachedUnix ?? shellOnDevice('cat /proc/net/unix')
69
- const name = `webview_devtools_remote_${pid}`
70
- return unix.includes(`@${name}`) ? name : null
71
- }
72
-
73
- function pickFreePort(): Promise<number> {
74
- return new Promise((res, rej) => {
75
- const srv = createServer()
76
- srv.listen(0, '127.0.0.1', () => {
77
- const a = srv.address()
78
- if (a && typeof a === 'object') srv.close((e) => e ? rej(e) : res(a.port))
79
- else srv.close(() => rej(new Error('Failed to get port')))
80
- })
81
- srv.on('error', rej)
82
- })
83
- }
84
-
85
- function setupForward(port: number, socketName: string): void {
86
- const ruler = `tcp:${port} localabstract:${socketName}`
87
- try { hdc(['fport', 'rm', ruler]) } catch {}
88
- hdc(['fport', 'tcp:' + port, 'localabstract:' + socketName])
89
- }
90
-
91
- interface CdpProbeResult { ok: boolean; err?: string; body?: string }
92
-
93
- function probeCdp(port: number): Promise<CdpProbeResult> {
94
- return new Promise((res) => {
95
- const req = http.get(`http://127.0.0.1:${port}/json/version`, (r) => {
96
- let b = ''
97
- r.on('data', (c: string) => (b += c))
98
- r.on('end', () => res({ ok: r.statusCode === 200, body: b }))
99
- })
100
- req.on('error', (e: NodeJS.ErrnoException) => res({ ok: false, err: e.code }))
101
- req.setTimeout(2000, () => { req.destroy(); res({ ok: false, err: 'TIMEOUT' }) })
102
- })
103
- }
104
-
105
- const IP_PORT_RE = /^(\d{1,3}\.){3}\d{1,3}:\d+$/
106
-
107
- function listTargets(): string { return hdc(['list', 'targets']) }
108
-
109
- export function hasDeviceConnected(): boolean {
110
- const t = listTargets()
111
- return t.length > 0 && t !== '[Empty]'
112
- }
113
-
114
- // discoverDevices timeout reduced from 6s to 3s — LAN broadcast on local
115
- // network should respond within 1-2s; longer wait is unlikely to help.
116
- export function discoverDevices(): string[] {
117
- let out = ''
118
- try { out = hdc(['discover'], { timeout: 3000 }) } catch (e: unknown) {
119
- out = (e as { stdout?: Buffer | string }).stdout?.toString() ?? ''
120
- }
121
- return out.split('\n').map(s => s.trim()).filter(s => IP_PORT_RE.test(s))
122
- }
123
-
124
- function tconn(addr: string): boolean {
125
- try { return hdc(['tconn', addr], { timeout: 10000 }).includes('Connect OK') } catch { return false }
126
- }
127
-
128
- function promptAddress(): Promise<string> {
129
- return new Promise((res) => {
130
- const rl = createInterface({ input: process.stdin, output: process.stdout })
131
- rl.question('[ohos-playwright] paste device ip:port (Enter to abort): ', (a) => { rl.close(); res(a.trim()) })
132
- })
133
- }
134
-
135
- const CONNECT_HELP = [
136
- '[ohos-playwright] 未发现设备。请在设备上:',
137
- ' 1) 进入「设置 → 关于本机」连点版本号开启「开发者选项」',
138
- ' 2) 进入「开发者选项」启用「无线调试」',
139
- ' 3) 确认设备与本机在同一 Wi-Fi 下',
140
- ' 4) 防火墙放行本机 UDP:8710 入站(hdc discover 广播用)',
141
- '也可手动跑 `hdc tconn <ip:port>` 后重新启动测试。',
142
- '若不希望自动连接,设 OHOS_PW_AUTO_CONNECT=0 跳过。',
143
- ].join('\n')
144
-
145
- export function tryLocalDevice(): boolean {
146
- try {
147
- const raw = String(execFileSync('param', ['get', 'persist.hdc.port'], { ...HDC_OPTS, timeout: 3000 })).trim()
148
- const port = parseInt(raw, 10)
149
- if (!port || port < 1 || port > 65535) return false
150
- console.log(`[ohos-playwright] local device port from param: ${port}`)
151
- const addr = `127.0.0.1:${port}`
152
- if (tconn(addr) && hasDeviceConnected()) { console.log(`[ohos-playwright] connected: ${addr}`); return true }
153
- console.warn(`[ohos-playwright] tconn ${addr} failed`)
154
- } catch {}
155
- return false
156
- }
157
-
158
- export async function ensureDeviceConnected(): Promise<void> {
159
- if (process.env.OHOS_PW_AUTO_CONNECT === '0') return
160
- if (hasDeviceConnected()) return
161
- if (tryLocalDevice()) return
162
-
163
- console.log('[ohos-playwright] no local device, broadcasting (hdc discover)...')
164
- const found = discoverDevices()
165
- for (const addr of found) {
166
- console.log(`[ohos-playwright] hdc tconn ${addr}`)
167
- if (tconn(addr) && hasDeviceConnected()) return
168
- }
169
- if (found.length > 0) console.warn('[ohos-playwright] discovered devices but none connected')
170
-
171
- if (!process.stdin.isTTY) throw new Error(CONNECT_HELP)
172
- console.log(CONNECT_HELP)
173
- const addr = await promptAddress()
174
- if (!addr) throw new Error('[ohos-playwright] no device address provided; aborting.')
175
- if (!IP_PORT_RE.test(addr)) throw new Error(`[ohos-playwright] "${addr}" is not a valid ip:port.`)
176
- if (!tconn(addr)) throw new Error(`[ohos-playwright] hdc tconn ${addr} failed.`)
177
- if (!hasDeviceConnected()) throw new Error('[ohos-playwright] tconn reported OK but list targets still empty.')
178
- console.log(`[ohos-playwright] connected: ${addr}`)
179
- }
180
-
181
- export default async function globalSetup(): Promise<void> {
182
- await ensureDeviceConnected()
183
- console.log(`[ohos-playwright] locating ${BUNDLE}...`)
184
-
185
- // Batch ps + /proc/net/unix into a single hdc call.
186
- let deviceState = fetchDeviceState()
187
- let pid = findBrowserPid()
188
-
189
- if (!pid) {
190
- console.log('[ohos-playwright] browser not running, launching...')
191
- launchBrowser()
192
- // Backoff: 100, 200, 400, 800, 1000, 1000, … (max 20 attempts ≈ 10s total)
193
- pid = await retry(findBrowserPid, { max: 20, interval: 1000, label: `Failed to launch ${BUNDLE}` }) as number
194
- }
195
- console.log(`[ohos-playwright] browser pid=${pid}`)
196
-
197
- // Reuse the /proc/net/unix snapshot if it's fresh (same shell call as ps above).
198
- // If we waited for browser launch, re-fetch since the socket may be new.
199
- let unixCache: string | undefined = pid ? undefined : deviceState.unix
200
- if (!unixCache) {
201
- // Re-fetch device state to get fresh /proc/net/unix after browser launch
202
- deviceState = fetchDeviceState()
203
- unixCache = deviceState.unix
204
- }
205
-
206
- const socket = await retry(
207
- () => findDevToolsSocket(pid, unixCache),
208
- { max: 10, interval: 500, label: `DevTools socket not found for pid ${pid}` },
209
- ) as string
210
- console.log(`[ohos-playwright] socket=${socket}`)
211
-
212
- const port = await pickFreePort()
213
- setupForward(port, socket)
214
- console.log(`[ohos-playwright] hdc fport tcp:${port} -> localabstract:${socket}`)
215
-
216
- const probe = await probeCdp(port)
217
- if (!probe.ok) throw new Error(`CDP probe failed: ${probe.err || probe.body}`)
218
- const info = JSON.parse(probe.body!)
219
- console.log(`[ohos-playwright] CDP ready: ${info.Browser}`)
220
-
221
- mkdirSync(dirname(INFO_PATH), { recursive: true })
222
- writeFileSync(INFO_PATH, JSON.stringify({ port, pid, socket, endpoint: `http://127.0.0.1:${port}` }, null, 2))
223
- console.log(`[ohos-playwright] wrote ${INFO_PATH}`)
224
- }
package/src/teardown.mts DELETED
@@ -1,30 +0,0 @@
1
- import { execFileSync } from 'node:child_process'
2
- import { readFileSync, unlinkSync } from 'node:fs'
3
- import { INFO_PATH, type CdpInfo } from './info-path.mts'
4
-
5
- const HDC: string = process.env.OHOS_PW_HDC ?? '/data/service/hnp/bin/hdc'
6
-
7
- export default async function globalTeardown(): Promise<void> {
8
- let info: CdpInfo
9
- try {
10
- info = JSON.parse(readFileSync(INFO_PATH, 'utf8'))
11
- } catch {
12
- return
13
- }
14
- const ruler = `tcp:${info.port} localabstract:${info.socket}`
15
- try {
16
- execFileSync(HDC, ['fport', 'rm', ruler], { stdio: ['ignore', 'pipe', 'pipe'] })
17
- console.log(`[ohos-playwright] removed fport ${ruler}`)
18
- } catch (e: unknown) {
19
- const msg = e instanceof Error ? e.message?.split('\n')[0] : String(e)
20
- console.warn(`[ohos-playwright] fport rm failed (non-fatal): ${msg}`)
21
- }
22
- try {
23
- unlinkSync(INFO_PATH)
24
- } catch (e: unknown) {
25
- const err = e as NodeJS.ErrnoException
26
- if (err.code !== 'ENOENT') {
27
- console.warn(`[ohos-playwright] Failed to remove ${INFO_PATH}: ${err.message}`)
28
- }
29
- }
30
- }