diff --git a/docs/agent-context/build-and-validation.md b/docs/agent-context/build-and-validation.md index 1cbdf398..364b282f 100644 --- a/docs/agent-context/build-and-validation.md +++ b/docs/agent-context/build-and-validation.md @@ -75,6 +75,7 @@ The current high-signal smoke covers: - main scan shell, semantic navigation, and the search control; - keyboard activation/focus through Settings and a click-driven About route; - desktop and 600px narrow settings rendering, responsive control swap, bounds, and horizontal overflow; +- result-card item art containment (portrait, landscape, square icons) and the Details user-action contract; - an ARIA snapshot of the important narrow settings structure; - uncaught page exceptions, browser console errors, failed app-resource requests, and app-resource HTTP 5xx responses. diff --git a/src/App/Pages/App/Index.razor b/src/App/Pages/App/Index.razor index 3ba028fa..4336092e 100644 --- a/src/App/Pages/App/Index.razor +++ b/src/App/Pages/App/Index.razor @@ -279,14 +279,14 @@ - @if (_detailsOpen) + @if (_uiState.DetailsOpen) {
@@ -324,7 +324,7 @@ class="copy-btn" title="@Localizer["CopyItemId"]" @onclick="CopyItemId"> - @(_copiedId ? Localizer["Copied"] : Localizer["Copy"]) + @(_uiState.CopiedId ? Localizer["Copied"] : Localizer["Copy"]) }
@@ -359,8 +359,7 @@ private string? _itemsLocale; private TarkovDev.GameMode? _itemsGameMode; private MudAutocomplete? _search; - private bool _detailsOpen; - private bool _copiedId; + private readonly ResultCardUiState _uiState = new(); // Perf sequence of the scan this render is showing, plus when its notification // arrived. Consumed after the next paint so the scan timeline records how long @@ -524,8 +523,9 @@ private void RefreshResult() { _result = MenuVM.LastItemScan.IsSeed ? null : ScanResultAdapter.Map(MenuVM); - _detailsOpen = false; - _copiedId = false; + // Scan-driven refreshes must not collapse a Details section the user opened; + // only explicit user actions (ToggleDetails, SelectRecent, SelectItem) change it. + _uiState.OnResultRefreshed(); } private async void OnResultChanged(object? sender, PropertyChangedEventArgs e) => @@ -559,8 +559,7 @@ private void SelectRecent(ScanResultViewModel scan) { _result = scan; - _detailsOpen = false; - _copiedId = false; + _uiState.OnItemSelected(); } private System.Threading.Tasks.Task> SearchItems(string value, CancellationToken cancellationToken) @@ -599,14 +598,17 @@ private void SelectItem(Item? item) { if (item is not null) + { + // Deliberate item switch: collapse any open Details section, matching + // SelectRecent. Scan-driven refreshes alone must not close it. + _uiState.OnItemSelected(); MenuVM.ItemScans.Enqueue(new DefaultItemScan(item)); + } } private void ToggleDetails() { - _detailsOpen = !_detailsOpen; - if (!_detailsOpen) - _copiedId = false; + _uiState.ToggleDetails(); } private async System.Threading.Tasks.Task FocusSearch() @@ -623,12 +625,12 @@ try { System.Windows.Clipboard.SetText(_result.Item.Id); - _copiedId = true; + _uiState.MarkCopied(); } catch { // Clipboard access can fail if the host window is not focused. - _copiedId = false; + _uiState.MarkCopyFailed(); } } diff --git a/src/App/Pages/App/Index.razor.css b/src/App/Pages/App/Index.razor.css index 899b71b5..59fde417 100644 --- a/src/App/Pages/App/Index.razor.css +++ b/src/App/Pages/App/Index.razor.css @@ -238,14 +238,22 @@ position: relative; display: grid; place-items: center; + overflow: hidden; border: 1px solid var(--rs-border-subtle); border-radius: 8px; background: var(--rs-surface-2); } .item-art img { - max-width: calc(100% - 10px); - max-height: calc(100% - 10px); + /* Deterministic frame fill: percentage max-width/max-height on a grid item + can leave the grid area height indefinite in Chromium, so tall (and even + square) intrinsic icons paint outside the frame. Absolute positioning + resolves the percentages against this fixed-size box; object-fit: contain + letterboxes without cropping or stretching any aspect ratio. */ + position: absolute; + inset: 5px; + width: calc(100% - 10px); + height: calc(100% - 10px); object-fit: contain; } diff --git a/src/App/Presentation/ResultCardUiState.cs b/src/App/Presentation/ResultCardUiState.cs new file mode 100644 index 00000000..696d3cfc --- /dev/null +++ b/src/App/Presentation/ResultCardUiState.cs @@ -0,0 +1,41 @@ +namespace RatScanner.Presentation; + +/// +/// Open/closed state of the scan result card's Details section together with the +/// copied-id label flag that lives on the same toggle. Centralized so the +/// user-choice rule is explicit and unit-testable: scan-driven result refreshes +/// must preserve the user's Details choice; only explicit user actions (the +/// toggle, recent-scan click, or search pick) may close it. +/// +internal sealed class ResultCardUiState +{ + public bool DetailsOpen { get; private set; } + + public bool CopiedId { get; private set; } + + /// + /// A new scan result arrived without an explicit user selection (auto scan, + /// game-mode refresh). The Details section must not collapse — the user may be + /// watching scan details while auto-scanning. The copied-id label is per-item, + /// so it resets. + /// + public void OnResultRefreshed() => CopiedId = false; + + /// The user deliberately switched to a different item (recent scan click or search pick). + public void OnItemSelected() + { + DetailsOpen = false; + CopiedId = false; + } + + public void ToggleDetails() + { + DetailsOpen = !DetailsOpen; + if (!DetailsOpen) + CopiedId = false; + } + + public void MarkCopied() => CopiedId = true; + + public void MarkCopyFailed() => CopiedId = false; +} diff --git a/tests/RatScanner.Tests/ResultCardUiStateTests.cs b/tests/RatScanner.Tests/ResultCardUiStateTests.cs new file mode 100644 index 00000000..66e450e5 --- /dev/null +++ b/tests/RatScanner.Tests/ResultCardUiStateTests.cs @@ -0,0 +1,101 @@ +#nullable enable + +using RatScanner.Presentation; +using Xunit; + +namespace RatScanner.Tests; + +/// +/// The Details section of the scan result card must preserve the user's choice +/// across scan-driven refreshes (auto scan enqueues fire MenuVM.PropertyChanged, +/// which re-maps the result) and close only on explicit user actions. Regression +/// coverage for the issue where Details auto-collapsed on every scan. +/// +public sealed class ResultCardUiStateTests +{ + [Fact] + public void Result_refresh_preserves_open_details() + { + var state = new ResultCardUiState(); + state.ToggleDetails(); + Assert.True(state.DetailsOpen); + + // The regression: RefreshResult() used to reset the flag on every scan. + state.OnResultRefreshed(); + + Assert.True(state.DetailsOpen); + } + + [Fact] + public void Result_refresh_resets_copied_label_but_not_details() + { + var state = new ResultCardUiState(); + state.ToggleDetails(); + state.MarkCopied(); + Assert.True(state.CopiedId); + + state.OnResultRefreshed(); + + Assert.False(state.CopiedId); + Assert.True(state.DetailsOpen); + } + + [Fact] + public void Item_selection_closes_details_even_when_user_opened_them() + { + var state = new ResultCardUiState(); + state.ToggleDetails(); + state.MarkCopied(); + + state.OnItemSelected(); + + Assert.False(state.DetailsOpen); + Assert.False(state.CopiedId); + } + + [Fact] + public void Toggle_details_flips_open_and_closed() + { + var state = new ResultCardUiState(); + Assert.False(state.DetailsOpen); + + state.ToggleDetails(); + Assert.True(state.DetailsOpen); + + state.ToggleDetails(); + Assert.False(state.DetailsOpen); + } + + [Fact] + public void Closing_details_via_toggle_clears_copied_label() + { + var state = new ResultCardUiState(); + state.ToggleDetails(); + state.MarkCopied(); + Assert.True(state.CopiedId); + + state.ToggleDetails(); + + Assert.False(state.CopiedId); + } + + [Fact] + public void Copy_result_flags_follow_success_and_failure() + { + var state = new ResultCardUiState(); + + state.MarkCopied(); + Assert.True(state.CopiedId); + + state.MarkCopyFailed(); + Assert.False(state.CopiedId); + } + + [Fact] + public void Fresh_state_starts_collapsed() + { + var state = new ResultCardUiState(); + Assert.False(state.DetailsOpen); + Assert.False(state.CopiedId); + } +} diff --git a/tests/RatScanner.UiTests/WebViewSmokeTests.cs b/tests/RatScanner.UiTests/WebViewSmokeTests.cs index 1ddaf63f..54304047 100644 --- a/tests/RatScanner.UiTests/WebViewSmokeTests.cs +++ b/tests/RatScanner.UiTests/WebViewSmokeTests.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -25,101 +26,18 @@ public sealed class WebViewSmokeTests public async Task Main_shell_navigation_and_responsive_layout_work_in_WebView2() { CancellationToken testCancellation = TestContext.Current.CancellationToken; - string repositoryRoot = FindRepositoryRoot(); - string configuration = GetConfiguration(); - string appDirectory = Path.Combine( - repositoryRoot, - "src", - "App", - "bin", - configuration, - "net10.0-windows10.0.22621.0" - ); - string appPath = Path.Combine(appDirectory, "RatScanner.exe"); - Assert.True(File.Exists(appPath), $"Build the {configuration} app before running UI tests: {appPath}"); - - Process[] existingProcesses = Process.GetProcessesByName("RatScanner"); - Assert.Empty(existingProcesses); - - string artifactRoot = - Environment.GetEnvironmentVariable("RATSCANNER_UI_ARTIFACTS") - ?? Path.Combine(repositoryRoot, "artifacts", "ui-tests"); - string runDirectory = Path.Combine( - artifactRoot, - $"{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}-{Guid.NewGuid():N}" - ); - Directory.CreateDirectory(runDirectory); - - string profileDirectory = Path.Combine(Path.GetTempPath(), $"RatScanner-ui-{Guid.NewGuid():N}"); - Directory.CreateDirectory(profileDirectory); - - Process? app = null; - Task? stdoutTask = null; - Task? stderrTask = null; - IPlaywright? playwright = null; - IBrowser? browser = null; - IBrowserContext? context = null; - IPage? page = null; - bool traceStarted = false; - bool failed = false; - Exception? testFailure = null; - ConcurrentQueue runtimeFailures = new(); - + await using UiSession session = await UiSession.StartAsync(testCancellation); try { - ProcessStartInfo startInfo = new(appPath) - { - WorkingDirectory = appDirectory, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - startInfo.Environment["RATSCANNER_UI_TEST_CDP_PORT"] = "0"; - startInfo.Environment["RATSCANNER_UI_TEST_PROFILE"] = profileDirectory; - - app = Process.Start(startInfo); - Assert.NotNull(app); - stdoutTask = app.StandardOutput.ReadToEndAsync(testCancellation); - stderrTask = app.StandardError.ReadToEndAsync(testCancellation); - - int port = await WaitForDebugPortAsync(profileDirectory, app, StartupTimeout); - File.WriteAllText(Path.Combine(runDirectory, "endpoint.txt"), $"http://127.0.0.1:{port}"); - - playwright = await Playwright.CreateAsync(); - float slowMo = ParseSlowMo(); - browser = await playwright.Chromium.ConnectOverCDPAsync( - $"http://127.0.0.1:{port}", - new BrowserTypeConnectOverCDPOptions - { - ArtifactsDir = runDirectory, - SlowMo = slowMo, - Timeout = (float)StartupTimeout.TotalMilliseconds, - } - ); - context = Assert.Single(browser.Contexts); - await context.Tracing.StartAsync( - new TracingStartOptions - { - Screenshots = true, - Snapshots = true, - Sources = true, - } - ); - traceStarted = true; - - page = await WaitForAppPageAsync(context, app, StartupTimeout); - AttachRuntimeDiagnostics(page, runtimeFailures); - - await ResizeAppWindowAsync(app, page, width: 1100, height: 850); + IPage page = session.Page; await page.Locator(".scan-page") .WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 120_000 }); await AssertVisibleAsync(page.Locator(".rs-search-field input")); await AssertVisibleAsync(page.GetByRole(AriaRole.Navigation).First); + await session.ResizeAsync(width: 1100, height: 850); await AssertNoHorizontalOverflowAsync(page, "desktop scan page"); - await page.ScreenshotAsync( - new PageScreenshotOptions { Path = Path.Combine(runDirectory, "desktop-scan.png"), FullPage = true } - ); + await session.ScreenshotAsync("desktop-scan.png"); ILocator settingsLink = page.Locator("a[href='/app/settings/general']"); await settingsLink.FocusAsync(); @@ -163,15 +81,9 @@ await page.ScreenshotAsync( Assert.Equal(0, await page.Locator("a[href*='tarkovtracker.io' i]").CountAsync()); await AssertNoHorizontalOverflowAsync(page, "desktop tracking settings"); await seasonalTrackingHeading.ScrollIntoViewIfNeededAsync(); - await page.ScreenshotAsync( - new PageScreenshotOptions - { - Path = Path.Combine(runDirectory, "desktop-seasonal-tracking.png"), - FullPage = false, - } - ); + await session.ScreenshotAsync("desktop-seasonal-tracking.png", fullPage: false); - await ResizeAppWindowAsync(app, page, width: 600, height: 850); + await session.ResizeAsync(width: 600, height: 850); await page.Locator(".sidebar.overlay") .WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Attached }); await page.WaitForFunctionAsync( @@ -194,14 +106,8 @@ await AssertInsideViewportAsync( width: trackingViewportWidth, surface: "narrow tracking settings" ); - await page.ScreenshotAsync( - new PageScreenshotOptions - { - Path = Path.Combine(runDirectory, "narrow-tracking-settings.png"), - FullPage = false, - } - ); - await ResizeAppWindowAsync(app, page, width: 1100, height: 850); + await session.ScreenshotAsync("narrow-tracking-settings.png", fullPage: false); + await session.ResizeAsync(width: 1100, height: 850); ILocator aboutLink = page.Locator("a[href='/app/credits']"); await aboutLink.ClickAsync(); @@ -216,7 +122,7 @@ await page.ScreenshotAsync( await page.Locator("a[href='/app/settings/general']").ClickAsync(); await page.WaitForURLAsync("**/app/settings/general"); - await ResizeAppWindowAsync(app, page, width: 600, height: 850); + await session.ResizeAsync(width: 600, height: 850); await page.Locator(".sidebar.overlay") .WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Attached }); await page.WaitForFunctionAsync( @@ -244,230 +150,188 @@ await AssertInsideViewportAsync( width: viewportWidth, surface: "narrow settings page" ); - await page.ScreenshotAsync( - new PageScreenshotOptions - { - Path = Path.Combine(runDirectory, "narrow-settings.png"), - // Full-page capture includes the transformed-offscreen drawer in the image bounds. - // Capture the real narrow viewport so the visual artifact matches what a user sees. - FullPage = false, - } + await session.ScreenshotAsync( + "narrow-settings.png", + // Full-page capture includes the transformed-offscreen drawer in the image bounds. + // Capture the real narrow viewport so the visual artifact matches what a user sees. + fullPage: false ); string ariaSnapshot = await page.Locator("body").AriaSnapshotAsync(); await File.WriteAllTextAsync( - Path.Combine(runDirectory, "accessibility.yml"), + Path.Combine(session.RunDirectory, "accessibility.yml"), ariaSnapshot, testCancellation ); - await File.WriteAllTextAsync(Path.Combine(runDirectory, "current-url.txt"), page.Url, testCancellation); + await File.WriteAllTextAsync( + Path.Combine(session.RunDirectory, "current-url.txt"), + page.Url, + testCancellation + ); - Assert.Empty(runtimeFailures); + Assert.Empty(session.RuntimeFailures); } catch (Exception exception) { - failed = true; - testFailure = exception; - if (page is not null) - { - try - { - File.WriteAllText(Path.Combine(runDirectory, "failure-url.txt"), page.Url); - await CaptureFailureEvidenceAsync(page, runDirectory); - } - catch (Exception captureException) - { - TryWriteDiagnostic( - Path.Combine(runDirectory, "failure-capture-error.txt"), - captureException.ToString() - ); - } - } - } - - List cleanupFailures = []; - await RecordCleanupFailureAsync( - cleanupFailures, - async () => - { - if (!runtimeFailures.IsEmpty) - await File.WriteAllLinesAsync(Path.Combine(runDirectory, "browser-runtime.log"), runtimeFailures); - } - ); - - if (traceStarted && context is not null) - { - await RecordCleanupFailureAsync( - cleanupFailures, - async () => - { - await context.Tracing.StopAsync( - failed ? new TracingStopOptions { Path = Path.Combine(runDirectory, "trace.zip") } : null - ); - } - ); - RecordCleanupFailure( - cleanupFailures, - () => - { - foreach (string transientTrace in Directory.EnumerateFiles(runDirectory, "*.trace")) - File.Delete(transientTrace); - foreach (string transientNetwork in Directory.EnumerateFiles(runDirectory, "*.network")) - File.Delete(transientNetwork); - } - ); - } - - if (browser is not null) - await RecordCleanupFailureAsync(cleanupFailures, async () => await browser.DisposeAsync()); - if (playwright is not null) - RecordCleanupFailure(cleanupFailures, playwright.Dispose); - - if (app is not null) - { - await RecordCleanupFailureAsync(cleanupFailures, async () => await StopOwnedProcessAsync(app)); - bool appExited = false; - RecordCleanupFailure( - cleanupFailures, - () => - { - app.Refresh(); - appExited = app.HasExited; - } - ); - if (appExited) - { - if (stdoutTask is not null) - await RecordCleanupFailureAsync( - cleanupFailures, - async () => - await File.WriteAllTextAsync(Path.Combine(runDirectory, "app-stdout.log"), await stdoutTask) - ); - if (stderrTask is not null) - await RecordCleanupFailureAsync( - cleanupFailures, - async () => - await File.WriteAllTextAsync(Path.Combine(runDirectory, "app-stderr.log"), await stderrTask) - ); - } + await session.MarkFailedAsync(exception); + throw; } + } - string appLog = Path.Combine(appDirectory, "Log.txt"); - if (File.Exists(appLog)) - RecordCleanupFailure( - cleanupFailures, - () => File.Copy(appLog, Path.Combine(runDirectory, "RatScanner.log"), overwrite: true) - ); - + [Fact] + [Trait("Category", "UI")] + public async Task Scan_result_card_contains_non_square_icons_and_details_state_follows_user_actions() + { + // Seed a tiny offline catalog (Pevko 1x2 portrait, Makarov PM 2x1 landscape, + // M4A1 1x1 square, all with locally installed icons) plus empty tasks/hideout/ + // crafts/barters/maps caches, so startup and the search-driven result card run + // hermetically — no network and no dependency on a developer cache. The run + // directory is created first so the seed's failure diagnostics land in the run's + // own artifact folder. + string runDirectory = UiSession.CreateRunDirectory(); + using CatalogCacheSeed catalogSeed = new(runDirectory); + CancellationToken testCancellation = TestContext.Current.CancellationToken; + await using UiSession session = await UiSession.StartAsync(testCancellation, runDirectory); try { - Directory.Delete(profileDirectory, recursive: true); - } - catch (IOException exception) - { - TryWriteDiagnostic(Path.Combine(runDirectory, "profile-cleanup-error.txt"), exception.ToString()); + IPage page = session.Page; + await page.Locator(".scan-page") + .WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 120_000 }); + await session.ResizeAsync(width: 1100, height: 850); + + ILocator detailsToggle = page.Locator(".details-toggle"); + + // Portrait non-square item (1x2, 64x127 local icon) must stay inside the frame. + await SelectCatalogItemAsync(page, "Pevko"); + await AssertVisibleAsync(page.Locator(".result-card")); + await WaitForArtImageLoadedAsync(page); + await AssertArtContainedAsync(page, "desktop portrait (Pevko)"); + await session.ScreenshotAsync("result-card-pevko.png"); + await WaitForDetailsStateAsync(page, expanded: false); + + // Opening Details is a toggle, and a fresh scan-driven render must not close it. + await detailsToggle.ClickAsync(); + await WaitForDetailsStateAsync(page, expanded: true); + await AssertVisibleAsync(page.Locator(".details")); + await session.ScreenshotAsync("result-card-details-open.png"); + + // Deliberately picking a different item from search is an explicit switch: + // it must close Details again, and the landscape icon must stay contained. + await SelectCatalogItemAsync(page, "PM"); + await WaitForArtImageLoadedAsync(page); + await WaitForArtAltAsync(page, "Makarov PM 9x18PM pistol"); + await WaitForDetailsStateAsync(page, expanded: false); + await AssertArtContainedAsync(page, "desktop landscape (PM)"); + await session.ScreenshotAsync("result-card-pm.png"); + + // Clicking a recent scan is an explicit switch too: it closes Details. + await detailsToggle.ClickAsync(); + await WaitForDetailsStateAsync(page, expanded: true); + ILocator recentThumb = page.Locator(".recent .thumb").First; + Assert.True(await recentThumb.CountAsync() > 0, "Recent scans should list the scanned items."); + await recentThumb.ClickAsync(); + await WaitForDetailsStateAsync(page, expanded: false); + + // Square icons must stay contained as well. + await SelectCatalogItemAsync(page, "M4A1"); + await WaitForArtImageLoadedAsync(page); + await WaitForArtAltAsync(page, "Colt M4A1 5.56x45 assault rifle"); + await AssertArtContainedAsync(page, "desktop square (M4A1)"); + + // Narrow breakpoint: portrait icon still contained inside the 72x54 frame. + await session.ResizeAsync(width: 600, height: 850); + await SelectCatalogItemAsync(page, "Pevko"); + await WaitForArtImageLoadedAsync(page); + await WaitForArtAltAsync(page, "Bottle of Pevko Light beer"); + await AssertArtContainedAsync(page, "narrow portrait (Pevko)"); + await session.ScreenshotAsync("result-card-narrow.png"); + + Assert.Empty(session.RuntimeFailures); } - catch (UnauthorizedAccessException exception) + catch (Exception exception) { - TryWriteDiagnostic(Path.Combine(runDirectory, "profile-cleanup-error.txt"), exception.ToString()); + await session.MarkFailedAsync(exception); + throw; } - - if (cleanupFailures.Count > 0) - TryWriteDiagnostic( - Path.Combine(runDirectory, "cleanup-errors.log"), - string.Join(Environment.NewLine + Environment.NewLine, cleanupFailures) - ); - - if (testFailure is not null && cleanupFailures.Count > 0) - throw new AggregateException("UI smoke failed and cleanup also failed.", [testFailure, .. cleanupFailures]); - if (testFailure is not null) - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(testFailure).Throw(); - if (cleanupFailures.Count > 0) - throw new AggregateException("UI smoke cleanup failed.", cleanupFailures); } - private static async Task WaitForDebugPortAsync(string profileDirectory, Process app, TimeSpan timeout) + private static async Task SelectCatalogItemAsync(IPage page, string query) { - DateTime deadline = DateTime.UtcNow + timeout; - while (DateTime.UtcNow < deadline) - { - app.Refresh(); - if (app.HasExited) - throw new InvalidOperationException($"RatScanner exited during startup with code {app.ExitCode}."); - - try - { - string? portFile = Directory - .EnumerateFiles(profileDirectory, "DevToolsActivePort", SearchOption.AllDirectories) - .FirstOrDefault(); - if (portFile is not null) + ILocator searchInput = page.Locator(".rs-search-field input"); + await searchInput.FillAsync(string.Empty); + await searchInput.FillAsync(query); + // MudAutocomplete renders one .mud-list-item per result whose ShortName sits in a + // ; match that exactly so a query like "PM" cannot land on another item + // whose Name merely contains the text (HasText is a substring match). + ILocator option = page.Locator(".mud-list-item") + .Filter( + new LocatorFilterOptions { - string? firstLine = (await File.ReadAllLinesAsync(portFile)).FirstOrDefault(); - if (int.TryParse(firstLine, NumberStyles.None, CultureInfo.InvariantCulture, out int port)) - return port; + Has = page.Locator(".search-result small") + .GetByText(query, new LocatorGetByTextOptions { Exact = true }), } - } - catch (IOException) - { - // Chromium can still be creating or replacing the readiness file; retry until the deadline. - } - catch (UnauthorizedAccessException) - { - // The profile tree can be transiently locked while WebView2 initializes; retry. - } - - await Task.Delay(250); - } - - throw new TimeoutException($"WebView2 did not publish DevToolsActivePort within {timeout}."); + ) + .First; + await option.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 }); + await option.ClickAsync(); } - private static async Task WaitForAppPageAsync(IBrowserContext context, Process app, TimeSpan timeout) + private static async Task WaitForArtImageLoadedAsync(IPage page) { - DateTime deadline = DateTime.UtcNow + timeout; - while (DateTime.UtcNow < deadline) - { - app.Refresh(); - if (app.HasExited) - throw new InvalidOperationException( - $"RatScanner exited before the /app WebView loaded: {app.ExitCode}." - ); - - IPage? page = context.Pages.FirstOrDefault(candidate => - Uri.TryCreate(candidate.Url, UriKind.Absolute, out Uri? uri) && uri.AbsolutePath == "/app" - ); - if (page is not null) - return page; - - await Task.Delay(250); - } + await page.WaitForFunctionAsync( + """ + () => { + const img = document.querySelector('.item-art img'); + return img !== null && img.complete && img.naturalWidth > 0; + } + """ + ); + } - throw new TimeoutException("RatScanner's /app WebView target did not become ready."); + private static async Task WaitForArtAltAsync(IPage page, string expectedAlt) + { + await page.WaitForFunctionAsync( + """ + (expected) => { + const img = document.querySelector('.item-art img'); + return img !== null && img.getAttribute('alt') === expected; + } + """, + expectedAlt + ); } - private static void AttachRuntimeDiagnostics(IPage page, ConcurrentQueue failures) + private static async Task WaitForDetailsStateAsync(IPage page, bool expanded) { - page.Console += (_, message) => - { - if (message.Type == "error") - failures.Enqueue($"console error: {message.Text}"); - }; - page.PageError += (_, error) => failures.Enqueue($"uncaught page error: {error}"); - page.RequestFailed += (_, request) => - { - if (IsAppResource(request.Url)) - failures.Enqueue($"failed app request: {request.Method} {request.Url} ({request.Failure})"); - }; - page.Response += (_, response) => - { - if (response.Status >= 500 && IsAppResource(response.Url)) - failures.Enqueue($"app response {response.Status}: {response.Url}"); - }; + string expected = expanded ? "true" : "false"; + await page.WaitForFunctionAsync( + """ + (expected) => { + const toggle = document.querySelector('.details-toggle'); + return toggle !== null && toggle.getAttribute('aria-expanded') === expected; + } + """, + expected + ); } - private static bool IsAppResource(string url) => - Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) - && (uri.Host is "0.0.0.1" or "local.data" || uri.Scheme == "file"); + private static async Task AssertArtContainedAsync(IPage page, string surface) + { + double[] box = await page.EvaluateAsync( + """ + () => { + const frame = document.querySelector('.item-art').getBoundingClientRect(); + const img = document.querySelector('.item-art img').getBoundingClientRect(); + return [frame.left, frame.top, frame.right, frame.bottom, img.left, img.top, img.right, img.bottom]; + } + """ + ); + Assert.True( + box[4] >= box[0] - 0.5 && box[5] >= box[1] - 0.5 && box[6] <= box[2] + 0.5 && box[7] <= box[3] + 0.5, + $"{surface}: item art image overflows the frame (frame [{box[0]:0.##},{box[1]:0.##},{box[2]:0.##},{box[3]:0.##}] " + + $"vs img [{box[4]:0.##},{box[5]:0.##},{box[6]:0.##},{box[7]:0.##}])." + ); + } private static async Task AssertNoHorizontalOverflowAsync(IPage page, string surface) { @@ -505,50 +369,46 @@ private static async Task AssertInsideViewportAsync(ILocator locator, int width, Assert.True(bounds.X + bounds.Width <= width, $"{surface} extends beyond the {width}px viewport."); } - private static async Task ResizeAppWindowAsync(Process app, IPage page, int width, int height) + private static float ParseSlowMo() => + float.TryParse( + Environment.GetEnvironmentVariable("RATSCANNER_UI_SLOWMO_MS"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out float slowMo + ) + ? Math.Max(0, slowMo) + : 0; + + private static string GetConfiguration() { - DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); - nint window = 0; - while (DateTime.UtcNow < deadline) - { - app.Refresh(); - window = FindLargestOwnedWindow(app.Id); - if (window != 0) - break; - if (app.HasExited) - break; - await Task.Delay(100); - } - - if (window == 0) - throw new InvalidOperationException("RatScanner did not expose a main window handle."); - NativeMethods.ShowWindow(window, NativeMethods.RestoreWindow); - uint dpi = NativeMethods.GetDpiForWindow(window); - if (dpi == 0) - throw new InvalidOperationException( - $"Unable to determine RatScanner window DPI (Win32 error {Marshal.GetLastWin32Error()})." - ); - int deviceWidth = checked((int)Math.Round(width * dpi / 96d, MidpointRounding.AwayFromZero)); - int deviceHeight = checked((int)Math.Round(height * dpi / 96d, MidpointRounding.AwayFromZero)); - int viewportWidth = await page.EvaluateAsync("window.innerWidth"); - DateTime resizeDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); - while (DateTime.UtcNow < resizeDeadline) - { - if (!NativeMethods.SetWindowPos(window, 0, 0, 0, deviceWidth, deviceHeight, NativeMethods.ResizeOnlyFlags)) - throw new InvalidOperationException( - $"Unable to resize RatScanner for UI validation (Win32 error {Marshal.GetLastWin32Error()})." - ); + DirectoryInfo targetFramework = new(AppContext.BaseDirectory); + return targetFramework.Parent?.Name + ?? throw new DirectoryNotFoundException("Could not determine the test build configuration."); + } - viewportWidth = await page.EvaluateAsync("window.innerWidth"); - bool expectedBreakpoint = width <= 680 ? viewportWidth <= 680 : viewportWidth > 680; - if (expectedBreakpoint) - return; - await Task.Delay(100); + private static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "RatScanner.sln"))) + return directory.FullName; + directory = directory.Parent; } - throw new InvalidOperationException( - $"RatScanner did not reach the requested {width}px responsive viewport; actual width was {viewportWidth}px." - ); + throw new DirectoryNotFoundException("Could not locate the repository root."); + } + + private static void TryWriteDiagnostic(string path, string contents) + { + try + { + File.WriteAllText(path, contents); + } + catch (Exception) + { + // Diagnostics are best effort and must not replace the original test or cleanup failure. + } } private static async Task RecordCleanupFailureAsync(List failures, Func cleanup) @@ -575,124 +435,885 @@ private static void RecordCleanupFailure(List failures, Action cleanu } } - private static void TryWriteDiagnostic(string path, string contents) + /// + /// Owns a launched RatScanner process (isolated WebView2 profile), the CDP + /// connection used to drive it, and every artifact/cleanup responsibility of a + /// UI run. The single-instance rule is enforced at startup: the test must never + /// attach to or stop an unrelated RatScanner instance. + /// + private sealed class UiSession : IAsyncDisposable { - try + private readonly Process _app; + private readonly Task _stdoutTask; + private readonly Task _stderrTask; + private readonly IPlaywright _playwright; + private readonly IBrowser _browser; + private readonly IBrowserContext _context; + private readonly string _profileDirectory; + private readonly string _appLogPath; + private readonly ConcurrentQueue _runtimeFailures; + private readonly CancellationToken _cancellationToken; + private bool _traceStarted; + private bool _failed; + private bool _disposed; + + internal IPage Page { get; } + + internal string RunDirectory { get; } + + internal ConcurrentQueue RuntimeFailures => _runtimeFailures; + + private UiSession( + Process app, + Task stdoutTask, + Task stderrTask, + IPlaywright playwright, + IBrowser browser, + IBrowserContext context, + IPage page, + string runDirectory, + string profileDirectory, + string appLogPath, + ConcurrentQueue runtimeFailures, + bool traceStarted, + CancellationToken cancellationToken + ) { - File.WriteAllText(path, contents); + _app = app; + _stdoutTask = stdoutTask; + _stderrTask = stderrTask; + _playwright = playwright; + _browser = browser; + _context = context; + Page = page; + RunDirectory = runDirectory; + _profileDirectory = profileDirectory; + _appLogPath = appLogPath; + _runtimeFailures = runtimeFailures; + _cancellationToken = cancellationToken; + _traceStarted = traceStarted; } - catch (Exception) + + internal static async Task StartAsync( + CancellationToken cancellationToken, + string? runDirectory = null + ) { - // Diagnostics are best effort and must not replace the original test or cleanup failure. + string repositoryRoot = FindRepositoryRoot(); + string configuration = GetConfiguration(); + string appDirectory = Path.Combine( + repositoryRoot, + "src", + "App", + "bin", + configuration, + "net10.0-windows10.0.22621.0" + ); + string appPath = Path.Combine(appDirectory, "RatScanner.exe"); + Assert.True(File.Exists(appPath), $"Build the {configuration} app before running UI tests: {appPath}"); + + Process[] existingProcesses = Process.GetProcessesByName("RatScanner"); + Assert.Empty(existingProcesses); + + runDirectory ??= CreateRunDirectory(); + + string profileDirectory = Path.Combine(Path.GetTempPath(), $"RatScanner-ui-{Guid.NewGuid():N}"); + Directory.CreateDirectory(profileDirectory); + + ProcessStartInfo startInfo = new(appPath) + { + WorkingDirectory = appDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + startInfo.Environment["RATSCANNER_UI_TEST_CDP_PORT"] = "0"; + startInfo.Environment["RATSCANNER_UI_TEST_PROFILE"] = profileDirectory; + + Process? startedApp = Process.Start(startInfo); + Assert.NotNull(startedApp); + Process app = startedApp; + Task stdoutTask = app.StandardOutput.ReadToEndAsync(cancellationToken); + Task stderrTask = app.StandardError.ReadToEndAsync(cancellationToken); + + IPlaywright? playwright = null; + IBrowser? browser = null; + try + { + int port = await WaitForDebugPortAsync(profileDirectory, app, StartupTimeout); + await File.WriteAllTextAsync( + Path.Combine(runDirectory, "endpoint.txt"), + $"http://127.0.0.1:{port}", + cancellationToken + ); + + playwright = await Playwright.CreateAsync(); + float slowMo = ParseSlowMo(); + browser = await playwright.Chromium.ConnectOverCDPAsync( + $"http://127.0.0.1:{port}", + new BrowserTypeConnectOverCDPOptions + { + ArtifactsDir = runDirectory, + SlowMo = slowMo, + Timeout = (float)StartupTimeout.TotalMilliseconds, + } + ); + IBrowserContext context = Assert.Single(browser.Contexts); + await context.Tracing.StartAsync( + new TracingStartOptions + { + Screenshots = true, + Snapshots = true, + Sources = true, + } + ); + + IPage page = await WaitForAppPageAsync(context, app, StartupTimeout); + + return new UiSession( + app, + stdoutTask, + stderrTask, + playwright, + browser, + context, + page, + runDirectory, + profileDirectory, + Path.Combine(appDirectory, "Log.txt"), + AttachRuntimeDiagnostics(page), + traceStarted: true, + cancellationToken + ); + } + catch + { + // One failure path for the whole post-start sequence: stop the owned process, + // release any Playwright/browser resources created so far, and remove the + // temporary profile directory. Cleanup is best effort so the original startup + // failure stays the exception the caller sees. + try + { + await StopOwnedProcessAsync(app); + } + catch + { + // Best effort; the startup failure is the reportable one. + } + + if (browser is not null) + { + try + { + await browser.DisposeAsync(); + } + catch + { + // Best effort; the startup failure is the reportable one. + } + } + + if (playwright is not null) + { + try + { + playwright.Dispose(); + } + catch + { + // Best effort; the startup failure is the reportable one. + } + } + + try + { + Directory.Delete(profileDirectory, recursive: true); + } + catch (IOException) + { + // Best effort; the startup failure is the reportable one. + } + catch (UnauthorizedAccessException) + { + // Best effort; the startup failure is the reportable one. + } + + throw; + } } - } - private static nint FindLargestOwnedWindow(int expectedProcessId) - { - nint selected = 0; - long largestArea = 0; - for ( - nint candidate = NativeMethods.GetTopWindow(0); - candidate != 0; - candidate = NativeMethods.GetWindow(candidate, 2) - ) + /// Creates the run's artifact directory under artifacts/ui-tests (or the + /// RATSCANNER_UI_ARTIFACTS override). + internal static string CreateRunDirectory() { - uint threadId = NativeMethods.GetWindowThreadProcessId(candidate, out uint processId); - if (threadId == 0 || processId != expectedProcessId || !NativeMethods.IsWindowVisible(candidate)) - continue; - if (GetWindowTitle(candidate).Contains("Overlay", StringComparison.OrdinalIgnoreCase)) - continue; - if (!NativeMethods.GetWindowRect(candidate, out NativeMethods.WindowRect bounds)) - continue; + string artifactRoot = + Environment.GetEnvironmentVariable("RATSCANNER_UI_ARTIFACTS") + ?? Path.Combine(FindRepositoryRoot(), "artifacts", "ui-tests"); + string runDirectory = Path.Combine( + artifactRoot, + $"{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}-{Guid.NewGuid():N}" + ); + Directory.CreateDirectory(runDirectory); + return runDirectory; + } - long area = Math.Max(0, bounds.Right - bounds.Left) * (long)Math.Max(0, bounds.Bottom - bounds.Top); - if (area > largestArea) + internal async Task ResizeAsync(int width, int height) + { + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + nint window = 0; + while (DateTime.UtcNow < deadline) { - largestArea = area; - selected = candidate; + _app.Refresh(); + window = FindLargestOwnedWindow(_app.Id); + if (window != 0) + break; + if (_app.HasExited) + break; + await Task.Delay(100, _cancellationToken); } + + if (window == 0) + throw new InvalidOperationException("RatScanner did not expose a main window handle."); + NativeMethods.ShowWindow(window, NativeMethods.RestoreWindow); + uint dpi = NativeMethods.GetDpiForWindow(window); + if (dpi == 0) + throw new InvalidOperationException( + $"Unable to determine RatScanner window DPI (Win32 error {Marshal.GetLastWin32Error()})." + ); + int deviceWidth = checked((int)Math.Round(width * dpi / 96d, MidpointRounding.AwayFromZero)); + int deviceHeight = checked((int)Math.Round(height * dpi / 96d, MidpointRounding.AwayFromZero)); + int viewportWidth = await Page.EvaluateAsync("window.innerWidth").WaitAsync(_cancellationToken); + DateTime resizeDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); + while (DateTime.UtcNow < resizeDeadline) + { + if ( + !NativeMethods.SetWindowPos( + window, + 0, + 0, + 0, + deviceWidth, + deviceHeight, + NativeMethods.ResizeOnlyFlags + ) + ) + throw new InvalidOperationException( + $"Unable to resize RatScanner for UI validation (Win32 error {Marshal.GetLastWin32Error()})." + ); + + viewportWidth = await Page.EvaluateAsync("window.innerWidth").WaitAsync(_cancellationToken); + bool expectedBreakpoint = width <= 680 ? viewportWidth <= 680 : viewportWidth > 680; + if (expectedBreakpoint) + return; + await Task.Delay(100, _cancellationToken); + } + + throw new InvalidOperationException( + $"RatScanner did not reach the requested {width}px responsive viewport; actual width was {viewportWidth}px." + ); } - return selected; - } - private static string GetWindowTitle(nint window) - { - int length = NativeMethods.GetWindowTextLength(window); - if (length == 0) - return string.Empty; - StringBuilder title = new(length + 1); - return NativeMethods.GetWindowText(window, title, title.Capacity) == 0 ? string.Empty : title.ToString(); - } + internal async Task ScreenshotAsync(string fileName, bool fullPage = true) + { + await Page.ScreenshotAsync( + new PageScreenshotOptions { Path = Path.Combine(RunDirectory, fileName), FullPage = fullPage } + ); + } - private static async Task CaptureFailureEvidenceAsync(IPage page, string runDirectory) - { - try + /// Captures failure evidence; the session keeps the trace for diagnosis. + internal async Task MarkFailedAsync(Exception exception) { - await page.ScreenshotAsync( - new PageScreenshotOptions { Path = Path.Combine(runDirectory, "failure.png"), FullPage = true } + _failed = true; + try + { + await File.WriteAllTextAsync( + Path.Combine(RunDirectory, "failure-url.txt"), + Page.Url, + _cancellationToken + ); + await File.WriteAllTextAsync( + Path.Combine(RunDirectory, "failure-page-state.txt"), + await Page.EvaluateAsync( + """ + () => { + const toggle = document.querySelector('.details-toggle'); + const card = document.querySelector('.result-card'); + return JSON.stringify({ + hidden: document.hidden, + visibilityState: document.visibilityState, + title: document.title, + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + toggleExists: toggle !== null, + ariaExpanded: toggle ? toggle.getAttribute('aria-expanded') : null, + detailsVisible: !!document.querySelector('.details'), + resultCard: card !== null, + artAlt: document.querySelector('.item-art img')?.getAttribute('alt') ?? null, + }); + } + """ + ) + ); + await CaptureFailureEvidenceAsync(Page, RunDirectory); + } + catch (Exception captureException) + { + TryWriteDiagnostic( + Path.Combine(RunDirectory, "failure-capture-error.txt"), + captureException.ToString() + ); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + _disposed = true; + + List cleanupFailures = []; + await RecordCleanupFailureAsync( + cleanupFailures, + async () => + { + if (!RuntimeFailures.IsEmpty) + await File.WriteAllLinesAsync( + Path.Combine(RunDirectory, "browser-runtime.log"), + RuntimeFailures + ); + } ); - await File.WriteAllTextAsync(Path.Combine(runDirectory, "failure-dom.html"), await page.ContentAsync()); - await File.WriteAllTextAsync( - Path.Combine(runDirectory, "failure-accessibility.yml"), - await page.Locator("body").AriaSnapshotAsync() + + if (_traceStarted) + { + await RecordCleanupFailureAsync( + cleanupFailures, + async () => + { + await _context.Tracing.StopAsync( + _failed ? new TracingStopOptions { Path = Path.Combine(RunDirectory, "trace.zip") } : null + ); + } + ); + RecordCleanupFailure( + cleanupFailures, + () => + { + foreach (string transientTrace in Directory.EnumerateFiles(RunDirectory, "*.trace")) + File.Delete(transientTrace); + foreach (string transientNetwork in Directory.EnumerateFiles(RunDirectory, "*.network")) + File.Delete(transientNetwork); + } + ); + } + + await RecordCleanupFailureAsync(cleanupFailures, async () => await _browser.DisposeAsync()); + RecordCleanupFailure(cleanupFailures, _playwright.Dispose); + + await RecordCleanupFailureAsync(cleanupFailures, async () => await StopOwnedProcessAsync(_app)); + bool appExited = false; + RecordCleanupFailure( + cleanupFailures, + () => + { + _app.Refresh(); + appExited = _app.HasExited; + } ); + if (appExited) + { + await RecordCleanupFailureAsync( + cleanupFailures, + async () => + await File.WriteAllTextAsync(Path.Combine(RunDirectory, "app-stdout.log"), await _stdoutTask) + ); + await RecordCleanupFailureAsync( + cleanupFailures, + async () => + await File.WriteAllTextAsync(Path.Combine(RunDirectory, "app-stderr.log"), await _stderrTask) + ); + } + + if (File.Exists(_appLogPath)) + { + RecordCleanupFailure( + cleanupFailures, + () => File.Copy(_appLogPath, Path.Combine(RunDirectory, "RatScanner.log"), overwrite: true) + ); + } + + try + { + Directory.Delete(_profileDirectory, recursive: true); + } + catch (IOException exception) + { + TryWriteDiagnostic(Path.Combine(RunDirectory, "profile-cleanup-error.txt"), exception.ToString()); + } + catch (UnauthorizedAccessException exception) + { + TryWriteDiagnostic(Path.Combine(RunDirectory, "profile-cleanup-error.txt"), exception.ToString()); + } + + if (cleanupFailures.Count > 0) + { + TryWriteDiagnostic( + Path.Combine(RunDirectory, "cleanup-errors.log"), + string.Join(Environment.NewLine + Environment.NewLine, cleanupFailures) + ); + } } - catch (PlaywrightException exception) + + private static async Task WaitForDebugPortAsync(string profileDirectory, Process app, TimeSpan timeout) { - await File.WriteAllTextAsync(Path.Combine(runDirectory, "failure-capture-error.txt"), exception.ToString()); + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + app.Refresh(); + if (app.HasExited) + throw new InvalidOperationException($"RatScanner exited during startup with code {app.ExitCode}."); + + try + { + string? portFile = Directory + .EnumerateFiles(profileDirectory, "DevToolsActivePort", SearchOption.AllDirectories) + .FirstOrDefault(); + if (portFile is not null) + { + string? firstLine = (await File.ReadAllLinesAsync(portFile)).FirstOrDefault(); + if (int.TryParse(firstLine, NumberStyles.None, CultureInfo.InvariantCulture, out int port)) + return port; + } + } + catch (IOException) + { + // Chromium can still be creating or replacing the readiness file; retry until the deadline. + } + catch (UnauthorizedAccessException) + { + // The profile tree can be transiently locked while WebView2 initializes; retry. + } + + await Task.Delay(250); + } + + throw new TimeoutException($"WebView2 did not publish DevToolsActivePort within {timeout}."); } - } - private static async Task StopOwnedProcessAsync(Process process) - { - process.Refresh(); - if (process.HasExited) - return; + private static async Task WaitForAppPageAsync(IBrowserContext context, Process app, TimeSpan timeout) + { + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + app.Refresh(); + if (app.HasExited) + throw new InvalidOperationException( + $"RatScanner exited before the /app WebView loaded: {app.ExitCode}." + ); - process.CloseMainWindow(); - using CancellationTokenSource gracefulTimeout = new(TimeSpan.FromSeconds(5)); - try + IPage? page = context.Pages.FirstOrDefault(candidate => + Uri.TryCreate(candidate.Url, UriKind.Absolute, out Uri? uri) && uri.AbsolutePath == "/app" + ); + if (page is not null) + return page; + + await Task.Delay(250); + } + + throw new TimeoutException("RatScanner's /app WebView target did not become ready."); + } + + private static ConcurrentQueue AttachRuntimeDiagnostics(IPage page) { - await process.WaitForExitAsync(gracefulTimeout.Token); - return; + ConcurrentQueue failures = new(); + page.Console += (_, message) => + { + if (message.Type == "error") + failures.Enqueue($"console error: {message.Text}"); + }; + page.PageError += (_, error) => failures.Enqueue($"uncaught page error: {error}"); + page.RequestFailed += (_, request) => + { + if (IsAppResource(request.Url)) + failures.Enqueue($"failed app request: {request.Method} {request.Url} ({request.Failure})"); + }; + page.Response += (_, response) => + { + if (response.Status >= 500 && IsAppResource(response.Url)) + failures.Enqueue($"app response {response.Status}: {response.Url}"); + }; + return failures; } - catch (OperationCanceledException) + + private static bool IsAppResource(string url) => + Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) + && (uri.Host is "0.0.0.1" or "local.data" || uri.Scheme == "file"); + + private static async Task CaptureFailureEvidenceAsync(IPage page, string runDirectory) { - // Fall through to a scoped process-tree kill for the app launched by this test. + try + { + await page.ScreenshotAsync( + new PageScreenshotOptions { Path = Path.Combine(runDirectory, "failure.png"), FullPage = true } + ); + await File.WriteAllTextAsync(Path.Combine(runDirectory, "failure-dom.html"), await page.ContentAsync()); + await File.WriteAllTextAsync( + Path.Combine(runDirectory, "failure-accessibility.yml"), + await page.Locator("body").AriaSnapshotAsync() + ); + } + catch (PlaywrightException exception) + { + await File.WriteAllTextAsync( + Path.Combine(runDirectory, "failure-capture-error.txt"), + exception.ToString() + ); + } } - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(); - } + private static async Task StopOwnedProcessAsync(Process process) + { + process.Refresh(); + if (process.HasExited) + return; - private static float ParseSlowMo() => - float.TryParse( - Environment.GetEnvironmentVariable("RATSCANNER_UI_SLOWMO_MS"), - NumberStyles.Float, - CultureInfo.InvariantCulture, - out float slowMo - ) - ? Math.Max(0, slowMo) - : 0; + process.CloseMainWindow(); + using CancellationTokenSource gracefulTimeout = new(TimeSpan.FromSeconds(5)); + try + { + await process.WaitForExitAsync(gracefulTimeout.Token); + return; + } + catch (OperationCanceledException) + { + // Fall through to a scoped process-tree kill for the app launched by this test. + } - private static string GetConfiguration() - { - DirectoryInfo targetFramework = new(AppContext.BaseDirectory); - return targetFramework.Parent?.Name - ?? throw new DirectoryNotFoundException("Could not determine the test build configuration."); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + + private static nint FindLargestOwnedWindow(int expectedProcessId) + { + nint selected = 0; + long largestArea = 0; + for ( + nint candidate = NativeMethods.GetTopWindow(0); + candidate != 0; + candidate = NativeMethods.GetWindow(candidate, 2) + ) + { + uint threadId = NativeMethods.GetWindowThreadProcessId(candidate, out uint processId); + if (threadId == 0 || processId != expectedProcessId || !NativeMethods.IsWindowVisible(candidate)) + continue; + if (GetWindowTitle(candidate).Contains("Overlay", StringComparison.OrdinalIgnoreCase)) + continue; + if (!NativeMethods.GetWindowRect(candidate, out NativeMethods.WindowRect bounds)) + continue; + + long area = Math.Max(0, bounds.Right - bounds.Left) * (long)Math.Max(0, bounds.Bottom - bounds.Top); + if (area > largestArea) + { + largestArea = area; + selected = candidate; + } + } + return selected; + } + + private static string GetWindowTitle(nint window) + { + int length = NativeMethods.GetWindowTextLength(window); + if (length == 0) + return string.Empty; + StringBuilder title = new(length + 1); + return NativeMethods.GetWindowText(window, title, title.Capacity) == 0 ? string.Empty : title.ToString(); + } } - private static string FindRepositoryRoot() + /// + /// Writes the tiny fixture catalog into the app's offline cache location + /// (%TEMP%\RatScanner\Cache) for every locale/game-mode key the app can ask + /// for, so the launched app serves the search from cache without touching the + /// network or depending on a developer's cache. All startup-refreshed families are + /// seeded — items with the three fixture items, and tasks/hideout/crafts/barters/maps + /// with empty arrays — so the app's cold-start cache refresh stays fully offline. + /// Pre-existing cache files are backed up and restored on dispose; only this + /// fixture's files are removed. Dispose never throws: cleanup problems are reported + /// to the run's diagnostics file so they cannot mask the test's real failure. + /// + private sealed class CatalogCacheSeed : IDisposable { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory is not null) + // Mirrors TarkovDevAPI key formats (items_{locale}_{gameMode}, tasks_v2_{locale}_{gameMode}, + // hideout_{locale}_{gameMode}, crafts_{gameMode}, barters_{gameMode}, maps_{locale}_{gameMode}) + // and RatConfig.GetCachePath (SHA-256 of the key, hex, .data). The app's + // Newtonsoft deserialization matches property names case-insensitively. + private static readonly string[] Locales = + [ + "zh", + "cs", + "en", + "es", + "fr", + "de", + "hu", + "it", + "ja", + "ko", + "pl", + "pt", + "ru", + "sk", + "tr", + ]; + + private static readonly string[] GameModes = ["Regular", "Pve", "Seasonal"]; + + private readonly string _cacheDirectory; + private readonly string _diagnosticsPath; + private readonly Dictionary _backups = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _written = new(StringComparer.OrdinalIgnoreCase); + + internal CatalogCacheSeed(string runDirectory) { - if (File.Exists(Path.Combine(directory.FullName, "RatScanner.sln"))) - return directory.FullName; - directory = directory.Parent; + _cacheDirectory = Path.Combine(Path.GetTempPath(), "RatScanner", "Cache"); + _diagnosticsPath = Path.Combine(runDirectory, "catalog-seed-cleanup-errors.log"); + Assert.Empty(Process.GetProcessesByName("RatScanner")); + + try + { + SeedCache(); + } + catch + { + // A partially seeded cache must not leave the developer's files renamed or + // replaced. Roll back only what this constructor actually owns, then surface + // the seeding failure; rollback is best effort and cannot mask the rethrow. + RollbackSeeding(); + throw; + } } - throw new DirectoryNotFoundException("Could not locate the repository root."); + private void SeedCache() + { + foreach (string key in CacheKeys()) + { + string path = Path.Combine(_cacheDirectory, CacheFileName(key)); + if (File.Exists(path)) + { + string backup = path + "." + Guid.NewGuid().ToString("N") + ".bak"; + File.Move(path, backup); + _backups[path] = backup; + } + } + + Directory.CreateDirectory(_cacheDirectory); + foreach (string key in CacheKeys()) + { + string path = Path.Combine(_cacheDirectory, CacheFileName(key)); + // Record before writing: a partially written file is still owned by the + // seed and must be removed by rollback if the write fails midway. + _written.Add(path); + File.WriteAllText(path, FixtureJson(key)); + } + } + + private void RollbackSeeding() + { + // Only touch files this seed owns: restore every backup it moved aside and + // delete only paths it wrote. Files it never touched (for example a backup + // move that failed, leaving the developer's file in place) stay untouched. + // Failures are reported to the run's diagnostics file — best effort and + // non-throwing, so a stranded backup never goes silent. + List rollbackFailures = []; + foreach (string key in CacheKeys()) + { + string path = Path.Combine(_cacheDirectory, CacheFileName(key)); + if (_backups.TryGetValue(path, out string? backup)) + { + try + { + File.Move(backup, path, overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + rollbackFailures.Add(exception); + } + } + else if (_written.Contains(path)) + { + try + { + File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + rollbackFailures.Add(exception); + } + } + } + + _backups.Clear(); + _written.Clear(); + + if (rollbackFailures.Count > 0) + { + TryWriteDiagnostic( + _diagnosticsPath, + $"{rollbackFailures.Count} catalog cache rollback operation(s) failed after a partial seed; " + + "a developer cache file may be stranded in a .bak." + + Environment.NewLine + + Environment.NewLine + + string.Join(Environment.NewLine + Environment.NewLine, rollbackFailures) + ); + } + } + + public void Dispose() + { + // The app process is stopped before this runs, but WebView2 child processes can + // linger and hold cache files briefly; retry so transient locks do not fail the + // run. Persistent failures are reported to the run's diagnostics file — throwing + // here would replace the test's real failure while the using scope unwinds. + List cleanupFailures = []; + foreach (string key in CacheKeys()) + { + string path = Path.Combine(_cacheDirectory, CacheFileName(key)); + try + { + DeleteWithRetries(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + cleanupFailures.Add(exception); + } + } + + foreach ((string path, string backup) in _backups) + { + try + { + File.Move(backup, path, overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + cleanupFailures.Add(exception); + } + } + + if (cleanupFailures.Count > 0) + { + TryWriteDiagnostic( + _diagnosticsPath, + $"{cleanupFailures.Count} catalog cache cleanup operation(s) failed; " + + "a later run may back up and restore stale fixture files." + + Environment.NewLine + + Environment.NewLine + + string.Join(Environment.NewLine + Environment.NewLine, cleanupFailures) + ); + } + } + + private static IEnumerable CacheKeys() + { + foreach (string locale in Locales) + { + foreach (string gameMode in GameModes) + { + yield return $"items_{locale}_{gameMode}"; + yield return $"tasks_v2_{locale}_{gameMode}"; + yield return $"hideout_{locale}_{gameMode}"; + yield return $"maps_{locale}_{gameMode}"; + } + } + + foreach (string gameMode in GameModes) + { + yield return $"crafts_{gameMode}"; + yield return $"barters_{gameMode}"; + } + } + + private static string CacheFileName(string key) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + return Convert.ToHexString(hash) + ".data"; + } + + private static string FixtureJson(string key) => + key.StartsWith("items_", StringComparison.Ordinal) ? FixtureItemsJson : "[]"; + + private static void DeleteWithRetries(string path) + { + const int attempts = 3; + for (int attempt = 0; ; attempt++) + { + try + { + File.Delete(path); + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (attempt + 1 >= attempts) + throw; + Thread.Sleep(150); + } + } + } + + // Item ids, names and icons match the repository's installed Data/icons so the + // result card resolves the local icons (64x127 portrait, 127x64 landscape, 64x64 + // square) instead of a remote asset. + private const string FixtureItemsJson = """ + [ + { + "Id": "62a09f32621468534a797acb", + "Name": "Bottle of Pevko Light beer", + "ShortName": "Pevko", + "Updated": "2026-08-08T04:56:48.000Z", + "Width": 1, + "Height": 2, + "WikiLink": "https://escapefromtarkov.fandom.com/wiki/Bottle_of_Pevko_Light_beer", + "Link": "https://tarkov.dev/item/bottle-of-pevko-light-beer", + "IconLink": "https://assets.tarkov.dev/62a09f32621468534a797acb-icon.webp", + "BaseImageLink": "https://assets.tarkov.dev/62a09f32621468534a797acb-base-image.webp", + "Avg24HPrice": 5200, + "BackgroundColor": "green", + "Types": ["other"] + }, + { + "Id": "5448bd6b4bdc2dfc2f8b4569", + "Name": "Makarov PM 9x18PM pistol", + "ShortName": "PM", + "Updated": "2026-08-08T04:51:41.000Z", + "Width": 2, + "Height": 1, + "WikiLink": "https://escapefromtarkov.fandom.com/wiki/Makarov_PM_9x18PM_pistol", + "Link": "https://tarkov.dev/item/makarov-pm-9x18pm-pistol", + "IconLink": "https://assets.tarkov.dev/5448bd6b4bdc2dfc2f8b4569-icon.webp", + "BaseImageLink": "https://assets.tarkov.dev/5448bd6b4bdc2dfc2f8b4569-base-image.webp", + "Avg24HPrice": 4800, + "BackgroundColor": "yellow", + "Types": ["gun", "wearable"] + }, + { + "Id": "5447a9cd4bdc2dbd208b4567", + "Name": "Colt M4A1 5.56x45 assault rifle", + "ShortName": "M4A1", + "Updated": "2026-08-08T04:51:41.000Z", + "Width": 1, + "Height": 1, + "WikiLink": "https://escapefromtarkov.fandom.com/wiki/Colt_M4A1_5.56x45_assault_rifle", + "Link": "https://tarkov.dev/item/colt-m4a1-556x45-assault-rifle", + "IconLink": "https://assets.tarkov.dev/5447a9cd4bdc2dbd208b4567-icon.webp", + "BaseImageLink": "https://assets.tarkov.dev/5447a9cd4bdc2dbd208b4567-base-image.webp", + "Avg24HPrice": 38500, + "BackgroundColor": "yellow", + "Types": ["gun"] + } + ] + """; } private static class NativeMethods