Add E2E testing infrastructure with WebdriverIO + Appium
Set up E2E test framework for Android using WebdriverIO, Appium, and UiAutomator2. Add testID props to key components (AuthButton, BaseButton, ChatBubble, CustomTextInput, ProposedEventCard) and apply testIDs to login screen, chat screen, tab bar, and settings. Include initial tests for app launch detection and login flow validation. Update CLAUDE.md with E2E docs.
This commit is contained in:
49
apps/client/e2e/config/capabilities.ts
Normal file
49
apps/client/e2e/config/capabilities.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export type AppiumCapabilities = Record<string, unknown>;
|
||||
|
||||
const COMMON_CAPABILITIES: AppiumCapabilities = {
|
||||
platformName: "Android",
|
||||
"appium:automationName": "UiAutomator2",
|
||||
"appium:deviceName": process.env.DEVICE_NAME || "emulator-5554",
|
||||
"appium:autoGrantPermissions": true,
|
||||
"appium:newCommandTimeout": 300,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dev Client mode: app is already installed via `expo start --android`.
|
||||
* Appium connects to the running app without reinstalling.
|
||||
*/
|
||||
const DEV_CLIENT_CAPABILITIES: AppiumCapabilities = {
|
||||
...COMMON_CAPABILITIES,
|
||||
"appium:appPackage": "host.exp.exponent",
|
||||
"appium:appActivity": ".experience.ExperienceActivity",
|
||||
"appium:noReset": true,
|
||||
};
|
||||
|
||||
/**
|
||||
* APK mode: Appium installs the APK directly.
|
||||
* Used in CI where the APK is built via `eas build --profile e2e`.
|
||||
*/
|
||||
function getApkCapabilities(apkPath: string): AppiumCapabilities {
|
||||
return {
|
||||
...COMMON_CAPABILITIES,
|
||||
"appium:app": apkPath,
|
||||
"appium:noReset": false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate capabilities based on the APK_PATH env variable.
|
||||
* - APK_PATH set → APK mode (CI)
|
||||
* - APK_PATH not set → Dev Client mode (local development)
|
||||
*/
|
||||
export function getCapabilities(): AppiumCapabilities {
|
||||
const apkPath = process.env.APK_PATH;
|
||||
|
||||
if (apkPath) {
|
||||
console.log(`[Appium] APK mode: ${apkPath}`);
|
||||
return getApkCapabilities(apkPath);
|
||||
}
|
||||
|
||||
console.log("[Appium] Dev Client mode");
|
||||
return DEV_CLIENT_CAPABILITIES;
|
||||
}
|
||||
41
apps/client/e2e/helpers/driver.ts
Normal file
41
apps/client/e2e/helpers/driver.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { remote, type Browser } from "webdriverio";
|
||||
import { getCapabilities } from "../config/capabilities";
|
||||
|
||||
let driver: Browser | null = null;
|
||||
|
||||
const APPIUM_HOST = process.env.APPIUM_HOST || "localhost";
|
||||
const APPIUM_PORT = parseInt(process.env.APPIUM_PORT || "4723", 10);
|
||||
|
||||
/**
|
||||
* Initialize the Appium driver. If already initialized, returns the existing instance.
|
||||
* With --runInBand, all test files share this singleton.
|
||||
*/
|
||||
export async function initDriver(): Promise<Browser> {
|
||||
if (driver) return driver;
|
||||
|
||||
const capabilities = getCapabilities();
|
||||
|
||||
driver = await remote({
|
||||
hostname: APPIUM_HOST,
|
||||
port: APPIUM_PORT,
|
||||
path: "/",
|
||||
capabilities,
|
||||
logLevel: "warn",
|
||||
});
|
||||
|
||||
return driver;
|
||||
}
|
||||
|
||||
export function getDriver(): Browser {
|
||||
if (!driver) {
|
||||
throw new Error("Driver not initialized. Call initDriver() first.");
|
||||
}
|
||||
return driver;
|
||||
}
|
||||
|
||||
export async function quitDriver(): Promise<void> {
|
||||
if (driver) {
|
||||
await driver.deleteSession();
|
||||
driver = null;
|
||||
}
|
||||
}
|
||||
30
apps/client/e2e/helpers/selectors.ts
Normal file
30
apps/client/e2e/helpers/selectors.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* All testID strings used in the CalChat app.
|
||||
* Appium finds them via accessibility id selector: `~testId`
|
||||
*/
|
||||
export const TestIDs = {
|
||||
// Login screen
|
||||
LOGIN_TITLE: "login-title",
|
||||
LOGIN_ERROR_TEXT: "login-error-text",
|
||||
LOGIN_IDENTIFIER_INPUT: "login-identifier-input",
|
||||
LOGIN_PASSWORD_INPUT: "login-password-input",
|
||||
LOGIN_BUTTON: "login-button",
|
||||
|
||||
// Tab navigation
|
||||
TAB_CHAT: "tab-chat",
|
||||
TAB_CALENDAR: "tab-calendar",
|
||||
TAB_SETTINGS: "tab-settings",
|
||||
|
||||
// Chat screen
|
||||
CHAT_MESSAGE_INPUT: "chat-message-input",
|
||||
CHAT_SEND_BUTTON: "chat-send-button",
|
||||
CHAT_BUBBLE_LEFT: "chat-bubble-left",
|
||||
CHAT_BUBBLE_RIGHT: "chat-bubble-right",
|
||||
|
||||
// Settings screen
|
||||
SETTINGS_LOGOUT_BUTTON: "settings-logout-button",
|
||||
|
||||
// Event proposal
|
||||
EVENT_ACCEPT_BUTTON: "event-accept-button",
|
||||
EVENT_REJECT_BUTTON: "event-reject-button",
|
||||
} as const;
|
||||
105
apps/client/e2e/helpers/utils.ts
Normal file
105
apps/client/e2e/helpers/utils.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Browser } from "webdriverio";
|
||||
import { TestIDs } from "./selectors";
|
||||
|
||||
const DEFAULT_TIMEOUT = 15_000;
|
||||
|
||||
/**
|
||||
* Build a UiAutomator selector for a resource-id.
|
||||
* React Native on Android maps testID to resource-id (not content-desc).
|
||||
*/
|
||||
function byTestId(testId: string): string {
|
||||
return `android=new UiSelector().resourceId("${testId}")`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an element with the given testID to exist and be displayed.
|
||||
*/
|
||||
export async function waitForTestId(
|
||||
driver: Browser,
|
||||
testId: string,
|
||||
timeout = DEFAULT_TIMEOUT,
|
||||
) {
|
||||
const element = driver.$(byTestId(testId));
|
||||
await element.waitForDisplayed({ timeout });
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element with the given testID is currently visible.
|
||||
*/
|
||||
export async function isTestIdVisible(
|
||||
driver: Browser,
|
||||
testId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const element = driver.$(byTestId(testId));
|
||||
return await element.isDisplayed();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the login screen. If already logged in (auto-login),
|
||||
* go to Settings and tap Logout first.
|
||||
*/
|
||||
export async function ensureOnLoginScreen(driver: Browser): Promise<void> {
|
||||
const onLogin = await isTestIdVisible(driver, TestIDs.LOGIN_TITLE);
|
||||
if (onLogin) return;
|
||||
|
||||
// We're on the main app — navigate to Settings and logout
|
||||
const settingsTab = driver.$(byTestId(TestIDs.TAB_SETTINGS));
|
||||
await settingsTab.waitForDisplayed({ timeout: DEFAULT_TIMEOUT });
|
||||
await settingsTab.click();
|
||||
|
||||
const logoutButton = await waitForTestId(
|
||||
driver,
|
||||
TestIDs.SETTINGS_LOGOUT_BUTTON,
|
||||
);
|
||||
await logoutButton.click();
|
||||
|
||||
// Wait for login screen to appear
|
||||
await waitForTestId(driver, TestIDs.LOGIN_TITLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform login with the given credentials.
|
||||
*/
|
||||
export async function performLogin(
|
||||
driver: Browser,
|
||||
identifier: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const identifierInput = await waitForTestId(
|
||||
driver,
|
||||
TestIDs.LOGIN_IDENTIFIER_INPUT,
|
||||
);
|
||||
await identifierInput.clearValue();
|
||||
await identifierInput.setValue(identifier);
|
||||
|
||||
const passwordInput = await waitForTestId(
|
||||
driver,
|
||||
TestIDs.LOGIN_PASSWORD_INPUT,
|
||||
);
|
||||
await passwordInput.clearValue();
|
||||
await passwordInput.setValue(password);
|
||||
|
||||
const loginButton = await waitForTestId(driver, TestIDs.LOGIN_BUTTON);
|
||||
await loginButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the user is logged in. If on the login screen, perform login
|
||||
* with test credentials.
|
||||
*/
|
||||
export async function ensureLoggedIn(driver: Browser): Promise<void> {
|
||||
const testUser = process.env.TEST_USER || "test";
|
||||
const testPassword = process.env.TEST_PASSWORD || "test";
|
||||
|
||||
const onLogin = await isTestIdVisible(driver, TestIDs.LOGIN_TITLE);
|
||||
if (onLogin) {
|
||||
await performLogin(driver, testUser, testPassword);
|
||||
// Wait for chat screen to appear after login
|
||||
await waitForTestId(driver, TestIDs.CHAT_MESSAGE_INPUT, 30_000);
|
||||
}
|
||||
}
|
||||
14
apps/client/e2e/jest.config.ts
Normal file
14
apps/client/e2e/jest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Config } from "jest";
|
||||
|
||||
const config: Config = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
testMatch: ["<rootDir>/tests/**/*.test.ts"],
|
||||
testTimeout: 120_000,
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "json"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
49
apps/client/e2e/tests/01-app-launch.test.ts
Normal file
49
apps/client/e2e/tests/01-app-launch.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { initDriver, getDriver } from "../helpers/driver";
|
||||
import { TestIDs } from "../helpers/selectors";
|
||||
import { waitForTestId } from "../helpers/utils";
|
||||
|
||||
describe("App Launch", () => {
|
||||
beforeAll(async () => {
|
||||
await initDriver();
|
||||
const driver = getDriver();
|
||||
|
||||
// Dismiss Expo Go banner by tapping near the top of the screen
|
||||
await driver.pause(3000);
|
||||
const { width } = await driver.getWindowSize();
|
||||
await driver.touchAction({ action: "tap", x: Math.round(width / 2), y: 100 });
|
||||
await driver.pause(500);
|
||||
});
|
||||
|
||||
it("should launch the app and show login or chat screen", async () => {
|
||||
const driver = getDriver();
|
||||
|
||||
// Wait for either the login screen or the chat screen to appear
|
||||
// Try login first with a long timeout (app needs to fully load)
|
||||
try {
|
||||
await waitForTestId(driver, TestIDs.LOGIN_TITLE, 30_000);
|
||||
// On login screen — verify elements
|
||||
const identifierInput = await waitForTestId(
|
||||
driver,
|
||||
TestIDs.LOGIN_IDENTIFIER_INPUT,
|
||||
);
|
||||
expect(await identifierInput.isDisplayed()).toBe(true);
|
||||
|
||||
const passwordInput = await waitForTestId(
|
||||
driver,
|
||||
TestIDs.LOGIN_PASSWORD_INPUT,
|
||||
);
|
||||
expect(await passwordInput.isDisplayed()).toBe(true);
|
||||
|
||||
const loginButton = await waitForTestId(driver, TestIDs.LOGIN_BUTTON);
|
||||
expect(await loginButton.isDisplayed()).toBe(true);
|
||||
} catch {
|
||||
// Not on login — should be on chat screen (auto-login)
|
||||
const chatInput = await waitForTestId(
|
||||
driver,
|
||||
TestIDs.CHAT_MESSAGE_INPUT,
|
||||
30_000,
|
||||
);
|
||||
expect(await chatInput.isDisplayed()).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
73
apps/client/e2e/tests/02-login.test.ts
Normal file
73
apps/client/e2e/tests/02-login.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { initDriver, getDriver, quitDriver } from "../helpers/driver";
|
||||
import { TestIDs } from "../helpers/selectors";
|
||||
import {
|
||||
waitForTestId,
|
||||
ensureOnLoginScreen,
|
||||
performLogin,
|
||||
} from "../helpers/utils";
|
||||
|
||||
describe("Login", () => {
|
||||
beforeAll(async () => {
|
||||
await initDriver();
|
||||
const driver = getDriver();
|
||||
|
||||
// Dismiss Expo Go banner by tapping near the top of the screen
|
||||
await driver.pause(3000);
|
||||
const { width } = await driver.getWindowSize();
|
||||
await driver.touchAction({ action: "tap", x: Math.round(width / 2), y: 100 });
|
||||
await driver.pause(500);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const driver = getDriver();
|
||||
await ensureOnLoginScreen(driver);
|
||||
});
|
||||
|
||||
it("should show error when fields are empty", async () => {
|
||||
const driver = getDriver();
|
||||
|
||||
// Tap login without entering credentials
|
||||
const loginButton = await waitForTestId(driver, TestIDs.LOGIN_BUTTON);
|
||||
await loginButton.click();
|
||||
|
||||
// Error message should appear
|
||||
const errorText = await waitForTestId(driver, TestIDs.LOGIN_ERROR_TEXT);
|
||||
const text = await errorText.getText();
|
||||
expect(text).toContain("Bitte alle Felder");
|
||||
});
|
||||
|
||||
it("should show error for invalid credentials", async () => {
|
||||
const driver = getDriver();
|
||||
|
||||
await performLogin(driver, "invalid_user", "wrong_password");
|
||||
|
||||
// Wait for error message
|
||||
const errorText = await waitForTestId(
|
||||
driver,
|
||||
TestIDs.LOGIN_ERROR_TEXT,
|
||||
30_000,
|
||||
);
|
||||
const text = await errorText.getText();
|
||||
expect(text).toContain("Anmeldung fehlgeschlagen");
|
||||
});
|
||||
|
||||
it("should login successfully with valid credentials", async () => {
|
||||
const driver = getDriver();
|
||||
const testUser = process.env.TEST_USER || "test";
|
||||
const testPassword = process.env.TEST_PASSWORD || "test";
|
||||
|
||||
await performLogin(driver, testUser, testPassword);
|
||||
|
||||
// Chat screen should appear after successful login
|
||||
const chatInput = await waitForTestId(
|
||||
driver,
|
||||
TestIDs.CHAT_MESSAGE_INPUT,
|
||||
30_000,
|
||||
);
|
||||
expect(await chatInput.isDisplayed()).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await quitDriver();
|
||||
});
|
||||
});
|
||||
16
apps/client/e2e/tsconfig.json
Normal file
16
apps/client/e2e/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user