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)
This commit is contained in:
parent
ea487cd332
commit
2c43e31605
16 changed files with 903 additions and 36 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -44,3 +44,6 @@ Thumbs.db
|
|||
.claude/
|
||||
|
||||
builds
|
||||
|
||||
## Web project: content files copied by build target (source of truth is content/)
|
||||
src/OpenTheBox.Web/wwwroot/content/
|
||||
|
|
@ -5,5 +5,6 @@
|
|||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/OpenTheBox.Tests/OpenTheBox.Tests.csproj" />
|
||||
<Project Path="tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
|
|
|||
|
|
@ -16,20 +16,30 @@
|
|||
<ProjectReference Include="..\OpenTheBox\OpenTheBox.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy game content files to wwwroot for HTTP loading -->
|
||||
<!-- Loreline DLL: must be explicitly referenced for WASM (transitive ref from OpenTheBox doesn't resolve for browser-wasm) -->
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\content\data\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Link>wwwroot\content\data\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
</Content>
|
||||
<Content Include="..\..\content\adventures\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Link>wwwroot\content\adventures\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
</Content>
|
||||
<Content Include="..\..\content\strings\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Link>wwwroot\content\strings\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
</Content>
|
||||
<Reference Include="Loreline">
|
||||
<HintPath>..\..\lib\Loreline.dll</HintPath>
|
||||
</Reference>
|
||||
<TrimmerRootAssembly Include="Loreline" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Copy game content files into wwwroot before build so Blazor serves them as static files -->
|
||||
<Target Name="CopyGameContent" BeforeTargets="BeforeBuild">
|
||||
<ItemGroup>
|
||||
<GameDataFiles Include="$(MSBuildProjectDirectory)\..\..\content\data\**\*" />
|
||||
<GameAdventureFiles Include="$(MSBuildProjectDirectory)\..\..\content\adventures\**\*" />
|
||||
<GameStringFiles Include="$(MSBuildProjectDirectory)\..\..\content\strings\**\*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(GameDataFiles)"
|
||||
DestinationFiles="@(GameDataFiles->'$(MSBuildProjectDirectory)\wwwroot\content\data\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="@(GameAdventureFiles)"
|
||||
DestinationFiles="@(GameAdventureFiles->'$(MSBuildProjectDirectory)\wwwroot\content\adventures\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="@(GameStringFiles)"
|
||||
DestinationFiles="@(GameStringFiles->'$(MSBuildProjectDirectory)\wwwroot\content\strings\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ public static class Program
|
|||
var http = host.Services.GetRequiredService<HttpClient>();
|
||||
|
||||
var gameHost = new WebGameHost(js, http);
|
||||
await gameHost.RunAsync();
|
||||
try
|
||||
{
|
||||
await gameHost.RunAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OpenTheBox] Fatal error: {ex}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<List<AdventureEvent>> PlayAdventureWasmAsync(
|
||||
AdventureTheme theme, string scriptContent, string? translationContent)
|
||||
{
|
||||
var events = new List<AdventureEvent>();
|
||||
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<string>? pendingChoiceOptions = null;
|
||||
List<bool>? pendingChoiceEnabled = null;
|
||||
List<string?>? pendingChoiceHints = null;
|
||||
Action<int>? 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<string>();
|
||||
var enabled = new List<bool>();
|
||||
var hints = new List<string?>();
|
||||
|
||||
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
|
|||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> 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<string> options) => 0;
|
||||
public string ShowTextInput(string prompt) => "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// [DEPRECATED] Sync→async bridge for IRenderer. Kept for reference but no longer used
|
||||
/// by adventure playback (which now uses PlayAdventureWasmAsync directly).
|
||||
/// </summary>
|
||||
internal sealed class WebRendererAdapter : IRenderer
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,11 +14,17 @@ public sealed class WebTerminal
|
|||
private static WebTerminal? _instance;
|
||||
|
||||
private readonly IJSRuntime _js;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded key buffer — prevents held keys from accumulating minutes of input.
|
||||
/// FullMode.DropWrite silently drops new keys when the buffer is full.
|
||||
/// </summary>
|
||||
private readonly Channel<ConsoleKeyInfo> _keyChannel =
|
||||
Channel.CreateUnbounded<ConsoleKeyInfo>(new UnboundedChannelOptions
|
||||
Channel.CreateBounded<ConsoleKeyInfo>(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);
|
||||
|
||||
|
|
|
|||
1
src/OpenTheBox.Web/wwwroot/appsettings.json
Normal file
1
src/OpenTheBox.Web/wwwroot/appsettings.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ public enum GameEventKind
|
|||
/// <example>
|
||||
/// In .lor files: Open the secret path|||A keen sense of Luck might help here... #label [if hasStat("Luck", 30)]
|
||||
/// </example>
|
||||
internal static class HintSeparator
|
||||
public static class HintSeparator
|
||||
{
|
||||
public const string Delimiter = "|||";
|
||||
|
||||
|
|
@ -77,6 +77,13 @@ public sealed class AdventureEngine
|
|||
/// </summary>
|
||||
public async Task<List<AdventureEvent>> 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<string, Loreline.Interpreter.Function> BuildCustomFunctions(
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Dictionary<string, Loreline.Interpreter.Function> BuildCustomFunctions(
|
||||
GameState state,
|
||||
List<AdventureEvent> events)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
393
tests/OpenTheBox.Web.Tests/GameFlowTests.cs
Normal file
393
tests/OpenTheBox.Web.Tests/GameFlowTests.cs
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
using Microsoft.Playwright;
|
||||
|
||||
namespace OpenTheBox.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types a number and presses Enter for numbered selection prompts.
|
||||
/// </summary>
|
||||
private static async Task SelectNumberedOptionAsync(IPage page, int number)
|
||||
{
|
||||
await TerminalHelper.PressDigitAsync(page, number);
|
||||
await Task.Delay(200);
|
||||
await TerminalHelper.PressEnterAsync(page);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates through language selection → main menu → name entry → game loop.
|
||||
/// Returns the page ready at the first action menu.
|
||||
/// </summary>
|
||||
private async Task<IPage> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a box from the action menu (selects "Open a box" → selects box #1 → presses through results).
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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<bool>(@"() => {
|
||||
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<bool>(@"() => {
|
||||
const el = document.getElementById('loading');
|
||||
return el && el.classList.contains('hidden');
|
||||
}");
|
||||
Assert.True(loadingHidden, "Loading screen should be hidden");
|
||||
|
||||
var cols = await page.EvaluateAsync<int>("() => 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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
21
tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj
Normal file
21
tests/OpenTheBox.Web.Tests/OpenTheBox.Web.Tests.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.52.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
107
tests/OpenTheBox.Web.Tests/TerminalHelper.cs
Normal file
107
tests/OpenTheBox.Web.Tests/TerminalHelper.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
using Microsoft.Playwright;
|
||||
|
||||
namespace OpenTheBox.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for interacting with the xterm.js terminal in Playwright tests.
|
||||
/// </summary>
|
||||
public static class TerminalHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the visible text content from the xterm.js terminal buffer.
|
||||
/// </summary>
|
||||
public static async Task<string> ReadTerminalAsync(IPage page)
|
||||
{
|
||||
return await page.EvaluateAsync<string>(@"() => {
|
||||
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;
|
||||
}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the terminal contains the specified text, with timeout.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types text into the terminal (sends keystrokes).
|
||||
/// </summary>
|
||||
public static async Task TypeAsync(IPage page, string text)
|
||||
{
|
||||
await page.Keyboard.TypeAsync(text, new KeyboardTypeOptions { Delay = 50 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presses Enter in the terminal.
|
||||
/// </summary>
|
||||
public static async Task PressEnterAsync(IPage page)
|
||||
{
|
||||
await page.Keyboard.PressAsync("Enter");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Presses a specific key (Arrow keys, Escape, etc.).
|
||||
/// </summary>
|
||||
public static async Task PressKeyAsync(IPage page, string key)
|
||||
{
|
||||
await page.Keyboard.PressAsync(key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a digit key press (1-9) for numbered menu selection.
|
||||
/// </summary>
|
||||
public static async Task PressDigitAsync(IPage page, int digit)
|
||||
{
|
||||
await page.Keyboard.PressAsync($"Digit{digit}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the terminal to be initialized (xterm.js loaded and terminal visible).
|
||||
/// </summary>
|
||||
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<bool>(@"() => {
|
||||
return window.terminalInterop?.term != null;
|
||||
}");
|
||||
if (ready)
|
||||
{
|
||||
// Verify terminal has proper dimensions (should be 120x30 after init)
|
||||
var cols = await page.EvaluateAsync<int>("() => 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");
|
||||
}
|
||||
}
|
||||
82
tests/OpenTheBox.Web.Tests/WebAppFixture.cs
Normal file
82
tests/OpenTheBox.Web.Tests/WebAppFixture.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System.Diagnostics;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace OpenTheBox.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixture that starts the Blazor WASM dev server and Playwright browser once per test collection.
|
||||
/// </summary>
|
||||
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<WebAppFixture>;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue