From b425c4d5d63d1dda95332c7c2c069a93cafc3ba2 Mon Sep 17 00:00:00 2001 From: jheaps Date: Wed, 24 Sep 2025 13:48:20 -0600 Subject: [PATCH] Add playwright tests with github actions --- .claude/settings.local.json | 9 + .github/workflows/playwright-tests.yml | 73 +++++++ JoshHeaps.Net.UiTests/ApiTests.cs | 119 ++++++++++++ JoshHeaps.Net.UiTests/ChessGameTests.cs | 128 +++++++++++++ JoshHeaps.Net.UiTests/GameplayTests.cs | 180 ++++++++++++++++++ .../JoshHeaps.Net.UiTests.csproj | 39 ++++ JoshHeaps.Net.UiTests/MultiplayerTests.cs | 98 ++++++++++ JoshHeaps.Net.UiTests/README.md | 61 ++++++ JoshHeaps.Net.UiTests/TestConfiguration.cs | 95 +++++++++ JoshHeaps.Net.UiTests/UnitTest1.cs | 0 JoshHeaps.Net.UiTests/appsettings.CI.json | 19 ++ .../appsettings.Integration.json | 18 ++ JoshHeaps.Net.UiTests/playwright.config.json | 24 +++ JoshHeaps.Net.sln | 10 +- 14 files changed, 868 insertions(+), 5 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .github/workflows/playwright-tests.yml create mode 100644 JoshHeaps.Net.UiTests/ApiTests.cs create mode 100644 JoshHeaps.Net.UiTests/ChessGameTests.cs create mode 100644 JoshHeaps.Net.UiTests/GameplayTests.cs create mode 100644 JoshHeaps.Net.UiTests/JoshHeaps.Net.UiTests.csproj create mode 100644 JoshHeaps.Net.UiTests/MultiplayerTests.cs create mode 100644 JoshHeaps.Net.UiTests/README.md create mode 100644 JoshHeaps.Net.UiTests/TestConfiguration.cs create mode 100644 JoshHeaps.Net.UiTests/UnitTest1.cs create mode 100644 JoshHeaps.Net.UiTests/appsettings.CI.json create mode 100644 JoshHeaps.Net.UiTests/appsettings.Integration.json create mode 100644 JoshHeaps.Net.UiTests/playwright.config.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..84a6040 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml new file mode 100644 index 0000000..4026983 --- /dev/null +++ b/.github/workflows/playwright-tests.yml @@ -0,0 +1,73 @@ +name: Playwright Tests + +on: + pull_request: + branches: [ master, main ] + push: + branches: [ master, main ] + +jobs: + playwright-tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build solution + run: dotnet build --no-restore + + - name: Install Playwright browsers + run: | + cd JoshHeaps.Net.UiTests + dotnet build + pwsh bin/Debug/net8.0/playwright.ps1 install --with-deps + + - name: Start server in background + run: | + cd JoshHeaps.Net + dotnet run --urls "https://localhost:7118" & + echo $! > server.pid + # Wait for server to start + timeout 60 bash -c 'until curl -k https://localhost:7118/health 2>/dev/null; do sleep 1; done' || echo "Server may not have health endpoint, continuing..." + sleep 5 + + - name: Run Playwright tests + run: | + cd JoshHeaps.Net.UiTests + dotnet test --logger "trx;LogFileName=test-results.trx" --logger "console;verbosity=detailed" + env: + HEADED: false # Force headless mode in CI + + - name: Stop server + if: always() + run: | + if [ -f JoshHeaps.Net/server.pid ]; then + kill $(cat JoshHeaps.Net/server.pid) || true + fi + pkill -f "dotnet run" || true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: | + JoshHeaps.Net.UiTests/TestResults/ + JoshHeaps.Net.UiTests/test-results/ + retention-days: 7 + + - name: Upload Playwright videos + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-videos + path: JoshHeaps.Net.UiTests/test-results/videos/ + retention-days: 7 \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/ApiTests.cs b/JoshHeaps.Net.UiTests/ApiTests.cs new file mode 100644 index 0000000..1889d2d --- /dev/null +++ b/JoshHeaps.Net.UiTests/ApiTests.cs @@ -0,0 +1,119 @@ +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; +using NUnit.Framework; +using System.Text.Json; + +namespace JoshHeaps.Net.UiTests; + +[TestFixture] +public class ApiTests : PageTest +{ + private IAPIRequestContext? _apiContext; + private TestConfiguration Config => TestConfiguration.Instance; + + [SetUp] + public async Task Setup() + { + _apiContext = await Playwright.APIRequest.NewContextAsync(new() + { + BaseURL = Config.Test.BaseUrl, + IgnoreHTTPSErrors = true + }); + } + + [TearDown] + public async Task TearDown() + { + if (_apiContext != null) + { + await _apiContext.DisposeAsync(); + } + } + + [Test] + public async Task JoinGame_Returns_Valid_Response() + { + var response = await _apiContext!.GetAsync("/api/chess/JoinGame"); + + Assert.That(response.Status, Is.EqualTo(200)); + + var jsonResponse = await response.JsonAsync(); + var gameData = JsonSerializer.Deserialize(jsonResponse.ToString()!); + + Assert.That(gameData.TryGetProperty("id", out _), Is.True); + Assert.That(gameData.TryGetProperty("isWhite", out _), Is.True); + Assert.That(gameData.TryGetProperty("gameId", out _), Is.True); + } + + [Test] + public async Task CreateCPUGame_Returns_Valid_Response() + { + var response = await _apiContext!.GetAsync("/api/chess/new/5"); + + Assert.That(response.Status, Is.EqualTo(200)); + + var jsonResponse = await response.JsonAsync(); + var gameData = JsonSerializer.Deserialize(jsonResponse.ToString()!); + + Assert.That(gameData.TryGetProperty("id", out _), Is.True); + Assert.That(gameData.TryGetProperty("isWhite", out _), Is.True); + Assert.That(gameData.TryGetProperty("gameId", out _), Is.True); + } + + [Test] + public async Task GetGameState_Returns_Valid_Game_Data() + { + // First create a game + var createResponse = await _apiContext!.GetAsync("/api/chess/JoinGame"); + var createData = JsonSerializer.Deserialize((await createResponse.JsonAsync()).ToString()!); + var gameId = createData.GetProperty("gameId").GetString(); + + // Then get the game state + var response = await _apiContext.GetAsync($"/api/chess/{gameId}"); + + Assert.That(response.Status, Is.EqualTo(200)); + + var jsonResponse = await response.JsonAsync(); + var gameState = JsonSerializer.Deserialize(jsonResponse.ToString()!); + + Assert.That(gameState.TryGetProperty("gameId", out _), Is.True); + Assert.That(gameState.TryGetProperty("currentPlayer", out _), Is.True); + Assert.That(gameState.TryGetProperty("pieces", out var pieces), Is.True); + + // Should have 32 pieces initially + Assert.That(pieces.GetArrayLength(), Is.EqualTo(32)); + } + + [Test] + public async Task GetLegalMoves_Returns_Valid_Moves() + { + // First create a game + var createResponse = await _apiContext!.GetAsync("/api/chess/JoinGame"); + var createData = JsonSerializer.Deserialize((await createResponse.JsonAsync()).ToString()!); + var gameId = createData.GetProperty("gameId").GetString(); + + // Get game state to find a piece + var gameStateResponse = await _apiContext.GetAsync($"/api/chess/{gameId}"); + var gameState = JsonSerializer.Deserialize((await gameStateResponse.JsonAsync()).ToString()!); + var pieces = gameState.GetProperty("pieces"); + var firstPiece = pieces[0]; + var pieceId = firstPiece.GetProperty("id").GetString(); + + // Get legal moves for the piece + var response = await _apiContext.GetAsync($"/api/chess/{gameId}/legalMoves/{pieceId}"); + + Assert.That(response.Status, Is.EqualTo(200)); + + var moves = await response.JsonAsync(); + Assert.That(moves, Is.Not.Null); + } + + [Test] + public async Task Invalid_GameId_Returns_NotFound() + { + var invalidGameId = Guid.NewGuid().ToString(); + var response = await _apiContext!.GetAsync($"/api/chess/{invalidGameId}"); + + Assert.That(response.Status, Is.EqualTo(404)); + } +} \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/ChessGameTests.cs b/JoshHeaps.Net.UiTests/ChessGameTests.cs new file mode 100644 index 0000000..4556199 --- /dev/null +++ b/JoshHeaps.Net.UiTests/ChessGameTests.cs @@ -0,0 +1,128 @@ +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; +using NUnit.Framework; + +namespace JoshHeaps.Net.UiTests; + +[TestFixture] +public class ChessGameTests : PageTest +{ + private TestConfiguration Config => TestConfiguration.Instance; + + public override BrowserNewContextOptions ContextOptions() + { + return new BrowserNewContextOptions + { + ViewportSize = new ViewportSize + { + Width = Config.Playwright.Viewport.Width, + Height = Config.Playwright.Viewport.Height + } + }; + } + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + // Set Playwright browser options for headless control + if (!Config.Playwright.Headless) + { + Environment.SetEnvironmentVariable("HEADED", "1"); + } + if (Config.Playwright.SlowMotion > 0) + { + Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString()); + } + } + + [SetUp] + public async Task Setup() + { + await Page.GotoAsync($"{Config.Test.BaseUrl}/chess"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + protected async Task WaitDefault() + { + await Page.WaitForTimeoutAsync(Config.Test.WaitTimeout); + } + + [Test] + public async Task Chess_Page_Loads_Successfully() + { + await Expect(Page.Locator("h1")).ToContainTextAsync("Chess"); + await Expect(Page.Locator("#startGameBtn")).ToBeVisibleAsync(); + await Expect(Page.Locator("#startCPUGame")).ToBeVisibleAsync(); + await Expect(Page.Locator("#chessBoard")).ToBeVisibleAsync(); + } + + [Test] + public async Task Chess_Board_Has_64_Squares() + { + var squares = Page.Locator(".chessSquare"); + await Expect(squares).ToHaveCountAsync(64); + } + + [Test] + public async Task Start_New_Game_Button_Works() + { + await Page.ClickAsync("#startGameBtn"); + + // Wait for game to start (pieces should appear) + await Page.Locator(".chessPiece").First.WaitForAsync(); + + // Check that pieces have been rendered + var pieces = Page.Locator(".chessPiece"); + await Expect(pieces).ToHaveCountAsync(32); // Standard chess has 32 pieces + } + + [Test] + public async Task Start_CPU_Game_Shows_Difficulty_Modal() + { + await Page.ClickAsync("#startCPUGame"); + + // Should show difficulty modal + await Expect(Page.Locator("#difficultyModal")).ToBeVisibleAsync(); + await Expect(Page.Locator("#difficultyModal p")).ToContainTextAsync("Set bot difficulty to:"); + + // Should have difficulty buttons 1-20 + var difficultyButtons = Page.Locator("#difficultyButtonContainer button"); + await Expect(difficultyButtons).ToHaveCountAsync(20); + } + + [Test] + public async Task CPU_Game_Starts_After_Selecting_Difficulty() + { + await Page.ClickAsync("#startCPUGame"); + + // Select difficulty 5 + await Page.ClickAsync("#difficultyButtonContainer button:nth-child(5)"); + + // Modal should close + await Expect(Page.Locator("#difficultyModal")).ToBeHiddenAsync(); + + // Wait for game to start + await Page.Locator(".chessPiece").First.WaitForAsync(); + + // Check that pieces have been rendered + var pieces = Page.Locator(".chessPiece"); + await Expect(pieces).ToHaveCountAsync(32); + } + + [Test] + public async Task Pieces_WhenClicked_HighlightLegalMoves() + { + // Start a new game first + await Page.ClickAsync("#startGameBtn"); + + // Wait for pieces to be rendered + await Page.Locator(".chessPiece").First.WaitForAsync(); + + // Find any white piece and click it + var pawn = Page.Locator("#chessBoard div:has(img[src*='WhitePawn'])").First; + await pawn.ClickAsync(); + + var legalMoves = Page.Locator(".legal"); + await Expect(legalMoves).ToHaveCountAsync(2); + } +} \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/GameplayTests.cs b/JoshHeaps.Net.UiTests/GameplayTests.cs new file mode 100644 index 0000000..bdb61d0 --- /dev/null +++ b/JoshHeaps.Net.UiTests/GameplayTests.cs @@ -0,0 +1,180 @@ +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; +using NUnit.Framework; + +namespace JoshHeaps.Net.UiTests; + +[TestFixture] +public class GameplayTests : PageTest +{ + private TestConfiguration Config => TestConfiguration.Instance; + + public override BrowserNewContextOptions ContextOptions() + { + return Config.GetBrowserContextOptions(); + } + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + if (!Config.Playwright.Headless) + { + Environment.SetEnvironmentVariable("HEADED", "1"); + } + if (Config.Playwright.SlowMotion > 0) + { + Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString()); + } + } + + [SetUp] + public async Task Setup() + { + await Page.GotoAsync($"{Config.Test.BaseUrl}/chess"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + [Test] + public async Task Scholars_Mate_Results_In_Checkmate() + { + // Start a multiplayer game + await Page.ClickAsync("#startGameBtn"); + await Page.WaitForTimeoutAsync(3000); + await Page.WaitForSelectorAsync(".chessPiece"); + + // Create a second browser context for the second player + await using var browser = await Playwright.Chromium.LaunchAsync(); + await using var context2 = await browser.NewContextAsync(); + var page2 = await context2.NewPageAsync(); + await page2.GotoAsync($"{Config.Test.BaseUrl}/chess"); + await page2.ClickAsync("#startGameBtn"); + await page2.WaitForTimeoutAsync(3000); + await page2.WaitForSelectorAsync(".chessPiece"); + + /* + * Scholar's Mate sequence: + * 1. e4 e5 + * 2. Bc4 Nc6 + * 3. Qh5 Nf6?? + * 4. Qxf7# (checkmate) + * + * In source square to destination square, accounting for black ui swapping: + * 1. 52-36 51-35 + * 2. 61-34 58-37 + * 3. 59-45 62-45 + * 4. 45-13 + */ + + // Move 1: White plays e4 (pawn e2-e4) + await MakeMove(Page, 52, 36); // e2 to e4 + + // Move 1: Black plays e5 (pawn e7-e5) + await MakeMove(page2, 51, 35); // e7 to e5 + + // Move 2: White plays Bc4 (bishop f1-c4) + await MakeMove(Page, 61, 34); // f1 to c4 + + // Move 2: Black plays Nc6 (knight b8-c6) + await MakeMove(page2, 58, 37); // b8 to c6 + + // Move 3: White plays Qh5 (queen d1-h5) + await MakeMove(Page, 59, 45); // d1 to h5 + + // Move 3: Black plays Nf6 (knight g8-f6) - the blunder + await MakeMove(page2, 62, 45); // g8 to f6 + + string? alertMessage = null; + + Page.Dialog += async (_, dialog) => + { + alertMessage = dialog.Message; + await dialog.AcceptAsync(); + }; + + // Move 4: White plays Qxf7# (queen h5-f7, checkmate) + await MakeMove(Page, 45, 13); // h5 to f7 + + // The test passes if we can execute all moves without errors + // Specific checkmate verification depends on how your UI handles game end + Assert.That(alertMessage, Is.Not.Null); + Assert.That(alertMessage, Contains.Substring("Checkmate")); + } + + [Test] + public async Task Pawn_Promotion_Shows_Modal() + { + // Start a multiplayer game + await Page.ClickAsync("#startGameBtn"); + await Page.WaitForSelectorAsync(".chessPiece"); + + // Create a second browser context for the second player (black) + await using var browser = await Playwright.Chromium.LaunchAsync(); + await using var context2 = await browser.NewContextAsync(); + var page2 = await context2.NewPageAsync(); + await page2.GotoAsync($"{Config.Test.BaseUrl}/chess"); + await page2.ClickAsync("#startGameBtn"); + await page2.WaitForSelectorAsync(".chessPiece"); + + // Exact sequence: 1. h4 g5 2. hxg5 h6 3. gxh6 a6 4. h7 a5 5. hxg8=Q + // in square numbers: 55-39 49-33 39-30 48-40 30-23 55-47 23-15 47-39 15-6 + + // Move 1: White h4 (h2-h4, square 55 to 39) + await MakeMove(Page, 55, 39); // h2 to h4 + + // Move 1: Black g5 (g7-g5, square 14 to 30) + await MakeMove(page2, 49, 33); // g7 to g5 + + // Move 2: White hxg5 (h4 captures g5, square 39 to 30) + await MakeMove(Page, 39, 30); // h4 captures g5 + + // Move 2: Black h6 (h7-h6, square 15 to 23) + await MakeMove(page2, 48, 40); // h7 to h6 + + // Move 3: White gxh6 (g5 captures h6, square 30 to 23) + await MakeMove(Page, 30, 23); // g5 captures h6 + + // Move 3: Black a6 (a7-a6, square 8 to 16) + await MakeMove(page2, 55, 47); // a7 to a6 + + // Move 4: White h7 (h6-h7, square 23 to 15) + await MakeMove(Page, 23, 15); // h6 to h7 + + // Move 4: Black a5 (a6-a5, square 16 to 24) + await MakeMove(page2, 47, 39); // a6 to a5 + + // Move 5: White hxg8=Q (h7 captures g8 and promotes, square 15 to 6) + await MakeMove(Page, 15, 6); // h7 captures g8 (promotion) + + // Check if promotion modal appears + var promotionModal = Page.Locator("#promotionModal"); + var queenOption = Page.Locator("button:has(img[alt='Queen'])"); + var promotedQueenSquare = Page.Locator("#square-6:has(img[src='/images/Chess Images/WhiteQueen.svg'])"); + var isModalVisible = await promotionModal.IsVisibleAsync(); + + await Expect(promotionModal).ToBeVisibleAsync(); + await Expect(queenOption).ToBeVisibleAsync(); + + await queenOption.ClickAsync(); + + await promotionModal.WaitForAsync(new() { State = WaitForSelectorState.Hidden }); + await Expect(promotedQueenSquare).ToBeVisibleAsync(); + } + + private async Task MakeMove(IPage page, int fromSquare, int toSquare) + { + // Click on the source square/piece + var fromSquareElement = page.Locator($"#square-{fromSquare}"); + var piece = fromSquareElement.Locator(".chessPiece"); + + if (await piece.CountAsync() > 0) + { + await piece.ClickAsync(); + await page.WaitForTimeoutAsync(500); + } + + // Click on the destination square + var toSquareElement = page.Locator($"#square-{toSquare}"); + await toSquareElement.ClickAsync(); + await page.WaitForTimeoutAsync(1000); + } +} \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/JoshHeaps.Net.UiTests.csproj b/JoshHeaps.Net.UiTests/JoshHeaps.Net.UiTests.csproj new file mode 100644 index 0000000..c32b9b9 --- /dev/null +++ b/JoshHeaps.Net.UiTests/JoshHeaps.Net.UiTests.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/JoshHeaps.Net.UiTests/MultiplayerTests.cs b/JoshHeaps.Net.UiTests/MultiplayerTests.cs new file mode 100644 index 0000000..38809a1 --- /dev/null +++ b/JoshHeaps.Net.UiTests/MultiplayerTests.cs @@ -0,0 +1,98 @@ +using Microsoft.Playwright; +using Microsoft.Playwright.NUnit; +using NUnit.Framework; + +namespace JoshHeaps.Net.UiTests; + +[TestFixture] +public class MultiplayerTests : PageTest +{ + private TestConfiguration Config => TestConfiguration.Instance; + + public override BrowserNewContextOptions ContextOptions() + { + return Config.GetBrowserContextOptions(); + } + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + if (!Config.Playwright.Headless) + { + Environment.SetEnvironmentVariable("HEADED", "1"); + } + if (Config.Playwright.SlowMotion > 0) + { + Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString()); + } + } + + [SetUp] + public async Task Setup() + { + await Page.GotoAsync($"{Config.Test.BaseUrl}/chess"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } + + [Test] + public async Task Two_Players_Can_Join_Same_Game() + { + // This test requires running two browser contexts + await using var browser = await Playwright.Chromium.LaunchAsync(); + await using var context1 = await browser.NewContextAsync(); + await using var context2 = await browser.NewContextAsync(); + + var page1 = await context1.NewPageAsync(); + var page2 = await context2.NewPageAsync(); + + await page1.GotoAsync($"{Config.Test.BaseUrl}/chess"); + await page2.GotoAsync($"{Config.Test.BaseUrl}/chess"); + + // Player 1 starts a game + await page1.ClickAsync("#startGameBtn"); + await page1.Locator(".chessPiece").First.WaitForAsync(); + + // Player 2 joins the same game + await page2.ClickAsync("#startGameBtn"); + await page2.Locator(".chessPiece").First.WaitForAsync(); + + // Both players should see pieces + var pieces1 = page1.Locator(".chessPiece"); + var pieces2 = page2.Locator(".chessPiece"); + + await Expect(pieces1).ToHaveCountAsync(32); + await Expect(pieces2).ToHaveCountAsync(32); + } + + [Test] + public async Task SignalR_Connection_Established() + { + // Start a game to trigger SignalR connection + await Page.ClickAsync("#startGameBtn"); + await Page.Locator(".chessPiece").First.WaitForAsync(); + + // Check browser console for SignalR connection message + var consoleLogs = new List(); + Page.Console += (_, e) => consoleLogs.Add(e.Text); + + await Page.ReloadAsync(); + await Page.WaitForTimeoutAsync(1000); + + // Should see SignalR connected message in console + Assert.That(consoleLogs, Does.Contain("✅ SignalR connected").Or.Contain("SignalR connected")); + } + + [Test] + public async Task Move_Updates_Are_Sent_Via_SignalR() + { + // This would require more complex setup with two players + // For now, just verify the SignalR methods exist in the JavaScript + var jsContent = await Page.EvaluateAsync(@" + () => { + return document.querySelector('script[src*=""chessLogic.js""]') ? 'chessLogic.js loaded' : 'not found'; + } + "); + + Assert.That(jsContent, Is.EqualTo("chessLogic.js loaded")); + } +} \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/README.md b/JoshHeaps.Net.UiTests/README.md new file mode 100644 index 0000000..ad62391 --- /dev/null +++ b/JoshHeaps.Net.UiTests/README.md @@ -0,0 +1,61 @@ +# JoshHeaps.Net UI Tests + +This project contains end-to-end tests for the JoshHeaps.Net website using Playwright. + +## Setup + +1. Install Playwright browsers: + ```bash + # From the test project directory + dotnet build + # Then install browsers (requires PowerShell or appropriate command for your OS) + bin/Debug/net8.0/playwright.cmd install # Windows + ``` + +2. Make sure the main application is running on `https://localhost:7065` + +## Running Tests + +```bash +# Run all tests +dotnet test + +# Run specific test file +dotnet test --filter "FullyQualifiedName~ChessGameTests" + +# Run with verbose output +dotnet test --logger "console;verbosity=detailed" +``` + +## Test Categories + +### ChessGameTests +- Basic chess page functionality +- Game start/stop operations +- Piece interaction and move validation +- UI element verification + +### MultiplayerTests +- Two-player game scenarios +- SignalR connection testing +- Real-time move synchronization + +### ApiTests +- Chess API endpoint testing +- Game creation and state management +- Move validation at API level +- Error handling + +## Configuration + +The tests are configured to: +- Use headless browsers by default +- Take screenshots on failure +- Record videos on failure +- Automatically start the web server if needed (port 7065) + +## Notes + +- Tests expect the main application to be available at `https://localhost:7065` +- Some tests require browsers to be installed via Playwright CLI +- Browser installations may require PowerShell on Windows systems \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/TestConfiguration.cs b/JoshHeaps.Net.UiTests/TestConfiguration.cs new file mode 100644 index 0000000..5b29399 --- /dev/null +++ b/JoshHeaps.Net.UiTests/TestConfiguration.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Playwright; + +namespace JoshHeaps.Net.UiTests; + +public class TestConfiguration +{ + private static TestConfiguration? _instance; + private static readonly object _lock = new object(); + + public PlaywrightSettings Playwright { get; } + public TestSettings Test { get; } + + private TestConfiguration() + { + var isCI = Environment.GetEnvironmentVariable("CI") == "true" || + Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true"; + + var configFile = isCI ? "appsettings.CI.json" : "appsettings.Integration.json"; + + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile(configFile, optional: true, reloadOnChange: false) + .AddJsonFile("appsettings.Integration.json", optional: true, reloadOnChange: false) // fallback + .Build(); + + Playwright = configuration.GetSection("PlaywrightSettings").Get() ?? new PlaywrightSettings(); + Test = configuration.GetSection("TestSettings").Get() ?? new TestSettings(); + } + + public static TestConfiguration Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + _instance = new TestConfiguration(); + } + } + } + return _instance; + } + } + + public BrowserNewContextOptions GetBrowserContextOptions() + { + return new BrowserNewContextOptions + { + ViewportSize = new ViewportSize + { + Width = Playwright.Viewport.Width, + Height = Playwright.Viewport.Height + }, + RecordVideoDir = Playwright.EnableVideoRecording ? Path.Combine(Directory.GetCurrentDirectory(), "test-results", "videos") : null + }; + } + + public BrowserTypeLaunchOptions GetLaunchOptions() + { + return new BrowserTypeLaunchOptions + { + Headless = Playwright.Headless, + SlowMo = Playwright.SlowMotion, + Timeout = Playwright.BrowserTimeout + }; + } +} + +public class PlaywrightSettings +{ + public bool Headless { get; set; } = true; + public float SlowMotion { get; set; } = 0; + public ViewportSettings Viewport { get; set; } = new(); + public float BrowserTimeout { get; set; } = 30000; + public float ActionTimeout { get; set; } = 10000; + public bool EnableVideoRecording { get; set; } = false; +} + +public class ViewportSettings +{ + public int Width { get; set; } = 1280; + public int Height { get; set; } = 720; +} + +public class TestSettings +{ + public string BaseUrl { get; set; } = "https://localhost:7118"; + public int WaitTimeout { get; set; } = 3000; + public bool EnableScreenshots { get; set; } = true; + public bool EnableVideoRecording { get; set; } = false; +} \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/UnitTest1.cs b/JoshHeaps.Net.UiTests/UnitTest1.cs new file mode 100644 index 0000000..e69de29 diff --git a/JoshHeaps.Net.UiTests/appsettings.CI.json b/JoshHeaps.Net.UiTests/appsettings.CI.json new file mode 100644 index 0000000..826ed24 --- /dev/null +++ b/JoshHeaps.Net.UiTests/appsettings.CI.json @@ -0,0 +1,19 @@ +{ + "PlaywrightSettings": { + "Headless": true, + "SlowMotion": 0, + "Viewport": { + "Width": 1280, + "Height": 720 + }, + "BrowserTimeout": 60000, + "ActionTimeout": 30000, + "EnableVideoRecording": true + }, + "TestSettings": { + "BaseUrl": "https://localhost:7118", + "WaitTimeout": 5000, + "EnableScreenshots": true, + "EnableVideoRecording": true + } +} \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/appsettings.Integration.json b/JoshHeaps.Net.UiTests/appsettings.Integration.json new file mode 100644 index 0000000..80a2b92 --- /dev/null +++ b/JoshHeaps.Net.UiTests/appsettings.Integration.json @@ -0,0 +1,18 @@ +{ + "PlaywrightSettings": { + "Headless": false, + "SlowMotion": 500, + "Viewport": { + "Width": 1280, + "Height": 720 + }, + "BrowserTimeout": 30000, + "ActionTimeout": 10000 + }, + "TestSettings": { + "BaseUrl": "https://localhost:7118", + "WaitTimeout": 3000, + "EnableScreenshots": true, + "EnableVideoRecording": true + } +} \ No newline at end of file diff --git a/JoshHeaps.Net.UiTests/playwright.config.json b/JoshHeaps.Net.UiTests/playwright.config.json new file mode 100644 index 0000000..3bed25a --- /dev/null +++ b/JoshHeaps.Net.UiTests/playwright.config.json @@ -0,0 +1,24 @@ +{ + "use": { + "headless": true, + "viewport": { "width": 1280, "height": 720 }, + "ignoreHTTPSErrors": true, + "video": "retain-on-failure", + "screenshot": "only-on-failure" + }, + "projects": [ + { + "name": "chromium", + "use": { "browserName": "chromium" } + }, + { + "name": "firefox", + "use": { "browserName": "firefox" } + } + ], + "webServer": { + "command": "dotnet run --project ../JoshHeaps.Net", + "port": 7065, + "reuseExistingServer": true + } +} \ No newline at end of file diff --git a/JoshHeaps.Net.sln b/JoshHeaps.Net.sln index 1d468c4..270423a 100644 --- a/JoshHeaps.Net.sln +++ b/JoshHeaps.Net.sln @@ -5,7 +5,7 @@ VisualStudioVersion = 17.9.34728.123 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{82206AD3-19DF-4DDA-9647-02B691B78CE8}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -17,10 +17,10 @@ Global {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU - {82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.Build.0 = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE