Skip to content
Open
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
32 changes: 32 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Playwright E2E Tests

These tests protect user-visible release workflows. They complement backend integration tests instead of repeating endpoint and repository assertions.

## Test Boundary

- Use the API to arrange isolated data, wait for asynchronous ingestion, and clean up.
- Perform the behavior under test through the browser.
- Assert navigation, rendered state, validation, dialogs, filters, and state that remains visible after reload.
- Leave response schemas, authorization matrices, query correctness, and business-rule permutations to integration tests.

## Maintenance Rules

- Prefer roles, labels, and visible text. Add a test ID only when the UI has no useful accessible contract.
- Do not use fixed sleeps. Wait for a visible outcome, URL, or the specific mutation response that unlocks the next UI assertion.
- Keep each spec centered on one user outcome. Seed prerequisites directly instead of replaying unrelated UI flows.
- Put reusable data setup in `support/event-data.ts` and fixture lifecycle in `fixtures/e2e-test.ts`.
- Add small task helpers when an interaction repeats. Do not grow a page object that mirrors every page or hides the user behavior.
- Tests must be independent and safe to retry. Every created project, organization, and user needs deterministic cleanup.

## Coverage Shape

- Authentication: validation, successful login, session restoration, logout, and protected-route redirect.
- Account recovery: password reset and organization invitation acceptance through local email links.
- Discovery: sidebar navigation, filters, empty states, and recovery.
- Investigation: open event and stack details through rendered tables and sheets.
- Organizations: switch organizations and verify event isolation persists through reload.
- Projects: create API keys, open client setup, and redirect when the first event arrives.
- Sessions: find a user session and open its event details.
- Triage: mutate stack state and verify the persisted UI after reload.
- Scoping: select and clear projects through the filter UI.
- Onboarding: sign up, create an organization/project, and verify setup instructions.
104 changes: 99 additions & 5 deletions src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ export class E2EApiClient {
return response.status();
}

async deleteOrganizationUser(token: string, organizationId: string, email: string): Promise<number> {
const response = await this.request.delete(this.url(`organizations/${organizationId}/users/${encodeURIComponent(email)}`), {
headers: this.authHeaders(token)
});

await expectStatus(response, [200, 202, 204, 404], 'delete organization user');
return response.status();
}

