Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,37 @@ npm run lint

## Browser Smoke Tests

This repo also contains a minimal Playwright smoke layer for browser-visible public routes. To list or run the suite:
This repo also contains a Playwright e2e layer for browser-visible
public routes and for the chat-component flows that mount under
`/app/chat`. To list or run the suite:

```bash
npm run test:e2e -- --list
npm run test:e2e
```

### What's covered

| Spec | Tests | Notes |
|------|-------|-------|
| `tests/e2e/smoke.spec.ts` | Public-page renders (login, register, 404) | Mocks `/v1/apps/get-config` |
| `tests/e2e/auth-flows.spec.ts` | Host login form validation + POST body shape | Mocks login endpoint; doesn't need full bootstrap |
| `tests/e2e/chat-flows.spec.ts` | Chat-component room list, send-text, attach button | `test.fixme` stubs until post-login bootstrap mocks land |

### Cross-platform testing overview

This repo is the Layer 2 (browser e2e) home for chat-component. The
testid constants in `tests/e2e/_chatComponentTestIds.ts` mirror the
public testIds exported by `@ethora/chat-component` and match the
Compose `testTag` / SwiftUI `accessibilityIdentifier` strings used
by the mobile SDKs and their Maestro flows.

| Layer 1 (hermetic) | Layer 2 (E2E) |
|--------------------|----------------|
| [`ethora-chat-component`](https://github.com/dappros/ethora-chat-component) — Vitest + RTL + `data-testid` | `ethora-app-reactjs/tests/e2e/` — Playwright (this repo) |
| [`ethora-sdk-android`](https://github.com/dappros/ethora-sdk-android) — Compose UI tests | [`ethora-sample-android/.maestro/`](https://github.com/dappros/ethora-sample-android) — 19 Maestro flows |
| [`ethora-sdk-swift`](https://github.com/dappros/ethora-sdk-swift) — XCTest + accessibility-id markers | [`ethora-sample-swift/.maestro/`](https://github.com/dappros/ethora-sample-swift) — same 19 Maestro flows on iOS Simulator |

A Playwright spec using `[data-testid="chat_input"]` and a Maestro
flow using `id: "chat_input"` resolve the same intent — one selector
contract across all four runtime targets.
53 changes: 53 additions & 0 deletions tests/e2e/_chatComponentTestIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Local mirror of `@ethora/chat-component`'s public testid constants.
*
* The chat-component package exports these via `src/main.ts` (see PR
* https://github.com/dappros/ethora-chat-component/pull/71). Once that
* PR merges and a new version (>= 26.3.18) publishes, replace the
* inline definitions below with:
*
* import {
* ChatInputTestIds,
* MessageBubbleTestIds,
* RoomListTestIds,
* AuthTestIds,
* } from '@ethora/chat-component';
*
* and delete the literals here. The string values are the source of
* truth for cross-platform testing — they also match Android's
* `*TestTags` Kotlin objects (in `ethora-sdk-android`'s `chat-ui`
* module) and iOS's `*AccessibilityID` Swift enums (in
* `ethora-sdk-swift`'s `XMPPChatUI/AccessibilityIdentifiers.swift`).
*
* Maestro flows in `ethora-sample-android/.maestro/` and
* `ethora-sample-swift/.maestro/` already use these strings via
* `id: "chat_input"` etc. So a Playwright test in this repo that
* resolves `[data-testid="chat_input"]` is exercising the same
* intent as the corresponding mobile flow — three platforms,
* one selector contract.
*/

export const ChatInputTestIds = {
inputField: 'chat_input',
sendButton: 'chat_send_button',
attachButton: 'chat_attach_button',
} as const;

export const MessageBubbleTestIds = {
mediaContent: 'chat_message_image',
} as const;

export const RoomListTestIds = {
roomsList: 'rooms_list',
roomRow: 'room_row',
searchInput: 'rooms_search_input',
createRoomButton: 'create_room_button',
} as const;

export const AuthTestIds = {
emailInput: 'auth_email_input',
passwordInput: 'auth_password_input',
submitButton: 'auth_submit_button',
emailError: 'auth_email_error',
passwordError: 'auth_password_error',
} as const;
165 changes: 165 additions & 0 deletions tests/e2e/auth-flows.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Auth-flow Playwright tests against the host app's OWN login form
* (`src/pages/AuthPage/Login/Steps/LoginForm.tsx`). The chat-component
* has its own `<Login>` for embed scenarios — that one is covered by
* Vitest in `ethora-chat-component`. Here we drive the admin/portal
* login the host renders at `/login`.
*
* Mocks all HTTP — no real backend needed. Mirrors the pattern in
* `smoke.spec.ts` for `/v1/apps/get-config`.
*/

import { expect, Page, test } from '@playwright/test';

const appConfigResponse = {
result: {
_id: 'playwright-app',
afterLoginPage: '/app/admin/apps',
aiBot: {
userId: '',
chatId: '',
status: 'off',
greetingMessage: '',
isRAG: false,
trigger: '',
prompt: '',
siteLinks: [],
siteUrlsV2: [],
files: [],
user: { _id: '', firstName: '', lastName: '', isBot: true },
chat: { _id: '', name: '', title: '', description: '', type: '', picture: '' },
},
allowUsersToCreateRooms: true,
appTagline: 'Playwright auth-flow environment',
appToken: 'playwright-app-token',
availableMenuItems: { chats: true, profile: true, settings: true },
bundleId: 'com.ethora.playwright',
coinName: 'Ethora',
coinSymbol: 'ETHORA',
createdAt: '2026-01-01T00:00:00.000Z',
creatorId: 'playwright-user',
defaultAccessAssetsOpen: true,
defaultAccessProfileOpen: true,
defaultRooms: [],
displayName: 'Ethora Test App',
domainName: 'playwright.local',
firebaseWebConfigString: '',
googleServiceInfoPlist: '',
googleServicesJson: '',
isAllowedNewAppCreate: true,
isBaseApp: false,
logoImage: '',
parentAppId: '',
primaryColor: '#0052CD',
signonOptions: ['email'],
stats: {
recentlyApiCalls: 0,
recentlyFiles: 0,
recentlyIssuance: 0,
recentlyRegistered: 0,
recentlySessions: 0,
recentlyTokens: 0,
recentlyTransactions: 0,
totalApiCalls: 0,
totalFiles: 0,
totalIssuance: 0,
totalRegistered: 0,
totalSessions: 0,
totalTransactions: 0,
totalChats: 0,
totalTokens: 0,
recentlyChats: 0,
},
sublogoImage: '',
systemChatAccount: { jid: 'system@example.com' },
updatedAt: '2026-01-01T00:00:00.000Z',
usersCanFree: true,
},
};

async function mockAppConfig(page: Page) {
await page.route('**/v1/apps/get-config*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(appConfigResponse),
});
});
}

test.beforeEach(async ({ page }) => {
await mockAppConfig(page);
});

test('login form rejects empty submit with required-field validation', async ({
page,
}) => {
await page.goto('/login');

// Click "Sign In" without typing anything. react-hook-form fires
// `required` validation messages without hitting the API.
await page.getByRole('button', { name: 'Sign In' }).click();

Check failure on line 101 in tests/e2e/auth-flows.spec.ts

View workflow job for this annotation

GitHub Actions / build

[chromium] › tests/e2e/auth-flows.spec.ts:94:1 › login form rejects empty submit with required-field validation

1) [chromium] › tests/e2e/auth-flows.spec.ts:94:1 › login form rejects empty submit with required-field validation Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.click: Test timeout of 30000ms exceeded. Call log: - waiting for getByRole('button', { name: 'Sign In' }) 99 | // Click "Sign In" without typing anything. react-hook-form fires 100 | // `required` validation messages without hitting the API. > 101 | await page.getByRole('button', { name: 'Sign In' }).click(); | ^ 102 | 103 | // The form's required validators surface the messages defined in 104 | // LoginForm.tsx: 'Email is required' + 'Required field' (password). at /home/runner/work/ethora-app-reactjs/ethora-app-reactjs/tests/e2e/auth-flows.spec.ts:101:55

Check failure on line 101 in tests/e2e/auth-flows.spec.ts

View workflow job for this annotation

GitHub Actions / build

[chromium] › tests/e2e/auth-flows.spec.ts:94:1 › login form rejects empty submit with required-field validation

1) [chromium] › tests/e2e/auth-flows.spec.ts:94:1 › login form rejects empty submit with required-field validation Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.click: Test timeout of 30000ms exceeded. Call log: - waiting for getByRole('button', { name: 'Sign In' }) 99 | // Click "Sign In" without typing anything. react-hook-form fires 100 | // `required` validation messages without hitting the API. > 101 | await page.getByRole('button', { name: 'Sign In' }).click(); | ^ 102 | 103 | // The form's required validators surface the messages defined in 104 | // LoginForm.tsx: 'Email is required' + 'Required field' (password). at /home/runner/work/ethora-app-reactjs/ethora-app-reactjs/tests/e2e/auth-flows.spec.ts:101:55

Check failure on line 101 in tests/e2e/auth-flows.spec.ts

View workflow job for this annotation

GitHub Actions / build

[chromium] › tests/e2e/auth-flows.spec.ts:94:1 › login form rejects empty submit with required-field validation

1) [chromium] › tests/e2e/auth-flows.spec.ts:94:1 › login form rejects empty submit with required-field validation Error: locator.click: Test timeout of 30000ms exceeded. Call log: - waiting for getByRole('button', { name: 'Sign In' }) 99 | // Click "Sign In" without typing anything. react-hook-form fires 100 | // `required` validation messages without hitting the API. > 101 | await page.getByRole('button', { name: 'Sign In' }).click(); | ^ 102 | 103 | // The form's required validators surface the messages defined in 104 | // LoginForm.tsx: 'Email is required' + 'Required field' (password). at /home/runner/work/ethora-app-reactjs/ethora-app-reactjs/tests/e2e/auth-flows.spec.ts:101:55

