feat: Add Playwright E2E tests for role-based navigation#112
Conversation
- Add PlaywrightFixture that initializes Aspire AppHost and Playwright browser - Add PlaywrightCollection for shared test fixture across E2E tests - Add Auth0LoginHelper for browser-based OIDC authentication flow - Add AdminNavigationTests (6 tests): login, menu visibility, navigation to all admin pages - Add AuthorNavigationTests (5 tests): login, menu visibility, navigation permissions - Add UserNavigationTests (5 tests): login, menu visibility, protected content access - Add UnauthenticatedNavigationTests (7 tests): login button, protected route redirect - Add LogoutTests (7 tests): logout functionality, menu hidden after logout Test credentials are read from environment variables for security: - E2E_TEST_ADMIN_EMAIL/PASSWORD - E2E_TEST_AUTHOR_EMAIL/PASSWORD - E2E_TEST_USER_EMAIL/PASSWORD Closes #109 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds Playwright-based end-to-end coverage to verify Auth0 login and role-based navigation behavior in the IssueManager web UI, using an Aspire-hosted AppHost started once per test collection.
Changes:
- Introduces a shared
PlaywrightFixture+ xUnit collection for running E2E browser tests against the Aspire-hosted app. - Adds an
Auth0LoginHelperfor browser-driven Auth0 OIDC login/logout using env-var credentials. - Adds role-focused navigation and logout E2E test suites (admin/author/user/unauthenticated).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs | Starts Aspire AppHost, waits for web resource readiness, and provides Playwright browser/page creation. |
| tests/AppHost.Tests.E2E/Fixtures/PlaywrightCollection.cs | Defines the shared Playwright E2E test collection. |
| tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs | Implements Auth0 login/logout helpers for Playwright tests, reading credentials from environment variables. |
| tests/AppHost.Tests.E2E/Navigation/AdminNavigationTests.cs | Admin role E2E navigation assertions. |
| tests/AppHost.Tests.E2E/Navigation/AuthorNavigationTests.cs | Author role E2E navigation assertions (including admin-menu exclusion). |
| tests/AppHost.Tests.E2E/Navigation/UserNavigationTests.cs | User role E2E navigation assertions (including admin-menu exclusion). |
| tests/AppHost.Tests.E2E/Navigation/UnauthenticatedNavigationTests.cs | Anonymous-user E2E coverage for login button, protected-route redirects, and menu visibility. |
| tests/AppHost.Tests.E2E/Navigation/LogoutTests.cs | E2E coverage for logout behavior and post-logout access/menu state. |
| tests/AppHost.Tests.E2E/GlobalUsings.cs | Adds global usings for Playwright and the new helper namespace. |
You can also share your feedback on Copilot code review. Take the survey.
| await Auth0LoginHelper.LoginAsync( | ||
| page, | ||
| fixture.WebUrl, | ||
| credentials.Value.Email, | ||
| credentials.Value.Password); |
There was a problem hiding this comment.
This test ignores the LoginAsync result and immediately asserts that admin-only links are not visible. If the login failed or the authenticated nav hasn't rendered yet, these negative assertions can pass incorrectly. Wait for a logged-in indicator (e.g., logout link visible) before asserting admin links are hidden.
| { | ||
| // Act | ||
| await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); | ||
|
|
There was a problem hiding this comment.
This test asserts admin-only links are not visible immediately after navigation, but it doesn't first wait for a stable unauthenticated UI indicator (e.g., login link visible). If the nav/auth state is still loading, these negative checks can pass incorrectly. Wait for the login button (or another known element) before asserting admin links are hidden.
| // Wait for a stable unauthenticated UI indicator (login button visible) | |
| var loginButton = page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Log in" }); | |
| await loginButton.WaitForAsync(new LocatorWaitForOptions | |
| { | |
| State = WaitForSelectorState.Visible, | |
| Timeout = 10000 | |
| }); |
| // Act - Log out | ||
| await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); | ||
| await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); | ||
|
|
There was a problem hiding this comment.
This test checks that the "New Issue" link is hidden right after logout + navigation, but it doesn't first wait for the logged-out UI state to be confirmed (e.g., login button visible / logout link hidden). Without that, the negative assertion can be flaky or even pass before the UI updates. Add an explicit wait/assert for logged-out state before checking link visibility.
| // Wait for logged-out UI state (e.g., login link visible) before asserting protected content is hidden | |
| var loginLink = page.Locator("a[href='/login']"); | |
| await loginLink.WaitForAsync(new LocatorWaitForOptions | |
| { | |
| State = WaitForSelectorState.Visible, | |
| Timeout = 5000 | |
| }); |
| namespace AppHost.Tests.E2E.Fixtures; | ||
|
|
||
| /// <summary> | ||
| /// Playwright fixture that extends DistributedApplicationFixture. |
There was a problem hiding this comment.
The XML doc comment says this fixture "extends DistributedApplicationFixture", but it doesn't inherit from that type (it implements IAsyncLifetime and has its own Aspire startup). Update the summary to avoid misleading future readers.
| /// Playwright fixture that extends DistributedApplicationFixture. | |
| /// Playwright fixture that hosts the Aspire AppHost for end-to-end tests. |
| "web", | ||
| KnownResourceStates.Running, | ||
| CancellationToken.None).WaitAsync(TimeSpan.FromMinutes(3)); | ||
|
|
||
| // Get the web app URL | ||
| _webUrl = _app.GetEndpoint("web", "https")?.ToString() | ||
| ?? _app.GetEndpoint("web", "http")?.ToString(); |
There was a problem hiding this comment.
PlaywrightFixture waits for resource "web", but the Web project is registered in the AppHost with the resource name Website ("WebApp") (see src/AppHost/Program.cs). With the current value, this wait will never complete and the fixture will be marked unavailable. Use the actual resource name (preferably the shared Website constant).
| "web", | |
| KnownResourceStates.Running, | |
| CancellationToken.None).WaitAsync(TimeSpan.FromMinutes(3)); | |
| // Get the web app URL | |
| _webUrl = _app.GetEndpoint("web", "https")?.ToString() | |
| ?? _app.GetEndpoint("web", "http")?.ToString(); | |
| "Website", | |
| KnownResourceStates.Running, | |
| CancellationToken.None).WaitAsync(TimeSpan.FromMinutes(3)); | |
| // Get the web app URL | |
| _webUrl = _app.GetEndpoint("Website", "https")?.ToString() | |
| ?? _app.GetEndpoint("Website", "http")?.ToString(); |
| // Get the web app URL | ||
| _webUrl = _app.GetEndpoint("web", "https")?.ToString() | ||
| ?? _app.GetEndpoint("web", "http")?.ToString(); | ||
|
|
There was a problem hiding this comment.
Related to the resource-name mismatch: _app.GetEndpoint("web", ...) uses the same incorrect resource name. This will yield a null endpoint and cause WebUrl to be unavailable even if the app started. Use the same resource name the AppHost registers for the Web project (e.g., Website / "WebApp").
| await Auth0LoginHelper.LoginAsync( | ||
| page, | ||
| fixture.WebUrl, | ||
| credentials.Value.Email, | ||
| credentials.Value.Password); |
There was a problem hiding this comment.
This test logs in and then proceeds to role-based nav assertions without first verifying the authenticated UI has fully rendered (and it also ignores the LoginAsync return value). If the nav/auth state is still loading, the negative assertions can become false positives. Add an explicit wait/assert for logged-in state (e.g., logout link visible) before checking admin links are hidden.
| await Auth0LoginHelper.LogoutAsync(page, fixture.WebUrl); | ||
| await page.GotoAsync(fixture.WebUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); | ||
|
|
||
| // Assert - Admin menu items should be hidden | ||
| var categoriesLink = page.Locator("a[href='/categories']"); |
There was a problem hiding this comment.
After LogoutAsync, this test immediately asserts that admin links are hidden. If the UI/auth state hasn't finished updating yet, these negative assertions can pass/fail nondeterministically. Add an explicit wait/assert for logged-out state (e.g., login button visible / logout link hidden) before checking that admin menu items are not visible.
| await Auth0LoginHelper.LoginAsync( | ||
| page, | ||
| fixture.WebUrl, | ||
| credentials.Value.Email, | ||
| credentials.Value.Password); | ||
|
|
There was a problem hiding this comment.
This test proceeds right after LoginAsync without checking the returned success value or waiting for a logged-in indicator (e.g., logout link visible). If login fails or the authenticated nav hasn't rendered yet, the menu visibility assertions can be flaky or misleading. Consider asserting login success and/or waiting for logged-in state before asserting menu items.
| await Auth0LoginHelper.LoginAsync( | |
| page, | |
| fixture.WebUrl, | |
| credentials.Value.Email, | |
| credentials.Value.Password); | |
| var loginSuccess = await Auth0LoginHelper.LoginAsync( | |
| page, | |
| fixture.WebUrl, | |
| credentials.Value.Email, | |
| credentials.Value.Password); | |
| loginSuccess.Should().BeTrue("Admin should be able to log in successfully before checking navigation menu"); | |
| (await Auth0LoginHelper.IsLoggedInAsync(page)).Should().BeTrue("Page should show logged-in state before checking navigation menu"); |
| try | ||
| { | ||
| // Navigate to the login endpoint which triggers Auth0 redirect | ||
| await page.GotoAsync($"{baseUrl.TrimEnd('/')}/auth/login", new PageGotoOptions | ||
| { | ||
| WaitUntil = WaitUntilState.NetworkIdle, | ||
| Timeout = timeout | ||
| }); | ||
|
|
||
| // Wait for Auth0 login page | ||
| await page.WaitForURLAsync("**/u/login**", new PageWaitForURLOptions { Timeout = timeout }); | ||
|
|
||
| // Fill in the Auth0 Universal Login form | ||
| await page.FillAsync("input[name='username'], input[name='email'], input#username, input[type='email']", email); | ||
| await page.FillAsync("input[name='password'], input#password, input[type='password']", password); | ||
|
|
||
| // Click the submit button | ||
| await page.ClickAsync("button[type='submit'], button[name='action'], button[data-action-button-primary='true']"); | ||
|
|
||
| // Wait for redirect back to our app (should redirect to home or return URL) | ||
| await page.WaitForURLAsync(url => | ||
| url.Contains(new Uri(baseUrl).Host) && !url.Contains("auth0.com"), | ||
| new PageWaitForURLOptions { Timeout = timeout }); | ||
|
|
||
| // Wait for the page to stabilize | ||
| await page.WaitForLoadStateAsync(LoadState.NetworkIdle); | ||
|
|
||
| return true; | ||
| } | ||
| catch (TimeoutException) | ||
| { | ||
| return false; | ||
| } | ||
| catch (PlaywrightException) | ||
| { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
LoginAsync catches exceptions and returns false, which hides the underlying reason for the login failure and makes debugging test failures harder (especially where callers don't check the returned bool). Consider failing fast by rethrowing/wrapping the exception, or returning a richer result that includes the error details for assertions/logging.
| try | |
| { | |
| // Navigate to the login endpoint which triggers Auth0 redirect | |
| await page.GotoAsync($"{baseUrl.TrimEnd('/')}/auth/login", new PageGotoOptions | |
| { | |
| WaitUntil = WaitUntilState.NetworkIdle, | |
| Timeout = timeout | |
| }); | |
| // Wait for Auth0 login page | |
| await page.WaitForURLAsync("**/u/login**", new PageWaitForURLOptions { Timeout = timeout }); | |
| // Fill in the Auth0 Universal Login form | |
| await page.FillAsync("input[name='username'], input[name='email'], input#username, input[type='email']", email); | |
| await page.FillAsync("input[name='password'], input#password, input[type='password']", password); | |
| // Click the submit button | |
| await page.ClickAsync("button[type='submit'], button[name='action'], button[data-action-button-primary='true']"); | |
| // Wait for redirect back to our app (should redirect to home or return URL) | |
| await page.WaitForURLAsync(url => | |
| url.Contains(new Uri(baseUrl).Host) && !url.Contains("auth0.com"), | |
| new PageWaitForURLOptions { Timeout = timeout }); | |
| // Wait for the page to stabilize | |
| await page.WaitForLoadStateAsync(LoadState.NetworkIdle); | |
| return true; | |
| } | |
| catch (TimeoutException) | |
| { | |
| return false; | |
| } | |
| catch (PlaywrightException) | |
| { | |
| return false; | |
| } | |
| // Navigate to the login endpoint which triggers Auth0 redirect | |
| await page.GotoAsync($"{baseUrl.TrimEnd('/')}/auth/login", new PageGotoOptions | |
| { | |
| WaitUntil = WaitUntilState.NetworkIdle, | |
| Timeout = timeout | |
| }); | |
| // Wait for Auth0 login page | |
| await page.WaitForURLAsync("**/u/login**", new PageWaitForURLOptions { Timeout = timeout }); | |
| // Fill in the Auth0 Universal Login form | |
| await page.FillAsync("input[name='username'], input[name='email'], input#username, input[type='email']", email); | |
| await page.FillAsync("input[name='password'], input#password, input[type='password']", password); | |
| // Click the submit button | |
| await page.ClickAsync("button[type='submit'], button[name='action'], button[data-action-button-primary='true']"); | |
| // Wait for redirect back to our app (should redirect to home or return URL) | |
| await page.WaitForURLAsync(url => | |
| url.Contains(new Uri(baseUrl).Host) && !url.Contains("auth0.com"), | |
| new PageWaitForURLOptions { Timeout = timeout }); | |
| // Wait for the page to stabilize | |
| await page.WaitForLoadStateAsync(LoadState.NetworkIdle); | |
| return true; |
Test Results Summary 6 files - 1 33 suites - 5 13s ⏱️ -1s Results for commit ceb1bf4. ± Comparison against base commit fde78b0. This pull request removes 18 tests.♻️ This comment has been updated with latest results. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #112 +/- ##
=======================================
Coverage 55.52% 55.52%
=======================================
Files 124 124
Lines 2826 2826
Branches 313 313
=======================================
Hits 1569 1569
Misses 1027 1027
Partials 230 230 🚀 New features to boost your workflow:
|
- Fix PlaywrightFixture to use Website constant instead of hardcoded 'web' - Update PlaywrightFixture doc comment to not reference non-existent class - Add login verification before negative assertions in test methods - Add wait for unauthenticated state (login button visible) before asserting menu items are hidden in UnauthenticatedNavigationTests and LogoutTests Addresses Copilot reviewer feedback on PR #112. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR Review Feedback Addressed ✅I've applied the following fixes based on the Copilot code reviewer feedback: Critical Bug Fixed
Test Robustness Improvements
Documentation Fixed
CI Status
Ready for merge when you're ready! 🚀 |
Summary
Implements Playwright E2E tests that verify login and navigation behavior for each user role.
Closes #109
Changes
New Files
Navigation Test Files (30 tests total)
Configuration
Test credentials are read from environment variables for security:
Tests skip gracefully when credentials are not configured.
Tech Stack