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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/tests/event-details.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ test('new user can inspect event details and exception context @signup', async (
});

await test.step('inspect the event details tabs', async () => {
const projectUserCountResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
url.pathname.includes(`/api/v2/projects/${e2eScenario.projectId}/events/count`) && url.searchParams.get('aggregations') === 'cardinality:user'
);
});

await journey.expectEventDetails();

const countResponse = await projectUserCountResponse;
const countResponseUrl = new URL(countResponse.url());
test.expect(countResponse.ok()).toBe(true);
test.expect(countResponseUrl.searchParams.get('time')).toBe('[now-7d TO now]');
});
});
351 changes: 351 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/tests/event-effects-chaos.e2e.ts

Large diffs are not rendered by default.

216 changes: 216 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/tests/stack-effects-chaos.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';

import { expect, test } from '../fixtures/e2e-test';
import { ExceptionlessE2EJourney } from '../support/exceptionless-journey';
import { createRepresentativeEvent } from '../support/synthetic-event';

interface ActionSample {
listRequests: number;
name: string;
runtimeErrors: number;
}

interface RuntimeDiagnostics {
actionSamples: ActionSample[];
activeAction: string;
listRequests: number;
networkFailures: { action: string; status: number; url: string }[];
runtimeErrors: { action: string; message: string }[];
}

test('stack effects stay bounded through background, paging, and navigation chaos @signup', async ({ e2eApi, e2eScenario, page }, testInfo) => {
test.slow();

const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario);
const diagnostics: RuntimeDiagnostics = {
actionSamples: [],
activeAction: 'setup',
listRequests: 0,
networkFailures: [],
runtimeErrors: []
};

page.on('console', (message) => recordConsoleError(diagnostics, message));
page.on('pageerror', (error) => diagnostics.runtimeErrors.push({ action: diagnostics.activeAction, message: error.stack ?? error.message }));
page.on('request', (request) => recordListRequest(diagnostics, request, e2eScenario.organizationId));
page.on('response', (response) => recordNetworkFailure(diagnostics, response));

await test.step('seed enough independent stacks to exercise pagination', async () => {
await journey.submitRepresentativeEvent();
const events = Array.from({ length: 6 }, (_, index) => createChaosEvent(e2eApi.environment.appUrl, e2eScenario.run, index));
await Promise.all(events.map(({ event }) => e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, event)));
await Promise.all(events.map(({ referenceId }) => e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, referenceId)));
});

await test.step('load the stack page with one page of results', async () => {
const response = page.waitForResponse((candidate) => isStackListResponse(candidate, e2eScenario.organizationId));
await page.goto('/next/stack?limit=5');
expect((await response).ok()).toBe(true);
await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible();
await expect(page.locator('tbody tr:visible').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Go to next page' })).toBeEnabled();
});

await measureAction(diagnostics, 'paging', async () => {
for (let index = 0; index < 4; index++) {
await page.getByRole('button', { name: 'Go to next page' }).click();
await expect(page).toHaveURL(/(?:\?|&)page=2(?:&|$)/);
await page.getByRole('button', { name: 'Go to previous page' }).click();
await expect(page).not.toHaveURL(/(?:\?|&)page=2(?:&|$)/);
}
});
expect(actionSample(diagnostics, 'paging').listRequests).toBe(8);

await measureAction(diagnostics, 'stack detail mount and teardown', async () => {
for (let index = 0; index < 5; index++) {
await page.locator('tbody tr:visible').first().click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
}
});
expect(actionSample(diagnostics, 'stack detail mount and teardown').listRequests).toBe(0);

await measureAction(diagnostics, 'rapid visibility changes', async () => {
for (let index = 0; index < 30; index++) {
await setDocumentHidden(page, true);
await setDocumentHidden(page, false);
}

await page.waitForTimeout(2_000);
});
expect(actionSample(diagnostics, 'rapid visibility changes').listRequests).toBeLessThanOrEqual(1);

await measureAction(diagnostics, 'background ingestion and resume', async () => {
await setDocumentHidden(page, true);
const { event, referenceId } = createChaosEvent(e2eApi.environment.appUrl, e2eScenario.run, 100);
await e2eApi.submitEvent(e2eScenario.projectId, e2eScenario.projectToken, event);
await e2eApi.pollForEventByReference(e2eScenario.userToken, e2eScenario.projectId, referenceId);
await setDocumentHidden(page, false);
await page.waitForTimeout(2_000);
});
expect(actionSample(diagnostics, 'background ingestion and resume').listRequests).toBeLessThanOrEqual(1);

await measureAction(diagnostics, 'bursty stack change notifications', async () => {
await page.evaluate((organizationId) => {
for (let index = 0; index < 30; index++) {
document.dispatchEvent(
new CustomEvent('StackChanged', {
detail: {
change_type: 2,
data: {},
id: `chaos-missing-stack-${index}`,
organization_id: organizationId,
type: 'Stack'
}
})
);
}
}, e2eScenario.organizationId);
await page.waitForTimeout(2_000);
});
expect(actionSample(diagnostics, 'bursty stack change notifications').listRequests).toBe(1);

await measureAction(diagnostics, 'route remounts', async () => {
for (let index = 0; index < 5; index++) {
await page.goto(`/next/event/${journey.eventId}`);
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await page.goto('/next/stack?limit=5');
await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible();
}
});
expect(actionSample(diagnostics, 'route remounts').listRequests).toBeLessThanOrEqual(5);

await testInfo.attach('stack-effect-chaos-diagnostics', {
body: Buffer.from(JSON.stringify(diagnostics, null, 2)),
contentType: 'application/json'
});

expect(diagnostics.runtimeErrors).toEqual([]);
expect(diagnostics.networkFailures).toEqual([]);
await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible();
});

