From 872b5eca1b98028e29f21d37dca8ee69a3bb2601 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 18 Aug 2026 21:53:51 -0500 Subject: [PATCH 01/10] Fix saved view navigation and feedback regressions --- .../Pipeline/030_CheckForRegressionAction.cs | 2 +- ...aved-view-navigation-dialog.stories.svelte | 22 ++++++ .../saved-view-navigation-dialog.svelte | 28 +++++++ .../components/saved-view-picker.svelte | 75 ++++++++++++++++++- .../saved-views/use-saved-views.svelte.ts | 16 ++++ .../saved-views/use-saved-views.test.ts | 55 ++++++++++++++ .../status/service-status-redirect.test.ts | 12 ++- .../status/service-status-redirect.ts | 4 + .../ClientApp/src/routes/+layout.svelte | 4 +- 9 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte diff --git a/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs b/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs index d5b052ec72..f7a8c79a62 100644 --- a/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs +++ b/src/Exceptionless.Core/Pipeline/030_CheckForRegressionAction.cs @@ -70,7 +70,7 @@ public override async Task ProcessBatchAsync(ICollection contexts) } if (regressedContext is null) - return; + continue; _logger.LogDebug("Marking stack and events as regressed in version: {Version}", regressedVersion); stack.Status = StackStatus.Regressed; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.stories.svelte new file mode 100644 index 0000000000..e3f36158e9 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.stories.svelte @@ -0,0 +1,22 @@ + + + {}, + onSave: () => {}, + onStay: () => {}, + open: true, + saving: false + }} +/> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte new file mode 100644 index 0000000000..f9585f2c27 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte @@ -0,0 +1,28 @@ + + + + + + Save view changes? + You changed this view's settings. Save the changes before leaving, or discard them and continue? + + + Stay here + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 9287293041..21e677c9e7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -9,6 +9,8 @@ import type { ProblemDetails } from '@foundatiofx/fetchclient'; import type { StockFeatures, Table } from '@tanstack/svelte-table'; + import { beforeNavigate, goto } from '$app/navigation'; + import { page } from '$app/state'; import { Button } from '$comp/ui/button'; import * as DropdownMenu from '$comp/ui/dropdown-menu'; import { toFilter } from '$features/events/components/filters/helpers.svelte'; @@ -29,10 +31,12 @@ import { deleteSavedView, markSavedViewDeleted, patchSavedView, postSavedView, restoreDeletedSavedView } from '../api.svelte'; import { buildColumnSettings } from '../column-settings'; + import { shouldPromptForSavedViewNavigation } from '../use-saved-views.svelte'; import ColumnManagementDialog from './column-management-dialog.svelte'; import DeleteViewDialog from './delete-view-dialog.svelte'; import RenameViewDialog from './rename-view-dialog.svelte'; import SaveViewDialog from './save-view-dialog.svelte'; + import SavedViewNavigationDialog from './saved-view-navigation-dialog.svelte'; function getErrorMessage(error: unknown, fallback: string): string { const problem = error as ProblemDetails; @@ -96,7 +100,10 @@ let isRenameDialogOpen = $state(false); let isDeleteDialogOpen = $state(false); let isColumnDialogOpen = $state(false); + let isNavigationDialogOpen = $state(false); let isMenuOpen = $state(false); + let isNavigatingAfterSave = $state(false); + let pendingNavigation = $state(); let viewToDelete = $state(null); const organizationId = $derived(organization.current); @@ -245,19 +252,75 @@ }; } - async function handleUpdate() { + async function handleUpdate(): Promise { if (!activeView || !organizationId) { - return; + return false; } try { await updateMutation.mutateAsync(getUpdateBody()); toast.success(`View "${activeView.name}" saved.`); + return true; } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); + return false; + } + } + + function clearPendingNavigation(): void { + isNavigationDialogOpen = false; + pendingNavigation = undefined; + } + + async function continueNavigation(url: URL): Promise { + isNavigatingAfterSave = true; + clearPendingNavigation(); + try { + await goto(url); + } finally { + isNavigatingAfterSave = false; + } + } + + async function saveAndContinueNavigation(): Promise { + const url = pendingNavigation; + if (!url || saving || !(await handleUpdate())) { + return; } + + await continueNavigation(url); + } + + function discardAndContinueNavigation(): void { + const url = pendingNavigation; + if (!url) { + return; + } + + onResetToSaved(); + void continueNavigation(url); } + beforeNavigate(({ cancel, to, willUnload }) => { + const targetUrl = to?.url; + if ( + !targetUrl || + !shouldPromptForSavedViewNavigation({ + currentPathname: page.url.pathname, + isModified, + isNavigatingAfterSave, + targetPathname: targetUrl?.pathname ?? null, + willUnload + }) + ) { + return; + } + + cancel(); + pendingNavigation = targetUrl; + isNavigationDialogOpen = true; + }); + async function handleDelete() { if (!viewToDelete || !organizationId) { return; @@ -403,3 +466,11 @@ {#if isColumnDialogOpen} {/if} + + void saveAndContinueNavigation()} + onStay={clearPendingNavigation} + {saving} +/> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 59627e249f..3f9e0213f6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -162,6 +162,22 @@ export function setTimeQueryParam(queryParams: SavedViewQueryParams, value: null } } +export function shouldPromptForSavedViewNavigation(options: { + currentPathname: string; + isModified: boolean; + isNavigatingAfterSave: boolean; + targetPathname: null | string; + willUnload: boolean; +}): boolean { + return ( + !options.willUnload && + options.targetPathname !== null && + options.isModified && + !options.isNavigatingAfterSave && + options.targetPathname !== options.currentPathname + ); +} + export function supportsSortQueryParam(queryParams: SavedViewQueryParams): queryParams is SavedViewQueryParams & { sort: null | string | undefined } { return Object.prototype.hasOwnProperty.call(queryParams, 'sort'); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts index e3534d203a..26ed71381d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts @@ -18,6 +18,7 @@ import { type SavedViewQueryParams, setSortQueryParam, setTimeQueryParam, + shouldPromptForSavedViewNavigation, supportsSortQueryParam, supportsTimeQueryParam } from './use-saved-views.svelte'; @@ -63,6 +64,60 @@ function buildSavedView({ id, name, ...overrides }: Partial & Pick { + describe('saved view navigation prompts', () => { + it.each([ + { + currentPathname: '/next/event', + isModified: true, + isNavigatingAfterSave: false, + targetPathname: '/next/stack', + willUnload: false + }, + { + currentPathname: '/next/event', + isModified: true, + isNavigatingAfterSave: false, + targetPathname: '/next/event/production-errors', + willUnload: false + } + ])('prompts before leaving a modified saved view', (options) => { + expect(shouldPromptForSavedViewNavigation(options)).toBe(true); + }); + + it.each([ + { + currentPathname: '/next/event', + isModified: false, + isNavigatingAfterSave: false, + targetPathname: '/next/stack', + willUnload: false + }, + { + currentPathname: '/next/event', + isModified: true, + isNavigatingAfterSave: false, + targetPathname: '/next/event', + willUnload: false + }, + { + currentPathname: '/next/event', + isModified: true, + isNavigatingAfterSave: true, + targetPathname: '/next/stack', + willUnload: false + }, + { + currentPathname: '/next/event', + isModified: true, + isNavigatingAfterSave: false, + targetPathname: null, + willUnload: true + } + ])('does not prompt for same-page or already-approved navigation', (options) => { + expect(shouldPromptForSavedViewNavigation(options)).toBe(false); + }); + }); + describe('saved view slugs', () => { it('falls back to the normalized name for views created before slugs were stored', () => { // Arrange diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts index 1cbadc2ce5..f4e2b0abe6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildServiceStatusUrl, createServiceStatusRedirector } from './service-status-redirect'; +import { buildServiceStatusUrl, createServiceStatusRedirector, isServiceUnavailableStatus } from './service-status-redirect'; + +describe('isServiceUnavailableStatus', () => { + it.each([0, 408, 500, 503, 599])('treats %s as service unavailable', (status) => { + expect(isServiceUnavailableStatus(status)).toBe(true); + }); + + it.each([200, 400, 404, 429])('does not treat %s as service unavailable', (status) => { + expect(isServiceUnavailableStatus(status)).toBe(false); + }); +}); describe('buildServiceStatusUrl', () => { it('preserves the current path, query, and hash as an encoded redirect', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts index fdf3b90e60..31ea8961af 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts @@ -42,3 +42,7 @@ export function createServiceStatusRedirector(options: ServiceStatusRedirectorOp return redirectPromise; }; } + +export function isServiceUnavailableStatus(status: number): boolean { + return status === 0 || status === 408 || (status >= 500 && status < 600); +} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte index 3a8cfb960c..eef2219cc7 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte @@ -8,7 +8,7 @@ import { Toaster } from '$comp/ui/sonner'; import { accessToken } from '$features/auth/index.svelte'; import { handleUnexpectedUnauthorized } from '$features/auth/unauthorized'; - import { buildServiceStatusUrl, createServiceStatusRedirector } from '$features/status/service-status-redirect'; + import { buildServiceStatusUrl, createServiceStatusRedirector, isServiceUnavailableStatus } from '$features/status/service-status-redirect'; import { type FetchClientContext, ProblemDetails, setAccessTokenFunc, setBaseUrl, setRequestOptions, useMiddleware } from '@foundatiofx/fetchclient'; import { error } from '@sveltejs/kit'; import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; @@ -67,7 +67,7 @@ return; } else if (status === 404 && !ctx.options.expectedStatusCodes?.includes(404)) { throw error(404, 'Not found'); - } else if ([0, 408, 503].includes(status) && !ctx.options.expectedStatusCodes?.includes(status)) { + } else if (isServiceUnavailableStatus(status) && !ctx.options.expectedStatusCodes?.includes(status)) { if (page.url.pathname.startsWith(resolve('/status'))) { return; } From e7f663fb5b8aa67a9918565cfcfd993f29e47b65 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 18 Aug 2026 22:27:24 -0500 Subject: [PATCH 02/10] Simplify saved view navigation guard --- .../saved-view-navigation-dialog.svelte | 22 +-- .../saved-view-navigation-guard.svelte | 61 +++++++++ ...saved-view-navigation-guard.svelte.test.ts | 126 ++++++++++++++++++ .../components/saved-view-picker.svelte | 70 +--------- .../saved-views/use-saved-views.svelte.ts | 16 --- .../saved-views/use-saved-views.test.ts | 55 -------- 6 files changed, 203 insertions(+), 147 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte index f9585f2c27..0ddb91e327 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte @@ -11,18 +11,24 @@ } let { onDiscard, onSave, onStay, open = $bindable(), saving }: Props = $props(); + + function handleOpenChange(nextOpen: boolean): void { + if (!nextOpen) { + onStay(); + } + } - - + + - Save view changes? - You changed this view's settings. Save the changes before leaving, or discard them and continue? + Save changes to this view? + Your changes will be lost if you leave without saving. - - Stay here - - + + Cancel + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte new file mode 100644 index 0000000000..a855fb4528 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte @@ -0,0 +1,61 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts new file mode 100644 index 0000000000..92fc3e8b5c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts @@ -0,0 +1,126 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SavedViewNavigationGuard from './saved-view-navigation-guard.svelte'; + +const navigation = vi.hoisted(() => ({ + beforeNavigate: vi.fn(), + goto: vi.fn() +})); +const pageState = vi.hoisted(() => ({ + url: 'http://localhost/next/event' +})); + +vi.mock('$app/navigation', () => navigation); +vi.mock('$app/state', () => ({ + page: { + get url() { + return new URL(pageState.url); + } + } +})); + +interface NavigationAttempt { + cancel: () => void; + to: null | { url: URL }; + willUnload: boolean; +} + +function getBeforeNavigation(): (navigationAttempt: NavigationAttempt) => void { + const callback = navigation.beforeNavigate.mock.calls[0]?.[0]; + if (!callback) { + throw new Error('beforeNavigate was not registered'); + } + + return callback; +} + +describe('SavedViewNavigationGuard', () => { + beforeEach(() => { + vi.clearAllMocks(); + pageState.url = 'http://localhost/next/event'; + navigation.goto.mockResolvedValue(undefined); + }); + + it('allows navigation when the view is unchanged', () => { + const cancel = vi.fn(); + render(SavedViewNavigationGuard, { + isModified: false, + onDiscard: vi.fn(), + onSave: vi.fn(), + saving: false + }); + + getBeforeNavigation()({ + cancel, + to: { url: new URL('http://localhost/next/stack') }, + willUnload: false + }); + + expect(cancel).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('stays on the page when the user cancels navigation', async () => { + const cancel = vi.fn(); + render(SavedViewNavigationGuard, { + isModified: true, + onDiscard: vi.fn(), + onSave: vi.fn(), + saving: false + }); + + getBeforeNavigation()({ + cancel, + to: { url: new URL('http://localhost/next/stack') }, + willUnload: false + }); + + expect(cancel).toHaveBeenCalledOnce(); + expect(await screen.findByRole('alertdialog')).not.toBeNull(); + + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(navigation.goto).not.toHaveBeenCalled(); + }); + + it('discards changes before continuing to the requested page', async () => { + const onDiscard = vi.fn(); + const destination = new URL('http://localhost/next/stack'); + render(SavedViewNavigationGuard, { + isModified: true, + onDiscard, + onSave: vi.fn(), + saving: false + }); + + getBeforeNavigation()({ cancel: vi.fn(), to: { url: destination }, willUnload: false }); + await fireEvent.click(await screen.findByRole('button', { name: "Don't save" })); + + expect(onDiscard).toHaveBeenCalledOnce(); + expect(navigation.goto).toHaveBeenCalledWith(destination); + }); + + it('continues only after the view saves successfully', async () => { + const onSave = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const destination = new URL('http://localhost/next/stack'); + render(SavedViewNavigationGuard, { + isModified: true, + onDiscard: vi.fn(), + onSave, + saving: false + }); + + getBeforeNavigation()({ cancel: vi.fn(), to: { url: destination }, willUnload: false }); + await fireEvent.click(await screen.findByRole('button', { name: 'Save' })); + + expect(onSave).toHaveBeenCalledOnce(); + expect(navigation.goto).not.toHaveBeenCalled(); + expect(screen.getByRole('alertdialog')).not.toBeNull(); + + await fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(navigation.goto).toHaveBeenCalledWith(destination)); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index 21e677c9e7..28cc38010c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -9,8 +9,6 @@ import type { ProblemDetails } from '@foundatiofx/fetchclient'; import type { StockFeatures, Table } from '@tanstack/svelte-table'; - import { beforeNavigate, goto } from '$app/navigation'; - import { page } from '$app/state'; import { Button } from '$comp/ui/button'; import * as DropdownMenu from '$comp/ui/dropdown-menu'; import { toFilter } from '$features/events/components/filters/helpers.svelte'; @@ -31,12 +29,11 @@ import { deleteSavedView, markSavedViewDeleted, patchSavedView, postSavedView, restoreDeletedSavedView } from '../api.svelte'; import { buildColumnSettings } from '../column-settings'; - import { shouldPromptForSavedViewNavigation } from '../use-saved-views.svelte'; import ColumnManagementDialog from './column-management-dialog.svelte'; import DeleteViewDialog from './delete-view-dialog.svelte'; import RenameViewDialog from './rename-view-dialog.svelte'; import SaveViewDialog from './save-view-dialog.svelte'; - import SavedViewNavigationDialog from './saved-view-navigation-dialog.svelte'; + import SavedViewNavigationGuard from './saved-view-navigation-guard.svelte'; function getErrorMessage(error: unknown, fallback: string): string { const problem = error as ProblemDetails; @@ -100,10 +97,7 @@ let isRenameDialogOpen = $state(false); let isDeleteDialogOpen = $state(false); let isColumnDialogOpen = $state(false); - let isNavigationDialogOpen = $state(false); let isMenuOpen = $state(false); - let isNavigatingAfterSave = $state(false); - let pendingNavigation = $state(); let viewToDelete = $state(null); const organizationId = $derived(organization.current); @@ -267,60 +261,6 @@ } } - function clearPendingNavigation(): void { - isNavigationDialogOpen = false; - pendingNavigation = undefined; - } - - async function continueNavigation(url: URL): Promise { - isNavigatingAfterSave = true; - clearPendingNavigation(); - try { - await goto(url); - } finally { - isNavigatingAfterSave = false; - } - } - - async function saveAndContinueNavigation(): Promise { - const url = pendingNavigation; - if (!url || saving || !(await handleUpdate())) { - return; - } - - await continueNavigation(url); - } - - function discardAndContinueNavigation(): void { - const url = pendingNavigation; - if (!url) { - return; - } - - onResetToSaved(); - void continueNavigation(url); - } - - beforeNavigate(({ cancel, to, willUnload }) => { - const targetUrl = to?.url; - if ( - !targetUrl || - !shouldPromptForSavedViewNavigation({ - currentPathname: page.url.pathname, - isModified, - isNavigatingAfterSave, - targetPathname: targetUrl?.pathname ?? null, - willUnload - }) - ) { - return; - } - - cancel(); - pendingNavigation = targetUrl; - isNavigationDialogOpen = true; - }); - async function handleDelete() { if (!viewToDelete || !organizationId) { return; @@ -467,10 +407,4 @@ {/if} - void saveAndContinueNavigation()} - onStay={clearPendingNavigation} - {saving} -/> + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts index 3f9e0213f6..59627e249f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -162,22 +162,6 @@ export function setTimeQueryParam(queryParams: SavedViewQueryParams, value: null } } -export function shouldPromptForSavedViewNavigation(options: { - currentPathname: string; - isModified: boolean; - isNavigatingAfterSave: boolean; - targetPathname: null | string; - willUnload: boolean; -}): boolean { - return ( - !options.willUnload && - options.targetPathname !== null && - options.isModified && - !options.isNavigatingAfterSave && - options.targetPathname !== options.currentPathname - ); -} - export function supportsSortQueryParam(queryParams: SavedViewQueryParams): queryParams is SavedViewQueryParams & { sort: null | string | undefined } { return Object.prototype.hasOwnProperty.call(queryParams, 'sort'); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts index 26ed71381d..e3534d203a 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.test.ts @@ -18,7 +18,6 @@ import { type SavedViewQueryParams, setSortQueryParam, setTimeQueryParam, - shouldPromptForSavedViewNavigation, supportsSortQueryParam, supportsTimeQueryParam } from './use-saved-views.svelte'; @@ -64,60 +63,6 @@ function buildSavedView({ id, name, ...overrides }: Partial & Pick { - describe('saved view navigation prompts', () => { - it.each([ - { - currentPathname: '/next/event', - isModified: true, - isNavigatingAfterSave: false, - targetPathname: '/next/stack', - willUnload: false - }, - { - currentPathname: '/next/event', - isModified: true, - isNavigatingAfterSave: false, - targetPathname: '/next/event/production-errors', - willUnload: false - } - ])('prompts before leaving a modified saved view', (options) => { - expect(shouldPromptForSavedViewNavigation(options)).toBe(true); - }); - - it.each([ - { - currentPathname: '/next/event', - isModified: false, - isNavigatingAfterSave: false, - targetPathname: '/next/stack', - willUnload: false - }, - { - currentPathname: '/next/event', - isModified: true, - isNavigatingAfterSave: false, - targetPathname: '/next/event', - willUnload: false - }, - { - currentPathname: '/next/event', - isModified: true, - isNavigatingAfterSave: true, - targetPathname: '/next/stack', - willUnload: false - }, - { - currentPathname: '/next/event', - isModified: true, - isNavigatingAfterSave: false, - targetPathname: null, - willUnload: true - } - ])('does not prompt for same-page or already-approved navigation', (options) => { - expect(shouldPromptForSavedViewNavigation(options)).toBe(false); - }); - }); - describe('saved view slugs', () => { it('falls back to the normalized name for views created before slugs were stored', () => { // Arrange From fd749a81b24a0be7c2a9a39c8bdf0223e9895ce4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 18 Aug 2026 22:28:54 -0500 Subject: [PATCH 03/10] Cover multi-stack regression processing --- .../Pipeline/CheckForRegressionActionTests.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs diff --git a/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs b/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs new file mode 100644 index 0000000000..7fea4f8794 --- /dev/null +++ b/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs @@ -0,0 +1,86 @@ +using System.Reflection; +using Exceptionless.Core; +using Exceptionless.Core.Models; +using Exceptionless.Core.Pipeline; +using Exceptionless.Core.Plugins.EventProcessor; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Utility; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Exceptionless.Tests.Pipeline; + +public sealed class CheckForRegressionActionTests +{ + [Fact] + public async Task ProcessBatchAsync_ContinuesAfterStackWithoutRegression() + { + var repository = DispatchProxy.Create(); + var action = new CheckForRegressionAction( + repository, + new SemanticVersionParser(NullLoggerFactory.Instance), + CreateOptions(), + NullLoggerFactory.Instance + ); + var fixedAt = new DateTime(2026, 8, 18, 12, 0, 0, DateTimeKind.Utc); + var firstStack = CreateFixedStack("first", fixedAt); + var secondStack = CreateFixedStack("second", fixedAt); + + var firstContext = CreateContext(firstStack, fixedAt.AddMinutes(-1)); + var secondContext = CreateContext(secondStack, fixedAt.AddMinutes(1)); + + await action.ProcessBatchAsync([firstContext, secondContext]); + + Assert.False(firstContext.IsRegression); + Assert.Equal(StackStatus.Fixed, firstStack.Status); + Assert.True(secondContext.IsRegression); + Assert.Equal(StackStatus.Regressed, secondStack.Status); + } + + private static AppOptions CreateOptions() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [nameof(AppOptions.BaseURL)] = "http://localhost" + }) + .Build(); + + return AppOptions.ReadFromConfiguration(configuration); + } + + private static Stack CreateFixedStack(string id, DateTime fixedAt) + { + return new Stack + { + DateFixed = fixedAt, + Id = id, + Status = StackStatus.Fixed + }; + } + + private static EventContext CreateContext(Stack stack, DateTime eventDate) + { + var organization = new Organization { Id = stack.OrganizationId }; + var project = new Project { Id = stack.ProjectId }; + var ev = new PersistentEvent + { + Date = new DateTimeOffset(eventDate, TimeSpan.Zero), + StackId = stack.Id + }; + + return new EventContext(ev, organization, project) { Stack = stack }; + } + + public class StackRepositoryProxy : DispatchProxy + { + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == nameof(IStackRepository.MarkAsRegressedAsync)) + return Task.CompletedTask; + + throw new NotSupportedException($"Unexpected repository call: {targetMethod?.Name}"); + } + } +} From 24fe0e5c221d62725dea61c38c06783b5b6a06d1 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 18 Aug 2026 22:43:39 -0500 Subject: [PATCH 04/10] Cover saved view navigation confirmation --- .../ClientApp/e2e/tests/saved-views.e2e.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts index d4323356a5..f9a2bbe1a8 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts @@ -97,7 +97,7 @@ test('events saved view can be saved, renamed, loaded, and deleted', async ({ e2 expect(failedApiRequests).toEqual([]); }); -test('switching saved views discards temporary filter overrides', async ({ e2eApi, e2eScenario, page }) => { +test('switching saved views confirms before discarding temporary filter overrides', async ({ e2eApi, e2eScenario, page }) => { const failedApiRequests = captureFailedApiRequests(page); const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); const suffix = journey.run.slice(-28); @@ -117,6 +117,11 @@ test('switching saved views discards temporary filter overrides', async ({ e2eAp await expect(page).toHaveURL(/[?&]time=90d(?:&|$)/); await page.getByRole('link', { exact: true, name: secondViewName }).first().click(); + const navigationDialog = page.getByRole('alertdialog', { name: 'Save changes to this view?' }); + await expect(navigationDialog).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/next/event/${escapeRegExp(firstViewSlug)}\\?time=90d$`)); + await navigationDialog.getByRole('button', { name: "Don't save" }).click(); + await expect(page).toHaveURL(new RegExp(`/next/event/${escapeRegExp(secondViewSlug)}(?:[?#]|$)`)); await expect(page.getByRole('heading', { name: secondViewName })).toBeVisible(); await expect( From 56d86e1237dee85fe629551552fc11ad8f3f1978 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 19 Aug 2026 07:13:45 -0500 Subject: [PATCH 05/10] Guard saved view exits consistently --- .../saved-view-navigation-guard.svelte | 11 +++- ...saved-view-navigation-guard.svelte.test.ts | 55 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte index a855fb4528..1d837513b8 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte @@ -49,7 +49,16 @@ } beforeNavigate(({ cancel, to, willUnload }) => { - if (willUnload || !to || !isModified || isResumingNavigation || to.url.pathname === page.url.pathname) { + if (!isModified || isResumingNavigation) { + return; + } + + if (willUnload) { + cancel(); + return; + } + + if (!to || (to.url.pathname === page.url.pathname && to.url.searchParams.get('saved') === page.url.searchParams.get('saved'))) { return; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts index 92fc3e8b5c..21575082d2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts @@ -61,6 +61,61 @@ describe('SavedViewNavigationGuard', () => { expect(screen.queryByRole('alertdialog')).toBeNull(); }); + it('allows query changes within the active saved view', () => { + const cancel = vi.fn(); + pageState.url = 'http://localhost/next/stream?saved=first&filter=old'; + render(SavedViewNavigationGuard, { + isModified: true, + onDiscard: vi.fn(), + onSave: vi.fn(), + saving: false + }); + + getBeforeNavigation()({ + cancel, + to: { url: new URL('http://localhost/next/stream?saved=first&filter=new') }, + willUnload: false + }); + + expect(cancel).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('confirms before switching query-based saved views', async () => { + const cancel = vi.fn(); + pageState.url = 'http://localhost/next/stream?saved=first'; + render(SavedViewNavigationGuard, { + isModified: true, + onDiscard: vi.fn(), + onSave: vi.fn(), + saving: false + }); + + getBeforeNavigation()({ + cancel, + to: { url: new URL('http://localhost/next/stream?saved=second') }, + willUnload: false + }); + + expect(cancel).toHaveBeenCalledOnce(); + expect(await screen.findByRole('alertdialog')).not.toBeNull(); + }); + + it('requests native confirmation before unloading a modified view', () => { + const cancel = vi.fn(); + render(SavedViewNavigationGuard, { + isModified: true, + onDiscard: vi.fn(), + onSave: vi.fn(), + saving: false + }); + + getBeforeNavigation()({ cancel, to: null, willUnload: true }); + + expect(cancel).toHaveBeenCalledOnce(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + it('stays on the page when the user cancels navigation', async () => { const cancel = vi.fn(); render(SavedViewNavigationGuard, { From f27306f4c26d007e79758c409d473b7711ecb49f Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 19 Aug 2026 07:31:34 -0500 Subject: [PATCH 06/10] Preserve guarded history navigation --- .../saved-view-navigation-guard.svelte | 46 +++++++++++++------ ...saved-view-navigation-guard.svelte.test.ts | 29 +++++++++++- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte index 1d837513b8..0c631b6d18 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte @@ -11,45 +11,62 @@ saving: boolean; } + interface PendingNavigation { + delta: number | undefined; + url: URL; + } + let { isModified, onDiscard, onSave, saving }: Props = $props(); let isResumingNavigation = false; - let pendingNavigation = $state(); + let pendingNavigation = $state(); function stayOnPage(): void { pendingNavigation = undefined; } - async function resumeNavigation(url: URL): Promise { + async function resumeNavigation(navigation: PendingNavigation): Promise { isResumingNavigation = true; pendingNavigation = undefined; + + if (navigation.delta !== undefined) { + history.go(navigation.delta); + return; + } + try { - await goto(url); - } finally { + await goto(navigation.url); + } catch (error) { isResumingNavigation = false; + throw error; } } async function saveAndContinue(): Promise { - const url = pendingNavigation; - if (!url || saving || !(await onSave())) { + const navigation = pendingNavigation; + if (!navigation || saving || !(await onSave())) { return; } - await resumeNavigation(url); + await resumeNavigation(navigation); } function discardAndContinue(): void { - const url = pendingNavigation; - if (!url) { + const navigation = pendingNavigation; + if (!navigation) { return; } onDiscard(); - void resumeNavigation(url); + void resumeNavigation(navigation); } - beforeNavigate(({ cancel, to, willUnload }) => { - if (!isModified || isResumingNavigation) { + beforeNavigate(({ cancel, delta, to, type, willUnload }) => { + if (isResumingNavigation) { + isResumingNavigation = false; + return; + } + + if (!isModified) { return; } @@ -63,7 +80,10 @@ } cancel(); - pendingNavigation = to.url; + pendingNavigation = { + delta: type === 'popstate' ? delta : undefined, + url: to.url + }; }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts index 21575082d2..fca3a23318 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts @@ -1,5 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import SavedViewNavigationGuard from './saved-view-navigation-guard.svelte'; @@ -22,7 +22,9 @@ vi.mock('$app/state', () => ({ interface NavigationAttempt { cancel: () => void; + delta?: number; to: null | { url: URL }; + type?: 'goto' | 'popstate'; willUnload: boolean; } @@ -42,6 +44,10 @@ describe('SavedViewNavigationGuard', () => { navigation.goto.mockResolvedValue(undefined); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('allows navigation when the view is unchanged', () => { const cancel = vi.fn(); render(SavedViewNavigationGuard, { @@ -157,6 +163,27 @@ describe('SavedViewNavigationGuard', () => { expect(navigation.goto).toHaveBeenCalledWith(destination); }); + it('preserves browser history traversal after discarding changes', async () => { + const historyGo = vi.spyOn(history, 'go').mockImplementation(() => undefined); + const destination = new URL('http://localhost/next/event/previous'); + render(SavedViewNavigationGuard, { + isModified: true, + onDiscard: vi.fn(), + onSave: vi.fn(), + saving: false + }); + + getBeforeNavigation()({ cancel: vi.fn(), delta: -1, to: { url: destination }, type: 'popstate', willUnload: false }); + await fireEvent.click(await screen.findByRole('button', { name: "Don't save" })); + + expect(historyGo).toHaveBeenCalledWith(-1); + expect(navigation.goto).not.toHaveBeenCalled(); + + const replayCancel = vi.fn(); + getBeforeNavigation()({ cancel: replayCancel, delta: -1, to: { url: destination }, type: 'popstate', willUnload: false }); + expect(replayCancel).not.toHaveBeenCalled(); + }); + it('continues only after the view saves successfully', async () => { const onSave = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); const destination = new URL('http://localhost/next/stack'); From 19db9598aa4edb7aca4e5156d97dc3db8e53620a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 19 Aug 2026 07:46:16 -0500 Subject: [PATCH 07/10] Preserve status redirect replacement --- .../saved-view-navigation-guard.svelte | 11 ++++++++++- .../saved-view-navigation-guard.svelte.test.ts | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte index 0c631b6d18..f25dd17202 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte @@ -1,5 +1,6 @@ + {queryParameters.filter} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts index 066d219f08..7ab4b41a7f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts @@ -5,12 +5,14 @@ export interface CreateQueryParametersOptions { schema: T; } +export type QueryParameterHistory = 'push' | 'replace'; + export type QueryParameterInput = { [K in keyof T]: QueryParameterTypeOutput | undefined; }; export type QueryParameters = QueryParameterState & { - update: (values: Partial>) => void; + update: (values: Partial>, history?: QueryParameterHistory) => void; }; export type QueryParameterSchema = Record; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 53b9549071..68b64e0846 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -429,10 +429,10 @@ } ); - function handleResetToSaved(): void { + function handleResetToSaved(options?: Parameters[1]): void { isInternalFilterUpdate = false; - queryParams.update(LIST_FILTER_QUERY_PARAM_RESET); - savedViewsState.handleResetToSaved(); + queryParams.update(LIST_FILTER_QUERY_PARAM_RESET, options); + savedViewsState.handleResetToSaved(options); filters = getCurrentFilters(); } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index 00585957d1..13e85f75e4 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -423,10 +423,10 @@ } ); - function handleResetToSaved(): void { + function handleResetToSaved(options?: Parameters[1]): void { isInternalFilterUpdate = false; - queryParams.update(LIST_FILTER_QUERY_PARAM_RESET); - savedViewsState.handleResetToSaved(); + queryParams.update(LIST_FILTER_QUERY_PARAM_RESET, options); + savedViewsState.handleResetToSaved(options); filters = getCurrentFilters(); } From 236b244aa7413c58d09dbdde7b6b40838c994d53 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 19 Aug 2026 09:08:24 -0500 Subject: [PATCH 09/10] Remove saved view navigation prompt --- .../ClientApp/e2e/tests/saved-views.e2e.ts | 7 +- ...aved-view-navigation-dialog.stories.svelte | 22 -- .../saved-view-navigation-dialog.svelte | 34 --- .../saved-view-navigation-guard.svelte | 101 -------- ...saved-view-navigation-guard.svelte.test.ts | 227 ------------------ .../components/saved-view-picker.svelte | 19 +- .../saved-views/use-saved-views.svelte.ts | 31 +-- .../lib/features/shared/query-params/proxy.ts | 4 +- .../query-params/query-params.svelte.test.ts | 16 -- .../query-params/query-params.svelte.ts | 17 +- .../query-params.test-harness.svelte | 7 - .../lib/features/shared/query-params/types.ts | 4 +- .../src/routes/(app)/event/+page.svelte | 6 +- .../src/routes/(app)/stack/+page.svelte | 6 +- .../Pipeline/CheckForRegressionActionTests.cs | 20 +- 15 files changed, 31 insertions(+), 490 deletions(-) delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.stories.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte delete mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts index f9a2bbe1a8..d4323356a5 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts @@ -97,7 +97,7 @@ test('events saved view can be saved, renamed, loaded, and deleted', async ({ e2 expect(failedApiRequests).toEqual([]); }); -test('switching saved views confirms before discarding temporary filter overrides', async ({ e2eApi, e2eScenario, page }) => { +test('switching saved views discards temporary filter overrides', async ({ e2eApi, e2eScenario, page }) => { const failedApiRequests = captureFailedApiRequests(page); const journey = ExceptionlessE2EJourney.fromScenario(page, e2eApi, e2eScenario); const suffix = journey.run.slice(-28); @@ -117,11 +117,6 @@ test('switching saved views confirms before discarding temporary filter override await expect(page).toHaveURL(/[?&]time=90d(?:&|$)/); await page.getByRole('link', { exact: true, name: secondViewName }).first().click(); - const navigationDialog = page.getByRole('alertdialog', { name: 'Save changes to this view?' }); - await expect(navigationDialog).toBeVisible(); - await expect(page).toHaveURL(new RegExp(`/next/event/${escapeRegExp(firstViewSlug)}\\?time=90d$`)); - await navigationDialog.getByRole('button', { name: "Don't save" }).click(); - await expect(page).toHaveURL(new RegExp(`/next/event/${escapeRegExp(secondViewSlug)}(?:[?#]|$)`)); await expect(page.getByRole('heading', { name: secondViewName })).toBeVisible(); await expect( diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.stories.svelte deleted file mode 100644 index e3f36158e9..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.stories.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - {}, - onSave: () => {}, - onStay: () => {}, - open: true, - saving: false - }} -/> diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte deleted file mode 100644 index 0ddb91e327..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-dialog.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Save changes to this view? - Your changes will be lost if you leave without saving. - - - Cancel - - - - - diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte deleted file mode 100644 index 032ac9b67f..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte +++ /dev/null @@ -1,101 +0,0 @@ - - - diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts deleted file mode 100644 index 6b44f8df67..0000000000 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-navigation-guard.svelte.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import SavedViewNavigationGuard from './saved-view-navigation-guard.svelte'; - -const navigation = vi.hoisted(() => ({ - beforeNavigate: vi.fn(), - goto: vi.fn() -})); -const pageState = vi.hoisted(() => ({ - url: 'http://localhost/next/event' -})); - -vi.mock('$app/navigation', () => navigation); -vi.mock('$app/paths', () => ({ resolve: (path: string) => path })); -vi.mock('$app/state', () => ({ - page: { - get url() { - return new URL(pageState.url); - } - } -})); - -interface NavigationAttempt { - cancel: () => void; - delta?: number; - to: null | { url: URL }; - type?: 'goto' | 'popstate'; - willUnload: boolean; -} - -function getBeforeNavigation(): (navigationAttempt: NavigationAttempt) => void { - const callback = navigation.beforeNavigate.mock.calls[0]?.[0]; - if (!callback) { - throw new Error('beforeNavigate was not registered'); - } - - return callback; -} - -describe('SavedViewNavigationGuard', () => { - beforeEach(() => { - vi.clearAllMocks(); - pageState.url = 'http://localhost/next/event'; - navigation.goto.mockResolvedValue(undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('allows navigation when the view is unchanged', () => { - const cancel = vi.fn(); - render(SavedViewNavigationGuard, { - isModified: false, - onDiscard: vi.fn(), - onSave: vi.fn(), - saving: false - }); - - getBeforeNavigation()({ - cancel, - to: { url: new URL('http://localhost/next/stack') }, - willUnload: false - }); - - expect(cancel).not.toHaveBeenCalled(); - expect(screen.queryByRole('alertdialog')).toBeNull(); - }); - - it('allows query changes within the active saved view', () => { - const cancel = vi.fn(); - pageState.url = 'http://localhost/next/stream?saved=first&filter=old'; - render(SavedViewNavigationGuard, { - isModified: true, - onDiscard: vi.fn(), - onSave: vi.fn(), - saving: false - }); - - getBeforeNavigation()({ - cancel, - to: { url: new URL('http://localhost/next/stream?saved=first&filter=new') }, - willUnload: false - }); - - expect(cancel).not.toHaveBeenCalled(); - expect(screen.queryByRole('alertdialog')).toBeNull(); - }); - - it('confirms before switching query-based saved views', async () => { - const cancel = vi.fn(); - pageState.url = 'http://localhost/next/stream?saved=first'; - render(SavedViewNavigationGuard, { - isModified: true, - onDiscard: vi.fn(), - onSave: vi.fn(), - saving: false - }); - - getBeforeNavigation()({ - cancel, - to: { url: new URL('http://localhost/next/stream?saved=second') }, - willUnload: false - }); - - expect(cancel).toHaveBeenCalledOnce(); - expect(await screen.findByRole('alertdialog')).not.toBeNull(); - }); - - it('requests native confirmation before unloading a modified view', () => { - const cancel = vi.fn(); - render(SavedViewNavigationGuard, { - isModified: true, - onDiscard: vi.fn(), - onSave: vi.fn(), - saving: false - }); - - getBeforeNavigation()({ cancel, to: null, willUnload: true }); - - expect(cancel).toHaveBeenCalledOnce(); - expect(screen.queryByRole('alertdialog')).toBeNull(); - }); - - it('stays on the page when the user cancels navigation', async () => { - const cancel = vi.fn(); - render(SavedViewNavigationGuard, { - isModified: true, - onDiscard: vi.fn(), - onSave: vi.fn(), - saving: false - }); - - getBeforeNavigation()({ - cancel, - to: { url: new URL('http://localhost/next/stack') }, - willUnload: false - }); - - expect(cancel).toHaveBeenCalledOnce(); - expect(await screen.findByRole('alertdialog')).not.toBeNull(); - - await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); - - await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); - expect(navigation.goto).not.toHaveBeenCalled(); - }); - - it('discards changes before continuing to the requested page', async () => { - const onDiscard = vi.fn(); - const destination = new URL('http://localhost/next/stack'); - render(SavedViewNavigationGuard, { - isModified: true, - onDiscard, - onSave: vi.fn(), - saving: false - }); - - getBeforeNavigation()({ cancel: vi.fn(), to: { url: destination }, willUnload: false }); - await fireEvent.click(await screen.findByRole('button', { name: "Don't save" })); - - expect(onDiscard).toHaveBeenCalledOnce(); - expect(onDiscard).toHaveBeenCalledWith('replace'); - expect(navigation.goto).toHaveBeenCalledWith(destination); - }); - - it('preserves browser history traversal after discarding changes', async () => { - const historyGo = vi.spyOn(history, 'go').mockImplementation(() => undefined); - const destination = new URL('http://localhost/next/event/previous'); - const onDiscard = vi.fn(); - render(SavedViewNavigationGuard, { - isModified: true, - onDiscard, - onSave: vi.fn(), - saving: false - }); - - getBeforeNavigation()({ cancel: vi.fn(), delta: -1, to: { url: destination }, type: 'popstate', willUnload: false }); - await fireEvent.click(await screen.findByRole('button', { name: "Don't save" })); - - expect(onDiscard).toHaveBeenCalledWith('replace'); - expect(historyGo).toHaveBeenCalledWith(-1); - expect(navigation.goto).not.toHaveBeenCalled(); - - const replayCancel = vi.fn(); - getBeforeNavigation()({ cancel: replayCancel, delta: -1, to: { url: destination }, type: 'popstate', willUnload: false }); - expect(replayCancel).not.toHaveBeenCalled(); - }); - - it('preserves replacement when continuing to service status', async () => { - const destination = new URL('http://localhost/status?redirect=%2Fnext%2Fevent'); - render(SavedViewNavigationGuard, { - isModified: true, - onDiscard: vi.fn(), - onSave: vi.fn(), - saving: false - }); - - getBeforeNavigation()({ cancel: vi.fn(), to: { url: destination }, type: 'goto', willUnload: false }); - await fireEvent.click(await screen.findByRole('button', { name: "Don't save" })); - - expect(navigation.goto).toHaveBeenCalledWith(destination, { replaceState: true }); - }); - - it('continues only after the view saves successfully', async () => { - const onSave = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); - const destination = new URL('http://localhost/next/stack'); - render(SavedViewNavigationGuard, { - isModified: true, - onDiscard: vi.fn(), - onSave, - saving: false - }); - - getBeforeNavigation()({ cancel: vi.fn(), to: { url: destination }, willUnload: false }); - await fireEvent.click(await screen.findByRole('button', { name: 'Save' })); - - expect(onSave).toHaveBeenCalledOnce(); - expect(navigation.goto).not.toHaveBeenCalled(); - expect(screen.getByRole('alertdialog')).not.toBeNull(); - - await fireEvent.click(screen.getByRole('button', { name: 'Save' })); - - await waitFor(() => expect(navigation.goto).toHaveBeenCalledWith(destination)); - }); -}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte index bd6c913531..9287293041 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -6,7 +6,6 @@ - {queryParameters.filter} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts index 7ab4b41a7f..066d219f08 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/query-params/types.ts @@ -5,14 +5,12 @@ export interface CreateQueryParametersOptions { schema: T; } -export type QueryParameterHistory = 'push' | 'replace'; - export type QueryParameterInput = { [K in keyof T]: QueryParameterTypeOutput | undefined; }; export type QueryParameters = QueryParameterState & { - update: (values: Partial>, history?: QueryParameterHistory) => void; + update: (values: Partial>) => void; }; export type QueryParameterSchema = Record; diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte index 68b64e0846..53b9549071 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/event/+page.svelte @@ -429,10 +429,10 @@ } ); - function handleResetToSaved(options?: Parameters[1]): void { + function handleResetToSaved(): void { isInternalFilterUpdate = false; - queryParams.update(LIST_FILTER_QUERY_PARAM_RESET, options); - savedViewsState.handleResetToSaved(options); + queryParams.update(LIST_FILTER_QUERY_PARAM_RESET); + savedViewsState.handleResetToSaved(); filters = getCurrentFilters(); } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte index 13e85f75e4..00585957d1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stack/+page.svelte @@ -423,10 +423,10 @@ } ); - function handleResetToSaved(options?: Parameters[1]): void { + function handleResetToSaved(): void { isInternalFilterUpdate = false; - queryParams.update(LIST_FILTER_QUERY_PARAM_RESET, options); - savedViewsState.handleResetToSaved(options); + queryParams.update(LIST_FILTER_QUERY_PARAM_RESET); + savedViewsState.handleResetToSaved(); filters = getCurrentFilters(); } diff --git a/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs b/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs index 7fea4f8794..2c9d53a60d 100644 --- a/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs @@ -5,7 +5,6 @@ using Exceptionless.Core.Plugins.EventProcessor; using Exceptionless.Core.Repositories; using Exceptionless.Core.Utility; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -14,13 +13,14 @@ namespace Exceptionless.Tests.Pipeline; public sealed class CheckForRegressionActionTests { [Fact] - public async Task ProcessBatchAsync_ContinuesAfterStackWithoutRegression() + public async Task ProcessBatchAsync_StackWithoutRegression_ContinuesProcessingRemainingStacks() { + // Arrange var repository = DispatchProxy.Create(); var action = new CheckForRegressionAction( repository, new SemanticVersionParser(NullLoggerFactory.Instance), - CreateOptions(), + new AppOptions { DisabledPipelineActions = [] }, NullLoggerFactory.Instance ); var fixedAt = new DateTime(2026, 8, 18, 12, 0, 0, DateTimeKind.Utc); @@ -30,26 +30,16 @@ public async Task ProcessBatchAsync_ContinuesAfterStackWithoutRegression() var firstContext = CreateContext(firstStack, fixedAt.AddMinutes(-1)); var secondContext = CreateContext(secondStack, fixedAt.AddMinutes(1)); + // Act await action.ProcessBatchAsync([firstContext, secondContext]); + // Assert Assert.False(firstContext.IsRegression); Assert.Equal(StackStatus.Fixed, firstStack.Status); Assert.True(secondContext.IsRegression); Assert.Equal(StackStatus.Regressed, secondStack.Status); } - private static AppOptions CreateOptions() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [nameof(AppOptions.BaseURL)] = "http://localhost" - }) - .Build(); - - return AppOptions.ReadFromConfiguration(configuration); - } - private static Stack CreateFixedStack(string id, DateTime fixedAt) { return new Stack From d185d57b08eac2e4831ab06a1762bb7ee7bcc5a9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 20 Aug 2026 06:43:19 -0500 Subject: [PATCH 10/10] Address regression processing review feedback --- .../status/service-status-redirect.test.ts | 12 +-- .../status/service-status-redirect.ts | 4 - .../ClientApp/src/routes/+layout.svelte | 4 +- .../Pipeline/CheckForRegressionActionTests.cs | 76 ------------------- .../Pipeline/EventPipelineTests.cs | 40 ++++++++++ 5 files changed, 43 insertions(+), 93 deletions(-) delete mode 100644 tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts index f4e2b0abe6..1cbadc2ce5 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.test.ts @@ -1,16 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildServiceStatusUrl, createServiceStatusRedirector, isServiceUnavailableStatus } from './service-status-redirect'; - -describe('isServiceUnavailableStatus', () => { - it.each([0, 408, 500, 503, 599])('treats %s as service unavailable', (status) => { - expect(isServiceUnavailableStatus(status)).toBe(true); - }); - - it.each([200, 400, 404, 429])('does not treat %s as service unavailable', (status) => { - expect(isServiceUnavailableStatus(status)).toBe(false); - }); -}); +import { buildServiceStatusUrl, createServiceStatusRedirector } from './service-status-redirect'; describe('buildServiceStatusUrl', () => { it('preserves the current path, query, and hash as an encoded redirect', () => { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts index 31ea8961af..fdf3b90e60 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/status/service-status-redirect.ts @@ -42,7 +42,3 @@ export function createServiceStatusRedirector(options: ServiceStatusRedirectorOp return redirectPromise; }; } - -export function isServiceUnavailableStatus(status: number): boolean { - return status === 0 || status === 408 || (status >= 500 && status < 600); -} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte index eef2219cc7..3a8cfb960c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte @@ -8,7 +8,7 @@ import { Toaster } from '$comp/ui/sonner'; import { accessToken } from '$features/auth/index.svelte'; import { handleUnexpectedUnauthorized } from '$features/auth/unauthorized'; - import { buildServiceStatusUrl, createServiceStatusRedirector, isServiceUnavailableStatus } from '$features/status/service-status-redirect'; + import { buildServiceStatusUrl, createServiceStatusRedirector } from '$features/status/service-status-redirect'; import { type FetchClientContext, ProblemDetails, setAccessTokenFunc, setBaseUrl, setRequestOptions, useMiddleware } from '@foundatiofx/fetchclient'; import { error } from '@sveltejs/kit'; import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query'; @@ -67,7 +67,7 @@ return; } else if (status === 404 && !ctx.options.expectedStatusCodes?.includes(404)) { throw error(404, 'Not found'); - } else if (isServiceUnavailableStatus(status) && !ctx.options.expectedStatusCodes?.includes(status)) { + } else if ([0, 408, 503].includes(status) && !ctx.options.expectedStatusCodes?.includes(status)) { if (page.url.pathname.startsWith(resolve('/status'))) { return; } diff --git a/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs b/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs deleted file mode 100644 index 2c9d53a60d..0000000000 --- a/tests/Exceptionless.Tests/Pipeline/CheckForRegressionActionTests.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Reflection; -using Exceptionless.Core; -using Exceptionless.Core.Models; -using Exceptionless.Core.Pipeline; -using Exceptionless.Core.Plugins.EventProcessor; -using Exceptionless.Core.Repositories; -using Exceptionless.Core.Utility; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace Exceptionless.Tests.Pipeline; - -public sealed class CheckForRegressionActionTests -{ - [Fact] - public async Task ProcessBatchAsync_StackWithoutRegression_ContinuesProcessingRemainingStacks() - { - // Arrange - var repository = DispatchProxy.Create(); - var action = new CheckForRegressionAction( - repository, - new SemanticVersionParser(NullLoggerFactory.Instance), - new AppOptions { DisabledPipelineActions = [] }, - NullLoggerFactory.Instance - ); - var fixedAt = new DateTime(2026, 8, 18, 12, 0, 0, DateTimeKind.Utc); - var firstStack = CreateFixedStack("first", fixedAt); - var secondStack = CreateFixedStack("second", fixedAt); - - var firstContext = CreateContext(firstStack, fixedAt.AddMinutes(-1)); - var secondContext = CreateContext(secondStack, fixedAt.AddMinutes(1)); - - // Act - await action.ProcessBatchAsync([firstContext, secondContext]); - - // Assert - Assert.False(firstContext.IsRegression); - Assert.Equal(StackStatus.Fixed, firstStack.Status); - Assert.True(secondContext.IsRegression); - Assert.Equal(StackStatus.Regressed, secondStack.Status); - } - - private static Stack CreateFixedStack(string id, DateTime fixedAt) - { - return new Stack - { - DateFixed = fixedAt, - Id = id, - Status = StackStatus.Fixed - }; - } - - private static EventContext CreateContext(Stack stack, DateTime eventDate) - { - var organization = new Organization { Id = stack.OrganizationId }; - var project = new Project { Id = stack.ProjectId }; - var ev = new PersistentEvent - { - Date = new DateTimeOffset(eventDate, TimeSpan.Zero), - StackId = stack.Id - }; - - return new EventContext(ev, organization, project) { Stack = stack }; - } - - public class StackRepositoryProxy : DispatchProxy - { - protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) - { - if (targetMethod?.Name == nameof(IStackRepository.MarkAsRegressedAsync)) - return Task.CompletedTask; - - throw new NotSupportedException($"Unexpected repository call: {targetMethod?.Name}"); - } - } -} diff --git a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs index d7c94b962e..27f0ab42a6 100644 --- a/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs +++ b/tests/Exceptionless.Tests/Pipeline/EventPipelineTests.cs @@ -808,6 +808,46 @@ public async Task EnsureSingleRegressionAsync() Assert.Equal(2, contexts.Count(c => !c.IsRegression)); } + [Fact] + public async Task ProcessBatchAsync_ContinuesAfterStackWithoutRegression() + { + // Arrange + var fixedAtUtc = DateTime.UtcNow; + var organization = _organizationData.GenerateSampleOrganization(_billingManager, _plans); + var project = _projectData.GenerateSampleProject(); + var firstContext = await _pipeline.RunAsync( + _eventData.GenerateEvent(projectId: project.Id, organizationId: organization.Id, source: "first-stack", occurrenceDate: fixedAtUtc.AddMinutes(-2)), + organization, + project); + var secondContext = await _pipeline.RunAsync( + _eventData.GenerateEvent(projectId: project.Id, organizationId: organization.Id, source: "second-stack", occurrenceDate: fixedAtUtc.AddMinutes(-2)), + organization, + project); + + Assert.NotNull(firstContext.Stack); + Assert.NotNull(secondContext.Stack); + firstContext.Stack.MarkFixed(null, TimeProvider); + secondContext.Stack.MarkFixed(null, TimeProvider); + await _stackRepository.SaveAsync(firstContext.Stack, o => o.ImmediateConsistency().Cache()); + await _stackRepository.SaveAsync(secondContext.Stack, o => o.ImmediateConsistency().Cache()); + await RefreshDataAsync(); + + var contexts = new List + { + new(_eventData.GenerateEvent(stackId: firstContext.Stack.Id, projectId: project.Id, organizationId: organization.Id, occurrenceDate: fixedAtUtc.AddMinutes(-1)), organization, project), + new(_eventData.GenerateEvent(stackId: secondContext.Stack.Id, projectId: project.Id, organizationId: organization.Id, occurrenceDate: fixedAtUtc.AddMinutes(1)), organization, project) + }; + + // Act + await _pipeline.RunAsync(contexts); + + // Assert + Assert.False(contexts[0].IsRegression); + Assert.Equal(StackStatus.Fixed, contexts[0].Stack?.Status); + Assert.True(contexts[1].IsRegression); + Assert.Equal(StackStatus.Regressed, contexts[1].Stack?.Status); + } + [Fact] public async Task EnsureVersionedRegressionAsync() {