diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1d0725d975..939f022e44 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -279,6 +279,7 @@ jobs: - name: Start AppHost run: | + mkdir -p "$HOME/.aspnet/https" aspire run --non-interactive --nologo -- --ci-e2e > aspire-run.log 2>&1 & echo "$!" > aspire-run.pid sleep 5 @@ -288,10 +289,11 @@ jobs: fi - name: Wait for Aspire Resources + timeout-minutes: 5 run: | for attempt in {1..60}; do - if curl -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null && - curl -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null; then + if curl --connect-timeout 5 --max-time 20 -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null && + curl --connect-timeout 5 --max-time 20 -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null; then break fi @@ -310,8 +312,8 @@ jobs: - name: Verify E2E Endpoints run: | - curl -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null - curl -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null + curl --connect-timeout 5 --max-time 30 -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null + curl --connect-timeout 5 --max-time 30 -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null - name: Run Playwright E2E Tests working-directory: src/Exceptionless.Web/ClientApp @@ -320,6 +322,16 @@ jobs: E2E_RUN_ID: ci-${{ github.run_id }}-${{ github.run_attempt }} run: npm run test:e2e:ci + - name: Capture E2E Failure Diagnostics + if: ${{ failure() }} + run: | + mkdir -p aspire-logs + for resource in Api App OldApp; do + timeout 30s aspire logs "$resource" --apphost src/Exceptionless.AppHost --tail 200 --timestamps --non-interactive > "aspire-logs/$resource.log" 2>&1 || true + done + free -m > aspire-logs/memory.log + sudo dmesg --ctime | grep -Ei 'out of memory|killed process|oom' >> aspire-logs/memory.log || true + - name: Stop AppHost if: ${{ always() }} run: | diff --git a/AGENTS.md b/AGENTS.md index 3a61ee9835..a1d353f1a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,8 @@ tests/ # C# tests and HTTP samples - NuGet feeds are in `NuGet.Config` — don't add sources - Prefer additive documentation updates — don't replace strategic docs wholesale, extend them - **Backwards compatibility:** Never break existing public APIs, WebSocket message formats, config keys, or exported library interfaces without explicit user approval. Call out any breaking change as a BLOCKER in reviews. -- **API contracts:** When an endpoint's route, response, or authorization changes, update `tests/http/*.http` and `tests/Exceptionless.Tests/Api/Data/openapi.json`, then run the focused endpoint tests and `OpenApiSnapshotTests`. +- **API contracts:** When an endpoint's route, response, or authorization changes, update `tests/Exceptionless.Tests/Api/Data/openapi.json`, then run the focused endpoint tests and `OpenApiSnapshotTests`. +- **HTTP examples:** `tests/http/*.http` contains curated, runnable examples of useful API workflows. Add or update an example only when it helps users accomplish a meaningful task. Do not require a sample for every endpoint or change, duplicate OpenAPI documentation, or use these files as automated contract tests. - **Abbreviations:** Never abbreviate `Organization` as `org` in code (variable names, parameters, method names, or comments). Always spell out `organization`. - **Fix what you cause or block:** Fix regressions caused by the change and failures that block its verification. Report unrelated pre-existing issues with evidence; do not expand scope without approval. diff --git a/src/Exceptionless.Core/Exceptionless.Core.csproj b/src/Exceptionless.Core/Exceptionless.Core.csproj index d9ec2f997b..3bca65b57d 100644 --- a/src/Exceptionless.Core/Exceptionless.Core.csproj +++ b/src/Exceptionless.Core/Exceptionless.Core.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/Exceptionless.Core/Models/User.cs b/src/Exceptionless.Core/Models/User.cs index 8168c0302e..6af660fa6f 100644 --- a/src/Exceptionless.Core/Models/User.cs +++ b/src/Exceptionless.Core/Models/User.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Exceptionless.Core.Attributes; using Foundatio.Repositories.Models; @@ -25,6 +26,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject public ICollection OAuthAccounts { get; init; } = new Collection(); public ICollection OrganizationPreferences { get; init; } = new Collection(); public ICollection SavedViewOrders { get; init; } = new Collection(); + public IDictionary ProductTours { get; init; } = new Dictionary(StringComparer.Ordinal); /// /// Gets or sets the users Full Name. diff --git a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs index 4835daa616..8c8663aa3a 100644 --- a/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs +++ b/src/Exceptionless.Core/Repositories/Interfaces/IUserRepository.cs @@ -6,6 +6,7 @@ namespace Exceptionless.Core.Repositories; public interface IUserRepository : ISearchableRepository { + Task RecordProductTourAsync(User user, string stateKey, DateTime recordedUtc); Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor? options = null); Task GetByEmailAddressAsync(string emailAddress); Task GetByPasswordResetTokenAsync(string token); diff --git a/src/Exceptionless.Core/Repositories/UserRepository.cs b/src/Exceptionless.Core/Repositories/UserRepository.cs index 91919a6beb..abaa91716a 100644 --- a/src/Exceptionless.Core/Repositories/UserRepository.cs +++ b/src/Exceptionless.Core/Repositories/UserRepository.cs @@ -10,6 +10,8 @@ namespace Exceptionless.Core.Repositories; public class UserRepository : RepositoryBase, IUserRepository { + private const int MaximumProductTourEntries = 100; + public UserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) : base(configuration.Users, validator, options) { @@ -17,6 +19,39 @@ public UserRepository(ExceptionlessElasticConfiguration configuration, MiniValid AddRequiredField(u => u.EmailAddress, u => u.OrganizationIds); } + public async Task RecordProductTourAsync(User user, string stateKey, DateTime recordedUtc) + { + const string script = """ + if (ctx._source.product_tours == null) { + ctx._source.product_tours = [:]; + } + if (ctx._source.product_tours[params.key] instanceof String || + (!ctx._source.product_tours.containsKey(params.key) && ctx._source.product_tours.size() >= params.maximum_entries)) { + ctx.op = 'none'; + } else { + ctx._source.product_tours[params.key] = params.recorded_utc; + } + """; + + await PatchAsync(user.Id, new ScriptPatch(script) + { + Params = new Dictionary + { + ["key"] = stateKey, + ["maximum_entries"] = MaximumProductTourEntries, + ["recorded_utc"] = recordedUtc.ToString("O") + } + }); + await Cache.RemoveAsync(EmailCacheKey(user.EmailAddress)); + + var updatedUser = await GetByIdAsync(user.Id, o => o.Cache(false)); + // A concurrent writer may have advanced the document since this read; do not cache this snapshot. + if (updatedUser is not null) + await InvalidateCacheAsync(updatedUser); + + return updatedUser; + } + public Task SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor? options = null) { var savedViewOrders = user.SavedViewOrders.ToList(); diff --git a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs index 44ce3acd29..f486e97d1b 100644 --- a/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs +++ b/src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs @@ -37,6 +37,22 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder } }); + group.MapPut("users/me/product-tours/{tourName}/record", async (string tourName, IMediator mediator, IMediatorResultMapper resultMapper) + => (await mediator.InvokeAsync>(new UserMessages.RecordCurrentUserProductTour(tourName))).ToHttpResult(resultMapper)) + .Produces() + .ProducesProblem(StatusCodes.Status422UnprocessableEntity) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithSummary("Record current user product tour") + .WithMetadata(new EndpointDocumentation { + ParameterDescriptions = new() { + ["tourName"] = "A UI-defined product tour identifier using lowercase letters, digits, and hyphens (up to 64 characters).", + }, + ResponseDescriptions = new() { + ["422"] = "The product tour name is invalid or the limit of 100 recorded product tour entries has been reached.", + ["404"] = "The current user could not be found.", + } + }); + group.MapGet("users/me/oauth-grants", async (IMediator mediator, IMediatorResultMapper resultMapper) => (await mediator.InvokeAsync>>(new UserMessages.GetCurrentUserOAuthGrants())).ToHttpResult(resultMapper)) .Produces>() diff --git a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs index 8a517f525d..f875725b20 100644 --- a/src/Exceptionless.Web/Api/Handlers/UserHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/UserHandler.cs @@ -14,8 +14,9 @@ using Exceptionless.Web.Models.OAuth; using Exceptionless.Web.Utility; using Foundatio.Caching; -using Foundatio.Repositories; using Foundatio.Mediator; +using Foundatio.Repositories; +using Foundatio.Repositories.Exceptions; namespace Exceptionless.Web.Api.Handlers; @@ -39,7 +40,8 @@ public class UserHandler( public async Task> Handle(GetCurrentUser message) { - var currentUser = await GetModelAsync(GetCurrentUserId()); + // Preferences must reflect completed writes even if an in-flight lookup repopulates an older cache entry. + var currentUser = await GetModelAsync(GetCurrentUserId(), useCache: false); if (currentUser is null) return Result.NotFound("User not found."); @@ -49,6 +51,43 @@ public async Task> Handle(GetCurrentUser message) }; } + public async Task> Handle(RecordCurrentUserProductTour message) + { + if (message.TourName.Length is < 1 or > 64 || message.TourName.Any(c => !Char.IsAsciiLetterLower(c) && !Char.IsAsciiDigit(c) && c != '-')) + { + return Result.Invalid(ValidationError.Create("tour_name", "Use lowercase letters, digits, and hyphens for the product tour name.")); + } + + var currentUser = await GetModelAsync(GetCurrentUserId()); + if (currentUser is null) + { + return Result.NotFound("User not found."); + } + + // Keep the existing JSON keys while letting the UI define new tour identifiers. + string stateKey = message.TourName.Replace('-', '_'); + try + { + currentUser = await repository.RecordProductTourAsync(currentUser, stateKey, timeProvider.GetUtcNow().UtcDateTime); + } + catch (DocumentNotFoundException) + { + return Result.NotFound("User not found."); + } + + if (currentUser is null) + { + return Result.NotFound("User not found."); + } + + if (!currentUser.ProductTours.TryGetValue(stateKey, out var recorded)) + { + return Result.Invalid(ValidationError.Create("tour_name", "The maximum number of recorded product tours has been reached.")); + } + + return new RecordProductTourResult(recorded.GetDateTime()); + } + public async Task>> Handle(GetCurrentUserOAuthGrants message) { var tokens = new List(); diff --git a/src/Exceptionless.Web/Api/Messages/UserMessages.cs b/src/Exceptionless.Web/Api/Messages/UserMessages.cs index 7cc329710b..11542a3f2c 100644 --- a/src/Exceptionless.Web/Api/Messages/UserMessages.cs +++ b/src/Exceptionless.Web/Api/Messages/UserMessages.cs @@ -6,6 +6,7 @@ namespace Exceptionless.Web.Api.Messages; public record GetCurrentUser; public record GetCurrentUserOAuthGrants; public record RevokeCurrentUserOAuthGrant(string Id); +public record RecordCurrentUserProductTour(string TourName); public record GetUserById(string Id); public record GetUsersByOrganization(string OrganizationId, int Page, int Limit); public record UpdateUserMessage(string Id, Delta Changes); diff --git a/src/Exceptionless.Web/ClientApp/.gitignore b/src/Exceptionless.Web/ClientApp/.gitignore index 246cecff7e..e21726351b 100644 --- a/src/Exceptionless.Web/ClientApp/.gitignore +++ b/src/Exceptionless.Web/ClientApp/.gitignore @@ -1,4 +1,5 @@ test-results +playwright-report node_modules # Output diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts index 8b3f0f6eaf..c8193979b4 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts @@ -22,6 +22,7 @@ export interface E2EOrganization { export interface E2EProject { id: string; + is_configured?: boolean; name: string; organization_id?: string; } @@ -38,10 +39,10 @@ interface TokenResult { export class E2EApiClient { constructor( private readonly request: APIRequestContext, - readonly environment: E2EEnvironment + public readonly environment: E2EEnvironment ) {} - async createOrganization(token: string, name: string): Promise { + public async createOrganization(token: string, name: string): Promise { const response = await this.request.post(this.url('organizations'), { data: { name }, headers: this.authHeaders(token) @@ -51,7 +52,7 @@ export class E2EApiClient { return toOrganization(await readJson(response)); } - async createProject(token: string, organizationId: string, name: string): Promise { + public async createProject(token: string, organizationId: string, name: string): Promise { const response = await this.request.post(this.url('projects'), { data: { delete_bot_data_enabled: true, @@ -62,10 +63,23 @@ export class E2EApiClient { }); await expectStatus(response, [201], 'create project'); - return toProject(await readJson(response)); + const project = toProject(await readJson(response)); + await waitForCondition( + async () => { + const listed = await this.request.get(this.url(`organizations/${organizationId}/projects`), { + headers: this.authHeaders(token) + }); + await expectStatus(listed, [200], 'list projects'); + const projects = await readJson(listed); + return Array.isArray(projects) && projects.some((item) => toProject(item).id === project.id); + }, + 30_000, + `Timed out waiting for E2E project ${project.id} to appear in the projects list` + ); + return project; } - async deleteCurrentUser(token: string): Promise { + public async deleteCurrentUser(token: string): Promise { const response = await this.request.delete(this.url('users/me'), { headers: this.authHeaders(token) }); @@ -74,7 +88,7 @@ export class E2EApiClient { return response.status(); } - async deleteOrganization(token: string, organizationId: string): Promise { + public async deleteOrganization(token: string, organizationId: string): Promise { const response = await this.request.delete(this.url(`organizations/${organizationId}`), { headers: this.authHeaders(token) }); @@ -83,7 +97,7 @@ export class E2EApiClient { return response.status(); } - async deleteOrganizationUser(token: string, organizationId: string, email: string): Promise { + public async deleteOrganizationUser(token: string, organizationId: string, email: string): Promise { const response = await this.request.delete(this.url(`organizations/${organizationId}/users/${encodeURIComponent(email)}`), { headers: this.authHeaders(token) }); @@ -92,7 +106,7 @@ export class E2EApiClient { return response.status(); } - async deleteProject(token: string, projectId: string): Promise { + public async deleteProject(token: string, projectId: string): Promise { const response = await this.request.delete(this.url(`projects/${projectId}`), { headers: this.authHeaders(token) }); @@ -101,14 +115,14 @@ export class E2EApiClient { return response.status(); } - async getAbout(): Promise> { + public async getAbout(): Promise> { const response = await this.request.get(this.url('about')); await expectStatus(response, [200], 'get about'); return toRecord(await readJson(response), 'about response'); } - async getCurrentUser(token: string): Promise { + public async getCurrentUser(token: string): Promise { const response = await this.request.get(this.url('users/me'), { headers: this.authHeaders(token) }); @@ -122,7 +136,7 @@ export class E2EApiClient { return toCurrentUser(await readJson(response)); } - async getEvent(token: string, eventId: string): Promise { + public async getEvent(token: string, eventId: string): Promise { const response = await this.request.get(this.url(`events/${eventId}`), { headers: this.authHeaders(token) }); @@ -131,7 +145,7 @@ export class E2EApiClient { return toEvent(await readJson(response)); } - async getEventsByReference(token: string, projectId: string, referenceId: string): Promise { + public async getEventsByReference(token: string, projectId: string, referenceId: string): Promise { const response = await this.request.get(this.url(`projects/${projectId}/events/by-ref/${encodeURIComponent(referenceId)}`), { headers: this.authHeaders(token) }); @@ -145,7 +159,7 @@ export class E2EApiClient { return toEventArray(await readJson(response)); } - async getOrganization(token: string, organizationId: string): Promise { + public async getOrganization(token: string, organizationId: string): Promise { const response = await this.request.get(this.url(`organizations/${organizationId}`), { headers: this.authHeaders(token) }); @@ -159,7 +173,7 @@ export class E2EApiClient { return toOrganization(await readJson(response)); } - async getOrganizations(token: string): Promise { + public async getOrganizations(token: string): Promise { const response = await this.request.get(this.url('organizations'), { headers: this.authHeaders(token) }); @@ -168,7 +182,7 @@ export class E2EApiClient { return toOrganizationArray(await readJson(response)); } - async getProject(token: string, projectId: string): Promise { + public async getProject(token: string, projectId: string): Promise { const response = await this.request.get(this.url(`projects/${projectId}`), { headers: this.authHeaders(token) }); @@ -182,7 +196,7 @@ export class E2EApiClient { return toProject(await readJson(response)); } - async getProjectDefaultToken(token: string, projectId: string): Promise { + public async getProjectDefaultToken(token: string, projectId: string): Promise { const response = await this.request.get(this.url(`projects/${projectId}/tokens/default`), { headers: this.authHeaders(token) }); @@ -191,7 +205,7 @@ export class E2EApiClient { return toToken(await readJson(response)); } - async inviteOrganizationUser(token: string, organizationId: string, email: string): Promise { + public async inviteOrganizationUser(token: string, organizationId: string, email: string): Promise { const response = await this.request.post(this.url(`organizations/${organizationId}/users/${encodeURIComponent(email)}`), { headers: this.authHeaders(token) }); @@ -199,7 +213,7 @@ export class E2EApiClient { await expectStatus(response, [200], 'invite organization user'); } - async login(email = this.environment.email, password = this.environment.password): Promise { + public async login(email = this.environment.email, password = this.environment.password): Promise { const token = await this.loginIfExists(email, password); if (!token) { throw new Error('login failed with status 401'); @@ -208,7 +222,7 @@ export class E2EApiClient { return token; } - async loginIfExists(email: string, password: string): Promise { + public async loginIfExists(email: string, password: string): Promise { if (!email || !password) { throw new Error('Email and password are required when using API login.'); } @@ -230,7 +244,7 @@ export class E2EApiClient { return result.token; } - async pollForEventByReference(token: string, projectId: string, referenceId: string, timeoutMs = 90_000): Promise { + public async pollForEventByReference(token: string, projectId: string, referenceId: string, timeoutMs = 90_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -247,7 +261,7 @@ export class E2EApiClient { throw new Error(`Timed out waiting for E2E event with reference id ${referenceId}`); } - async pollForMailToken(email: string, path: 'reset-password' | 'signup', timeoutMs = 30_000): Promise { + public async pollForMailToken(email: string, path: 'reset-password' | 'signup', timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; const normalizedEmail = email.toLowerCase(); @@ -285,10 +299,19 @@ export class E2EApiClient { throw new Error(`Timed out waiting for ${path} email sent to ${email}`); } - async signup(name: string, email: string, password: string): Promise { + public async recordProductTour(token: string, tourName: string): Promise { + const response = await this.request.put(this.url(`users/me/product-tours/${tourName}/record`), { + headers: this.authHeaders(token) + }); + + await expectStatus(response, [200], 'record product tour'); + } + + public async signup(name: string, email: string, password: string, inviteToken?: string): Promise { const response = await this.request.post(this.url('auth/signup'), { data: { email, + invite_token: inviteToken, name, password } @@ -300,7 +323,7 @@ export class E2EApiClient { return result.token; } - async submitEvent(projectId: string, projectToken: string, event: Record): Promise { + public async submitEvent(projectId: string, projectToken: string, event: Record): Promise { const response = await this.request.post(this.url(`projects/${projectId}/events`), { data: event, headers: this.authHeaders(projectToken) @@ -309,7 +332,7 @@ export class E2EApiClient { await expectStatus(response, [202], 'submit event'); } - async waitForCurrentUserDeleted(token: string, timeoutMs = 30_000): Promise { + public async waitForCurrentUserDeleted(token: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => !(await this.getCurrentUser(token)), timeoutMs, @@ -317,7 +340,33 @@ export class E2EApiClient { ); } - async waitForOrganizationDeleted(token: string, organizationId: string, timeoutMs = 30_000): Promise { + public async waitForInvitationListed(token: string, organizationId: string, inviteToken: string): Promise { + await waitForCondition( + async () => { + const response = await this.request.get(this.url('organizations'), { + headers: this.authHeaders(token), + params: { filter: `id:${organizationId}` } + }); + await expectStatus(response, [200], 'find indexed invitation'); + const organizations = await readJson(response); + return ( + Array.isArray(organizations) && + organizations.some((value) => { + const organization = toRecord(value, 'organization'); + return ( + organization.id === organizationId && + Array.isArray(organization.invites) && + organization.invites.some((invite) => toRecord(invite, 'invitation').token === inviteToken) + ); + }) + ); + }, + 30_000, + 'Timed out waiting for the test invitation to be indexed' + ); + } + + public async waitForOrganizationDeleted(token: string, organizationId: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => !(await this.getOrganization(token, organizationId)), timeoutMs, @@ -325,7 +374,7 @@ export class E2EApiClient { ); } - async waitForOrganizationListed(token: string, organizationId: string, timeoutMs = 30_000): Promise { + public async waitForOrganizationListed(token: string, organizationId: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => (await this.getOrganizations(token)).some((organization) => organization.id === organizationId), timeoutMs, @@ -333,7 +382,7 @@ export class E2EApiClient { ); } - async waitForOrganizationNotListed(token: string, organizationId: string, timeoutMs = 30_000): Promise { + public async waitForOrganizationNotListed(token: string, organizationId: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => !(await this.getOrganizations(token)).some((organization) => organization.id === organizationId), timeoutMs, @@ -341,7 +390,7 @@ export class E2EApiClient { ); } - async waitForProjectDeleted(token: string, projectId: string, timeoutMs = 30_000): Promise { + public async waitForProjectDeleted(token: string, projectId: string, timeoutMs = 30_000): Promise { await waitForCondition( async () => !(await this.getProject(token, projectId)), timeoutMs, @@ -460,6 +509,7 @@ function toProject(value: unknown): E2EProject { return { id: getRequiredString(record, 'id', 'project response'), + is_configured: typeof record.is_configured === 'boolean' ? record.is_configured : undefined, name: getRequiredString(record, 'name', 'project response'), organization_id: getOptionalString(record, 'organization_id') }; diff --git a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts index 154144230d..ba182a0928 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts @@ -43,10 +43,13 @@ export interface E2ESecondaryProject { interface E2EFixtures { e2eApi: E2EApiClient; e2eCleanupPassword: string; + e2eDismissProductTourWelcome: boolean; e2eScenario: E2EScenario; e2eSecondaryOrganization: E2ESecondaryOrganization; e2eSecondaryProject: E2ESecondaryProject; e2eUseGeneratedUser: boolean; + e2eUseInvitedUser: boolean; + e2eUserInvitation: undefined | { organizationId: string; token: string }; effectDepthGuard: void; } @@ -57,7 +60,9 @@ export const test = base.extend({ e2eCleanupPassword: [E2E_TEST_PASSWORD, { option: true }], - e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eUseGeneratedUser, page }, use, testInfo) => { + e2eDismissProductTourWelcome: [true, { option: true }], + + e2eScenario: async ({ e2eApi, e2eCleanupPassword, e2eDismissProductTourWelcome, e2eUseGeneratedUser, e2eUserInvitation, page }, use, testInfo) => { const run = createRunName(e2eApi.environment.runId, testInfo); const userName = `Playwright User ${run}`; const email = `playwright-${run}@exceptionless.test`.toLowerCase(); @@ -72,19 +77,26 @@ export const test = base.extend({ let generatedUserSignupAttempted = false; try { - if (!e2eUseGeneratedUser && !e2eApi.environment.isProduction && e2eApi.environment.email && e2eApi.environment.password) { + if (!e2eUseGeneratedUser && !e2eUserInvitation && !e2eApi.environment.isProduction && e2eApi.environment.email && e2eApi.environment.password) { userToken = await e2eApi.login(); } else { generatedUserSignupAttempted = true; - userToken = await e2eApi.signup(userName, email, E2E_TEST_PASSWORD); + userToken = await e2eApi.signup(userName, email, E2E_TEST_PASSWORD, e2eUserInvitation?.token); createdUser = true; } const organization = await e2eApi.createOrganization(userToken, organizationName); organizationId = organization.id; + if (e2eUserInvitation) { + await e2eApi.deleteOrganizationUser(userToken, e2eUserInvitation.organizationId, email); + await e2eApi.waitForOrganizationNotListed(userToken, e2eUserInvitation.organizationId); + } const project = await e2eApi.createProject(userToken, organization.id, projectName); projectId = project.id; const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id); + if (e2eDismissProductTourWelcome) { + await e2eApi.recordProductTour(userToken, 'app-welcome'); + } await page.addInitScript( ({ organizationId, token }) => { @@ -232,6 +244,33 @@ export const test = base.extend({ e2eUseGeneratedUser: [false, { option: true }], + e2eUseInvitedUser: [false, { option: true }], + + e2eUserInvitation: async ({ e2eApi, e2eUseInvitedUser }, use, testInfo) => { + if (!e2eUseInvitedUser) { + await use(undefined); + return; + } + + if (e2eApi.environment.isProduction) { + throw new Error('Invited test users require local Mailpit.'); + } + + const run = createRunName(e2eApi.environment.runId, testInfo); + const email = `playwright-${run}@exceptionless.test`.toLowerCase(); + const ownerToken = await e2eApi.login(); + const organization = await e2eApi.createOrganization(ownerToken, `${E2E_ORGANIZATION_NAME_PREFIX} Invitations ${run}`); + try { + await e2eApi.inviteOrganizationUser(ownerToken, organization.id, email); + const inviteToken = await e2eApi.pollForMailToken(email, 'signup'); + await e2eApi.waitForInvitationListed(ownerToken, organization.id, inviteToken); + await use({ organizationId: organization.id, token: inviteToken }); + } finally { + await e2eApi.deleteOrganization(ownerToken, organization.id); + await e2eApi.waitForOrganizationDeleted(ownerToken, organization.id); + } + }, + effectDepthGuard: [ async ({ page }, use) => { const errors = new Set(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts index 5844adad6f..f3b8a303af 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/support/exceptionless-journey.ts @@ -20,19 +20,19 @@ import { const FIXED_VERSION = '1.0.0'; export class ExceptionlessE2EJourney { - email: string; - eventId?: string; - message: string; - organizationId?: string; - organizationName: string; - projectId?: string; - projectName: string; - projectToken?: string; - referenceId: string; - run: string; - stackId?: string; - userName: string; - userToken?: string; + public email: string; + public eventId?: string; + public message: string; + public organizationId?: string; + public organizationName: string; + public projectId?: string; + public projectName: string; + public projectToken?: string; + public referenceId: string; + public run: string; + public stackId?: string; + public userName: string; + public userToken?: string; constructor( private readonly page: Page, @@ -65,11 +65,11 @@ export class ExceptionlessE2EJourney { this.message = `Playwright onboarding event ${this.run}`; } - static fromScenario(page: Page, e2eApi: E2EApiClient, scenario: E2EScenario): ExceptionlessE2EJourney { + public static fromScenario(page: Page, e2eApi: E2EApiClient, scenario: E2EScenario): ExceptionlessE2EJourney { return new ExceptionlessE2EJourney(page, e2eApi, scenario); } - async cleanup(): Promise { + public async cleanup(): Promise { if (!this.userToken) { return; } @@ -104,7 +104,7 @@ export class ExceptionlessE2EJourney { throwIfCleanupFailed(errors); } - async createFirstProjectAndVerifyConfigureToken(): Promise { + public async createFirstProjectAndVerifyConfigureToken(): Promise { if (this.projectId) { await this.page.goto(`/next/project/${this.projectId}/configure`); } else { @@ -127,7 +127,7 @@ export class ExceptionlessE2EJourney { this.projectToken = await getProjectTokenFromConfigurePage(this.page); } - async expectEventDetails(): Promise { + public async expectEventDetails(): Promise { expect(this.eventId).toBeTruthy(); await this.page.goto(`/next/event/${this.eventId}`); @@ -163,7 +163,7 @@ export class ExceptionlessE2EJourney { await expect(getVisibleText(this.page, this.e2eApi.environment.runId)).toBeVisible(); } - async expectEventInPrimaryViews(): Promise { + public async expectEventInPrimaryViews(): Promise { await this.page.goto('/next/event'); await expect(this.page.getByRole('heading', { name: 'Events' })).toBeVisible(); await expect(getVisibleText(this.page, this.message)).toBeVisible({ timeout: 30_000 }); @@ -177,7 +177,7 @@ export class ExceptionlessE2EJourney { await expect(getVisibleText(this.page, this.message)).toBeVisible({ timeout: 30_000 }); } - async markStackFixed(version = FIXED_VERSION): Promise { + public async markStackFixed(version = FIXED_VERSION): Promise { expect(this.stackId).toBeTruthy(); await this.expectEventDetails(); @@ -196,12 +196,12 @@ export class ExceptionlessE2EJourney { }).toPass({ intervals: [1_000, 2_000, 5_000], timeout: 30_000 }); } - async onboardProject(): Promise { + public async onboardProject(): Promise { await this.signUpAndCreateOrganization(); await this.createFirstProjectAndVerifyConfigureToken(); } - async signUpAndCreateOrganization(): Promise { + public async signUpAndCreateOrganization(): Promise { await this.page.goto('/next/signup'); await this.page.getByLabel('Name', { exact: true }).fill(this.userName); @@ -229,7 +229,7 @@ export class ExceptionlessE2EJourney { await expect(this.page.getByRole('button', { name: 'Please select a project type' })).toBeVisible(); } - async submitRepresentativeEvent(): Promise { + public async submitRepresentativeEvent(): Promise { expect(this.projectId).toBeTruthy(); expect(this.projectToken).toBeTruthy(); expect(this.userToken).toBeTruthy(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts index 729f62728e..09e58f92de 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/chart-refresh.e2e.ts @@ -3,7 +3,9 @@ import type { Page, Route } from '@playwright/test'; import { expect, test } from '../fixtures/e2e-test'; test('dashboard charts stay mounted while list data refreshes', async ({ e2eApi, page }) => { + // Arrange const userToken = await e2eApi.login(); + await e2eApi.recordProductTour(userToken, 'app-welcome'); const organizations = await e2eApi.getOrganizations(userToken); const organizationId = organizations[0]?.id; expect(organizationId).toBeTruthy(); @@ -16,9 +18,10 @@ test('dashboard charts stay mounted while list data refreshes', async ({ e2eApi, { organizationId, token: userToken } ); - await verifyChartRefresh(page, '/next/stack', (route) => isOrganizationEventListRequest(route, organizationId!, 'stack_frequent')); - await verifyChartRefresh(page, '/next/event', (route) => isOrganizationEventListRequest(route, organizationId!, 'summary')); - await verifyChartRefresh(page, '/next/sessions', (route) => { + // Act & Assert: the helper refreshes each dashboard and checks that its chart stays mounted. + await verifyChartRefresh(page, '/next/stack/all', (route) => isOrganizationEventListRequest(route, organizationId!, 'stack_frequent')); + await verifyChartRefresh(page, '/next/event/all', (route) => isOrganizationEventListRequest(route, organizationId!, 'summary')); + await verifyChartRefresh(page, '/next/sessions/all', (route) => { return new URL(route.request().url()).pathname === `/api/v2/organizations/${organizationId}/events/sessions`; }); }); @@ -29,6 +32,7 @@ function isOrganizationEventListRequest(route: Route, organizationId: string, mo } async function verifyChartRefresh(page: Page, path: string, matchesRefreshRequest: (route: Route) => boolean): Promise { + // Arrange await page.goto(path); const chart = page.locator('[data-slot="chart"]').first(); await expect(chart).toBeVisible(); @@ -68,8 +72,10 @@ async function verifyChartRefresh(page: Page, path: string, matchesRefreshReques await page.route('**/api/v2/organizations/**', holdRefresh); try { + // Act await page.getByTitle('Refresh results').click(); await refreshIntercepted; + // Assert await expect(page.getByTitle('Refresh results').locator('svg')).toHaveClass(/animate-spin/); expect(await chartElement!.evaluate((element) => element.isConnected)).toBe(true); await expect(chart).toBeVisible(); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts index 731f23a2d1..5925abab0b 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/password-recovery.e2e.ts @@ -3,38 +3,56 @@ import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; const RESET_PASSWORD = `${E2E_TEST_PASSWORD}-reset`; test.skip(process.env.E2E_ENV === 'production', 'Password recovery requires local Mailpit.'); -test.use({ e2eCleanupPassword: RESET_PASSWORD, e2eUseGeneratedUser: true }); - -test('user can reset a forgotten password and log in @signup', async ({ e2eApi, e2eScenario, page }) => { - await test.step('request a password reset through the UI', async () => { - await page.goto('/next/forgot-password'); - await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); - await page.getByRole('button', { name: 'Send Reset Email' }).click(); - - await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); - await expect(page.getByText('Please check your inbox for the password reset email.')).toBeVisible(); - }); - - const resetToken = await test.step('read the reset link from local mail', async () => { - return await e2eApi.pollForMailToken(e2eScenario.email, 'reset-password'); - }); - - await test.step('change the password through the emailed route', async () => { - await page.goto(`/next/reset-password/${encodeURIComponent(resetToken)}`); - await page.getByLabel('New Password', { exact: true }).fill(RESET_PASSWORD); - await page.getByLabel('Confirm Password', { exact: true }).fill(RESET_PASSWORD); - await page.getByRole('button', { name: 'Change Password' }).click(); - - await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); - await expect(page.getByText('You have successfully changed your password.')).toBeVisible(); - }); - - await test.step('log in with the new password', async () => { - await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); - await page.getByPlaceholder('Enter password').fill(RESET_PASSWORD); - await page.getByRole('button', { exact: true, name: 'Login' }).click(); - - await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); - await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); - }); +test.use({ e2eCleanupPassword: RESET_PASSWORD, e2eUseInvitedUser: true }); + +test('user can reset a forgotten password and log in @signup', async ({ browser, e2eApi, e2eScenario }) => { + // Arrange + const recoveryContext = await browser.newContext({ baseURL: e2eApi.environment.appUrl, ignoreHTTPSErrors: true }); + const page = await recoveryContext.newPage(); + + try { + // Act & Assert: verify each stage of password recovery. + await test.step('request a password reset through the UI', async () => { + // Arrange + await page.goto('/next/forgot-password'); + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + // Act + await page.getByRole('button', { name: 'Send Reset Email' }).click(); + + // Assert + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + await expect(page.getByText('Please check your inbox for the password reset email.')).toBeVisible(); + }); + + const resetToken = await test.step('read the reset link from local mail', async () => { + return await e2eApi.pollForMailToken(e2eScenario.email, 'reset-password'); + }); + + await test.step('change the password through the emailed route', async () => { + // Arrange + await page.goto(`/next/reset-password/${encodeURIComponent(resetToken)}`); + await page.getByLabel('New Password', { exact: true }).fill(RESET_PASSWORD); + await page.getByLabel('Confirm Password', { exact: true }).fill(RESET_PASSWORD); + // Act + await page.getByRole('button', { name: 'Change Password' }).click(); + + // Assert + await expect(page).toHaveURL(/\/next\/login(?:[?#]|$)/); + await expect(page.getByText('You have successfully changed your password.')).toBeVisible(); + }); + + await test.step('log in with the new password', async () => { + // Arrange + await page.getByLabel('Email', { exact: true }).fill(e2eScenario.email); + await page.getByPlaceholder('Enter password').fill(RESET_PASSWORD); + // Act + await page.getByRole('button', { exact: true, name: 'Login' }).click(); + + // Assert + await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); + }); + } finally { + await recoveryContext.close(); + } }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts new file mode 100644 index 0000000000..4a2c80c6ef --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/product-tours.e2e.ts @@ -0,0 +1,842 @@ +import type { Page, Request, Response } from '@playwright/test'; + +import { E2E_TEST_PASSWORD, expect, test } from '../fixtures/e2e-test'; +import { seedRepresentativeEvent } from '../support/event-data'; +import { createRepresentativeEvent } from '../support/synthetic-event'; + +test.use({ actionTimeout: 15_000, e2eUseInvitedUser: true }); + +test.describe('first-run welcome', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + for (const [tourName, dismissLabel] of [ + ['app-welcome', 'Close welcome'], + ['exie-announcement', 'Dismiss Exie announcement'] + ] as const) { + test(`${tourName} stays dismissed when telemetry and session storage are unavailable`, async ({ e2eApi, e2eScenario, page }) => { + // Arrange + await page.route('**/api/v2/events', (route) => route.abort()); + if (tourName === 'exie-announcement') { + await e2eApi.recordProductTour(e2eScenario.userToken, 'app-welcome'); + } + await mockAssistantAccess(page); + await page.goto('/next/stack'); + const dismiss = page.getByRole('button', { name: dismissLabel }); + await expect(dismiss).toBeVisible(); + + // Act + const persisted = page.waitForResponse(isSuccessfulTourProgress(tourName)); + await dismiss.click(); + expect(await (await persisted).json()).toMatchObject({ recorded_utc: expect.any(String) }); + await expect(dismiss).toBeHidden(); + await page.addInitScript(() => + Object.defineProperty(window, 'sessionStorage', { + get() { + throw new DOMException('Storage denied', 'SecurityError'); + } + }) + ); + const reloadedUser = page + .waitForResponse((response) => new URL(response.url()).pathname === '/api/v2/users/me' && response.status() === 200) + .then((response) => response.json()); + const reloadedProjects = page.waitForResponse( + (response) => new URL(response.url()).pathname === `/api/v2/organizations/${e2eScenario.organizationId}/projects` && response.status() === 200 + ); + const [, currentUser] = await Promise.all([page.reload(), reloadedUser, reloadedProjects]); + + // Assert + expect(currentUser).toMatchObject({ + product_tours: { [tourName.replaceAll('-', '_')]: expect.any(String) } + }); + await expect(page.getByRole('button', { name: 'Search Exceptionless' })).toBeVisible(); + if (tourName === 'app-welcome') { + // A different, unseen invitation remains eligible; saved outcomes do not hide unrelated guides. + await expect(page.getByRole('button', { name: 'Dismiss Exie announcement' })).toBeVisible(); + } + await expect(dismiss).toBeHidden(); + }); + } + + test('a manual guide does not compete with or accept the pending welcome', async ({ e2eScenario, page }) => { + // Arrange + const welcome = page.getByRole('region', { name: 'Welcome to Exceptionless' }); + await test.step(`show the pending welcome for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack/all'); + await expect(welcome).toBeVisible(); + }); + const invitationWrites: Request[] = []; + page.on('request', (request) => { + if (request.method() === 'PUT' && new URL(request.url()).pathname.endsWith('/product-tours/app-welcome/record')) { + invitationWrites.push(request); + } + }); + + // Act + await startTourFromCommand(page, 'Explore Exceptionless'); + + // Assert + const guide = page.locator('.driver-popover'); + await expect(guide.getByText('Spot repeated problems')).toBeVisible(); + await expect(welcome).toBeHidden(); + await guide.getByRole('button', { name: 'End guide' }).click(); + await expect(guide).toBeHidden(); + await expect(welcome).toBeHidden(); + expect(invitationWrites).toEqual([]); + }); + + test('Browse Guides saves acknowledgment and opens the catalog', async ({ e2eScenario, page }, testInfo) => { + // Arrange + await test.step(`show the first-run prompt for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack'); + await expect(page.getByRole('region', { name: 'Welcome to Exceptionless' })).toBeVisible(); + await expect(page.getByRole('dialog')).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('welcome-desktop.png') }); + await page.getByRole('button', { name: 'Search Exceptionless' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('region', { name: 'Welcome to Exceptionless' })).toBeVisible(); + }); + + // Act + const persisted = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); + await page.getByRole('region', { name: 'Welcome to Exceptionless' }).getByRole('button', { name: 'Browse guides' }).click(); + await persisted; + + // Assert + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + await expect(catalog).toBeVisible(); + + // Act + await catalog.getByRole('button', { name: 'Close' }).click(); + await page.reload(); + + // Assert + await expect(page.getByRole('region', { name: 'Welcome to Exceptionless' })).toBeHidden(); + }); + + test('the compact mobile welcome respects reduced motion and starts the recommended setup', async ({ e2eScenario, page }, testInfo) => { + // Arrange + await page.setViewportSize({ height: 844, width: 390 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/next/stack'); + const welcome = page.getByRole('region', { name: 'Welcome to Exceptionless' }); + await expect(welcome).toBeVisible(); + + // Act + const presentation = await welcome.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + return { animation: getComputedStyle(element).animationName, bottom: bounds.bottom, height: bounds.height, left: bounds.left, right: bounds.right }; + }); + + // Assert + expect(presentation.animation).toBe('none'); + expect(presentation.left).toBeGreaterThanOrEqual(16); + expect(presentation.right).toBeLessThanOrEqual(374); + expect(presentation.bottom).toBeLessThanOrEqual(828); + expect(presentation.height).toBeLessThan(220); + await expect(page.getByRole('dialog')).toBeHidden(); + await page.screenshot({ path: testInfo.outputPath('welcome-mobile.png') }); + const persisted = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); + await welcome.getByRole('button', { name: 'Continue setup' }).click(); + await persisted; + await expect(page).toHaveURL(new RegExp(`/next/project/(?:add|${e2eScenario.projectId}/configure)`)); + await expect(welcome).toBeHidden(); + }); + + test('a failed close is non-blocking and can be retried after reload', async ({ e2eScenario, page }) => { + // Arrange + const welcome = page.getByRole('region', { name: 'Welcome to Exceptionless' }); + await test.step(`show the welcome for ${e2eScenario.email}`, async () => { + await page.goto('/next/stack'); + await expect(welcome).toBeVisible(); + }); + const progressRoute = '**/api/v2/users/me/product-tours/app-welcome/record'; + await page.route(progressRoute, (route) => route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 })); + + // Act + await welcome.getByRole('button', { name: 'Close welcome' }).click(); + + // Assert + await expect(page.getByText('We could not save your guided-tour preference. Please try again.')).toBeVisible(); + await expect(welcome).toBeHidden(); + await page.unroute(progressRoute); + await page.reload(); + + // An unsaved preference can be retried when the user returns. + await expect(welcome).toBeVisible(); + const persisted = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); + await welcome.getByRole('button', { name: 'Close welcome' }).click(); + await persisted; + await expect(welcome).toBeHidden(); + }); +}); + +test.describe('shell and identity checkpoints', () => { + test.use({ e2eDismissProductTourWelcome: false }); + + test('supports responsive guides and clears them on reload or identity changes', async ({ e2eApi, e2eScenario, e2eSecondaryOrganization, page }) => { + // Arrange + test.setTimeout(240_000); + const progressWrites: string[] = []; + page.on('request', (request) => { + if (request.method() === 'PUT' && request.url().includes('/api/v2/users/me/product-tours/')) { + progressWrites.push(new URL(request.url()).pathname); + } + }); + + // Act & Assert: each step checks a responsive or identity transition. + await test.step('closing the welcome persists dismissal', async () => { + // Arrange + await page.goto('/next/stack'); + const welcome = page.getByRole('region', { name: 'Welcome to Exceptionless' }); + await expect(welcome).toBeVisible(); + const dismissed = page.waitForResponse(isSuccessfulTourProgress('app-welcome')); + // Act + await welcome.getByRole('button', { name: 'Close welcome' }).focus(); + await page.keyboard.press('Escape'); + await dismissed; + // Assert + await expect(welcome).toBeHidden(); + }); + + await test.step('the shell tour renders on mobile and resumes on desktop with reduced motion', async () => { + // Arrange + await page.setViewportSize({ height: 844, width: 390 }); + // Act + await startTourFromCommand(page, 'Explore Exceptionless'); + const tour = page.locator('.driver-popover'); + // Assert + await expect(page.locator('[data-tour="app-navigation"]')).toBeVisible(); + await expect(tour.getByText('Spot repeated problems')).toBeVisible(); + + // Act + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ height: 900, width: 1440 }); + const closeButton = tour.getByRole('button', { name: 'End guide' }); + // Assert + await expect(closeButton).toHaveText('×'); + const closeBounds = await closeButton.boundingBox(); + const titleBounds = await tour.locator('.driver-popover-title').evaluate((element) => { + const range = document.createRange(); + range.selectNodeContents(element); + return range.getBoundingClientRect().toJSON(); + }); + const tourBounds = await tour.boundingBox(); + const descriptionBounds = await tour.locator('.driver-popover-description').boundingBox(); + const continueBounds = await tour.getByRole('button', { name: 'Next' }).boundingBox(); + expect(closeBounds).not.toBeNull(); + expect(titleBounds).not.toBeNull(); + expect(descriptionBounds).not.toBeNull(); + expect(continueBounds?.height).toBeGreaterThanOrEqual(32); + expect(closeBounds?.height).toBe(32); + expect(closeBounds!.x + closeBounds!.width).toBeCloseTo(tourBounds!.x + tourBounds!.width - 5, 0); + expect(closeBounds!.y).toBeCloseTo(tourBounds!.y + 5, 0); + expect(titleBounds!.x + titleBounds!.width).toBeLessThanOrEqual(closeBounds!.x); + expect(closeBounds!.y + closeBounds!.height).toBeLessThanOrEqual(descriptionBounds!.y); + // Act + await tour.getByRole('button', { name: 'Next' }).click(); + // Assert + await expect(tour.getByText('See each report')).toBeVisible(); + // Act + await page.reload(); + // Assert + await expect(tour).toBeHidden(); + // Act + await startTourFromCommand(page, 'Explore Exceptionless'); + // Assert + await expect(tour.getByText('Spot repeated problems')).toBeVisible(); + const writesBeforeDismissal = progressWrites.length; + // Act + await tour.getByRole('button', { name: 'End guide' }).click(); + // Assert + expect(progressWrites).toHaveLength(writesBeforeDismissal); + await expectActiveProductTour(page, false); + }); + + await test.step('every shell target remains visible on mobile', async () => { + // Arrange + await mockAssistantAccess(page); + await page.reload(); + await page.setViewportSize({ height: 844, width: 390 }); + // Act + await startTourFromCommand(page, 'Explore Exceptionless'); + const tour = page.locator('.driver-popover'); + + // Assert + for (const [title, target] of [ + ['Spot repeated problems', '[data-tour="navigation-stacks"]'], + ['See each report', '[data-tour="navigation-events"]'], + ['Narrow your results', '[data-tour="event-filters"]'], + ['Keep a useful view', '[data-tour="saved-view-trigger"]'], + ['Get help from Exie', '[data-tour="exie-trigger"]'], + ['Search and take action', '[data-tour="command-search"]'] + ] as const) { + await expect(tour.getByText(title)).toBeVisible(); + await expect(page.locator(target)).toBeVisible(); + if (title !== 'Search and take action') { + // Act + await tour.getByRole('button', { name: 'Next' }).click(); + } + } + + // Assert + await expectCalloutBesideTarget(page); + const completed = page.waitForResponse(isSuccessfulTourProgress('app-overview')); + // Act + await tour.getByRole('button', { name: 'Done' }).click(); + await completed; + // Assert + await expectActiveProductTour(page, false); + }); + + await test.step('an organization change clears an active checkpoint even when projects fail to load', async () => { + // Arrange + await mockAssistantAccess(page); + await page.reload(); + await startTourFromCommand(page, 'Meet Exie'); + await expectActiveProductTour(page, true); + const writesBeforeSwitch = progressWrites.length; + const projectsRoute = `**/api/v2/organizations/${e2eSecondaryOrganization.organizationId}/projects*`; + const projectLookup = Promise.withResolvers(); + await page.route(projectsRoute, async (route) => { + await projectLookup.promise; + await route.fulfill({ json: { title: 'Injected project lookup failure' }, status: 500 }); + }); + + // Act + const identityTab = await page.context().newPage(); + await identityTab.goto('/next/stack'); + await identityTab.evaluate((organizationId) => { + window.localStorage.setItem('organization', JSON.stringify(organizationId)); + }, e2eSecondaryOrganization.organizationId); + await identityTab.close(); + // Assert + await expectActiveProductTour(page, false); + expect(progressWrites).toHaveLength(writesBeforeSwitch); + // Act + await page.getByRole('button', { name: 'Search Exceptionless' }).click(); + await page.getByRole('dialog').getByRole('option', { exact: true, name: 'Guided Tours' }).click(); + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + try { + // Assert + // Shell guides do not depend on the pending project lookup. + await expect(catalog.getByRole('button', { exact: true, name: 'Restart Explore Exceptionless' })).toBeEnabled(); + } finally { + projectLookup.resolve(); + } + await expect(catalog.getByRole('button', { exact: true, name: 'Start Configure a project' })).toBeDisabled(); + await expect(catalog.getByText('Projects could not be loaded. Try again shortly.', { exact: true })).toBeVisible(); + await page.keyboard.press('Escape'); + await page.unroute(projectsRoute); + await page.reload(); + }); + + await test.step('logout clears an active checkpoint without recording progress', async () => { + // Arrange + await page.setViewportSize({ height: 900, width: 1440 }); + await startTourFromCommand(page, 'Meet Exie'); + await expectActiveProductTour(page, true); + const writesBeforeLogout = progressWrites.length; + + // Act + await page.getByRole('button', { name: new RegExp(e2eScenario.userName) }).dispatchEvent('click'); + await page.getByRole('menuitem', { name: 'Log Out' }).dispatchEvent('click'); + // Assert + await expect(page).toHaveURL(/\/next\/login/); + await expectActiveProductTour(page, false); + expect(progressWrites).toHaveLength(writesBeforeLogout); + + e2eScenario.userToken = await e2eApi.login(e2eScenario.email, E2E_TEST_PASSWORD); + }); + }); +}); + +for (const title of ['Explore Exceptionless', 'Create a saved view', 'Meet Exie']) { + test(`Search and the catalog suspend ${title} until Continue`, async ({ e2eScenario, page }) => { + // Arrange + await mockAssistantAccess(page); + await test.step(`start ${title} for ${e2eScenario.email}`, async () => { + await page.goto('/next/event'); + await startTourFromCommand(page, title); + }); + const calloutTitle = page.locator('.driver-popover-title'); + await expect(calloutTitle).toBeVisible(); + const stepTitle = await calloutTitle.innerText(); + + // Act + await page.keyboard.press('/'); + await expect(page.getByRole('combobox')).toBeVisible(); + + // Assert: the active guide must not compete with either dialog. + await expect(page.locator('.driver-popover')).toHaveCount(0); + await page.getByRole('option', { exact: true, name: 'Guided Tours' }).click(); + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + await expect(catalog).toBeVisible(); + await expect(page.locator('.driver-popover')).toHaveCount(0); + await expect(page.locator('.driver-overlay')).toHaveCount(0); + + // Act + await catalog.getByRole('button', { exact: true, name: `Continue ${title}` }).click(); + + // Assert + await expect(catalog).toBeHidden(); + await expect(calloutTitle).toHaveText(stepTitle); + await expectActiveProductTour(page, true); + }); +} + +for (const [stepTitle, advances] of [ + ['Narrow your results', 2], + ['Keep a useful view', 3] +] as const) { + test(`the overview offers Restart when leaving ${stepTitle}`, async ({ e2eScenario, page }) => { + // Arrange: start from a page without event filters or a View menu. + const projectPath = `/next/project/${e2eScenario.projectId}/manage`; + await page.goto(projectPath); + await startTourFromCommand(page, 'Explore Exceptionless'); + await expect(page).toHaveURL(/\/next\/event$/); + await expect(page.locator('[data-tour="event-filters"]')).toBeVisible(); + const callout = page.locator('.driver-popover'); + const stepTitles = ['Spot repeated problems', 'See each report', 'Narrow your results']; + for (let step = 0; step < advances; step++) { + await expect(page.locator('.driver-popover-title')).toHaveText(stepTitles[step]); + await callout.getByRole('button', { name: 'Next' }).click(); + } + await expect(page.locator('.driver-popover-title')).toHaveText(stepTitle); + + // Act + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`${projectPath}$`)); + await page.keyboard.press('/'); + await page.getByRole('option', { exact: true, name: 'Guided Tours' }).click(); + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + + // Assert: restarting returns to a page with the tour's controls. + const restart = catalog.getByRole('button', { exact: true, name: 'Restart Explore Exceptionless' }); + await expect(restart).toBeVisible(); + await restart.click(); + await expect(page).toHaveURL(/\/next\/event$/); + await expect(page.locator('.driver-popover-title')).toHaveText('Spot repeated problems'); + }); +} + +test('project guide preserves the current SDK selection', async ({ e2eScenario, page }) => { + // Arrange + await page.route('**/api/v2/organizations/*/projects*', async (route) => { + await route.fulfill({ json: [] }); + }); + await page.goto(`/next/project/${e2eScenario.projectId}/configure?type=dotnet-legacy-mvc`); + await expect(page.locator('[data-tour="project-configure-platform"]')).toContainText('ASP.NET MVC'); + + // Act + await startTourFromCommand(page, 'Configure a project'); + + // Assert + await expect(page.getByRole('button', { exact: true, name: 'End guide' })).toBeVisible(); + await expect(page.locator('.driver-popover')).toHaveCount(0); + await expect(page.locator('[data-tour="project-configure-platform"]')).toContainText('ASP.NET MVC'); + expect(new URL(page.url()).searchParams.get('type')).toBe('dotnet-legacy-mvc'); + expect(new URL(page.url()).searchParams.get('redirect')).toBe('true'); + expect(new URL(page.url()).pathname).toBe(`/next/project/${e2eScenario.projectId}/configure`); +}); + +test('a saved-view guide allows submitting the form before finishing its steps', async ({ e2eScenario, page }) => { + // Arrange + await page.goto('/next/event'); + await startTourFromCommand(page, 'Create a saved view'); + const guide = page.locator('.driver-popover'); + await guide.getByRole('button', { name: 'Open View' }).click(); + await guide.getByRole('button', { name: 'Save As…' }).click(); + const name = page.getByLabel('Name', { exact: true }); + await name.fill(`Early Save ${e2eScenario.run}`); + const completed = page.waitForResponse(isSuccessfulTourProgress('saved-view-create')); + + // Act + await name.press('Enter'); + + // Assert + await completed; + await expectActiveProductTour(page, false); + await expect(page.getByText('Your saved view is ready', { exact: true })).toBeVisible(); +}); + +test('domain workflows advance only on real success', async ({ e2eApi, e2eScenario, page }) => { + // Arrange: the invited-user fixture supplies the organization and project. + test.setTimeout(300_000); + + // Act & Assert: each workflow below exercises and verifies its own transitions. + await test.step('project configuration advances after setup and the first event', async () => { + // Arrange + await page.goto('/next/stack'); + await startTourFromCommand(page, 'Configure a project'); + await page.waitForURL(/\/next\/project\/(?:add|[^/]+\/configure)/); + + let createdProject = false; + let projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + if (!projectId) { + createdProject = true; + await expect(page.getByRole('heading', { name: 'Add Project' })).toBeVisible(); + await page.getByLabel('Project Name', { exact: true }).fill(`Tour Project ${e2eScenario.run}`); + await page.getByRole('button', { name: 'Continue to Client Setup' }).click(); + await page.waitForURL(/\/next\/project\/[^/]+\/configure\?redirect=true/); + projectId = page.url().match(/\/project\/([^/]+)\/configure/)?.[1]; + } else { + expect(projectId).toBe(e2eScenario.projectId); + } + + expect(projectId).toBeTruthy(); + + const projectProgressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/project-configure/record'; + try { + // Act + await page.locator('[data-tour="project-configure-platform"]').click(); + await page.getByRole('option', { name: 'Browser applications' }).click(); + // Assert + await expect(page.getByText('Waiting for your first event')).toBeVisible(); + await expect(page.locator('.driver-popover')).toHaveCount(0); + await expect(page.locator('.driver-overlay')).toHaveCount(0); + await expect(page.locator('[data-tour="project-sdk-instructions"]')).toBeVisible(); + await expect(page.getByRole('button', { exact: true, name: 'End guide' })).toBeVisible(); + + // Act + const instructionButtons = page.locator('[data-tour="project-sdk-instructions"]').getByRole('button'); + const reachedButtons = new Set(); + await page.locator('[data-tour="project-configure-platform"]').focus(); + for (let tab = 0; tab < 40 && reachedButtons.size < (await instructionButtons.count()); tab++) { + const focusedIndex = await instructionButtons.evaluateAll((buttons) => buttons.indexOf(document.activeElement as HTMLButtonElement)); + if (focusedIndex >= 0) { + reachedButtons.add(focusedIndex); + } + await page.keyboard.press('Tab'); + } + // Assert + expect(reachedButtons.size).toBe(await instructionButtons.count()); + + // Arrange + let projectProgressRequests = 0; + await page.route(projectProgressRoute, async (route) => { + projectProgressRequests += 1; + await route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 }); + }); + const token = await e2eApi.getProjectDefaultToken(e2eScenario.userToken, projectId!); + // Act + await e2eApi.submitEvent( + projectId!, + token.id, + createRepresentativeEvent({ + appUrl: e2eApi.environment.appUrl, + message: e2eScenario.message, + referenceId: e2eScenario.referenceId, + runId: e2eApi.environment.runId + }) + ); + // Assert + await expect(page).toHaveURL(/\/next\/event/); + await expectActiveProductTour(page, false); + await expect.poll(() => projectProgressRequests).toBe(1); + await expect.poll(async () => (await e2eApi.getProject(e2eScenario.userToken, projectId!))?.is_configured).toBe(true); + + // Act + await page.unroute(projectProgressRoute); + await page.goto(`/next/project/${projectId}/configure`); + // Assert + await expectActiveProductTour(page, false); + expect(projectProgressRequests).toBe(1); + } finally { + await page.unroute(projectProgressRoute); + if (createdProject) { + await e2eApi.deleteProject(e2eScenario.userToken, projectId!); + await e2eApi.waitForProjectDeleted(e2eScenario.userToken, projectId!); + } + } + }); + + await test.step('saved-view completion closes without blocking when persistence fails', async () => { + // Arrange + let createRequests = 0; + let progressRequests = 0; + const countSavedViewCreation = (request: Request) => { + const path = new URL(request.url()).pathname; + if (request.method() === 'POST' && /^\/api\/v2\/organizations\/[^/]+\/saved-views$/.test(path)) { + createRequests += 1; + } + }; + const progressRoute = (url: URL) => url.pathname === '/api/v2/users/me/product-tours/saved-view-create/record'; + page.on('request', countSavedViewCreation); + await page.route(progressRoute, async (route) => { + progressRequests += 1; + if (progressRequests === 1) { + await route.fulfill({ json: { title: 'Injected progress failure' }, status: 500 }); + return; + } + + await route.continue(); + }); + + try { + await page.goto('/next/event'); + // Act + await startTourFromCommand(page, 'Create a saved view'); + // Assert + await expectActiveProductTour(page, true); + const tour = page.locator('.driver-popover'); + // Act + await tour.getByRole('button', { name: 'Open View' }).click(); + // Assert + await expect(page.locator('[data-tour="saved-view-save-as"]')).toHaveClass(/driver-active-element/); + await expectCalloutBesideTarget(page); + // Act + await tour.getByRole('button', { name: 'Save As…' }).click(); + + await page.getByLabel('Name', { exact: true }).fill(`Tour View ${e2eScenario.run}`); + await page.getByRole('button', { exact: true, name: 'Save' }).click(); + // Assert + await expect(page.getByText('Your saved view is ready', { exact: true })).toBeVisible(); + await expect(page.locator('.driver-popover')).toHaveCount(0); + expect(createRequests).toBe(1); + expect(progressRequests).toBe(1); + + // Act + await page.reload(); + // Assert + await expect(page.getByRole('button', { name: 'Retry guide completion' })).toHaveCount(0); + await expect.poll(() => createRequests).toBe(1); + await expectActiveProductTour(page, false); + expect(progressRequests).toBe(1); + } finally { + page.off('request', countSavedViewCreation); + await page.unroute(progressRoute); + } + }); + + await test.step('investigation advances when a real error opens', async () => { + // Arrange + await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }); + await page.goto('/next/event?time=all&type=error'); + await expect(page.getByText(e2eScenario.message).first()).toBeVisible({ timeout: 30_000 }); + // Act + await startTourFromCommand(page, 'Investigate an error'); + await page.locator('.driver-popover').getByRole('button', { name: 'Open error' }).click(); + const callout = page.locator('.driver-popover'); + // Assert + await expect(callout.getByText('See the impact')).toBeVisible(); + for (const title of ['Read what happened', 'See related reports']) { + // Act + await callout.getByRole('button', { name: 'Next' }).click(); + // Assert + await expect(callout.getByText(title)).toBeVisible(); + } + + const completed = page.waitForResponse(isSuccessfulTourProgress('event-investigate')); + // Act + await expectCalloutBesideTarget(page); + await page.locator('[data-tour="stack-events"]').click(); + await completed; + // Assert + await expectActiveProductTour(page, false); + // Act + await page.reload(); + // Assert + await expect(page.locator('.driver-popover')).toBeHidden(); + }); + + await test.step('Exie opens context without provider submission', async () => { + // Arrange + await mockAssistantAccess(page); + let chatRequests = 0; + const countChatRequest = (request: Request) => { + if (new URL(request.url()).pathname === '/api/v2/assistant/chat') { + chatRequests += 1; + } + }; + page.on('request', countChatRequest); + + try { + await page.goto('/next/stack'); + // Act + await startTourFromCommand(page, 'Meet Exie'); + const tour = page.locator('.driver-popover'); + await tour.getByRole('button', { name: 'Open Exie' }).click(); + // Assert + await expect(tour.getByText('Ask your first question')).toBeVisible(); + expect(chatRequests).toBe(0); + } finally { + page.off('request', countChatRequest); + } + }); +}); + +test('the error guide keeps the start of a wide report visible on mobile', async ({ e2eApi, e2eScenario, page }) => { + // Arrange + await seedRepresentativeEvent(e2eApi, e2eScenario.userToken, { + message: e2eScenario.message, + projectId: e2eScenario.projectId, + projectToken: e2eScenario.projectToken, + referenceId: e2eScenario.referenceId + }); + await page.setViewportSize({ height: 844, width: 390 }); + await page.goto('/next/event?time=all&type=error'); + await expect(page.getByText(e2eScenario.message).first()).toBeVisible(); + + // Act + await startTourFromCommand(page, 'Investigate an error'); + await expect(page.locator('.driver-popover-title')).toHaveText('Take a closer look'); + + // Assert: the row may extend past the right edge, but its report name must stay visible. + const row = page.locator('.driver-active-element'); + await expect.poll(async () => (await row.boundingBox())?.x ?? -1).toBeGreaterThanOrEqual(0); + await page.locator('.driver-popover').getByRole('button', { name: 'Open error' }).click(); + await expect(page.locator('.driver-popover-title')).toHaveText('See the impact'); + await expectCalloutBesideTarget(page); +}); + +test('overview arrows stay beside each control on desktop and mobile', async ({ e2eScenario, page }) => { + // Arrange + await mockAssistantAccess(page); + await page.goto('/next/stack'); + expect(e2eScenario.email).toContain('@exceptionless.test'); + const titles = ['Spot repeated problems', 'See each report', 'Narrow your results', 'Keep a useful view', 'Get help from Exie', 'Search and take action']; + + for (const viewport of [ + { height: 900, width: 1440 }, + { height: 844, width: 390 } + ]) { + await page.setViewportSize(viewport); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await startTourFromCommand(page, 'Explore Exceptionless'); + const tour = page.locator('.driver-popover'); + + for (const [index, title] of titles.entries()) { + // Assert: visibility alone misses detached cards and arrows. + await expect(tour.getByText(title, { exact: true })).toBeVisible(); + await expect(tour.getByText(`Step ${index + 1} of 6`, { exact: true })).toBeVisible(); + await expectCalloutBesideTarget(page); + + // Act + await tour.getByRole('button', { exact: true, name: index === titles.length - 1 ? 'Done' : 'Next' }).click(); + } + await expectActiveProductTour(page, false); + } + + // Act & Assert: leaving by keyboard remains available with reduced motion. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await startTourFromCommand(page, 'Explore Exceptionless'); + await expectCalloutBesideTarget(page); + await page.keyboard.press('Escape'); + await expectActiveProductTour(page, false); +}); + +async function expectActiveProductTour(page: Page, present: boolean): Promise { + const guide = page.getByRole('button', { exact: true, name: 'End guide' }); + if (present) { + await expect(guide).toBeVisible(); + } else { + await expect(guide).toBeHidden(); + } +} + +async function expectCalloutBesideTarget(page: Page): Promise { + await expect + .poll(() => + page.evaluate(() => { + const target = document.querySelector('.driver-active-element'); + const popover = document.querySelector('.driver-popover'); + const arrow = popover?.querySelector('.driver-popover-arrow'); + if (!target || !popover || !arrow) { + return false; + } + const targetBounds = target.getBoundingClientRect(); + const bounds = popover.getBoundingClientRect(); + const arrowBounds = arrow.getBoundingClientRect(); + const gap = Math.max( + targetBounds.left - bounds.right, + bounds.left - targetBounds.right, + targetBounds.top - bounds.bottom, + bounds.top - targetBounds.bottom + ); + const verticalArrow = arrow.classList.contains('driver-popover-arrow-side-top') || arrow.classList.contains('driver-popover-arrow-side-bottom'); + const arrowCenter = verticalArrow ? arrowBounds.left + arrowBounds.width / 2 : arrowBounds.top + arrowBounds.height / 2; + const targetStart = verticalArrow ? targetBounds.left : targetBounds.top; + const targetEnd = verticalArrow ? targetBounds.right : targetBounds.bottom; + return ( + bounds.left >= 0 && + bounds.top >= 0 && + bounds.right <= innerWidth && + bounds.bottom <= innerHeight && + targetBounds.left >= 0 && + targetBounds.top >= 0 && + targetBounds.right <= innerWidth && + targetBounds.bottom <= innerHeight && + gap >= 0 && + gap <= 24 && + getComputedStyle(arrow).display !== 'none' && + arrowCenter >= targetStart - 8 && + arrowCenter <= targetEnd + 8 + ); + }) + ) + .toBe(true); +} + +test('completion survives unavailable telemetry and session storage', async ({ e2eScenario, page }) => { + // Arrange + await page.route('**/api/v2/events', (route) => route.abort()); + const tour = page.locator('.driver-popover'); + await page.addInitScript(() => + Object.defineProperty(window, 'sessionStorage', { + get() { + throw new DOMException('Storage denied', 'SecurityError'); + } + }) + ); + await mockAssistantAccess(page); + await page.goto('/next/stack'); + // Act + await startTourFromCommand(page, 'Explore Exceptionless'); + for (const title of ['Spot repeated problems', 'See each report', 'Narrow your results', 'Keep a useful view', 'Get help from Exie']) { + await expect(tour.getByText(title)).toBeVisible(); + await tour.getByRole('button', { name: 'Next' }).click(); + } + await expect(tour.getByText('Search and take action')).toBeVisible(); + const completed = page.waitForResponse(isSuccessfulTourProgress('app-overview')); + await tour.getByRole('button', { name: 'Done' }).click(); + const response = await completed; + + // Assert + expect(await response.json()).toMatchObject({ recorded_utc: expect.any(String) }); + await expectActiveProductTour(page, false); + expect(e2eScenario.email).toContain('@exceptionless.test'); +}); + +function isSuccessfulTourProgress(tourName: string) { + return (response: Response): boolean => { + const path = new URL(response.url()).pathname; + return response.request().method() === 'PUT' && path === `/api/v2/users/me/product-tours/${tourName}/record` && response.status() === 200; + }; +} + +async function mockAssistantAccess(page: Page): Promise { + await page.route( + (url) => url.pathname === '/api/v2/assistant/access', + (route) => route.fulfill({ json: { enabled: true, has_access: true, message: null, upgrade_required: false } }) + ); +} + +async function startTourFromCommand(page: Page, title: string): Promise { + const announcementStart = page.getByRole('button', { name: 'See how it works' }); + if (title === 'Meet Exie' && (await announcementStart.isVisible())) { + await announcementStart.click(); + return; + } + + await page.getByRole('button', { name: 'Search Exceptionless' }).click(); + await page.getByRole('dialog').getByRole('option', { exact: true, name: 'Guided Tours' }).click(); + const catalog = page.getByRole('dialog', { name: 'Guided Tours' }); + const tour = catalog.getByRole('region', { name: title }); + await tour.getByRole('button', { name: /^(Continue|Restart|Start) / }).click(); +} 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 da9ab73284..1bc5c2ee3a 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/saved-views.e2e.ts @@ -5,6 +5,7 @@ import { ExceptionlessE2EJourney } from '../support/exceptionless-journey'; import { getVisibleText } from '../support/page-helpers'; test('home navigation honors personal and organization saved views and survives deletion', async ({ e2eApi, e2eScenario, page, request }) => { + // Arrange const failedApiRequests = captureFailedApiRequests(page); const savedViewListLimits: string[] = []; page.on('request', (request) => { @@ -17,28 +18,45 @@ test('home navigation honors personal and organization saved views and survives const viewName = `E2E Home ${journey.run.slice(-36)}`; const viewSlug = savedViewSlug(viewName); + // Act & Assert: each step exercises and verifies a home-view transition. await test.step('fall back to the first Stacks saved view when no default is configured', async () => { + // Act await page.goto('/next/'); + // Assert await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); await expect(page.getByRole('heading', { name: 'All' })).toBeVisible({ timeout: 30_000 }); await expect.poll(() => savedViewListLimits).toContain('100'); }); await test.step('prefer the personal saved view', async () => { + // Arrange await journey.submitRepresentativeEvent(); await page.goto(`/next/event?reference=${encodeURIComponent(journey.referenceId)}&time=all`); await expect(getVisibleText(page, journey.message)).toBeVisible({ timeout: 30_000 }); + // Act await openViewMenu(page); await page.getByRole('menuitem', { name: 'Save As...' }).click(); const dialog = page.getByRole('dialog', { name: 'Save View' }); + // Assert + await expect(dialog.getByRole('switch', { exact: true, name: 'Private' })).not.toBeChecked(); + // Act + await dialog.getByRole('button', { exact: true, name: 'Cancel' }).click(); + // Assert + await expect(dialog).toBeHidden(); + // Act + await openViewMenu(page); + await page.getByRole('menuitem', { name: 'Save As...' }).click(); await dialog.getByLabel('Name', { exact: true }).fill(viewName); await dialog.getByRole('button', { name: 'Save' }).click(); + // Assert await expect(dialog).toBeHidden({ timeout: 30_000 }); await expect(page.getByRole('heading', { name: viewName })).toBeVisible({ timeout: 30_000 }); + // Act await openViewMenu(page); await page.getByRole('menuitem', { name: 'Set as my home view' }).click(); + // Assert await expect(page.getByText(`"${viewName}" is now your home view.`)).toBeVisible(); await expect @@ -54,24 +72,33 @@ test('home navigation honors personal and organization saved views and survives ) .toBe(true); + // Act await page.goto('/next/'); + // Assert await expect(page).toHaveURL(new RegExp(`/next/event/${escapeRegExp(viewSlug)}(?:[?#]|$)`)); }); await test.step('fall back to the organization saved view after clearing the personal preference', async () => { + // Act await openViewMenu(page); await page.getByRole('menuitem', { name: 'Set as organization home' }).click(); + // Assert await expect(page.getByText(`"${viewName}" is now the organization home view.`)).toBeVisible(); + // Act await openViewMenu(page); await page.getByRole('menuitem', { name: 'Clear my home view' }).click(); + // Assert await expect(page.getByText('Personal home view cleared.')).toBeVisible(); + // Act await page.goto('/next/'); + // Assert await expect(page).toHaveURL(new RegExp(`/next/event/${escapeRegExp(viewSlug)}(?:[?#]|$)`)); }); await test.step('clear deleted defaults and return to the first Stacks saved view', async () => { + // Act const deletion = await page.evaluate( async ({ organizationId, token, viewName }) => { const headers = { Authorization: `Bearer ${token}` }; @@ -86,12 +113,16 @@ test('home navigation honors personal and organization saved views and survives }, { organizationId: e2eScenario.organizationId, token: e2eScenario.userToken, viewName } ); + // Assert expect(deletion).toBe(202); + // Act await page.goto('/next/'); + // Assert await expect(page).toHaveURL(/\/next\/stack\/all(?:[?#]|$)/); }); + // Assert expect(failedApiRequests).toEqual([]); }); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/sessions-saved-views.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/sessions-saved-views.e2e.ts index 985fa05363..aae7d272a3 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/sessions-saved-views.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/sessions-saved-views.e2e.ts @@ -273,7 +273,9 @@ test('Sessions ignore legacy structured Type filters and Type URL parameters', a async function captureEvidence(page: Page, fileName: string): Promise { const outputDirectory = process.env.DOGFOOD_OUTPUT; - if (!outputDirectory) return; + if (!outputDirectory) { + return; + } const resolvedDirectory = path.resolve(outputDirectory); mkdirSync(resolvedDirectory, { recursive: true }); diff --git a/src/Exceptionless.Web/ClientApp/eslint.config.js b/src/Exceptionless.Web/ClientApp/eslint.config.js index 0e7fe79173..6b5c978a94 100644 --- a/src/Exceptionless.Web/ClientApp/eslint.config.js +++ b/src/Exceptionless.Web/ClientApp/eslint.config.js @@ -52,7 +52,13 @@ export default ts.config( rules: { '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], '@stylistic/object-curly-newline': ['error', { ObjectExpression: { minProperties: 1 } }], - curly: ['error', 'all'], + curly: ['error', 'all'] + } + }, + { + files: ['**/*.svelte', '**/*.ts'], + rules: { + '@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'explicit', overrides: { constructors: 'no-public' } }], 'padding-line-between-statements': ['error', { blankLine: 'always', next: ['if', 'while', 'for', 'do'], prev: 'block-like' }] } }, diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index b777986eac..bed636e2f1 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.15", + "driver.js": "^1.8.0", "layerchart": "^2.5.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", @@ -5954,6 +5955,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/driver.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz", + "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 2e8ce87038..a91efa7c24 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -94,6 +94,7 @@ "clsx": "^2.1.1", "d3-scale": "^4.0.2", "dompurify": "^3.4.15", + "driver.js": "^1.8.0", "layerchart": "^2.5.0", "mode-watcher": "^1.1.0", "oidc-client-ts": "^3.5.0", diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-composer.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-composer.svelte index 5d206108dc..571a73a240 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-composer.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/assistant/components/assistant-composer.svelte @@ -78,6 +78,7 @@ (); private pageResource = $state(); - clearOverlay(owner: symbol): void { + public clearOverlay(owner: symbol): void { if (this.overlayOwner === owner) { this.overlayOwner = undefined; this.overlayResource = undefined; } } - getContext(eventId?: string, stackId?: string): AssistantResourceContext | undefined { + public getContext(eventId?: string, stackId?: string): AssistantResourceContext | undefined { if (this.overlayResource) { return this.overlayResource; } @@ -31,12 +31,12 @@ class AssistantPageContext { return stackId && this.pageResource?.stackId === stackId ? this.pageResource : undefined; } - setOverlay(owner: symbol, resource: AssistantResourceContext): void { + public setOverlay(owner: symbol, resource: AssistantResourceContext): void { this.overlayOwner = owner; this.overlayResource = resource; } - setOverlayEvent(owner: symbol, event: PersistentEvent): void { + public setOverlayEvent(owner: symbol, event: PersistentEvent): void { this.setOverlay(owner, { eventId: event.id, projectId: event.project_id, @@ -44,14 +44,14 @@ class AssistantPageContext { }); } - setOverlayStack(owner: symbol, stack: Stack): void { + public setOverlayStack(owner: symbol, stack: Stack): void { this.setOverlay(owner, { projectId: stack.project_id, stackId: stack.id }); } - setPageEvent(event: PersistentEvent): void { + public setPageEvent(event: PersistentEvent): void { this.pageResource = { eventId: event.id, projectId: event.project_id, @@ -59,7 +59,7 @@ class AssistantPageContext { }; } - setPageStack(stack: Stack): void { + public setPageStack(stack: Stack): void { this.pageResource = { projectId: stack.project_id, stackId: stack.id diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte index aa27e05825..c43b73b63b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/events-overview.svelte @@ -31,6 +31,7 @@ import { getSessionId } from '../utils'; import { shouldResetActiveEventTab } from './events-overview-tab-state'; + import InvestigationDetailTour from './tours/investigation-detail.svelte'; import Environment from './views/environment.svelte'; import Error from './views/error.svelte'; import ExtendedData from './views/extended-data.svelte'; @@ -61,6 +62,8 @@ onNavigate, prepareStackAssistantContext }: Props = $props(); + let tourStackId = $state(); + let investigationTour: InvestigationDetailTour | undefined; function getTabs(event?: null | PersistentEvent, project?: ViewProject): TabType[] { if (!event) { @@ -110,6 +113,13 @@ return tabs; } + async function showAllEvents(): Promise { + if (event?.stack_id) { + await investigationTour?.completeComparison(); + filterChanged(new EventsFacetedFilter.StringFilter('stack', event.stack_id)); + } + } + const eventQuery = getEventWithNavigationQuery({ params: { get expected_stack_id() { @@ -333,11 +343,13 @@