async deleteProject(token: string, projectId: string): Promise<number> {
const response = await this.request.delete(this.url(`projects/${projectId}`), {
headers: this.authHeaders(token)
Expand Down Expand Up @@ -197,15 +206,15 @@ export class E2EApiClient {
return toStack(await readJson(response));
}

async login(): Promise<string> {
if (!this.environment.email || !this.environment.password) {
throw new Error('E2E_EMAIL and E2E_PASSWORD are required when using API login.');
async login(email = this.environment.email, password = this.environment.password): Promise<string> {
if (!email || !password) {
throw new Error('Email and password are required when using API login.');
}

const response = await this.request.post(this.url('auth/login'), {
data: {
email: this.environment.email,
password: this.environment.password
email,
password
}
});

Expand All @@ -232,6 +241,41 @@ export class E2EApiClient {
throw new Error(`Timed out waiting for E2E event with reference id ${referenceId}`);
}

async pollForMailToken(email: string, path: 'reset-password' | 'signup', timeoutMs = 30_000): Promise<string> {
const deadline = Date.now() + timeoutMs;

while (Date.now() < deadline) {
const messagesResponse = await this.request.get(`${this.environment.mailUrl}/api/v1/messages`);
await expectStatus(messagesResponse, [200], 'list local mail');
const messagesResult = toRecord(await readJson(messagesResponse), 'mail messages response');
const messages = messagesResult.Messages ?? messagesResult.messages;

if (Array.isArray(messages)) {
for (const message of messages) {
if (!isRecord(message) || !JSON.stringify(message).toLowerCase().includes(email.toLowerCase())) {
continue;
}

const id = getOptionalString(message, 'ID') ?? getOptionalString(message, 'id');
if (!id) {
continue;
}

const messageResponse = await this.request.get(`${this.environment.mailUrl}/api/v1/message/${encodeURIComponent(id)}`);
await expectStatus(messageResponse, [200], 'read local mail');
const token = extractMailToken(JSON.stringify(await readJson(messageResponse)), path);
if (token) {
return token;
}
}
}

await delay(1_000);
}

throw new Error(`Timed out waiting for ${path} email sent to ${email}`);
}

async signup(name: string, email: string, password: string): Promise<string> {
const response = await this.request.post(this.url('auth/signup'), {
data: {
Expand Down Expand Up @@ -298,6 +342,50 @@ export class E2EApiClient {
);
}

async waitForOrganizationListed(token: string, organizationId: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: Error | undefined;

while (Date.now() < deadline) {
try {
const organizations = await this.getOrganizations(token);
if (organizations.some((organization) => organization.id === organizationId)) {
return;
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}

await delay(1_000);
}

throw new Error(
`Timed out waiting for E2E organization ${organizationId} to appear in the organizations list${lastError ? `: ${lastError.message}` : ''}`
);
}

async waitForOrganizationNotListed(token: string, organizationId: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: Error | undefined;

while (Date.now() < deadline) {
try {
const organizations = await this.getOrganizations(token);
if (!organizations.some((organization) => organization.id === organizationId)) {
return;
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}

await delay(1_000);
}

throw new Error(
`Timed out waiting for E2E organization ${organizationId} to disappear from the organizations list${lastError ? `: ${lastError.message}` : ''}`
);
}

async waitForProjectDeleted(token: string, projectId: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: Error | undefined;
Expand Down Expand Up @@ -343,6 +431,12 @@ async function expectStatus(response: APIResponse, expectedStatuses: number[], o
throw new Error(`${operation} failed with status ${response.status()} ${response.statusText()}${body ? `: ${body}` : ''}`);
}

function extractMailToken(content: string, path: 'reset-password' | 'signup'): string | undefined {
const pattern = path === 'reset-password' ? /\/reset-password\/([^?"'<\\\s]+)/ : /\/signup\?token=([^&"'<\\\s]+)/;
const match = pattern.exec(content.replaceAll('&amp;', '&'));
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
}

function getOptionalString(value: Record<string, unknown>, key: string): string | undefined {
const property = value[key];
return typeof property === 'string' ? property : undefined;
Expand Down
119 changes: 113 additions & 6 deletions src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { runCleanupStep, throwIfCleanupFailed } from '../support/cleanup';
import { E2EApiClient } from './api-client';
import { getE2EEnvironment } from './environment';

const PASSWORD = 'tester';
export const E2E_TEST_PASSWORD = 'tester';

export const E2E_ORGANIZATION_NAME_PREFIX = 'E2E Playwright Org';

Expand All @@ -22,17 +22,36 @@ export interface E2EScenario {
userToken: string;
}

export interface E2ESecondaryOrganization extends E2ESecondaryProject {
organizationId: string;
organizationName: string;
}

export interface E2ESecondaryProject {
message: string;
projectId: string;
projectName: string;
projectToken: string;
referenceId: string;
}

interface E2EFixtures {
e2eApi: E2EApiClient;
e2eCleanupPassword: string;
e2eScenario: E2EScenario;
e2eSecondaryOrganization: E2ESecondaryOrganization;
e2eSecondaryProject: E2ESecondaryProject;
e2eUseGeneratedUser: boolean;
}

export const test = base.extend<E2EFixtures>({
e2eApi: async ({ request }, use) => {
await use(new E2EApiClient(request, getE2EEnvironment()));
},

e2eScenario: async ({ e2eApi, page }, use, testInfo) => {
e2eCleanupPassword: [E2E_TEST_PASSWORD, { option: true }],

e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eUseGeneratedUser, page }, use, testInfo) => {
const run = createRunName(e2eApi.environment.runId, testInfo);
const userName = `Playwright User ${run}`;
const email = `playwright-${run}@exceptionless.test`.toLowerCase();
Expand All @@ -46,10 +65,10 @@ export const test = base.extend<E2EFixtures>({
let createdUser = false;

try {
if (!e2eApi.environment.isProduction && e2eApi.environment.email && e2eApi.environment.password) {
if (!e2eUseGeneratedUser && !e2eApi.environment.isProduction && e2eApi.environment.email && e2eApi.environment.password) {
userToken = await e2eApi.login();
} else {
userToken = await e2eApi.signup(userName, email, PASSWORD);
userToken = await e2eApi.signup(userName, email, E2E_TEST_PASSWORD);
createdUser = true;
}

Expand All @@ -62,7 +81,9 @@ export const test = base.extend<E2EFixtures>({
await page.addInitScript(
({ organizationId, token }) => {
window.localStorage.setItem('satellizer_token', token);
window.localStorage.setItem('organization', JSON.stringify(organizationId));
if (!window.localStorage.getItem('organization')) {
window.localStorage.setItem('organization', JSON.stringify(organizationId));
}
},
{ organizationId: organization.id, token: userToken }
);
Expand All @@ -83,6 +104,12 @@ export const test = base.extend<E2EFixtures>({
} finally {
const cleanupErrors: Error[] = [];

if (createdUser && userToken) {
await runCleanupStep(cleanupErrors, 'restore generated user session for cleanup', async () => {
userToken = await e2eApi.login(email, e2eCleanupPassword);
});
}

if (userToken && projectId) {
await runCleanupStep(cleanupErrors, `delete project ${projectId}`, async () => {
await e2eApi.deleteProject(userToken!, projectId!);
Expand All @@ -106,7 +133,87 @@ export const test = base.extend<E2EFixtures>({

throwIfCleanupFailed(cleanupErrors);
}
}
},

e2eSecondaryOrganization: async ({ e2eApi, e2eScenario }, use) => {
const organizationName = `${E2E_ORGANIZATION_NAME_PREFIX} Secondary ${e2eScenario.run}`;
const projectName = `Playwright Secondary Organization Project ${e2eScenario.run}`;
const referenceId = `${e2eScenario.referenceId}-organization`;
const message = `Playwright secondary organization event ${e2eScenario.run}`;
let organizationId: string | undefined;
let projectId: string | undefined;

try {
const organization = await e2eApi.createOrganization(e2eScenario.userToken, organizationName);
organizationId = organization.id;
await e2eApi.waitForOrganizationListed(e2eScenario.userToken, organization.id);
const project = await e2eApi.createProject(e2eScenario.userToken, organization.id, projectName);
projectId = project.id;
const projectToken = await e2eApi.getProjectDefaultToken(e2eScenario.userToken, project.id);

await use({
message,
organizationId: organization.id,
organizationName,
projectId: project.id,
projectName,
projectToken: projectToken.id,
referenceId
});
} finally {
const cleanupErrors: Error[] = [];

if (projectId) {
await runCleanupStep(cleanupErrors, `delete secondary organization project ${projectId}`, async () => {
await e2eApi.deleteProject(e2eScenario.userToken, projectId!);
await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!);
});
}

if (organizationId) {
await runCleanupStep(cleanupErrors, `delete secondary organization ${organizationId}`, async () => {
await e2eApi.deleteOrganization(e2eScenario.userToken, organizationId!);
await e2eApi.waitForOrganizationDeleted(e2eScenario.userToken, organizationId!);
});
}

throwIfCleanupFailed(cleanupErrors);
}
},

e2eSecondaryProject: async ({ e2eApi, e2eScenario }, use) => {
const projectName = `Playwright Secondary Project ${e2eScenario.run}`;
const referenceId = `${e2eScenario.referenceId}-secondary`;
const message = `Playwright secondary project event ${e2eScenario.run}`;
let projectId: string | undefined;

try {
const project = await e2eApi.createProject(e2eScenario.userToken, e2eScenario.organizationId, projectName);
projectId = project.id;
const projectToken = await e2eApi.getProjectDefaultToken(e2eScenario.userToken, project.id);

await use({
message,
projectId: project.id,
projectName,
projectToken: projectToken.id,
referenceId
});
} finally {
const cleanupErrors: Error[] = [];

if (projectId) {
await runCleanupStep(cleanupErrors, `delete secondary project ${projectId}`, async () => {
await e2eApi.deleteProject(e2eScenario.userToken, projectId!);
await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!);
});
}

throwIfCleanupFailed(cleanupErrors);
}
},

e2eUseGeneratedUser: [false, { option: true }]
});

export { expect };
Expand Down
3 changes: 3 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
const DEFAULT_APP_URL = 'https://web-ex.dev.localhost:7131';
const DEFAULT_EMAIL = 'admin@exceptionless.test';
const DEFAULT_MAIL_URL = 'http://localhost:8025';
const DEFAULT_PASSWORD = 'tester';

export interface E2EEnvironment {
apiUrl: string;
appUrl: string;
email?: string;
isProduction: boolean;
mailUrl: string;
password?: string;
runId: string;
}
Expand All @@ -32,6 +34,7 @@ export function getE2EEnvironment(): E2EEnvironment {
appUrl: normalizedAppUrl,
email,
isProduction,
mailUrl: normalizeUrl(getOptionalEnv('E2E_MAIL_URL') ?? DEFAULT_MAIL_URL),
password,
runId: sanitizeRunId(runId)
};
Expand Down
Loading
Loading