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.
Files changed (51) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +54 -0
  3. package/bin/cli.mjs +120 -0
  4. package/lib/create.mjs +128 -0
  5. package/lib/doctor-cli.mjs +38 -0
  6. package/lib/doctor.mjs +324 -0
  7. package/package.json +44 -0
  8. package/template/.cursor/mcp.json +13 -0
  9. package/template/.cursor/rules/wdio-mcp-mobile.mdc +52 -0
  10. package/template/.env.example +33 -0
  11. package/template/LICENSE +15 -0
  12. package/template/README.md +158 -0
  13. package/template/THIRD_PARTY.md +39 -0
  14. package/template/apps/.gitkeep +1 -0
  15. package/template/configs/wdio.cloud.android.conf.ts +31 -0
  16. package/template/configs/wdio.cloud.ios.conf.ts +31 -0
  17. package/template/configs/wdio.local.android.conf.ts +23 -0
  18. package/template/configs/wdio.local.ios.conf.ts +23 -0
  19. package/template/configs/wdio.shared.ts +36 -0
  20. package/template/package.json +61 -0
  21. package/template/patches/@wdio+mcp+3.2.2.patch +87 -0
  22. package/template/scripts/android-env.sh +102 -0
  23. package/template/scripts/doctor-cli.mjs +38 -0
  24. package/template/scripts/doctor-runner.mjs +7 -0
  25. package/template/scripts/download-demo-android.mjs +47 -0
  26. package/template/scripts/ensure-appium.mjs +71 -0
  27. package/template/scripts/mcp-with-appium.sh +30 -0
  28. package/template/scripts/mobile-wdio-doctor-core.mjs +324 -0
  29. package/template/scripts/ping-appium.mjs +24 -0
  30. package/template/scripts/run-android-local.sh +11 -0
  31. package/template/scripts/run-appium-local.sh +11 -0
  32. package/template/scripts/run-mcp-android-smoke.sh +47 -0
  33. package/template/src/env/buildEnv.test.ts +126 -0
  34. package/template/src/env/buildEnv.ts +81 -0
  35. package/template/src/env.ts +13 -0
  36. package/template/src/lib/safeFilePart.test.ts +17 -0
  37. package/template/src/lib/safeFilePart.ts +4 -0
  38. package/template/src/locators/locators.test.ts +35 -0
  39. package/template/src/locators/login.locators.ts +18 -0
  40. package/template/src/locators/nativeAlert.locators.ts +13 -0
  41. package/template/src/locators/tabBar.locators.ts +12 -0
  42. package/template/src/pages/Login.page.test.ts +73 -0
  43. package/template/src/pages/Login.page.ts +37 -0
  44. package/template/src/pages/NativeAlert.page.test.ts +91 -0
  45. package/template/src/pages/NativeAlert.page.ts +35 -0
  46. package/template/src/pages/TabBar.page.test.ts +35 -0
  47. package/template/src/pages/TabBar.page.ts +19 -0
  48. package/template/src/specs/app.login.spec.ts +20 -0
  49. package/template/src/test-utils/wdioTestGlobals.ts +82 -0
  50. package/template/tsconfig.json +22 -0
  51. 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
+ }