From 0ed3cc8d5c9d3a6d9d6d5077b2bfa20308229282 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Sat, 15 Aug 2026 12:17:19 -0400 Subject: [PATCH 1/8] fix(ui): keep Details section open across scan-driven refreshes (#61) RefreshResult() reset _detailsOpen on every scan-driven PropertyChanged, collapsing the Details section every time Auto Scan enqueued a result. The user's Details choice now lives in ResultCardUiState and is preserved by scan-driven refreshes; only explicit user actions (toggle, recent-scan click, search pick) change it. Also render aria-expanded as an explicit lowercase string: Blazor omits bool-valued attributes when false, so the toggle never exposed its expanded state while collapsed. --- src/App/Pages/App/Index.razor | 34 +++--- src/App/Presentation/ResultCardUiState.cs | 41 +++++++ .../ResultCardUiStateTests.cs | 101 ++++++++++++++++++ 3 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 src/App/Presentation/ResultCardUiState.cs create mode 100644 tests/RatScanner.Tests/ResultCardUiStateTests.cs 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/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); + } +} From 5969f2ec4a3ef421d16b25cd7ef2615de31176cb Mon Sep 17 00:00:00 2001 From: DysektAI Date: Sat, 15 Aug 2026 12:17:25 -0400 Subject: [PATCH 2/8] fix(ui): contain non-square item icons inside the result art frame (#60) Percentage max-width/max-height on a grid item can leave the grid area height indefinite in Chromium, so tall intrinsic icons (e.g. the 64x127 Pevko grid image) painted below the 88x64 frame onto the verdict row; square icons overflowed slightly too. The image is now absolutely positioned to the frame box (5px inset) with object-fit: contain, which letterboxes any aspect ratio without cropping or stretching, plus an overflow: hidden invariant on the frame. --- src/App/Pages/App/Index.razor.css | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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; } From 6edfd99b66e9485dd2d1fe9f1fb69b601b1224c8 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Sat, 15 Aug 2026 12:17:25 -0400 Subject: [PATCH 3/8] test(ui): cover result-card art containment and Details persistence Extract the launch/CDP/cleanup machinery from the WebView smoke test into a reusable UiSession so a second durable fact can share it, and add a regression fact that: - seeds a tiny offline catalog (portrait 1x2 Pevko, landscape 2x1 Makarov PM, square M4A1 with locally installed icons) so the result card renders hermetically without network or a developer cache; - asserts the art image stays inside the frame at desktop and narrow breakpoints for portrait, landscape, and square icons (#60); - asserts Details follows user actions: closed after a deliberate search pick, open via the toggle, closed again by a deliberate switch and by a recent-scan click, with aria-expanded exposed throughout (#61). Unit coverage for the scan-driven preserve rule lives in ResultCardUiStateTests; the harness cannot reach a scan-driven refresh without a game client. --- tests/RatScanner.UiTests/WebViewSmokeTests.cs | 1246 +++++++++++------ 1 file changed, 826 insertions(+), 420 deletions(-) diff --git a/tests/RatScanner.UiTests/WebViewSmokeTests.cs b/tests/RatScanner.UiTests/WebViewSmokeTests.cs index 1ddaf63f..e2efaa35 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,172 @@ 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.MarkFailed(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) so the search-driven result card renders hermetically with + // locally installed icons — no network and no dependency on a developer cache. + using CatalogCacheSeed catalogSeed = new(); + CancellationToken testCancellation = TestContext.Current.CancellationToken; + await using UiSession session = await UiSession.StartAsync(testCancellation); 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 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.MarkFailed(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}."); + ILocator searchInput = page.Locator(".rs-search-field input"); + await searchInput.FillAsync(string.Empty); + await searchInput.FillAsync(query); + ILocator option = page.Locator(".mud-list-item").Filter(new LocatorFilterOptions { HasText = query }).First; + await option.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 }); + await option.ClickAsync(); + } - 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. + private static async Task WaitForArtImageLoadedAsync(IPage page) + { + await page.WaitForFunctionAsync( + """ + () => { + const img = document.querySelector('.item-art img'); + return img !== null && img.complete && img.naturalWidth > 0; } - - await Task.Delay(250); - } - - throw new TimeoutException($"WebView2 did not publish DevToolsActivePort within {timeout}."); + """ + ); } - private static async Task WaitForAppPageAsync(IBrowserContext context, Process app, TimeSpan timeout) + private static async Task WaitForArtAltAsync(IPage page, string expectedAlt) { - 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); - } - - throw new TimeoutException("RatScanner's /app WebView target did not become ready."); + 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 +353,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() + { + DirectoryInfo targetFramework = new(AppContext.BaseDirectory); + return targetFramework.Parent?.Name + ?? throw new DirectoryNotFoundException("Could not determine the test build configuration."); + } + + private static string FindRepositoryRoot() { - DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); - nint window = 0; - while (DateTime.UtcNow < deadline) + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) { - app.Refresh(); - window = FindLargestOwnedWindow(app.Id); - if (window != 0) - break; - if (app.HasExited) - break; - await Task.Delay(100); + if (File.Exists(Path.Combine(directory.FullName, "RatScanner.sln"))) + return directory.FullName; + directory = directory.Parent; } - 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()})." - ); + throw new DirectoryNotFoundException("Could not locate the repository root."); + } - viewportWidth = await page.EvaluateAsync("window.innerWidth"); - bool expectedBreakpoint = width <= 680 ? viewportWidth <= 680 : viewportWidth > 680; - if (expectedBreakpoint) - return; - await Task.Delay(100); + 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. } - - throw new InvalidOperationException( - $"RatScanner did not reach the requested {width}px responsive viewport; actual width was {viewportWidth}px." - ); } private static async Task RecordCleanupFailureAsync(List failures, Func cleanup) @@ -575,124 +419,686 @@ 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 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 + ) { - 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; + _traceStarted = traceStarted; } - catch (Exception) + + internal static async Task StartAsync(CancellationToken cancellationToken) { - // 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); + + 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); + + 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); + + int port = await WaitForDebugPortAsync(profileDirectory, app, StartupTimeout); + await File.WriteAllTextAsync( + Path.Combine(runDirectory, "endpoint.txt"), + $"http://127.0.0.1:{port}", + cancellationToken + ); + + IPlaywright playwright = await Playwright.CreateAsync(); + float slowMo = ParseSlowMo(); + IBrowser 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; + try + { + page = await WaitForAppPageAsync(context, app, StartupTimeout); + } + catch + { + await StopOwnedProcessAsync(app); + throw; + } + + return new UiSession( + app, + stdoutTask, + stderrTask, + playwright, + browser, + context, + page, + runDirectory, + profileDirectory, + Path.Combine(appDirectory, "Log.txt"), + AttachRuntimeDiagnostics(page), + traceStarted: true + ); } - } - 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) - ) + internal async Task ResizeAsync(int width, int height) { - 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) + 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) { - largestArea = area; - selected = candidate; + 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"); + bool expectedBreakpoint = width <= 680 ? viewportWidth <= 680 : viewportWidth > 680; + if (expectedBreakpoint) + return; + await Task.Delay(100); } + + 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 MarkFailed(Exception exception) + { + _failed = true; + try + { + await File.WriteAllTextAsync(Path.Combine(RunDirectory, "failure-url.txt"), Page.Url); + 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() { - await page.ScreenshotAsync( - new PageScreenshotOptions { Path = Path.Combine(runDirectory, "failure.png"), FullPage = true } + 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. Pre-existing cache files + /// are backed up and restored on dispose; only this fixture's files are removed. + /// + private sealed class CatalogCacheSeed : IDisposable { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory is not null) + // Mirrors TarkovDevAPI.ItemsQueryKey(locale, gameMode) => $"items_{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 Dictionary _backups = new(StringComparer.OrdinalIgnoreCase); + + internal CatalogCacheSeed() { - if (File.Exists(Path.Combine(directory.FullName, "RatScanner.sln"))) - return directory.FullName; - directory = directory.Parent; + _cacheDirectory = Path.Combine(Path.GetTempPath(), "RatScanner", "Cache"); + Assert.Empty(Process.GetProcessesByName("RatScanner")); + + 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()) + File.WriteAllText(Path.Combine(_cacheDirectory, CacheFileName(key)), FixtureItemsJson); } - throw new DirectoryNotFoundException("Could not locate the repository root."); + public void Dispose() + { + foreach (string key in CacheKeys()) + { + string path = Path.Combine(_cacheDirectory, CacheFileName(key)); + TryDelete(path); + } + + foreach ((string path, string backup) in _backups) + { + try + { + File.Move(backup, path, overwrite: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + private static IEnumerable CacheKeys() + { + foreach (string locale in Locales) + { + foreach (string gameMode in GameModes) + yield return $"items_{locale}_{gameMode}"; + } + } + + private static string CacheFileName(string key) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + return Convert.ToHexString(hash) + ".data"; + } + + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + + // 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 From 46f27a45099d2f18a81fec61176e8531c6f84963 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Sat, 15 Aug 2026 15:24:28 -0400 Subject: [PATCH 4/8] test(ui): stop owned process when UiSession startup fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR review finding: if UiSession.StartAsync threw after Process.Start succeeded (DevToolsActivePort timeout, Playwright connect, tracing, or endpoint write), the launched RatScanner process, its temp WebView2 profile, and the run directory leaked — breaking the single-instance guard for every later UI run. The whole post-start sequence now shares one failure path that stops the owned process, disposes any Playwright/browser resources created so far, and best-effort deletes the profile before rethrowing the original startup failure. Also hardens the suite while touching this file: - SelectCatalogItemAsync matches the ShortName exactly instead of substring-matching item names, so a query like "PM" cannot land on a different list item. - Cancellation token is plumbed into resize waits and failure-evidence writes. - CatalogCacheSeed cleanup retries transient WebView2 file locks and surfaces persistent cleanup failures instead of swallowing them. --- tests/RatScanner.UiTests/WebViewSmokeTests.cs | 227 +++++++++++++----- 1 file changed, 164 insertions(+), 63 deletions(-) diff --git a/tests/RatScanner.UiTests/WebViewSmokeTests.cs b/tests/RatScanner.UiTests/WebViewSmokeTests.cs index e2efaa35..d64a3e5c 100644 --- a/tests/RatScanner.UiTests/WebViewSmokeTests.cs +++ b/tests/RatScanner.UiTests/WebViewSmokeTests.cs @@ -173,7 +173,7 @@ await File.WriteAllTextAsync( } catch (Exception exception) { - await session.MarkFailed(exception); + await session.MarkFailedAsync(exception); throw; } } @@ -238,6 +238,7 @@ await page.Locator(".scan-page") 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"); @@ -245,7 +246,7 @@ await page.Locator(".scan-page") } catch (Exception exception) { - await session.MarkFailed(exception); + await session.MarkFailedAsync(exception); throw; } } @@ -255,7 +256,18 @@ private static async Task SelectCatalogItemAsync(IPage page, string query) ILocator searchInput = page.Locator(".rs-search-field input"); await searchInput.FillAsync(string.Empty); await searchInput.FillAsync(query); - ILocator option = page.Locator(".mud-list-item").Filter(new LocatorFilterOptions { HasText = query }).First; + // 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 + { + Has = page.Locator(".search-result small") + .GetByText(query, new LocatorGetByTextOptions { Exact = true }), + } + ) + .First; await option.WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible, Timeout = 30_000 }); await option.ClickAsync(); } @@ -436,6 +448,7 @@ private sealed class UiSession : IAsyncDisposable 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; @@ -458,7 +471,8 @@ private UiSession( string profileDirectory, string appLogPath, ConcurrentQueue runtimeFailures, - bool traceStarted + bool traceStarted, + CancellationToken cancellationToken ) { _app = app; @@ -472,6 +486,7 @@ bool traceStarted _profileDirectory = profileDirectory; _appLogPath = appLogPath; _runtimeFailures = runtimeFailures; + _cancellationToken = cancellationToken; _traceStarted = traceStarted; } @@ -521,59 +536,110 @@ internal static async Task StartAsync(CancellationToken cancellationT Task stdoutTask = app.StandardOutput.ReadToEndAsync(cancellationToken); Task stderrTask = app.StandardError.ReadToEndAsync(cancellationToken); - int port = await WaitForDebugPortAsync(profileDirectory, app, StartupTimeout); - await File.WriteAllTextAsync( - Path.Combine(runDirectory, "endpoint.txt"), - $"http://127.0.0.1:{port}", - 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 + ); - IPlaywright playwright = await Playwright.CreateAsync(); - float slowMo = ParseSlowMo(); - IBrowser browser = await playwright.Chromium.ConnectOverCDPAsync( - $"http://127.0.0.1:{port}", - new BrowserTypeConnectOverCDPOptions + 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 { - ArtifactsDir = runDirectory, - SlowMo = slowMo, - Timeout = (float)StartupTimeout.TotalMilliseconds, + await StopOwnedProcessAsync(app); } - ); - IBrowserContext context = Assert.Single(browser.Contexts); - await context.Tracing.StartAsync( - new TracingStartOptions + catch { - Screenshots = true, - Snapshots = true, - Sources = true, + // 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. } - ); - IPage page; - try - { - page = await WaitForAppPageAsync(context, app, StartupTimeout); - } - catch - { - await StopOwnedProcessAsync(app); throw; } - - return new UiSession( - app, - stdoutTask, - stderrTask, - playwright, - browser, - context, - page, - runDirectory, - profileDirectory, - Path.Combine(appDirectory, "Log.txt"), - AttachRuntimeDiagnostics(page), - traceStarted: true - ); } internal async Task ResizeAsync(int width, int height) @@ -588,7 +654,7 @@ internal async Task ResizeAsync(int width, int height) break; if (_app.HasExited) break; - await Task.Delay(100); + await Task.Delay(100, _cancellationToken); } if (window == 0) @@ -601,7 +667,7 @@ internal async Task ResizeAsync(int width, int height) ); 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"); + int viewportWidth = await Page.EvaluateAsync("window.innerWidth").WaitAsync(_cancellationToken); DateTime resizeDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); while (DateTime.UtcNow < resizeDeadline) { @@ -620,11 +686,11 @@ internal async Task ResizeAsync(int width, int height) $"Unable to resize RatScanner for UI validation (Win32 error {Marshal.GetLastWin32Error()})." ); - viewportWidth = await Page.EvaluateAsync("window.innerWidth"); + viewportWidth = await Page.EvaluateAsync("window.innerWidth").WaitAsync(_cancellationToken); bool expectedBreakpoint = width <= 680 ? viewportWidth <= 680 : viewportWidth > 680; if (expectedBreakpoint) return; - await Task.Delay(100); + await Task.Delay(100, _cancellationToken); } throw new InvalidOperationException( @@ -640,12 +706,16 @@ await Page.ScreenshotAsync( } /// Captures failure evidence; the session keeps the trace for diagnosis. - internal async Task MarkFailed(Exception exception) + internal async Task MarkFailedAsync(Exception exception) { _failed = true; try { - await File.WriteAllTextAsync(Path.Combine(RunDirectory, "failure-url.txt"), Page.Url); + 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( @@ -1005,10 +1075,21 @@ internal CatalogCacheSeed() 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. Any persistent failure is reported so the test run artifacts show it. + List cleanupFailures = []; foreach (string key in CacheKeys()) { string path = Path.Combine(_cacheDirectory, CacheFileName(key)); - TryDelete(path); + try + { + DeleteWithRetries(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + cleanupFailures.Add(exception); + } } foreach ((string path, string backup) in _backups) @@ -1017,8 +1098,19 @@ public void Dispose() { File.Move(backup, path, overwrite: true); } - catch (IOException) { } - catch (UnauthorizedAccessException) { } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + cleanupFailures.Add(exception); + } + } + + if (cleanupFailures.Count > 0) + { + throw new AggregateException( + $"{cleanupFailures.Count} catalog cache cleanup operation(s) failed; " + + "a later run may back up and restore stale fixture files.", + cleanupFailures + ); } } @@ -1037,14 +1129,23 @@ private static string CacheFileName(string key) return Convert.ToHexString(hash) + ".data"; } - private static void TryDelete(string path) + private static void DeleteWithRetries(string path) { - try + const int attempts = 3; + for (int attempt = 0; ; attempt++) { - File.Delete(path); + try + { + File.Delete(path); + return; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (attempt + 1 >= attempts) + throw; + Thread.Sleep(150); + } } - catch (IOException) { } - catch (UnauthorizedAccessException) { } } // Item ids, names and icons match the repository's installed Data/icons so the From d93f3332dba49a9062903d294308831513d0cbb4 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Sat, 15 Aug 2026 15:26:00 -0400 Subject: [PATCH 5/8] docs(agent): note result-card coverage in UI smoke scope --- docs/agent-context/build-and-validation.md | 1 + 1 file changed, 1 insertion(+) 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. From 4e13a7018b4a22ee1077fc448ddb669ddc457193 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Sat, 15 Aug 2026 15:49:58 -0400 Subject: [PATCH 6/8] test(ui): keep catalog seeding hermetic and non-throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review round on the previous commit. All three findings target CatalogCacheSeed teardown/rollback and its hermeticity claim: - Dispose no longer throws: during a test failure the AggregateException would have replaced the real assertion failure while the using scope unwound, hiding the evidence MarkFailedAsync captured. Cleanup failures are now written to catalog-seed-cleanup-errors.log in the run's own artifact directory (UiSession.CreateRunDirectory is shared so the seed and session land in the same folder), matching UiSession.DisposeAsync's report-don't-throw teardown pattern. - The constructor rolls back on partial seeding: if backing up or writing a fixture file fails, every file moved to .bak is restored and written fixtures are removed before rethrowing, so the developer's real cache can never be left renamed. - The cache seed now covers every family the app refreshes at startup (tasks_v2, hideout, crafts, barters, maps alongside items). The test previously still fetched those catalogs from the network — 'hermetic' in name only. Verified in the run log: 0 'Fetching data' lines, all core caches loaded from offline storage. --- tests/RatScanner.UiTests/WebViewSmokeTests.cs | 104 ++++++++++++++---- 1 file changed, 80 insertions(+), 24 deletions(-) diff --git a/tests/RatScanner.UiTests/WebViewSmokeTests.cs b/tests/RatScanner.UiTests/WebViewSmokeTests.cs index d64a3e5c..3d773b72 100644 --- a/tests/RatScanner.UiTests/WebViewSmokeTests.cs +++ b/tests/RatScanner.UiTests/WebViewSmokeTests.cs @@ -183,11 +183,15 @@ await File.WriteAllTextAsync( 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) so the search-driven result card renders hermetically with - // locally installed icons — no network and no dependency on a developer cache. - using CatalogCacheSeed catalogSeed = new(); + // 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); + await using UiSession session = await UiSession.StartAsync(testCancellation, runDirectory); try { IPage page = session.Page; @@ -490,7 +494,10 @@ CancellationToken cancellationToken _traceStarted = traceStarted; } - internal static async Task StartAsync(CancellationToken cancellationToken) + internal static async Task StartAsync( + CancellationToken cancellationToken, + string? runDirectory = null + ) { string repositoryRoot = FindRepositoryRoot(); string configuration = GetConfiguration(); @@ -508,14 +515,7 @@ internal static async Task StartAsync(CancellationToken cancellationT 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); + runDirectory ??= CreateRunDirectory(); string profileDirectory = Path.Combine(Path.GetTempPath(), $"RatScanner-ui-{Guid.NewGuid():N}"); Directory.CreateDirectory(profileDirectory); @@ -642,6 +642,21 @@ await context.Tracing.StartAsync( } } + /// Creates the run's artifact directory under artifacts/ui-tests (or the + /// RATSCANNER_UI_ARTIFACTS override). + internal static string CreateRunDirectory() + { + 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; + } + internal async Task ResizeAsync(int width, int height) { DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); @@ -1018,14 +1033,19 @@ private static string GetWindowTitle(nint window) /// /// 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. Pre-existing cache files - /// are backed up and restored on dispose; only this fixture's files are removed. + /// (%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 { - // Mirrors TarkovDevAPI.ItemsQueryKey(locale, gameMode) => $"items_{locale}_{gameMode}" + // 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 = @@ -1050,13 +1070,31 @@ private sealed class CatalogCacheSeed : IDisposable private static readonly string[] GameModes = ["Regular", "Pve", "Seasonal"]; private readonly string _cacheDirectory; + private readonly string _diagnosticsPath; private readonly Dictionary _backups = new(StringComparer.OrdinalIgnoreCase); - internal CatalogCacheSeed() + internal CatalogCacheSeed(string runDirectory) { _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 what this constructor did; Dispose is non-throwing + // by contract, so it cannot mask the seeding failure being rethrown here. + Dispose(); + throw; + } + } + + private void SeedCache() + { foreach (string key in CacheKeys()) { string path = Path.Combine(_cacheDirectory, CacheFileName(key)); @@ -1070,14 +1108,15 @@ internal CatalogCacheSeed() Directory.CreateDirectory(_cacheDirectory); foreach (string key in CacheKeys()) - File.WriteAllText(Path.Combine(_cacheDirectory, CacheFileName(key)), FixtureItemsJson); + File.WriteAllText(Path.Combine(_cacheDirectory, CacheFileName(key)), FixtureJson(key)); } 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. Any persistent failure is reported so the test run artifacts show it. + // 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()) { @@ -1106,10 +1145,13 @@ public void Dispose() if (cleanupFailures.Count > 0) { - throw new AggregateException( + TryWriteDiagnostic( + _diagnosticsPath, $"{cleanupFailures.Count} catalog cache cleanup operation(s) failed; " - + "a later run may back up and restore stale fixture files.", - cleanupFailures + + "a later run may back up and restore stale fixture files." + + Environment.NewLine + + Environment.NewLine + + string.Join(Environment.NewLine + Environment.NewLine, cleanupFailures) ); } } @@ -1119,7 +1161,18 @@ 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}"; } } @@ -1129,6 +1182,9 @@ private static string CacheFileName(string 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; From d0d764ff2e188ccd3626423a1f99cabd5293bca7 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Sat, 15 Aug 2026 16:05:59 -0400 Subject: [PATCH 7/8] test(ui): roll back only files owned by a failed cache seed If a backup File.Move threw during seeding, the developer's real cache file stayed at the original path unrecorded, and the previous rollback (which deleted every cache key before restoring recorded backups) would delete that unowned file permanently. Seeding now tracks both the backups moved aside and the paths it wrote; rollback restores the former and deletes only the latter, leaving every file the seed never touched exactly as found. --- tests/RatScanner.UiTests/WebViewSmokeTests.cs | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/tests/RatScanner.UiTests/WebViewSmokeTests.cs b/tests/RatScanner.UiTests/WebViewSmokeTests.cs index 3d773b72..777f1da8 100644 --- a/tests/RatScanner.UiTests/WebViewSmokeTests.cs +++ b/tests/RatScanner.UiTests/WebViewSmokeTests.cs @@ -1072,6 +1072,7 @@ private sealed class CatalogCacheSeed : IDisposable 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) { @@ -1086,9 +1087,9 @@ internal CatalogCacheSeed(string runDirectory) catch { // A partially seeded cache must not leave the developer's files renamed or - // replaced. Roll back what this constructor did; Dispose is non-throwing - // by contract, so it cannot mask the seeding failure being rethrown here. - Dispose(); + // 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; } } @@ -1108,7 +1109,45 @@ private void SeedCache() Directory.CreateDirectory(_cacheDirectory); foreach (string key in CacheKeys()) - File.WriteAllText(Path.Combine(_cacheDirectory, CacheFileName(key)), FixtureJson(key)); + { + 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. + 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 (IOException) { } + catch (UnauthorizedAccessException) { } + } + else if (_written.Contains(path)) + { + try + { + File.Delete(path); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + _backups.Clear(); + _written.Clear(); } public void Dispose() From 42e4b0cd5a34d9ff5d7c3f5eb4b69f4638baa627 Mon Sep 17 00:00:00 2001 From: DysektAI Date: Sat, 15 Aug 2026 16:21:55 -0400 Subject: [PATCH 8/8] test(ui): log cache seed rollback failures to the run diagnostics RollbackSeeding swallowed restore/delete failures silently, unlike the same class of cleanup failure in Dispose, which reports to catalog-seed-cleanup-errors.log. Rollback now collects and writes its failures to the same diagnostics file (best effort, non-throwing), so a stranded .bak or leftover fixture file never goes undiagnosed. --- tests/RatScanner.UiTests/WebViewSmokeTests.cs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/tests/RatScanner.UiTests/WebViewSmokeTests.cs b/tests/RatScanner.UiTests/WebViewSmokeTests.cs index 777f1da8..54304047 100644 --- a/tests/RatScanner.UiTests/WebViewSmokeTests.cs +++ b/tests/RatScanner.UiTests/WebViewSmokeTests.cs @@ -1123,6 +1123,9 @@ 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)); @@ -1132,8 +1135,10 @@ private void RollbackSeeding() { File.Move(backup, path, overwrite: true); } - catch (IOException) { } - catch (UnauthorizedAccessException) { } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + rollbackFailures.Add(exception); + } } else if (_written.Contains(path)) { @@ -1141,13 +1146,27 @@ private void RollbackSeeding() { File.Delete(path); } - catch (IOException) { } - catch (UnauthorizedAccessException) { } + 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()