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
|
@@ -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" "$@"
|