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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .squad/agents/gimli/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,3 +757,32 @@ History file currently at 37KB. If exceeded 12KB limit in future, will require s
- This separation ensures explicit ID ownership at the application layer

**Outcome:** All 35 Create handler unit tests pass. Integration tests verified fixed (skipped in CI due to no Docker).

---

## 2026-03-12 — Playwright E2E Role-Based Navigation Tests

**Task:** Issue #109 — Implement Playwright E2E tests for role-based navigation.

**New Test Structure:**
- `tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs` — Shared fixture initializing Aspire AppHost + Playwright browser
- `tests/AppHost.Tests.E2E/Fixtures/PlaywrightCollection.cs` — xUnit collection definition for shared fixture
- `tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs` — Browser-based Auth0 OIDC authentication helper
- `tests/AppHost.Tests.E2E/Navigation/` — 5 test classes with 30 total tests

**Key Patterns:**
1. **PlaywrightFixture** extends Aspire testing — builds the distributed app, starts it, waits for `web` resource to be running, then initializes Playwright browser
2. **Test credentials from environment variables** — `E2E_TEST_{ROLE}_EMAIL` / `E2E_TEST_{ROLE}_PASSWORD` for Admin, Author, User roles; tests skip gracefully when not configured
3. **Browser context isolation** — Each test creates a fresh context via `fixture.NewPageAsync()` with `IgnoreHTTPSErrors = true`
4. **Auth0 Universal Login flow** — Navigate to `/auth/login`, fill form on Auth0 hosted page, wait for redirect back to app
5. **Graceful skip pattern** — Tests check `fixture.IsAvailable` and throw `SkipException.ForSkip()` when Aspire/Docker initialization fails
6. **LocatorIsVisibleOptions.Timeout is obsolete** — Causes warnings but still works; the property is deprecated in newer Playwright

**Test Coverage:**
- AdminNavigationTests (6): Login, 4 admin menu items visible, navigation to each admin page
- AuthorNavigationTests (5): Login, basic menu items visible, admin items NOT visible, navigation
- UserNavigationTests (5): Login, basic menu items visible, admin items NOT visible, New Issue visible
- UnauthenticatedNavigationTests (7): Login button visible, logout NOT visible, protected routes redirect, public pages accessible
- LogoutTests (7): Logout works for each role, protected content hidden after logout

**PR:** #112 (closes #109)
18 changes: 18 additions & 0 deletions tests/AppHost.Tests.E2E/Fixtures/PlaywrightCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// ============================================
// Copyright (c) 2026. All rights reserved.
// File Name : PlaywrightCollection.cs
// Company : mpaulosky
// Author : Matthew Paulosky
// Solution Name : IssueManager
// Project Name : AppHost.Tests.E2E
// =============================================

namespace AppHost.Tests.E2E.Fixtures;

/// <summary>
/// Defines the PlaywrightE2E collection, backed by a shared PlaywrightFixture.
/// Tests in this collection share one Aspire host + Playwright browser instance.
/// </summary>
[ExcludeFromCodeCoverage]
[CollectionDefinition("PlaywrightE2E")]
public sealed class PlaywrightCollection : ICollectionFixture<PlaywrightFixture>;
151 changes: 151 additions & 0 deletions tests/AppHost.Tests.E2E/Fixtures/PlaywrightFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// ============================================
// Copyright (c) 2026. All rights reserved.
// File Name : PlaywrightFixture.cs
// Company : mpaulosky
// Author : Matthew Paulosky
// Solution Name : IssueManager
// Project Name : AppHost.Tests.E2E
// =============================================

namespace AppHost.Tests.E2E.Fixtures;

