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,47 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Starts Appium in the background, waits until /status is ready, runs MCP smoke, stops Appium.
|
|
3
|
+
set -eu
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
6
|
+
# shellcheck source=android-env.sh
|
|
7
|
+
. "$SCRIPT_DIR/android-env.sh"
|
|
8
|
+
|
|
9
|
+
PROJECT_ROOT=$(android_env_project_root)
|
|
10
|
+
cd "$PROJECT_ROOT"
|
|
11
|
+
android_env_prepare_paths
|
|
12
|
+
|
|
13
|
+
APPIUM_HOST="${APPIUM_HOST:-127.0.0.1}"
|
|
14
|
+
APPIUM_PORT="${APPIUM_PORT:-4723}"
|
|
15
|
+
STATUS_URL="http://${APPIUM_HOST}:${APPIUM_PORT}/status"
|
|
16
|
+
|
|
17
|
+
"$PROJECT_ROOT/node_modules/.bin/appium" --address "$APPIUM_HOST" --port "$APPIUM_PORT" &
|
|
18
|
+
APPIUM_PID=$!
|
|
19
|
+
|
|
20
|
+
cleanup() {
|
|
21
|
+
if kill -0 "$APPIUM_PID" 2>/dev/null; then
|
|
22
|
+
kill "$APPIUM_PID" 2>/dev/null || true
|
|
23
|
+
wait "$APPIUM_PID" 2>/dev/null || true
|
|
24
|
+
fi
|
|
25
|
+
}
|
|
26
|
+
trap cleanup EXIT INT TERM
|
|
27
|
+
|
|
28
|
+
i=0
|
|
29
|
+
while [ "$i" -lt 120 ]; do
|
|
30
|
+
# Appium can answer /status before the HTTP listener and drivers are fully ready;
|
|
31
|
+
# require JSON "ready":true so new sessions are accepted reliably.
|
|
32
|
+
if curl -sf "$STATUS_URL" | grep -q '"ready":true'; then
|
|
33
|
+
break
|
|
34
|
+
fi
|
|
35
|
+
i=$((i + 1))
|
|
36
|
+
sleep 1
|
|
37
|
+
done
|
|
38
|
+
|
|
39
|
+
if ! curl -sf "$STATUS_URL" | grep -q '"ready":true'; then
|
|
40
|
+
echo "Appium did not become ready at $STATUS_URL" >&2
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Brief settle after ready flag (driver init / listener wiring).
|
|
45
|
+
sleep 2
|
|
46
|
+
|
|
47
|
+
node "$PROJECT_ROOT/tests/mcp-android-login-smoke.mjs"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
buildEnv,
|
|
5
|
+
requireCloudCredentialsFrom,
|
|
6
|
+
} from "./buildEnv.ts";
|
|
7
|
+
|
|
8
|
+
const cwd = "/project/root";
|
|
9
|
+
|
|
10
|
+
describe("buildEnv", () => {
|
|
11
|
+
it("uses defaults when vars are empty", () => {
|
|
12
|
+
const e = buildEnv({}, cwd);
|
|
13
|
+
expect(e.appiumHost).toBe("127.0.0.1");
|
|
14
|
+
expect(e.appiumPort).toBe(4723);
|
|
15
|
+
expect(e.artifactsDir).toBe("./artifacts");
|
|
16
|
+
expect(e.mobileUsername).toBe("test@webdriver.io");
|
|
17
|
+
expect(e.mobilePassword).toBe("Test1234!");
|
|
18
|
+
expect(e.buildName).toBe("wdio-mobile");
|
|
19
|
+
expect(e.cloudHostname).toBe("mobile-hub.lambdatest.com");
|
|
20
|
+
expect(e.cloudPath).toBe("/wd/hub");
|
|
21
|
+
expect(e.android.deviceName).toBe("Android Emulator");
|
|
22
|
+
expect(e.android.udid).toBe("");
|
|
23
|
+
expect(e.android.appPath).toBe(
|
|
24
|
+
resolve(cwd, "apps/android.wdio.native.app.v2.0.0.apk"),
|
|
25
|
+
);
|
|
26
|
+
expect(e.android.cloudDevice).toBe("Galaxy S24");
|
|
27
|
+
expect(e.android.cloudPlatformVersion).toBe("14");
|
|
28
|
+
expect(e.android.cloudApp).toBe("");
|
|
29
|
+
expect(e.ios.deviceName).toBe("iPhone 15");
|
|
30
|
+
expect(e.ios.appPath).toBe(resolve(cwd, "apps/ios-demo.app"));
|
|
31
|
+
expect(e.ios.bundleId).toBe("com.example.demo");
|
|
32
|
+
expect(e.ios.cloudDevice).toBe("iPhone 15");
|
|
33
|
+
expect(e.ios.cloudPlatformVersion).toBe("17");
|
|
34
|
+
expect(e.ios.cloudApp).toBe("");
|
|
35
|
+
expect(e.cloudUsername).toBe("");
|
|
36
|
+
expect(e.cloudAccessKey).toBe("");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("reads explicit vars and resolves app paths", () => {
|
|
40
|
+
const e = buildEnv(
|
|
41
|
+
{
|
|
42
|
+
APPIUM_HOST: "10.0.0.2",
|
|
43
|
+
APPIUM_PORT: "9000",
|
|
44
|
+
ARTIFACTS_DIR: "./out",
|
|
45
|
+
MOBILE_USERNAME: "u@x.com",
|
|
46
|
+
MOBILE_PASSWORD: "secret",
|
|
47
|
+
BUILD_NAME: "ci-1",
|
|
48
|
+
CLOUD_USERNAME: "cu",
|
|
49
|
+
CLOUD_ACCESS_KEY: "ck",
|
|
50
|
+
CLOUD_HOSTNAME: "hub.example.com",
|
|
51
|
+
CLOUD_PATH: "/hub",
|
|
52
|
+
ANDROID_DEVICE_NAME: "Pixel",
|
|
53
|
+
ANDROID_UDID: "emulator-5554",
|
|
54
|
+
ANDROID_APP_PATH: "./custom/app.apk",
|
|
55
|
+
ANDROID_CLOUD_DEVICE: "Nexus",
|
|
56
|
+
ANDROID_CLOUD_PLATFORM_VERSION: "15",
|
|
57
|
+
ANDROID_CLOUD_APP: "lt://android-app",
|
|
58
|
+
IOS_DEVICE_NAME: "iPhone 16",
|
|
59
|
+
IOS_APP_PATH: "./build/My.app",
|
|
60
|
+
IOS_BUNDLE_ID: "com.app.id",
|
|
61
|
+
IOS_CLOUD_DEVICE: "iPhone 14",
|
|
62
|
+
IOS_CLOUD_PLATFORM_VERSION: "16",
|
|
63
|
+
IOS_CLOUD_APP: "lt://ios-app",
|
|
64
|
+
},
|
|
65
|
+
cwd,
|
|
66
|
+
);
|
|
67
|
+
expect(e.appiumHost).toBe("10.0.0.2");
|
|
68
|
+
expect(e.appiumPort).toBe(9000);
|
|
69
|
+
expect(e.artifactsDir).toBe("./out");
|
|
70
|
+
expect(e.mobileUsername).toBe("u@x.com");
|
|
71
|
+
expect(e.mobilePassword).toBe("secret");
|
|
72
|
+
expect(e.buildName).toBe("ci-1");
|
|
73
|
+
expect(e.cloudUsername).toBe("cu");
|
|
74
|
+
expect(e.cloudAccessKey).toBe("ck");
|
|
75
|
+
expect(e.cloudHostname).toBe("hub.example.com");
|
|
76
|
+
expect(e.cloudPath).toBe("/hub");
|
|
77
|
+
expect(e.android.deviceName).toBe("Pixel");
|
|
78
|
+
expect(e.android.udid).toBe("emulator-5554");
|
|
79
|
+
expect(e.android.appPath).toBe(resolve(cwd, "custom/app.apk"));
|
|
80
|
+
expect(e.android.cloudDevice).toBe("Nexus");
|
|
81
|
+
expect(e.android.cloudPlatformVersion).toBe("15");
|
|
82
|
+
expect(e.android.cloudApp).toBe("lt://android-app");
|
|
83
|
+
expect(e.ios.deviceName).toBe("iPhone 16");
|
|
84
|
+
expect(e.ios.appPath).toBe(resolve(cwd, "build/My.app"));
|
|
85
|
+
expect(e.ios.bundleId).toBe("com.app.id");
|
|
86
|
+
expect(e.ios.cloudDevice).toBe("iPhone 14");
|
|
87
|
+
expect(e.ios.cloudPlatformVersion).toBe("16");
|
|
88
|
+
expect(e.ios.cloudApp).toBe("lt://ios-app");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("falls back APPIUM_PORT when not a finite number", () => {
|
|
92
|
+
expect(buildEnv({ APPIUM_PORT: "nope" }, cwd).appiumPort).toBe(4723);
|
|
93
|
+
expect(buildEnv({ APPIUM_PORT: "Infinity" }, cwd).appiumPort).toBe(4723);
|
|
94
|
+
expect(buildEnv({ APPIUM_PORT: "12.5" }, cwd).appiumPort).toBe(12.5);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("uses default Android APK when ANDROID_APP_PATH is empty string", () => {
|
|
98
|
+
const e = buildEnv({ ANDROID_APP_PATH: "" }, cwd);
|
|
99
|
+
expect(e.android.appPath).toBe(
|
|
100
|
+
resolve(cwd, "apps/android.wdio.native.app.v2.0.0.apk"),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("requireCloudCredentialsFrom", () => {
|
|
106
|
+
it("returns user and key when set", () => {
|
|
107
|
+
expect(
|
|
108
|
+
requireCloudCredentialsFrom({
|
|
109
|
+
CLOUD_USERNAME: "user",
|
|
110
|
+
CLOUD_ACCESS_KEY: "key",
|
|
111
|
+
}),
|
|
112
|
+
).toEqual({ user: "user", key: "key" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("throws when CLOUD_USERNAME is missing", () => {
|
|
116
|
+
expect(() =>
|
|
117
|
+
requireCloudCredentialsFrom({ CLOUD_ACCESS_KEY: "k" }),
|
|
118
|
+
).toThrowError("Missing required environment variable: CLOUD_USERNAME");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws when CLOUD_ACCESS_KEY is missing", () => {
|
|
122
|
+
expect(() =>
|
|
123
|
+
requireCloudCredentialsFrom({ CLOUD_USERNAME: "u" }),
|
|
124
|
+
).toThrowError("Missing required environment variable: CLOUD_ACCESS_KEY");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
const optional = (
|
|
4
|
+
vars: NodeJS.ProcessEnv,
|
|
5
|
+
name: string,
|
|
6
|
+
fallback = "",
|
|
7
|
+
): string => vars[name] ?? fallback;
|
|
8
|
+
|
|
9
|
+
const required = (vars: NodeJS.ProcessEnv, name: string): string => {
|
|
10
|
+
const value = vars[name];
|
|
11
|
+
if (!value) throw new Error(`Missing required environment variable: ${name}`);
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const toNumber = (value: string, fallback: number): number => {
|
|
16
|
+
const parsed = Number(value);
|
|
17
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MobileEnv = ReturnType<typeof buildEnv>;
|
|
21
|
+
|
|
22
|
+
/** Build env from a process-like map (testable; no dotenv). */
|
|
23
|
+
export function buildEnv(
|
|
24
|
+
vars: NodeJS.ProcessEnv = process.env,
|
|
25
|
+
cwd: string = process.cwd(),
|
|
26
|
+
) {
|
|
27
|
+
return {
|
|
28
|
+
appiumHost: optional(vars, "APPIUM_HOST", "127.0.0.1"),
|
|
29
|
+
appiumPort: toNumber(optional(vars, "APPIUM_PORT", "4723"), 4723),
|
|
30
|
+
artifactsDir: optional(vars, "ARTIFACTS_DIR", "./artifacts"),
|
|
31
|
+
mobileUsername: optional(vars, "MOBILE_USERNAME", "test@webdriver.io"),
|
|
32
|
+
mobilePassword: optional(vars, "MOBILE_PASSWORD", "Test1234!"),
|
|
33
|
+
|
|
34
|
+
buildName: optional(vars, "BUILD_NAME", "wdio-mobile"),
|
|
35
|
+
cloudUsername: optional(vars, "CLOUD_USERNAME"),
|
|
36
|
+
cloudAccessKey: optional(vars, "CLOUD_ACCESS_KEY"),
|
|
37
|
+
cloudHostname: optional(vars, "CLOUD_HOSTNAME", "mobile-hub.lambdatest.com"),
|
|
38
|
+
cloudPath: optional(vars, "CLOUD_PATH", "/wd/hub"),
|
|
39
|
+
|
|
40
|
+
android: {
|
|
41
|
+
deviceName: optional(vars, "ANDROID_DEVICE_NAME", "Android Emulator"),
|
|
42
|
+
udid: optional(vars, "ANDROID_UDID"),
|
|
43
|
+
appPath: (() => {
|
|
44
|
+
const p = optional(vars, "ANDROID_APP_PATH");
|
|
45
|
+
return p
|
|
46
|
+
? resolve(cwd, p)
|
|
47
|
+
: resolve(cwd, "apps/android.wdio.native.app.v2.0.0.apk");
|
|
48
|
+
})(),
|
|
49
|
+
cloudDevice: optional(vars, "ANDROID_CLOUD_DEVICE", "Galaxy S24"),
|
|
50
|
+
cloudPlatformVersion: optional(
|
|
51
|
+
vars,
|
|
52
|
+
"ANDROID_CLOUD_PLATFORM_VERSION",
|
|
53
|
+
"14",
|
|
54
|
+
),
|
|
55
|
+
cloudApp: optional(vars, "ANDROID_CLOUD_APP"),
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
ios: {
|
|
59
|
+
deviceName: optional(vars, "IOS_DEVICE_NAME", "iPhone 15"),
|
|
60
|
+
appPath: (() => {
|
|
61
|
+
const p = optional(vars, "IOS_APP_PATH", "./apps/ios-demo.app");
|
|
62
|
+
return resolve(cwd, p);
|
|
63
|
+
})(),
|
|
64
|
+
bundleId: optional(vars, "IOS_BUNDLE_ID", "com.example.demo"),
|
|
65
|
+
cloudDevice: optional(vars, "IOS_CLOUD_DEVICE", "iPhone 15"),
|
|
66
|
+
cloudPlatformVersion: optional(
|
|
67
|
+
vars,
|
|
68
|
+
"IOS_CLOUD_PLATFORM_VERSION",
|
|
69
|
+
"17",
|
|
70
|
+
),
|
|
71
|
+
cloudApp: optional(vars, "IOS_CLOUD_APP"),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function requireCloudCredentialsFrom(vars: NodeJS.ProcessEnv) {
|
|
77
|
+
return {
|
|
78
|
+
user: required(vars, "CLOUD_USERNAME"),
|
|
79
|
+
key: required(vars, "CLOUD_ACCESS_KEY"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import {
|
|
3
|
+
buildEnv,
|
|
4
|
+
requireCloudCredentialsFrom,
|
|
5
|
+
} from "./env/buildEnv.ts";
|
|
6
|
+
|
|
7
|
+
/** Central env for WDIO configs and specs. No hidden defaults beyond these. */
|
|
8
|
+
export const env = buildEnv(process.env, process.cwd());
|
|
9
|
+
|
|
10
|
+
export const requireCloudCredentials = () =>
|
|
11
|
+
requireCloudCredentialsFrom(process.env);
|
|
12
|
+
|
|
13
|
+
export type { MobileEnv } from "./env/buildEnv.ts";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { safeFilePart } from "./safeFilePart.ts";
|
|
3
|
+
|
|
4
|
+
describe("safeFilePart", () => {
|
|
5
|
+
it("keeps alphanumerics underscore and hyphen", () => {
|
|
6
|
+
expect(safeFilePart("login-flow_ok")).toBe("login-flow_ok");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("replaces other characters with single hyphen runs", () => {
|
|
10
|
+
expect(safeFilePart("a b::c")).toBe("a-b-c");
|
|
11
|
+
expect(safeFilePart("foo---bar")).toBe("foo-bar");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("handles empty string", () => {
|
|
15
|
+
expect(safeFilePart("")).toBe("");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { loginLocators } from "./login.locators.ts";
|
|
3
|
+
import { nativeAlertLocators } from "./nativeAlert.locators.ts";
|
|
4
|
+
import { tabBarLocators } from "./tabBar.locators.ts";
|
|
5
|
+
|
|
6
|
+
describe("loginLocators", () => {
|
|
7
|
+
it("has matching keys for android and ios", () => {
|
|
8
|
+
expect(Object.keys(loginLocators.android).sort()).toEqual(
|
|
9
|
+
Object.keys(loginLocators.ios).sort(),
|
|
10
|
+
);
|
|
11
|
+
expect(loginLocators.android.screenTitle).toContain("Login");
|
|
12
|
+
expect(loginLocators.android.signInButton).toBeTruthy();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("tabBarLocators", () => {
|
|
17
|
+
it("has home and login tabs for both platforms", () => {
|
|
18
|
+
expect(tabBarLocators.android.homeTab).toBe("~Home");
|
|
19
|
+
expect(tabBarLocators.android.loginTab).toBe("~Login");
|
|
20
|
+
expect(tabBarLocators.ios).toEqual(tabBarLocators.android);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("nativeAlertLocators", () => {
|
|
25
|
+
it("defines android title, message, and OK", () => {
|
|
26
|
+
expect(nativeAlertLocators.android.alertTitle).toContain("alert_title");
|
|
27
|
+
expect(nativeAlertLocators.android.alertMessage).toContain("message");
|
|
28
|
+
expect(nativeAlertLocators.android.okButton).toContain("OK");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("defines ios alert predicate and OK accessibility id", () => {
|
|
32
|
+
expect(nativeAlertLocators.ios.alert).toContain("XCUIElementTypeAlert");
|
|
33
|
+
expect(nativeAlertLocators.ios.okButton).toBe("~OK");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type PlatformLocators = Record<"android" | "ios", Record<string, string>>;
|
|
2
|
+
|
|
3
|
+
export const loginLocators: PlatformLocators = {
|
|
4
|
+
android: {
|
|
5
|
+
screenTitle: "~Login-screen",
|
|
6
|
+
loginContainerButton: "~button-login-container",
|
|
7
|
+
usernameField: "~input-email",
|
|
8
|
+
passwordField: "~input-password",
|
|
9
|
+
signInButton: "~button-LOGIN",
|
|
10
|
+
},
|
|
11
|
+
ios: {
|
|
12
|
+
screenTitle: "~Login-screen",
|
|
13
|
+
loginContainerButton: "~button-login-container",
|
|
14
|
+
usernameField: "~input-email",
|
|
15
|
+
passwordField: "~input-password",
|
|
16
|
+
signInButton: "~button-LOGIN",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type PlatformLocators = Record<"android" | "ios", Record<string, string>>;
|
|
2
|
+
|
|
3
|
+
export const nativeAlertLocators: PlatformLocators = {
|
|
4
|
+
android: {
|
|
5
|
+
alertTitle: '*//android.widget.TextView[@resource-id="com.wdiodemoapp:id/alert_title"]',
|
|
6
|
+
alertMessage: '*//android.widget.TextView[@resource-id="android:id/message"]',
|
|
7
|
+
okButton: '*//android.widget.Button[@text="OK"]',
|
|
8
|
+
},
|
|
9
|
+
ios: {
|
|
10
|
+
alert: "-ios predicate string:type == 'XCUIElementTypeAlert'",
|
|
11
|
+
okButton: "~OK",
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { loginLocators } from "../locators/login.locators.ts";
|
|
3
|
+
import { setupWdioTestContext } from "../test-utils/wdioTestGlobals.ts";
|
|
4
|
+
import { LoginPage } from "./Login.page.ts";
|
|
5
|
+
|
|
6
|
+
const L = loginLocators.android;
|
|
7
|
+
|
|
8
|
+
describe("LoginPage", () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllGlobals();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("waitForDisplay waits on screen title", async () => {
|
|
14
|
+
setupWdioTestContext({ isAndroid: true, isIOS: false });
|
|
15
|
+
const page = new LoginPage();
|
|
16
|
+
await page.waitForDisplay();
|
|
17
|
+
const title = globalThis.$(L.screenTitle);
|
|
18
|
+
expect(title.waitForDisplayed).toHaveBeenCalledWith({ timeout: 20_000 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("openLoginForm waits then taps login container", async () => {
|
|
22
|
+
setupWdioTestContext({ isAndroid: true, isIOS: false });
|
|
23
|
+
const page = new LoginPage();
|
|
24
|
+
await page.openLoginForm();
|
|
25
|
+
expect(globalThis.$(L.loginContainerButton).click).toHaveBeenCalled();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("login on Android sets credentials, dismisses keyboard via title tap, scrolls and taps sign in", async () => {
|
|
29
|
+
const { chainFor } = setupWdioTestContext({
|
|
30
|
+
isAndroid: true,
|
|
31
|
+
isIOS: false,
|
|
32
|
+
});
|
|
33
|
+
const page = new LoginPage();
|
|
34
|
+
await page.login("user@x.com", "pw");
|
|
35
|
+
|
|
36
|
+
expect(chainFor(L.usernameField).setValue).toHaveBeenCalledWith(
|
|
37
|
+
"user@x.com",
|
|
38
|
+
);
|
|
39
|
+
expect(chainFor(L.passwordField).setValue).toHaveBeenCalledWith("pw");
|
|
40
|
+
expect(chainFor(L.screenTitle).click).toHaveBeenCalled();
|
|
41
|
+
expect(chainFor(L.signInButton).scrollIntoView).toHaveBeenCalledWith({
|
|
42
|
+
scrollableElement: chainFor(L.screenTitle),
|
|
43
|
+
});
|
|
44
|
+
expect(chainFor(L.signInButton).click).toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("login on iOS without keyboard skips title tap for keyboard dismissal", async () => {
|
|
48
|
+
const { chainFor } = setupWdioTestContext({
|
|
49
|
+
isAndroid: false,
|
|
50
|
+
isIOS: true,
|
|
51
|
+
keyboardShown: false,
|
|
52
|
+
});
|
|
53
|
+
const page = new LoginPage();
|
|
54
|
+
await page.login("a@b.co", "x");
|
|
55
|
+
|
|
56
|
+
expect(chainFor(L.usernameField).setValue).toHaveBeenCalled();
|
|
57
|
+
expect(chainFor(L.screenTitle).click).not.toHaveBeenCalled();
|
|
58
|
+
expect(chainFor(L.signInButton).click).toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("login on iOS with keyboard taps title to dismiss", async () => {
|
|
62
|
+
const { chainFor } = setupWdioTestContext({
|
|
63
|
+
isAndroid: false,
|
|
64
|
+
isIOS: true,
|
|
65
|
+
keyboardShown: true,
|
|
66
|
+
});
|
|
67
|
+
const page = new LoginPage();
|
|
68
|
+
await page.login("a@b.co", "x");
|
|
69
|
+
|
|
70
|
+
expect(globalThis.driver.isKeyboardShown).toHaveBeenCalled();
|
|
71
|
+
expect(chainFor(L.screenTitle).click).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { loginLocators } from "../locators/login.locators.ts";
|
|
2
|
+
|
|
3
|
+
const platform = (): "android" | "ios" => (driver.isIOS ? "ios" : "android");
|
|
4
|
+
|
|
5
|
+
const sel = (name: keyof (typeof loginLocators)["android"]): string =>
|
|
6
|
+
loginLocators[platform()][name];
|
|
7
|
+
|
|
8
|
+
export class LoginPage {
|
|
9
|
+
async waitForDisplay(): Promise<void> {
|
|
10
|
+
await $(sel("screenTitle")).waitForDisplayed({ timeout: 20_000 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async openLoginForm(): Promise<void> {
|
|
14
|
+
await this.waitForDisplay();
|
|
15
|
+
await $(sel("loginContainerButton")).click();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async login(username: string, password: string): Promise<void> {
|
|
19
|
+
await this.waitForDisplay();
|
|
20
|
+
await $(sel("usernameField")).setValue(username);
|
|
21
|
+
await $(sel("passwordField")).setValue(password);
|
|
22
|
+
|
|
23
|
+
// Android: avoid isKeyboardShown() — it shells out to `dumpsys input_method` (huge, slow, fails if adb/device flakes).
|
|
24
|
+
if (driver.isAndroid) {
|
|
25
|
+
await $(sel("screenTitle")).click();
|
|
26
|
+
} else if (await driver.isKeyboardShown()) {
|
|
27
|
+
await $(sel("screenTitle")).click();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await $(sel("signInButton")).scrollIntoView({
|
|
31
|
+
scrollableElement: await $(sel("screenTitle")),
|
|
32
|
+
});
|
|
33
|
+
await $(sel("signInButton")).click();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const loginPage = new LoginPage();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { nativeAlertLocators } from "../locators/nativeAlert.locators.ts";
|
|
3
|
+
import {
|
|
4
|
+
setupWdioTestContext,
|
|
5
|
+
stubExpectWebdriverStyle,
|
|
6
|
+
} from "../test-utils/wdioTestGlobals.ts";
|
|
7
|
+
import { NativeAlertPage } from "./NativeAlert.page.ts";
|
|
8
|
+
|
|
9
|
+
describe("NativeAlertPage", () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("waitForDisplay on iOS waits for alert element", async () => {
|
|
15
|
+
setupWdioTestContext({ isAndroid: false, isIOS: true });
|
|
16
|
+
const page = new NativeAlertPage();
|
|
17
|
+
await page.waitForDisplay();
|
|
18
|
+
const alertSel = nativeAlertLocators.ios.alert;
|
|
19
|
+
expect(globalThis.$(alertSel).waitForExist).toHaveBeenCalledWith({
|
|
20
|
+
timeout: 11_000,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("waitForDisplay on Android waits for alert title", async () => {
|
|
25
|
+
setupWdioTestContext({ isAndroid: true, isIOS: false });
|
|
26
|
+
const page = new NativeAlertPage();
|
|
27
|
+
await page.waitForDisplay();
|
|
28
|
+
const titleSel = nativeAlertLocators.android.alertTitle;
|
|
29
|
+
expect(globalThis.$(titleSel).waitForExist).toHaveBeenCalledWith({
|
|
30
|
+
timeout: 11_000,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("expectSuccessMessage", () => {
|
|
35
|
+
let toHaveText: ReturnType<typeof stubExpectWebdriverStyle>["toHaveText"];
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
({ toHaveText } = stubExpectWebdriverStyle());
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("on iOS uses expect().toHaveText(stringContaining)", async () => {
|
|
42
|
+
setupWdioTestContext({ isAndroid: false, isIOS: true });
|
|
43
|
+
const page = new NativeAlertPage();
|
|
44
|
+
await page.expectSuccessMessage();
|
|
45
|
+
expect(toHaveText).toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("on Android asserts title and message contain Success", async () => {
|
|
49
|
+
setupWdioTestContext({
|
|
50
|
+
isAndroid: true,
|
|
51
|
+
isIOS: false,
|
|
52
|
+
});
|
|
53
|
+
const titleSel = nativeAlertLocators.android.alertTitle;
|
|
54
|
+
const msgSel = nativeAlertLocators.android.alertMessage;
|
|
55
|
+
globalThis.$(titleSel);
|
|
56
|
+
globalThis.$(msgSel);
|
|
57
|
+
vi.mocked(globalThis.$(titleSel).getText).mockResolvedValue("Done");
|
|
58
|
+
vi.mocked(globalThis.$(msgSel).getText).mockResolvedValue(
|
|
59
|
+
"Success — logged in",
|
|
60
|
+
);
|
|
61
|
+
const page = new NativeAlertPage();
|
|
62
|
+
await expect(page.expectSuccessMessage()).resolves.toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("on Android throws when Success is absent", async () => {
|
|
66
|
+
setupWdioTestContext({
|
|
67
|
+
isAndroid: true,
|
|
68
|
+
isIOS: false,
|
|
69
|
+
});
|
|
70
|
+
const titleSel = nativeAlertLocators.android.alertTitle;
|
|
71
|
+
const msgSel = nativeAlertLocators.android.alertMessage;
|
|
72
|
+
globalThis.$(titleSel);
|
|
73
|
+
globalThis.$(msgSel);
|
|
74
|
+
vi.mocked(globalThis.$(titleSel).getText).mockResolvedValue("Nope");
|
|
75
|
+
vi.mocked(globalThis.$(msgSel).getText).mockResolvedValue("Try again");
|
|
76
|
+
const page = new NativeAlertPage();
|
|
77
|
+
await expect(page.expectSuccessMessage()).rejects.toThrow(
|
|
78
|
+
/Expected string to contain/,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("confirm clicks OK for current platform", async () => {
|
|
84
|
+
setupWdioTestContext({ isAndroid: true, isIOS: false });
|
|
85
|
+
const page = new NativeAlertPage();
|
|
86
|
+
await page.confirm();
|
|
87
|
+
expect(
|
|
88
|
+
globalThis.$(nativeAlertLocators.android.okButton).click,
|
|
89
|
+
).toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { nativeAlertLocators } from "../locators/nativeAlert.locators.ts";
|
|
2
|
+
|
|
3
|
+
const platform = (): "android" | "ios" => (driver.isIOS ? "ios" : "android");
|
|
4
|
+
|
|
5
|
+
export class NativeAlertPage {
|
|
6
|
+
async waitForDisplay(): Promise<void> {
|
|
7
|
+
if (driver.isIOS) {
|
|
8
|
+
await $(nativeAlertLocators[platform()].alert).waitForExist({ timeout: 11_000 });
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
await $(nativeAlertLocators[platform()].alertTitle).waitForExist({
|
|
13
|
+
timeout: 11_000,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async expectSuccessMessage(): Promise<void> {
|
|
18
|
+
if (driver.isIOS) {
|
|
19
|
+
await expect($(nativeAlertLocators[platform()].alert)).toHaveText(
|
|
20
|
+
expect.stringContaining("Success"),
|
|
21
|
+
);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const title = await $(nativeAlertLocators[platform()].alertTitle).getText();
|
|
26
|
+
const message = await $(nativeAlertLocators[platform()].alertMessage).getText();
|
|
27
|
+
expect(`${title}\n${message}`).toContain("Success");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async confirm(): Promise<void> {
|
|
31
|
+
await $(nativeAlertLocators[platform()].okButton).click();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const nativeAlertPage = new NativeAlertPage();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { tabBarLocators } from "../locators/tabBar.locators.ts";
|
|
3
|
+
import { setupWdioTestContext } from "../test-utils/wdioTestGlobals.ts";
|
|
4
|
+
import { TabBarPage } from "./TabBar.page.ts";
|
|
5
|
+
|
|
6
|
+
const T = tabBarLocators.android;
|
|
7
|
+
|
|
8
|
+
describe("TabBarPage", () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllGlobals();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("waitForDisplay waits on home tab", async () => {
|
|
14
|
+
setupWdioTestContext({ isAndroid: true, isIOS: false });
|
|
15
|
+
const page = new TabBarPage();
|
|
16
|
+
await page.waitForDisplay();
|
|
17
|
+
expect(globalThis.$(T.homeTab).waitForDisplayed).toHaveBeenCalledWith({
|
|
18
|
+
timeout: 20_000,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("openLogin taps login tab after home is visible", async () => {
|
|
23
|
+
setupWdioTestContext({ isAndroid: true, isIOS: false });
|
|
24
|
+
const page = new TabBarPage();
|
|
25
|
+
await page.openLogin();
|
|
26
|
+
expect(globalThis.$(T.loginTab).click).toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("uses iOS locators when driver.isIOS is true", async () => {
|
|
30
|
+
setupWdioTestContext({ isAndroid: false, isIOS: true });
|
|
31
|
+
const page = new TabBarPage();
|
|
32
|
+
await page.openLogin();
|
|
33
|
+
expect(globalThis.$(T.loginTab).click).toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { tabBarLocators } from "../locators/tabBar.locators.ts";
|
|
2
|
+
|
|
3
|
+
const platform = (): "android" | "ios" => (driver.isIOS ? "ios" : "android");
|
|
4
|
+
|
|
5
|
+
const sel = (name: keyof (typeof tabBarLocators)["android"]): string =>
|
|
6
|
+
tabBarLocators[platform()][name];
|
|
7
|
+
|
|
8
|
+
export class TabBarPage {
|
|
9
|
+
async waitForDisplay(): Promise<void> {
|
|
10
|
+
await $(sel("homeTab")).waitForDisplayed({ timeout: 20_000 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async openLogin(): Promise<void> {
|
|
14
|
+
await this.waitForDisplay();
|
|
15
|
+
await $(sel("loginTab")).click();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const tabBarPage = new TabBarPage();
|