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:
2026-02-26 21:37:40 +01:00
parent 4f5737d27e
commit 27602aee4c
21 changed files with 12549 additions and 41 deletions

View File

@@ -25,6 +25,7 @@ npm run ios -w @calchat/client # Start on iOS
npm run web -w @calchat/client # Start web version npm run web -w @calchat/client # Start web version
npm run lint -w @calchat/client # Run ESLint npm run lint -w @calchat/client # Run ESLint
npm run build:apk -w @calchat/client # Build APK locally with EAS npm run build:apk -w @calchat/client # Build APK locally with EAS
npm run test:e2e -w @calchat/client # Run E2E tests (requires Appium server running)
``` ```
### Shared (packages/shared) ### Shared (packages/shared)
@@ -61,6 +62,8 @@ npm run test -w @calchat/server # Run Jest unit tests
| | tsdav | CalDAV client library | | | tsdav | CalDAV client library |
| | ical.js | iCalendar parsing/generation | | | ical.js | iCalendar parsing/generation |
| Testing | Jest / ts-jest | Server unit tests | | Testing | Jest / ts-jest | Server unit tests |
| | WebdriverIO + Appium | E2E tests (Android) |
| | UiAutomator2 | Android UI automation driver |
| Deployment | Docker | Server containerization (multi-stage build) | | Deployment | Docker | Server containerization (multi-stage build) |
| | Drone CI | CI/CD pipelines (build, test, format check, deploy, APK build + Gitea release) | | | Drone CI | CI/CD pipelines (build, test, format check, deploy, APK build + Gitea release) |
| Planned | iCalendar | Event export/import | | Planned | iCalendar | Event export/import |
@@ -132,6 +135,19 @@ src/
│ └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand │ └── ThemeStore.ts # theme, setTheme() - reactive theme switching with Zustand
└── hooks/ └── hooks/
└── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element └── useDropdownPosition.ts # Hook for positioning dropdowns relative to trigger element
e2e/ # E2E tests (WebdriverIO + Appium)
├── jest.config.ts # Jest config (ts-jest, 120s timeout)
├── tsconfig.json # TypeScript config (ES2020, CommonJS)
├── .env # Test credentials, device name, Appium host/port
├── config/
│ └── capabilities.ts # Appium capabilities (Dev Client vs APK mode)
├── helpers/
│ ├── driver.ts # WebdriverIO driver singleton (init, get, quit)
│ ├── selectors.ts # testID constants for all screens
│ └── utils.ts # Helpers (waitForTestId, performLogin, ensureLoggedIn, ensureOnLoginScreen)
└── tests/
├── 01-app-launch.test.ts # App startup & screen detection
└── 02-login.test.ts # Login flow (empty fields, invalid creds, success)
``` ```
**Routing:** Tab-based navigation with Chat, Calendar, and Settings as main screens. Auth screens (login, register) outside tabs. Dynamic routes for event detail and note editing. **Routing:** Tab-based navigation with Chat, Calendar, and Settings as main screens. Auth screens (login, register) outside tabs. Dynamic routes for event detail and note editing.
@@ -672,6 +688,13 @@ NODE_ENV=development # development = pretty logs, production = JSON
- **Chat mode**: Edit AI-proposed events before confirming - updates ChatStore locally and persists to server via ChatService.updateProposalEvent() - **Chat mode**: Edit AI-proposed events before confirming - updates ChatStore locally and persists to server via ChatService.updateProposalEvent()
- Route params: `mode` ('calendar' | 'chat'), `id?`, `date?`, `eventData?` (JSON), `proposalContext?` (JSON with messageId, proposalId, conversationId) - Route params: `mode` ('calendar' | 'chat'), `id?`, `date?`, `eventData?` (JSON), `proposalContext?` (JSON with messageId, proposalId, conversationId)
- Supports recurring events with RRULE configuration (daily/weekly/monthly/yearly) - Supports recurring events with RRULE configuration (daily/weekly/monthly/yearly)
- **E2E testing infrastructure:**
- WebdriverIO + Appium with UiAutomator2 driver for Android
- testID props added to key components (`AuthButton`, `BaseButton`, `ChatBubble`, `CustomTextInput`, `ProposedEventCard`)
- testIDs applied to login screen, chat screen, tab bar, settings logout button, and event proposal buttons
- `app.json`: `usesCleartextTraffic: true` for Android (allows HTTP connections needed by Appium)
- Two execution modes: Dev Client (local) and APK (CI)
- Tests: app launch detection, login flow validation (empty fields, invalid creds, success)
## Building ## Building
@@ -714,12 +737,49 @@ The project uses Drone CI (`.drone.yml`) with five pipelines:
## Testing ## Testing
Server uses Jest with ts-jest for unit testing. Config in `apps/server/jest.config.js` ignores `/node_modules/` and `/dist/`. ### Server Unit Tests
Jest with ts-jest for unit testing. Config in `apps/server/jest.config.js` ignores `/node_modules/` and `/dist/`.
**Existing tests:** **Existing tests:**
- `src/utils/password.test.ts` - Tests for bcrypt hash() and compare() - `src/utils/password.test.ts` - Tests for bcrypt hash() and compare()
- `src/utils/recurrenceExpander.test.ts` - Tests for expandRecurringEvents() (non-recurring, weekly/daily/UNTIL recurrence, EXDATE filtering, RRULE: prefix stripping, invalid RRULE fallback, multi-day events, sorting) - `src/utils/recurrenceExpander.test.ts` - Tests for expandRecurringEvents() (non-recurring, weekly/daily/UNTIL recurrence, EXDATE filtering, RRULE: prefix stripping, invalid RRULE fallback, multi-day events, sorting)
### E2E Tests (Client)
WebdriverIO + Appium for Android E2E testing. Tests run sequentially (`--runInBand`) sharing a singleton Appium driver.
**Two execution modes:**
- **Dev Client mode** (local): Connects to running Expo app (`host.exp.exponent`), `noReset: true`
- **APK mode** (CI): Installs APK via `APK_PATH` env var, `noReset: false`
**Running locally:**
```bash
# Terminal 1: Start Appium server
appium
# Terminal 2: Start Expo dev server on Android emulator
npm run android -w @calchat/client
# Terminal 3: Run E2E tests
npm run test:e2e -w @calchat/client
```
**Environment variables** (`apps/client/e2e/.env`):
```
TEST_USER=test # Login credentials for tests
TEST_PASSWORD=test
DEVICE_NAME=emulator-5554 # Android device/emulator
APPIUM_HOST=localhost
APPIUM_PORT=4723
```
**Element selection:** Uses Android UiAutomator2 with `resource-id` selectors (React Native maps `testID``resource-id` on Android).
**testID conventions:** Components with testID support: `AuthButton`, `BaseButton`, `ChatBubble`, `CustomTextInput`, `ProposedEventCard`. Key testIDs: `login-title`, `login-identifier-input`, `login-password-input`, `login-button`, `login-error-text`, `tab-chat`, `tab-calendar`, `tab-settings`, `chat-message-input`, `chat-send-button`, `chat-bubble-left`, `chat-bubble-right`, `settings-logout-button`, `event-accept-button`, `event-reject-button`.
**Existing E2E tests:**
- `01-app-launch.test.ts` - App startup, detects login or auto-logged-in state
- `02-login.test.ts` - Empty field validation, invalid credentials error, successful login
## Documentation ## Documentation
Detailed architecture diagrams are in `docs/`: Detailed architecture diagrams are in `docs/`:

