From f22a06bc21a842523962817b66f15c75aed9461e Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 16:01:14 -0500 Subject: [PATCH 01/14] Add Playwright E2E coverage --- .github/workflows/build.yaml | 121 ++++++- .github/workflows/copilot-setup-steps.yml | 4 +- .github/workflows/production-e2e.yml | 69 ++++ .gitignore | 1 + docs/playwright-e2e-tests.md | 54 ++++ .../ClientApp/e2e/fixtures/api-client.ts | 297 ++++++++++++++++++ .../ClientApp/e2e/fixtures/e2e-test.ts | 94 ++++++ .../ClientApp/e2e/fixtures/environment.ts | 78 +++++ .../ClientApp/e2e/index.test.ts | 7 - .../e2e/tests/basic-lifecycle.e2e.ts | 23 ++ .../ClientApp/e2e/tests/health.e2e.ts | 14 + src/Exceptionless.Web/ClientApp/package.json | 3 + .../ClientApp/playwright.config.ts | 41 ++- 13 files changed, 790 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/production-e2e.yml create mode 100644 docs/playwright-e2e-tests.md create mode 100644 src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts delete mode 100644 src/Exceptionless.Web/ClientApp/e2e/index.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/health.e2e.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 88dad7b377..0464a19d10 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -157,6 +157,125 @@ jobs: - name: Run Integration Tests run: echo "npm run test:integration" + test-e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET Core + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.* + dotnet-quality: ga + + - name: Install Aspire CLI + run: | + export PATH="$HOME/.dotnet/tools:$PATH" + if dotnet tool list --global | grep -Eiq '^aspire\.cli[[:space:]]'; then + dotnet tool update --global Aspire.Cli --version 13.3.0 + else + dotnet tool install --global Aspire.Cli --version 13.3.0 + fi + echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + aspire --version + + - name: Setup Node.js environment + uses: actions/setup-node@v6 + with: + node-version: 24 + + - uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Nuget Restore + run: dotnet restore ./Exceptionless.slnx + + - name: Build + run: dotnet build ./Exceptionless.slnx --no-restore --configuration Release + + - name: Install Client Npm Packages + working-directory: src/Exceptionless.Web/ClientApp + run: npm ci + + - name: Install Legacy Client Npm Packages + working-directory: src/Exceptionless.Web/ClientApp.angular + run: npm ci + + - name: Install Playwright Chromium + working-directory: src/Exceptionless.Web/ClientApp + run: npx playwright install chromium --with-deps + + - name: Start AppHost + run: | + aspire run --detach --format Json --non-interactive --nologo > aspire-run.json + + - name: Wait for Aspire Resources + run: | + aspire wait Api --status healthy --timeout 300 --non-interactive + aspire wait Jobs --status healthy --timeout 300 --non-interactive + aspire wait App --status up --timeout 300 --non-interactive + + - name: Verify E2E Endpoints + run: | + curl -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null + curl -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null + + - name: Run Playwright E2E Tests + working-directory: src/Exceptionless.Web/ClientApp + env: + E2E_APP_URL: https://web-ex.dev.localhost:7131 + E2E_EMAIL: admin@exceptionless.test + E2E_PASSWORD: tester + E2E_RUN_ID: ci-${{ github.run_id }}-${{ github.run_attempt }} + run: npm run test:e2e:ci + + - name: Stop AppHost + if: ${{ always() }} + run: | + aspire stop --all --non-interactive || true + + - name: Collect Aspire Logs + if: ${{ !cancelled() }} + run: | + mkdir -p aspire-logs + if [ -f aspire-run.json ]; then + cp aspire-run.json aspire-logs/ + fi + if [ -d "$HOME/.aspire/cli/logs" ]; then + cp -r "$HOME/.aspire/cli/logs/." aspire-logs/ + fi + + - name: Upload Playwright Report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v5 + with: + name: playwright-report + path: src/Exceptionless.Web/ClientApp/playwright-report/ + retention-days: 14 + + - name: Upload Playwright Test Results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v5 + with: + name: playwright-test-results + path: src/Exceptionless.Web/ClientApp/test-results/ + retention-days: 14 + + - name: Upload Aspire Logs + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v5 + with: + name: aspire-logs + path: aspire-logs/ + retention-days: 14 + docker-build: runs-on: ubuntu-latest needs: [version] @@ -223,7 +342,7 @@ jobs: docker-publish: if: ${{ needs.version.outputs.should_publish == 'true' }} runs-on: ubuntu-latest - needs: [version, docker-build, test-api, test-client] + needs: [version, docker-build, test-api, test-client, test-e2e] timeout-minutes: 30 env: VERSION: ${{ needs.version.outputs.version }} diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 99de9e2946..361b126af1 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -32,9 +32,9 @@ jobs: run: | export PATH="$HOME/.dotnet/tools:$PATH" if dotnet tool list --global | grep -Eiq '^aspire\.cli[[:space:]]'; then - dotnet tool update --global Aspire.Cli --version 13.2.4 + dotnet tool update --global Aspire.Cli --version 13.3.0 else - dotnet tool install --global Aspire.Cli --version 13.2.4 + dotnet tool install --global Aspire.Cli --version 13.3.0 fi echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" aspire --version diff --git a/.github/workflows/production-e2e.yml b/.github/workflows/production-e2e.yml new file mode 100644 index 0000000000..213462bed9 --- /dev/null +++ b/.github/workflows/production-e2e.yml @@ -0,0 +1,69 @@ +name: Production E2E Tests + +on: + workflow_dispatch: + schedule: + - cron: "0 8 * * *" + +permissions: + contents: read + +concurrency: + group: production-e2e + cancel-in-progress: false + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: src/Exceptionless.Web/ClientApp + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js environment + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install Npm Packages + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install chromium --with-deps + + - name: Run Production E2E Tests + env: + E2E_APP_URL: ${{ vars[format('{0}_{1}_{2}', 'E2E', 'PRODUCTION', 'APP_URL')] }} + E2E_EMAIL: ${{ secrets[format('{0}_{1}_{2}', 'E2E', 'PRODUCTION', 'EMAIL')] }} + E2E_ENV: production + E2E_PASSWORD: ${{ secrets[format('{0}_{1}_{2}', 'E2E', 'PRODUCTION', 'PASSWORD')] }} + E2E_RUN_ID: production-${{ github.run_id }}-${{ github.run_attempt }} + run: npm run test:e2e:prod + + - name: Write Summary + if: ${{ always() }} + run: | + echo "### Production E2E Tests" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- Run ID: production-${{ github.run_id }}-${{ github.run_attempt }}" >> $GITHUB_STEP_SUMMARY + echo "- App URL configured: ${{ vars[format('{0}_{1}_{2}', 'E2E', 'PRODUCTION', 'APP_URL')] != '' }}" >> $GITHUB_STEP_SUMMARY + + - name: Upload Playwright Report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v5 + with: + name: production-playwright-report + path: src/Exceptionless.Web/ClientApp/playwright-report/ + retention-days: 14 + + - name: Upload Playwright Test Results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v5 + with: + name: production-playwright-test-results + path: src/Exceptionless.Web/ClientApp/test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 912e01a38b..df76fbbc95 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ artifacts [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* coverage/ +src/Exceptionless.Web/ClientApp/playwright-report/ src/Exceptionless.Web/ClientApp/test-results/ # IDE / editor diff --git a/docs/playwright-e2e-tests.md b/docs/playwright-e2e-tests.md new file mode 100644 index 0000000000..5c985a9943 --- /dev/null +++ b/docs/playwright-e2e-tests.md @@ -0,0 +1,54 @@ +# Playwright E2E Tests + +The ClientApp Playwright tests validate the core Exceptionless lifecycle against either the local Aspire stack or the production site: + +1. Log in with a dedicated test account. +2. Create an isolated organization. +3. Create a project in that organization. +4. Submit a real event through the public API. +5. Verify the event through API and Svelte UI surfaces. +6. Delete the temporary organization during fixture teardown. + +## Local Run + +Start the full app stack first: + +```powershell +aspire run +``` + +Then run the E2E tests from the Svelte app folder: + +```powershell +cd src/Exceptionless.Web/ClientApp +npm run test:e2e:local +``` + +Local defaults target `https://web-ex.dev.localhost:7131` for the app and derive API calls from that origin at `/api/v2`. The default local credentials are `admin@exceptionless.test` / `tester`. + +## Environment Variables + +| Name | Description | +| -------------- | ------------------------------------------------------------------------- | +| `E2E_APP_URL` | Base URL for the app origin. API requests use this origin plus `/api/v2`. | +| `E2E_EMAIL` | Test account email address. | +| `E2E_PASSWORD` | Test account password. | +| `E2E_RUN_ID` | Optional identifier included in generated org, project, and event names. | +| `E2E_ENV` | Set to `production` to require explicit URL and credential variables. | + +## Production Nightly Policy + +Production E2E tests must use a dedicated service account stored in GitHub secrets. Each run creates one temporary organization and deletes it with the public organization delete API during teardown. Tokens and passwords must never be printed to logs or committed to the repository. + +## Test Style + +- Prefer user-visible locators such as `getByRole`, `getByLabel`, and `getByText`. +- Use Playwright fixtures for setup and cleanup so resources are removed even when assertions fail. +- Keep the org/project/event journey self-contained instead of splitting it into dependent tests. +- Use Playwright `request` for API setup and polling, and browser assertions for user-visible behavior. +- Use SvelteKit's current Playwright test filename convention: `*.e2e.{ts,js}`. +- Keep CI stable with Chromium-only E2E runs, retries, one worker, and traces on first retry. + +## SvelteKit Integration + +SvelteKit's current `sv add playwright` template adds `@playwright/test`, a `test:e2e` script, a `playwright.config.ts`, and test files that match `*.e2e.{ts,js}`. The template normally starts a built preview server with `npm run build && npm run preview`, but these tests intentionally use the Aspire AppHost instead because the Exceptionless UI depends on the API, Elasticsearch, Redis, and the job worker. CI installs Aspire CLI 13.3, starts AppHost with `aspire run --detach`, waits for `Api`, `Jobs`, and `App` with `aspire wait`, stops with `aspire stop`, and all E2E runs point Playwright and API setup at a single `E2E_APP_URL` origin. diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts new file mode 100644 index 0000000000..d75c02c8d7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -0,0 +1,297 @@ +import type { APIRequestContext, APIResponse } from '@playwright/test'; + +import type { E2EEnvironment } from './environment'; + +export interface E2EEvent { + id: string; + message?: string; + reference_id?: string; + stack_id?: string; + type?: string; +} + +export interface E2EOrganization { + id: string; + name: string; +} + +export interface E2EProject { + id: string; + name: string; + organization_id?: string; +} + +export interface E2EToken { + id: string; + notes?: string; +} + +interface TokenResult { + token: string; +} + +export class E2EApiClient { + constructor( + private readonly request: APIRequestContext, + readonly environment: E2EEnvironment + ) {} + + async createOrganization(token: string, name: string): Promise { + const response = await this.request.post(this.url('organizations'), { + data: { name }, + headers: this.authHeaders(token) + }); + + await expectStatus(response, [201], 'create organization'); + return toOrganization(await readJson(response)); + } + + async createProject(token: string, organizationId: string, name: string): Promise { + const response = await this.request.post(this.url('projects'), { + data: { + delete_bot_data_enabled: true, + name, + organization_id: organizationId + }, + headers: this.authHeaders(token) + }); + + await expectStatus(response, [201], 'create project'); + return toProject(await readJson(response)); + } + + async deleteOrganization(token: string, organizationId: string): Promise { + const response = await this.request.delete(this.url(`organizations/${organizationId}`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [202, 404], 'delete organization'); + return response.status(); + } + + async getAbout(): Promise> { + const response = await this.request.get(this.url('about')); + + await expectStatus(response, [200], 'get about'); + return toRecord(await readJson(response), 'about response'); + } + + async getEvent(token: string, eventId: string): Promise { + const response = await this.request.get(this.url(`events/${eventId}`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200], 'get event'); + return toEvent(await readJson(response)); + } + + async getEventsByReference(token: string, projectId: string, referenceId: string): Promise { + const response = await this.request.get(this.url(`projects/${projectId}/events/by-ref/${encodeURIComponent(referenceId)}`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200, 404], 'get events by reference'); + + if (response.status() === 404) { + return []; + } + + return toEventArray(await readJson(response)); + } + + async getOrganizations(token: string): Promise { + const response = await this.request.get(this.url('organizations'), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200], 'get organizations'); + return toOrganizationArray(await readJson(response)); + } + + async getProjectDefaultToken(token: string, projectId: string): Promise { + const response = await this.request.get(this.url(`projects/${projectId}/tokens/default`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200, 201], 'get default project token'); + return toToken(await readJson(response)); + } + + async login(): Promise { + const response = await this.request.post(this.url('auth/login'), { + data: { + email: this.environment.email, + password: this.environment.password + } + }); + + await expectStatus(response, [200], 'login'); + const result = toTokenResult(await readJson(response)); + + return result.token; + } + + async pollForEventByReference(token: string, projectId: string, referenceId: string, timeoutMs = 90_000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const events = await this.getEventsByReference(token, projectId, referenceId); + const event = events.find((item) => item.reference_id === referenceId) ?? events[0]; + + if (event?.id) { + return event; + } + + await delay(2_000); + } + + throw new Error(`Timed out waiting for E2E event with reference id ${referenceId}`); + } + + async submitEvent(projectId: string, projectToken: string, event: Record): Promise { + const response = await this.request.post(this.url(`projects/${projectId}/events?access_token=${encodeURIComponent(projectToken)}`), { + data: event + }); + + await expectStatus(response, [202], 'submit event'); + } + + async waitForOrganizationDeleted(token: string, organizationId: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const organizations = await this.getOrganizations(token); + if (!organizations.some((organization) => organization.id === organizationId)) { + return; + } + + await delay(1_000); + } + + throw new Error(`Timed out waiting for E2E organization ${organizationId} to disappear from the organization list`); + } + + private authHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}` + }; + } + + private url(path: string): string { + const normalizedPath = path.replace(/^\/+/, ''); + return `${this.environment.apiUrl}/${normalizedPath}`; + } +} + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function expectStatus(response: APIResponse, expectedStatuses: number[], operation: string): Promise { + if (expectedStatuses.includes(response.status())) { + return; + } + + throw new Error(`${operation} failed with status ${response.status()} ${response.statusText()}`); +} + +function getOptionalString(value: Record, key: string): string | undefined { + const property = value[key]; + return typeof property === 'string' ? property : undefined; +} + +function getRequiredString(value: Record, key: string, context: string): string { + const property = getOptionalString(value, key); + + if (!property) { + throw new Error(`${context} did not contain a string ${key} value`); + } + + return property; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function readJson(response: APIResponse): Promise { + const text = await response.text(); + + if (!text) { + return undefined; + } + + return JSON.parse(text) as unknown; +} + +function toEvent(value: unknown): E2EEvent { + const record = toRecord(value, 'event response'); + + return { + id: getRequiredString(record, 'id', 'event response'), + message: getOptionalString(record, 'message'), + reference_id: getOptionalString(record, 'reference_id'), + stack_id: getOptionalString(record, 'stack_id'), + type: getOptionalString(record, 'type') + }; +} + +function toEventArray(value: unknown): E2EEvent[] { + if (!Array.isArray(value)) { + throw new Error('events by reference response was not an array'); + } + + return value.map(toEvent); +} + +function toOrganization(value: unknown): E2EOrganization { + const record = toRecord(value, 'organization response'); + + return { + id: getRequiredString(record, 'id', 'organization response'), + name: getRequiredString(record, 'name', 'organization response') + }; +} + +function toOrganizationArray(value: unknown): E2EOrganization[] { + if (!Array.isArray(value)) { + throw new Error('organizations response was not an array'); + } + + return value.map(toOrganization); +} + +function toProject(value: unknown): E2EProject { + const record = toRecord(value, 'project response'); + + return { + id: getRequiredString(record, 'id', 'project response'), + name: getRequiredString(record, 'name', 'project response'), + organization_id: getOptionalString(record, 'organization_id') + }; +} + +function toRecord(value: unknown, context: string): Record { + if (!isRecord(value)) { + throw new Error(`${context} was not an object`); + } + + return value; +} + +function toToken(value: unknown): E2EToken { + const record = toRecord(value, 'token response'); + + return { + id: getRequiredString(record, 'id', 'token response'), + notes: getOptionalString(record, 'notes') + }; +} + +function toTokenResult(value: unknown): TokenResult { + const record = toRecord(value, 'token result'); + + return { + token: getRequiredString(record, 'token', 'token result') + }; +} diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts new file mode 100644 index 0000000000..ed75cd27b3 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -0,0 +1,94 @@ +import { test as base, expect, type Page, type TestInfo } from '@playwright/test'; + +import { E2EApiClient, type E2EEvent, type E2EOrganization, type E2EProject, type E2EToken } from './api-client'; +import { getE2EEnvironment } from './environment'; + +export interface E2EScenario { + event: E2EEvent & { message: string; reference_id: string }; + organization: E2EOrganization; + project: E2EProject; + projectToken: E2EToken; + referenceId: string; + runName: string; + userToken: string; +} + +interface E2EFixtures { + authenticatedPage: Page; + e2eApi: E2EApiClient; + e2eScenario: E2EScenario; +} + +export const test = base.extend({ + authenticatedPage: async ({ e2eScenario, page }, use) => { + await page.addInitScript( + ({ organizationId, token }) => { + window.localStorage.setItem('satellizer_token', token); + window.localStorage.setItem('organization', JSON.stringify(organizationId)); + }, + { + organizationId: e2eScenario.organization.id, + token: e2eScenario.userToken + } + ); + + await use(page); + }, + + e2eApi: async ({ request }, use) => { + await use(new E2EApiClient(request, getE2EEnvironment())); + }, + + e2eScenario: async ({ e2eApi }, use, testInfo) => { + const userToken = await e2eApi.login(); + const runName = createRunName(e2eApi.environment.runId, testInfo); + const organization = await e2eApi.createOrganization(userToken, `Playwright E2E ${runName}`); + + try { + const project = await e2eApi.createProject(userToken, organization.id, `E2E Project ${runName}`); + const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id); + const referenceId = `pw-e2e-${runName}`; + const message = `Playwright E2E event ${runName}`; + + await e2eApi.submitEvent(project.id, projectToken.id, { + data: { + e2e_reference: referenceId, + run_id: e2eApi.environment.runId + }, + message, + reference_id: referenceId, + source: 'playwright-e2e', + type: 'error' + }); + + const event = await e2eApi.pollForEventByReference(userToken, project.id, referenceId); + + await use({ + event: { + ...event, + message, + reference_id: referenceId + }, + organization, + project, + projectToken, + referenceId, + runName, + userToken + }); + } finally { + await e2eApi.deleteOrganization(userToken, organization.id); + await e2eApi.waitForOrganizationDeleted(userToken, organization.id); + } + } +}); + +export { expect }; + +function createRunName(runId: string, testInfo: TestInfo): string { + const rawName = [runId, `w${testInfo.workerIndex}`, `r${testInfo.retry}`, Date.now().toString(36)].join('-'); + return rawName + .replace(/[^a-zA-Z0-9_-]/g, '-') + .replace(/-+/g, '-') + .slice(0, 96); +} diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts new file mode 100644 index 0000000000..071901ed5d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts @@ -0,0 +1,78 @@ +const DEFAULT_APP_URL = 'https://web-ex.dev.localhost:7131'; +const DEFAULT_EMAIL = 'admin@exceptionless.test'; +const DEFAULT_PASSWORD = 'tester'; + +export interface E2EEnvironment { + apiUrl: string; + appUrl: string; + email: string; + isProduction: boolean; + password: string; + runId: string; +} + +export function getE2EEnvironment(): E2EEnvironment { + const isProduction = getOptionalEnv('E2E_ENV') === 'production'; + const appUrl = getOptionalEnv('E2E_APP_URL') ?? DEFAULT_APP_URL; + const email = getOptionalEnv('E2E_EMAIL') ?? (isProduction ? undefined : DEFAULT_EMAIL); + const password = getOptionalEnv('E2E_PASSWORD') ?? (isProduction ? undefined : DEFAULT_PASSWORD); + const runId = getOptionalEnv('E2E_RUN_ID') ?? getDefaultRunId(); + + const missing = [ + ['E2E_APP_URL', appUrl], + ['E2E_EMAIL', email], + ['E2E_PASSWORD', password] + ] + .filter(([, value]) => !value) + .map(([name]) => name); + + if (isProduction && missing.length > 0) { + throw new Error(`Production E2E tests require these environment variables: ${missing.join(', ')}`); + } + + if (!email || !password) { + throw new Error('E2E test credentials are required. Set E2E_EMAIL and E2E_PASSWORD.'); + } + + const normalizedAppUrl = normalizeUrl(appUrl); + + return { + apiUrl: getApiUrl(normalizedAppUrl), + appUrl: normalizedAppUrl, + email, + isProduction, + password, + runId: sanitizeRunId(runId) + }; +} + +function getApiUrl(appUrl: string): string { + return new URL('/api/v2', `${appUrl}/`).toString().replace(/\/+$/, ''); +} + +function getDefaultRunId(): string { + const githubRunId = getOptionalEnv('GITHUB_RUN_ID'); + const githubRunAttempt = getOptionalEnv('GITHUB_RUN_ATTEMPT'); + + if (githubRunId) { + return ['gh', githubRunId, githubRunAttempt].filter(Boolean).join('-'); + } + + return `local-${new Date().toISOString()}`; +} + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value ? value : undefined; +} + +function normalizeUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +function sanitizeRunId(value: string): string { + return value + .replace(/[^a-zA-Z0-9_-]/g, '-') + .replace(/-+/g, '-') + .slice(0, 80); +} diff --git a/src/Exceptionless.Web/ClientApp/e2e/index.test.ts b/src/Exceptionless.Web/ClientApp/e2e/index.test.ts deleted file mode 100644 index b7abc1c705..0000000000 --- a/src/Exceptionless.Web/ClientApp/e2e/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('default route should redirect to login page when unauthorized', async ({ page }) => { - await page.goto('/next'); - await page.waitForURL('/next/login'); - await expect(page.getByRole('button', { name: 'Login' })).toBeVisible(); -}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts new file mode 100644 index 0000000000..e88679700d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts @@ -0,0 +1,23 @@ +import { expect, test } from '../fixtures/e2e-test'; + +test('creates an isolated organization and verifies event ingestion in the app', async ({ authenticatedPage: page, e2eApi, e2eScenario }) => { + const event = await e2eApi.getEvent(e2eScenario.userToken, e2eScenario.event.id); + + expect(event.id).toBe(e2eScenario.event.id); + expect(event.reference_id).toBe(e2eScenario.referenceId); + + await page.goto('/next'); + await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); + await expect(page.getByText(e2eScenario.event.message)).toBeVisible({ timeout: 30_000 }); + + await page.goto('/next/issues'); + await expect(page.getByRole('heading', { name: 'Issues' })).toBeVisible(); + + await page.goto('/next/stream'); + await expect(page.getByRole('heading', { name: 'Event Stream' })).toBeVisible(); + await expect(page.getByText(e2eScenario.event.message)).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/next/event/${e2eScenario.event.id}`); + await expect(page.getByRole('heading', { name: 'Event Details' })).toBeVisible(); + await expect(page.getByTitle(e2eScenario.event.message, { exact: true })).toBeVisible({ timeout: 30_000 }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/health.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/health.e2e.ts new file mode 100644 index 0000000000..dbd5c4ed52 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/health.e2e.ts @@ -0,0 +1,14 @@ +import { expect, test } from '../fixtures/e2e-test'; + +test('api health endpoint responds', async ({ e2eApi }) => { + const about = await e2eApi.getAbout(); + + expect(about).toBeTruthy(); +}); + +test('default app route redirects anonymous users to login', async ({ page }) => { + await page.goto('/next'); + + await page.waitForURL('**/next/login'); + await expect(page.getByRole('button', { name: 'Login' })).toBeVisible(); +}); diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index f766e15d40..8dd6c93803 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -18,6 +18,9 @@ "generate-models": "node scripts/generate-api.mjs", "generate-templates": "swagger-typescript-api generate-templates -o api-templates", "test:e2e": "playwright test", + "test:e2e:ci": "playwright test --project=chromium", + "test:e2e:local": "cross-env E2E_APP_URL=https://web-ex.dev.localhost:7131 playwright test --project=chromium", + "test:e2e:prod": "cross-env E2E_ENV=production playwright test --project=chromium", "test:unit": "vitest run", "test": "npm run test:unit -- --run && npm run test:e2e", "storybook": "storybook dev -p 6006", diff --git a/src/Exceptionless.Web/ClientApp/playwright.config.ts b/src/Exceptionless.Web/ClientApp/playwright.config.ts index 612fe6f28b..d352f206c7 100644 --- a/src/Exceptionless.Web/ClientApp/playwright.config.ts +++ b/src/Exceptionless.Web/ClientApp/playwright.config.ts @@ -1,10 +1,39 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig, devices } from '@playwright/test'; + +const isCi = !!process.env.CI; +const appUrl = process.env.E2E_APP_URL || 'https://web-ex.dev.localhost:7131'; export default defineConfig({ - testDir: 'e2e', + expect: { + timeout: 10_000 + }, + + outputDir: 'test-results', + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'] + } + } + ], + + reporter: [['list'], ['html', { open: 'never' }], ['junit', { outputFile: 'test-results/e2e-junit-results.xml' }]], + + retries: isCi ? 2 : 0, + + testMatch: '**/*.e2e.{ts,js}', + + timeout: 120_000, + + use: { + baseURL: appUrl, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure', + trace: 'on-first-retry', + video: 'retain-on-failure' + }, - webServer: { - command: 'npm run build && npm run preview', - port: 4173 - } + workers: isCi ? 1 : undefined }); From d4370be136f40721ebed8656bed2fca6cc161e24 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 16:03:42 -0500 Subject: [PATCH 02/14] Use generic production E2E settings --- .github/workflows/production-e2e.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/production-e2e.yml b/.github/workflows/production-e2e.yml index 213462bed9..a836a266c1 100644 --- a/.github/workflows/production-e2e.yml +++ b/.github/workflows/production-e2e.yml @@ -37,10 +37,10 @@ jobs: - name: Run Production E2E Tests env: - E2E_APP_URL: ${{ vars[format('{0}_{1}_{2}', 'E2E', 'PRODUCTION', 'APP_URL')] }} - E2E_EMAIL: ${{ secrets[format('{0}_{1}_{2}', 'E2E', 'PRODUCTION', 'EMAIL')] }} + E2E_APP_URL: ${{ vars.E2E_APP_URL }} + E2E_EMAIL: ${{ secrets.E2E_EMAIL }} E2E_ENV: production - E2E_PASSWORD: ${{ secrets[format('{0}_{1}_{2}', 'E2E', 'PRODUCTION', 'PASSWORD')] }} + E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} E2E_RUN_ID: production-${{ github.run_id }}-${{ github.run_attempt }} run: npm run test:e2e:prod @@ -50,7 +50,7 @@ jobs: echo "### Production E2E Tests" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- Run ID: production-${{ github.run_id }}-${{ github.run_attempt }}" >> $GITHUB_STEP_SUMMARY - echo "- App URL configured: ${{ vars[format('{0}_{1}_{2}', 'E2E', 'PRODUCTION', 'APP_URL')] != '' }}" >> $GITHUB_STEP_SUMMARY + echo "- App URL configured: ${{ vars.E2E_APP_URL != '' }}" >> $GITHUB_STEP_SUMMARY - name: Upload Playwright Report if: ${{ !cancelled() }} From 707a39c5253320aba74342da3f6fa627a5a866c5 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 16:04:41 -0500 Subject: [PATCH 03/14] Rename E2E app setting --- .github/workflows/build.yaml | 2 +- .github/workflows/production-e2e.yml | 4 ++-- docs/playwright-e2e-tests.md | 4 ++-- src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts | 4 ++-- src/Exceptionless.Web/ClientApp/package.json | 2 +- src/Exceptionless.Web/ClientApp/playwright.config.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0464a19d10..7b11711b2e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -230,7 +230,7 @@ jobs: - name: Run Playwright E2E Tests working-directory: src/Exceptionless.Web/ClientApp env: - E2E_APP_URL: https://web-ex.dev.localhost:7131 + E2E_URL: https://web-ex.dev.localhost:7131 E2E_EMAIL: admin@exceptionless.test E2E_PASSWORD: tester E2E_RUN_ID: ci-${{ github.run_id }}-${{ github.run_attempt }} diff --git a/.github/workflows/production-e2e.yml b/.github/workflows/production-e2e.yml index a836a266c1..def016b6f2 100644 --- a/.github/workflows/production-e2e.yml +++ b/.github/workflows/production-e2e.yml @@ -37,7 +37,7 @@ jobs: - name: Run Production E2E Tests env: - E2E_APP_URL: ${{ vars.E2E_APP_URL }} + E2E_URL: ${{ vars.E2E_URL }} E2E_EMAIL: ${{ secrets.E2E_EMAIL }} E2E_ENV: production E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} @@ -50,7 +50,7 @@ jobs: echo "### Production E2E Tests" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- Run ID: production-${{ github.run_id }}-${{ github.run_attempt }}" >> $GITHUB_STEP_SUMMARY - echo "- App URL configured: ${{ vars.E2E_APP_URL != '' }}" >> $GITHUB_STEP_SUMMARY + echo "- E2E URL configured: ${{ vars.E2E_URL != '' }}" >> $GITHUB_STEP_SUMMARY - name: Upload Playwright Report if: ${{ !cancelled() }} diff --git a/docs/playwright-e2e-tests.md b/docs/playwright-e2e-tests.md index 5c985a9943..3dd3596651 100644 --- a/docs/playwright-e2e-tests.md +++ b/docs/playwright-e2e-tests.md @@ -30,7 +30,7 @@ Local defaults target `https://web-ex.dev.localhost:7131` for the app and derive | Name | Description | | -------------- | ------------------------------------------------------------------------- | -| `E2E_APP_URL` | Base URL for the app origin. API requests use this origin plus `/api/v2`. | +| `E2E_URL` | Base URL for the app origin. API requests use this origin plus `/api/v2`. | | `E2E_EMAIL` | Test account email address. | | `E2E_PASSWORD` | Test account password. | | `E2E_RUN_ID` | Optional identifier included in generated org, project, and event names. | @@ -51,4 +51,4 @@ Production E2E tests must use a dedicated service account stored in GitHub secre ## SvelteKit Integration -SvelteKit's current `sv add playwright` template adds `@playwright/test`, a `test:e2e` script, a `playwright.config.ts`, and test files that match `*.e2e.{ts,js}`. The template normally starts a built preview server with `npm run build && npm run preview`, but these tests intentionally use the Aspire AppHost instead because the Exceptionless UI depends on the API, Elasticsearch, Redis, and the job worker. CI installs Aspire CLI 13.3, starts AppHost with `aspire run --detach`, waits for `Api`, `Jobs`, and `App` with `aspire wait`, stops with `aspire stop`, and all E2E runs point Playwright and API setup at a single `E2E_APP_URL` origin. +SvelteKit's current `sv add playwright` template adds `@playwright/test`, a `test:e2e` script, a `playwright.config.ts`, and test files that match `*.e2e.{ts,js}`. The template normally starts a built preview server with `npm run build && npm run preview`, but these tests intentionally use the Aspire AppHost instead because the Exceptionless UI depends on the API, Elasticsearch, Redis, and the job worker. CI installs Aspire CLI 13.3, starts AppHost with `aspire run --detach`, waits for `Api`, `Jobs`, and `App` with `aspire wait`, stops with `aspire stop`, and all E2E runs point Playwright and API setup at a single `E2E_URL` origin. diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts index 071901ed5d..853c4bc932 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts @@ -13,13 +13,13 @@ export interface E2EEnvironment { export function getE2EEnvironment(): E2EEnvironment { const isProduction = getOptionalEnv('E2E_ENV') === 'production'; - const appUrl = getOptionalEnv('E2E_APP_URL') ?? DEFAULT_APP_URL; + const appUrl = getOptionalEnv('E2E_URL') ?? DEFAULT_APP_URL; const email = getOptionalEnv('E2E_EMAIL') ?? (isProduction ? undefined : DEFAULT_EMAIL); const password = getOptionalEnv('E2E_PASSWORD') ?? (isProduction ? undefined : DEFAULT_PASSWORD); const runId = getOptionalEnv('E2E_RUN_ID') ?? getDefaultRunId(); const missing = [ - ['E2E_APP_URL', appUrl], + ['E2E_URL', appUrl], ['E2E_EMAIL', email], ['E2E_PASSWORD', password] ] diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 8dd6c93803..b97614490d 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -19,7 +19,7 @@ "generate-templates": "swagger-typescript-api generate-templates -o api-templates", "test:e2e": "playwright test", "test:e2e:ci": "playwright test --project=chromium", - "test:e2e:local": "cross-env E2E_APP_URL=https://web-ex.dev.localhost:7131 playwright test --project=chromium", + "test:e2e:local": "cross-env E2E_URL=https://web-ex.dev.localhost:7131 playwright test --project=chromium", "test:e2e:prod": "cross-env E2E_ENV=production playwright test --project=chromium", "test:unit": "vitest run", "test": "npm run test:unit -- --run && npm run test:e2e", diff --git a/src/Exceptionless.Web/ClientApp/playwright.config.ts b/src/Exceptionless.Web/ClientApp/playwright.config.ts index d352f206c7..7c176346b1 100644 --- a/src/Exceptionless.Web/ClientApp/playwright.config.ts +++ b/src/Exceptionless.Web/ClientApp/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; const isCi = !!process.env.CI; -const appUrl = process.env.E2E_APP_URL || 'https://web-ex.dev.localhost:7131'; +const appUrl = process.env.E2E_URL || 'https://web-ex.dev.localhost:7131'; export default defineConfig({ expect: { From baff7d9e1adc6f44a1cd5475f13ceed5ef6b108d Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 17:36:59 -0500 Subject: [PATCH 04/14] Remove E2E docs file --- docs/playwright-e2e-tests.md | 54 ------------------------------------ 1 file changed, 54 deletions(-) delete mode 100644 docs/playwright-e2e-tests.md diff --git a/docs/playwright-e2e-tests.md b/docs/playwright-e2e-tests.md deleted file mode 100644 index 3dd3596651..0000000000 --- a/docs/playwright-e2e-tests.md +++ /dev/null @@ -1,54 +0,0 @@ -# Playwright E2E Tests - -The ClientApp Playwright tests validate the core Exceptionless lifecycle against either the local Aspire stack or the production site: - -1. Log in with a dedicated test account. -2. Create an isolated organization. -3. Create a project in that organization. -4. Submit a real event through the public API. -5. Verify the event through API and Svelte UI surfaces. -6. Delete the temporary organization during fixture teardown. - -## Local Run - -Start the full app stack first: - -```powershell -aspire run -``` - -Then run the E2E tests from the Svelte app folder: - -```powershell -cd src/Exceptionless.Web/ClientApp -npm run test:e2e:local -``` - -Local defaults target `https://web-ex.dev.localhost:7131` for the app and derive API calls from that origin at `/api/v2`. The default local credentials are `admin@exceptionless.test` / `tester`. - -## Environment Variables - -| Name | Description | -| -------------- | ------------------------------------------------------------------------- | -| `E2E_URL` | Base URL for the app origin. API requests use this origin plus `/api/v2`. | -| `E2E_EMAIL` | Test account email address. | -| `E2E_PASSWORD` | Test account password. | -| `E2E_RUN_ID` | Optional identifier included in generated org, project, and event names. | -| `E2E_ENV` | Set to `production` to require explicit URL and credential variables. | - -## Production Nightly Policy - -Production E2E tests must use a dedicated service account stored in GitHub secrets. Each run creates one temporary organization and deletes it with the public organization delete API during teardown. Tokens and passwords must never be printed to logs or committed to the repository. - -## Test Style - -- Prefer user-visible locators such as `getByRole`, `getByLabel`, and `getByText`. -- Use Playwright fixtures for setup and cleanup so resources are removed even when assertions fail. -- Keep the org/project/event journey self-contained instead of splitting it into dependent tests. -- Use Playwright `request` for API setup and polling, and browser assertions for user-visible behavior. -- Use SvelteKit's current Playwright test filename convention: `*.e2e.{ts,js}`. -- Keep CI stable with Chromium-only E2E runs, retries, one worker, and traces on first retry. - -## SvelteKit Integration - -SvelteKit's current `sv add playwright` template adds `@playwright/test`, a `test:e2e` script, a `playwright.config.ts`, and test files that match `*.e2e.{ts,js}`. The template normally starts a built preview server with `npm run build && npm run preview`, but these tests intentionally use the Aspire AppHost instead because the Exceptionless UI depends on the API, Elasticsearch, Redis, and the job worker. CI installs Aspire CLI 13.3, starts AppHost with `aspire run --detach`, waits for `Api`, `Jobs`, and `App` with `aspire wait`, stops with `aspire stop`, and all E2E runs point Playwright and API setup at a single `E2E_URL` origin. From 6165288cf95a2077335afe1a2dc85dd7f5777eb7 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 17:37:44 -0500 Subject: [PATCH 05/14] Simplify Aspire CLI install --- .github/workflows/build.yaml | 6 +----- .github/workflows/copilot-setup-steps.yml | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7b11711b2e..fad8dc0090 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -174,11 +174,7 @@ jobs: - name: Install Aspire CLI run: | export PATH="$HOME/.dotnet/tools:$PATH" - if dotnet tool list --global | grep -Eiq '^aspire\.cli[[:space:]]'; then - dotnet tool update --global Aspire.Cli --version 13.3.0 - else - dotnet tool install --global Aspire.Cli --version 13.3.0 - fi + dotnet tool install --global Aspire.Cli --version 13.3.0 echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" aspire --version diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 361b126af1..2dcd8038bf 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -31,11 +31,7 @@ jobs: - name: Install Aspire CLI run: | export PATH="$HOME/.dotnet/tools:$PATH" - if dotnet tool list --global | grep -Eiq '^aspire\.cli[[:space:]]'; then - dotnet tool update --global Aspire.Cli --version 13.3.0 - else - dotnet tool install --global Aspire.Cli --version 13.3.0 - fi + dotnet tool install --global Aspire.Cli --version 13.3.0 echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" aspire --version From d248fc587cf28a3cb5608bab224b3ca03add9ee4 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 17:39:43 -0500 Subject: [PATCH 06/14] Hard-code production E2E URL --- .github/workflows/production-e2e.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/production-e2e.yml b/.github/workflows/production-e2e.yml index def016b6f2..2f6dde3da3 100644 --- a/.github/workflows/production-e2e.yml +++ b/.github/workflows/production-e2e.yml @@ -37,7 +37,7 @@ jobs: - name: Run Production E2E Tests env: - E2E_URL: ${{ vars.E2E_URL }} + E2E_URL: https://be.exceptionless.io E2E_EMAIL: ${{ secrets.E2E_EMAIL }} E2E_ENV: production E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} @@ -50,7 +50,7 @@ jobs: echo "### Production E2E Tests" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- Run ID: production-${{ github.run_id }}-${{ github.run_attempt }}" >> $GITHUB_STEP_SUMMARY - echo "- E2E URL configured: ${{ vars.E2E_URL != '' }}" >> $GITHUB_STEP_SUMMARY + echo "- E2E URL: https://be.exceptionless.io" >> $GITHUB_STEP_SUMMARY - name: Upload Playwright Report if: ${{ !cancelled() }} From 9a1a0b03a8b8bc4f7935ad920fd5878e438518a2 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 17:41:27 -0500 Subject: [PATCH 07/14] Remove E2E health test --- .../e2e/tests/{health.e2e.ts => anonymous-redirect.e2e.ts} | 6 ------ 1 file changed, 6 deletions(-) rename src/Exceptionless.Web/ClientApp/e2e/tests/{health.e2e.ts => anonymous-redirect.e2e.ts} (67%) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/health.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/anonymous-redirect.e2e.ts similarity index 67% rename from src/Exceptionless.Web/ClientApp/e2e/tests/health.e2e.ts rename to src/Exceptionless.Web/ClientApp/e2e/tests/anonymous-redirect.e2e.ts index dbd5c4ed52..42efdc036a 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/health.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/anonymous-redirect.e2e.ts @@ -1,11 +1,5 @@ import { expect, test } from '../fixtures/e2e-test'; -test('api health endpoint responds', async ({ e2eApi }) => { - const about = await e2eApi.getAbout(); - - expect(about).toBeTruthy(); -}); - test('default app route redirects anonymous users to login', async ({ page }) => { await page.goto('/next'); From 87252c1c4d537bec18dfc2e9f795d40410934c31 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 17:42:13 -0500 Subject: [PATCH 08/14] Remove redirect E2E test --- .../ClientApp/e2e/tests/anonymous-redirect.e2e.ts | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/anonymous-redirect.e2e.ts diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/anonymous-redirect.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/anonymous-redirect.e2e.ts deleted file mode 100644 index 42efdc036a..0000000000 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/anonymous-redirect.e2e.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { expect, test } from '../fixtures/e2e-test'; - -test('default app route redirects anonymous users to login', async ({ page }) => { - await page.goto('/next'); - - await page.waitForURL('**/next/login'); - await expect(page.getByRole('button', { name: 'Login' })).toBeVisible(); -}); From fb5240bb085d716acb04573ca28e5426691f3209 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 18:01:11 -0500 Subject: [PATCH 09/14] Add UI-driven onboarding E2E coverage --- .github/workflows/build.yaml | 2 - .github/workflows/production-e2e.yml | 3 - .../ClientApp/e2e/fixtures/api-client.ts | 41 +++- .../ClientApp/e2e/fixtures/e2e-test.ts | 76 +------ .../ClientApp/e2e/fixtures/environment.ts | 22 +- .../e2e/tests/basic-lifecycle.e2e.ts | 208 ++++++++++++++++-- 6 files changed, 240 insertions(+), 112 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index fad8dc0090..2a4956c22e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -227,8 +227,6 @@ jobs: working-directory: src/Exceptionless.Web/ClientApp env: E2E_URL: https://web-ex.dev.localhost:7131 - E2E_EMAIL: admin@exceptionless.test - E2E_PASSWORD: tester E2E_RUN_ID: ci-${{ github.run_id }}-${{ github.run_attempt }} run: npm run test:e2e:ci diff --git a/.github/workflows/production-e2e.yml b/.github/workflows/production-e2e.yml index 2f6dde3da3..7c74d92af5 100644 --- a/.github/workflows/production-e2e.yml +++ b/.github/workflows/production-e2e.yml @@ -38,9 +38,6 @@ jobs: - name: Run Production E2E Tests env: E2E_URL: https://be.exceptionless.io - E2E_EMAIL: ${{ secrets.E2E_EMAIL }} - E2E_ENV: production - E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }} E2E_RUN_ID: production-${{ github.run_id }}-${{ github.run_attempt }} run: npm run test:e2e:prod diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index d75c02c8d7..4ebb0cce17 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -21,6 +21,12 @@ export interface E2EProject { organization_id?: string; } +export interface E2EStack { + id: string; + status?: string; + title?: string; +} + export interface E2EToken { id: string; notes?: string; @@ -69,6 +75,15 @@ export class E2EApiClient { return response.status(); } + async deleteProject(token: string, projectId: string): Promise { + const response = await this.request.delete(this.url(`projects/${projectId}`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [202, 404], 'delete project'); + return response.status(); + } + async getAbout(): Promise> { const response = await this.request.get(this.url('about')); @@ -117,7 +132,20 @@ export class E2EApiClient { return toToken(await readJson(response)); } + async getStack(token: string, stackId: string): Promise { + const response = await this.request.get(this.url(`stacks/${stackId}`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200], 'get stack'); + 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.'); + } + const response = await this.request.post(this.url('auth/login'), { data: { email: this.environment.email, @@ -192,7 +220,8 @@ async function expectStatus(response: APIResponse, expectedStatuses: number[], o return; } - throw new Error(`${operation} failed with status ${response.status()} ${response.statusText()}`); + const body = await response.text(); + throw new Error(`${operation} failed with status ${response.status()} ${response.statusText()}${body ? `: ${body}` : ''}`); } function getOptionalString(value: Record, key: string): string | undefined { @@ -279,6 +308,16 @@ function toRecord(value: unknown, context: string): Record { return value; } +function toStack(value: unknown): E2EStack { + const record = toRecord(value, 'stack response'); + + return { + id: getRequiredString(record, 'id', 'stack response'), + status: getOptionalString(record, 'status'), + title: getOptionalString(record, 'title') + }; +} + function toToken(value: unknown): E2EToken { const record = toRecord(value, 'token response'); diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index ed75cd27b3..16a7ed011c 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -1,91 +1,21 @@ -import { test as base, expect, type Page, type TestInfo } from '@playwright/test'; +import { test as base, expect, type TestInfo } from '@playwright/test'; -import { E2EApiClient, type E2EEvent, type E2EOrganization, type E2EProject, type E2EToken } from './api-client'; +import { E2EApiClient } from './api-client'; import { getE2EEnvironment } from './environment'; -export interface E2EScenario { - event: E2EEvent & { message: string; reference_id: string }; - organization: E2EOrganization; - project: E2EProject; - projectToken: E2EToken; - referenceId: string; - runName: string; - userToken: string; -} - interface E2EFixtures { - authenticatedPage: Page; e2eApi: E2EApiClient; - e2eScenario: E2EScenario; } export const test = base.extend({ - authenticatedPage: async ({ e2eScenario, page }, use) => { - await page.addInitScript( - ({ organizationId, token }) => { - window.localStorage.setItem('satellizer_token', token); - window.localStorage.setItem('organization', JSON.stringify(organizationId)); - }, - { - organizationId: e2eScenario.organization.id, - token: e2eScenario.userToken - } - ); - - await use(page); - }, - e2eApi: async ({ request }, use) => { await use(new E2EApiClient(request, getE2EEnvironment())); - }, - - e2eScenario: async ({ e2eApi }, use, testInfo) => { - const userToken = await e2eApi.login(); - const runName = createRunName(e2eApi.environment.runId, testInfo); - const organization = await e2eApi.createOrganization(userToken, `Playwright E2E ${runName}`); - - try { - const project = await e2eApi.createProject(userToken, organization.id, `E2E Project ${runName}`); - const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id); - const referenceId = `pw-e2e-${runName}`; - const message = `Playwright E2E event ${runName}`; - - await e2eApi.submitEvent(project.id, projectToken.id, { - data: { - e2e_reference: referenceId, - run_id: e2eApi.environment.runId - }, - message, - reference_id: referenceId, - source: 'playwright-e2e', - type: 'error' - }); - - const event = await e2eApi.pollForEventByReference(userToken, project.id, referenceId); - - await use({ - event: { - ...event, - message, - reference_id: referenceId - }, - organization, - project, - projectToken, - referenceId, - runName, - userToken - }); - } finally { - await e2eApi.deleteOrganization(userToken, organization.id); - await e2eApi.waitForOrganizationDeleted(userToken, organization.id); - } } }); export { expect }; -function createRunName(runId: string, testInfo: TestInfo): string { +export function createRunName(runId: string, testInfo: TestInfo): string { const rawName = [runId, `w${testInfo.workerIndex}`, `r${testInfo.retry}`, Date.now().toString(36)].join('-'); return rawName .replace(/[^a-zA-Z0-9_-]/g, '-') diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts index 853c4bc932..6ccaf78e5a 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts @@ -1,39 +1,27 @@ const DEFAULT_APP_URL = 'https://web-ex.dev.localhost:7131'; -const DEFAULT_EMAIL = 'admin@exceptionless.test'; -const DEFAULT_PASSWORD = 'tester'; export interface E2EEnvironment { apiUrl: string; appUrl: string; - email: string; + email?: string; isProduction: boolean; - password: string; + password?: string; runId: string; } export function getE2EEnvironment(): E2EEnvironment { const isProduction = getOptionalEnv('E2E_ENV') === 'production'; const appUrl = getOptionalEnv('E2E_URL') ?? DEFAULT_APP_URL; - const email = getOptionalEnv('E2E_EMAIL') ?? (isProduction ? undefined : DEFAULT_EMAIL); - const password = getOptionalEnv('E2E_PASSWORD') ?? (isProduction ? undefined : DEFAULT_PASSWORD); + const email = getOptionalEnv('E2E_EMAIL'); + const password = getOptionalEnv('E2E_PASSWORD'); const runId = getOptionalEnv('E2E_RUN_ID') ?? getDefaultRunId(); - const missing = [ - ['E2E_URL', appUrl], - ['E2E_EMAIL', email], - ['E2E_PASSWORD', password] - ] - .filter(([, value]) => !value) - .map(([name]) => name); + const missing = [['E2E_URL', appUrl]].filter(([, value]) => !value).map(([name]) => name); if (isProduction && missing.length > 0) { throw new Error(`Production E2E tests require these environment variables: ${missing.join(', ')}`); } - if (!email || !password) { - throw new Error('E2E test credentials are required. Set E2E_EMAIL and E2E_PASSWORD.'); - } - const normalizedAppUrl = normalizeUrl(appUrl); return { diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts index e88679700d..1822939a5b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts @@ -1,23 +1,199 @@ -import { expect, test } from '../fixtures/e2e-test'; +import type { Page } from '@playwright/test'; -test('creates an isolated organization and verifies event ingestion in the app', async ({ authenticatedPage: page, e2eApi, e2eScenario }) => { - const event = await e2eApi.getEvent(e2eScenario.userToken, e2eScenario.event.id); +import { createRunName, expect, test } from '../fixtures/e2e-test'; - expect(event.id).toBe(e2eScenario.event.id); - expect(event.reference_id).toBe(e2eScenario.referenceId); +const PASSWORD = 'tester'; - await page.goto('/next'); - await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); - await expect(page.getByText(e2eScenario.event.message)).toBeVisible({ timeout: 30_000 }); +test('new user can onboard a project, receive an event, and triage the stack', async ({ e2eApi, page }, testInfo) => { + const run = createRunName(e2eApi.environment.runId, testInfo); + const userName = `Playwright User ${run}`; + const email = `playwright-${run}@exceptionless.test`.toLowerCase(); + const organizationName = `Playwright Org ${run}`; + const projectName = `Playwright Project ${run}`; + const referenceId = `pw-e2e-${run}`; + const message = `Playwright onboarding event ${run}`; - await page.goto('/next/issues'); - await expect(page.getByRole('heading', { name: 'Issues' })).toBeVisible(); + let organizationId: string | undefined; + let projectId: string | undefined; + let userToken: string | undefined; - await page.goto('/next/stream'); - await expect(page.getByRole('heading', { name: 'Event Stream' })).toBeVisible(); - await expect(page.getByText(e2eScenario.event.message)).toBeVisible({ timeout: 30_000 }); + try { + await test.step('sign up and create the organization in the UI', async () => { + await page.goto('/next/signup'); - await page.goto(`/next/event/${e2eScenario.event.id}`); - await expect(page.getByRole('heading', { name: 'Event Details' })).toBeVisible(); - await expect(page.getByTitle(e2eScenario.event.message, { exact: true })).toBeVisible({ timeout: 30_000 }); + await page.getByLabel('Name').fill(userName); + await page.getByLabel('Email').fill(email); + await waitForEmailValidation(page); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Create My Account' }).click(); + + await expect(page.getByRole('heading', { name: 'Add Organization' })).toBeVisible({ timeout: 30_000 }); + userToken = await getUserToken(page); + + await page.getByLabel('Organization Name').fill(organizationName); + await page.getByRole('button', { name: 'Add Organization' }).click(); + + await page.waitForURL(/\/next\/organization\/[^/]+\/manage/, { timeout: 30_000 }); + organizationId = getIdFromUrl(page, /\/organization\/([^/]+)\/manage/); + await expect(page.getByRole('heading', { name: new RegExp(`${escapeRegExp(organizationName)} Settings`) })).toBeVisible(); + }); + + await test.step('create the first project and verify the configure token in the UI', async () => { + await page.getByRole('link', { name: 'add a new project' }).click(); + await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + + await page.getByLabel('Project Name').fill(projectName); + await page.getByRole('button', { name: 'Add Project' }).click(); + + await page.waitForURL(/\/next\/project\/[^/]+\/configure/, { timeout: 30_000 }); + projectId = getIdFromUrl(page, /\/project\/([^/]+)\/configure/); + await expect(page.getByRole('heading', { name: 'Download & Configure Project' })).toBeVisible(); + + await selectProjectType(page, 'Bash Shell'); + await expect(page.getByText('Execute the following in your shell.')).toBeVisible(); + await expect(page.getByText(/Authorization: Bearer (?!YOUR_API_KEY)[A-Za-z0-9_-]+/)).toBeVisible(); + }); + + const projectToken = await test.step('submit and index a representative event', async () => { + expect(userToken).toBeTruthy(); + expect(projectId).toBeTruthy(); + + const token = await getProjectTokenFromConfigurePage(page); + + await e2eApi.submitEvent(projectId!, token, { + data: { + '@environment': { + machine_name: 'playwright-runner', + process_name: 'e2e-tests' + }, + '@request': { + headers: { + 'User-Agent': ['Exceptionless Playwright E2E'] + }, + host: 'web-ex.dev.localhost', + http_method: 'GET', + is_secure: true, + path: '/e2e/onboarding', + port: 7131, + query_string: { + reference: referenceId + }, + user_agent: 'Exceptionless Playwright E2E' + }, + '@simple_error': { + message, + stack_trace: `Error: ${message}\n at onboarding-flow.spec.ts:42:13`, + type: 'PlaywrightOnboardingException' + }, + e2e_reference: referenceId, + run_id: e2eApi.environment.runId + }, + message, + reference_id: referenceId, + source: 'playwright-e2e', + type: 'error' + }); + + const event = await e2eApi.pollForEventByReference(userToken!, projectId!, referenceId); + expect(event.reference_id).toBe(referenceId); + expect(event.stack_id).toBeTruthy(); + + return { eventId: event.id, stackId: event.stack_id!, token }; + }); + + await test.step('find the event in the primary views and inspect details', async () => { + await page.goto('/next'); + await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); + await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto('/next/issues'); + await expect(page.getByRole('heading', { name: 'Issues' })).toBeVisible(); + await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto('/next/stream'); + await expect(page.getByRole('heading', { name: 'Event Stream' })).toBeVisible(); + await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/next/event/${projectToken.eventId}`); + await expect(page.getByRole('heading', { name: 'Event Details' })).toBeVisible(); + await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Exception' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Environment' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Extended Data' })).toBeVisible(); + }); + + await test.step('change stack status from the event detail view', async () => { + expect(userToken).toBeTruthy(); + + await page.getByRole('button', { exact: true, name: 'Open' }).click(); + await page.getByRole('menuitem', { name: 'Fixed' }).click(); + await expect(page.getByRole('heading', { name: 'Mark Stack As Fixed' })).toBeVisible(); + await page.getByLabel('Version').fill('1.0.0'); + await page.getByRole('button', { name: 'Mark Stack Fixed' }).click(); + + await expect.poll(async () => (await e2eApi.getStack(userToken!, projectToken.stackId)).status, { timeout: 30_000 }).toBe('fixed'); + await page.reload(); + await expect(page.getByRole('button', { name: 'Fixed' })).toBeVisible({ timeout: 30_000 }); + }); + } finally { + if (userToken && organizationId) { + if (projectId) { + await e2eApi.deleteProject(userToken, projectId); + } + + await e2eApi.deleteOrganization(userToken, organizationId); + await e2eApi.waitForOrganizationDeleted(userToken, organizationId); + } + } }); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getIdFromUrl(page: Page, pattern: RegExp): string { + const match = pattern.exec(new URL(page.url()).pathname); + if (!match?.[1]) { + throw new Error(`Could not extract id from ${page.url()}`); + } + + return match[1]; +} + +async function getProjectTokenFromConfigurePage(page: Page): Promise { + const text = await page.locator('body').innerText(); + const match = /Authorization: Bearer ([A-Za-z0-9_-]+)/.exec(text); + if (!match?.[1] || match[1] === 'YOUR_API_KEY') { + throw new Error('Configure page did not expose a project token.'); + } + + return match[1]; +} + +async function getUserToken(page: Page): Promise { + const token = await page.evaluate(() => window.localStorage.getItem('satellizer_token')); + if (!token) { + throw new Error('Signup did not persist an access token.'); + } + + return token; +} + +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 }); + + try { + await option.click({ timeout: 5_000 }); + } catch { + await page.keyboard.press('Enter'); + } +} + +async function waitForEmailValidation(page: Page): Promise { + await page + .getByLabel('Validating email') + .waitFor({ state: 'detached', timeout: 10_000 }) + .catch(() => undefined); +} From 43ab7e945f1f26224a4e0316ceaa4484c7a1cf0b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 20:27:55 -0500 Subject: [PATCH 10/14] Split E2E coverage into focused specs --- .../ClientApp/e2e/fixtures/api-client.ts | 15 + .../ClientApp/e2e/fixtures/e2e-test.ts | 69 +++++ .../ClientApp/e2e/fixtures/environment.ts | 6 +- .../e2e/support/exceptionless-journey.ts | 273 ++++++++++++++++++ .../e2e/tests/basic-lifecycle.e2e.ts | 199 ------------- .../ClientApp/e2e/tests/event-details.e2e.ts | 14 + .../e2e/tests/event-visibility.e2e.ts | 14 + .../ClientApp/e2e/tests/onboarding.e2e.ts | 18 ++ .../ClientApp/e2e/tests/stack-triage.e2e.ts | 14 + 9 files changed, 421 insertions(+), 201 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts delete mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/event-details.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/event-visibility.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/onboarding.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/stack-triage.e2e.ts diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 4ebb0cce17..5395db8d18 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -176,6 +176,21 @@ export class E2EApiClient { throw new Error(`Timed out waiting for E2E event with reference id ${referenceId}`); } + async signup(name: string, email: string, password: string): Promise { + const response = await this.request.post(this.url('auth/signup'), { + data: { + email, + name, + password + } + }); + + await expectStatus(response, [200], 'signup'); + const result = toTokenResult(await readJson(response)); + + return result.token; + } + async submitEvent(projectId: string, projectToken: string, event: Record): Promise { const response = await this.request.post(this.url(`projects/${projectId}/events?access_token=${encodeURIComponent(projectToken)}`), { data: event diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 16a7ed011c..b2ccf7f929 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -3,13 +3,82 @@ import { test as base, expect, type TestInfo } from '@playwright/test'; import { E2EApiClient } from './api-client'; import { getE2EEnvironment } from './environment'; +const PASSWORD = 'tester'; + +export interface E2EScenario { + email: string; + message: string; + organizationId: string; + organizationName: string; + projectId: string; + projectName: string; + projectToken: string; + referenceId: string; + run: string; + userName: string; + userToken: string; +} + interface E2EFixtures { e2eApi: E2EApiClient; + e2eScenario: E2EScenario; } export const test = base.extend({ e2eApi: async ({ request }, use) => { await use(new E2EApiClient(request, getE2EEnvironment())); + }, + + e2eScenario: async ({ e2eApi, page }, use, testInfo) => { + const run = createRunName(e2eApi.environment.runId, testInfo); + const userName = `Playwright User ${run}`; + const email = `playwright-${run}@exceptionless.test`.toLowerCase(); + const organizationName = `Playwright Org ${run}`; + const projectName = `Playwright Project ${run}`; + const referenceId = `pw-e2e-${run}`; + const message = `Playwright onboarding event ${run}`; + let organizationId: string | undefined; + let projectId: string | undefined; + let userToken: string | undefined; + + try { + userToken = e2eApi.environment.email && e2eApi.environment.password ? await e2eApi.login() : await e2eApi.signup(userName, email, PASSWORD); + const organization = await e2eApi.createOrganization(userToken, organizationName); + organizationId = organization.id; + const project = await e2eApi.createProject(userToken, organization.id, projectName); + projectId = project.id; + const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id); + + await page.addInitScript( + ({ organizationId, token }) => { + window.localStorage.setItem('satellizer_token', token); + window.localStorage.setItem('organization', JSON.stringify(organizationId)); + }, + { organizationId: organization.id, token: userToken } + ); + + await use({ + email, + message, + organizationId: organization.id, + organizationName, + projectId: project.id, + projectName, + projectToken: projectToken.id, + referenceId, + run, + userName, + userToken + }); + } finally { + if (userToken && projectId) { + await e2eApi.deleteProject(userToken, projectId); + } + + if (userToken && organizationId) { + await e2eApi.deleteOrganization(userToken, organizationId); + } + } } }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts index 6ccaf78e5a..6118bdbdbf 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts @@ -1,4 +1,6 @@ const DEFAULT_APP_URL = 'https://web-ex.dev.localhost:7131'; +const DEFAULT_EMAIL = 'admin@exceptionless.test'; +const DEFAULT_PASSWORD = 'tester'; export interface E2EEnvironment { apiUrl: string; @@ -12,8 +14,8 @@ export interface E2EEnvironment { export function getE2EEnvironment(): E2EEnvironment { const isProduction = getOptionalEnv('E2E_ENV') === 'production'; const appUrl = getOptionalEnv('E2E_URL') ?? DEFAULT_APP_URL; - const email = getOptionalEnv('E2E_EMAIL'); - const password = getOptionalEnv('E2E_PASSWORD'); + const email = getOptionalEnv('E2E_EMAIL') ?? (isProduction ? undefined : DEFAULT_EMAIL); + const password = getOptionalEnv('E2E_PASSWORD') ?? (isProduction ? undefined : DEFAULT_PASSWORD); const runId = getOptionalEnv('E2E_RUN_ID') ?? getDefaultRunId(); const missing = [['E2E_URL', appUrl]].filter(([, value]) => !value).map(([name]) => name); diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts new file mode 100644 index 0000000000..0c7515427a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -0,0 +1,273 @@ +import { expect, type Page, type TestInfo } from '@playwright/test'; + +import type { E2EApiClient } from '../fixtures/api-client'; +import type { E2EScenario } from '../fixtures/e2e-test'; + +import { createRunName } from '../fixtures/e2e-test'; + +const FIXED_VERSION = '1.0.0'; +const PASSWORD = 'tester'; + +export class ExceptionlessE2EJourney { + email: string; + eventId?: string; + message: string; + organizationId?: string; + organizationName: string; + projectId?: string; + projectName: string; + projectToken?: string; + referenceId: string; + run: string; + stackId?: string; + userName: string; + userToken?: string; + + constructor( + private readonly page: Page, + private readonly e2eApi: E2EApiClient, + testInfoOrScenario: E2EScenario | TestInfo + ) { + if (isE2EScenario(testInfoOrScenario)) { + this.email = testInfoOrScenario.email; + this.message = testInfoOrScenario.message; + this.organizationId = testInfoOrScenario.organizationId; + this.organizationName = testInfoOrScenario.organizationName; + this.projectId = testInfoOrScenario.projectId; + this.projectName = testInfoOrScenario.projectName; + this.projectToken = testInfoOrScenario.projectToken; + this.referenceId = testInfoOrScenario.referenceId; + this.run = testInfoOrScenario.run; + this.userName = testInfoOrScenario.userName; + this.userToken = testInfoOrScenario.userToken; + + return; + } + + const testInfo = testInfoOrScenario; + this.run = createRunName(e2eApi.environment.runId, testInfo); + this.userName = `Playwright User ${this.run}`; + this.email = `playwright-${this.run}@exceptionless.test`.toLowerCase(); + this.organizationName = `Playwright Org ${this.run}`; + this.projectName = `Playwright Project ${this.run}`; + this.referenceId = `pw-e2e-${this.run}`; + this.message = `Playwright onboarding event ${this.run}`; + } + + static fromScenario(page: Page, e2eApi: E2EApiClient, scenario: E2EScenario): ExceptionlessE2EJourney { + return new ExceptionlessE2EJourney(page, e2eApi, scenario); + } + + async cleanup(): Promise { + if (!this.userToken || !this.organizationId) { + return; + } + + if (this.projectId) { + await this.e2eApi.deleteProject(this.userToken, this.projectId); + } + + await this.e2eApi.deleteOrganization(this.userToken, this.organizationId); + await this.e2eApi.waitForOrganizationDeleted(this.userToken, this.organizationId); + } + + async createFirstProjectAndVerifyConfigureToken(): Promise { + await this.page.getByRole('link', { name: 'add a new project' }).click(); + await expect(this.page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + + await this.page.getByLabel('Project Name').fill(this.projectName); + await this.page.getByRole('button', { name: 'Add Project' }).click(); + + await this.page.waitForURL(/\/next\/project\/[^/]+\/configure/, { timeout: 30_000 }); + this.projectId = getIdFromUrl(this.page, /\/project\/([^/]+)\/configure/); + await expect(this.page.getByRole('heading', { name: 'Download & Configure Project' })).toBeVisible(); + + await selectProjectType(this.page, 'Bash Shell'); + await expect(this.page.getByText('Execute the following in your shell.')).toBeVisible(); + await expect(this.page.getByText(/Authorization: Bearer (?!YOUR_API_KEY)[A-Za-z0-9_-]+/)).toBeVisible(); + + this.projectToken = await getProjectTokenFromConfigurePage(this.page); + } + + async expectEventDetails(): Promise { + expect(this.eventId).toBeTruthy(); + + await this.page.goto('/next'); + await this.page + .getByRole('link', { name: new RegExp(escapeRegExp(this.message)) }) + .first() + .click(); + const eventDetails = this.page.getByRole('dialog', { name: 'Event Details' }); + await expect(eventDetails).toBeVisible(); + await expect(eventDetails.getByText(this.message).first()).toBeVisible({ timeout: 30_000 }); + await expect(eventDetails.getByRole('tab', { name: 'Overview' })).toBeVisible(); + await expect(eventDetails.getByRole('tab', { name: 'Exception' })).toBeVisible(); + await expect(eventDetails.getByRole('tab', { name: 'Environment' })).toBeVisible(); + await expect(eventDetails.getByRole('tab', { name: 'Extended Data' })).toBeVisible(); + } + + async expectEventInPrimaryViews(): Promise { + await this.page.goto('/next'); + await expect(this.page.getByRole('heading', { name: 'Events' })).toBeVisible(); + await expect(this.page.getByText(this.message).first()).toBeVisible({ timeout: 30_000 }); + + await this.page.goto('/next/issues'); + await expect(this.page.getByRole('heading', { name: 'Issues' })).toBeVisible(); + await expect(this.page.getByText(this.message).first()).toBeVisible({ timeout: 30_000 }); + + await this.page.goto('/next/stream'); + await expect(this.page.getByRole('heading', { name: 'Event Stream' })).toBeVisible(); + await expect(this.page.getByText(this.message).first()).toBeVisible({ timeout: 30_000 }); + } + + 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').fill(version); + await this.page.getByRole('button', { name: 'Mark Stack Fixed' }).click(); + + await expect.poll(async () => (await this.e2eApi.getStack(this.userToken!, this.stackId!)).status, { timeout: 30_000 }).toBe('fixed'); + await expect(this.page.getByRole('button', { name: 'Fixed' })).toBeVisible({ timeout: 30_000 }); + } + + async onboardProject(): Promise { + await this.signUpAndCreateOrganization(); + await this.createFirstProjectAndVerifyConfigureToken(); + } + + async signUpAndCreateOrganization(): Promise { + await this.page.goto('/next/signup'); + + const email = this.e2eApi.environment.email ?? this.email; + const password = this.e2eApi.environment.password ?? PASSWORD; + + await this.page.getByLabel('Name').fill(this.userName); + await this.page.getByLabel('Email').fill(email); + await waitForEmailValidation(this.page); + await this.page.getByLabel('Password').fill(password); + await this.page.getByRole('button', { name: 'Create My Account' }).click(); + + this.userToken = await getUserToken(this.page); + const addOrganizationHeading = this.page.getByRole('heading', { name: 'Add Organization' }); + + if (!(await addOrganizationHeading.isVisible())) { + await this.page.goto('/next/organization/add'); + } + + await expect(addOrganizationHeading).toBeVisible({ timeout: 30_000 }); + + await this.page.getByLabel('Organization Name').fill(this.organizationName); + await this.page.getByRole('button', { name: 'Add Organization' }).click(); + + await this.page.waitForURL(/\/next\/organization\/[^/]+\/manage/, { timeout: 30_000 }); + this.organizationId = getIdFromUrl(this.page, /\/organization\/([^/]+)\/manage/); + await expect(this.page.getByRole('heading', { name: new RegExp(`${escapeRegExp(this.organizationName)} Settings`) })).toBeVisible(); + } + + async submitRepresentativeEvent(): Promise { + expect(this.projectId).toBeTruthy(); + expect(this.projectToken).toBeTruthy(); + expect(this.userToken).toBeTruthy(); + + await this.e2eApi.submitEvent(this.projectId!, this.projectToken!, { + data: { + '@environment': { + machine_name: 'playwright-runner', + process_name: 'e2e-tests' + }, + '@request': { + headers: { + 'User-Agent': ['Exceptionless Playwright E2E'] + }, + host: 'web-ex.dev.localhost', + http_method: 'GET', + is_secure: true, + path: '/e2e/onboarding', + port: 7131, + query_string: { + reference: this.referenceId + }, + user_agent: 'Exceptionless Playwright E2E' + }, + '@simple_error': { + message: this.message, + stack_trace: `Error: ${this.message}\n at exceptionless-journey.ts:42:13`, + type: 'PlaywrightOnboardingException' + }, + e2e_reference: this.referenceId, + run_id: this.e2eApi.environment.runId + }, + message: this.message, + reference_id: this.referenceId, + source: 'playwright-e2e', + type: 'error' + }); + + const event = await this.e2eApi.pollForEventByReference(this.userToken!, this.projectId!, this.referenceId); + expect(event.reference_id).toBe(this.referenceId); + expect(event.stack_id).toBeTruthy(); + + this.eventId = event.id; + this.stackId = event.stack_id; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getIdFromUrl(page: Page, pattern: RegExp): string { + const match = pattern.exec(new URL(page.url()).pathname); + if (!match?.[1]) { + throw new Error(`Could not extract id from ${page.url()}`); + } + + return match[1]; +} + +async function getProjectTokenFromConfigurePage(page: Page): Promise { + const text = await page.locator('body').innerText(); + const match = /Authorization: Bearer ([A-Za-z0-9_-]+)/.exec(text); + if (!match?.[1] || match[1] === 'YOUR_API_KEY') { + throw new Error('Configure page did not expose a project token.'); + } + + return match[1]; +} + +async function getUserToken(page: Page): Promise { + await expect.poll(async () => page.evaluate(() => window.localStorage.getItem('satellizer_token')), { timeout: 30_000 }).toBeTruthy(); + const token = await page.evaluate(() => window.localStorage.getItem('satellizer_token')); + if (!token) { + throw new Error('Signup did not persist an access token.'); + } + + return token; +} + +function isE2EScenario(value: E2EScenario | TestInfo): value is E2EScenario { + return 'projectToken' in value; +} + +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 }); + + try { + await option.click({ timeout: 5_000 }); + } catch { + await page.keyboard.press('Enter'); + } +} + +async function waitForEmailValidation(page: Page): Promise { + await page + .getByLabel('Validating email') + .waitFor({ state: 'detached', timeout: 10_000 }) + .catch(() => undefined); +} diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts deleted file mode 100644 index 1822939a5b..0000000000 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/basic-lifecycle.e2e.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { Page } from '@playwright/test'; - -import { createRunName, expect, test } from '../fixtures/e2e-test'; - -const PASSWORD = 'tester'; - -test('new user can onboard a project, receive an event, and triage the stack', async ({ e2eApi, page }, testInfo) => { - const run = createRunName(e2eApi.environment.runId, testInfo); - const userName = `Playwright User ${run}`; - const email = `playwright-${run}@exceptionless.test`.toLowerCase(); - const organizationName = `Playwright Org ${run}`; - const projectName = `Playwright Project ${run}`; - const referenceId = `pw-e2e-${run}`; - const message = `Playwright onboarding event ${run}`; - - let organizationId: string | undefined; - let projectId: string | undefined; - let userToken: string | undefined; - - try { - await test.step('sign up and create the organization in the UI', async () => { - await page.goto('/next/signup'); - - await page.getByLabel('Name').fill(userName); - await page.getByLabel('Email').fill(email); - await waitForEmailValidation(page); - await page.getByLabel('Password').fill(PASSWORD); - await page.getByRole('button', { name: 'Create My Account' }).click(); - - await expect(page.getByRole('heading', { name: 'Add Organization' })).toBeVisible({ timeout: 30_000 }); - userToken = await getUserToken(page); - - await page.getByLabel('Organization Name').fill(organizationName); - await page.getByRole('button', { name: 'Add Organization' }).click(); - - await page.waitForURL(/\/next\/organization\/[^/]+\/manage/, { timeout: 30_000 }); - organizationId = getIdFromUrl(page, /\/organization\/([^/]+)\/manage/); - await expect(page.getByRole('heading', { name: new RegExp(`${escapeRegExp(organizationName)} Settings`) })).toBeVisible(); - }); - - await test.step('create the first project and verify the configure token in the UI', async () => { - await page.getByRole('link', { name: 'add a new project' }).click(); - await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); - - await page.getByLabel('Project Name').fill(projectName); - await page.getByRole('button', { name: 'Add Project' }).click(); - - await page.waitForURL(/\/next\/project\/[^/]+\/configure/, { timeout: 30_000 }); - projectId = getIdFromUrl(page, /\/project\/([^/]+)\/configure/); - await expect(page.getByRole('heading', { name: 'Download & Configure Project' })).toBeVisible(); - - await selectProjectType(page, 'Bash Shell'); - await expect(page.getByText('Execute the following in your shell.')).toBeVisible(); - await expect(page.getByText(/Authorization: Bearer (?!YOUR_API_KEY)[A-Za-z0-9_-]+/)).toBeVisible(); - }); - - const projectToken = await test.step('submit and index a representative event', async () => { - expect(userToken).toBeTruthy(); - expect(projectId).toBeTruthy(); - - const token = await getProjectTokenFromConfigurePage(page); - - await e2eApi.submitEvent(projectId!, token, { - data: { - '@environment': { - machine_name: 'playwright-runner', - process_name: 'e2e-tests' - }, - '@request': { - headers: { - 'User-Agent': ['Exceptionless Playwright E2E'] - }, - host: 'web-ex.dev.localhost', - http_method: 'GET', - is_secure: true, - path: '/e2e/onboarding', - port: 7131, - query_string: { - reference: referenceId - }, - user_agent: 'Exceptionless Playwright E2E' - }, - '@simple_error': { - message, - stack_trace: `Error: ${message}\n at onboarding-flow.spec.ts:42:13`, - type: 'PlaywrightOnboardingException' - }, - e2e_reference: referenceId, - run_id: e2eApi.environment.runId - }, - message, - reference_id: referenceId, - source: 'playwright-e2e', - type: 'error' - }); - - const event = await e2eApi.pollForEventByReference(userToken!, projectId!, referenceId); - expect(event.reference_id).toBe(referenceId); - expect(event.stack_id).toBeTruthy(); - - return { eventId: event.id, stackId: event.stack_id!, token }; - }); - - await test.step('find the event in the primary views and inspect details', async () => { - await page.goto('/next'); - await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible(); - await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000 }); - - await page.goto('/next/issues'); - await expect(page.getByRole('heading', { name: 'Issues' })).toBeVisible(); - await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000 }); - - await page.goto('/next/stream'); - await expect(page.getByRole('heading', { name: 'Event Stream' })).toBeVisible(); - await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000 }); - - await page.goto(`/next/event/${projectToken.eventId}`); - await expect(page.getByRole('heading', { name: 'Event Details' })).toBeVisible(); - await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible(); - await expect(page.getByRole('tab', { name: 'Exception' })).toBeVisible(); - await expect(page.getByRole('tab', { name: 'Environment' })).toBeVisible(); - await expect(page.getByRole('tab', { name: 'Extended Data' })).toBeVisible(); - }); - - await test.step('change stack status from the event detail view', async () => { - expect(userToken).toBeTruthy(); - - await page.getByRole('button', { exact: true, name: 'Open' }).click(); - await page.getByRole('menuitem', { name: 'Fixed' }).click(); - await expect(page.getByRole('heading', { name: 'Mark Stack As Fixed' })).toBeVisible(); - await page.getByLabel('Version').fill('1.0.0'); - await page.getByRole('button', { name: 'Mark Stack Fixed' }).click(); - - await expect.poll(async () => (await e2eApi.getStack(userToken!, projectToken.stackId)).status, { timeout: 30_000 }).toBe('fixed'); - await page.reload(); - await expect(page.getByRole('button', { name: 'Fixed' })).toBeVisible({ timeout: 30_000 }); - }); - } finally { - if (userToken && organizationId) { - if (projectId) { - await e2eApi.deleteProject(userToken, projectId); - } - - await e2eApi.deleteOrganization(userToken, organizationId); - await e2eApi.waitForOrganizationDeleted(userToken, organizationId); - } - } -}); - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function getIdFromUrl(page: Page, pattern: RegExp): string { - const match = pattern.exec(new URL(page.url()).pathname); - if (!match?.[1]) { - throw new Error(`Could not extract id from ${page.url()}`); - } - - return match[1]; -} - -async function getProjectTokenFromConfigurePage(page: Page): Promise { - const text = await page.locator('body').innerText(); - const match = /Authorization: Bearer ([A-Za-z0-9_-]+)/.exec(text); - if (!match?.[1] || match[1] === 'YOUR_API_KEY') { - throw new Error('Configure page did not expose a project token.'); - } - - return match[1]; -} - -async function getUserToken(page: Page): Promise { - const token = await page.evaluate(() => window.localStorage.getItem('satellizer_token')); - if (!token) { - throw new Error('Signup did not persist an access token.'); - } - - return token; -} - -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 }); - - try { - await option.click({ timeout: 5_000 }); - } catch { - await page.keyboard.press('Enter'); - } -} - -async function waitForEmailValidation(page: Page): Promise { - await page - .getByLabel('Validating email') - .waitFor({ state: 'detached', timeout: 10_000 }) - .catch(() => undefined); -} diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/event-details.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-details.e2e.ts new file mode 100644 index 0000000000..c82a23e74c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/event-details.e2e.ts @@ -0,0 +1,14 @@ +import { test } from '../fixtures/e2e-test'; +import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; + +test('new user can inspect event details and exception context', async ({ e2eApi, e2eScenario, page }) => { + const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); + + await test.step('submit a representative event', async () => { + await journey.submitRepresentativeEvent(); + }); + + await test.step('inspect the event details tabs', async () => { + await journey.expectEventDetails(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/event-visibility.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/event-visibility.e2e.ts new file mode 100644 index 0000000000..44a89d6434 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/event-visibility.e2e.ts @@ -0,0 +1,14 @@ +import { test } from '../fixtures/e2e-test'; +import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; + +test('new user can send an event and find it in primary views', async ({ e2eApi, e2eScenario, page }) => { + const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); + + await test.step('submit a representative event', async () => { + await journey.submitRepresentativeEvent(); + }); + + await test.step('find the event in Events, Issues, and Event Stream', async () => { + await journey.expectEventInPrimaryViews(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/onboarding.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/onboarding.e2e.ts new file mode 100644 index 0000000000..9b6d303fba --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/onboarding.e2e.ts @@ -0,0 +1,18 @@ +import { test } from '../fixtures/e2e-test'; +import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; + +test('new user can sign up and configure a first project', async ({ e2eApi, page }, testInfo) => { + const journey = new ExceptionlessE2EJourney(page, e2eApi, testInfo); + + try { + await test.step('sign up and create the organization in the UI', async () => { + await journey.signUpAndCreateOrganization(); + }); + + await test.step('create the first project and verify the configure token in the UI', async () => { + await journey.createFirstProjectAndVerifyConfigureToken(); + }); + } finally { + await journey.cleanup(); + } +}); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/stack-triage.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-triage.e2e.ts new file mode 100644 index 0000000000..6ab838fd35 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/stack-triage.e2e.ts @@ -0,0 +1,14 @@ +import { test } from '../fixtures/e2e-test'; +import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; + +test('new user can mark an open stack fixed from event details', async ({ e2eApi, e2eScenario, page }) => { + const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); + + await test.step('submit a representative event', async () => { + await journey.submitRepresentativeEvent(); + }); + + await test.step('mark the stack fixed through the UI', async () => { + await journey.markStackFixed(); + }); +}); From c93a0454e3a0517974c981447689125e1a76739b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 21:00:44 -0500 Subject: [PATCH 11/14] Use generated user for UI onboarding E2E --- .../ClientApp/e2e/support/exceptionless-journey.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts index 0c7515427a..27fb423019 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -143,13 +143,10 @@ export class ExceptionlessE2EJourney { async signUpAndCreateOrganization(): Promise { await this.page.goto('/next/signup'); - const email = this.e2eApi.environment.email ?? this.email; - const password = this.e2eApi.environment.password ?? PASSWORD; - await this.page.getByLabel('Name').fill(this.userName); - await this.page.getByLabel('Email').fill(email); + await this.page.getByLabel('Email').fill(this.email); await waitForEmailValidation(this.page); - await this.page.getByLabel('Password').fill(password); + await this.page.getByLabel('Password').fill(PASSWORD); await this.page.getByRole('button', { name: 'Create My Account' }).click(); this.userToken = await getUserToken(this.page); From 8a9c1b3d53564990e0336c0d040aca3defd42818 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 21:06:20 -0500 Subject: [PATCH 12/14] Verify E2E organization cleanup --- .../ClientApp/e2e/fixtures/api-client.ts | 63 +++++++++++++++++-- .../ClientApp/e2e/fixtures/e2e-test.ts | 6 +- .../e2e/support/exceptionless-journey.ts | 5 +- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 5395db8d18..6c84ae88fd 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -114,6 +114,20 @@ export class E2EApiClient { return toEventArray(await readJson(response)); } + async getOrganization(token: string, organizationId: string): Promise { + const response = await this.request.get(this.url(`organizations/${organizationId}`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200, 404], 'get organization'); + + if (response.status() === 404) { + return undefined; + } + + return toOrganization(await readJson(response)); + } + async getOrganizations(token: string): Promise { const response = await this.request.get(this.url('organizations'), { headers: this.authHeaders(token) @@ -123,6 +137,20 @@ export class E2EApiClient { return toOrganizationArray(await readJson(response)); } + async getProject(token: string, projectId: string): Promise { + const response = await this.request.get(this.url(`projects/${projectId}`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200, 404], 'get project'); + + if (response.status() === 404) { + return undefined; + } + + return toProject(await readJson(response)); + } + async getProjectDefaultToken(token: string, projectId: string): Promise { const response = await this.request.get(this.url(`projects/${projectId}/tokens/default`), { headers: this.authHeaders(token) @@ -201,17 +229,44 @@ export class E2EApiClient { async waitForOrganizationDeleted(token: string, organizationId: string, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; + let lastError: Error | undefined; + + while (Date.now() < deadline) { + try { + const organization = await this.getOrganization(token, organizationId); + if (!organization) { + 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 be inaccessible after deletion${lastError ? `: ${lastError.message}` : ''}` + ); + } + + async waitForProjectDeleted(token: string, projectId: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: Error | undefined; while (Date.now() < deadline) { - const organizations = await this.getOrganizations(token); - if (!organizations.some((organization) => organization.id === organizationId)) { - return; + try { + const project = await this.getProject(token, projectId); + if (!project) { + 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 organization list`); + throw new Error(`Timed out waiting for E2E project ${projectId} to be inaccessible after deletion${lastError ? `: ${lastError.message}` : ''}`); } private authHeaders(token: string): Record { diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index b2ccf7f929..1711997cb9 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -5,6 +5,8 @@ import { getE2EEnvironment } from './environment'; const PASSWORD = 'tester'; +export const E2E_ORGANIZATION_NAME_PREFIX = 'E2E Playwright Org'; + export interface E2EScenario { email: string; message: string; @@ -33,7 +35,7 @@ export const test = base.extend({ const run = createRunName(e2eApi.environment.runId, testInfo); const userName = `Playwright User ${run}`; const email = `playwright-${run}@exceptionless.test`.toLowerCase(); - const organizationName = `Playwright Org ${run}`; + const organizationName = `${E2E_ORGANIZATION_NAME_PREFIX} ${run}`; const projectName = `Playwright Project ${run}`; const referenceId = `pw-e2e-${run}`; const message = `Playwright onboarding event ${run}`; @@ -73,10 +75,12 @@ export const test = base.extend({ } finally { if (userToken && projectId) { await e2eApi.deleteProject(userToken, projectId); + await e2eApi.waitForProjectDeleted(userToken, projectId); } if (userToken && organizationId) { await e2eApi.deleteOrganization(userToken, organizationId); + await e2eApi.waitForOrganizationDeleted(userToken, organizationId); } } } diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts index 27fb423019..7b82b00650 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -3,7 +3,7 @@ import { expect, type Page, type TestInfo } from '@playwright/test'; import type { E2EApiClient } from '../fixtures/api-client'; import type { E2EScenario } from '../fixtures/e2e-test'; -import { createRunName } from '../fixtures/e2e-test'; +import { createRunName, E2E_ORGANIZATION_NAME_PREFIX } from '../fixtures/e2e-test'; const FIXED_VERSION = '1.0.0'; const PASSWORD = 'tester'; @@ -48,7 +48,7 @@ export class ExceptionlessE2EJourney { this.run = createRunName(e2eApi.environment.runId, testInfo); this.userName = `Playwright User ${this.run}`; this.email = `playwright-${this.run}@exceptionless.test`.toLowerCase(); - this.organizationName = `Playwright Org ${this.run}`; + this.organizationName = `${E2E_ORGANIZATION_NAME_PREFIX} ${this.run}`; this.projectName = `Playwright Project ${this.run}`; this.referenceId = `pw-e2e-${this.run}`; this.message = `Playwright onboarding event ${this.run}`; @@ -65,6 +65,7 @@ export class ExceptionlessE2EJourney { if (this.projectId) { await this.e2eApi.deleteProject(this.userToken, this.projectId); + await this.e2eApi.waitForProjectDeleted(this.userToken, this.projectId); } await this.e2eApi.deleteOrganization(this.userToken, this.organizationId); From 12a9260f7e6611440bdcf5a32bb96f58404174af Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 21:13:25 -0500 Subject: [PATCH 13/14] Clean up generated E2E users --- .../ClientApp/e2e/fixtures/api-client.ts | 57 +++++++++++++++++++ .../ClientApp/e2e/fixtures/e2e-test.ts | 14 ++++- .../e2e/support/exceptionless-journey.ts | 2 + 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 6c84ae88fd..f90e3db6f8 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -2,6 +2,11 @@ import type { APIRequestContext, APIResponse } from '@playwright/test'; import type { E2EEnvironment } from './environment'; +export interface E2ECurrentUser { + email_address?: string; + id: string; +} + export interface E2EEvent { id: string; message?: string; @@ -66,6 +71,15 @@ export class E2EApiClient { return toProject(await readJson(response)); } + async deleteCurrentUser(token: string): Promise { + const response = await this.request.delete(this.url('users/me'), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [202, 404], 'delete current user'); + return response.status(); + } + async deleteOrganization(token: string, organizationId: string): Promise { const response = await this.request.delete(this.url(`organizations/${organizationId}`), { headers: this.authHeaders(token) @@ -91,6 +105,20 @@ export class E2EApiClient { return toRecord(await readJson(response), 'about response'); } + async getCurrentUser(token: string): Promise { + const response = await this.request.get(this.url('users/me'), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200, 401, 404], 'get current user'); + + if (response.status() !== 200) { + return undefined; + } + + return toCurrentUser(await readJson(response)); + } + async getEvent(token: string, eventId: string): Promise { const response = await this.request.get(this.url(`events/${eventId}`), { headers: this.authHeaders(token) @@ -227,6 +255,26 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } + async waitForCurrentUserDeleted(token: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: Error | undefined; + + while (Date.now() < deadline) { + try { + const user = await this.getCurrentUser(token); + if (!user) { + return; + } + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + + await delay(1_000); + } + + throw new Error(`Timed out waiting for generated E2E user to be inaccessible after deletion${lastError ? `: ${lastError.message}` : ''}`); + } + async waitForOrganizationDeleted(token: string, organizationId: string, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; let lastError: Error | undefined; @@ -323,6 +371,15 @@ async function readJson(response: APIResponse): Promise { return JSON.parse(text) as unknown; } +function toCurrentUser(value: unknown): E2ECurrentUser { + const record = toRecord(value, 'current user response'); + + return { + email_address: getOptionalString(record, 'email_address'), + id: getRequiredString(record, 'id', 'current user response') + }; +} + function toEvent(value: unknown): E2EEvent { const record = toRecord(value, 'event response'); diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 1711997cb9..23ff52c01b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -42,9 +42,16 @@ export const test = base.extend({ let organizationId: string | undefined; let projectId: string | undefined; let userToken: string | undefined; + let createdUser = false; try { - userToken = e2eApi.environment.email && e2eApi.environment.password ? await e2eApi.login() : await e2eApi.signup(userName, email, PASSWORD); + if (e2eApi.environment.email && e2eApi.environment.password) { + userToken = await e2eApi.login(); + } else { + userToken = await e2eApi.signup(userName, email, PASSWORD); + createdUser = true; + } + const organization = await e2eApi.createOrganization(userToken, organizationName); organizationId = organization.id; const project = await e2eApi.createProject(userToken, organization.id, projectName); @@ -82,6 +89,11 @@ export const test = base.extend({ await e2eApi.deleteOrganization(userToken, organizationId); await e2eApi.waitForOrganizationDeleted(userToken, organizationId); } + + if (userToken && createdUser) { + await e2eApi.deleteCurrentUser(userToken); + await e2eApi.waitForCurrentUserDeleted(userToken); + } } } }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts index 7b82b00650..89b2e9eeb6 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -70,6 +70,8 @@ export class ExceptionlessE2EJourney { await this.e2eApi.deleteOrganization(this.userToken, this.organizationId); await this.e2eApi.waitForOrganizationDeleted(this.userToken, this.organizationId); + await this.e2eApi.deleteCurrentUser(this.userToken); + await this.e2eApi.waitForCurrentUserDeleted(this.userToken); } async createFirstProjectAndVerifyConfigureToken(): Promise { From 0f6e226a57d91f09324ef4ca0c2b6a68833a99e9 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 10 May 2026 22:12:56 -0500 Subject: [PATCH 14/14] Address E2E PR feedback --- .github/workflows/build.yaml | 6 +++++- .github/workflows/copilot-setup-steps.yml | 6 +++++- src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts | 5 +++-- src/Exceptionless.Web/ClientApp/playwright.config.ts | 2 ++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2a4956c22e..a7183e6988 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -174,7 +174,11 @@ jobs: - name: Install Aspire CLI run: | export PATH="$HOME/.dotnet/tools:$PATH" - dotnet tool install --global Aspire.Cli --version 13.3.0 + if dotnet tool list --global | grep -Eiq '^aspire\.cli[[:space:]]'; then + dotnet tool update --global Aspire.Cli --version 13.3.0 + else + dotnet tool install --global Aspire.Cli --version 13.3.0 + fi echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" aspire --version diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 2dcd8038bf..361b126af1 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -31,7 +31,11 @@ jobs: - name: Install Aspire CLI run: | export PATH="$HOME/.dotnet/tools:$PATH" - dotnet tool install --global Aspire.Cli --version 13.3.0 + if dotnet tool list --global | grep -Eiq '^aspire\.cli[[:space:]]'; then + dotnet tool update --global Aspire.Cli --version 13.3.0 + else + dotnet tool install --global Aspire.Cli --version 13.3.0 + fi echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" aspire --version diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts index 6118bdbdbf..68474f1836 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts @@ -13,12 +13,13 @@ export interface E2EEnvironment { export function getE2EEnvironment(): E2EEnvironment { const isProduction = getOptionalEnv('E2E_ENV') === 'production'; - const appUrl = getOptionalEnv('E2E_URL') ?? DEFAULT_APP_URL; + const rawAppUrl = getOptionalEnv('E2E_URL'); + const appUrl = rawAppUrl ?? DEFAULT_APP_URL; const email = getOptionalEnv('E2E_EMAIL') ?? (isProduction ? undefined : DEFAULT_EMAIL); const password = getOptionalEnv('E2E_PASSWORD') ?? (isProduction ? undefined : DEFAULT_PASSWORD); const runId = getOptionalEnv('E2E_RUN_ID') ?? getDefaultRunId(); - const missing = [['E2E_URL', appUrl]].filter(([, value]) => !value).map(([name]) => name); + const missing = [['E2E_URL', rawAppUrl]].filter(([, value]) => !value).map(([name]) => name); if (isProduction && missing.length > 0) { throw new Error(`Production E2E tests require these environment variables: ${missing.join(', ')}`); diff --git a/src/Exceptionless.Web/ClientApp/playwright.config.ts b/src/Exceptionless.Web/ClientApp/playwright.config.ts index 7c176346b1..80170cf9c0 100644 --- a/src/Exceptionless.Web/ClientApp/playwright.config.ts +++ b/src/Exceptionless.Web/ClientApp/playwright.config.ts @@ -23,6 +23,8 @@ export default defineConfig({ retries: isCi ? 2 : 0, + testDir: 'e2e', + testMatch: '**/*.e2e.{ts,js}', timeout: 120_000,