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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 4 additions & 2 deletions .agents/skills/frontend-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Keep feature behavior in `src/lib/features/<feature>/`: API operations in `api.s

## Local UI conventions

- Reuse `$comp`, `$shared`, and `$lib` helpers. In particular, use `createQueryParameters` for route query binding.
- Reuse `#comp`, `#shared`, and `#lib` helpers. In particular, use `createQueryParameters` for route query binding.
- Use shared formatter components in `src/lib/features/shared/components/formatters/` for dates, relative time, duration, bytes, numbers, currency, percentages, booleans, and date math.
- Match the existing dense, restrained operational UI and its theme tokens.
- Use `href` for ordinary navigation; use action handlers when work must finish before navigating.
Expand All @@ -26,8 +26,10 @@ Keep feature behavior in `src/lib/features/<feature>/`: API operations in `api.s

## Svelte and TypeScript details

- Use `options.ts` for shared option sets within a feature. Use `$features` for feature imports and `$generated` for generated contracts; extend generated types in feature-owned files.
- Use `options.ts` for shared option sets within a feature. Use `#features` for feature imports and `#generated` for generated contracts; extend generated types in feature-owned files.
- Prefer named imports; use namespace imports for composite components such as `Dialog`, `DropdownMenu`, `Field`, and `Card`.
- The `#` aliases are subpath imports declared in `package.json`. Name the file on disk in each specifier: `#lib/utils.ts`, `#features/auth/index.svelte.ts`, `#comp/ui/button/index.ts` for a barrel, or `#comp/logo.svelte` for a component.
- For shallow routing, write history through `updateShallowHistory` in `#shared/history-state.ts` and read the address-bar URL with `getVisibleUrl()` from `#features/navigation/visible-url.ts`; `page.url` keeps the rendered page's URL. Navigation hooks that should ignore shallow routing start with `if (shallow && type === 'goto') return;`.
- Keep files and directories kebab-case. Follow the app's ESLint rules for braces and statement formatting.
- Avoid `any`; use generated types, explicit interfaces, `unknown`, and type guards. Spell out identifiers such as `organization` and `filter`.
- Await asynchronous work and handle failures at the appropriate local boundary. Do not let rejected promises bypass error UI or mark an operation complete before its required work finishes.
Expand Down
28 changes: 21 additions & 7 deletions src/Exceptionless.Web/ClientApp/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
import type { StorybookConfig } from '@storybook/sveltekit';
import type { Plugin } from 'vite';

import { mergeConfig } from 'vite';

// SvelteKit resolves `$app/env/public` itself, ahead of any alias, so the mock replaces the
// module that the import resolves to.
function mockPublicEnvironment(): Plugin {
const mock = new URL('./mocks/env.js', import.meta.url).pathname;
return {
enforce: 'pre',
load: {
filter: { id: /\/@sveltejs\/kit\/src\/runtime\/app\/env\/public\/index\.js(\?|$)/ },
handler: () => `export * from ${JSON.stringify(mock)};`
},
name: 'exceptionless:storybook-public-env'
};
}

const config: StorybookConfig = {
addons: ['@storybook/addon-svelte-csf', '@chromatic-com/storybook', '@storybook/addon-a11y', '@storybook/addon-docs'],
framework: '@storybook/sveltekit',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],

async viteFinal(config) {
async viteFinal(config, { configType }) {
return mergeConfig(config, {
// Storybook 10 removes SvelteKit's compile plugin, which defines the payload that
// SvelteKit 3's `$app/env` and `$app/paths` read in builds. Storybook 11 defines it:
// https://github.com/storybookjs/storybook/blob/next/code/frameworks/sveltekit/src/plugins/config-overrides.ts
define: configType === 'PRODUCTION' ? { __SVELTEKIT_PAYLOAD__: JSON.stringify({}) } : {},
envPrefix: ['PUBLIC_'],
resolve: {
alias: {
// Mock SvelteKit's $env/dynamic/public for Storybook
'$env/dynamic/public': new URL('./mocks/env.js', import.meta.url).pathname
}
}
plugins: [mockPublicEnvironment()]
});
}
};
Expand Down
29 changes: 21 additions & 8 deletions src/Exceptionless.Web/ClientApp/.storybook/mocks/env.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
// Mock for $env/dynamic/public in Storybook
export const env = {
// Filter to only include PUBLIC_ prefixed environment variables
...Object.fromEntries(Object.entries(import.meta.env).filter(([key]) => key.startsWith('PUBLIC_'))),
// Provide a Stripe publishable key so isStripeEnabled() returns true in
// billing stories. The StripeProvider will fail to init (expected).
PUBLIC_STRIPE_PUBLISHABLE_KEY: import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_storybook_placeholder'
};
// Mock for SvelteKit's $app/env/public in Storybook, which has no SvelteKit server to provide it.
const env = import.meta.env;