Stack

+ {#if event?.stack_id} (tourStackId = stack.id)} prepareAssistantContext={assistantResource === 'event' ? prepareEventAssistantContext : prepareStackAssistantContext} > {/if} @@ -353,13 +365,7 @@ {/if} {#if event?.stack_id} - {/if} @@ -381,7 +387,7 @@ - + {#if event} @@ -417,6 +423,7 @@ > {#each tabs as tab (tab)} + import { createProductTourActions } from '$features/product-tours/actions.svelte'; + import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; + import { PRODUCT_TOUR_CHECKPOINTS } from '$features/product-tours/models'; + import { productTourCheckpoint } from '$features/product-tours/state.svelte'; + + import type { PersistentEvent } from '../../models'; + + import { hasErrorOrSimpleError } from '../../persistent-event'; + + interface Props { + event?: PersistentEvent; + onCompareEvents: () => Promise; + } + + let { event, onCompareEvents }: Props = $props(); + + const actions = createProductTourActions(); + const firstDetailCheckpoint = 'stack-summary'; + const checkpoint = $derived(productTourCheckpoint.current?.tourName === 'event-investigate' ? productTourCheckpoint.current : undefined); + const steps = PRODUCT_TOUR_CHECKPOINTS['event-investigate']; + const stepIndex = $derived(checkpoint ? steps.indexOf(checkpoint.checkpointName) : -1); + const copy = $derived.by(() => { + switch (checkpoint?.checkpointName) { + case 'filter-stack-events': + return { + description: 'Use Show all events to see the other reports of this same problem.', + target: '[data-tour="stack-events"]', + title: 'See related reports' + }; + case firstDetailCheckpoint: + return { + description: 'See how often this problem happens and how many people it affects.', + target: '[data-tour="stack-metrics"]', + title: 'See the impact' + }; + case 'tab-overview': + return { + description: 'Overview has the error message and details about where it happened.', + target: '[data-tour="event-overview"]', + title: 'Read what happened' + }; + default: + return undefined; + } + }); + + $effect(() => { + const active = checkpoint; + if (active?.checkpointName === 'choose-error' && event && hasErrorOrSimpleError(event)) { + productTourCheckpoint.advance(active, firstDetailCheckpoint); + } + }); + + export async function completeComparison(): Promise { + if (checkpoint?.checkpointName === 'filter-stack-events') { + await actions.complete(checkpoint); + } + } + + function back(): void { + const previous = steps[stepIndex - 1]; + if (checkpoint && previous && stepIndex > steps.indexOf(firstDetailCheckpoint)) { + productTourCheckpoint.advance(checkpoint, previous); + } + } + + async function continueTour(): Promise { + const active = checkpoint; + if (!active) { + return; + } + + const next = steps[stepIndex + 1]; + if (next) { + productTourCheckpoint.advance(active, next); + } else { + await onCompareEvents(); + } + } + + +{#if event && checkpoint && copy} + {#key checkpoint} + steps.indexOf(firstDetailCheckpoint) ? back : undefined} + onDismiss={actions.dismiss} + side="bottom" + target={copy.target} + title={copy.title} + /> + {/key} +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-detail.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-detail.svelte.test.ts new file mode 100644 index 0000000000..3f8ee4183f --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-detail.svelte.test.ts @@ -0,0 +1,146 @@ +import { productTourCheckpoint } from '$features/product-tours/state.svelte'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PersistentEvent } from '../../models'; + +import InvestigationDetailTour from './investigation-detail.svelte'; + +const actions = vi.hoisted(() => ({ complete: vi.fn(), dismiss: vi.fn() })); +vi.mock('$features/product-tours/actions.svelte', () => ({ createProductTourActions: () => actions })); +vi.mock('$features/product-tours/activity', () => ({ submitProductTourActivity: vi.fn() })); + +const event: PersistentEvent = { + created_utc: '2026-09-01T00:00:00Z', + data: { '@simple_error': { message: 'Example error', type: 'ExampleException' } }, + date: '2026-09-01T00:00:00Z', + id: 'event', + is_first_occurrence: false, + organization_id: 'organization', + project_id: 'project', + stack_id: 'stack', + type: 'error' +}; + +describe('InvestigationDetailTour', () => { + let targets: HTMLElement[]; + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + public disconnect() {} + public observe() {} + } + ); + targets = ['stack-metrics', 'event-overview', 'stack-events'].map((name) => { + const element = document.createElement('button'); + element.dataset.tour = name; + element.scrollIntoView = vi.fn(); + document.body.append(element); + return element; + }); + }); + + afterEach(() => { + cleanup(); + targets.forEach((target) => target.remove()); + vi.unstubAllGlobals(); + productTourCheckpoint.clear(); + vi.clearAllMocks(); + }); + + it('uses a spotlight on the actual control at every detail step', async () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'choose-error', 'user'); + const onCompareEvents = vi.fn(); + render(InvestigationDetailTour, { event, onCompareEvents }); + await screen.findByText('See the impact'); + + for (const [index, title] of ['See the impact', 'Read what happened', 'See related reports'].entries()) { + // Act + await screen.findByText(title); + + // Assert + expect(targets[index]?.classList.contains('driver-active-element')).toBe(true); + if (index < targets.length - 1) { + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Next' })); + } + } + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Show all events' })); + + // Assert + expect(onCompareEvents).toHaveBeenCalledOnce(); + expect(actions.dismiss).not.toHaveBeenCalled(); + }); + + it('records completion when the highlighted Show all events control is used', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('event-investigate', 'filter-stack-events', 'user'); + const view = render(InvestigationDetailTour, { event, onCompareEvents: vi.fn() }); + + // Act: the parent calls this before closing the details panel. + await view.component.completeComparison(); + + // Assert + expect(actions.complete).toHaveBeenCalledExactlyOnceWith(checkpoint); + }); + + it('does not advance for a non-error event', () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'choose-error', 'user'); + + // Act + render(InvestigationDetailTour, { event: { ...event, data: {}, type: 'log' }, onCompareEvents: vi.fn() }); + + // Assert + expect(productTourCheckpoint.current?.checkpointName).toBe('choose-error'); + expect(screen.queryByRole('region', { name: 'Guide' })).toBeNull(); + }); + + it('advances an already-open error after selection and on a later guide run', async () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'choose-error', 'user'); + render(InvestigationDetailTour, { event, onCompareEvents: vi.fn() }); + + for (let run = 0; run < 2; run++) { + // Act + productTourCheckpoint.start('event-investigate', 'choose-error', 'user'); + await screen.findByText('See the impact'); + + // Assert + expect(productTourCheckpoint.current?.checkpointName).toBe('stack-summary'); + } + }); + + it('goes back through detail steps without reopening events or saving progress', async () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'tab-overview', 'user'); + render(InvestigationDetailTour, { event, onCompareEvents: vi.fn() }); + + // Act + await fireEvent.click(await screen.findByRole('button', { name: 'Back' })); + await screen.findByText('See the impact'); + + // Assert + expect(productTourCheckpoint.current?.checkpointName).toBe('stack-summary'); + expect(screen.queryByRole('button', { name: 'Back' })).toBeNull(); + expect(actions.complete).not.toHaveBeenCalled(); + expect(actions.dismiss).not.toHaveBeenCalled(); + }); + + it('retains an accessible end-guide action', async () => { + // Arrange + productTourCheckpoint.start('event-investigate', 'stack-summary', 'user'); + render(InvestigationDetailTour, { event, onCompareEvents: vi.fn() }); + + // Act + await fireEvent.click(await screen.findByRole('button', { name: 'End guide' })); + + // Assert + expect(actions.dismiss).toHaveBeenCalledExactlyOnceWith(productTourCheckpoint.current); + expect(actions.complete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-list.svelte new file mode 100644 index 0000000000..9f4c83a0ed --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-list.svelte @@ -0,0 +1,38 @@ + + +{#if checkpoint?.checkpointName === 'choose-error'} + {#key firstErrorId} + + {/key} +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-list.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-list.svelte.test.ts new file mode 100644 index 0000000000..d9098cd044 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/tours/investigation-list.svelte.test.ts @@ -0,0 +1,94 @@ +import { productTourCheckpoint } from '$features/product-tours/state.svelte'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import InvestigationListTour from './investigation-list.svelte'; +vi.mock('$features/product-tours/activity', () => ({ submitProductTourActivity: vi.fn() })); + +vi.mock('$features/product-tours/actions.svelte', () => ({ + createProductTourActions: () => ({ dismiss: vi.fn() }) +})); + +describe('InvestigationListTour', () => { + let target: HTMLDivElement; + let filters: HTMLButtonElement; + + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + public disconnect() {} + public observe() {} + } + ); + target = document.createElement('div'); + target.dataset.tour = 'event-list'; + target.innerHTML = '
Error
'; + target.querySelector('tr')!.scrollIntoView = vi.fn(); + filters = document.createElement('button'); + filters.dataset.tour = 'event-filters'; + filters.scrollIntoView = vi.fn(); + document.body.append(target, filters); + target.scrollIntoView = vi.fn(); + productTourCheckpoint.start('event-investigate', 'choose-error', 'user'); + }); + + afterEach(() => { + cleanup(); + target.remove(); + filters.remove(); + vi.unstubAllGlobals(); + productTourCheckpoint.clear(); + }); + + it('opens the supplied first error only after the user chooses the action', async () => { + // Arrange + const onOpenError = vi.fn(); + render(InvestigationListTour, { firstErrorId: 'first-error', onOpenError }); + const open = await screen.findByRole('button', { name: 'Open error' }); + expect(onOpenError).not.toHaveBeenCalled(); + + // Act + await fireEvent.click(open); + + // Assert + expect(onOpenError).toHaveBeenCalledExactlyOnceWith('first-error'); + expect(productTourCheckpoint.current?.checkpointName).toBe('choose-error'); + }); + + it('offers no open action until an error is available', async () => { + // Arrange + const onOpenError = vi.fn(); + const component = render(InvestigationListTour, { onOpenError }); + await screen.findByText('There are no errors in this list yet. Try a different time range or project.'); + expect(screen.queryByRole('button', { name: 'Open error' })).toBeNull(); + + // Act + target.querySelector('a')!.setAttribute('href', '/next/event/loaded-error'); + await component.rerender({ firstErrorId: 'loaded-error', onOpenError }); + await fireEvent.click(await screen.findByRole('button', { name: 'Open error' })); + + // Assert + expect(onOpenError).toHaveBeenCalledExactlyOnceWith('loaded-error'); + }); + + it('highlights the same error as its action when resuming on a mixed list', async () => { + // Arrange: the event list has a newer log before the selected error. + const errorId = '507f1f77bcf86cd799439012'; + target.innerHTML = ` + + +
Newer log
Checkout error
`; + const errorRow = target.querySelectorAll('tr')[1]; + const onOpenError = vi.fn(); + productTourCheckpoint.start('event-investigate', 'choose-error', 'user'); + + // Act: mount the real guide on the existing list. + render(InvestigationListTour, { firstErrorId: errorId, onOpenError }); + + // Assert + await waitFor(() => expect(document.querySelector('.driver-active-element')).toBe(errorRow)); + await fireEvent.click(document.querySelector('.driver-popover-next-btn')!); + expect(onOpenError).toHaveBeenCalledWith(errorId); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/context.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/context.svelte.ts index 599f3b6de0..9aed6ffa4e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/context.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/context.svelte.ts @@ -3,13 +3,13 @@ import { PersistedState } from 'runed'; export const organization = new PersistedState('organization', undefined); class ShowOrganizationNotificationsState { - get current() { + public get current() { return this._visible; } private _visible = $state(true); - set(value: boolean) { + public set(value: boolean) { this._visible = value; } } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/premium-page.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/premium-page.svelte.ts index 2aebae1ccb..af0f9d7eb3 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/premium-page.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/premium-page.svelte.ts @@ -4,15 +4,15 @@ * Follows the same getter/setter pattern as CachedPersistedState. */ class PremiumPageState { - get current(): string | undefined { + public get current(): string | undefined { return this.#value; } - set current(featureName: string | undefined) { + public set current(featureName: string | undefined) { this.#value = featureName; } - get requiresPremium() { + public get requiresPremium() { return this.#value !== undefined; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.test.ts new file mode 100644 index 0000000000..d623185a01 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createProductTourActions } from './actions.svelte'; +import { productTourCheckpoint } from './state.svelte'; + +const mocks = vi.hoisted(() => ({ + error: vi.fn(), + mutateAsync: vi.fn<() => Promise>(), + openCatalog: vi.fn(), + submitFeatureUsage: vi.fn(), + success: vi.fn() +})); +vi.mock('./activity', () => ({ submitProductTourActivity: mocks.submitFeatureUsage })); +vi.mock('$features/users/api.svelte', () => ({ putCurrentUserProductTour: () => ({ mutateAsync: mocks.mutateAsync }) })); +vi.mock('./controls.svelte', () => ({ tryUseProductTourControls: () => ({ openCatalog: mocks.openCatalog }) })); +vi.mock('svelte-sonner', () => ({ toast: { error: mocks.error, success: mocks.success } })); + +describe('product tour completion', () => { + beforeEach(() => mocks.mutateAsync.mockResolvedValue(undefined)); + + it('finishes without waiting for telemetry', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('saved-view-create', 'name-view', 'user'); + mocks.submitFeatureUsage.mockReturnValue(new Promise(() => {})); + + // Act + const completed = await createProductTourActions().complete(checkpoint); + + // Assert + expect(completed).toBe(true); + expect(productTourCheckpoint.current).toBeUndefined(); + expect(mocks.success).toHaveBeenCalledOnce(); + }); + + afterEach(() => { + productTourCheckpoint.clear(); + vi.resetAllMocks(); + }); + + it('offers an actionable next step when the guide finishes', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('event-investigate', 'filter-stack-events', 'user'); + const actions = createProductTourActions(); + + // Act + const completed = await actions.complete(checkpoint); + + // Assert + expect(completed).toBe(true); + expect(productTourCheckpoint.current).toBeUndefined(); + expect(mocks.success).toHaveBeenCalledExactlyOnceWith('You’ve explored an error and its occurrences', { + action: { label: 'Browse guides', onClick: mocks.openCatalog }, + description: 'Find more tours in Search → Guided Tours.' + }); + + // Act + const options = mocks.success.mock.calls[0]![1]; + options.action.onClick(); + + // Assert + expect(mocks.openCatalog).toHaveBeenCalledOnce(); + }); + + it('leaves the overview menu handoff unobstructed by a completion toast', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('app-overview', 'command-search', 'user'); + + // Act + const completed = await createProductTourActions().complete(checkpoint); + + // Assert + expect(completed).toBe(true); + expect(mocks.success).not.toHaveBeenCalled(); + }); + + it('closes immediately when persistence cannot be saved', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('app-overview', 'command-search', 'user'); + mocks.mutateAsync.mockRejectedValueOnce(new Error('Unavailable')); + + // Act + const completed = await createProductTourActions().complete(checkpoint); + + // Assert + expect(completed).toBe(true); + expect(productTourCheckpoint.current).toBeUndefined(); + expect(mocks.openCatalog).not.toHaveBeenCalled(); + }); + + it('closes immediately when completion persistence never settles', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('saved-view-create', 'name-view', 'user'); + mocks.mutateAsync.mockReturnValue(new Promise(() => {})); + + // Act + const completed = await createProductTourActions().complete(checkpoint); + + // Assert + expect(completed).toBe(true); + expect(productTourCheckpoint.current).toBeUndefined(); + }); + + it('does not submit completion for a dismissed checkpoint', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('app-overview', 'command-search', 'user'); + const actions = createProductTourActions(); + + // Act + await actions.dismiss(checkpoint); + const completed = await actions.complete(checkpoint); + + // Assert + expect(completed).toBe(false); + expect(mocks.mutateAsync).not.toHaveBeenCalled(); + expect(mocks.success).not.toHaveBeenCalled(); + expect(mocks.openCatalog).not.toHaveBeenCalled(); + }); + + it('does not submit dismissal after another guide replaces the checkpoint', async () => { + // Arrange + const previous = productTourCheckpoint.start('app-overview', 'command-search', 'user'); + const current = productTourCheckpoint.start('saved-view-create', 'open-view-menu', 'user'); + + // Act + const dismissed = await createProductTourActions().dismiss(previous); + + // Assert + expect(dismissed).toBe(false); + expect(productTourCheckpoint.current).toBe(current); + expect(mocks.mutateAsync).not.toHaveBeenCalled(); + expect(mocks.submitFeatureUsage).not.toHaveBeenCalled(); + }); + + it('offers the next guide once after a first event succeeds', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('project-configure', 'sdk-instructions', 'user'); + mocks.mutateAsync.mockResolvedValueOnce(undefined); + const actions = createProductTourActions(); + + // Act + await Promise.all([actions.complete(checkpoint), actions.complete(checkpoint)]); + + // Assert + expect(mocks.mutateAsync).toHaveBeenCalledOnce(); + expect(mocks.success).toHaveBeenCalledExactlyOnceWith( + 'Your project received its first event', + expect.objectContaining({ + action: { label: 'Browse guides', onClick: mocks.openCatalog } + }) + ); + }); + + it('does not submit a second outcome while progress is being saved', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('app-overview', 'command-search', 'user'); + const pending = Promise.withResolvers(); + mocks.mutateAsync.mockReturnValue(pending.promise); + const actions = createProductTourActions(); + + // Act + const completion = actions.complete(checkpoint); + const dismissed = await actions.dismiss(checkpoint); + pending.resolve(); + const completed = await completion; + + // Assert + expect(dismissed).toBe(false); + expect(completed).toBe(true); + expect(mocks.mutateAsync).toHaveBeenCalledOnce(); + expect(mocks.submitFeatureUsage).toHaveBeenCalledExactlyOnceWith('completed', 'app-overview'); + }); + + it('clears a domain-success checkpoint even when persistence fails', async () => { + // Arrange + const checkpoint = productTourCheckpoint.start('project-configure', 'sdk-instructions', 'user'); + mocks.mutateAsync.mockRejectedValueOnce(new Error('Unavailable')); + + // Act + await createProductTourActions().complete(checkpoint); + + // Assert + expect(productTourCheckpoint.current).toBeUndefined(); + expect(mocks.success).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts new file mode 100644 index 0000000000..834f5b15a1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/actions.svelte.ts @@ -0,0 +1,68 @@ +import { putCurrentUserProductTour } from '$features/users/api.svelte'; +import { toast } from 'svelte-sonner'; + +import type { ProductTourCheckpoint } from './models'; + +import { submitProductTourActivity } from './activity'; +import { tryUseProductTourControls } from './controls.svelte'; +import { productTourCheckpoint } from './state.svelte'; + +const COMPLETION_MESSAGES: Record, string> = { + 'event-investigate': 'You’ve explored an error and its occurrences', + 'exie-overview': 'You’re ready to ask Exie a question', + 'project-configure': 'Your project received its first event', + 'saved-view-create': 'Your saved view is ready' +}; + +export function createProductTourActions() { + const controls = tryUseProductTourControls(); + const progressMutation = putCurrentUserProductTour(); + + async function complete(checkpoint: ProductTourCheckpoint): Promise { + return finish(checkpoint, 'completed'); + } + + async function dismiss(checkpoint: ProductTourCheckpoint): Promise { + return finish(checkpoint, 'dismissed'); + } + + async function finish(checkpoint: ProductTourCheckpoint, action: 'completed' | 'dismissed'): Promise { + if (!productTourCheckpoint.clear(checkpoint)) { + return false; + } + + if (action === 'completed') { + void progressMutation + .mutateAsync({ + tourName: checkpoint.tourName, + userId: checkpoint.userId + }) + .catch(() => undefined); + } + void submitProductTourActivity(action, checkpoint.tourName); + if (action === 'completed') { + showCompletion(checkpoint); + } + return true; + } + + function showCompletion(checkpoint: ProductTourCheckpoint): void { + // The overview ends beside Search, where tours can be reopened. + if (checkpoint.tourName !== 'app-overview') { + toast.success(COMPLETION_MESSAGES[checkpoint.tourName], { + action: controls + ? { + label: 'Browse guides', + onClick: controls.openCatalog + } + : undefined, + description: 'Find more tours in Search → Guided Tours.' + }); + } + } + + return { + complete, + dismiss + }; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/activity.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/activity.test.ts new file mode 100644 index 0000000000..ea6e7b81a1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/activity.test.ts @@ -0,0 +1,38 @@ +import { ExceptionlessClient } from '@exceptionless/browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { submitProductTourActivity } from './activity'; + +const submitFeatureUsage = vi.hoisted(() => vi.fn()); +vi.mock('$features/auth/exceptionless-session', () => ({ submitFeatureUsage })); + +describe('product-tour activity', () => { + beforeEach(() => vi.resetAllMocks()); + + it.each(['completed', 'dismissed'] as const)('uses the existing feature-usage pipeline for %s', async (action) => { + // Arrange: each case supplies an action; beforeEach resets the mocked usage pipeline. + + // Act + await submitProductTourActivity(action, 'app-overview'); + + // Assert + expect(submitFeatureUsage).toHaveBeenCalledExactlyOnceWith(`product-tour.${action}.app-overview`); + }); + + it.each(['', 'synthetic-local-test-key'])('honors the existing SDK configuration (key: %s)', async (apiKey) => { + // Arrange + const client = new ExceptionlessClient(); + client.config.apiKey = apiKey; + const enqueue = vi.spyOn(client.config.services.queue, 'enqueue').mockResolvedValue(undefined); + submitFeatureUsage.mockImplementation((feature: string) => client.submitFeatureUsage(feature)); + + // Act + await submitProductTourActivity('completed', 'app-overview'); + + // Assert + expect(enqueue).toHaveBeenCalledTimes(apiKey ? 1 : 0); + if (apiKey) { + expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ source: 'product-tour.completed.app-overview', type: 'usage' })); + } + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/activity.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/activity.ts new file mode 100644 index 0000000000..15b8a16bef --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/activity.ts @@ -0,0 +1,7 @@ +import { submitFeatureUsage } from '$features/auth/exceptionless-session'; + +import type { ProductTourKey } from './models'; + +export async function submitProductTourActivity(action: 'completed' | 'dismissed', name: ProductTourKey): Promise { + await submitFeatureUsage(`product-tour.${action}.${name}`); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts new file mode 100644 index 0000000000..fac3cb4ce1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest'; + +import type { ProductTourContext } from './models'; + +import { getProductTourItems, getRecommendedProductTourName, productTourCatalog } from './catalog'; + +function context(overrides: Partial = {}): ProductTourContext { + return { + errorEventAvailability: 'available', + isProjectConfigurePage: false, + isSetupPage: false, + organizationId: 'organization-id', + pathname: '/next', + projects: [], + ...overrides + }; +} + +describe('product tour catalog', () => { + it.each(['filters', 'saved-views'] as const)('only resumes %s where its controls exist', (checkpoint) => { + const guide = productTourCatalog.find((tour) => tour.name === 'app-overview')!; + expect(guide.canResume(checkpoint, '/(app)/event')).toBe(true); + expect(guide.canResume(checkpoint, '/(app)/project/[projectId]/manage')).toBe(false); + expect(guide.canResume(checkpoint, null)).toBe(false); + }); + + it('allows the View step on every page with a View menu', () => { + const guide = productTourCatalog.find((tour) => tour.name === 'app-overview')!; + for (const route of ['/(app)/stack', '/(app)/sessions', '/(app)/stream'] as const) { + expect(guide.canResume('saved-views', route)).toBe(true); + expect(guide.canResume('filters', route)).toBe(false); + } + }); + + it('keeps shell-wide overview steps resumable outside Events', () => { + const guide = productTourCatalog.find((tour) => tour.name === 'app-overview')!; + for (const checkpoint of ['navigation', 'events', 'exie', 'command-search'] as const) { + expect(guide.canResume(checkpoint, '/(app)/project/[projectId]/manage')).toBe(true); + expect(guide.canResume(checkpoint, null)).toBe(false); + } + }); + + it('resumes project setup using route identity rather than path substrings', () => { + // Arrange + const guide = productTourCatalog.find((tour) => tour.name === 'project-configure')!; + + // Act + const organization = guide.canResume('organization-name', '/(app)/organization/add'); + const project = guide.canResume('project-name', '/(app)/project/add'); + const sdk = guide.canResume('sdk-instructions', '/(app)/project/[projectId]/configure'); + const wrongRoute = guide.canResume('sdk-instructions', '/(app)/project/add'); + const missingRoute = guide.canResume('sdk-instructions', null); + + // Assert + expect(organization).toBe(true); + expect(project).toBe(true); + expect(sdk).toBe(true); + expect(wrongRoute).toBe(false); + expect(missingRoute).toBe(false); + }); + + it('does not resume dialog or detail checkpoints on their parent list', () => { + // Arrange + const savedView = productTourCatalog.find((tour) => tour.name === 'saved-view-create')!; + const investigation = productTourCatalog.find((tour) => tour.name === 'event-investigate')!; + + // Act + const viewMenu = savedView.canResume('open-view-menu', '/(app)/event'); + const viewDialog = savedView.canResume('name-view', '/(app)/event'); + const errorList = investigation.canResume('choose-error', '/(app)/event'); + const errorDetail = investigation.canResume('stack-summary', '/(app)/event'); + + // Assert + expect(viewMenu).toBe(true); + expect(viewDialog).toBe(false); + expect(errorList).toBe(true); + expect(errorDetail).toBe(false); + }); + + it('contains only durable metadata for the five named tours', () => { + // Arrange: the static catalog supplies the guide definitions. + + // Act + const names = productTourCatalog.map((tour) => tour.name); + const hasKeywords = productTourCatalog.every((tour) => tour.keywords.length > 0); + const metadata = JSON.stringify(productTourCatalog); + + // Assert + expect(names).toEqual(['app-overview', 'project-configure', 'saved-view-create', 'event-investigate', 'exie-overview']); + expect(hasKeywords).toBe(true); + expect(metadata).not.toContain('data-tour'); + }); + + it('recommends setup until an organization has configured projects', () => { + // Arrange + const noOrganization = context({ organizationId: undefined }); + const noProjects = context({ projects: [] }); + const unconfigured = context({ projects: [{ id: 'project-id', is_configured: false }] }); + const configured = context({ projects: [{ id: 'project-id', is_configured: true }] }); + + // Act + const recommendations = [noOrganization, noProjects, unconfigured, configured].map(getRecommendedProductTourName); + + // Assert + expect(recommendations).toEqual(['project-configure', 'project-configure', 'project-configure', 'app-overview']); + }); + + it('reports availability separately from catalog metadata', () => { + // Arrange + const currentContext = context({ + assistantAccess: { enabled: false, has_access: false, upgrade_required: false }, + errorEventAvailability: 'empty' + }); + + // Act + const items = getProductTourItems(currentContext); + + // Assert + expect(items.find((item) => item.name === 'exie-overview')?.currentAvailability.available).toBe(false); + expect(items.find((item) => item.name === 'event-investigate')?.currentAvailability.available).toBe(false); + }); + + it('maps every guide to a stable record and typed state field', () => { + // Arrange + const currentContext = context(); + + // Act + const items = getProductTourItems(currentContext); + + // Assert + expect(items.every((item) => item.stateKey)).toBe(true); + }); + + it('does not mistake an unavailable project list for an empty organization', () => { + // Arrange + const currentContext = context({ projects: undefined }); + + // Act + const items = getProductTourItems(currentContext); + const recommended = getRecommendedProductTourName(currentContext); + + // Assert + expect(items.find((item) => item.name === 'project-configure')?.currentAvailability).toEqual({ + available: false, + reason: 'Projects could not be loaded. Try again shortly.' + }); + expect(items.find((item) => item.name === 'app-overview')?.currentAvailability.available).toBe(true); + expect(items.find((item) => item.name === 'saved-view-create')?.currentAvailability.available).toBe(true); + expect(recommended).toBe('app-overview'); + }); + + it.each([{ isProjectConfigurePage: true }, { organizationId: undefined }])('allows setup without a project lookup when %o', (overrides) => { + // Arrange + const currentContext = context({ projects: undefined, ...overrides }); + + // Act + const projectGuide = getProductTourItems(currentContext).find((item) => item.name === 'project-configure'); + + // Assert + expect(projectGuide?.currentAvailability.available).toBe(true); + }); + + it('starts project setup from domain state', () => { + // Arrange + const definition = productTourCatalog.find((tour) => tour.name === 'project-configure')!; + + // Act + const organization = definition.start(context({ organizationId: undefined })); + const project = definition.start(context({ projects: [] })); + const platform = definition.start(context({ projects: [{ id: 'project-id', is_configured: false }] })); + + // Assert + expect(organization).toEqual({ checkpointName: 'organization-name', route: '/next/organization/add' }); + expect(project).toEqual({ checkpointName: 'project-name', route: '/next/project/add' }); + expect(platform).toEqual({ + checkpointName: 'choose-platform', + route: '/next/project/project-id/configure?redirect=true' + }); + }); + + it('keeps the current project and SDK when starting from Client Setup', () => { + // Arrange + const definition = productTourCatalog.find((tour) => tour.name === 'project-configure')!; + const currentContext = context({ + isProjectConfigurePage: true, + pathname: '/next/project/current-project/configure', + projects: [ + { id: 'other-project', is_configured: false }, + { id: 'current-project', is_configured: true } + ], + search: '?type=dotnet-legacy-mvc' + }); + + // Act + const start = definition.start(currentContext); + + // Assert + expect(start).toEqual({ checkpointName: 'choose-platform', route: '/next/project/current-project/configure?type=dotnet-legacy-mvc&redirect=true' }); + }); + + it('does not carry another page SDK selection into project setup', () => { + // Arrange + const definition = productTourCatalog.find((tour) => tour.name === 'project-configure')!; + + // Act + const start = definition.start(context({ projects: [{ id: 'project-id', is_configured: false }], search: '?type=error' })); + + // Assert + expect(start).toEqual({ checkpointName: 'choose-platform', route: '/next/project/project-id/configure?redirect=true' }); + }); + + it('keeps Client Setup when the organization project list has not caught up', () => { + // Arrange + const definition = productTourCatalog.find((tour) => tour.name === 'project-configure')!; + + // Act + const start = definition.start( + context({ + isProjectConfigurePage: true, + organizationId: undefined, + pathname: '/next/project/current-project/configure', + projects: [], + search: '?type=dotnet-legacy-mvc' + }) + ); + + // Assert + expect(start).toEqual({ checkpointName: 'choose-platform', route: '/next/project/current-project/configure?type=dotnet-legacy-mvc&redirect=true' }); + }); + + it('requires actual Exie access', () => { + // Arrange + const currentContext = context({ assistantAccess: { enabled: true, has_access: false, upgrade_required: true } }); + + // Act + const item = getProductTourItems(currentContext).find((tour) => tour.name === 'exie-overview'); + + // Assert + expect(item?.currentAvailability.available).toBe(false); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts new file mode 100644 index 0000000000..97b2322028 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/catalog.ts @@ -0,0 +1,161 @@ +import type { RouteId } from '$app/types'; +import type { ProductTourState } from '$features/users/models'; + +import { resolve } from '$app/paths'; + +import type { ProductTourContext, ProductTourDefinition, ProductTourListItem, ProductTourName } from './models'; + +import { getProductTourRecordedAt } from './eligibility'; + +const savedViewRouteIds = new Set(['/(app)/event', '/(app)/sessions', '/(app)/stack', '/(app)/stream']); + +function requireApplicationShell(context: ProductTourContext) { + return context.isSetupPage || !context.organizationId + ? { available: false, reason: 'Finish organization setup to explore Exceptionless.' } + : { available: true }; +} + +function requireError(context: ProductTourContext) { + if (!context.organizationId) { + return { available: false, reason: 'Create an organization and project first.' }; + } + + if (context.errorEventAvailability === 'loading') { + return { available: false, reason: 'Checking for an accessible error report…' }; + } + + if (context.errorEventAvailability === 'error') { + return { available: false, reason: 'Error reports could not be checked. Try again shortly.' }; + } + + if (context.errorEventAvailability === 'empty') { + return { available: false, reason: 'Send an error report before starting this guide.' }; + } + return { available: true }; +} + +function requireOrganization(context: ProductTourContext) { + return context.organizationId ? { available: true } : { available: false, reason: 'Create an organization and project first.' }; +} + +export const productTourCatalog: readonly ProductTourDefinition[] = [ + { + availability: requireApplicationShell, + canResume: (checkpoint, routeId) => { + if (!routeId) { + return false; + } + + if (checkpoint === 'filters') { + return routeId === '/(app)/event'; + } + + if (checkpoint === 'saved-views') { + return savedViewRouteIds.has(routeId); + } + return true; + }, + description: 'Find your way around in about a minute.', + keywords: ['navigation', 'ui', 'search', 'command palette', 'help', 'saved views', 'stacks', 'occurrences'], + name: 'app-overview', + start: () => ({ checkpointName: 'navigation', route: resolve('/(app)/event') }), + stateKey: 'app_overview', + title: 'Explore Exceptionless' + }, + { + availability: (context) => + context.isProjectConfigurePage || !context.organizationId || context.projects + ? { available: true } + : { available: false, reason: 'Projects could not be loaded. Try again shortly.' }, + canResume: (checkpoint, routeId) => { + if (checkpoint === 'organization-name') { + return routeId === '/(app)/organization/add'; + } + + if (checkpoint === 'project-name') { + return routeId === '/(app)/organization/add' || routeId === '/(app)/project/add'; + } + return routeId === '/(app)/project/[projectId]/configure'; + }, + description: 'Connect your app and start seeing errors here.', + keywords: ['add project', 'configure', 'sdk', 'api key', 'first event'], + name: 'project-configure', + start: (context) => { + if (context.isProjectConfigurePage) { + const search = new URLSearchParams(context.search); + search.set('redirect', 'true'); + return { checkpointName: 'choose-platform', route: `${context.pathname}?${search}` }; + } + + if (!context.organizationId) { + return { checkpointName: 'organization-name', route: resolve('/(app)/organization/add') }; + } + + const unconfiguredProject = context.projects?.find((project) => !project.is_configured); + if (unconfiguredProject?.id) { + return { + checkpointName: 'choose-platform', + route: `${resolve('/(app)/project/[projectId]/configure', { projectId: unconfiguredProject.id })}?redirect=true` + }; + } + + return { checkpointName: 'project-name', route: resolve('/(app)/project/add') }; + }, + stateKey: 'project_configure', + title: 'Configure a project' + }, + { + availability: requireOrganization, + canResume: (checkpoint, routeId) => routeId === '/(app)/event' && checkpoint === 'open-view-menu', + description: 'Save a useful set of filters to come back to later.', + keywords: ['saved view', 'filter', 'columns', 'private', 'dashboard'], + name: 'saved-view-create', + start: () => ({ checkpointName: 'open-view-menu', route: resolve('/(app)/event') }), + stateKey: 'saved_view_create', + title: 'Create a saved view' + }, + { + availability: requireError, + canResume: (checkpoint, routeId) => routeId === '/(app)/event' && checkpoint === 'choose-error', + description: 'Open an error, see its impact, and read what happened.', + keywords: ['error report', 'event details', 'occurrences', 'exception', 'filter', 'stack', 'triage'], + name: 'event-investigate', + start: () => ({ checkpointName: 'choose-error', route: `${resolve('/(app)/event')}?time=all&type=error` }), + stateKey: 'event_investigate', + title: 'Investigate an error' + }, + { + availability: (context) => { + if (!context.assistantAccess?.enabled) { + return { available: false, reason: 'Exie is not available in this workspace.' }; + } + + return context.assistantAccess.has_access + ? { available: true } + : { available: false, reason: context.assistantAccess.message ?? 'Exie requires access.' }; + }, + canResume: (checkpoint) => checkpoint === 'open-exie', + description: 'Get help understanding errors and finding patterns.', + keywords: ['exie', 'assistant', 'ai', 'help', 'investigate'], + name: 'exie-overview', + start: () => ({ checkpointName: 'open-exie', route: resolve('/') }), + stateKey: 'exie_overview', + title: 'Meet Exie' + } +] as const; + +export function getProductTourItems(context: ProductTourContext, state: ProductTourState = {}): ProductTourListItem[] { + return productTourCatalog.map((definition) => { + return { + ...definition, + currentAvailability: definition.availability(context), + recordedAt: getProductTourRecordedAt(state, definition.stateKey) + }; + }); +} + +export function getRecommendedProductTourName(context: ProductTourContext): ProductTourName { + return !context.organizationId || context.projects?.length === 0 || context.projects?.some((project) => !project.is_configured) + ? 'project-configure' + : 'app-overview'; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-feature-announcement.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-feature-announcement.svelte new file mode 100644 index 0000000000..c85a85a352 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-feature-announcement.svelte @@ -0,0 +1,46 @@ + + +{#if open} + + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte new file mode 100644 index 0000000000..de31a91244 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte @@ -0,0 +1,48 @@ + + +{#if open} + +
+

Welcome to Exceptionless

+ +
+ {recommended.description} +
+ + +
+
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte.test.ts new file mode 100644 index 0000000000..534b4e830d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/alerts/product-tour-welcome.svelte.test.ts @@ -0,0 +1,108 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import ProductTourWelcome from './product-tour-welcome.svelte'; + +const recommended = { + availability: vi.fn(() => ({ available: true })), + canResume: () => true, + currentAvailability: { available: true }, + description: 'Learn navigation and search.', + keywords: ['navigation'], + name: 'app-overview' as const, + start: vi.fn(() => ({ checkpointName: 'navigation' as const, route: '/next' })), + stateKey: 'app_overview' as const, + title: 'Explore Exceptionless' +}; + +describe('ProductTourWelcome', () => { + it('records dismissal from Escape inside the welcome', async () => { + // Arrange + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + const onStart = vi.fn(); + render(ProductTourWelcome, { onBrowse, onDismiss, onStart, open: true, recommended }); + + // Act + await fireEvent.keyDown(screen.getByRole('button', { name: 'Close welcome' }), { key: 'Escape' }); + // Assert + expect(onBrowse).not.toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalledOnce(); + expect(onStart).not.toHaveBeenCalled(); + }); + + it('provides browse and close choices', async () => { + // Arrange + const onBrowse = vi.fn(); + const onDismiss = vi.fn(); + render(ProductTourWelcome, { onBrowse, onDismiss, onStart: vi.fn(), open: true, recommended }); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Browse guides' })); + // Assert + expect(onBrowse).toHaveBeenCalledOnce(); + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Close welcome' })); + // Assert + expect(onDismiss).toHaveBeenCalledOnce(); + }); + + it('offers only the recommended action without a modal or taking focus', async () => { + // Arrange + const onStart = vi.fn(); + const focusedElement = document.activeElement; + + // Act + render(ProductTourWelcome, { onBrowse: vi.fn(), onDismiss: vi.fn(), onStart, open: true, recommended }); + + // Assert + expect(screen.getByRole('region', { name: 'Welcome to Exceptionless' })).toBeTruthy(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(document.activeElement).toBe(focusedElement); + expect(screen.getAllByRole('button')).toHaveLength(3); + expect(screen.getByText(recommended.description)).toBeTruthy(); + // Act + await fireEvent.click(screen.getByRole('button', { name: recommended.title })); + // Assert + expect(onStart).toHaveBeenCalledOnce(); + }); + + it('offers setup when that is the recommendation', () => { + // Arrange: the shared recommendation supplies the guide metadata. + + // Act + render(ProductTourWelcome, { + onBrowse: vi.fn(), + onDismiss: vi.fn(), + onStart: vi.fn(), + open: true, + recommended: { ...recommended, name: 'project-configure', title: 'Configure a project' } + }); + + // Assert + expect(screen.getByRole('button', { name: 'Continue setup' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Explore Exceptionless' })).toBeNull(); + }); + + it('does not dismiss on Escape outside the welcome', async () => { + // Arrange + const onDismiss = vi.fn(); + render(ProductTourWelcome, { onBrowse: vi.fn(), onDismiss, onStart: vi.fn(), open: true, recommended }); + + // Act + await fireEvent.keyDown(document.body, { key: 'Escape' }); + + // Assert + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it('does not render when closed', () => { + // Arrange: the shared recommendation supplies the guide metadata. + + // Act + render(ProductTourWelcome, { onBrowse: vi.fn(), onDismiss: vi.fn(), onStart: vi.fn(), recommended }); + + // Assert + expect(screen.queryByRole('region')).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte new file mode 100644 index 0000000000..25da03df72 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte @@ -0,0 +1,81 @@ + + + + + + Guided Tours + Take a quick look around, or get help with one task. You can stop and come back anytime. + + +
    + {#each items as item (item.name)} + {@const Icon = icons[item.name]} + {@const completed = !!item.recordedAt} + {@const actionLabel = resumableTourName === item.name ? 'Continue' : activeTourName === item.name || completed ? 'Restart' : 'Start'} +
  • +
    +
    +
  • + {/each} +
+
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts new file mode 100644 index 0000000000..403ee28598 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/dialogs/product-tour-catalog-dialog.svelte.test.ts @@ -0,0 +1,73 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getProductTourItems } from '../../catalog'; +import ProductTourCatalogDialog from './product-tour-catalog-dialog.svelte'; + +describe('ProductTourCatalogDialog', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(async () => { + cleanup(); + await vi.runOnlyPendingTimersAsync(); + vi.useRealTimers(); + }); + it('distinguishes guides and preserves restart, continue, and unavailable actions', async () => { + // Arrange + const items = getProductTourItems( + { + errorEventAvailability: 'empty', + isProjectConfigurePage: false, + isSetupPage: false, + organizationId: 'organization', + pathname: '/next', + projects: [] + }, + { app_overview: '2026-09-08T00:00:00Z' } + ); + const onStart = vi.fn(async () => {}); + render(ProductTourCatalogDialog, { items, onStart, open: true, ready: true, resumableTourName: 'saved-view-create' }); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Restart Explore Exceptionless' })); + await fireEvent.click(screen.getByRole('button', { name: 'Continue Create a saved view' })); + screen.getByRole('button', { name: 'Start Investigate an error' }).click(); + + // Assert + expect(onStart.mock.calls).toEqual([['app-overview'], ['saved-view-create']]); + expect(screen.getByText('Completed')).toBeTruthy(); + const unavailable = screen.getByRole('button', { name: 'Start Investigate an error' }); + expect(unavailable.hasAttribute('disabled')).toBe(true); + expect(document.getElementById(unavailable.getAttribute('aria-describedby')!)?.textContent).toContain('Send an error report'); + expect(screen.getByRole('list', { name: 'Available guides' })).toBeTruthy(); + expect(screen.getAllByRole('listitem')).toHaveLength(5); + const icons = items.map((item) => { + const icon = screen.getByRole('region', { name: item.title }).querySelector('svg'); + expect(icon?.getAttribute('aria-hidden')).toBe('true'); + return icon?.innerHTML; + }); + expect(new Set(icons).size).toBe(5); + }); + + it('keeps the picker focused on outcomes without step counts or documentation detours', () => { + // Arrange + const items = getProductTourItems({ + errorEventAvailability: 'empty', + isProjectConfigurePage: false, + isSetupPage: false, + pathname: '/next', + projects: [] + }); + const onStart = vi.fn(); + + // Act + render(ProductTourCatalogDialog, { items, onStart, open: true, ready: false }); + + // Assert + for (const item of items) { + expect(screen.getByText(item.description)).toBeTruthy(); + } + expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(screen.queryByText(/\d+ steps/)).toBeNull(); + expect(onStart).not.toHaveBeenCalled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte new file mode 100644 index 0000000000..5d7ac33bdc --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-description.svelte @@ -0,0 +1,14 @@ + + +

+ {#if typeof description === 'string'} + {description} + {:else} + {@render description()} + {/if} +

diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte new file mode 100644 index 0000000000..e1543b9909 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte @@ -0,0 +1,350 @@ + + + + +{#if exieAnnouncementOpen && assistantAccess} + +{/if} + + + +{#if checkpoint && (checkpoint.tourName === 'exie-overview' || checkpoint.tourName === 'app-overview')} + {#key checkpoint} + + {/key} +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte.test.ts new file mode 100644 index 0000000000..e0db3fd160 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-host.svelte.test.ts @@ -0,0 +1,129 @@ +import type { AssistantAccess } from '$features/assistant/models'; +import type { ViewCurrentUser } from '$features/users/models'; + +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { productTourCheckpoint } from '../state.svelte'; +import ProductTourHost from './product-tour-host.svelte'; + +const mocks = vi.hoisted(() => ({ + invalidateAssistantAccessQueries: vi.fn(), + isStripeEnabled: vi.fn(), + mutateAsync: vi.fn(), + queryClient: {}, + showChangePlanDialog: vi.fn() +})); + +vi.mock('$app/state', () => ({ page: { route: { id: '/(app)/stack/all' } } })); +vi.mock('$features/assistant/api.svelte', () => ({ invalidateAssistantAccessQueries: mocks.invalidateAssistantAccessQueries })); +vi.mock('$features/billing/change-plan.svelte', () => ({ showChangePlanDialog: mocks.showChangePlanDialog })); +vi.mock('$features/billing/stripe.svelte', () => ({ isStripeEnabled: mocks.isStripeEnabled })); +vi.mock('$features/events/api.svelte', () => ({ getOrganizationEventsQuery: () => ({ data: { data: [] }, isError: false, isPending: false }) })); +vi.mock('$features/projects/api.svelte', () => ({ getOrganizationProjectsQuery: () => ({ data: { data: [] }, isError: false, isSuccess: true }) })); +vi.mock('$features/users/api.svelte', () => ({ putCurrentUserProductTour: () => ({ mutateAsync: mocks.mutateAsync }) })); +vi.mock('@tanstack/svelte-query', () => ({ useQueryClient: () => mocks.queryClient })); +vi.mock('../actions.svelte', () => ({ createProductTourActions: () => ({ complete: vi.fn() }) })); + +const assistantAccess: AssistantAccess = { enabled: true, has_access: false, minimum_plan_id: 'EX_MEDIUM', upgrade_required: true }; +const currentUser = { id: 'user', product_tours: { app_welcome: '2026-09-08T00:00:00Z' } } as ViewCurrentUser; + +function props() { + return { + assistantAccess, + closeOverlays: vi.fn(), + currentUser, + isAnyOverlayOpen: false, + isImpersonating: false, + isMobile: false, + isNavigationOverlayOpen: false, + isSetupPage: false, + openAssistant: vi.fn(async () => {}), + organizationId: 'organization', + pathname: '/next/stack/all', + setMobileNavigationOpen: vi.fn(), + stateSettled: true + }; +} + +describe('ProductTourHost', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.isStripeEnabled.mockReturnValue(true); + mocks.mutateAsync.mockResolvedValue({ recorded_utc: '2026-09-09T00:00:00Z' }); + }); + + afterEach(() => { + cleanup(); + productTourCheckpoint.clear(); + }); + + it('opens Change Plan directly with the eligible plan and refreshes access after success', async () => { + // Arrange + const options = props(); + render(ProductTourHost, options); + + // Act + await fireEvent.click(await screen.findByRole('button', { name: 'Upgrade Plan' })); + + // Assert + expect(mocks.showChangePlanDialog).toHaveBeenCalledExactlyOnceWith('organization', { + initialPlanId: 'EX_MEDIUM', + onSuccess: expect.any(Function) + }); + expect(options.openAssistant).not.toHaveBeenCalled(); + expect(mocks.mutateAsync).toHaveBeenCalledWith({ tourName: 'exie-announcement', userId: 'user' }); + + // Act + await mocks.showChangePlanDialog.mock.calls[0]![1].onSuccess(); + + // Assert + expect(mocks.invalidateAssistantAccessQueries).toHaveBeenCalledExactlyOnceWith(mocks.queryClient); + }); + + it.each([ + { billingEnabled: false, upgradeRequired: true }, + { billingEnabled: true, upgradeRequired: false } + ])('opens Exie without promising an unavailable upgrade: %j', async ({ billingEnabled, upgradeRequired }) => { + // Arrange + mocks.isStripeEnabled.mockReturnValue(billingEnabled); + const options = props(); + options.assistantAccess = { ...assistantAccess, upgrade_required: upgradeRequired }; + render(ProductTourHost, options); + + // Act + await fireEvent.click(await screen.findByRole('button', { name: 'Open Exie' })); + + // Assert + expect(options.openAssistant).toHaveBeenCalledOnce(); + expect(mocks.showChangePlanDialog).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: 'Upgrade Plan' })).toBeNull(); + }); + + it('selects invitations for the new user after the previous user dismisses one', async () => { + // Arrange + const options = props(); + const view = render(ProductTourHost, options); + await fireEvent.click(await screen.findByRole('button', { name: 'Dismiss Exie announcement' })); + expect(screen.queryByRole('button', { name: 'Upgrade Plan' })).toBeNull(); + + // Act + await view.rerender({ currentUser: { ...currentUser, id: 'next-user' } }); + + // Assert + expect(await screen.findByRole('button', { name: 'Upgrade Plan' })).toBeTruthy(); + }); + + it('clears an active guide when its organization changes', async () => { + // Arrange + const options = props(); + productTourCheckpoint.start('saved-view-create', 'open-view-menu', 'user', 'organization'); + const view = render(ProductTourHost, options); + + // Act + await view.rerender({ organizationId: 'other-organization' }); + + // Assert + expect(productTourCheckpoint.current).toBeUndefined(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte new file mode 100644 index 0000000000..0ca3a16926 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte @@ -0,0 +1,154 @@ + + +{#if spotlight && targetReady && navigationReady && (!isAnyOverlayOpen || checkpoint.tourName === 'exie-overview')} + 0 ? back : undefined} + side={spotlight.mobileNavigation && !isMobile ? 'right' : 'bottom'} + stepCount={steps.length} + stepNumber={stepIndex + 1} + target={spotlight.target} + title={spotlight.title} + /> +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte.test.ts new file mode 100644 index 0000000000..775c2e7951 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-shell-spotlight.svelte.test.ts @@ -0,0 +1,37 @@ +import { cleanup, render, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { productTourCheckpoint } from '../state.svelte'; +import ProductTourShellSpotlight from './product-tour-shell-spotlight.svelte'; + +vi.mock('../actions.svelte', () => ({ createProductTourActions: () => ({ complete: vi.fn(), dismiss: vi.fn() }) })); +vi.mock('../activity', () => ({ submitProductTourActivity: vi.fn() })); + +afterEach(() => { + cleanup(); + productTourCheckpoint.clear(); +}); + +describe('ProductTourShellSpotlight', () => { + it.each([undefined, { enabled: true, has_access: false, upgrade_required: true }])( + 'resumes an unavailable Exie checkpoint at Search: %j', + async (assistantAccess) => { + // Arrange + productTourCheckpoint.start('app-overview', 'exie', 'user'); + + // Act + render(ProductTourShellSpotlight, { + assistantAccess, + checkpoint: productTourCheckpoint.current!, + isAnyOverlayOpen: false, + isMobile: false, + openAssistant: vi.fn(), + setMobileNavigationOpen: vi.fn() + }); + + // Assert + await waitFor(() => expect(productTourCheckpoint.current?.checkpointName).toBe('command-search')); + expect(productTourCheckpoint.current?.tourName).toBe('app-overview'); + } + ); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte new file mode 100644 index 0000000000..144a310bc0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte @@ -0,0 +1,388 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte.test.ts new file mode 100644 index 0000000000..0cccc5b9f7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/product-tour-spotlight.svelte.test.ts @@ -0,0 +1,174 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ProductTourCheckpoint } from '../models'; + +import ProductTourSpotlight from './product-tour-spotlight.svelte'; +vi.mock('../activity', () => ({ submitProductTourActivity: vi.fn() })); + +const checkpoint: ProductTourCheckpoint = { checkpointName: 'command-search', tourName: 'app-overview', userId: 'user' }; + +describe('ProductTourSpotlight', () => { + let target: HTMLButtonElement; + + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + public disconnect() {} + public observe() {} + } + ); + target = document.createElement('button'); + target.scrollIntoView = vi.fn(); + document.body.append(target); + }); + + afterEach(() => { + cleanup(); + target.remove(); + vi.unstubAllGlobals(); + }); + + it('renders safe text and progress in the driver popover', async () => { + // Arrange: beforeEach creates the spotlight target. + + // Act + render(ProductTourSpotlight, { + props: { + checkpoint, + description: 'Search ', + onDismiss: vi.fn(async () => true), + target, + title: 'Search' + } + }); + + // Assert + expect(await screen.findByText('Search ')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Back' })).toBeNull(); + expect(screen.getByText('Step 6 of 6')).toBeTruthy(); + + // Act + cleanup(); + + // Assert + expect(document.querySelector('.product-tour-popover')).toBeNull(); + }); + + it('omits progress when checkpoints include work outside the guide', async () => { + // Arrange: beforeEach creates the spotlight target. + + // Act + render(ProductTourSpotlight, { + props: { checkpoint, description: 'Choose a platform', onDismiss: vi.fn(async () => true), showProgress: false, target, title: 'Setup' } + }); + + // Assert + expect(await screen.findByText('Choose a platform')).toBeTruthy(); + expect(screen.queryByText(/Step \d of \d/)).toBeNull(); + expect(screen.getByRole('button', { name: 'End guide' })).toBeTruthy(); + }); + + it('enables Back only when the caller provides a safe previous step', async () => { + // Arrange + const onPrevious = vi.fn(); + const onNext = vi.fn(); + render(ProductTourSpotlight, { + props: { checkpoint, description: 'Search', onDismiss: vi.fn(async () => true), onNext, onPrevious, target, title: 'Search' } + }); + const back = await screen.findByRole('button', { name: 'Back' }); + + // Act + await fireEvent.click(back); + + // Assert + expect(back.hasAttribute('disabled')).toBe(false); + expect(onPrevious).toHaveBeenCalledExactlyOnceWith(checkpoint); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('ignores Escape keyup from a closing overlay but handles a fresh Escape press', async () => { + // Arrange + const onDismiss = vi.fn(async () => true); + render(ProductTourSpotlight, { props: { checkpoint, description: 'Search', onDismiss, target, title: 'Search' } }); + + // Act + await fireEvent.keyUp(window, { key: 'Escape' }); + + // Assert + expect(onDismiss).not.toHaveBeenCalled(); + + // Act + await fireEvent.keyDown(window, { key: 'Escape' }); + + // Assert + expect(onDismiss).toHaveBeenCalledExactlyOnceWith(checkpoint); + }); + + it('follows a moving target without a resize event', async () => { + // Arrange + let top = 80; + vi.spyOn(target, 'getBoundingClientRect').mockImplementation(() => new DOMRect(100, top, 100, 32)); + render(ProductTourSpotlight, { + props: { checkpoint, description: 'Search', onDismiss: vi.fn(async () => true), side: 'bottom', target, title: 'Search' } + }); + await screen.findByText('Search', { selector: '.driver-popover-title' }); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + const popover = document.querySelector('.product-tour-popover')!; + const originalPosition = popover.style.bottom; + const originalHighlight = document.querySelector('.driver-overlay path')?.getAttribute('d'); + + // Act: opening a menu moves its target without changing its size. + top = 220; + + // Assert + await waitFor(() => expect(popover.style.bottom).not.toBe(originalPosition)); + expect(document.querySelector('.driver-overlay path')?.getAttribute('d')).not.toBe(originalHighlight); + }); + + it('reattaches when a refreshed list replaces the target element', async () => { + // Arrange + target.dataset.tour = 'report'; + render(ProductTourSpotlight, { + props: { checkpoint, description: 'Open this report', onDismiss: vi.fn(async () => true), target: '[data-tour="report"]', title: 'Report' } + }); + await waitFor(() => expect(target.classList.contains('driver-active-element')).toBe(true)); + + // Act + const replacement = document.createElement('button'); + replacement.dataset.tour = 'report'; + replacement.scrollIntoView = vi.fn(); + target.replaceWith(replacement); + target = replacement; + + // Assert + await waitFor(() => expect(replacement.classList.contains('driver-active-element')).toBe(true)); + expect(document.querySelectorAll('.product-tour-popover')).toHaveLength(1); + expect(screen.getByText('Open this report')).toBeTruthy(); + }); + + it('removes its popover and keyboard listener when unmounted', async () => { + // Arrange + const disconnect = vi.fn(); + const onDismiss = vi.fn(async () => true); + vi.stubGlobal( + 'ResizeObserver', + class { + public disconnect = disconnect; + public observe() {} + } + ); + const view = render(ProductTourSpotlight, { props: { checkpoint, description: 'Search', onDismiss, target, title: 'Search' } }); + await screen.findByText('Search', { selector: '.driver-popover-title' }); + + // Act + view.unmount(); + await fireEvent.keyDown(window, { key: 'Escape' }); + + // Assert + expect(onDismiss).not.toHaveBeenCalled(); + expect(document.querySelector('.product-tour-popover')).toBeNull(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte new file mode 100644 index 0000000000..d366a67f98 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/components/saved-view-create-tour.svelte @@ -0,0 +1,89 @@ + + +{#if checkpoint?.checkpointName === 'open-view-menu'} + +{:else if checkpoint?.checkpointName === 'review-settings'} + { + closeMenu(); + productTourCheckpoint.advance(active, 'open-view-menu'); + }} + onNext={async () => { + closeMenu(); + await openSaveDialog(); + }} + target="[data-tour='saved-view-save-as']" + title="Save your current view" + > + {#snippet description()} + Choose Save As… to give your current view a name. + {/snippet} + +{:else if checkpoint?.checkpointName === 'name-view'} + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/controls.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/controls.svelte.ts new file mode 100644 index 0000000000..3dce7bc1cb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/controls.svelte.ts @@ -0,0 +1,16 @@ +import { getContext, setContext } from 'svelte'; + +interface ProductTourControls { + getNavigationTarget: () => HTMLElement | undefined; + openCatalog: () => void; +} + +const PRODUCT_TOUR_CONTROLS_CONTEXT_KEY = Symbol.for('exceptionless-product-tour-controls'); + +export function setProductTourControls(controls: ProductTourControls): void { + setContext(PRODUCT_TOUR_CONTROLS_CONTEXT_KEY, controls); +} + +export function tryUseProductTourControls(): ProductTourControls | undefined { + return getContext(PRODUCT_TOUR_CONTROLS_CONTEXT_KEY); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts new file mode 100644 index 0000000000..aec43313fd --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { getProductTourRecordedAt, isProductTourSetupRoute, shouldOfferProductTourInvitation } from './eligibility'; + +describe('product tour setup routes', () => { + it.each(['/(app)/organization/add', '/(app)/project/add', '/(app)/project/[projectId]/configure'] as const)( + 'suppresses automatic tours on %s', + (routeId) => { + // Arrange: each case supplies a setup route ID. + + // Act + const isSetup = isProductTourSetupRoute(routeId); + + // Assert + expect(isSetup).toBe(true); + } + ); + + it('allows automatic tours after setup', () => { + // Arrange + const routeId = '/(app)/stack'; + + // Act + const isSetup = isProductTourSetupRoute(routeId); + const missingRouteIsSetup = isProductTourSetupRoute(null); + + // Assert + expect(isSetup).toBe(false); + expect(missingRouteIsSetup).toBe(false); + }); +}); + +describe('product tour invitation eligibility', () => { + it('offers an invitation when no progress has been saved', () => { + // Arrange + const recordedUtc = undefined; + + // Act + const eligible = shouldOfferProductTourInvitation(recordedUtc); + + // Assert + expect(eligible).toBe(true); + }); + + it('does not offer an invitation after it has been recorded', () => { + // Arrange + const recordedUtc = '2026-09-08T00:00:00Z'; + + // Act + const eligible = shouldOfferProductTourInvitation(recordedUtc); + + // Assert + expect(eligible).toBe(false); + }); +}); + +describe('persisted product tour state', () => { + const recordedUtc = '2026-09-08T00:00:00Z'; + + it('reads current timestamps and future UI-defined keys', () => { + expect(getProductTourRecordedAt({ future_guide: recordedUtc }, 'future_guide')).toBe(recordedUtc); + expect(getProductTourRecordedAt({}, 'app_overview')).toBeUndefined(); + }); + + it('preserves legacy completion without treating a dismissed guide as complete', () => { + const completed = { 'app-overview': { status: 'completed', updated_utc: recordedUtc, version: 1 } }; + const dismissed = { 'app-overview': { status: 'dismissed', updated_utc: recordedUtc, version: 1 } }; + + expect(getProductTourRecordedAt(completed, 'app_overview')).toBe(recordedUtc); + expect(getProductTourRecordedAt(dismissed, 'app_overview')).toBeUndefined(); + }); + + it('keeps previously dismissed invitations hidden', () => { + const state = { 'app-welcome': { status: 'dismissed', updated_utc: recordedUtc, version: 1 } }; + expect(shouldOfferProductTourInvitation(getProductTourRecordedAt(state, 'app_welcome', 'invitation'))).toBe(false); + }); + + it.each([ + ['new-ui-overview', 'app_overview'], + ['ui-overview', 'app_overview'], + ['configure-project', 'project_configure'], + ['create-saved-view', 'saved_view_create'], + ['investigate-error', 'event_investigate'], + ['meet-exie', 'exie_overview'] + ])('recognizes completion of the earlier %s tour', (legacyKey, currentKey) => { + const state = { [legacyKey]: { status: 'completed', updated_utc: recordedUtc, version: 1 } }; + expect(getProductTourRecordedAt(state, currentKey)).toBe(recordedUtc); + }); + + it.each(['completed', 'dismissed'])('keeps the earlier welcome invitation hidden when %s', (status) => { + const state = { welcome: { status, updated_utc: recordedUtc, version: 1 } }; + expect(shouldOfferProductTourInvitation(getProductTourRecordedAt(state, 'app_welcome', 'invitation'))).toBe(false); + }); + + it('prefers the current timestamp and ignores unrecognized values', () => { + const state = { 'app-overview': { status: 'dismissed', updated_utc: '2020-01-01T00:00:00Z' }, app_overview: recordedUtc }; + expect(getProductTourRecordedAt(state, 'app_overview')).toBe(recordedUtc); + expect(getProductTourRecordedAt({ app_overview: { future: true } }, 'app_overview')).toBeUndefined(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts new file mode 100644 index 0000000000..582eb68746 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/eligibility.ts @@ -0,0 +1,41 @@ +import type { RouteId } from '$app/types'; +import type { ProductTourState } from '$features/users/models'; + +const SETUP_ROUTE_IDS = new Set(['/(app)/organization/add', '/(app)/project/[projectId]/configure', '/(app)/project/add']); +const LEGACY_STATE_KEYS: Record = { + app_overview: ['ui-overview', 'new-ui-overview'], + app_welcome: ['welcome'], + event_investigate: ['investigate-error'], + exie_overview: ['meet-exie'], + project_configure: ['configure-project'], + saved_view_create: ['create-saved-view'] +}; + +export function getProductTourRecordedAt(state: ProductTourState = {}, key: string, kind: 'guide' | 'invitation' = 'guide'): string | undefined { + const values = state as Record; + const keys = [key, key.replaceAll('_', '-'), ...(LEGACY_STATE_KEYS[key] ?? [])]; + for (const stateKey of keys) { + const value = values[stateKey]; + if (typeof value === 'string') { + return value; + } + + // Earlier clients stored progress objects under different tour names. + if (value && typeof value === 'object' && 'status' in value && 'updated_utc' in value) { + const acknowledged = value.status === 'completed' || (kind === 'invitation' && value.status === 'dismissed'); + if (acknowledged && typeof value.updated_utc === 'string') { + return value.updated_utc; + } + } + } + + return undefined; +} + +export function isProductTourSetupRoute(routeId: null | RouteId): boolean { + return !!routeId && SETUP_ROUTE_IDS.has(routeId); +} + +export function shouldOfferProductTourInvitation(recordedAt?: null | string): boolean { + return !recordedAt; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/models.ts new file mode 100644 index 0000000000..ef3492e338 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/models.ts @@ -0,0 +1,57 @@ +import type { RouteId } from '$app/types'; +import type { AssistantAccess } from '$features/assistant/models'; +import type { ViewProject } from '$features/projects/models'; +export const PRODUCT_TOUR_CHECKPOINTS = { + 'app-overview': ['navigation', 'events', 'filters', 'saved-views', 'exie', 'command-search'], + 'event-investigate': ['choose-error', 'stack-summary', 'tab-overview', 'filter-stack-events'], + 'exie-overview': ['open-exie', 'exie-context'], + 'project-configure': ['organization-name', 'project-name', 'choose-platform', 'sdk-instructions'], + 'saved-view-create': ['open-view-menu', 'review-settings', 'name-view'] +} as const; + +export interface ProductTourAvailability { + available: boolean; + reason?: string; +} +export type ProductTourCheckpoint = Name extends ProductTourName + ? { + checkpointName: ProductTourCheckpointName; + organizationId?: string; + tourName: Name; + userId: string; + } + : never; +export type ProductTourCheckpointName = (typeof PRODUCT_TOUR_CHECKPOINTS)[Name][number]; +export interface ProductTourContext { + assistantAccess?: AssistantAccess; + errorEventAvailability: 'available' | 'empty' | 'error' | 'loading'; + isProjectConfigurePage: boolean; + isSetupPage: boolean; + organizationId?: string; + pathname: string; + projects?: Pick[]; + search?: string; +} +export interface ProductTourDefinition { + availability: (context: ProductTourContext) => ProductTourAvailability; + canResume: (checkpointName: ProductTourCheckpointName, routeId: null | RouteId) => boolean; + description: string; + keywords: readonly string[]; + name: Name; + start: (context: ProductTourContext) => ProductTourStart; + stateKey: string; + title: string; +} +export type ProductTourKey = 'app-welcome' | 'exie-announcement' | ProductTourName; + +export interface ProductTourListItem extends ProductTourDefinition { + currentAvailability: ProductTourAvailability; + recordedAt?: null | string; +} + +export type ProductTourName = keyof typeof PRODUCT_TOUR_CHECKPOINTS; + +export interface ProductTourStart { + checkpointName: ProductTourCheckpointName; + route: string; +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts new file mode 100644 index 0000000000..d96d43070a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.test.ts @@ -0,0 +1,78 @@ +import { flushSync } from 'svelte'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ProductTourCheckpoint } from './models'; + +import { productTourCheckpoint } from './state.svelte'; + +const checkpoint: ProductTourCheckpoint = { + checkpointName: 'navigation', + organizationId: 'organization-id', + tourName: 'app-overview', + userId: 'user-id' +}; + +describe('product tour checkpoint store', () => { + beforeEach(() => productTourCheckpoint.clear()); + + it('preserves the current checkpoint across forward and back navigation', () => { + // Arrange + const first = productTourCheckpoint.start('app-overview', 'navigation', 'user'); + + // Act + const second = productTourCheckpoint.advance(first, 'command-search')!; + + // Assert + expect(productTourCheckpoint.current).toBe(second); + + // Act + const back = productTourCheckpoint.advance(second, 'navigation')!; + + // Assert + expect(back.checkpointName).toBe('navigation'); + + // Act + const replay = productTourCheckpoint.start('app-overview', 'navigation', 'user'); + + // Assert + expect(productTourCheckpoint.current).toBe(replay); + }); + + it('does not retrigger an effect that clears an empty store', () => { + // Arrange + let runs = 0; + const dispose = $effect.root(() => { + $effect(() => { + if (!productTourCheckpoint.current) { + productTourCheckpoint.clear(); + } + runs += 1; + }); + }); + + try { + // Act + flushSync(); + + // Assert + expect(runs).toBe(1); + } finally { + dispose(); + } + }); + + it('does not let stale work advance or clear a newer tour', () => { + // Arrange + const first = productTourCheckpoint.start(checkpoint.tourName, checkpoint.checkpointName, checkpoint.userId, checkpoint.organizationId); + const second = productTourCheckpoint.start(checkpoint.tourName, checkpoint.checkpointName, checkpoint.userId, checkpoint.organizationId); + + // Act + const advanced = productTourCheckpoint.advance(first, 'command-search'); + const cleared = productTourCheckpoint.clear(first); + + // Assert + expect(advanced).toBeUndefined(); + expect(cleared).toBe(false); + expect(productTourCheckpoint.current).toBe(second); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts new file mode 100644 index 0000000000..46986951eb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/state.svelte.ts @@ -0,0 +1,55 @@ +import type { ProductTourCheckpoint, ProductTourCheckpointName, ProductTourName } from './models'; + +class ProductTourCheckpointStore { + public current = $state.raw(); + + public advance( + expected: ProductTourCheckpoint, + checkpointName: ProductTourCheckpointName, + organizationId = expected.organizationId + ): ProductTourCheckpoint | undefined { + if (this.current !== expected) { + return undefined; + } + + const next = { + ...expected, + checkpointName, + organizationId + } as ProductTourCheckpoint; + this.current = next; + return next; + } + + public clear(expected?: ProductTourCheckpoint): boolean { + if (expected && this.current !== expected) { + return false; + } + + this.current = undefined; + return true; + } + + public start( + tourName: Name, + checkpointName: ProductTourCheckpointName, + userId: string, + organizationId?: string + ): ProductTourCheckpoint { + const checkpoint = { + checkpointName, + organizationId, + tourName, + userId + } as ProductTourCheckpoint; + this.current = checkpoint; + return checkpoint; + } +} + +export const productTourCheckpoint = new ProductTourCheckpointStore(); + +// The shell host pauses all spotlights while navigation or the catalog takes focus. +export const productTourPresentation = $state({ + suspended: false +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts new file mode 100644 index 0000000000..3f69579626 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/product-tours/user-cache.svelte.test.ts @@ -0,0 +1,62 @@ +import type { ViewCurrentUser } from '$features/users/models'; + +import { putCurrentUserProductTour, queryKeys } from '$features/users/api.svelte'; +import { MutationObserver, type MutationObserverOptions, QueryClient } from '@tanstack/svelte-query'; +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ fetchApiJson: vi.fn(), useQueryClient: vi.fn() })); +vi.mock('$env/dynamic/public', () => ({ env: {} })); +vi.mock('$features/shared/api/api.svelte', () => ({ fetchApiJson: mocks.fetchApiJson })); +vi.mock('@tanstack/svelte-query', async (importOriginal) => ({ + ...(await importOriginal()), + createMutation: (options: () => MutationObserverOptions) => { + const observer = new MutationObserver(mocks.useQueryClient(), options()); + return { mutateAsync: (variables: TVariables) => observer.mutate(variables) }; + }, + useQueryClient: mocks.useQueryClient +})); + +describe('guided-tour user cache invalidation', () => { + it.each([false, true])('invalidates user queries without overwriting account data (account changed: %s)', async (changeAccount) => { + // Arrange + vi.resetAllMocks(); + const queryClient = new QueryClient(); + mocks.useQueryClient.mockReturnValue(queryClient); + const initial = { id: 'first-user', product_tours: {} } as ViewCurrentUser; + const current = { ...initial, id: changeAccount ? 'second-user' : initial.id }; + queryClient.setQueryData(queryKeys.me(), initial); + queryClient.setQueryData(queryKeys.id(initial.id), initial); + const request = Promise.withResolvers<{ recorded_utc: string }>(); + mocks.fetchApiJson.mockReturnValue(request.promise); + + try { + const pending = putCurrentUserProductTour().mutateAsync({ tourName: 'app-overview', userId: initial.id }); + await vi.waitFor(() => expect(mocks.fetchApiJson).toHaveBeenCalledOnce()); + expect(mocks.fetchApiJson).toHaveBeenCalledWith('users/me/product-tours/app-overview/record', { method: 'PUT' }); + queryClient.setQueryData(queryKeys.me(), current); + + // Act + request.resolve({ recorded_utc: '2026-09-08T00:00:00Z' }); + await pending; + + // Assert + expect(queryClient.getQueryData(queryKeys.me())).toEqual(current); + expect(queryClient.getQueryState(queryKeys.me())?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(queryKeys.id(initial.id))?.isInvalidated).toBe(true); + } finally { + queryClient.clear(); + } + }); + + it('does not issue a request after the account changes before mutation execution', async () => { + // Arrange + const queryClient = new QueryClient(); + mocks.useQueryClient.mockReturnValue(queryClient); + queryClient.setQueryData(queryKeys.me(), { id: 'new-user', product_tours: {} } as ViewCurrentUser); + + // Act & Assert + await expect(putCurrentUserProductTour().mutateAsync({ tourName: 'app-overview', userId: 'old-user' })).rejects.toThrow('current user changed'); + expect(mocks.fetchApiJson).not.toHaveBeenCalled(); + queryClient.clear(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte index 95fc6808ec..534ed7339c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte @@ -5,6 +5,7 @@ import { Input } from '$comp/ui/input'; import { Label } from '$comp/ui/label'; import { Switch } from '$comp/ui/switch'; + import { untrack } from 'svelte'; import type { SavedView } from '../models'; @@ -19,6 +20,7 @@ } from '../slugs'; interface Props { + defaultPrivate?: boolean; duplicateView?: SavedView; onClose: () => void; onLoadView: (view: SavedView) => void; @@ -28,7 +30,7 @@ saving: boolean; } - let { duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); + let { defaultPrivate = false, duplicateView, onClose, onLoadView, onSave, open = $bindable(), savedViews, saving }: Props = $props(); let saveName = $state(''); let saveSlug = $state(''); @@ -87,7 +89,7 @@ saveName = ''; saveSlug = ''; isSlugDirty = false; - isPrivate = false; + isPrivate = untrack(() => defaultPrivate); attemptedSubmit = false; } }); @@ -115,7 +117,14 @@ } - + { + if (!nextOpen) { + onClose(); + } + }} +> Save View @@ -137,6 +146,7 @@ {/if}
{ e.preventDefault(); @@ -146,6 +156,7 @@
{visibleSlugError}

{/if}
-
+
Only visible to you @@ -185,8 +196,8 @@
- - + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts new file mode 100644 index 0000000000..9b7330e40a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/save-view-dialog.svelte.test.ts @@ -0,0 +1,37 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it, vi } from 'vitest'; + +import SaveViewDialog from './save-view-dialog.svelte'; + +describe('SaveViewDialog', () => { + it('preserves the draft when the guide default changes while open', async () => { + // Arrange + const { rerender } = render(SaveViewDialog, { + defaultPrivate: true, + onClose: vi.fn(), + onLoadView: vi.fn(), + onSave: vi.fn(), + open: true, + savedViews: [], + saving: false + }); + await fireEvent.input(screen.getByLabelText('Name'), { target: { value: 'My errors' } }); + + // Act + await rerender({ defaultPrivate: false }); + + // Assert + expect(screen.getByLabelText('Name')).toHaveValue('My errors'); + expect(screen.getByLabelText('URL name')).toHaveValue('my-errors'); + expect(screen.getByRole('switch', { name: 'Private' })).toHaveAttribute('aria-checked', 'true'); + + // Act + await rerender({ open: false }); + await rerender({ open: true }); + + // Assert + expect(screen.getByLabelText('Name')).toHaveValue(''); + expect(screen.getByRole('switch', { name: 'Private' })).toHaveAttribute('aria-checked', 'false'); + }); +}); 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 6d76559f95..883d2c3063 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 @@ -15,6 +15,7 @@ import { serializeFilters } from '$features/events/components/filters/helpers.svelte'; import { getOrganizationQuery, getOrganizationsQuery } from '$features/organizations/api.svelte'; import { organization } from '$features/organizations/context.svelte'; + import SavedViewCreateTour from '$features/product-tours/components/saved-view-create-tour.svelte'; import { supportsColumnWrapping } from '$features/shared/components/data-table/column-meta'; import { getMeQuery } from '$features/users/api.svelte'; import Building2 from '@lucide/svelte/icons/building-2'; @@ -120,6 +121,7 @@ let isColumnDialogOpen = $state(false); let isMenuOpen = $state(false); let viewToDelete = $state(null); + let savedViewCreateTour = $state(); const organizationId = $derived(organization.current); const activeView = $derived(activeSavedView); @@ -301,6 +303,7 @@ return; } + const tour = savedViewCreateTour; const filterDefinitions = serializeFilters(filters); const body: NewSavedView = { columns: getSavedColumnSettings(), @@ -321,6 +324,9 @@ const result = await createMutation.mutateAsync(body); isSaveDialogOpen = false; onLoadView(result); + if (tour) { + await tour.created(); + } toast.success(`Saved view "${result.name}" created.`); } catch (error) { toast.error(getErrorMessage(error, 'Failed to save view. Please try again.')); @@ -355,6 +361,7 @@ async function openSaveDialog() { await tick(); isSaveDialogOpen = true; + savedViewCreateTour?.openingSaveDialog(); } async function toggleOrganizationDefault(): Promise { @@ -393,7 +400,7 @@ {#snippet child({ props })} - {/snippet} - + Saved View {#if activeView} @@ -411,7 +418,7 @@ Save {/if} - + @@ -494,15 +501,24 @@ {#if isSaveDialogOpen} (isSaveDialogOpen = false)} + onClose={() => savedViewCreateTour?.closed()} {onLoadView} /> {/if} + (isMenuOpen = false)} + {isMenuOpen} + openMenu={() => (isMenuOpen = true)} + {openSaveDialog} +/> + {#if isRenameDialogOpen && activeView} facet.filter.id === f.id); + // Raw-filter drafts can contain multiple filters with the same key. + const sameKeyFacets = facets.filter((facet) => facet.filter.key === f.key); + const existing = + facets.find((facet) => facet.filter.id === f.id) ?? + (sameKeyFacets.length === 1 && filters.filter((candidate) => candidate.key === f.key).length === 1 ? sameKeyFacets[0] : undefined); if (existing) { + if (lastOpenFilterId === existing.filter.id) { + lastOpenFilterId = f.id; + } existing.filter = f; existing.component = builder.component; existing.title = builder.title; @@ -198,7 +205,7 @@ {@render children()} {/if} -{#each visibleFacets as facet (facet.filter.id)} +{#each visibleFacets as facet (facet)} {@const Facet = facet.component}
{ + it('keeps duplicate raw filters distinct when another filter is added', async () => { + // Arrange + const local = new KeywordFilter('error.type:Local'); + const remote = new KeywordFilter('error.type:Remote'); + const view = render(Harness, { changed: vi.fn(), filters: [local], remove: vi.fn() }); + const original = await screen.findByRole('button', { name: /^Raw Filter.*error\.type:Local/ }); + + // Act + await view.rerender({ filters: [local, remote] }); + + // Assert + expect(screen.getByRole('button', { name: /^Raw Filter.*error\.type:Local/ })).toBe(original); + expect(screen.getByRole('button', { name: /^Raw Filter.*error\.type:Remote/ })).not.toBe(original); + + // Act + await view.rerender({ filters: [new KeywordFilter('error.type:Local'), new KeywordFilter('error.type:Remote')] }); + + // Assert + expect(screen.getAllByRole('button', { name: /^Raw Filter/ })).toHaveLength(2); + }); + + it('opens a newly added filter after the parent supplies it', async () => { + // Arrange + const changed = vi.fn<(filter: IFilter) => void>(); + const view = render(Harness, { changed, filters: [], remove: vi.fn() }); + await fireEvent.click(screen.getByRole('button', { name: 'Manage filters' })); + + // Act + await fireEvent.click(await screen.findByRole('option', { name: 'Date' })); + expect(changed).toHaveBeenCalledOnce(); + const added = changed.mock.calls[0]![0]; + await view.rerender({ filters: [added] }); + + // Assert + expect(screen.getByRole('button', { name: /^Date/ }).getAttribute('aria-expanded')).toBe('true'); + }); + + it('does not reopen a removed filter when it is added again', async () => { + // Arrange + const view = render(Harness, { changed: vi.fn(), filters: [new DateFilter('date', '[now-90d TO now]')], remove: vi.fn() }); + await fireEvent.click(await screen.findByRole('button', { name: /^Date/ })); + await screen.findByRole('button', { name: 'Last 30 days' }); + + // Act + await view.rerender({ filters: [] }); + await view.rerender({ filters: [new DateFilter('date', '[now-7d TO now]')] }); + + // Assert + expect(screen.getByRole('button', { name: /^Date/ }).getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps the date picker open when hydration replaces a filter instance', async () => { + // Arrange + const changed = vi.fn(); + const initial = new DateFilter('date', '[now-90d TO now]'); + const hydrated = new DateFilter('date', '[now-90d TO now]'); + const view = render(Harness, { changed, filters: [initial], remove: vi.fn() }); + const trigger = await screen.findByRole('button', { name: /^Date/ }); + await fireEvent.click(trigger); + await screen.findByRole('button', { name: 'Last 30 days' }); + + // Act + await view.rerender({ filters: [hydrated] }); + + // Assert + await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('true')); + expect(screen.getByRole('button', { name: /^Date/ })).toBe(trigger); + + // Act + await fireEvent.click(screen.getByRole('button', { name: 'Last 30 days' })); + + // Assert + expect(changed).toHaveBeenCalledWith(hydrated); + expect(hydrated.value).toBe('[now-30d TO now]'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte new file mode 100644 index 0000000000..0649f5b3b7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/faceted-filter/faceted-filter-builder.test-harness.svelte @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts index 17eb411621..f62c49cc55 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/tag-list.svelte.test.ts @@ -8,16 +8,16 @@ const resizeObservers: ResizeObserverMock[] = []; class ResizeObserverMock { private observedElements = new Set(); - disconnect = vi.fn(() => { + public disconnect = vi.fn(() => { this.observedElements.clear(); }); - observe = vi.fn((element: Element) => { + public observe = vi.fn((element: Element) => { this.observedElements.add(element); }); - takeRecords = vi.fn(() => []); - unobserve = vi.fn((element: Element) => { + public takeRecords = vi.fn(() => []); + public unobserve = vi.fn((element: Element) => { this.observedElements.delete(element); }); private callback: ResizeObserverCallback; @@ -26,7 +26,7 @@ class ResizeObserverMock { resizeObservers.push(this); } - trigger(element: Element) { + public trigger(element: Element) { if (this.observedElements.has(element)) { this.callback([{ target: element } as ResizeObserverEntry], this as unknown as ResizeObserver); } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte index a4122d895a..ce02f11c3f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/sidebar/sidebar.svelte @@ -40,6 +40,7 @@ > { /** * Get the cached value without triggering PersistedState's deserialize */ - get current(): T { + public get current(): T { return this.#cached; } /** * Set the value, updating both the cache and PersistedState */ - set current(newValue: T) { + public set current(newValue: T) { this.#cached = newValue; this.#persisted.current = newValue; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte index d28effe22b..b1680287e2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte @@ -184,7 +184,7 @@
- + @@ -192,7 +192,7 @@ -
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts index ff97db0e6d..7f334fb02c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/api.svelte.ts @@ -1,3 +1,4 @@ +import type { ProductTourKey } from '$features/product-tours/models'; import type { WebSocketMessageValue } from '$features/websockets/models'; import type { WorkInProgressResult } from '$shared/models'; @@ -7,7 +8,7 @@ import { fetchApiJson } from '$features/shared/api/api.svelte'; import { type FetchClientResponse, ProblemDetails, useFetchClient } from '@foundatiofx/fetchclient'; import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; -import type { OAuthGrant, UpdateEmailAddressResult, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; +import type { OAuthGrant, RecordProductTourResult, UpdateEmailAddressResult, UpdateUser, UpdateUserEmailAddress, ViewCurrentUser, ViewUser } from './models'; export async function invalidateUserQueries(queryClient: QueryClient, message: WebSocketMessageValue<'UserChanged'>) { const { id } = message; @@ -41,6 +42,7 @@ export const queryKeys = { organization: (id: string | undefined) => [...queryKeys.type, 'organization', id] as const, patchUser: (id: string | undefined) => [...queryKeys.id(id), 'patch'] as const, postEmailAddress: (id: string | undefined) => [...queryKeys.idEmailAddress(id), 'update'] as const, + productTour: () => [...queryKeys.me(), 'product-tour'] as const, type: ['User'] as const }; @@ -260,6 +262,27 @@ export function postEmailAddress(request: PostEmailAddressRequest) { })); } +export function putCurrentUserProductTour() { + const queryClient = useQueryClient(); + return createMutation(() => ({ + enabled: () => !!accessToken.current, + mutationFn: async ({ tourName, userId }) => { + if (queryClient.getQueryData(queryKeys.me())?.id !== userId) { + throw new Error('The current user changed before the product tour preference was recorded.'); + } + + return await fetchApiJson(`users/me/product-tours/${tourName}/record`, { + method: 'PUT' + }); + }, + mutationKey: queryKeys.productTour(), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: queryKeys.type + }) + })); +} + export function resendVerificationEmail(request: ResendVerificationEmailRequest) { return createMutation(() => ({ enabled: () => !!accessToken.current && !!request.route.id, diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts index a262d71122..0c0e2d17bf 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/users/models.ts @@ -1,9 +1,13 @@ -export type { ViewOAuthGrant as OAuthGrant, UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; +import type { ViewCurrentUser } from '$generated/api'; + +export type { ViewOAuthGrant as OAuthGrant, RecordProductTourResult, UpdateEmailAddressResult, ViewCurrentUser, ViewUser } from '$generated/api'; export interface InviteUserForm { email: string; } +export type ProductTourState = NonNullable; + export interface UpdateUser { email_notifications_enabled?: boolean; full_name?: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts index 62248bdc2d..9f7c125c8c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/websockets/web-socket-client.test.ts @@ -14,7 +14,7 @@ vi.mock('../auth/index.svelte', () => ({ vi.mock('$shared/document-visibility.svelte', () => { return { DocumentVisibility: class { - visible = true; + public visible = true; } }; }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 8db136b95e..626f32d8df 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -494,6 +494,11 @@ export interface ProblemDetails { instance?: null | string; } +export interface RecordProductTourResult { + /** @format date-time */ + recorded_utc: string; +} + export interface ResetPasswordModel { password_reset_token: string; password: string; @@ -737,6 +742,7 @@ export interface User { o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; saved_view_orders: UserSavedViewOrderPreference[]; + product_tours: object; /** Gets or sets the users Full Name. */ full_name: string; /** @format email */ @@ -784,6 +790,7 @@ export interface ViewCurrentUser { o_auth_accounts: OAuthAccount[]; organization_preferences: UserOrganizationPreference[]; saved_view_orders: UserSavedViewOrderPreference[]; + product_tours: object; /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; organization_ids: string[]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index ec58c99678..6044a09ace 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -626,6 +626,13 @@ export const ProblemDetailsSchema = object({ }); export type ProblemDetailsFormData = Infer; +export const RecordProductTourResultSchema = object({ + recorded_utc: iso.datetime(), +}); +export type RecordProductTourResultFormData = Infer< + typeof RecordProductTourResultSchema +>; + export const ResetPasswordModelSchema = object({ password_reset_token: string().length( 40, @@ -862,6 +869,7 @@ export const UserSchema = object({ o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), saved_view_orders: array(lazy(() => UserSavedViewOrderPreferenceSchema)), + product_tours: record(string(), unknown()), full_name: string().min(1, "Full name is required"), email_address: email(), avatar_file_name: string() @@ -919,6 +927,7 @@ export const ViewCurrentUserSchema = object({ o_auth_accounts: array(lazy(() => OAuthAccountSchema)), organization_preferences: array(lazy(() => UserOrganizationPreferenceSchema)), saved_view_orders: array(lazy(() => UserSavedViewOrderPreferenceSchema)), + product_tours: record(string(), unknown()), id: string() .length(24, "Id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), diff --git a/src/Exceptionless.Web/ClientApp/src/lib/hooks/use-clipboard.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/hooks/use-clipboard.svelte.ts index 98ac854d15..b7b794ac35 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/hooks/use-clipboard.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/hooks/use-clipboard.svelte.ts @@ -4,10 +4,10 @@ type Options = { }; export class UseClipboard { - get copied() { + public get copied() { return this.#copiedStatus === 'success'; } - get status() { + public get status() { return this.#copiedStatus; } #copiedStatus = $state<'failure' | 'success'>(); @@ -20,7 +20,7 @@ export class UseClipboard { this.delay = delay; } - async copy(text: string) { + public async copy(text: string) { if (this.timeout) { this.#copiedStatus = undefined; clearTimeout(this.timeout); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte index dda09bd8e4..f5f87f094c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/navbar.svelte @@ -41,6 +41,7 @@ + {/if} + {:else} +

Send an event from your app. When it arrives, we'll open the project Events page automatically.

+ {/if} {/if} -
    +
    1. Choose your project type.

      (isProjectTypeOpen = open)} onValueChange={(value) => { selectedProjectType = projectTypes.find((P) => P.id === value) || null; queryParams.type = value; }} > - + {#if selectedProjectType} {selectedProjectType.platform}: {selectedProjectType.label} @@ -768,6 +802,17 @@ public partial class App : Application { {/if}
    + {#if projectConfigureCheckpoint?.checkpointName === 'choose-platform' && !isProjectTypeOpen} + + {/if} + {#if selectedProjectType}

    That's it! diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte index d5211667cb..9076c179ee 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/add/+page.svelte @@ -11,6 +11,9 @@ import { Spinner } from '$comp/ui/spinner'; import { showBillingDialogOnUpgradeProblem } from '$features/billing'; import { organization } from '$features/organizations/context.svelte'; + import { createProductTourActions } from '$features/product-tours/actions.svelte'; + import ProductTourSpotlight from '$features/product-tours/components/product-tour-spotlight.svelte'; + import { productTourCheckpoint } from '$features/product-tours/state.svelte'; import { postProject } from '$features/projects/api.svelte'; import { type NewProjectFormData, NewProjectSchema } from '$features/projects/schemas'; import { ariaInvalid, getFormErrorMessages, mapFieldErrors, problemDetailsToFormErrors } from '$features/shared/validation'; @@ -21,6 +24,8 @@ let toastId = $state(); const createProject = postProject(); + const tourActions = createProductTourActions(); + const projectConfigureCheckpoint = $derived(productTourCheckpoint.current?.tourName === 'project-configure' ? productTourCheckpoint.current : undefined); const form = createForm(() => ({ defaultValues: { @@ -37,6 +42,10 @@ ...value, organization_id: organization.current ?? value.organization_id } as NewProject); + const checkpoint = projectConfigureCheckpoint; + if (checkpoint) { + productTourCheckpoint.advance(checkpoint, 'choose-platform'); + } toastId = toast.success('Project added successfully'); await goto( resolve('/(app)/project/[projectId]/configure', { @@ -75,6 +84,7 @@ Create a project, then configure a client to send your first event.

{ e.preventDefault(); e.stopPropagation(); @@ -91,6 +101,7 @@ Project Name
+ +{#if projectConfigureCheckpoint?.checkpointName === 'project-name'} + + {#snippet description()} + Give your app a name, then select Continue to Client Setup to connect it. + {/snippet} + +{/if} diff --git a/src/Exceptionless.Web/Models/User/RecordProductTourResult.cs b/src/Exceptionless.Web/Models/User/RecordProductTourResult.cs new file mode 100644 index 0000000000..e144f018fa --- /dev/null +++ b/src/Exceptionless.Web/Models/User/RecordProductTourResult.cs @@ -0,0 +1,3 @@ +namespace Exceptionless.Web.Models; + +public sealed record RecordProductTourResult(DateTime RecordedUtc); diff --git a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs index 0fb4c4f5a7..0961ed7415 100644 --- a/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs +++ b/src/Exceptionless.Web/Models/User/ViewCurrentUser.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using System.Text.Json; using Exceptionless.Core.Configuration; using Exceptionless.Core.Models; @@ -24,6 +25,7 @@ public ViewCurrentUser(User user, IntercomOptions options) Hash = HMACSHA256HashString(user.Id, options); HasLocalAccount = !String.IsNullOrWhiteSpace(user.Password); OAuthAccounts = user.OAuthAccounts; + ProductTours = user.ProductTours; } public string? Hash { get; set; } @@ -31,6 +33,7 @@ public ViewCurrentUser(User user, IntercomOptions options) public ICollection OAuthAccounts { get; set; } public ICollection OrganizationPreferences { get; set; } public ICollection SavedViewOrders { get; set; } + public IDictionary ProductTours { get; set; } = new Dictionary(StringComparer.Ordinal); private static string? HMACSHA256HashString(string value, IntercomOptions options) { diff --git a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json index 01cc5636a7..cc168911ed 100644 --- a/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json +++ b/tests/Exceptionless.Tests/Api/Data/endpoint-manifest.json @@ -2736,6 +2736,20 @@ "authorizationRoles": [], "authenticationSchemes": [] }, + { + "method": "PUT", + "route": "/api/v2/users/me/product-tours/{tourName}/record", + "displayName": "HTTP: PUT api/v2/users/me/product-tours/{tourName}/record", + "tags": [ + "User" + ], + "allowAnonymous": false, + "authorizationPolicies": [ + "UserPolicy" + ], + "authorizationRoles": [], + "authenticationSchemes": [] + }, { "method": "POST", "route": "/api/v2/users/unverify-email-address", @@ -3091,4 +3105,4 @@ "authorizationRoles": [], "authenticationSchemes": [] } -] \ No newline at end of file +] diff --git a/tests/Exceptionless.Tests/Api/Data/openapi.json b/tests/Exceptionless.Tests/Api/Data/openapi.json index 6e88d09679..a7e6c6ee5d 100644 --- a/tests/Exceptionless.Tests/Api/Data/openapi.json +++ b/tests/Exceptionless.Tests/Api/Data/openapi.json @@ -3954,6 +3954,57 @@ } } }, + "/api/v2/users/me/product-tours/{tourName}/record": { + "put": { + "tags": [ + "User" + ], + "summary": "Record current user product tour", + "parameters": [ + { + "name": "tourName", + "in": "path", + "description": "A UI-defined product tour identifier using lowercase letters, digits, and hyphens (up to 64 characters).", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecordProductTourResult" + } + } + } + }, + "422": { + "description": "The product tour name is invalid or the limit of 100 recorded product tour entries has been reached.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The current user could not be found.", + "content": { + "application/problem\u002Bjson": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v2/users/me/oauth-grants": { "get": { "tags": [ @@ -13229,6 +13280,18 @@ } } }, + "RecordProductTourResult": { + "required": [ + "recorded_utc" + ], + "type": "object", + "properties": { + "recorded_utc": { + "type": "string", + "format": "date-time" + } + } + }, "ResetPasswordModel": { "required": [ "password_reset_token", @@ -13866,6 +13929,7 @@ "o_auth_accounts", "organization_preferences", "saved_view_orders", + "product_tours", "email_notifications_enabled", "is_email_address_verified", "verify_email_address_token_expiration", @@ -13931,6 +13995,9 @@ "$ref": "#/components/schemas/UserSavedViewOrderPreference" } }, + "product_tours": { + "type": "object" + }, "full_name": { "type": "string", "description": "Gets or sets the users Full Name." @@ -14062,6 +14129,7 @@ "o_auth_accounts", "organization_preferences", "saved_view_orders", + "product_tours", "id", "organization_ids", "full_name", @@ -14101,6 +14169,9 @@ "$ref": "#/components/schemas/UserSavedViewOrderPreference" } }, + "product_tours": { + "type": "object" + }, "id": { "maxLength": 24, "minLength": 24, @@ -14947,4 +15018,4 @@ "name": "Source Map" } ] -} \ No newline at end of file +} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs new file mode 100644 index 0000000000..63e8a6df49 --- /dev/null +++ b/tests/Exceptionless.Tests/Api/Endpoints/ProductTourEndpointTests.cs @@ -0,0 +1,370 @@ +using System.Text.Json; +using Exceptionless.Core; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Repositories.Configuration; +using Exceptionless.Core.Validation; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Web.Models; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; +using Xunit; + +namespace Exceptionless.Tests.Api.Endpoints; + +public sealed class ProductTourEndpointTests : IntegrationTestsBase +{ + private readonly IUserRepository _userRepository; + + public ProductTourEndpointTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _userRepository = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await GetService().CreateDataAsync(); + } + + [Fact] + public async Task RecordCurrentUserProductTourAsync_NewTour_ReturnsAndPersistsServerTimestamp() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + var utcNow = new DateTimeOffset(2026, 9, 8, 20, 0, 0, TimeSpan.Zero); + TimeProvider.SetUtcNow(utcNow); + + // Act + var result = await SendRequestAsAsync(r => r + .Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview", "record") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(result); + Assert.Equal(utcNow.UtcDateTime, result.RecordedUtc); + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, o => o.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(utcNow.UtcDateTime, persistedUser.ProductTours["app_overview"].GetDateTime()); + } + + [Fact] + public async Task RecordCurrentUserProductTourAsync_RepeatedRequest_PreservesFirstTimestamp() + { + // Arrange + await GetTestOrganizationUserAsync(); + var firstUtc = new DateTimeOffset(2026, 9, 8, 20, 0, 0, TimeSpan.Zero); + TimeProvider.SetUtcNow(firstUtc); + var first = await SendRequestAsAsync(r => r + .Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "saved-view-create", "record") + .StatusCodeShouldBeOk()); + + // Act + TimeProvider.Advance(TimeSpan.FromMinutes(10)); + var second = await SendRequestAsAsync(r => r + .Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "saved-view-create", "record") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(first.RecordedUtc, second.RecordedUtc); + Assert.Equal(firstUtc.UtcDateTime, second.RecordedUtc); + } + + [Fact] + public async Task RecordCurrentUserProductTourAsync_ClientTimestampAndPath_IgnoresClientValues() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + var serverUtc = new DateTimeOffset(2026, 9, 8, 20, 0, 0, TimeSpan.Zero); + TimeProvider.SetUtcNow(serverUtc); + + // Act + var result = await SendRequestAsAsync(r => r + .Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview", "record") + .Content(new { recorded_utc = "2000-01-01T00:00:00Z", field = "full_name" }) + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(result); + Assert.Equal(serverUtc.UtcDateTime, result.RecordedUtc); + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, o => o.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(currentUser.FullName, persistedUser.FullName); + Assert.Equal(serverUtc.UtcDateTime, persistedUser.ProductTours["app_overview"].GetDateTime()); + } + + [Fact] + public async Task RecordCurrentUserProductTourAsync_SequentialTours_PreservesBothDates() + { + // Arrange + await GetTestOrganizationUserAsync(); + + // Act + await SendRequestAsync(r => r.Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview", "record") + .StatusCodeShouldBeOk()); + await SendRequestAsync(r => r.Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "exie-overview", "record") + .StatusCodeShouldBeOk()); + + // Assert + var currentUser = await GetTestOrganizationUserAsync(); + var persistedUser = await _userRepository.GetByIdAsync(currentUser.Id, o => o.Cache(false)); + Assert.NotNull(persistedUser); + Assert.True(persistedUser.ProductTours.ContainsKey("app_overview")); + Assert.True(persistedUser.ProductTours.ContainsKey("exie_overview")); + } + + [Fact] + public async Task RecordCurrentUserProductTourAsync_NewUiDefinedTours_PreservesConcurrentUpdatesAndLegacyState() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + var user = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(user); + var legacyState = JsonSerializer.SerializeToElement(new { status = "completed", updated_utc = "2024-01-15T12:00:00Z", version = 1 }); + user.ProductTours["old-tour"] = legacyState; + await _userRepository.SaveAsync(user); + string[] tourNames = ["future-guide-1", "future-guide-2", "future-guide-3", "future-guide-4"]; + + // Act + await Task.WhenAll(tourNames.Select(name => SendRequestAsync(r => r.Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", name, "record") + .StatusCodeShouldBeOk()))); + + // Assert + var persistedUser = await _userRepository.GetByIdAsync(user.Id, o => o.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(5, persistedUser.ProductTours.Count); + foreach (string name in tourNames) + { + Assert.True(persistedUser.ProductTours[name.Replace('-', '_')].TryGetDateTime(out _)); + } + Assert.True(JsonElement.DeepEquals(legacyState, persistedUser.ProductTours["old-tour"])); + + var cachedUser = await _userRepository.GetByEmailAddressAsync(user.EmailAddress); + Assert.NotNull(cachedUser); + Assert.Equal(5, cachedUser.ProductTours.Count); + } + + [Fact] + public async Task RecordProductTourAsync_OlderConcurrentRead_DoesNotCacheOutdatedProgress() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + var user = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(user); + var repository = new PausingUserRepository(GetService(), GetService(), GetService()); + var recordedUtc = TimeProvider.GetUtcNow().UtcDateTime; + + // Act: hold the first snapshot while a second completion is recorded and cached. + var first = repository.RecordProductTourAsync(user, "first_guide", recordedUtc); + try + { + await repository.SnapshotRead.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); + await _userRepository.RecordProductTourAsync(user, "second_guide", recordedUtc); + await _userRepository.GetByIdAsync(user.Id, o => o.Cache()); + await _userRepository.GetByEmailAddressAsync(user.EmailAddress); + } + finally + { + repository.ResumeRead.TrySetResult(); + await first; + } + var byId = await _userRepository.GetByIdAsync(user.Id, o => o.Cache()); + var byEmail = await _userRepository.GetByEmailAddressAsync(user.EmailAddress); + + // Assert + Assert.NotNull(byId); + Assert.NotNull(byEmail); + Assert.Equal(recordedUtc, byId.ProductTours["second_guide"].GetDateTime()); + Assert.Equal(recordedUtc, byEmail.ProductTours["second_guide"].GetDateTime()); + Assert.Equal(2, byId.ProductTours.Count); + Assert.Equal(2, byEmail.ProductTours.Count); + } + + [Theory] + [InlineData(100)] + [InlineData(101)] + public async Task RecordCurrentUserProductTourAsync_AtOrAboveLimit_PreservesExistingEntries(int count) + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + var user = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(user); + var recordedUtc = new DateTime(2026, 9, 8, 20, 0, 0, DateTimeKind.Utc); + for (int i = 0; i < count; i++) + user.ProductTours[$"guide_{i}"] = JsonSerializer.SerializeToElement(recordedUtc); + await _userRepository.SaveAsync(user); + + // Act + await SendRequestAsync(r => r.Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "new-guide", "record") + .StatusCodeShouldBeUnprocessableEntity()); + var result = await SendRequestAsAsync(r => r.Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "guide-0", "record") + .StatusCodeShouldBeOk()); + + // Assert + Assert.NotNull(result); + Assert.Equal(recordedUtc, result.RecordedUtc); + var persistedUser = await _userRepository.GetByIdAsync(user.Id, o => o.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(count, persistedUser.ProductTours.Count); + Assert.False(persistedUser.ProductTours.ContainsKey("new_guide")); + } + + [Fact] + public async Task RecordProductTourAsync_ConcurrentNewKeys_EnforcesLimitAtomically() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + var user = await _userRepository.GetByIdAsync(currentUser.Id); + Assert.NotNull(user); + var recordedUtc = TimeProvider.GetUtcNow().UtcDateTime; + for (int i = 0; i < 99; i++) + user.ProductTours[$"guide_{i}"] = JsonSerializer.SerializeToElement(recordedUtc); + await _userRepository.SaveAsync(user); + string[] names = ["future_1", "future_2", "future_3", "future_4"]; + + // Act + await Task.WhenAll(names.Select(name => _userRepository.RecordProductTourAsync(user, name, recordedUtc))); + + // Assert + var persistedUser = await _userRepository.GetByIdAsync(user.Id, o => o.Cache(false)); + Assert.NotNull(persistedUser); + Assert.Equal(100, persistedUser.ProductTours.Count); + Assert.Single(names, persistedUser.ProductTours.ContainsKey); + Assert.Equal(recordedUtc, persistedUser.ProductTours["guide_0"].GetDateTime()); + } + + [Fact] + public async Task GetCurrentUserAsync_CacheReadFinishesAfterCompletion_ReturnsRecordedProgress() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + var user = await _userRepository.GetByIdAsync(currentUser.Id, o => o.Cache(false)); + Assert.NotNull(user); + await _userRepository.InvalidateCacheAsync(user); + var repository = new PausingCacheUserRepository(GetService(), GetService(), GetService()); + var recordedUtc = TimeProvider.GetUtcNow().UtcDateTime; + + // Act: a lookup writes its old snapshot after the completion has invalidated the cache. + var read = repository.GetByIdAsync(user.Id, o => o.Cache()); + try + { + await repository.CacheWriteReady.Task.WaitAsync(TimeSpan.FromSeconds(10), TestCancellationToken); + await _userRepository.RecordProductTourAsync(user, "app_overview", recordedUtc); + } + finally + { + repository.ResumeCacheWrite.TrySetResult(); + await read; + } + var result = await SendRequestAsAsync(r => r.AsTestOrganizationUser() + .AppendPath("users/me").StatusCodeShouldBeOk()); + + // Assert + Assert.Equal(recordedUtc, result.GetProperty("product_tours").GetProperty("app_overview").GetDateTime()); + } + + [Theory] + [InlineData("tour.name")] + [InlineData("TourName")] + [InlineData("tour name")] + [InlineData("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm")] + public Task RecordCurrentUserProductTourAsync_InvalidIdentifier_ReturnsUnprocessableEntity(string name) + { + // Arrange: sample users are created by ResetDataAsync. + + // Act & Assert + return SendRequestAsync(r => r.Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", name, "record") + .StatusCodeShouldBeUnprocessableEntity()); + } + + [Fact] + public Task RecordCurrentUserProductTourAsync_OldRoute_ReturnsNotFound() + { + // Arrange: sample users are created by ResetDataAsync. + + // Act & Assert + return SendRequestAsync(r => r.Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview") + .StatusCodeShouldBeNotFound()); + } + + [Fact] + public Task RecordCurrentUserProductTourAsync_AnonymousUser_ReturnsUnauthorized() + { + // Arrange: sample users are created by ResetDataAsync. + + // Act & Assert + return SendRequestAsync(r => r.Put() + .AppendPaths("users", "me", "product-tours", "app-overview", "record") + .StatusCodeShouldBeUnauthorized()); + } + + [Fact] + public async Task RecordCurrentUserProductTourAsync_DeletedUser_ReturnsUnauthorizedWithoutRecreatingUser() + { + // Arrange + var currentUser = await GetTestOrganizationUserAsync(); + await _userRepository.RemoveAsync(currentUser.Id, o => o.ImmediateConsistency()); + + // Act + await SendRequestAsync(r => r.Put().AsTestOrganizationUser() + .AppendPaths("users", "me", "product-tours", "app-overview", "record") + .StatusCodeShouldBeUnauthorized()); + + // Assert + Assert.Null(await _userRepository.GetByIdAsync(currentUser.Id, o => o.Cache(false))); + } + + private async Task GetTestOrganizationUserAsync() + { + var user = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPath("users/me") + .StatusCodeShouldBeOk()); + Assert.NotNull(user); + return user; + } + + private sealed class PausingUserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) + : UserRepository(configuration, validator, options) + { + public TaskCompletionSource SnapshotRead { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ResumeRead { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async Task GetByIdAsync(Id id, ICommandOptions? options = null) + { + var user = await base.GetByIdAsync(id, options); + SnapshotRead.TrySetResult(); + await ResumeRead.Task; + return user; + } + } + + private sealed class PausingCacheUserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options) + : UserRepository(configuration, validator, options) + { + public TaskCompletionSource CacheWriteReady { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ResumeCacheWrite { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async Task AddDocumentsToCacheAsync(ICollection> findHits, ICommandOptions options, bool isDirtyRead) + { + CacheWriteReady.TrySetResult(); + await ResumeCacheWrite.Task; + await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead); + } + } + +} diff --git a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs index 9d85fa6956..d369d624ff 100644 --- a/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs +++ b/tests/Exceptionless.Tests/Api/OpenApiSnapshotTests.cs @@ -61,6 +61,11 @@ public async Task GetOpenApiJson_Default_ContainsExpectedRoutesOperationsAndResp Assert.True(projectsPost.TryGetProperty("requestBody", out _)); AssertResponseCodes(projectsPost, "201"); + Assert.True(paths.TryGetProperty("/api/v2/users/me/product-tours/{tourName}/record", out var productTourPath)); + Assert.True(productTourPath.TryGetProperty("put", out var productTourPut)); + AssertResponseCodes(productTourPut, "200", "404", "422"); + AssertResponseSchema(productTourPut, "200", "RecordProductTourResult"); + Assert.True(paths.TryGetProperty("/api/v2/assistant/chat", out var assistantChatPath)); Assert.True(assistantChatPath.TryGetProperty("post", out var assistantChatPost)); AssertResponseCodes(assistantChatPost, "200", "400", "401", "403", "404", "426", "429", "503"); @@ -100,6 +105,11 @@ public async Task GetOpenApiJson_Default_ContainsExpectedSchemasAndSecuritySchem Assert.True(schemas.TryGetProperty("NewProject", out _)); Assert.True(schemas.TryGetProperty("SavedViewColumnSettings", out var savedViewColumnSettings)); Assert.True(schemas.TryGetProperty("TokenResult", out _)); + var productTourState = schemas.GetProperty("ViewCurrentUser").GetProperty("properties").GetProperty("product_tours"); + Assert.Equal("object", productTourState.GetProperty("type").GetString()); + Assert.False(productTourState.TryGetProperty("properties", out _)); + Assert.True(schemas.TryGetProperty("RecordProductTourResult", out var recordProductTourResult)); + Assert.Equal("recorded_utc", Assert.Single(recordProductTourResult.GetProperty("properties").EnumerateObject()).Name); Assert.True(schemas.TryGetProperty("ViewOrganization", out _)); var savedViewColumnProperties = savedViewColumnSettings.GetProperty("properties"); @@ -290,6 +300,20 @@ private static void AssertResponseCodes(JsonElement operation, params string[] e Assert.True(responses.TryGetProperty(statusCode, out _), $"Expected response status code '{statusCode}'."); } + private static void AssertResponseSchema(JsonElement operation, string statusCode, string expectedSchema) + { + string? schema = operation + .GetProperty("responses") + .GetProperty(statusCode) + .GetProperty("content") + .GetProperty("application/json") + .GetProperty("schema") + .GetProperty("$ref") + .GetString(); + + Assert.Equal($"#/components/schemas/{expectedSchema}", schema); + } + private static void AssertPathResponseCodes(JsonElement paths, string path, string method, params string[] expectedStatusCodes) { var operation = paths.GetProperty(path).GetProperty(method); diff --git a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs index 9d3e9d4c42..8abd964e15 100644 --- a/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs +++ b/tests/Exceptionless.Tests/Serializer/Models/UserSerializerTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Exceptionless.Core.Models; using Foundatio.Serializer; using Xunit; @@ -238,6 +239,115 @@ public void Deserialize_SnakeCaseJson_PreservesOrganizationIds() Assert.Contains("client", user.Roles); } + [Fact] + public void Serialize_UserWithProductTourState_PreservesUiDefinedKeys() + { + // Arrange + var original = new User + { + Id = "tour-user", + FullName = "Tour User", + EmailAddress = "tour@example.com", + IsEmailAddressVerified = true, + ProductTours = new Dictionary + { + ["app_overview"] = JsonSerializer.SerializeToElement(FixedDateTime), + ["future_guide_v2"] = JsonSerializer.SerializeToElement(FixedDateTime.AddMinutes(1)) + } + }; + + // Act + string? json = _serializer.SerializeToString(original); + + // Assert + Assert.Contains("\"product_tours\":{\"app_overview\":\"2024-01-15T12:00:00Z\",\"future_guide_v2\":\"2024-01-15T12:01:00Z\"}", json); + Assert.DoesNotContain("status", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("version", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("event_investigate", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Deserialize_UserWithProductTourState_PreservesAllDates() + { + // Arrange + const string json = """ + { + "id": "tour-user", + "full_name": "Tour User", + "email_address": "tour@example.com", + "is_email_address_verified": true, + "product_tours": { + "app_overview": "2024-01-15T12:00:00Z", + "exie_overview": "2024-01-15T12:01:00Z", + "event_investigate": "2024-01-15T12:02:00Z", + "project_configure": "2024-01-15T12:03:00Z", + "saved_view_create": "2024-01-15T12:04:00Z", + "app_welcome": "2024-01-15T12:05:00Z", + "exie_announcement": "2024-01-15T12:06:00Z" + } + } + """; + + // Act + var user = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(user); + var state = user.ProductTours; + Assert.Equal(FixedDateTime, state["app_overview"].GetDateTime()); + Assert.Equal(FixedDateTime.AddMinutes(1), state["exie_overview"].GetDateTime()); + Assert.Equal(FixedDateTime.AddMinutes(2), state["event_investigate"].GetDateTime()); + Assert.Equal(FixedDateTime.AddMinutes(3), state["project_configure"].GetDateTime()); + Assert.Equal(FixedDateTime.AddMinutes(4), state["saved_view_create"].GetDateTime()); + Assert.Equal(FixedDateTime.AddMinutes(5), state["app_welcome"].GetDateTime()); + Assert.Equal(FixedDateTime.AddMinutes(6), state["exie_announcement"].GetDateTime()); + } + + [Theory] + [InlineData("{\"id\":\"legacy-user\",\"full_name\":\"Legacy User\",\"email_address\":\"legacy@example.com\",\"is_email_address_verified\":true}")] + public void Deserialize_UserWithoutProductTours_ReturnsEmptyState(string json) + { + // Arrange: InlineData supplies a legacy user with no product_tours field. + + // Act + var user = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(user); + var state = user.ProductTours; + Assert.Empty(state); + } + + [Fact] + public void Deserialize_UserWithLegacyAndFutureTourState_PreservesOpaqueValuesOnSave() + { + // Arrange + const string json = """ + { + "id": "tour-user", + "product_tours": { + "app-overview": { "status": "completed", "version": 1, "updated_utc": "2024-01-15T12:00:00Z" }, + "future_guide_v2": "2024-01-15T12:01:00Z", + "unknown_shape": { "step": 3 }, + "empty": null + } + } + """; + + // Act + var user = _serializer.Deserialize(json); + Assert.NotNull(user); + var roundTrip = _serializer.Deserialize(_serializer.SerializeToString(user)); + + // Assert + Assert.NotNull(roundTrip); + Assert.Equal(4, roundTrip.ProductTours.Count); + Assert.Equal("completed", roundTrip.ProductTours["app-overview"].GetProperty("status").GetString()); + Assert.Equal(FixedDateTime.AddMinutes(1), roundTrip.ProductTours["future_guide_v2"].GetDateTime()); + Assert.Equal(3, roundTrip.ProductTours["unknown_shape"].GetProperty("step").GetInt32()); + Assert.Equal(JsonValueKind.Null, roundTrip.ProductTours["empty"].ValueKind); + } + [Fact] public void Deserialize_SnakeCaseJson_PreservesOAuthAccounts() {