/// <summary>
/// Playwright fixture that hosts the Aspire AppHost for end-to-end tests.
/// Initializes Playwright and provides browser/page instances for E2E tests.
/// Starts the Aspire AppHost and captures the Web app URL for browser navigation.
/// </summary>
[ExcludeFromCodeCoverage]
public sealed class PlaywrightFixture : IAsyncLifetime
{
private IPlaywright? _playwright;
private IBrowser? _browser;
private IDistributedApplicationTestingBuilder? _builder;
private DistributedApplication? _app;
private string? _webUrl;
private ResourceNotificationService? _notificationService;

/// <summary>
/// Gets the Playwright instance.
/// </summary>
public IPlaywright Playwright =>
_playwright ?? throw new InvalidOperationException("Playwright not initialized. Check IsAvailable.");

/// <summary>
/// Gets the browser instance.
/// </summary>
public IBrowser Browser =>
_browser ?? throw new InvalidOperationException("Browser not initialized. Check IsAvailable.");

/// <summary>
/// Gets the base URL of the web application.
/// </summary>
public string WebUrl =>
_webUrl ?? throw new InvalidOperationException("Web URL not available. Check IsAvailable.");

/// <summary>
/// Gets the Aspire app instance.
/// </summary>
public DistributedApplication App =>
_app ?? throw new InvalidOperationException("Aspire app not initialized. Check IsAvailable.");

/// <summary>
/// True if the fixture was successfully initialized.
/// </summary>
public bool IsAvailable { get; private set; }

/// <summary>
/// The reason initialization was skipped or failed, if IsAvailable is false.
/// </summary>
public string? UnavailableReason { get; private set; }

/// <summary>
/// Creates a new browser context with isolated state for test isolation.
/// </summary>
public async Task<IBrowserContext> NewContextAsync(BrowserNewContextOptions? options = null)
{
return await Browser.NewContextAsync(options ?? new BrowserNewContextOptions
{
IgnoreHTTPSErrors = true
});
}

/// <summary>
/// Creates a new page in a fresh browser context for test isolation.
/// </summary>
public async Task<IPage> NewPageAsync()
{
var context = await NewContextAsync();
return await context.NewPageAsync();
}

public async ValueTask InitializeAsync()
{
try
{
// Step 1: Initialize Aspire AppHost
_builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.AppHost>(CancellationToken.None);

_builder.Services.ConfigureHttpClientDefaults(clientBuilder =>
{
clientBuilder.AddStandardResilienceHandler();
});

_app = await _builder.BuildAsync(CancellationToken.None);

_notificationService = _app.Services.GetRequiredService<ResourceNotificationService>();

// Start the distributed application
await _app.StartAsync(CancellationToken.None);

// Wait for the web app to be running
await _notificationService.WaitForResourceAsync(
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();

Comment on lines +107 to +110
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

Copilot uses AI. Check for mistakes.
if (string.IsNullOrEmpty(_webUrl))
{
IsAvailable = false;
UnavailableReason = "Could not get web app endpoint URL";
return;
}

// Step 2: Initialize Playwright
_playwright = await Microsoft.Playwright.Playwright.CreateAsync();

// Launch Chromium in headless mode
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});

IsAvailable = true;
}
catch (Exception ex)
{
IsAvailable = false;
UnavailableReason = $"Fixture initialization failed: {ex.Message}";
}
}