export const PUBLIC_APP_VERSION = env.PUBLIC_APP_VERSION;
export const PUBLIC_BASE_URL = env.PUBLIC_BASE_URL;
export const PUBLIC_ENABLE_ACCOUNT_CREATION = env.PUBLIC_ENABLE_ACCOUNT_CREATION;
export const PUBLIC_ENABLE_SSL = env.PUBLIC_ENABLE_SSL;
export const PUBLIC_EXCEPTIONLESS_API_KEY = env.PUBLIC_EXCEPTIONLESS_API_KEY;
export const PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL = env.PUBLIC_EXCEPTIONLESS_CLIENT_SETUP_SHOW_SERVER_URL;
export const PUBLIC_EXCEPTIONLESS_SERVER_URL = env.PUBLIC_EXCEPTIONLESS_SERVER_URL;
export const PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL = env.PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL;
export const PUBLIC_FACEBOOK_APPID = env.PUBLIC_FACEBOOK_APPID;
export const PUBLIC_GITHUB_APPID = env.PUBLIC_GITHUB_APPID;
export const PUBLIC_GOOGLE_APPID = env.PUBLIC_GOOGLE_APPID;
export const PUBLIC_INTERCOM_APPID = env.PUBLIC_INTERCOM_APPID;
export const PUBLIC_MICROSOFT_APPID = env.PUBLIC_MICROSOFT_APPID;
export const PUBLIC_SLACK_APPID = env.PUBLIC_SLACK_APPID;
// Provide a Stripe publishable key so isStripeEnabled() returns true in
// billing stories. The StripeProvider will fail to init (expected).
export const PUBLIC_STRIPE_PUBLISHABLE_KEY = env.PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_storybook_placeholder';
export const PUBLIC_SYSTEM_NOTIFICATION_MESSAGE = env.PUBLIC_SYSTEM_NOTIFICATION_MESSAGE;
8 changes: 4 additions & 4 deletions src/Exceptionless.Web/ClientApp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ Use focused verification while iterating. Do not run broad Svelte validation aft
- Organize code by feature under `src/lib/features`; match the nearest existing feature before adding files.
- Use generated API types and feature-local `api.svelte.ts`, `models.ts`, `schemas.ts`, and `validators.ts` patterns.
- Use TanStack Query for server state and TanStack Form with Zod for forms.
- Use `createQueryParameters` from `$shared/query-params` for route query parameters instead of ad-hoc URL parsing.
- Prefer shared components and formatters from `$comp`, `$shared`, and `$lib` before creating new primitives.
- Use installed shadcn-svelte components from `$comp/ui/*`; check `components.json` and the `src/lib/features/shared/components/ui` directory before importing a component.
- Use `createQueryParameters` from `#shared/query-params/index.ts` for route query parameters instead of ad-hoc URL parsing.
- Prefer shared components and formatters from `#comp`, `#shared`, and `#lib` before creating new primitives. These are `package.json` subpath imports, so specifiers name the file on disk, such as `#lib/utils.ts`, `#comp/ui/button/index.ts`, or `#comp/logo.svelte`.
- Use installed shadcn-svelte components from `#comp/ui/*`; check `components.json` and the `src/lib/features/shared/components/ui` directory before importing a component.

## Applying Installed Skills

- Repository conventions govern application changes when an upstream skill uses generic examples. Keep the existing operational UI, theme, and typography; an ordinary component change does not require a redesign.
- Run component CLI commands from this directory with `npx shadcn-svelte@latest`. Resolve imports through `components.json`; this app uses `$comp/ui/*`. Use `@lucide/svelte` unless the app's configured icon library changes.
- Run component CLI commands from this directory with `npx shadcn-svelte@latest`. Resolve imports through `components.json`; this app uses `#comp/ui/*`. The CLI writes `.js` specifiers, so rename them to the `.ts` files they import after adding a component. Use `@lucide/svelte` unless the app's configured icon library changes.
- Import only installed components. Use existing `RadioGroup` or `Select` for options and `Card`, `Alert`, or other installed primitives for empty states; add `ToggleGroup`, `Empty`, or other registry components only when the requested design needs them.

