mobile-wdio-kit 0.1.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/LICENSE +15 -0
- package/README.md +54 -0
- package/bin/cli.mjs +120 -0
- package/lib/create.mjs +128 -0
- package/lib/doctor-cli.mjs +38 -0
- package/lib/doctor.mjs +324 -0
- package/package.json +44 -0
- package/template/.cursor/mcp.json +13 -0
- package/template/.cursor/rules/wdio-mcp-mobile.mdc +52 -0
- package/template/.env.example +33 -0
- package/template/LICENSE +15 -0
- package/template/README.md +158 -0
- package/template/THIRD_PARTY.md +39 -0
- package/template/apps/.gitkeep +1 -0
- package/template/configs/wdio.cloud.android.conf.ts +31 -0
- package/template/configs/wdio.cloud.ios.conf.ts +31 -0
- package/template/configs/wdio.local.android.conf.ts +23 -0
- package/template/configs/wdio.local.ios.conf.ts +23 -0
- package/template/configs/wdio.shared.ts +36 -0
- package/template/package.json +61 -0
- package/template/patches/@wdio+mcp+3.2.2.patch +87 -0
- package/template/scripts/android-env.sh +102 -0
- package/template/scripts/doctor-cli.mjs +38 -0
- package/template/scripts/doctor-runner.mjs +7 -0
- package/template/scripts/download-demo-android.mjs +47 -0
- package/template/scripts/ensure-appium.mjs +71 -0
- package/template/scripts/mcp-with-appium.sh +30 -0
- package/template/scripts/mobile-wdio-doctor-core.mjs +324 -0
- package/template/scripts/ping-appium.mjs +24 -0
- package/template/scripts/run-android-local.sh +11 -0
- package/template/scripts/run-appium-local.sh +11 -0
- package/template/scripts/run-mcp-android-smoke.sh +47 -0
- package/template/src/env/buildEnv.test.ts +126 -0
- package/template/src/env/buildEnv.ts +81 -0
- package/template/src/env.ts +13 -0
- package/template/src/lib/safeFilePart.test.ts +17 -0
- package/template/src/lib/safeFilePart.ts +4 -0
- package/template/src/locators/locators.test.ts +35 -0
- package/template/src/locators/login.locators.ts +18 -0
- package/template/src/locators/nativeAlert.locators.ts +13 -0
- package/template/src/locators/tabBar.locators.ts +12 -0
- package/template/src/pages/Login.page.test.ts +73 -0
- package/template/src/pages/Login.page.ts +37 -0
- package/template/src/pages/NativeAlert.page.test.ts +91 -0
- package/template/src/pages/NativeAlert.page.ts +35 -0
- package/template/src/pages/TabBar.page.test.ts +35 -0
- package/template/src/pages/TabBar.page.ts +19 -0
- package/template/src/specs/app.login.spec.ts +20 -0
- package/template/src/test-utils/wdioTestGlobals.ts +82 -0
- package/template/tsconfig.json +22 -0
- package/template/vitest.config.ts +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# mobile-wdio-kit
|
|
2
|
+
|
|
3
|
+
CLI to **scaffold** a WebdriverIO + Appium mobile test project (Android/iOS, optional Cursor MCP wiring, Vitest, `patch-package`) and run **`doctor`** for an environment preflight.
|
|
4
|
+
|
|
5
|
+
- **License:** [LICENSE](./LICENSE) (ISC).
|
|
6
|
+
- **Third-party & trademarks:** each scaffolded project includes `THIRD_PARTY.md` (vendored from the monorepo root when maintainers run `npm run kit:sync`). Read it before redistributing the demo APK or publishing derivative work.
|
|
7
|
+
|
|
8
|
+
> Before `npm publish`, confirm `package.json` `repository` / `bugs` / `homepage` match your public repo. See [RELEASING.md](../../RELEASING.md).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g mobile-wdio-kit
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or use **npx** (no global install):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx mobile-wdio-kit@latest create ./my-mobile-tests
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## `create <directory>`
|
|
23
|
+
|
|
24
|
+
Copies the embedded template, sets `package.json` `name` from the folder (or `--name`), copies `.env.example` → `.env`, runs **`npm install`** (runs **`patch-package`**), then downloads the **WebdriverIO demo Android APK** unless `--no-demo-apk`.
|
|
25
|
+
|
|
26
|
+
**Path rules:** the directory is resolved with `path.resolve()` from your **current working directory** (where you run the command), not from the global npm package path. Relative paths (`./foo`), absolute paths (`/tmp/foo`), and `~/foo` (tilde expanded by your shell or by the CLI) are supported.
|
|
27
|
+
|
|
28
|
+
| Option | Meaning |
|
|
29
|
+
|--------|---------|
|
|
30
|
+
| `--force` | Overwrite a non-empty directory. |
|
|
31
|
+
| `--no-install` | Skip `npm install`. |
|
|
32
|
+
| `--no-demo-apk` | Skip demo APK download. |
|
|
33
|
+
| `--name <name>` | Override `package.json` `name`. |
|
|
34
|
+
|
|
35
|
+
## `doctor`
|
|
36
|
+
|
|
37
|
+
Checks Node (≥ 18), npm, Android SDK / `adb`, `JAVA_HOME`, demo APK, `.env`, local Appium + drivers, Xcode/simctl (macOS), and Appium `/status`. Exit code **1** only if a **required** check fails (Node version). Warnings are **!** and still exit **0**.
|
|
38
|
+
|
|
39
|
+
| Option | Meaning |
|
|
40
|
+
|--------|---------|
|
|
41
|
+
| `--cwd <path>` | Project root (default: `process.cwd()`). |
|
|
42
|
+
| `--json` | JSON on stdout. |
|
|
43
|
+
| `--appium-url <url>` | Override `/status` URL. |
|
|
44
|
+
|
|
45
|
+
In generated projects, **`npm run doctor`** uses a **vendored** script (no registry dependency on this package).
|
|
46
|
+
|
|
47
|
+
## Maintainers (monorepo)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm run kit:sync # repo root: refresh template/
|
|
51
|
+
cd packages/mobile-wdio-kit && npm publish --access public
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`prepack` fails if `template/` is missing—run `kit:sync` first.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createProject } from "../lib/create.mjs";
|
|
3
|
+
import { createDoctorMain } from "../lib/doctor-cli.mjs";
|
|
4
|
+
import { printDoctorResult, runDoctor } from "../lib/doctor.mjs";
|
|
5
|
+
|
|
6
|
+
const runDoctorMain = createDoctorMain({ runDoctor, printDoctorResult });
|
|
7
|
+
|
|
8
|
+
function help() {
|
|
9
|
+
process.stdout.write(`
|
|
10
|
+
mobile-wdio-kit — scaffold + environment checks for WebdriverIO + Appium mobile projects
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
mobile-wdio-kit create <directory> [options]
|
|
14
|
+
mobile-wdio-kit doctor [options]
|
|
15
|
+
mobile-wdio-kit --help
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
create Copy the framework template, run npm install, fetch the demo Android APK.
|
|
19
|
+
doctor Verify Node, Android SDK, adb, Appium drivers, demo APK, .env, Appium server.
|
|
20
|
+
|
|
21
|
+
Where create writes:
|
|
22
|
+
The target path is resolved with Node path.resolve() from your current working directory
|
|
23
|
+
(the shell’s cwd—not the global npm install location). Examples:
|
|
24
|
+
create ./my-app → <cwd>/my-app
|
|
25
|
+
create /tmp/foo → /tmp/foo
|
|
26
|
+
create ~/Desktop/foo → your home/Desktop/foo (tilde expanded by shell or by the CLI)
|
|
27
|
+
|
|
28
|
+
Create options:
|
|
29
|
+
--force Overwrite a non-empty directory (dangerous).
|
|
30
|
+
--no-install Skip npm install (you must install dependencies yourself).
|
|
31
|
+
--no-demo-apk Skip downloading the WebdriverIO demo APK.
|
|
32
|
+
--name <pkg-name> package.json name (default: folder name).
|
|
33
|
+
|
|
34
|
+
Doctor options:
|
|
35
|
+
--cwd <path> Project root to inspect (default: process.cwd()).
|
|
36
|
+
--json Machine-readable JSON on stdout.
|
|
37
|
+
--appium-url <url> Override status URL (default: from .env or http://127.0.0.1:4723/status).
|
|
38
|
+
|
|
39
|
+
Examples:
|
|
40
|
+
npx mobile-wdio-kit@latest create ./my-mobile-tests
|
|
41
|
+
cd my-mobile-tests && npm run doctor
|
|
42
|
+
mobile-wdio-kit doctor --cwd ./my-mobile-tests
|
|
43
|
+
|
|
44
|
+
`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseCreateArgs(argv) {
|
|
48
|
+
const out = {
|
|
49
|
+
target: null,
|
|
50
|
+
force: false,
|
|
51
|
+
skipInstall: false,
|
|
52
|
+
skipDemoApk: false,
|
|
53
|
+
name: null,
|
|
54
|
+
};
|
|
55
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
56
|
+
const a = argv[i];
|
|
57
|
+
if (a === "--force") out.force = true;
|
|
58
|
+
else if (a === "--no-install") out.skipInstall = true;
|
|
59
|
+
else if (a === "--no-demo-apk") out.skipDemoApk = true;
|
|
60
|
+
else if (a === "--name") {
|
|
61
|
+
out.name = argv[i + 1];
|
|
62
|
+
i += 1;
|
|
63
|
+
} else if (!a.startsWith("-") && !out.target) out.target = a;
|
|
64
|
+
else {
|
|
65
|
+
throw new Error(`Unknown argument: ${a}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main() {
|
|
72
|
+
const argv = process.argv.slice(2);
|
|
73
|
+
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
|
|
74
|
+
help();
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cmd = argv[0];
|
|
79
|
+
const rest = argv.slice(1);
|
|
80
|
+
|
|
81
|
+
if (cmd === "create") {
|
|
82
|
+
const args = parseCreateArgs(rest);
|
|
83
|
+
if (!args.target) {
|
|
84
|
+
console.error("Error: create requires a target directory.\n");
|
|
85
|
+
help();
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const { targetAbs, name } = createProject({
|
|
90
|
+
targetDir: args.target,
|
|
91
|
+
force: args.force,
|
|
92
|
+
skipInstall: args.skipInstall,
|
|
93
|
+
skipDemoApk: args.skipDemoApk,
|
|
94
|
+
projectName: args.name,
|
|
95
|
+
});
|
|
96
|
+
process.stdout.write(
|
|
97
|
+
`\nCreated project "${name}" at:\n ${targetAbs}\n\nNext:\n cd ${args.target}\n npm run doctor\n npm run test:android # with emulator + Appium\n`,
|
|
98
|
+
);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.error(String(/** @type {Error} */ (e).message || e));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (cmd === "doctor") {
|
|
107
|
+
try {
|
|
108
|
+
process.exit(await runDoctorMain(rest));
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error(String(/** @type {Error} */ (e).message || e));
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
116
|
+
help();
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main();
|
package/lib/create.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
function templateRoot() {
|
|
19
|
+
return resolve(__dirname, "..", "template");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve `~/...` even when the shell did not expand it (e.g. quoted path). */
|
|
23
|
+
function expandUserDir(targetDir) {
|
|
24
|
+
const t = String(targetDir).trim();
|
|
25
|
+
if (t === "~") return homedir();
|
|
26
|
+
if (t.startsWith("~/") || t.startsWith("~\\")) {
|
|
27
|
+
return join(homedir(), t.slice(2));
|
|
28
|
+
}
|
|
29
|
+
return t;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function kebabName(raw) {
|
|
33
|
+
return String(raw || "mobile-wdio-project")
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9-_]+/g, "-")
|
|
37
|
+
.replace(/^-+|-+$/g, "") || "mobile-wdio-project";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{ targetDir: string; force?: boolean; skipInstall?: boolean; skipDemoApk?: boolean; projectName?: string }} opts
|
|
42
|
+
*/
|
|
43
|
+
export function createProject(opts) {
|
|
44
|
+
const template = templateRoot();
|
|
45
|
+
if (!existsSync(join(template, "package.json"))) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"Template missing in mobile-wdio-kit. If you develop the kit from git, run: npm run kit:sync (repo root).",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const targetAbs = resolve(expandUserDir(opts.targetDir));
|
|
52
|
+
const name = kebabName(opts.projectName ?? basename(targetAbs));
|
|
53
|
+
|
|
54
|
+
if (existsSync(targetAbs)) {
|
|
55
|
+
const entries = readDirSafe(targetAbs);
|
|
56
|
+
if (entries.length > 0 && !opts.force) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Target directory is not empty: ${targetAbs}\nUse --force to overwrite.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (entries.length > 0 && opts.force) {
|
|
62
|
+
rmSync(targetAbs, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mkdirSync(targetAbs, { recursive: true });
|
|
67
|
+
cpSync(template, targetAbs, { recursive: true });
|
|
68
|
+
|
|
69
|
+
const pkgPath = join(targetAbs, "package.json");
|
|
70
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
71
|
+
pkg.name = name;
|
|
72
|
+
delete pkg.private;
|
|
73
|
+
if (!pkg.description) {
|
|
74
|
+
pkg.description = "WebdriverIO mobile tests + Appium + Cursor MCP (scaffolded).";
|
|
75
|
+
}
|
|
76
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
77
|
+
|
|
78
|
+
const envExample = join(targetAbs, ".env.example");
|
|
79
|
+
const envDest = join(targetAbs, ".env");
|
|
80
|
+
if (existsSync(envExample) && !existsSync(envDest)) {
|
|
81
|
+
copyFileSync(envExample, envDest);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!opts.skipInstall) {
|
|
85
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
86
|
+
const install = spawnSync(npm, ["install"], {
|
|
87
|
+
cwd: targetAbs,
|
|
88
|
+
stdio: "inherit",
|
|
89
|
+
shell: process.platform === "win32",
|
|
90
|
+
env: process.env,
|
|
91
|
+
});
|
|
92
|
+
if (install.status !== 0) {
|
|
93
|
+
throw new Error("`npm install` failed in the new project.");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!opts.skipDemoApk && !opts.skipInstall) {
|
|
98
|
+
const dl = join(targetAbs, "scripts", "download-demo-android.mjs");
|
|
99
|
+
if (existsSync(dl)) {
|
|
100
|
+
const r = spawnSync(process.execPath, [dl], {
|
|
101
|
+
cwd: targetAbs,
|
|
102
|
+
stdio: "inherit",
|
|
103
|
+
env: process.env,
|
|
104
|
+
});
|
|
105
|
+
if (r.status !== 0) {
|
|
106
|
+
console.warn(
|
|
107
|
+
"[mobile-wdio-kit] Demo APK download failed — run `npm run setup:demo-android` inside the project.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { targetAbs, name };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function basename(p) {
|
|
117
|
+
const s = p.replace(/[/\\]+$/, "");
|
|
118
|
+
const i = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
|
|
119
|
+
return i >= 0 ? s.slice(i + 1) : s;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readDirSafe(dir) {
|
|
123
|
+
try {
|
|
124
|
+
return readdirSync(dir);
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Shared doctor argv + runner factory (used by global CLI and vendored template). */
|
|
2
|
+
|
|
3
|
+
export function parseDoctorArgs(argv) {
|
|
4
|
+
const out = { cwd: process.cwd(), json: false, appiumUrl: null };
|
|
5
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
6
|
+
const a = argv[i];
|
|
7
|
+
if (a === "--json") out.json = true;
|
|
8
|
+
else if (a === "--cwd") {
|
|
9
|
+
out.cwd = argv[i + 1];
|
|
10
|
+
i += 1;
|
|
11
|
+
} else if (a === "--appium-url") {
|
|
12
|
+
out.appiumUrl = argv[i + 1];
|
|
13
|
+
i += 1;
|
|
14
|
+
} else {
|
|
15
|
+
throw new Error(`Unknown argument: ${a}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {{ runDoctor: Function; printDoctorResult: Function }} deps */
|
|
22
|
+
export function createDoctorMain(deps) {
|
|
23
|
+
const { runDoctor, printDoctorResult } = deps;
|
|
24
|
+
return async function runDoctorMain(argv) {
|
|
25
|
+
const args = parseDoctorArgs(argv);
|
|
26
|
+
const result = await runDoctor({
|
|
27
|
+
cwd: args.cwd,
|
|
28
|
+
json: args.json,
|
|
29
|
+
appiumUrl: args.appiumUrl ?? undefined,
|
|
30
|
+
});
|
|
31
|
+
if (args.json) {
|
|
32
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
33
|
+
} else {
|
|
34
|
+
printDoctorResult(result);
|
|
35
|
+
}
|
|
36
|
+
return result.ok ? 0 : 1;
|
|
37
|
+
};
|
|
38
|
+
}
|
package/lib/doctor.mjs
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const MIN_NODE = 18;
|
|
6
|
+
|
|
7
|
+
/** @typedef {{ ok: boolean; level: "ok" | "warn" | "fail"; title: string; detail?: string; fix?: string }} Check */
|
|
8
|
+
|
|
9
|
+
function nodeMajor(version) {
|
|
10
|
+
const m = /^v?(\d+)/.exec(version.trim());
|
|
11
|
+
return m ? Number(m[1]) : 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function run(cmd, args, opts = {}) {
|
|
15
|
+
const r = spawnSync(cmd, args, {
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
shell: process.platform === "win32",
|
|
18
|
+
...opts,
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
code: r.status ?? 1,
|
|
22
|
+
out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function which(name) {
|
|
27
|
+
const cmd = process.platform === "win32" ? "where" : "which";
|
|
28
|
+
const r = run(cmd, [name]);
|
|
29
|
+
return r.code === 0 ? r.out.split("\n")[0].trim() : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ cwd?: string; json?: boolean; appiumUrl?: string }} opts
|
|
34
|
+
*/
|
|
35
|
+
export async function runDoctor(opts = {}) {
|
|
36
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
37
|
+
const checks = /** @type {Check[]} */ ([]);
|
|
38
|
+
|
|
39
|
+
const add = (c) => checks.push(c);
|
|
40
|
+
|
|
41
|
+
const nodeVer = process.version;
|
|
42
|
+
const major = nodeMajor(nodeVer);
|
|
43
|
+
add({
|
|
44
|
+
ok: major >= MIN_NODE,
|
|
45
|
+
level: major >= MIN_NODE ? "ok" : "fail",
|
|
46
|
+
title: `Node.js ${nodeVer}`,
|
|
47
|
+
detail: `Required: >= ${MIN_NODE}`,
|
|
48
|
+
fix:
|
|
49
|
+
major < MIN_NODE
|
|
50
|
+
? "Install Node.js 18+ (LTS recommended) from https://nodejs.org/"
|
|
51
|
+
: undefined,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const npmWhich = which("npm");
|
|
55
|
+
add({
|
|
56
|
+
ok: Boolean(npmWhich),
|
|
57
|
+
level: npmWhich ? "ok" : "warn",
|
|
58
|
+
title: "npm on PATH",
|
|
59
|
+
detail: npmWhich || "not found",
|
|
60
|
+
fix: npmWhich ? undefined : "Install Node.js with npm, or use a version manager (nvm, fnm).",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const androidHome =
|
|
64
|
+
process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || "";
|
|
65
|
+
const sdkOk = Boolean(androidHome && existsSync(androidHome));
|
|
66
|
+
add({
|
|
67
|
+
ok: sdkOk,
|
|
68
|
+
level: sdkOk ? "ok" : "warn",
|
|
69
|
+
title: "Android SDK (ANDROID_HOME or ANDROID_SDK_ROOT)",
|
|
70
|
+
detail: sdkOk ? androidHome : "not set or path missing",
|
|
71
|
+
fix: sdkOk
|
|
72
|
+
? undefined
|
|
73
|
+
: "Install Android Studio / cmdline-tools, then export ANDROID_HOME (e.g. ~/Library/Android/sdk on macOS).",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const adbPath = which("adb");
|
|
77
|
+
const adbOk = Boolean(adbPath);
|
|
78
|
+
add({
|
|
79
|
+
ok: adbOk,
|
|
80
|
+
level: adbOk ? "ok" : "warn",
|
|
81
|
+
title: "adb on PATH",
|
|
82
|
+
detail: adbOk ? adbPath : "not found",
|
|
83
|
+
fix: adbOk
|
|
84
|
+
? undefined
|
|
85
|
+
: "Add platform-tools to PATH (inside your Android SDK).",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (adbOk) {
|
|
89
|
+
const dev = run("adb", ["devices"]);
|
|
90
|
+
const lines = dev.out.split("\n").filter(Boolean);
|
|
91
|
+
const deviceLines = lines.filter(
|
|
92
|
+
(l) => l.includes("\tdevice") || l.includes("\temulator"),
|
|
93
|
+
);
|
|
94
|
+
add({
|
|
95
|
+
ok: deviceLines.length > 0,
|
|
96
|
+
level: deviceLines.length > 0 ? "ok" : "warn",
|
|
97
|
+
title: "ADB device / emulator",
|
|
98
|
+
detail:
|
|
99
|
+
deviceLines.length > 0
|
|
100
|
+
? `${deviceLines.length} online`
|
|
101
|
+
: "no devices in `adb devices`",
|
|
102
|
+
fix:
|
|
103
|
+
deviceLines.length > 0
|
|
104
|
+
? undefined
|
|
105
|
+
: "Start an emulator (`emulator -avd …`) or plug in a device with USB debugging.",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const javaHome = process.env.JAVA_HOME || "";
|
|
110
|
+
const javaOk = Boolean(javaHome && existsSync(javaHome));
|
|
111
|
+
add({
|
|
112
|
+
ok: javaOk,
|
|
113
|
+
level: javaOk ? "ok" : "warn",
|
|
114
|
+
title: "JAVA_HOME (recommended for Android toolchain)",
|
|
115
|
+
detail: javaOk ? javaHome : "not set or path missing",
|
|
116
|
+
fix: javaOk
|
|
117
|
+
? undefined
|
|
118
|
+
: "Point JAVA_HOME at a JDK 17+ install (Android Studio bundled JBR works).",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const demoApk = join(cwd, "apps", "android.wdio.native.app.v2.0.0.apk");
|
|
122
|
+
const apkOk = existsSync(demoApk);
|
|
123
|
+
add({
|
|
124
|
+
ok: apkOk,
|
|
125
|
+
level: apkOk ? "ok" : "warn",
|
|
126
|
+
title: "Demo Android APK (apps/android.wdio.native.app.v2.0.0.apk)",
|
|
127
|
+
detail: apkOk ? demoApk : "missing",
|
|
128
|
+
fix: apkOk
|
|
129
|
+
? undefined
|
|
130
|
+
: "Run `npm run setup:demo-android` in this project (or set ANDROID_APP_PATH to your app).",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const envFile = join(cwd, ".env");
|
|
134
|
+
add({
|
|
135
|
+
ok: existsSync(envFile),
|
|
136
|
+
level: existsSync(envFile) ? "ok" : "warn",
|
|
137
|
+
title: ".env present",
|
|
138
|
+
detail: existsSync(envFile) ? envFile : "missing",
|
|
139
|
+
fix: existsSync(envFile)
|
|
140
|
+
? undefined
|
|
141
|
+
: "Copy `.env.example` to `.env` and adjust device names / paths.",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
let pkg = null;
|
|
145
|
+
try {
|
|
146
|
+
pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
147
|
+
} catch {
|
|
148
|
+
add({
|
|
149
|
+
ok: false,
|
|
150
|
+
level: "warn",
|
|
151
|
+
title: "package.json in cwd",
|
|
152
|
+
detail: "not readable — run doctor from the scaffolded project root",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (pkg) {
|
|
157
|
+
const appiumBin = join(cwd, "node_modules", ".bin", "appium");
|
|
158
|
+
const appiumLocal = existsSync(appiumBin);
|
|
159
|
+
add({
|
|
160
|
+
ok: appiumLocal,
|
|
161
|
+
level: appiumLocal ? "ok" : "warn",
|
|
162
|
+
title: "Appium (local devDependency)",
|
|
163
|
+
detail: appiumLocal ? appiumBin : "run npm install in this project",
|
|
164
|
+
fix: appiumLocal ? undefined : "From project root: npm install",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (appiumLocal) {
|
|
168
|
+
const appiumCli =
|
|
169
|
+
process.platform === "win32"
|
|
170
|
+
? join(cwd, "node_modules", ".bin", "appium.cmd")
|
|
171
|
+
: appiumBin;
|
|
172
|
+
const exe = existsSync(appiumCli) ? appiumCli : appiumBin;
|
|
173
|
+
const drivers = spawnSync(exe, ["driver", "list", "--installed"], {
|
|
174
|
+
cwd,
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
env: process.env,
|
|
177
|
+
shell: false,
|
|
178
|
+
});
|
|
179
|
+
const driverText = `${drivers.stdout ?? ""}${drivers.stderr ?? ""}`;
|
|
180
|
+
const hasU2 = /uiautomator2/i.test(driverText);
|
|
181
|
+
const hasXc = /xcuitest/i.test(driverText);
|
|
182
|
+
add({
|
|
183
|
+
ok: hasU2,
|
|
184
|
+
level: hasU2 ? "ok" : "warn",
|
|
185
|
+
title: "Appium UiAutomator2 driver",
|
|
186
|
+
detail: hasU2 ? "installed" : "not detected",
|
|
187
|
+
fix: hasU2 ? undefined : "npm run appium:driver:android",
|
|
188
|
+
});
|
|
189
|
+
if (process.platform === "darwin") {
|
|
190
|
+
add({
|
|
191
|
+
ok: hasXc,
|
|
192
|
+
level: hasXc ? "ok" : "warn",
|
|
193
|
+
title: "Appium XCUITest driver (iOS)",
|
|
194
|
+
detail: hasXc ? "installed" : "not detected",
|
|
195
|
+
fix: hasXc ? undefined : "npm run appium:driver:ios",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (process.platform === "darwin") {
|
|
202
|
+
const xc = run("xcodebuild", ["-version"]);
|
|
203
|
+
add({
|
|
204
|
+
ok: xc.code === 0,
|
|
205
|
+
level: xc.code === 0 ? "ok" : "warn",
|
|
206
|
+
title: "Xcode (xcodebuild)",
|
|
207
|
+
detail: xc.code === 0 ? xc.out.split("\n")[0] : xc.out || "not found",
|
|
208
|
+
fix:
|
|
209
|
+
xc.code === 0
|
|
210
|
+
? undefined
|
|
211
|
+
: "Install Xcode from the App Store and run `xcode-select --install` if needed.",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const sim = run("xcrun", ["simctl", "list", "devices", "available"]);
|
|
215
|
+
const simOk = sim.code === 0 && /iPhone|iPad/.test(sim.out);
|
|
216
|
+
add({
|
|
217
|
+
ok: simOk,
|
|
218
|
+
level: simOk ? "ok" : "warn",
|
|
219
|
+
title: "iOS Simulator (simctl)",
|
|
220
|
+
detail: simOk ? "at least one iPhone/iPad device listing present" : sim.out || "failed",
|
|
221
|
+
fix: simOk
|
|
222
|
+
? undefined
|
|
223
|
+
: "Open Xcode → Settings → Platforms and install a simulator runtime.",
|
|
224
|
+
});
|
|
225
|
+
} else {
|
|
226
|
+
add({
|
|
227
|
+
ok: true,
|
|
228
|
+
level: "ok",
|
|
229
|
+
title: "iOS tooling",
|
|
230
|
+
detail: "skipped (not macOS)",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const appiumUrl =
|
|
235
|
+
opts.appiumUrl ??
|
|
236
|
+
(() => {
|
|
237
|
+
try {
|
|
238
|
+
if (existsSync(join(cwd, ".env"))) {
|
|
239
|
+
const raw = readFileSync(join(cwd, ".env"), "utf8");
|
|
240
|
+
const host =
|
|
241
|
+
raw.match(/^\s*APPIUM_HOST\s*=\s*(.+)$/m)?.[1]?.trim() ??
|
|
242
|
+
"127.0.0.1";
|
|
243
|
+
const port =
|
|
244
|
+
raw.match(/^\s*APPIUM_PORT\s*=\s*(.+)$/m)?.[1]?.trim() ?? "4723";
|
|
245
|
+
return `http://${host}:${port}/status`;
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
/* ignore */
|
|
249
|
+
}
|
|
250
|
+
return "http://127.0.0.1:4723/status";
|
|
251
|
+
})();
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const ac = new AbortController();
|
|
255
|
+
const t = setTimeout(() => ac.abort(), 2500);
|
|
256
|
+
const res = await fetch(appiumUrl, { signal: ac.signal });
|
|
257
|
+
clearTimeout(t);
|
|
258
|
+
const body = res.ok ? await res.json().catch(() => ({})) : {};
|
|
259
|
+
const ready = body?.value?.ready === true;
|
|
260
|
+
add({
|
|
261
|
+
ok: ready,
|
|
262
|
+
level: ready ? "ok" : "warn",
|
|
263
|
+
title: "Appium server reachable",
|
|
264
|
+
detail: `${appiumUrl} → HTTP ${res.status}${ready ? ", ready" : ""}`,
|
|
265
|
+
fix: ready
|
|
266
|
+
? undefined
|
|
267
|
+
: "Start Appium: npm run appium:start (or your own 127.0.0.1:4723 server).",
|
|
268
|
+
});
|
|
269
|
+
} catch (e) {
|
|
270
|
+
add({
|
|
271
|
+
ok: false,
|
|
272
|
+
level: "warn",
|
|
273
|
+
title: "Appium server reachable",
|
|
274
|
+
detail: String(/** @type {Error} */ (e).message || e),
|
|
275
|
+
fix: "Start Appium on APPIUM_HOST:APPIUM_PORT (see .env).",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const failed = checks.filter((c) => c.level === "fail");
|
|
280
|
+
const warns = checks.filter((c) => c.level === "warn");
|
|
281
|
+
|
|
282
|
+
if (opts.json) {
|
|
283
|
+
return {
|
|
284
|
+
ok: failed.length === 0,
|
|
285
|
+
summary: {
|
|
286
|
+
fail: failed.length,
|
|
287
|
+
warn: warns.length,
|
|
288
|
+
ok: checks.filter((c) => c.level === "ok").length,
|
|
289
|
+
},
|
|
290
|
+
checks,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const icon = (c) =>
|
|
295
|
+
c.level === "ok" ? "✓" : c.level === "warn" ? "!" : "✗";
|
|
296
|
+
|
|
297
|
+
const lines = [
|
|
298
|
+
"",
|
|
299
|
+
"mobile-wdio-kit — doctor",
|
|
300
|
+
"========================",
|
|
301
|
+
"",
|
|
302
|
+
...checks.map(
|
|
303
|
+
(c) =>
|
|
304
|
+
`${icon(c)} ${c.title}${c.detail ? `\n ${c.detail.split("\n").join("\n ")}` : ""}${c.fix ? `\n → ${c.fix}` : ""}`,
|
|
305
|
+
),
|
|
306
|
+
"",
|
|
307
|
+
failed.length
|
|
308
|
+
? `Result: ${failed.length} required check(s) failed.`
|
|
309
|
+
: warns.length
|
|
310
|
+
? `Result: OK (with ${warns.length} warning(s)).`
|
|
311
|
+
: "Result: all checks passed.",
|
|
312
|
+
"",
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
ok: failed.length === 0,
|
|
317
|
+
text: lines.join("\n"),
|
|
318
|
+
checks,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function printDoctorResult(result) {
|
|
323
|
+
if (typeof result.text === "string") process.stdout.write(result.text);
|
|
324
|
+
}
|