function actionSample(diagnostics: RuntimeDiagnostics, name: string): ActionSample {
const sample = diagnostics.actionSamples.find((candidate) => candidate.name === name);
expect(sample, `Missing diagnostics for "${name}"`).toBeDefined();
return sample!;
}

function createChaosEvent(appUrl: string, run: string, index: number): { event: Record<string, unknown>; referenceId: string } {
const referenceId = `pw-effects-${run}-${index}`;
const message = `Playwright effect chaos ${run} ${index}`;
const event = createRepresentativeEvent({
appUrl,
message,
referenceId,
runId: run
});
const data = event.data as Record<string, unknown>;
const simpleError = data['@simple_error'] as Record<string, unknown>;
simpleError.type = `PlaywrightEffectChaosException${index}`;
simpleError.stack_trace = `Error: ${message}\n at stack-effect-chaos-${index}.ts:${index + 1}:1`;

return { event, referenceId };
}

function isStackListRequest(request: Request, organizationId: string): boolean {
const url = new URL(request.url());
return url.pathname === `/api/v2/organizations/${organizationId}/events` && url.searchParams.get('mode') === 'stack_frequent';
}

function isStackListResponse(response: Response, organizationId: string): boolean {
return isStackListRequest(response.request(), organizationId);
}

async function measureAction(diagnostics: RuntimeDiagnostics, name: string, action: () => Promise<void>): Promise<void> {
diagnostics.activeAction = name;
const initialListRequests = diagnostics.listRequests;
const initialRuntimeErrors = diagnostics.runtimeErrors.length;

await action();

diagnostics.actionSamples.push({
listRequests: diagnostics.listRequests - initialListRequests,
name,
runtimeErrors: diagnostics.runtimeErrors.length - initialRuntimeErrors
});
}

function recordConsoleError(diagnostics: RuntimeDiagnostics, message: ConsoleMessage): void {
const text = message.text();
if (message.type() === 'error' && /effect_update_depth_exceeded|maximum update depth|svelte\.dev\/e\/effect/i.test(text)) {
diagnostics.runtimeErrors.push({ action: diagnostics.activeAction, message: text });
}
}

function recordListRequest(diagnostics: RuntimeDiagnostics, request: Request, organizationId: string): void {
if (isStackListRequest(request, organizationId)) {
diagnostics.listRequests++;
}
}

function recordNetworkFailure(diagnostics: RuntimeDiagnostics, response: Response): void {
if (response.status() >= 400) {
diagnostics.networkFailures.push({
action: diagnostics.activeAction,
status: response.status(),
url: response.url()
});
}
}

