diff --git a/src/Exceptionless.Web/ClientApp/e2e/README.md b/src/Exceptionless.Web/ClientApp/e2e/README.md new file mode 100644 index 0000000000..319d8b4c1c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/README.md @@ -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. diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 8bf1539a9b..76364d61ed 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -89,6 +89,15 @@ export class E2EApiClient { return response.status(); } + async deleteOrganizationUser(token: string, organizationId: string, email: string): Promise { + 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 { const response = await this.request.delete(this.url(`projects/${projectId}`), { headers: this.authHeaders(token) @@ -197,15 +206,15 @@ export class E2EApiClient { return toStack(await readJson(response)); } - async login(): Promise { - 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 { + 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 } }); @@ -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 { + 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 { const response = await this.request.post(this.url('auth/signup'), { data: { @@ -298,6 +342,50 @@ export class E2EApiClient { ); } + async waitForOrganizationListed(token: string, organizationId: string, timeoutMs = 30_000): Promise { + 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 { + 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 { const deadline = Date.now() + timeoutMs; let lastError: Error | undefined; @@ -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('&', '&')); + return match?.[1] ? decodeURIComponent(match[1]) : undefined; +} + function getOptionalString(value: Record, key: string): string | undefined { const property = value[key]; return typeof property === 'string' ? property : undefined; diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 429a43b173..964ac131be 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -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'; @@ -22,9 +22,26 @@ 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({ @@ -32,7 +49,9 @@ export const test = base.extend({ 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(); @@ -46,10 +65,10 @@ export const test = base.extend({ 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; } @@ -62,7 +81,9 @@ export const test = base.extend({ 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 } ); @@ -83,6 +104,12 @@ export const test = base.extend({ } 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!); @@ -106,7 +133,87 @@ export const test = base.extend({ 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 }; diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts index 68474f1836..dedbe53c11 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts @@ -1,5 +1,6 @@ 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 { @@ -7,6 +8,7 @@ export interface E2EEnvironment { appUrl: string; email?: string; isProduction: boolean; + mailUrl: string; password?: string; runId: string; } @@ -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) }; diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/event-data.ts b/src/Exceptionless.Web/ClientApp/e2e/support/event-data.ts new file mode 100644 index 0000000000..75c0a28b4b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/support/event-data.ts @@ -0,0 +1,29 @@ +import type { E2EApiClient, E2EEvent } from '../fixtures/api-client'; + +import { createRepresentativeEvent } from './synthetic-event'; + +interface SeedRepresentativeEventOptions { + message: string; + projectId: string; + projectToken: string; + referenceId: string; +} + +export async function seedRepresentativeEvent( + e2eApi: E2EApiClient, + userToken: string, + { message, projectId, projectToken, referenceId }: SeedRepresentativeEventOptions +): Promise { + await e2eApi.submitEvent( + projectId, + projectToken, + createRepresentativeEvent({ + appUrl: e2eApi.environment.appUrl, + message, + referenceId, + runId: e2eApi.environment.runId + }) + ); + + return await e2eApi.pollForEventByReference(userToken, projectId, referenceId); +} diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts index 10623ad3cb..ac6377b198 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -5,6 +5,7 @@ import type { E2EScenario } from '../fixtures/e2e-test'; import { createRunName, E2E_ORGANIZATION_NAME_PREFIX } from '../fixtures/e2e-test'; import { runCleanupStep, throwIfCleanupFailed } from './cleanup'; +import { seedRepresentativeEvent } from './event-data'; import { getIdFromUrl, getProjectTokenFromConfigurePage, @@ -14,7 +15,6 @@ import { selectProjectType, waitForEmailValidation } from './page-helpers'; -import { createRepresentativeEvent } from './synthetic-event'; const FIXED_VERSION = '1.0.0'; const PASSWORD = 'tester'; @@ -179,17 +179,19 @@ export class ExceptionlessE2EJourney { async markStackFixed(version = FIXED_VERSION): Promise { expect(this.stackId).toBeTruthy(); - expect(this.userToken).toBeTruthy(); await this.expectEventDetails(); await this.page.getByRole('button', { exact: true, name: 'Open' }).click(); await this.page.getByRole('menuitem', { name: 'Fixed' }).click(); await expect(this.page.getByRole('heading', { name: 'Mark Stack As Fixed' })).toBeVisible(); await this.page.getByLabel('Version', { exact: true }).fill(version); + await this.page.getByRole('button', { name: 'Mark Stack Fixed' }).click(); + await expect(this.page.getByRole('button', { name: 'Fixed' })).toBeVisible({ timeout: 30_000 }); - await expect.poll(async () => (await this.e2eApi.getStack(this.userToken!, this.stackId!)).status, { timeout: 30_000 }).toBe('fixed'); + await this.page.reload(); await expect(this.page.getByRole('button', { name: 'Fixed' })).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(this.page, `Fixed in ${version}`)).toBeVisible(); } async onboardProject(): Promise { @@ -230,18 +232,12 @@ export class ExceptionlessE2EJourney { expect(this.projectToken).toBeTruthy(); expect(this.userToken).toBeTruthy(); - await this.e2eApi.submitEvent( - this.projectId!, - this.projectToken!, - createRepresentativeEvent({ - appUrl: this.e2eApi.environment.appUrl, - message: this.message, - referenceId: this.referenceId, - runId: this.e2eApi.environment.runId - }) - ); - - const event = await this.e2eApi.pollForEventByReference(this.userToken!, this.projectId!, this.referenceId); + const event = await seedRepresentativeEvent(this.e2eApi, this.userToken!, { + message: this.message, + projectId: this.projectId!, + projectToken: this.projectToken!, + referenceId: this.referenceId + }); expect(event.reference_id).toBe(this.referenceId); expect(event.stack_id).toBeTruthy(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts b/src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts index c1dbc2243d..7be56b4796 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts @@ -43,6 +43,21 @@ export function getVisibleText(page: Page, text: RegExp | string): Locator { return page.getByText(text).filter({ visible: true }).first(); } +export async function navigateToSidebarView(page: Page, parentName: string, childName: string): Promise { + const parentLink = page.getByRole('link', { exact: true, name: parentName }).filter({ visible: true }).first(); + if (await parentLink.isVisible()) { + await parentLink.click(); + return; + } + + const childLink = page.getByRole('link', { exact: true, name: childName }).filter({ visible: true }).first(); + if (!(await childLink.isVisible())) { + await page.getByRole('button', { exact: true, name: parentName }).filter({ visible: true }).first().click(); + } + + await childLink.click(); +} + export async function selectProjectType(page: Page, optionName: string): Promise { await page.getByRole('button', { name: /Please select a project type|Command Line:/ }).click(); const option = page.getByRole('option', { name: optionName }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/synthetic-event.ts b/src/Exceptionless.Web/ClientApp/e2e/support/synthetic-event.ts index 6d717789fc..0c3e12b01b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/synthetic-event.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/synthetic-event.ts @@ -7,6 +7,12 @@ interface RepresentativeEventOptions { runId: string; } +interface SessionEventOptions { + identity: string; + name: string; + sessionId: string; +} + export function createRepresentativeEvent({ appUrl, message, referenceId, runId }: RepresentativeEventOptions): Record { return { data: { @@ -30,6 +36,22 @@ export function createRepresentativeEvent({ appUrl, message, referenceId, runId }; } +export function createSessionEvent({ identity, name, sessionId }: SessionEventOptions): Record { + return { + data: { + '@user': { + identity, + name + } + }, + message: `Session for ${name}`, + reference_id: sessionId, + source: 'playwright-e2e', + type: 'session', + value: 0 + }; +} + function createSyntheticRequest(appUrl: string, referenceId: string): Record { const requestUrl = new URL('/e2e/onboarding', `${appUrl}/`); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts new file mode 100644 index 0000000000..10f8313c06 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/authentication.e2e.ts @@ -0,0 +1,45 @@ +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; + +test.use({ e2eUseGeneratedUser: true }); + +test('user can recover from a failed login, restore the session, and log out @signup', async ({ e2eScenario, page }) => { + await test.step('show an actionable error for invalid credentials', async () => { + await page.goto('/next/login'); + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByPlaceholder('Enter password').fill(`${E2E_TEST_PASSWORD}-invalid`); + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + + await expect(page.getByText('Invalid email or password', { exact: true })).toBeVisible(); + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + }); + + await test.step('log in through the form', async () => { + await page.getByPlaceholder('Enter password').fill(E2E_TEST_PASSWORD); + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + + await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/next\/stack(?:[?#]|$)/); + }); + + await test.step('restore the authenticated application after a reload', async () => { + await page.reload(); + + await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/next\/stack(?:[?#]|$)/); + }); + + await test.step('log out through the user menu', async () => { + await page.getByRole('button').filter({ hasText: e2eScenario.email }).filter({ visible: true }).first().click(); + await page.getByRole('menuitem', { exact: true, name: 'Log Out' }).click(); + + await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible(); + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + }); + + await test.step('redirect a signed-out user away from a protected route', async () => { + await page.goto('/next/stack'); + + await expect(page.getByRole('button', { exact: true, name: 'Login' })).toBeVisible(); + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/event-load-recovery.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-load-recovery.e2e.ts new file mode 100644 index 0000000000..ee030ac488 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/event-load-recovery.e2e.ts @@ -0,0 +1,36 @@ +import { expect, test } from '../fixtures/e2e-test'; +import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; +import { getVisibleText } from '../support/page-helpers'; + +test('operator can refresh Events after a transient load failure', async ({ e2eApi, e2eScenario, page }) => { + const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); + let failedOnce = false; + + await journey.submitRepresentativeEvent(); + + await page.route(`**/api/v2/organizations/${e2eScenario.organizationId}/events*`, async (route) => { + const url = new URL(route.request().url()); + if (!failedOnce && url.pathname.endsWith(`/organizations/${e2eScenario.organizationId}/events`)) { + failedOnce = true; + await route.fulfill({ + body: JSON.stringify({ status: 503, title: 'Temporary test failure' }), + contentType: 'application/problem+json', + status: 503 + }); + return; + } + + await route.continue(); + }); + + await test.step('encounter a transient Events request failure', async () => { + await page.goto(`/next/event?reference=${encodeURIComponent(e2eScenario.referenceId)}&time=all`); + await expect.poll(() => failedOnce).toBeTruthy(); + await expect(page.getByTitle('Refresh results')).toBeVisible(); + }); + + await test.step('recover through the visible refresh control', async () => { + await page.getByTitle('Refresh results').click(); + await expect(getVisibleText(page, e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/invitation-acceptance.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/invitation-acceptance.e2e.ts new file mode 100644 index 0000000000..528c7fc263 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/invitation-acceptance.e2e.ts @@ -0,0 +1,54 @@ +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; +import { getUserToken, waitForEmailValidation } from '../support/page-helpers'; + +test.skip(process.env.E2E_ENV === 'production', 'Invitation acceptance requires local Mailpit.'); + +test('invited user can accept an organization invitation @signup', async ({ browser, e2eApi, e2eScenario, page }) => { + const invitedEmail = `invited-${e2eScenario.run}@exceptionless.test`.toLowerCase(); + let invitedUserToken: string | undefined; + + try { + await test.step('invite a user through organization settings', async () => { + await page.goto(`/next/organization/${e2eScenario.organizationId}/users`); + await page.getByTitle('Invite User').click(); + + const dialog = page.getByRole('alertdialog', { name: 'Invite User' }); + await dialog.getByLabel('Email Address').fill(invitedEmail); + await dialog.getByRole('button', { name: 'Invite User' }).click(); + + await expect(page.getByText('User invited successfully')).toBeVisible(); + }); + + const inviteToken = await test.step('read the invitation from local mail', async () => { + return await e2eApi.pollForMailToken(invitedEmail, 'signup'); + }); + + await test.step('sign up through the invitation route', async () => { + const invitedContext = await browser.newContext({ baseURL: e2eApi.environment.appUrl, ignoreHTTPSErrors: true }); + const invitedPage = await invitedContext.newPage(); + + try { + await invitedPage.goto(`/next/signup?token=${encodeURIComponent(inviteToken)}`); + await invitedPage.getByLabel('Name', { exact: true }).fill(`Invited User ${e2eScenario.run}`); + await invitedPage.getByLabel('Email', { exact: true }).fill(invitedEmail); + await waitForEmailValidation(invitedPage); + await invitedPage.getByLabel('Password', { exact: true }).fill(E2E_TEST_PASSWORD); + await invitedPage.getByRole('button', { name: 'Create My Account' }).click(); + + invitedUserToken = await getUserToken(invitedPage); + await expect(invitedPage).toHaveURL(/\/next\/project\/add(?:[?#]|$)/, { timeout: 30_000 }); + await expect(invitedPage.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + await expect(invitedPage.getByRole('button').filter({ hasText: e2eScenario.organizationName }).filter({ visible: true }).first()).toBeVisible(); + } finally { + await invitedContext.close(); + } + }); + } finally { + if (invitedUserToken) { + await e2eApi.deleteOrganizationUser(e2eScenario.userToken, e2eScenario.organizationId, invitedEmail); + await e2eApi.waitForOrganizationNotListed(invitedUserToken, e2eScenario.organizationId); + await e2eApi.deleteCurrentUser(invitedUserToken); + await e2eApi.waitForCurrentUserDeleted(invitedUserToken); + } + } +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/operator-navigation.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/operator-navigation.e2e.ts new file mode 100644 index 0000000000..c41f6ee291 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/operator-navigation.e2e.ts @@ -0,0 +1,54 @@ +import { expect, test } from '../fixtures/e2e-test'; +import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; +import { getVisibleRow, getVisibleText, navigateToSidebarView } from '../support/page-helpers'; + +test('operator can navigate from event discovery to event and stack details through the UI', async ({ e2eApi, e2eScenario, page }) => { + const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); + + await test.step('seed a representative event', async () => { + await journey.submitRepresentativeEvent(); + }); + + await test.step('open event details from the Events table', async () => { + await page.goto('/next/stack'); + await navigateToSidebarView(page, 'Events', 'Errors'); + await expect(page).toHaveURL(/\/next\/event(?:\/errors)?(?:[?#]|$)/); + + await page.goto(`/next/event?reference=${encodeURIComponent(journey.referenceId)}&time=all`); + const eventRow = getVisibleRow(page, journey.message); + await expect(eventRow).toBeVisible({ timeout: 30_000 }); + await eventRow.click(); + + const eventSheet = page.getByRole('dialog', { name: 'Event' }); + await expect(eventSheet).toBeVisible(); + await expect(eventSheet.getByText(journey.message).filter({ visible: true }).first()).toBeVisible(); + await eventSheet.getByRole('link', { name: 'Open details in new window' }).click(); + + await expect(page).toHaveURL(new RegExp(`/next/stack/${journey.stackId}/event/${journey.eventId}(?:[?#]|$)`)); + await expect(getVisibleText(page, journey.message)).toBeVisible(); + }); + + await test.step('move from the event to its stack occurrences', async () => { + await page.getByRole('button', { name: 'Show all events' }).click(); + + await expect(page).toHaveURL(new RegExp(`[?&]stack=${journey.stackId}(?:&|$)`)); + await expect(getVisibleText(page, journey.message)).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('open stack details from the Stacks table', async () => { + await navigateToSidebarView(page, 'Stacks', 'Most Frequent Errors'); + await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible(); + + const stackRow = getVisibleRow(page, journey.message); + await expect(stackRow).toBeVisible({ timeout: 30_000 }); + await stackRow.click(); + + const stackSheet = page.getByRole('dialog', { name: 'Stack' }); + await expect(stackSheet).toBeVisible(); + await expect(stackSheet.getByText(journey.message).filter({ visible: true }).first()).toBeVisible(); + await stackSheet.getByRole('link', { name: 'Open details in new window' }).click(); + + await expect(page).toHaveURL(new RegExp(`/next/stack/${journey.stackId}(?:/event/${journey.eventId})?(?:[?#]|$)`)); + await expect(getVisibleText(page, journey.message)).toBeVisible(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/organization-switching.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-switching.e2e.ts new file mode 100644 index 0000000000..6ed04331e7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/organization-switching.e2e.ts @@ -0,0 +1,50 @@ +import { expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { getVisibleText } from '../support/page-helpers'; + +test('operator can switch organizations without leaking event data', async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => { + await test.step('seed a distinct event in each organization', async () => { + await Promise.all([ + seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }), + seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eSecondaryOrganization.message, + projectId: e2eSecondaryOrganization.projectId, + projectToken: e2eSecondaryOrganization.projectToken, + referenceId: e2eSecondaryOrganization.referenceId + }) + ]); + }); + + await test.step('show only the active organization data', async () => { + await page.goto('/next/event?time=all'); + + await expect(getVisibleText(page, e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eSecondaryOrganization.message)).toBeHidden(); + }); + + await test.step('switch organizations through the sidebar and persist the selection', async () => { + await page.getByRole('button', { name: `Switch organization. Current organization: ${e2eScenario.organizationName}` }).click(); + await page.getByRole('menuitem').filter({ hasText: e2eSecondaryOrganization.organizationName }).click(); + await expect + .poll(async () => page.evaluate(() => JSON.parse(window.localStorage.getItem('organization') ?? 'null'))) + .toBe(e2eSecondaryOrganization.organizationId); + + await page.goto('/next/event?time=all'); + + await expect(getVisibleText(page, e2eSecondaryOrganization.message)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eScenario.message)).toBeHidden(); + + await page.reload(); + + await expect( + page.getByRole('button', { name: `Switch organization. Current organization: ${e2eSecondaryOrganization.organizationName}` }) + ).toBeVisible(); + await expect(getVisibleText(page, e2eSecondaryOrganization.message)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eScenario.message)).toBeHidden(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts new file mode 100644 index 0000000000..81f9cb046c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts @@ -0,0 +1,39 @@ +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; + +const RESET_PASSWORD = `${E2E_TEST_PASSWORD}-reset`; + +test.skip(process.env.E2E_ENV === 'production', 'Password recovery requires local Mailpit.'); +test.use({ e2eCleanupPassword: RESET_PASSWORD, e2eUseGeneratedUser: true }); + +test('user can reset a forgotten password and log in @signup', async ({ e2eApi, e2eScenario, page }) => { + await test.step('request a password reset through the UI', async () => { + await page.goto('/next/forgot-password'); + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByRole('button', { name: 'Send Reset Email' }).click(); + + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + await expect(page.getByText('Please check your inbox for the password reset email.')).toBeVisible(); + }); + + const resetToken = await test.step('read the reset link from local mail', async () => { + return await e2eApi.pollForMailToken(e2eScenario.email, 'reset-password'); + }); + + await test.step('change the password through the emailed route', async () => { + await page.goto(`/next/reset-password/${encodeURIComponent(resetToken)}`); + await page.getByLabel('New Password', { exact: true }).fill(RESET_PASSWORD); + await page.getByLabel('Confirm Password', { exact: true }).fill(RESET_PASSWORD); + await page.getByRole('button', { name: 'Change Password' }).click(); + + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + await expect(page.getByText('You have successfully changed your password.')).toBeVisible(); + }); + + await test.step('log in with the new password', async () => { + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByPlaceholder('Enter password').fill(RESET_PASSWORD); + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + + await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/project-api-key-configuration.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/project-api-key-configuration.e2e.ts new file mode 100644 index 0000000000..dcee6f469d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-api-key-configuration.e2e.ts @@ -0,0 +1,36 @@ +import { expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; + +test('operator can manage project API keys and follow first-event redirect', async ({ e2eApi, e2eScenario, page }) => { + await test.step('open API Keys and create another client key', async () => { + await page.goto(`/next/project/${e2eScenario.projectId}/api-keys`); + + await expect(page.getByRole('heading', { name: `${e2eScenario.projectName} Settings` })).toBeVisible(); + await expect(page.getByRole('cell', { name: e2eScenario.projectToken })).toBeVisible(); + await page.getByTitle('Add API Key').click(); + await expect(page.getByText('API Key added successfully')).toBeVisible(); + await expect(page.getByRole('row')).toHaveCount(3); + }); + + await test.step('open client setup from API Keys', async () => { + await page.locator(`a[href="/next/project/${e2eScenario.projectId}/configure"]`).filter({ hasText: 'Client setup' }).last().click(); + + await expect(page).toHaveURL(new RegExp(`/next/project/${e2eScenario.projectId}/configure(?:[?#]|$)`)); + await expect(page.getByRole('heading', { name: `Send Events to ${e2eScenario.projectName}` })).toBeVisible(); + }); + + await test.step('redirect to project Events when the first event arrives', async () => { + await page.goto(`/next/project/${e2eScenario.projectId}/configure?redirect=true&type=bash`); + await expect(page.getByText('Waiting for your first event')).toBeVisible(); + await expect(page.getByText(e2eScenario.projectToken, { exact: false }).first()).toBeVisible(); + + await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }); + + await expect(page).toHaveURL(new RegExp(`/next/event[?].*project=${e2eScenario.projectId}`), { timeout: 30_000 }); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/project-scoping.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/project-scoping.e2e.ts new file mode 100644 index 0000000000..ae52716f2e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/project-scoping.e2e.ts @@ -0,0 +1,58 @@ +import { expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { getVisibleRow, getVisibleText } from '../support/page-helpers'; + +test('operator can scope Events to a project and clear the project filter', async ({ e2eApi, e2eScenario, e2eSecondaryProject, page }) => { + await test.step('seed events in two projects', async () => { + await Promise.all([ + seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }), + seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eSecondaryProject.message, + projectId: e2eSecondaryProject.projectId, + projectToken: e2eSecondaryProject.projectToken, + referenceId: e2eSecondaryProject.referenceId + }) + ]); + }); + + await test.step('show events from both projects before scoping', async () => { + await page.goto('/next/event?time=all'); + + await expect(getVisibleText(page, e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eSecondaryProject.message)).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('scope to a project from event details', async () => { + await getVisibleRow(page, e2eScenario.message).click(); + const eventSheet = page.getByRole('dialog', { name: 'Event' }); + await expect(eventSheet).toBeVisible(); + await eventSheet.getByTitle(`Filter project:${e2eScenario.projectId}`).click(); + + await expect(page).toHaveURL(new RegExp(`[?&]project=${e2eScenario.projectId}(?:&|$)`)); + await expect(getVisibleText(page, e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eSecondaryProject.message)).toBeHidden({ timeout: 30_000 }); + }); + + await test.step('persist project scope through reload, then clear it', async () => { + await page.reload(); + + await expect(page.getByRole('button', { name: new RegExp(`^Project\\s+${escapeRegExp(e2eScenario.projectName)}`) })).toBeVisible(); + await expect(getVisibleText(page, e2eScenario.message)).toBeVisible({ timeout: 30_000 }); + await expect(getVisibleText(page, e2eSecondaryProject.message)).toBeHidden(); + + await page.getByRole('button', { name: new RegExp(`^Project\\s+${escapeRegExp(e2eScenario.projectName)}`) }).click(); + await page.getByRole('button', { name: 'Remove filter' }).click(); + + await expect(page).not.toHaveURL(/[?&]project=/); + await expect(getVisibleText(page, e2eSecondaryProject.message)).toBeVisible({ timeout: 30_000 }); + }); +}); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts new file mode 100644 index 0000000000..c40f876110 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/session-investigation.e2e.ts @@ -0,0 +1,31 @@ +import { expect, test } from '../fixtures/e2e-test'; +import { getVisibleRow, getVisibleText } from '../support/page-helpers'; +import { createSessionEvent } from '../support/synthetic-event'; + +test('operator can find and inspect a user session', async ({ e2eApi, e2eScenario, page }) => { + const sessionId = `${e2eScenario.referenceId}-session`; + const identity = `session-${e2eScenario.run}@exceptionless.test`; + const name = `Session User ${e2eScenario.run}`; + + await test.step('seed a representative session', async () => { + await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, createSessionEvent({ identity, name, sessionId })); + await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, sessionId); + }); + + await test.step('open the session from the Sessions table', async () => { + await page.goto('/next/sessions?time=all'); + await expect(page.getByRole('heading', { name: 'Sessions' })).toBeVisible(); + + const sessionRow = getVisibleRow(page, name, identity); + await expect(sessionRow).toBeVisible({ timeout: 30_000 }); + await sessionRow.click(); + + const eventSheet = page.getByRole('dialog', { name: 'Event' }); + await expect(eventSheet).toBeVisible(); + await expect(eventSheet.getByText(name).filter({ visible: true }).first()).toBeVisible(); + await eventSheet.getByRole('link', { name: 'Open details in new window' }).click(); + + await expect(page).toHaveURL(/\/next\/(?:event|stack\/[^/]+\/event)\//); + await expect(getVisibleText(page, identity)).toBeVisible(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index 831d34e76c..fee088c2f1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -117,6 +117,7 @@ {#snippet child({ props })}