public async ValueTask DisposeAsync()
{
if (_browser is not null)
{
await _browser.CloseAsync();
}

_playwright?.Dispose();

if (_app is not null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
}
2 changes: 2 additions & 0 deletions tests/AppHost.Tests.E2E/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
global using System.Diagnostics.CodeAnalysis;

global using AppHost.Tests.E2E.Fixtures;
global using AppHost.Tests.E2E.Helpers;

global using Aspire.Hosting;
global using Aspire.Hosting.ApplicationModel;
Expand All @@ -21,6 +22,7 @@
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Diagnostics.HealthChecks;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Playwright;

global using OpenTelemetry.Metrics;
global using OpenTelemetry.Trace;
Expand Down
134 changes: 134 additions & 0 deletions tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// ============================================
// Copyright (c) 2026. All rights reserved.
// File Name : Auth0LoginHelper.cs
// Company : mpaulosky
// Author : Matthew Paulosky
// Solution Name : IssueManager
// Project Name : AppHost.Tests.E2E
// =============================================

namespace AppHost.Tests.E2E.Helpers;

/// <summary>
/// Helper for authenticating with Auth0 via browser-based OIDC flow in Playwright tests.
/// Test credentials are read from environment variables for security.
/// </summary>
[ExcludeFromCodeCoverage]
public static class Auth0LoginHelper
{
/// <summary>
/// Gets test credentials from environment variables.
/// </summary>
public static (string Email, string Password)? GetTestCredentials(string role)
{
var suffix = role.ToUpperInvariant();
var email = Environment.GetEnvironmentVariable($"E2E_TEST_{suffix}_EMAIL");
var password = Environment.GetEnvironmentVariable($"E2E_TEST_{suffix}_PASSWORD");

if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
{
return null;
}

return (email, password);
}

/// <summary>
/// Performs Auth0 login via the browser-based OIDC flow.
/// Navigates to /auth/login, fills Auth0 form, and waits for redirect back.
/// </summary>
/// <param name="page">The Playwright page instance.</param>
/// <param name="baseUrl">The application base URL.</param>
/// <param name="email">The test user email.</param>
/// <param name="password">The test user password.</param>
/// <param name="timeout">Timeout for waiting operations (default 30 seconds).</param>
/// <returns>True if login succeeded, false otherwise.</returns>
public static async Task<bool> LoginAsync(
IPage page,
string baseUrl,
string email,
string password,
int timeout = 30000)
{
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;
}
Comment on lines +53 to +89
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// Performs logout by navigating to /auth/logout.
/// </summary>
/// <param name="page">The Playwright page instance.</param>
/// <param name="baseUrl">The application base URL.</param>
/// <param name="timeout">Timeout for waiting operations (default 30 seconds).</param>
public static async Task LogoutAsync(IPage page, string baseUrl, int timeout = 30000)
{
await page.GotoAsync($"{baseUrl.TrimEnd('/')}/auth/logout", new PageGotoOptions
{
WaitUntil = WaitUntilState.NetworkIdle,
Timeout = timeout
});

// Wait for redirect back to home
await page.WaitForURLAsync(url =>
url.Contains(new Uri(baseUrl).Host) && !url.Contains("auth0.com"),
new PageWaitForURLOptions { Timeout = timeout });
}

/// <summary>
/// Checks if the current page shows the user as logged in.
/// </summary>
/// <param name="page">The Playwright page instance.</param>
/// <returns>True if logged in (greeting visible), false otherwise.</returns>
public static async Task<bool> IsLoggedInAsync(IPage page)
{
// Look for the "Hello, username!" text or "Log out" link
var logoutLink = page.Locator("a[href='/auth/logout']");
return await logoutLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 5000 });

Check warning on line 121 in tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs

View workflow job for this annotation

GitHub Actions / Build Solution

'LocatorIsVisibleOptions.Timeout' is obsolete

Check warning on line 121 in tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs

View workflow job for this annotation

GitHub Actions / Build and Test / Build Solution

'LocatorIsVisibleOptions.Timeout' is obsolete

Check warning on line 121 in tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs

View workflow job for this annotation

GitHub Actions / AppHost.Tests.E2E

'LocatorIsVisibleOptions.Timeout' is obsolete

Check warning on line 121 in tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs

View workflow job for this annotation

GitHub Actions / Build and Test / AppHost.Tests.E2E

'LocatorIsVisibleOptions.Timeout' is obsolete
}

/// <summary>
/// Checks if the login button is visible (indicates logged out state).
/// </summary>
/// <param name="page">The Playwright page instance.</param>
/// <returns>True if login button is visible, false otherwise.</returns>
public static async Task<bool> IsLoginButtonVisibleAsync(IPage page)
{
var loginLink = page.Locator("a[href='/auth/login']");
return await loginLink.IsVisibleAsync(new LocatorIsVisibleOptions { Timeout = 5000 });

Check warning on line 132 in tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs

View workflow job for this annotation

GitHub Actions / Build Solution

'LocatorIsVisibleOptions.Timeout' is obsolete

Check warning on line 132 in tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs

View workflow job for this annotation

GitHub Actions / Build and Test / Build Solution

'LocatorIsVisibleOptions.Timeout' is obsolete

Check warning on line 132 in tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs

View workflow job for this annotation

GitHub Actions / AppHost.Tests.E2E

'LocatorIsVisibleOptions.Timeout' is obsolete

Check warning on line 132 in tests/AppHost.Tests.E2E/Helpers/Auth0LoginHelper.cs

View workflow job for this annotation

GitHub Actions / Build and Test / AppHost.Tests.E2E

'LocatorIsVisibleOptions.Timeout' is obsolete
}
}
Loading
Loading