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
@@ -0,0 +1,102 @@
1
+ #!/bin/sh
2
+ # Shared Android SDK / emulator bootstrap for local scripts. Source (do not execute).
3
+ # shellcheck shell=sh
4
+
5
+ android_env_project_root() {
6
+ CDPATH= cd -- "$(dirname -- "$0")/.." && pwd
7
+ }
8
+
9
+ android_env_prepare_paths() {
10
+ PROJECT_ROOT="${PROJECT_ROOT:-$(android_env_project_root)}"
11
+ mkdir -p "$PROJECT_ROOT/artifacts"
12
+
13
+ if [ -z "${ANDROID_HOME:-}" ] && [ -d "$HOME/Library/Android/sdk" ]; then
14
+ export ANDROID_HOME="$HOME/Library/Android/sdk"
15
+ fi
16
+
17
+ if [ -z "${ANDROID_SDK_ROOT:-}" ] && [ -n "${ANDROID_HOME:-}" ]; then
18
+ export ANDROID_SDK_ROOT="$ANDROID_HOME"
19
+ fi
20
+
21
+ if [ -z "${JAVA_HOME:-}" ]; then
22
+ if [ -x "/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin/java" ]; then
23
+ export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
24
+ elif command -v /usr/libexec/java_home >/dev/null 2>&1; then
25
+ JAVA_HOME_CANDIDATE=$(/usr/libexec/java_home 2>/dev/null || true)
26
+ if [ -n "$JAVA_HOME_CANDIDATE" ]; then
27
+ export JAVA_HOME="$JAVA_HOME_CANDIDATE"
28
+ fi
29
+ fi
30
+ fi
31
+
32
+ if [ -n "${JAVA_HOME:-}" ]; then
33
+ export PATH="$JAVA_HOME/bin:$PATH"
34
+ fi
35
+
36
+ if [ -n "${ANDROID_HOME:-}" ]; then
37
+ ADB_BIN="$ANDROID_HOME/platform-tools/adb"
38
+ EMULATOR_BIN="$ANDROID_HOME/emulator/emulator"
39
+ else
40
+ ADB_BIN=""
41
+ EMULATOR_BIN=""
42
+ fi
43
+ }
44
+
45
+ android_env_has_device() {
46
+ [ -n "$ADB_BIN" ] && [ -x "$ADB_BIN" ] &&
47
+ "$ADB_BIN" devices | awk 'NR>1 && $2=="device" { found=1 } END { exit found ? 0 : 1 }'
48
+ }
49
+
50
+ android_env_ensure_emulator() {
51
+ android_env_prepare_paths
52
+
53
+ if android_env_has_device; then
54
+ return 0
55
+ fi
56
+
57
+ if [ ! -x "$EMULATOR_BIN" ] || [ ! -x "$ADB_BIN" ]; then
58
+ echo "Android SDK tools not found. Set ANDROID_HOME." >&2
59
+ exit 1
60
+ fi
61
+
62
+ SELECTED_AVD="${ANDROID_AVD:-$("$EMULATOR_BIN" -list-avds 2>/dev/null | sed -n '/^[A-Za-z0-9._-][A-Za-z0-9._-]*$/p' | sed -n '1p')}"
63
+
64
+ if [ -z "$SELECTED_AVD" ]; then
65
+ echo "No Android emulator available. Create an AVD or set ANDROID_AVD." >&2
66
+ exit 1
67
+ fi
68
+
69
+ nohup "$EMULATOR_BIN" -avd "$SELECTED_AVD" >"$PROJECT_ROOT/artifacts/emulator.log" 2>&1 &
70
+
71
+ "$ADB_BIN" wait-for-device
72
+ while [ "$("$ADB_BIN" shell getprop sys.boot_completed | tr -d '\r')" != "1" ]; do
73
+ sleep 2
74
+ done
75
+ "$ADB_BIN" shell input keyevent 82 >/dev/null 2>&1 || true
76
+ }
77
+
78
+ # Start an AVD in the background without waiting for boot. For MCP stdio: Cursor must get a
79
+ # JSON-RPC handshake quickly; blocking on cold boot exceeds typical client timeouts.
80
+ android_env_start_emulator_async() {
81
+ android_env_prepare_paths
82
+
83
+ if android_env_has_device; then
84
+ return 0
85
+ fi
86
+
87
+ if [ ! -x "$EMULATOR_BIN" ] || [ ! -x "$ADB_BIN" ]; then
88
+ echo "Android SDK tools not found. Set ANDROID_HOME." >&2
89
+ return 1
90
+ fi
91
+
92
+ SELECTED_AVD="${ANDROID_AVD:-$("$EMULATOR_BIN" -list-avds 2>/dev/null | sed -n '/^[A-Za-z0-9._-][A-Za-z0-9._-]*$/p' | sed -n '1p')}"
93
+
94
+ if [ -z "$SELECTED_AVD" ]; then
95
+ echo "No Android emulator available. Create an AVD or set ANDROID_AVD." >&2
96
+ return 1
97
+ fi
98
+
99
+ echo "[android-env] Starting emulator '$SELECTED_AVD' in the background (logs: $PROJECT_ROOT/artifacts/emulator.log). Wait for adb device before start_session." >&2
100
+ nohup "$EMULATOR_BIN" -avd "$SELECTED_AVD" >"$PROJECT_ROOT/artifacts/emulator.log" 2>&1 &
101
+ return 0
102
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { printDoctorResult, runDoctor } from "./mobile-wdio-doctor-core.mjs";
3
+ import { createDoctorMain } from "./doctor-cli.mjs";
4
+
5
+ const runDoctorMain = createDoctorMain({ runDoctor, printDoctorResult });
6
+ const code = await runDoctorMain(process.argv.slice(2));
7
+ process.exit(code);
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Downloads the WebdriverIO native demo Android APK into apps/ (for a lean npm template).
4
+ * Override URL: DEMO_APK_URL=https://.../file.apk
5
+ * Attribution / license for the binary: see THIRD_PARTY.md in this repository.
6
+ */
7
+ import { createWriteStream, existsSync, mkdirSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { pipeline } from "node:stream/promises";
11
+ import { Readable } from "node:stream";
12
+
13
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
14
+ const defaultUrl =
15
+ "https://github.com/webdriverio/native-demo-app/releases/download/v2.0.0/android.wdio.native.app.v2.0.0.apk";
16
+ const url = process.env.DEMO_APK_URL ?? defaultUrl;
17
+ const dest = join(
18
+ root,
19
+ "apps",
20
+ "android.wdio.native.app.v2.0.0.apk",
21
+ );
22
+
23
+ if (existsSync(dest) && process.env.DEMO_APK_FORCE !== "1") {
24
+ console.log(`Demo APK already present: ${dest}`);
25
+ process.exit(0);
26
+ }
27
+
28
+ mkdirSync(dirname(dest), { recursive: true });
29
+
30
+ console.log(`Downloading demo APK…\n ${url}`);
31
+
32
+ const res = await fetch(url, {
33
+ redirect: "follow",
34
+ headers: {
35
+ "User-Agent": "mobile-wdio-kit-setup",
36
+ Accept: "application/octet-stream,*/*",
37
+ },
38
+ });
39
+
40
+ if (!res.ok || !res.body) {
41
+ console.error(`Download failed: HTTP ${res.status} ${res.statusText}`);
42
+ process.exit(1);
43
+ }
44
+
45
+ const out = createWriteStream(dest);
46
+ await pipeline(Readable.fromWeb(res.body), out);
47
+ console.log(`Wrote ${dest}`);
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * If Appium is not ready at APPIUM_HOST:APPIUM_PORT, start it in the background
4
+ * (logs to artifacts/appium-mcp.log). Safe for MCP: does not touch stdout.
5
+ */
6
+ import "dotenv/config";
7
+ import { spawn } from "node:child_process";
8
+ import { mkdirSync, openSync, writeSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
13
+ const host = process.env.APPIUM_HOST ?? "127.0.0.1";
14
+ const port = Number(process.env.APPIUM_PORT ?? "4723");
15
+ const statusUrl = `http://${host}:${port}/status`;
16
+
17
+ async function ping() {
18
+ try {
19
+ const res = await fetch(statusUrl, { signal: AbortSignal.timeout(2000) });
20
+ if (!res.ok) return false;
21
+ const body = await res.json();
22
+ return body?.value?.ready === true;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ if (await ping()) {
29
+ console.error(`[ensure-appium] already ready at ${statusUrl}`);
30
+ process.exit(0);
31
+ }
32
+
33
+ mkdirSync(join(root, "artifacts"), { recursive: true });
34
+ const logPath = join(root, "artifacts", "appium-mcp.log");
35
+ const logFd = openSync(logPath, "a");
36
+ const line = `\n--- ${new Date().toISOString()} starting Appium for MCP ---\n`;
37
+ writeSync(logFd, line);
38
+
39
+ const appiumBin = join(root, "node_modules", ".bin", "appium");
40
+ const child = spawn(
41
+ appiumBin,
42
+ ["--address", host, "--port", String(port)],
43
+ {
44
+ cwd: root,
45
+ detached: true,
46
+ stdio: ["ignore", logFd, logFd],
47
+ }
48
+ );
49
+ child.unref();
50
+
51
+ child.on("error", (err) => {
52
+ console.error("[ensure-appium] failed to spawn appium:", err.message);
53
+ process.exit(1);
54
+ });
55
+
56
+ const deadline = Date.now() + 120_000;
57
+ while (Date.now() < deadline) {
58
+ if (await ping()) {
59
+ console.error(`[ensure-appium] Appium ready at ${statusUrl} (log: ${logPath})`);
60
+ process.exit(0);
61
+ }
62
+ await new Promise((r) => setTimeout(r, 500));
63
+ }
64
+
65
+ try {
66
+ process.kill(child.pid, "SIGTERM");
67
+ } catch {
68
+ // ignore
69
+ }
70
+ console.error("[ensure-appium] timed out waiting for Appium");
71
+ process.exit(1);
@@ -0,0 +1,30 @@
1
+ #!/bin/sh
2
+ # Cursor MCP entry: ensure Android device/emulator, ensure Appium, then run wdio-mcp on stdio.
3
+ set -eu
4
+
5
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
6
+ PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
7
+ cd "$PROJECT_ROOT"
8
+
9
+ # Load .env so wdio-mcp (patched) sees MOBILE_* for demo auto-login and APPIUM_* / paths match local runs.
10
+ if [ -f "$PROJECT_ROOT/.env" ]; then
11
+ set -a
12
+ # shellcheck disable=SC1091
13
+ . "$PROJECT_ROOT/.env"
14
+ set +a
15
+ fi
16
+
17
+ # shellcheck source=android-env.sh
18
+ . "$SCRIPT_DIR/android-env.sh"
19
+ android_env_prepare_paths
20
+ # Blocking until boot_completed can take minutes and breaks Cursor's MCP handshake (~10s).
21
+ # Terminal: npm run mcp:server:with-appium sets MCP_BLOCK_UNTIL_EMULATOR_READY=1 for full wait.
22
+ if [ "${MCP_BLOCK_UNTIL_EMULATOR_READY:-0}" = "1" ]; then
23
+ android_env_ensure_emulator
24
+ else
25
+ android_env_start_emulator_async || exit 1
26
+ fi
27
+
28
+ node "$SCRIPT_DIR/ensure-appium.mjs"
29
+
30
+ exec "$PROJECT_ROOT/node_modules/.bin/wdio-mcp" "$@"
@@ -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
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Quick check that Appium is up before using wdio-mcp from Cursor.
4
+ * Respects APPIUM_HOST / APPIUM_PORT (same as .env).
5
+ */
6
+ import "dotenv/config";
7
+
8
+ const host = process.env.APPIUM_HOST ?? "127.0.0.1";
9
+ const port = process.env.APPIUM_PORT ?? "4723";
10
+ const url = `http://${host}:${port}/status`;
11
+
12
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
13
+ if (!res.ok) {
14
+ console.error(`Appium HTTP ${res.status} at ${url}`);
15
+ process.exit(1);
16
+ }
17
+ const body = await res.json();
18
+ const ready = body?.value?.ready === true;
19
+ if (!ready) {
20
+ console.error("Appium responded but is not ready:", JSON.stringify(body, null, 2));
21
+ process.exit(1);
22
+ }
23
+ console.log(`Appium ready at ${url}`);
24
+ console.log(body.value?.message ?? "");
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
+ # shellcheck source=android-env.sh
6
+ . "$SCRIPT_DIR/android-env.sh"
7
+
8
+ PROJECT_ROOT=$(android_env_project_root)
9
+ android_env_ensure_emulator
10
+
11
+ exec "$PROJECT_ROOT/node_modules/.bin/wdio" run "$PROJECT_ROOT/configs/wdio.local.android.conf.ts" "$@"
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
+ # shellcheck source=android-env.sh
6
+ . "$SCRIPT_DIR/android-env.sh"
7
+
8
+ PROJECT_ROOT=$(android_env_project_root)
9
+ android_env_ensure_emulator
10
+
11
+ exec "$PROJECT_ROOT/node_modules/.bin/appium" "$@"