// The form's required validators surface the messages defined in
// LoginForm.tsx: 'Email is required' + 'Required field' (password).
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Required field')).toBeVisible();
});

test('login form rejects invalid email format with regex error', async ({
page,
}) => {
await page.goto('/login');

await page.getByPlaceholder('Email').fill('not-an-email');

Check failure on line 114 in tests/e2e/auth-flows.spec.ts

View workflow job for this annotation

GitHub Actions / build

[chromium] › tests/e2e/auth-flows.spec.ts:109:1 › login form rejects invalid email format with regex error

2) [chromium] › tests/e2e/auth-flows.spec.ts:109:1 › login form rejects invalid email format with regex error Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.fill: Test timeout of 30000ms exceeded. Call log: - waiting for getByPlaceholder('Email') 112 | await page.goto('/login'); 113 | > 114 | await page.getByPlaceholder('Email').fill('not-an-email'); | ^ 115 | await page.getByPlaceholder('Password').fill('something'); 116 | await page.getByRole('button', { name: 'Sign In' }).click(); 117 | at /home/runner/work/ethora-app-reactjs/ethora-app-reactjs/tests/e2e/auth-flows.spec.ts:114:40

Check failure on line 114 in tests/e2e/auth-flows.spec.ts

View workflow job for this annotation

GitHub Actions / build

[chromium] › tests/e2e/auth-flows.spec.ts:109:1 › login form rejects invalid email format with regex error

2) [chromium] › tests/e2e/auth-flows.spec.ts:109:1 › login form rejects invalid email format with regex error Error: locator.fill: Test timeout of 30000ms exceeded. Call log: - waiting for getByPlaceholder('Email') 112 | await page.goto('/login'); 113 | > 114 | await page.getByPlaceholder('Email').fill('not-an-email'); | ^ 115 | await page.getByPlaceholder('Password').fill('something'); 116 | await page.getByRole('button', { name: 'Sign In' }).click(); 117 | at /home/runner/work/ethora-app-reactjs/ethora-app-reactjs/tests/e2e/auth-flows.spec.ts:114:40
await page.getByPlaceholder('Password').fill('something');
await page.getByRole('button', { name: 'Sign In' }).click();

