diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 88dad7b377..a7183e6988 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -157,6 +157,123 @@ 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_URL: https://web-ex.dev.localhost:7131 + 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 +340,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..7c74d92af5 --- /dev/null +++ b/.github/workflows/production-e2e.yml @@ -0,0 +1,66 @@ +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_URL: https://be.exceptionless.io + 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 "- E2E URL: https://be.exceptionless.io" >> $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/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..f90e3db6f8 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -0,0 +1,463 @@ +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; + 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 E2EStack { + id: string; + status?: string; + title?: 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 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) + }); + + await expectStatus(response, [202, 404], 'delete organization'); + 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')); + + await expectStatus(response, [200], 'get about'); + 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) + }); + + 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 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) + }); + + await expectStatus(response, [200], 'get organizations'); + 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) + }); + + await expectStatus(response, [200, 201], 'get default project token'); + 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, + 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 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 + }); + + 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; + + 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) { + 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 project ${projectId} to be inaccessible after deletion${lastError ? `: ${lastError.message}` : ''}`); + } + + 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; + } + + 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 { + 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 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'); + + 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 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'); + + 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..23ff52c01b --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -0,0 +1,109 @@ +import { test as base, expect, type TestInfo } from '@playwright/test'; + +import { E2EApiClient } from './api-client'; +import { getE2EEnvironment } from './environment'; + +const PASSWORD = 'tester'; + +export const E2E_ORGANIZATION_NAME_PREFIX = 'E2E Playwright Org'; + +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 = `${E2E_ORGANIZATION_NAME_PREFIX} ${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; + let createdUser = false; + + try { + 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); + 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); + await e2eApi.waitForProjectDeleted(userToken, projectId); + } + + if (userToken && organizationId) { + await e2eApi.deleteOrganization(userToken, organizationId); + await e2eApi.waitForOrganizationDeleted(userToken, organizationId); + } + + if (userToken && createdUser) { + await e2eApi.deleteCurrentUser(userToken); + await e2eApi.waitForCurrentUserDeleted(userToken); + } + } + } +}); + +export { expect }; + +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, '-') + .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..68474f1836 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/environment.ts @@ -0,0 +1,69 @@ +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 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', rawAppUrl]].filter(([, value]) => !value).map(([name]) => name); + + if (isProduction && missing.length > 0) { + throw new Error(`Production E2E tests require these environment variables: ${missing.join(', ')}`); + } + + 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/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts new file mode 100644 index 0000000000..89b2e9eeb6 --- /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, E2E_ORGANIZATION_NAME_PREFIX } 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 = `${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}`; + } + + 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.waitForProjectDeleted(this.userToken, this.projectId); + } + + 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 { + 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'); + + await this.page.getByLabel('Name').fill(this.userName); + await this.page.getByLabel('Email').fill(this.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/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(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index f766e15d40..b97614490d 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_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..80170cf9c0 100644 --- a/src/Exceptionless.Web/ClientApp/playwright.config.ts +++ b/src/Exceptionless.Web/ClientApp/playwright.config.ts @@ -1,10 +1,41 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig, devices } from '@playwright/test'; + +const isCi = !!process.env.CI; +const appUrl = process.env.E2E_URL || 'https://web-ex.dev.localhost:7131'; export default defineConfig({ + 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, + testDir: 'e2e', - webServer: { - command: 'npm run build && npm run preview', - port: 4173 - } + testMatch: '**/*.e2e.{ts,js}', + + timeout: 120_000, + + use: { + baseURL: appUrl, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure', + trace: 'on-first-retry', + video: 'retain-on-failure' + }, + + workers: isCi ? 1 : undefined });