async function setDocumentHidden(page: Page, hidden: boolean): Promise<void> {
await page.evaluate((nextHidden) => {
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => nextHidden
});
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => (nextHidden ? 'hidden' : 'visible')
});
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('visibilitychange'));
}, hidden);
}
12 changes: 6 additions & 6 deletions src/Exceptionless.Web/ClientApp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/Exceptionless.Web/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
},
"dependencies": {
"@exceptionless/browser": "^3.2.1",
"@exceptionless/fetchclient": "^0.44.0",
"@foundatiofx/fetchclient": "^1.3.3",
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.24.0",
"@stripe/stripe-js": "^9.10.0",
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/ClientApp/src/hooks.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { page } from '$app/state';
import { env } from '$env/dynamic/public';
import { normalizePath, normalizeRouteId } from '$lib/telemetry';
import { Exceptionless, guid, toError } from '@exceptionless/browser';
import { useMiddleware } from '@exceptionless/fetchclient';
import { useMiddleware } from '@foundatiofx/fetchclient';

// If any of these are set in local storage, use them instead of the build-time environment variables.
// This allows you to target other environments from your browser without a rebuild.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';

import type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { type RunMaintenanceJobFormData, RunMaintenanceJobSchema } from '$features/admin/schemas';
import { formatDateLabel } from '$features/shared/dates';
import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '$shared/validation';
import { ProblemDetails } from '@exceptionless/fetchclient';
import { ProblemDetails } from '@foundatiofx/fetchclient';
import { CalendarDate, type DateValue } from '@internationalized/date';
import CalendarIcon from '@lucide/svelte/icons/calendar';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { env } from '$env/dynamic/public';
import { getIntercomTokenSessionKey, intercomTokenRefreshIntervalMs } from '$features/intercom/config';
import { organization } from '$features/organizations/context.svelte';
import { ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient';
import { hide as hideIntercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk';
import { createQuery, type QueryClient } from '@tanstack/svelte-query';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FetchClient } from '@exceptionless/fetchclient';
import { FetchClient } from '@foundatiofx/fetchclient';
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('./exceptionless-session', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { resolve } from '$app/paths';
import { page } from '$app/state';
import { env } from '$env/dynamic/public';
import { CachedPersistedState } from '$features/shared/utils/cached-persisted-state.svelte';
import { useFetchClient } from '@exceptionless/fetchclient';
import { useFetchClient } from '@foundatiofx/fetchclient';

import type { TokenResult } from './models';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import { changePlanMutation, getPlansQuery } from '$features/organizations/api.svelte';
import { getFormErrorMessages, problemDetailsToFormErrors } from '$features/shared/validation';
import { Exceptionless } from '@exceptionless/browser';
import { ProblemDetails } from '@exceptionless/fetchclient';
import { ProblemDetails } from '@foundatiofx/fetchclient';
import Check from '@lucide/svelte/icons/check';
import CreditCard from '@lucide/svelte/icons/credit-card';
import Plus from '@lucide/svelte/icons/plus';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ProblemDetails } from '@exceptionless/fetchclient';
import { ProblemDetails } from '@foundatiofx/fetchclient';

interface UpgradeRequiredState {
message: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { CountResult, WorkInProgressResult } from '$shared/models';
import { accessToken } from '$features/auth/index.svelte';
import { queryKeys as stackQueryKeys } from '$features/stacks/api.svelte';
import { DEFAULT_OFFSET } from '$shared/api/api.svelte';
import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient';
import { createMutation, createQuery, keepPreviousData, QueryClient, useQueryClient } from '@tanstack/svelte-query';

import type { EventSummaryModel, SummaryTemplateKeys } from './components/summary/index';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import type { IFilter } from '$comp/faceted-filter';
import type { ProblemDetails } from '@exceptionless/fetchclient';
import type { ProblemDetails } from '@foundatiofx/fetchclient';

import DetailSheet from '$comp/detail-sheet.svelte';

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import type { IFilter } from '$comp/faceted-filter';
import type { UpdateProject, ViewProject } from '$features/projects/models';
import type { ProblemDetails } from '@exceptionless/fetchclient';
import type { ProblemDetails } from '@foundatiofx/fetchclient';

import CopyToClipboardButton from '$comp/copy-to-clipboard-button.svelte';
import DateTime from '$comp/formatters/date-time.svelte';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { SystemNotification } from '$features/websockets/models';

import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';

export const queryKeys = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { QueryClient } from '@tanstack/svelte-query';
import { accessToken } from '$features/auth/index.svelte';
import { fetchApiJson } from '$features/shared/api/api.svelte';
import { queryKeys as userQueryKeys } from '$features/users/api.svelte';
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient';
import { type FetchClientResponse, type ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';

import type { Invoice, InvoiceGridModel, NewOrganization, SuspensionCode, ViewOrganization } from './models';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import Number from '$features/shared/components/formatters/number.svelte';
import { formatDateLabel } from '$features/shared/dates';
import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '$shared/validation';
import { ProblemDetails } from '@exceptionless/fetchclient';
import { ProblemDetails } from '@foundatiofx/fetchclient';
import { CalendarDate, type DateValue } from '@internationalized/date';
import CalendarIcon from '@lucide/svelte/icons/calendar';
import { createForm } from '@tanstack/svelte-form';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import { SuspensionCode } from '$features/organizations/models';
import { suspensionCodeOptions } from '$features/organizations/options';
import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '$features/shared/validation';
import { ProblemDetails } from '@exceptionless/fetchclient';
import { ProblemDetails } from '@foundatiofx/fetchclient';
import { createForm } from '@tanstack/svelte-form';

import { type SuspendOrganizationFormData, SuspendOrganizationSchema } from '../../schemas';
Expand Down
Loading
Loading