// The pattern check in LoginForm.tsx fires "Invalid email address"
// for anything that doesn't match /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.
await expect(page.getByText('Invalid email address')).toBeVisible();
});

test('login form posts to /v1/users/login-with-email on valid input', async ({
page,
}) => {
let postedBody: { email?: string; password?: string } | null = null;

// Intercept the login endpoint. We don't need to fully roundtrip
// through actionAfterLogin (that pulls the user via /me, sets
// auth state, navigates to /app/admin/apps); just proving the
// request fires with the right body confirms the form's HTTP
// wiring is intact.
await page.route('**/v1/users/login-with-email', async (route) => {
const request = route.request();
try {
postedBody = JSON.parse(request.postData() || '{}');
} catch {
postedBody = null;
}
// Reply with a 401 so the test exits the auth flow without
// needing to mock the rest of the post-login bootstrap chain
// (/me, /apps/get-config-by-token, etc.). The form's behavior
// we care about — "submit fired with the right body" — has
// already been observed by this point.
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ message: 'Invalid credentials' }),
});
});

await page.goto('/login');
await page.getByPlaceholder('Email').fill('alice@ethora.com');
await page.getByPlaceholder('Password').fill('TestPass123');
await page.getByRole('button', { name: 'Sign In' }).click();

// Wait for the POST to be intercepted (Playwright fulfills it
// synchronously, so the test can read postedBody right after
// waitForRequest resolves).
await page.waitForRequest('**/v1/users/login-with-email');

