From 35ea3681fa690993a90da95516c0425ec2cb3c30 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 13:11:26 -0500 Subject: [PATCH 1/2] fix(inspector): preserve UI sync context in async RelayCommand for StatusText --- src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs | 5 ++++- .../UiHeadless/AutomationIdCoverageHeadlessTests.cs | 2 +- .../UiHeadless/InspectTabsAndToolsHeadlessTests.cs | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index 60fe702ed..bb45d3d21 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -1996,7 +1996,10 @@ public async void Execute(object? parameter) { try { - await execute().ConfigureAwait(false); + // Preserve Avalonia UI sync context so StatusText / collection updates after + // awaits are applied on the UI thread (ConfigureAwait(false) caused macOS + // headless flakes where export wrote the file but StatusText stayed "Ready"). + await execute(); } catch { diff --git a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs index faff536ba..bed040212 100644 --- a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs +++ b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs @@ -264,7 +264,7 @@ await fx.DispatchAsync(() => fx.Robot.Click("MenuExportArchive"); }); - var exportDeadline = DateTime.UtcNow.AddSeconds(5); + var exportDeadline = DateTime.UtcNow.AddSeconds(15); while (DateTime.UtcNow < exportDeadline && !fx.ViewModel.StatusText.Contains("Exported 1 sessions", StringComparison.Ordinal)) { diff --git a/tests/Titanium.E2E.Tests/UiHeadless/InspectTabsAndToolsHeadlessTests.cs b/tests/Titanium.E2E.Tests/UiHeadless/InspectTabsAndToolsHeadlessTests.cs index 4aca3a11c..7c346b7ab 100644 --- a/tests/Titanium.E2E.Tests/UiHeadless/InspectTabsAndToolsHeadlessTests.cs +++ b/tests/Titanium.E2E.Tests/UiHeadless/InspectTabsAndToolsHeadlessTests.cs @@ -89,8 +89,8 @@ await fx.DispatchAsync(() => fx.Robot.Click("MenuExportHar"); }); - // ExportHarCommand is async; file may exist before StatusText is updated. - var deadline = DateTime.UtcNow.AddSeconds(5); + // ExportHarCommand is async; wait for StatusText (file may exist briefly before it). + var deadline = DateTime.UtcNow.AddSeconds(15); while (DateTime.UtcNow < deadline && !fx.ViewModel.StatusText.Contains("Exported 1 sessions", StringComparison.Ordinal)) { From fb2a0b1c5178ced37ef137c15f40e2e433835456 Mon Sep 17 00:00:00 2001 From: justcoding121 Date: Sun, 30 Aug 2026 13:26:20 -0500 Subject: [PATCH 2/2] fix(inspector): harden archive import after export on macOS headless Dispose/flush the zip before StatusText updates, surface import failures in StatusText, and wait for a readable file plus longer import completion in the headless path-picker test. --- .../Services/SessionArchive.cs | 40 +++++++++++++------ .../ViewModels/MainWindowViewModel.cs | 23 +++++++---- .../AutomationIdCoverageHeadlessTests.cs | 21 +++++++++- 3 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index 643da9299..94096bba0 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -52,24 +52,40 @@ public static async Task> ImportHarAsync(string path, Canc public static async Task ExportNativeArchiveAsync(IEnumerable sessions, string zipPath, CancellationToken ct = default) { - await using var fs = File.Create(zipPath); - using var zip = new ZipArchive(fs, ZipArchiveMode.Create); - var index = 0; - foreach (var session in sessions) + await using var fs = new FileStream( + zipPath, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using (var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: true)) { - ct.ThrowIfCancellationRequested(); - var entry = zip.CreateEntry($"session-{index:D5}.json"); - await using var stream = await entry.OpenAsync(ct); - await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct); - index++; + var index = 0; + foreach (var session in sessions) + { + ct.ThrowIfCancellationRequested(); + var entry = zip.CreateEntry($"session-{index:D5}.json"); + await using var stream = entry.Open(); + await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct); + index++; + } } + + await fs.FlushAsync(ct); } public static async Task> ImportNativeArchiveAsync(string zipPath, CancellationToken ct = default) { var list = new List(); - await using var fs = File.OpenRead(zipPath); - using var zip = new ZipArchive(fs, ZipArchiveMode.Read); + await using var fs = new FileStream( + zipPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: true); foreach (var entry in zip.Entries.OrderBy(e => e.FullName)) { ct.ThrowIfCancellationRequested(); @@ -78,7 +94,7 @@ public static async Task> ImportNativeArchiveAsync(string continue; } - await using var stream = await entry.OpenAsync(ct); + await using var stream = entry.Open(); var snap = await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct); if (snap is not null) { diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index bb45d3d21..529c0d18f 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -1932,16 +1932,23 @@ private async Task ImportArchiveAsync() return; } - var imported = await SessionArchive.ImportNativeArchiveAsync(path); - foreach (var snap in imported) + try { - _registry.Add(snap); - _all.Add(snap); - } + var imported = await SessionArchive.ImportNativeArchiveAsync(path); + foreach (var snap in imported) + { + _registry.Add(snap); + _all.Add(snap); + } - ApplyFilter(); - RefreshSessionCountText(); - StatusText = $"Appended {imported.Count} sessions from {Path.GetFileName(path)}"; + ApplyFilter(); + RefreshSessionCountText(); + StatusText = $"Appended {imported.Count} sessions from {Path.GetFileName(path)}"; + } + catch (Exception ex) + { + StatusText = "Import archive failed: " + Truncate(ex.Message, 160); + } } private IReadOnlyList ResolveExportSelection() diff --git a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs index bed040212..df51201f3 100644 --- a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs +++ b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs @@ -277,12 +277,29 @@ await fx.DispatchAsync(() => Assert.IsTrue(File.Exists(zip)); StringAssert.Contains(fx.ViewModel.StatusText, "Exported 1 sessions"); }); + + // macOS runners can briefly keep the zip handle; wait until a shared read succeeds. fx.PathPicker.OpenPath = zip; + var readableDeadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < readableDeadline) + { + try + { + await using var probe = new FileStream(zip, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + break; + } + catch (IOException) + { + await Task.Delay(50); + } + } + await fx.DispatchAsync(() => fx.Robot.Click("MenuImportArchive")); - var importDeadline = DateTime.UtcNow.AddSeconds(5); + var importDeadline = DateTime.UtcNow.AddSeconds(15); while (DateTime.UtcNow < importDeadline && - !fx.ViewModel.StatusText.Contains("Appended", StringComparison.Ordinal)) + !fx.ViewModel.StatusText.Contains("Appended", StringComparison.Ordinal) && + !fx.ViewModel.StatusText.Contains("Import archive failed", StringComparison.Ordinal)) { await Task.Delay(50); }