## Local URLs
Expand Down
10 changes: 5 additions & 5 deletions src/Exceptionless.Web/ClientApp/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
"baseColor": "zinc"
},
"aliases": {
"components": "$comp",
"utils": "$lib/utils",
"ui": "$comp/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
"components": "#comp",
"utils": "#lib/utils",
"ui": "#comp/ui",
"hooks": "#lib/hooks",
"lib": "#lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
Expand Down
7 changes: 7 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/support/page-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { parse } from 'devalue';
Comment thread
ejsmith marked this conversation as resolved.

export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** Reads the `page.state` that SvelteKit stored with the current history entry. */
export async function getHistoryPageState(page: Page): Promise<Record<string, unknown> | undefined> {
const serialized = await page.evaluate(() => (window.history.state as null | { 'sveltekit:metadata'?: { state?: string } })?.['sveltekit:metadata']?.state);
return serialized ? (parse(serialized) as Record<string, unknown>) : undefined;
}

export function getIdFromUrl(page: Page, pattern: RegExp): string {
const match = pattern.exec(new URL(page.url()).pathname);
if (!match?.[1]) {
Expand Down
12 changes: 8 additions & 4 deletions src/Exceptionless.Web/ClientApp/e2e/support/table-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ const ORGANIZATION_ID = '000000000000000000000001';
const PROJECT_ID = '000000000000000000000002';
const USER_ID = '000000000000000000000003';

export async function mockTablePage(page: Page, rowCount: number): Promise<void> {
export interface MockTablePageOptions {
hasPremiumFeatures?: boolean;
}

export async function mockTablePage(page: Page, rowCount: number, { hasPremiumFeatures = true }: MockTablePageOptions = {}): Promise<void> {
await page.addInitScript(
({ organizationId }) => {
window.localStorage.setItem('satellizer_token', 'table-layout-token');
Expand All @@ -14,15 +18,15 @@ export async function mockTablePage(page: Page, rowCount: number): Promise<void>
);
await page.routeWebSocket('**/api/v2/push*', () => {});
await page.route('**/health', (route) => route.fulfill({ body: 'OK', contentType: 'text/plain' }));
await page.route('**/api/v2/**', (route) => fulfillTablePageRequest(route, rowCount));
await page.route('**/api/v2/**', (route) => fulfillTablePageRequest(route, rowCount, hasPremiumFeatures));
}

async function fulfillTablePageRequest(route: Route, rowCount: number): Promise<void> {
async function fulfillTablePageRequest(route: Route, rowCount: number, hasPremiumFeatures: boolean): Promise<void> {
const url = new URL(route.request().url());
const path = url.pathname;
const organization = {
features: [],
has_premium_features: true,
has_premium_features: hasPremiumFeatures,
id: ORGANIZATION_ID,
name: 'Layout Organization',
plan_id: 'EX_UNLIMITED',
Expand Down
5 changes: 3 additions & 2 deletions src/Exceptionless.Web/ClientApp/e2e/tests/api-origin.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ test('configured HTTPS preserves incoming links before starting HTTP API request
await page.route(`${httpOrigin}/**`, async (route) => {
const url = new URL(route.request().url());
const response = await route.fetch({ url: new URL(url.pathname + url.search, e2eApi.environment.appUrl).href });
if (url.pathname.endsWith('virtual:env/dynamic/public')) {
// The dev server's `$app/env/public` module exports values read from `__sveltekit_dev.env`.
if (url.pathname.endsWith('/generated/dev/env/public/client.js')) {
sslFlagInjected = true;
await route.fulfill({ body: `${await response.text()}\nenv.PUBLIC_ENABLE_SSL = 'true';`, response });
await route.fulfill({ body: `globalThis.__sveltekit_dev.env.PUBLIC_ENABLE_SSL = 'true';\n${await response.text()}`, response });
} else {
await route.fulfill({ response });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, type Page, test } from '@playwright/test';

import { mockTablePage } from '../support/table-layout';

test.afterEach(async ({ page }) => {
expect(await page.pageErrors()).toEqual([]);
});

test('the Sessions premium notice stays visible when Back returns to an earlier filter', async ({ page }) => {
// Arrange
await page.clock.install();
await mockTablePage(page, 0, { hasPremiumFeatures: false });
await page.goto('/sessions');
const notice = page.getByText('to enable Sessions and other premium features!');
await expect(notice).toBeVisible();

// Act: each filter change adds a query history entry to the same page, and Back returns to one.
await selectDateRange(page, 'Last 7 days', 'Last 30 days');
// Rapid changes share one entry, so let the first one settle.
await page.clock.fastForward(250);
await selectDateRange(page, 'Last 30 days', 'Last 24 hours');
await page.goBack();

// Assert
await expect(page).toHaveURL(/[?&]time=30d(?:&|$)/);
await expect(dateFilter(page, 'Last 30 days')).toBeVisible();
await expect(notice).toBeVisible();
});

function dateFilter(page: Page, range: string) {
return page.getByRole('button', { exact: true, name: `Date ${range}` });
}

async function selectDateRange(page: Page, from: string, to: string): Promise<void> {
await dateFilter(page, from).click();
await page.getByRole('button', { exact: true, name: to }).click();
await expect(dateFilter(page, to)).toBeVisible();
}
26 changes: 9 additions & 17 deletions src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Locator, Page, Request } from '@playwright/test';

import { expect, test } from '../fixtures/e2e-test';
import { ExceptionlessE2EJourney } from '../support/exceptionless-journey';
import { getVisibleText } from '../support/page-helpers';
import { getHistoryPageState, getVisibleText } from '../support/page-helpers';

test('home navigation honors personal and organization saved views and survives deletion', async ({ e2eApi, e2eScenario, page, request }) => {
// Arrange
Expand Down Expand Up @@ -97,6 +97,9 @@ test('home navigation honors personal and organization saved views and survives
await expect(page).toHaveURL(new RegExp(`/event/${escapeRegExp(viewSlug)}(?:[?#]|$)`));
});

// Assert: switching home views reuses in-flight requests instead of cancelling them.
expect(failedApiRequests).toEqual([]);

await test.step('clear deleted defaults and return to the first Stacks saved view', async () => {
// Act
const deletion = await page.evaluate(
Expand All @@ -113,17 +116,16 @@ test('home navigation honors personal and organization saved views and survives
},
{ organizationId: e2eScenario.organizationId, token: e2eScenario.userToken, viewName }
);
// Assert
// Assert: the open view no longer exists. The full-page error replaces the app shell,
// which cancels the shell's in-flight requests.
expect(deletion).toBe(202);
await expect(page.getByRole('heading', { name: "We couldn't find that page" })).toBeVisible();

// Act
await page.goto('/');
// Assert
await expect(page).toHaveURL(/\/stack\/all(?:[?#]|$)/);
});

// Assert
expect(failedApiRequests).toEqual([]);
});

test('saved view navigation order is personal, persistent, and resettable', async ({ e2eScenario, page, request }) => {
Expand Down Expand Up @@ -857,12 +859,7 @@ test('stream switches to a session-local saved view draft without mixing in-flig
await expect(getVisibleText(page, sourceMessage)).toBeHidden();
await expect(page.getByLabel('Unsaved view changes')).toBeVisible();
await expect
.poll(() =>
page.evaluate(() => {
const state = window.history.state as { 'sveltekit:states'?: { __exceptionlessSavedViewDraftFilter?: unknown } };
return state['sveltekit:states']?.__exceptionlessSavedViewDraftFilter;
})
)
.poll(async () => (await getHistoryPageState(page))?.__exceptionlessSavedViewDraftFilter)
.toEqual({ filter: new URL(page.url()).searchParams.get('filter'), viewId: savedView.id });

const updatedFilterDefinitions = JSON.stringify([
Expand Down Expand Up @@ -892,12 +889,7 @@ test('stream switches to a session-local saved view draft without mixing in-flig
streamRequestFilters.length = 0;
await page.reload();
await expect
.poll(() =>
page.evaluate(() => {
const state = window.history.state as { 'sveltekit:states'?: { __exceptionlessSavedViewDraftFilter?: unknown } };
return state['sveltekit:states']?.__exceptionlessSavedViewDraftFilter;
})
)
.poll(async () => (await getHistoryPageState(page))?.__exceptionlessSavedViewDraftFilter)
.toEqual({ filter: new URL(page.url()).searchParams.get('filter'), viewId: savedView.id });
await expect(page.getByRole('heading', { name: viewName })).toBeVisible();
await expect.poll(() => streamRequestFilters.join('\n')).toContain(remoteExpression);
Expand Down
Loading
Loading