expect(postedBody).not.toBeNull();
expect(postedBody?.email).toBe('alice@ethora.com');
expect(postedBody?.password).toBe('TestPass123');
});
127 changes: 127 additions & 0 deletions tests/e2e/chat-flows.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Chat-flow Playwright tests against the host's `/app/chat` route,
* where `<Chat>` from `@ethora/chat-component` mounts. Resolves nodes
* by `data-testid` using the constants from `_chatComponentTestIds.ts`,
* which mirrors the chat-component package's public testid API.
*
* Cross-platform parity: the same testid strings drive Maestro
* flows on Android (`ethora-sample-android/.maestro/`) and iOS
* (`ethora-sample-swift/.maestro/`). One selector contract → three
* platforms.
*
* Status: most tests below are `test.fixme()` until backend mocks
* for the post-login bootstrap (`/me`, `/apps/get-config-by-token`,
* `/chats/my`, XMPP WebSocket handshake) are wired. The shape and
* the `data-testid` anchors are in place so filling each in is a
* mechanical follow-up.
*/

import { expect, test } from '@playwright/test';
import {
ChatInputTestIds,
RoomListTestIds,
} from './_chatComponentTestIds';

test.describe('chat-component on /app/chat', () => {
test.fixme(
'mounts the room list when the user lands on /app/chat',
async ({ page }) => {
// Pre-condition: an authenticated session. The host reads
// tokens from localStorage on boot via actionAfterLogin's
// persistence side effects. Easiest CI shape is to seed the
// expected localStorage keys via page.addInitScript before
// navigation, and route /me + /chats/my to a fixture.
await page.addInitScript(() => {
localStorage.setItem(
'auth_token',
'JWT eyJhbGciOiJIUzI1NiJ9.fake-test-token'
);
// Seed whatever other auth state the host expects — this
// shape needs to match useAppStore's persistence schema.
});

await page.route('**/v1/chats/my**', async (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
result: [
{
_id: 'room-1',
name: 'maestro-test-room',
title: 'Maestro Test Room',
jid: 'maestro_room@conference.xmpp.chat-qa.ethora.com',
participants: [],
messages: [],
pendingMessages: 0,
},
],
}),
})
);

await page.goto('/app/chat');

// chat-component's RoomListView wraps its <List> with
// `data-testid="rooms_list"` (RoomListTestIds.roomsList).
// If the constant ever changes upstream, the local mirror
// and Android/iOS tags must move in lockstep.
await expect(
page.locator(`[data-testid="${RoomListTestIds.roomsList}"]`)
).toBeVisible();

await expect(
page.locator(`[data-testid="${RoomListTestIds.roomRow}"]`).first()
).toBeVisible();
}
);

test.fixme(
'send a message via the chat input',
async ({ page }) => {
// Same auth + rooms-list pre-condition as the test above.
// After selecting a room, type into the chat input and
// assert the bubble renders. The actual XMPP send is hard
// to fully mock — easier to point this test at chat-qa
// when run with PLAYWRIGHT_REAL_BACKEND=1 and skip in CI
// unless the env var is set.
await page.goto('/app/chat');
await page
.locator(`[data-testid="${RoomListTestIds.roomRow}"]`)
.first()
.click();

const messageBody = `playwright-${Date.now()}`;
await page
.locator(`[data-testid="${ChatInputTestIds.inputField}"]`)
.fill(messageBody);
await page
.locator(`[data-testid="${ChatInputTestIds.sendButton}"]`)
.click();

await expect(page.getByText(messageBody)).toBeVisible({
timeout: 5000,
});
}
);

test.fixme(
'attach button is visible and enabled when media is allowed',
async ({ page }) => {
await page.goto('/app/chat');
await page
.locator(`[data-testid="${RoomListTestIds.roomRow}"]`)
.first()
.click();

// chat-component renders the attach button only when the
// host config sets onSendMedia. The host's Chat.tsx mounts
// <Chat> with media enabled by default.
const attach = page.locator(
`[data-testid="${ChatInputTestIds.attachButton}"]`
);
await expect(attach).toBeVisible();
await expect(attach).toBeEnabled();
}
);
});
Loading