From 2c43e3160595130305e6092d63de8c01184ff8d9 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Mon, 16 Mar 2026 18:21:31 +0100 Subject: [PATCH] Fix WASM compatibility issues and arrow selection rendering bug - Fix arrow selection line count calculation (overcounted by 1 due to trailing newline, causing progressive line truncation on each redraw) - Replace sync Loreline bridge with queue-based async pattern to avoid "Cannot wait on monitors" WASM deadlock - Add bounded input buffer (8 keys, DropOldest) to prevent held-key accumulation - Set Spectre.Console Profile.Height on all AnsiConsole.Create calls to prevent PlatformNotSupportedException on Console.WindowHeight - Add explicit Loreline.dll reference + TrimmerRootAssembly for WASM - Use MSBuild CopyGameContent target instead of Content/Link for static file serving in Blazor WASM - Add WASM guards for file I/O in ContentRegistry, LocalizationManager, AdventureEngine - Enforce min 120x30 terminal dimensions in xterm.js - Add Playwright E2E tests (6 tests: page load, language selection, full flow, multi-box progression, extended play, adventure) --- .gitignore | 5 +- OpenTheBox.slnx | 1 + src/OpenTheBox.Web/OpenTheBox.Web.csproj | 36 +- src/OpenTheBox.Web/Program.cs | 10 +- src/OpenTheBox.Web/WebGameHost.cs | 228 +++++++++- src/OpenTheBox.Web/WebTerminal.cs | 16 +- src/OpenTheBox.Web/wwwroot/appsettings.json | 1 + .../wwwroot/js/terminal-interop.js | 8 + src/OpenTheBox/Adventures/AdventureEngine.cs | 15 +- src/OpenTheBox/Data/ContentRegistry.cs | 4 + .../Localization/LocalizationManager.cs | 4 + tests/OpenTheBox.Web.Tests/GameFlowTests.cs | 393 ++++++++++++++++++ .../OpenTheBox.Web.Tests.csproj | 21 + tests/OpenTheBox.Web.Tests/TerminalHelper.cs | 107 +++++ tests/OpenTheBox.Web.Tests/WebAppFixture.cs | 82 ++++ tests/snapshots/item_utility_report.txt | 8 +- 16 files changed, 903 insertions(+), 36 deletions(-) create mode 100644 src/OpenTheBox.Web/wwwroot/appsettings.json create mode 100644 tests/OpenTheBox.Web.Tests/GameFlowTests.cs create mode 100644 tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj create mode 100644 tests/OpenTheBox.Web.Tests/TerminalHelper.cs create mode 100644 tests/OpenTheBox.Web.Tests/WebAppFixture.cs diff --git a/.gitignore b/.gitignore index 3e811c3..ee1216c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ Thumbs.db ## Claude .claude/ -builds \ No newline at end of file +builds + +## Web project: content files copied by build target (source of truth is content/) +src/OpenTheBox.Web/wwwroot/content/ \ No newline at end of file diff --git a/OpenTheBox.slnx b/OpenTheBox.slnx index f1ed150..4d1887c 100644 --- a/OpenTheBox.slnx +++ b/OpenTheBox.slnx @@ -5,5 +5,6 @@ + diff --git a/src/OpenTheBox.Web/OpenTheBox.Web.csproj b/src/OpenTheBox.Web/OpenTheBox.Web.csproj index b19de00..0d144cf 100644 --- a/src/OpenTheBox.Web/OpenTheBox.Web.csproj +++ b/src/OpenTheBox.Web/OpenTheBox.Web.csproj @@ -16,20 +16,30 @@ - + - - PreserveNewest - wwwroot\content\data\%(RecursiveDir)%(Filename)%(Extension) - - - PreserveNewest - wwwroot\content\adventures\%(RecursiveDir)%(Filename)%(Extension) - - - PreserveNewest - wwwroot\content\strings\%(RecursiveDir)%(Filename)%(Extension) - + + ..\..\lib\Loreline.dll + + + + + + + + + + + + + + diff --git a/src/OpenTheBox.Web/Program.cs b/src/OpenTheBox.Web/Program.cs index 8158da4..bba0209 100644 --- a/src/OpenTheBox.Web/Program.cs +++ b/src/OpenTheBox.Web/Program.cs @@ -20,6 +20,14 @@ public static class Program var http = host.Services.GetRequiredService(); var gameHost = new WebGameHost(js, http); - await gameHost.RunAsync(); + try + { + await gameHost.RunAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[OpenTheBox] Fatal error: {ex}"); + throw; + } } } diff --git a/src/OpenTheBox.Web/WebGameHost.cs b/src/OpenTheBox.Web/WebGameHost.cs index 079e718..3b7ca7b 100644 --- a/src/OpenTheBox.Web/WebGameHost.cs +++ b/src/OpenTheBox.Web/WebGameHost.cs @@ -129,7 +129,7 @@ public sealed class WebGameHost if (_locStrings.TryGetValue(key, out string? json)) _loc.LoadFromString(locale, json); else - _loc.Change(locale); // fallback to file-based (will fail gracefully in WASM) + _loc.LoadFromString(locale, "{}"); // no file I/O in WASM — use empty strings as fallback } // ── Main menu ──────────────────────────────────────────────────────── @@ -592,6 +592,7 @@ public sealed class WebGameHost ColorSystem = ColorSystemSupport.TrueColor }); bufferConsole.Profile.Width = WebTerminal.Width; + bufferConsole.Profile.Height = WebTerminal.Height; bufferConsole.Write(InventoryPanel.Render(_state, _registry, _loc, scrollOffset, selectedIndex: selectedIndex)); @@ -785,11 +786,7 @@ public sealed class WebGameHost _adventureTranslations.TryGetValue(themeName, out translationContent); } - // Create a web-compatible renderer adapter for AdventureEngine - var rendererAdapter = new WebRendererAdapter(this); - var adventureEngine = new AdventureEngine(rendererAdapter, _loc); - var events = await adventureEngine.PlayAdventureFromContent( - theme, _state, scriptContent, translationContent); + var events = await PlayAdventureWasmAsync(theme, scriptContent, translationContent); foreach (var evt in events) { @@ -826,6 +823,195 @@ public sealed class WebGameHost await WaitForKeyAsync(); } + /// + /// WASM-compatible adventure playback. Uses a queue-based pattern to bridge + /// Loreline's synchronous callbacks with async terminal I/O. + /// Loreline handlers store their continuation and signal the async loop, + /// which processes the action (render, wait for input) then resumes Loreline. + /// + private async Task> PlayAdventureWasmAsync( + AdventureTheme theme, string scriptContent, string? translationContent) + { + var events = new List(); + string themeName = theme.ToString().ToLowerInvariant(); + string adventureId = $"{themeName}/intro"; + + // Parse script + var script = Loreline.Engine.Parse( + scriptContent, + $"{themeName}/intro.lor", + (path, callback) => callback(string.Empty)); + + if (script is null) + { + await ShowErrorAsync("Failed to parse adventure script."); + return events; + } + + // Build interpreter options + var options = Loreline.Interpreter.InterpreterOptions.Default(); + + // Build custom functions using AdventureEngine's public method. + // We use a dummy renderer that does nothing for ShowMessage calls inside custom functions + // (events are tracked in the events list and displayed elsewhere). + var dummyRenderer = new NoOpRenderer(); + var engineHelper = new AdventureEngine(dummyRenderer, _loc); + options.Functions = engineHelper.BuildCustomFunctions(_state, events); + + if (translationContent is not null) + { + var translationScript = Loreline.Engine.Parse(translationContent); + if (translationScript is not null) + options.Translations = Loreline.Engine.ExtractTranslations(translationScript); + } + + // Queue-based async bridge for Loreline callbacks + // Each handler stores its data + continuation, then signals the loop + Action? pendingContinuation = null; + string? pendingDialogueChar = null; + string? pendingDialogueText = null; + List? pendingChoiceOptions = null; + List? pendingChoiceEnabled = null; + List? pendingChoiceHints = null; + Action? pendingChoiceCallback = null; + bool finished = false; + var actionReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Dialogue handler: store callback, DON'T call it — let the async loop handle it + void HandleDialogue(Loreline.Interpreter.Dialogue dialogue) + { + string? displayName = dialogue.Character; + if (displayName is not null) + { + string characterName = dialogue.Interpreter.GetCharacterField(displayName, "name") as string + ?? displayName; + string key = $"character.{characterName.ToLowerInvariant().Replace(" ", "_").Replace("'", "")}"; + string localized = _loc.Get(key); + displayName = !localized.StartsWith("[MISSING:") ? localized : characterName; + } + + pendingDialogueChar = displayName; + pendingDialogueText = dialogue.Text; + pendingContinuation = () => dialogue.Callback(); + actionReady.TrySetResult(); + } + + // Choice handler: store options + callback, DON'T call it + void HandleChoice(Loreline.Interpreter.Choice choice) + { + var opts = new List(); + var enabled = new List(); + var hints = new List(); + + foreach (var opt in choice.Options) + { + var (text, hint) = HintSeparator.Parse(opt.Text); + if (opt.Enabled) + { + opts.Add(text); + enabled.Add(true); + hints.Add(null); + } + else + { + string prefix = hint ?? _loc.Get("adventure.unavailable"); + opts.Add($"({prefix}) {text}"); + enabled.Add(false); + hints.Add(hint); + } + } + + pendingChoiceOptions = opts; + pendingChoiceEnabled = enabled; + pendingChoiceHints = hints; + pendingChoiceCallback = idx => choice.Callback(idx); + pendingContinuation = null; // signal that this is a choice, not a dialogue + actionReady.TrySetResult(); + } + + void HandleFinish(Loreline.Interpreter.Finish _) + { + finished = true; + actionReady.TrySetResult(); + } + + // Check for saved progress + bool hasSave = _state.AdventureSaveData.TryGetValue(adventureId, out string? saveData) + && !string.IsNullOrEmpty(saveData); + + Loreline.Interpreter interpreter; + if (hasSave) + { + interpreter = Loreline.Engine.Resume( + script, HandleDialogue, HandleChoice, HandleFinish, + saveData!, options: options); + } + else + { + interpreter = Loreline.Engine.Play( + script, HandleDialogue, HandleChoice, HandleFinish, + options: options); + } + + // Async loop: process queued actions until the adventure finishes + while (!finished) + { + await actionReady.Task; + actionReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (finished) break; + + if (pendingChoiceCallback is not null) + { + // Choice action: show options, get selection, resume Loreline + var choiceOpts = pendingChoiceOptions!; + var choiceEnabled = pendingChoiceEnabled!; + var choiceHints = pendingChoiceHints!; + var choiceCb = pendingChoiceCallback; + pendingChoiceCallback = null; + pendingChoiceOptions = null; + pendingChoiceEnabled = null; + pendingChoiceHints = null; + + int selectedIndex; + while (true) + { + selectedIndex = await _terminal.ShowSelectionAsync( + "", choiceOpts, _renderContext.HasArrowSelection); + + if (choiceEnabled[selectedIndex]) + break; + + if (choiceHints[selectedIndex] is { } hintText) + await _terminal.WriteMarkupLineAsync($"[dim italic]{Spectre.Console.Markup.Escape(hintText)}[/]"); + + await ShowErrorAsync(_loc.Get("adventure.unavailable")); + } + + choiceCb(selectedIndex); + } + else if (pendingContinuation is not null) + { + // Dialogue action: show text, wait for key, resume Loreline + await ShowAdventureDialogueAsync(pendingDialogueChar, pendingDialogueText!); + await WaitForKeyAsync(); + + var cont = pendingContinuation; + pendingContinuation = null; + pendingDialogueChar = null; + pendingDialogueText = null; + + cont(); + } + } + + // Adventure completed + _state.AdventureSaveData.Remove(adventureId); + _state.CompletedAdventures.Add(theme.ToString()); + + return events; + } + // ── Appearance ─────────────────────────────────────────────────────── private async Task ChangeAppearance() @@ -1291,11 +1477,31 @@ public sealed class WebGameHost /// /// Adapter that implements IRenderer by delegating to WebGameHost's async methods. -/// Used by AdventureEngine which requires a synchronous IRenderer. -/// The adventure callbacks (HandleDialogue/HandleChoice) are called synchronously -/// by Loreline, so this adapter blocks on the async methods. -/// In WASM single-threaded mode, this works because Loreline callbacks -/// resume execution synchronously via TaskCompletionSource. +/// No-op renderer used only to satisfy AdventureEngine.BuildCustomFunctions() parameter. +/// Custom function messages (e.g. "You received X") are not shown in WASM — the adventure +/// dialogue provides the narrative, and events are tracked in the events list. +/// +internal sealed class NoOpRenderer : IRenderer +{ + public void ShowMessage(string message) { } + public void ShowError(string message) { } + public void ShowBoxOpening(string boxName, string rarity) { } + public void ShowLootReveal(List<(string name, string rarity, string category)> items) { } + public void ShowGameState(GameState state, RenderContext context) { } + public void ShowUIFeatureUnlocked(string featureName) { } + public void ShowAdventureDialogue(string? character, string text) { } + public int ShowAdventureChoice(List options) => 0; + public void ShowAdventureHint(string hint) { } + public void ShowInteraction(string description) { } + public void WaitForKeyPress(string? message = null) { } + public void Clear() { } + public int ShowSelection(string prompt, List options) => 0; + public string ShowTextInput(string prompt) => ""; +} + +/// +/// [DEPRECATED] Sync→async bridge for IRenderer. Kept for reference but no longer used +/// by adventure playback (which now uses PlayAdventureWasmAsync directly). /// internal sealed class WebRendererAdapter : IRenderer { diff --git a/src/OpenTheBox.Web/WebTerminal.cs b/src/OpenTheBox.Web/WebTerminal.cs index 02c6fa0..83920ad 100644 --- a/src/OpenTheBox.Web/WebTerminal.cs +++ b/src/OpenTheBox.Web/WebTerminal.cs @@ -14,11 +14,17 @@ public sealed class WebTerminal private static WebTerminal? _instance; private readonly IJSRuntime _js; + + /// + /// Bounded key buffer — prevents held keys from accumulating minutes of input. + /// FullMode.DropWrite silently drops new keys when the buffer is full. + /// private readonly Channel _keyChannel = - Channel.CreateUnbounded(new UnboundedChannelOptions + Channel.CreateBounded(new BoundedChannelOptions(8) { SingleReader = true, - SingleWriter = true + SingleWriter = true, + FullMode = BoundedChannelFullMode.DropOldest }); public const int Width = 120; @@ -77,6 +83,7 @@ public sealed class WebTerminal ColorSystem = ColorSystemSupport.TrueColor }); console.Profile.Width = Width; + console.Profile.Height = Height; console.Write(renderable); return writer.ToString(); } @@ -105,6 +112,7 @@ public sealed class WebTerminal ColorSystem = ColorSystemSupport.TrueColor }); console.Profile.Width = Width; + console.Profile.Height = Height; console.MarkupLine(markup); string ansi = writer.ToString().Replace("\n", "\r\n"); await WriteAsync(ansi); @@ -233,6 +241,7 @@ public sealed class WebTerminal ColorSystem = ColorSystemSupport.TrueColor }); console.Profile.Width = Width; + console.Profile.Height = Height; if (prompt.Length > 0) console.MarkupLine($"[bold]{Markup.Escape(prompt)}[/]"); @@ -253,7 +262,8 @@ public sealed class WebTerminal console.MarkupLine("[dim] ▼ ...[/]"); string rendered = writer.ToString().Replace("\n", "\r\n"); - int lineCount = rendered.Split('\n').Length; + // Count actual line breaks — Split('\n').Length overcounts by 1 due to trailing newline + int lineCount = rendered.Count(c => c == '\n'); await WriteAsync(rendered); diff --git a/src/OpenTheBox.Web/wwwroot/appsettings.json b/src/OpenTheBox.Web/wwwroot/appsettings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/OpenTheBox.Web/wwwroot/appsettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js b/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js index 3057929..214f866 100644 --- a/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js +++ b/src/OpenTheBox.Web/wwwroot/js/terminal-interop.js @@ -25,7 +25,12 @@ window.terminalInterop = { term.loadAddon(fitAddon); term.open(document.getElementById('terminal')); + + // Fit to container, but enforce minimum dimensions for game readability fitAddon.fit(); + if (term.cols < 120 || term.rows < 30) { + term.resize(Math.max(term.cols, 120), Math.max(term.rows, 30)); + } // Forward key input to C# term.onData(function (data) { @@ -35,6 +40,9 @@ window.terminalInterop = { // Handle resize window.addEventListener('resize', function () { fitAddon.fit(); + if (term.cols < 120 || term.rows < 30) { + term.resize(Math.max(term.cols, 120), Math.max(term.rows, 30)); + } }); this.term = term; diff --git a/src/OpenTheBox/Adventures/AdventureEngine.cs b/src/OpenTheBox/Adventures/AdventureEngine.cs index 051a628..036b9a9 100644 --- a/src/OpenTheBox/Adventures/AdventureEngine.cs +++ b/src/OpenTheBox/Adventures/AdventureEngine.cs @@ -30,7 +30,7 @@ public enum GameEventKind /// /// In .lor files: Open the secret path|||A keen sense of Luck might help here... #label [if hasStat("Luck", 30)] /// -internal static class HintSeparator +public static class HintSeparator { public const string Delimiter = "|||"; @@ -77,6 +77,13 @@ public sealed class AdventureEngine /// public async Task> PlayAdventure(AdventureTheme theme, GameState state) { + // In WASM, use PlayAdventureFromContent() with pre-loaded scripts instead + if (OperatingSystem.IsBrowser()) + { + _renderer.ShowError("PlayAdventure requires file I/O. Use PlayAdventureFromContent in WASM."); + return []; + } + string themeName = theme.ToString().ToLowerInvariant(); string scriptPath = Path.Combine(AdventuresRoot, themeName, "intro.lor"); @@ -338,7 +345,11 @@ public sealed class AdventureEngine // ── Custom functions registered into the Loreline interpreter ──────── - private Dictionary BuildCustomFunctions( + /// + /// Builds the custom Loreline functions dictionary for a given game state. + /// Public so the WASM build can reuse it with its own async adventure loop. + /// + public Dictionary BuildCustomFunctions( GameState state, List events) { diff --git a/src/OpenTheBox/Data/ContentRegistry.cs b/src/OpenTheBox/Data/ContentRegistry.cs index 1e8e78e..064c53d 100644 --- a/src/OpenTheBox/Data/ContentRegistry.cs +++ b/src/OpenTheBox/Data/ContentRegistry.cs @@ -42,6 +42,10 @@ public class ContentRegistry public static ContentRegistry LoadFromFiles( string itemsPath, string boxesPath, string interactionsPath, string? recipesPath = null) { + if (OperatingSystem.IsBrowser()) + throw new PlatformNotSupportedException( + "LoadFromFiles requires file I/O. Use LoadFromStrings in WASM."); + var registry = new ContentRegistry(); if (File.Exists(itemsPath)) diff --git a/src/OpenTheBox/Localization/LocalizationManager.cs b/src/OpenTheBox/Localization/LocalizationManager.cs index 1a98418..b80f45c 100644 --- a/src/OpenTheBox/Localization/LocalizationManager.cs +++ b/src/OpenTheBox/Localization/LocalizationManager.cs @@ -35,6 +35,10 @@ public sealed class LocalizationManager CurrentLocale = locale; _strings = []; + // File I/O is not available in Blazor WASM — use LoadFromString() instead + if (OperatingSystem.IsBrowser()) + return; + string localeName = locale.ToString().ToLowerInvariant(); string path = Path.Combine(StringsDirectory, $"{localeName}.json"); diff --git a/tests/OpenTheBox.Web.Tests/GameFlowTests.cs b/tests/OpenTheBox.Web.Tests/GameFlowTests.cs new file mode 100644 index 0000000..de757ba --- /dev/null +++ b/tests/OpenTheBox.Web.Tests/GameFlowTests.cs @@ -0,0 +1,393 @@ +using Microsoft.Playwright; + +namespace OpenTheBox.Web.Tests; + +/// +/// End-to-end tests that validate the web game is interactive and functional. +/// These tests launch the Blazor WASM app and interact with the xterm.js terminal. +/// +[Collection("WebApp")] +public class GameFlowTests +{ + private readonly WebAppFixture _fixture; + + public GameFlowTests(WebAppFixture fixture) + { + _fixture = fixture; + } + + private async Task<(IPage page, IBrowserContext context)> CreateFreshSessionAsync() + { + var context = await _fixture.Browser.NewContextAsync(new BrowserNewContextOptions + { + ViewportSize = new ViewportSize { Width = 1400, Height = 900 } + }); + var page = await context.NewPageAsync(); + return (page, context); + } + + /// + /// Types a number and presses Enter for numbered selection prompts. + /// + private static async Task SelectNumberedOptionAsync(IPage page, int number) + { + await TerminalHelper.PressDigitAsync(page, number); + await Task.Delay(200); + await TerminalHelper.PressEnterAsync(page); + } + + /// + /// Navigates through language selection → main menu → name entry → game loop. + /// Returns the page ready at the first action menu. + /// + private async Task StartNewGameAsync(IBrowserContext context, string playerName = "Tester") + { + var page = await context.NewPageAsync(); + await page.GotoAsync(_fixture.BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + await TerminalHelper.WaitForTerminalReadyAsync(page, timeoutMs: 90000); + + // Language selection → English + await TerminalHelper.WaitForTerminalTextAsync(page, "Language", timeoutMs: 60000); + await SelectNumberedOptionAsync(page, 1); + await Task.Delay(500); + + // Main menu → New Game + await TerminalHelper.WaitForTerminalTextAsync(page, "OPEN THE BOX", timeoutMs: 30000); + await SelectNumberedOptionAsync(page, 1); + await Task.Delay(500); + + // Name prompt + await TerminalHelper.WaitForTerminalTextAsync(page, "name", timeoutMs: 15000); + await TerminalHelper.TypeAsync(page, playerName); + await TerminalHelper.PressEnterAsync(page); + await Task.Delay(500); + + // Welcome message → press Enter + await TerminalHelper.WaitForTerminalTextAsync(page, playerName, timeoutMs: 10000); + await TerminalHelper.PressEnterAsync(page); + await Task.Delay(500); + + // Wait for action menu + await TerminalHelper.WaitForTerminalTextAsync(page, "Open a box", timeoutMs: 15000); + + return page; + } + + /// + /// Opens a box from the action menu (selects "Open a box" → selects box #1 → presses through results). + /// + private static async Task OpenBoxAsync(IPage page) + { + // Select "Open a box" — find which option number it is + var content = await TerminalHelper.ReadTerminalAsync(page); + + // "Open a box" should be option 1 in the action menu + await SelectNumberedOptionAsync(page, 1); + await Task.Delay(500); + + // Select the first box in the list + await SelectNumberedOptionAsync(page, 1); + await Task.Delay(1000); + + // Press Enter through box opening results + for (int i = 0; i < 3; i++) + { + await TerminalHelper.PressEnterAsync(page); + await Task.Delay(300); + } + + // Wait for the action menu to reappear + await TerminalHelper.WaitForTerminalTextAsync(page, "Open a box", timeoutMs: 15000); + } + + // ── Tests ────────────────────────────────────────────────────────────── + + [Fact] + public async Task PageLoads_TerminalInitializes() + { + var (page, context) = await CreateFreshSessionAsync(); + var consoleLogs = new List(); + page.Console += (_, msg) => consoleLogs.Add($"[{msg.Type}] {msg.Text}"); + page.PageError += (_, error) => consoleLogs.Add($"[PAGE ERROR] {error}"); + try + { + await page.GotoAsync(_fixture.BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + await TerminalHelper.WaitForTerminalReadyAsync(page, timeoutMs: 90000); + + var containerVisible = await page.EvaluateAsync(@"() => { + const el = document.getElementById('terminal-container'); + return el && el.classList.contains('active'); + }"); + Assert.True(containerVisible, "Terminal container should be active"); + + var loadingHidden = await page.EvaluateAsync(@"() => { + const el = document.getElementById('loading'); + return el && el.classList.contains('hidden'); + }"); + Assert.True(loadingHidden, "Loading screen should be hidden"); + + var cols = await page.EvaluateAsync("() => window.terminalInterop?.term?.cols ?? 0"); + Assert.True(cols >= 120, $"Terminal should have at least 120 cols, got {cols}"); + + Assert.DoesNotContain(consoleLogs, l => l.StartsWith("[error]")); + } + finally + { + await context.CloseAsync(); + } + } + + [Fact] + public async Task LanguageSelection_DisplaysOptions() + { + var (page, context) = await CreateFreshSessionAsync(); + try + { + await page.GotoAsync(_fixture.BaseUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle }); + await TerminalHelper.WaitForTerminalReadyAsync(page, timeoutMs: 90000); + + await TerminalHelper.WaitForTerminalTextAsync(page, "Language", timeoutMs: 60000); + + var content = await TerminalHelper.ReadTerminalAsync(page); + Assert.Contains("English", content); + Assert.Contains("Fran", content); + } + finally + { + await context.CloseAsync(); + } + } + + [Fact] + public async Task FullFlow_SelectLanguage_EnterName_OpenFirstBox() + { + var (_, context) = await CreateFreshSessionAsync(); + try + { + var page = await StartNewGameAsync(context); + + // Verify initial state + var content = await TerminalHelper.ReadTerminalAsync(page); + Console.WriteLine($"Initial game state:\n{content}"); + Assert.Contains("Boxes Opened: 0", content); + + // Open the first box + await OpenBoxAsync(page); + + // Verify box was opened + var afterOpen = await TerminalHelper.ReadTerminalAsync(page); + Console.WriteLine($"After first box:\n{afterOpen}"); + Assert.Contains("Boxes Opened: 1", afterOpen); + } + finally + { + await context.CloseAsync(); + } + } + + [Fact] + public async Task MultipleBoxes_ProgressionAndMetaUnlocks() + { + var (_, context) = await CreateFreshSessionAsync(); + var consoleLogs = new List(); + try + { + var page = await StartNewGameAsync(context); + page.Console += (_, msg) => consoleLogs.Add($"[{msg.Type}] {msg.Text}"); + + // Open several boxes — should trigger meta unlocks (new UI features) + // The pacing test says 1st meta UI unlock should happen before box ~50 + int boxesOpened = 0; + bool sawNewLabel = false; + bool sawAdventure = false; + + for (int i = 0; i < 20; i++) + { + await OpenBoxAsync(page); + boxesOpened++; + + var content = await TerminalHelper.ReadTerminalAsync(page); + + // Check for NEW labels (meta unlocks) or adventure option + if (content.Contains("NEW", StringComparison.Ordinal)) + sawNewLabel = true; + if (content.Contains("adventure", StringComparison.OrdinalIgnoreCase)) + sawAdventure = true; + + Console.WriteLine($"Box #{boxesOpened}: menu options visible. NEW={sawNewLabel}, Adventure={sawAdventure}"); + + // If we've seen both, no need to open more + if (sawNewLabel && sawAdventure) + break; + } + + Console.WriteLine($"After {boxesOpened} boxes: NEW labels seen={sawNewLabel}, Adventure unlocked={sawAdventure}"); + + // Verify no browser errors from box opening + var errors = consoleLogs.Where(l => l.StartsWith("[error]")).ToList(); + foreach (var err in errors) + Console.WriteLine($"Browser error: {err}"); + Assert.Empty(errors); + } + finally + { + await context.CloseAsync(); + } + } + + [Fact] + public async Task ExtendedPlay_NoWasmCrash() + { + var (_, context) = await CreateFreshSessionAsync(); + var consoleLogs = new List(); + try + { + var page = await StartNewGameAsync(context); + page.Console += (_, msg) => consoleLogs.Add($"[{msg.Type}] {msg.Text}"); + + // Open 20+ boxes — should trigger meta unlocks, arrow navigation, adventures + // This validates that the full game loop works in WASM without crashing + for (int i = 0; i < 25; i++) + { + await OpenBoxAsync(page); + + // Check for fatal errors after each box + bool hasFatalError = consoleLogs.Any(l => l.Contains("[OpenTheBox] Fatal error")); + if (hasFatalError) + { + var errors = consoleLogs.Where(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)); + foreach (var err in errors) + Console.WriteLine($"WASM error: {err}"); + Assert.Fail($"Fatal error after box #{i + 1}"); + } + } + + var finalContent = await TerminalHelper.ReadTerminalAsync(page); + Console.WriteLine($"After 25 boxes:\n{finalContent}"); + + // After 25 boxes, the game should still be running and show the action menu + Assert.Contains("box", finalContent, StringComparison.OrdinalIgnoreCase); + + // Verify no WASM-specific errors + bool hasMonitorError = consoleLogs.Any(l => l.Contains("Cannot wait on monitors")); + Assert.False(hasMonitorError, "Should not get 'Cannot wait on monitors' error in WASM"); + + bool hasPlatformError = consoleLogs.Any(l => l.Contains("PlatformNotSupported")); + Assert.False(hasPlatformError, "Should not get PlatformNotSupportedException in WASM"); + + Console.WriteLine($"25 boxes opened successfully. Browser console had {consoleLogs.Count} messages."); + } + finally + { + await context.CloseAsync(); + } + } + + [Fact] + public async Task Adventure_NoWasmCrash() + { + var (_, context) = await CreateFreshSessionAsync(); + var consoleLogs = new List(); + try + { + var page = await StartNewGameAsync(context); + page.Console += (_, msg) => consoleLogs.Add($"[{msg.Type}] {msg.Text}"); + + // Open boxes until adventure is unlocked + bool adventureUnlocked = false; + for (int i = 0; i < 30 && !adventureUnlocked; i++) + { + await OpenBoxAsync(page); + var content = await TerminalHelper.ReadTerminalAsync(page); + adventureUnlocked = content.Contains("adventure", StringComparison.OrdinalIgnoreCase); + } + + if (!adventureUnlocked) + { + Console.WriteLine("Adventure not unlocked within 30 boxes — skipping"); + return; + } + + Console.WriteLine("Adventure unlocked, attempting to start..."); + + // Navigate to adventure: use arrow keys to find "adventure" option + // Try each digit from 1-9 — the arrow selection responds to digit shortcuts + for (int d = 1; d <= 9; d++) + { + await TerminalHelper.PressDigitAsync(page, d); + await Task.Delay(1000); + + var content = await TerminalHelper.ReadTerminalAsync(page); + // If we see the adventure list (contains "Back" and a theme name), we found it + if (content.Contains("Back", StringComparison.OrdinalIgnoreCase) + && !content.Contains("Open a box", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"Adventure menu found with digit {d}"); + break; + } + + // If we accidentally selected something else, press Escape or go back + if (!content.Contains("adventure", StringComparison.OrdinalIgnoreCase)) + { + // We're in a submenu we don't want — go back by selecting "Back" option + // First check if this is a box selection or other menu + if (content.Contains("Back")) + { + // Find the Back option number and press it + var backLines = content.Split('\n'); + foreach (var line in backLines) + { + if (line.Contains("Back")) + { + foreach (var ch in line) + { + if (char.IsDigit(ch)) + { + await TerminalHelper.PressDigitAsync(page, ch - '0'); + await Task.Delay(500); + break; + } + } + break; + } + } + } + } + } + + // Try to select the first adventure + await TerminalHelper.PressDigitAsync(page, 1); + await Task.Delay(2000); + + // Read the result + var dialogueContent = await TerminalHelper.ReadTerminalAsync(page); + Console.WriteLine($"After adventure selection:\n{dialogueContent}"); + + // The key assertion: no WASM-specific crash errors + bool hasMonitorError = consoleLogs.Any(l => l.Contains("Cannot wait on monitors")); + Assert.False(hasMonitorError, "Should not get 'Cannot wait on monitors' error in WASM"); + + bool hasFatalError = consoleLogs.Any(l => l.Contains("[OpenTheBox] Fatal error")); + if (hasFatalError) + { + var errors = consoleLogs.Where(l => l.Contains("error", StringComparison.OrdinalIgnoreCase)); + foreach (var err in errors) + Console.WriteLine($"Browser error: {err}"); + } + Assert.False(hasFatalError, "Should not get fatal error during adventure"); + + // If we got this far with dialogue, press through a few lines + for (int i = 0; i < 5; i++) + { + await TerminalHelper.PressEnterAsync(page); + await Task.Delay(500); + } + + Console.WriteLine("Adventure test completed without WASM crash"); + } + finally + { + await context.CloseAsync(); + } + } +} diff --git a/tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj b/tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj new file mode 100644 index 0000000..205a4d4 --- /dev/null +++ b/tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/tests/OpenTheBox.Web.Tests/TerminalHelper.cs b/tests/OpenTheBox.Web.Tests/TerminalHelper.cs new file mode 100644 index 0000000..d38cff4 --- /dev/null +++ b/tests/OpenTheBox.Web.Tests/TerminalHelper.cs @@ -0,0 +1,107 @@ +using Microsoft.Playwright; + +namespace OpenTheBox.Web.Tests; + +/// +/// Helpers for interacting with the xterm.js terminal in Playwright tests. +/// +public static class TerminalHelper +{ + /// + /// Reads the visible text content from the xterm.js terminal buffer. + /// + public static async Task ReadTerminalAsync(IPage page) + { + return await page.EvaluateAsync(@"() => { + const term = window.terminalInterop?.term; + if (!term) return ''; + const buffer = term.buffer.active; + let text = ''; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) text += line.translateToString(true) + '\n'; + } + return text; + }"); + } + + /// + /// Waits until the terminal contains the specified text, with timeout. + /// + public static async Task WaitForTerminalTextAsync(IPage page, string text, int timeoutMs = 30000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + var content = await ReadTerminalAsync(page); + if (content.Contains(text, StringComparison.OrdinalIgnoreCase)) + return; + await Task.Delay(500); + } + // Read one final time for the error message + var finalContent = await ReadTerminalAsync(page); + throw new TimeoutException( + $"Terminal did not contain \"{text}\" within {timeoutMs}ms.\n" + + $"Terminal content:\n{finalContent}"); + } + + /// + /// Types text into the terminal (sends keystrokes). + /// + public static async Task TypeAsync(IPage page, string text) + { + await page.Keyboard.TypeAsync(text, new KeyboardTypeOptions { Delay = 50 }); + } + + /// + /// Presses Enter in the terminal. + /// + public static async Task PressEnterAsync(IPage page) + { + await page.Keyboard.PressAsync("Enter"); + } + + /// + /// Presses a specific key (Arrow keys, Escape, etc.). + /// + public static async Task PressKeyAsync(IPage page, string key) + { + await page.Keyboard.PressAsync(key); + } + + /// + /// Sends a digit key press (1-9) for numbered menu selection. + /// + public static async Task PressDigitAsync(IPage page, int digit) + { + await page.Keyboard.PressAsync($"Digit{digit}"); + } + + /// + /// Waits for the terminal to be initialized (xterm.js loaded and terminal visible). + /// + public static async Task WaitForTerminalReadyAsync(IPage page, int timeoutMs = 60000) + { + var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs); + while (DateTime.UtcNow < deadline) + { + var ready = await page.EvaluateAsync(@"() => { + return window.terminalInterop?.term != null; + }"); + if (ready) + { + // Verify terminal has proper dimensions (should be 120x30 after init) + var cols = await page.EvaluateAsync("() => window.terminalInterop.term.cols"); + if (cols < 120) + { + // Force resize if fit addon miscalculated (e.g., headless browser) + await page.EvaluateAsync("() => window.terminalInterop.term.resize(120, 30)"); + await Task.Delay(200); + } + return; + } + await Task.Delay(500); + } + throw new TimeoutException($"Terminal was not initialized within {timeoutMs}ms"); + } +} diff --git a/tests/OpenTheBox.Web.Tests/WebAppFixture.cs b/tests/OpenTheBox.Web.Tests/WebAppFixture.cs new file mode 100644 index 0000000..ee224b8 --- /dev/null +++ b/tests/OpenTheBox.Web.Tests/WebAppFixture.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using Microsoft.Playwright; + +namespace OpenTheBox.Web.Tests; + +/// +/// Shared fixture that starts the Blazor WASM dev server and Playwright browser once per test collection. +/// +public class WebAppFixture : IAsyncLifetime +{ + private Process? _serverProcess; + public IBrowser Browser { get; private set; } = null!; + public IPlaywright Playwright { get; private set; } = null!; + public string BaseUrl { get; private set; } = "http://localhost:5280"; + + public async Task InitializeAsync() + { + // Start the Blazor WASM dev server + _serverProcess = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project src/OpenTheBox.Web --urls {BaseUrl}", + WorkingDirectory = FindSolutionRoot(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + _serverProcess.Start(); + + // Wait for the server to be ready (try connecting) + using var httpClient = new HttpClient(); + for (int i = 0; i < 120; i++) // up to 2 minutes for WASM compilation + { + try + { + var response = await httpClient.GetAsync(BaseUrl); + if (response.IsSuccessStatusCode) break; + } + catch { } + await Task.Delay(1000); + } + + // Initialize Playwright + Playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + Browser = await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + Args = ["--window-size=1400,900"] + }); + } + + public async Task DisposeAsync() + { + await Browser.DisposeAsync(); + Playwright.Dispose(); + + if (_serverProcess is { HasExited: false }) + { + _serverProcess.Kill(entireProcessTree: true); + _serverProcess.Dispose(); + } + } + + private static string FindSolutionRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + if (dir.GetFiles("OpenTheBox.slnx").Length > 0) + return dir.FullName; + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find solution root (OpenTheBox.slnx)"); + } +} + +[CollectionDefinition("WebApp")] +public class WebAppCollection : ICollectionFixture; diff --git a/tests/snapshots/item_utility_report.txt b/tests/snapshots/item_utility_report.txt index 87236e4..f3c24a7 100644 --- a/tests/snapshots/item_utility_report.txt +++ b/tests/snapshots/item_utility_report.txt @@ -1,5 +1,5 @@ # Item Utility Report -# Total items: 152 +# Total items: 151 # Total boxes: 31 # Total recipes: 18 @@ -439,7 +439,7 @@ Adventure: Pirate Interaction: pirate_map_compass -## Material (15 items) +## Material (14 items) ──────────────────────────────────────────────────────────────────────────────── [****] material_bronze_ingot (Uncommon) — Bronze Loot: box_ok_tier @@ -502,8 +502,6 @@ [*] material_diamond_gem (Legendary) — Diamant Loot: box_black - [NO USE] material_wood_nail (Common) — Bois - ## Meta (33 items) ──────────────────────────────────────────────────────────────────────────────── [**] meta_colors (Rare) — Couleurs de texte @@ -675,4 +673,4 @@ ## Orphan Items (no usage context) ──────────────────────────────────────────────────────────────────────────────── - material_wood_nail (Material, Common) + (none)