View File

@@ -14,7 +14,8 @@
"android": { "android": {
"package": "com.gilmour109.calchat", "package": "com.gilmour109.calchat",
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"usesCleartextTraffic": true
}, },
"web": { "web": {
"output": "static", "output": "static",

View 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;
}

View 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;
}
}

View 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;

View 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);
}
}

View 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;

View 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);
}
});
});

View 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();
});
});

View 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"]
}

View File

@@ -9,7 +9,8 @@
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"build:apk": "eas build --platform android --profile preview --local --non-interactive --output ./calchat.apk" "build:apk": "eas build --platform android --profile preview --local --non-interactive --output ./calchat.apk",
"test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config e2e/jest.config.ts --runInBand"
}, },
"dependencies": { "dependencies": {
"@calchat/shared": "*", "@calchat/shared": "*",
@@ -48,10 +49,17 @@
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14",
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"appium": "^2.17.1",
"appium-uiautomator2-driver": "^3.8.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",
"prettier-plugin-tailwindcss": "^0.5.11", "prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"webdriverio": "^9.14.2"
}, },
"private": true "private": true
} }

View File

@@ -19,6 +19,7 @@ export default function TabLayout() {
name="chat" name="chat"
options={{ options={{
title: "Chat", title: "Chat",
tabBarTestID: "tab-chat",
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<Ionicons size={28} name="chatbubble" color={color} /> <Ionicons size={28} name="chatbubble" color={color} />
), ),
@@ -28,6 +29,7 @@ export default function TabLayout() {
name="calendar" name="calendar"
options={{ options={{
title: "Calendar", title: "Calendar",
tabBarTestID: "tab-calendar",
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<Ionicons size={28} name="calendar" color={color} /> <Ionicons size={28} name="calendar" color={color} />
), ),
@@ -37,6 +39,7 @@ export default function TabLayout() {
name="settings" name="settings"
options={{ options={{
title: "Settings", title: "Settings",
tabBarTestID: "tab-settings",
tabBarIcon: ({ color }) => ( tabBarIcon: ({ color }) => (
<Ionicons size={28} name="settings" color={color} /> <Ionicons size={28} name="settings" color={color} />
), ),

View File

@@ -342,6 +342,7 @@ const ChatInput = ({ onSend }: ChatInputProps) => {
return ( return (
<View className="flex flex-row w-full items-end my-2 px-2"> <View className="flex flex-row w-full items-end my-2 px-2">
<TextInput <TextInput
testID="chat-message-input"
className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2" className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2"
style={{ style={{
backgroundColor: theme.messageBorderBg, backgroundColor: theme.messageBorderBg,
@@ -356,7 +357,7 @@ const ChatInput = ({ onSend }: ChatInputProps) => {
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
multiline multiline
/> />
<Pressable onPress={handleSend}> <Pressable testID="chat-send-button" onPress={handleSend}>
<View <View
className="w-10 h-10 rounded-full items-center justify-center" className="w-10 h-10 rounded-full items-center justify-center"
style={{ style={{
@@ -393,6 +394,7 @@ const ChatMessage = ({
return ( return (
<ChatBubble <ChatBubble
side={side} side={side}
testID={`chat-bubble-${side}`}
style={{ style={{
maxWidth: "80%", maxWidth: "80%",
minWidth: hasProposals ? "75%" : undefined, minWidth: hasProposals ? "75%" : undefined,

View File

@@ -20,6 +20,7 @@ const handleLogout = async () => {
const SettingsButton = (props: BaseButtonProps) => { const SettingsButton = (props: BaseButtonProps) => {
return ( return (
<BaseButton <BaseButton
testID={props.testID}
onPress={props.onPress} onPress={props.onPress}
solid={props.solid} solid={props.solid}
className={"w-11/12"} className={"w-11/12"}
@@ -214,7 +215,7 @@ const Settings = () => {
<BaseBackground> <BaseBackground>
<SimpleHeader text="Settings" /> <SimpleHeader text="Settings" />
<View className="flex items-center mt-4"> <View className="flex items-center mt-4">
<SettingsButton onPress={handleLogout} solid={true}> <SettingsButton testID="settings-logout-button" onPress={handleLogout} solid={true}>
<Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "} <Ionicons name="log-out-outline" size={24} color={theme.primeFg} />{" "}
Logout Logout
</SettingsButton> </SettingsButton>

View File

@@ -45,6 +45,7 @@ const LoginScreen = () => {
<BaseBackground> <BaseBackground>
<View className="flex-1 justify-center items-center p-8"> <View className="flex-1 justify-center items-center p-8">
<Text <Text
testID="login-title"
className="text-3xl font-bold mb-8" className="text-3xl font-bold mb-8"
style={{ color: theme.textPrimary }} style={{ color: theme.textPrimary }}
> >
@@ -53,6 +54,7 @@ const LoginScreen = () => {
{error && ( {error && (
<Text <Text
testID="login-error-text"
className="mb-4 text-center" className="mb-4 text-center"
style={{ color: theme.rejectButton }} style={{ color: theme.rejectButton }}
> >
@@ -61,6 +63,7 @@ const LoginScreen = () => {
)} )}
<CustomTextInput <CustomTextInput
testID="login-identifier-input"
placeholder="E-Mail oder Benutzername" placeholder="E-Mail oder Benutzername"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
text={identifier} text={identifier}
@@ -70,6 +73,7 @@ const LoginScreen = () => {
/> />
<CustomTextInput <CustomTextInput
testID="login-password-input"
placeholder="Passwort" placeholder="Passwort"
placeholderTextColor={theme.textMuted} placeholderTextColor={theme.textMuted}
text={password} text={password}
@@ -79,6 +83,7 @@ const LoginScreen = () => {
/> />
<AuthButton <AuthButton
testID="login-button"
title="Anmelden" title="Anmelden"
onPress={handleLogin} onPress={handleLogin}
isLoading={isLoading} isLoading={isLoading}

View File

@@ -5,12 +5,14 @@ interface AuthButtonProps {
title: string; title: string;
onPress: () => void; onPress: () => void;
isLoading?: boolean; isLoading?: boolean;
testID?: string;
} }
const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => { const AuthButton = ({ title, onPress, isLoading = false, testID }: AuthButtonProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
<Pressable <Pressable
testID={testID}
onPress={onPress} onPress={onPress}
disabled={isLoading} disabled={isLoading}
className="w-full rounded-lg p-4 mb-4 border-4" className="w-full rounded-lg p-4 mb-4 border-4"

View File

@@ -7,6 +7,7 @@ export type BaseButtonProps = {
className?: string; className?: string;
onPress: () => void; onPress: () => void;
solid?: boolean; solid?: boolean;
testID?: string;
}; };
const BaseButton = ({ const BaseButton = ({
@@ -14,10 +15,12 @@ const BaseButton = ({
children, children,
onPress, onPress,
solid = false, solid = false,
testID,
}: BaseButtonProps) => { }: BaseButtonProps) => {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
return ( return (
<Pressable <Pressable
testID={testID}
className={`rounded-lg p-4 mb-4 border-4 ${className}`} className={`rounded-lg p-4 mb-4 border-4 ${className}`}
onPress={onPress} onPress={onPress}
style={{ style={{

View File

@@ -8,6 +8,7 @@ type ChatBubbleProps = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
style?: ViewStyle; style?: ViewStyle;
testID?: string;
}; };
export function ChatBubble({ export function ChatBubble({
@@ -15,6 +16,7 @@ export function ChatBubble({
children, children,
className = "", className = "",
style, style,
testID,
}: ChatBubbleProps) { }: ChatBubbleProps) {
const { theme } = useThemeStore(); const { theme } = useThemeStore();
const borderColor = side === "left" ? theme.chatBot : theme.primeFg; const borderColor = side === "left" ? theme.chatBot : theme.primeFg;
@@ -25,6 +27,7 @@ export function ChatBubble({
return ( return (
<View <View
testID={testID}
className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`} className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
style={[ style={[
{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg }, { borderColor, elevation: 8, backgroundColor: theme.secondaryBg },

View File

@@ -13,6 +13,7 @@ export type CustomTextInputProps = {
secureTextEntry?: boolean; secureTextEntry?: boolean;
autoCapitalize?: TextInputProps["autoCapitalize"]; autoCapitalize?: TextInputProps["autoCapitalize"];
keyboardType?: TextInputProps["keyboardType"]; keyboardType?: TextInputProps["keyboardType"];
testID?: string;
}; };
const CustomTextInput = (props: CustomTextInputProps) => { const CustomTextInput = (props: CustomTextInputProps) => {
@@ -21,6 +22,7 @@ const CustomTextInput = (props: CustomTextInputProps) => {
return ( return (
<TextInput <TextInput
testID={props.testID}
className={`border border-solid rounded-2xl ${props.className}`} className={`border border-solid rounded-2xl ${props.className}`}
onChangeText={props.onValueChange} onChangeText={props.onValueChange}
value={props.text} value={props.text}

View File

@@ -31,6 +31,7 @@ const ActionButtons = ({
return ( return (
<View className="flex-row mt-3 gap-2"> <View className="flex-row mt-3 gap-2">
<Pressable <Pressable
testID="event-accept-button"
onPress={onConfirm} onPress={onConfirm}
disabled={isDisabled} disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center" className="flex-1 py-2 rounded-lg items-center"
@@ -47,6 +48,7 @@ const ActionButtons = ({
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
testID="event-reject-button"
onPress={onReject} onPress={onReject}
disabled={isDisabled} disabled={isDisabled}
className="flex-1 py-2 rounded-lg items-center" className="flex-1 py-2 rounded-lg items-center"

12107
package-lock.json generated

File diff suppressed because it is too large Load Diff