diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 1d864e27b..fb74fe321 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -77,7 +77,7 @@ jobs: /d:sonar.token="$env:SONAR_TOKEN" /d:sonar.cs.vscoveragexml.reportsPaths="coverage/coverage.xml" /d:sonar.exclusions="**/docs/**,**/examples/**,**/benchmarks/**,**/tools/**,**/*.axaml,**/website/**,**/.github/**" - /d:sonar.coverage.exclusions="**/examples/**,**/benchmarks/**,**/docs/**,**/Http3/Http3OriginBridge.cs,**/Http3/Http3OriginClientSession.cs,**/Titanium.Plus/Dashboard/**,**/Titanium.Inspector/Views/**,**/Titanium.Inspector/Services/AppContainerLoopback.cs,**/Titanium.Inspector/App.axaml.cs,**/Titanium.Inspector/Program.cs,**/Titanium.Inspector/InspectorAppFactory.cs,**/Titanium.Inspector/Services/UpdateService.cs,**/Titanium.Cli/Program.cs,**/Titanium.Cli/AsyncConsole.cs,**/Titanium.Cli/Http3/Http3DepsCommand.cs,**/Titanium.Cli/Updates/VersionAndUpdateCommands.cs,**/Titanium.Cli/Certificates/CertificateBootstrap.cs,**/Titanium.Plus/Discovery/**,**/Titanium.Plus/Security/**,**/Titanium.Plus/State/**,**/Titanium.Plus/Resilience/**,**/Titanium.Plus/PlusLog.cs,**/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs" + /d:sonar.coverage.exclusions="**/examples/**,**/benchmarks/**,**/docs/**,**/Http3/Http3OriginBridge.cs,**/Http3/Http3OriginClientSession.cs,**/Titanium.Plus/Dashboard/**,**/Titanium.Inspector/Views/**,**/Titanium.Inspector/Services/AppContainerLoopback.cs,**/Titanium.Inspector/App.axaml.cs,**/Titanium.Inspector/Program.cs,**/Titanium.Inspector/InspectorAppFactory.cs,**/Titanium.Inspector/Services/UpdateService.cs,**/Titanium.Cli/Program.cs,**/Titanium.Cli/AsyncConsole.cs,**/Titanium.Cli/Http3/Http3DepsCommand.cs,**/Titanium.Cli/Updates/VersionAndUpdateCommands.cs,**/Titanium.Cli/Certificates/CertificateBootstrap.cs,**/Titanium.Plus/Discovery/**,**/Titanium.Plus/Security/**,**/Titanium.Plus/State/**,**/Titanium.Plus/Resilience/**,**/Titanium.Plus/PlusLog.cs,**/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs,**/Titanium.Web.Proxy/Helpers/IElevationPrompt.cs,**/Titanium.Web.Proxy/Helpers/IProcessRunner.cs" - name: Build for SonarCloud analysis if: env.SONAR_TOKEN != '' diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index 94096bba0..db10e0c30 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -66,7 +66,7 @@ public static async Task ExportNativeArchiveAsync(IEnumerable s { ct.ThrowIfCancellationRequested(); var entry = zip.CreateEntry($"session-{index:D5}.json"); - await using var stream = entry.Open(); + await using var stream = await entry.OpenAsync(ct); await JsonSerializer.SerializeAsync(stream, session, cancellationToken: ct); index++; } @@ -94,7 +94,7 @@ public static async Task> ImportNativeArchiveAsync(string continue; } - await using var stream = entry.Open(); + await using var stream = await entry.OpenAsync(ct); 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 529c0d18f..38c652a96 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -1830,8 +1830,15 @@ private async Task ExportHarAsync() return; } - await SessionArchive.ExportHarAsync(_all, path); - StatusText = $"Exported {_all.Count} sessions to {path}"; + try + { + await SessionArchive.ExportHarAsync(_all, path); + await MarshalToUiAsync(() => StatusText = $"Exported {_all.Count} sessions to {path}"); + } + catch (Exception ex) + { + await MarshalToUiAsync(() => StatusText = "Export HAR failed: " + Truncate(ex.Message, 160)); + } } private async Task ExportSelectedHarAsync() @@ -1850,8 +1857,15 @@ private async Task ExportSelectedHarAsync() return; } - await SessionArchive.ExportHarAsync(sessions, path); - StatusText = $"Exported {sessions.Count} sessions to {path}"; + try + { + await SessionArchive.ExportHarAsync(sessions, path); + await MarshalToUiAsync(() => StatusText = $"Exported {sessions.Count} sessions to {path}"); + } + catch (Exception ex) + { + await MarshalToUiAsync(() => StatusText = "Export HAR failed: " + Truncate(ex.Message, 160)); + } } private async Task ImportHarAsync() diff --git a/tests/Titanium.Cli.Tests/ServerConfigApplierTests.cs b/tests/Titanium.Cli.Tests/ServerConfigApplierTests.cs index 31b042422..8bbd2e8fa 100644 --- a/tests/Titanium.Cli.Tests/ServerConfigApplierTests.cs +++ b/tests/Titanium.Cli.Tests/ServerConfigApplierTests.cs @@ -1,14 +1,30 @@ +using System.Net; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; using Microsoft.VisualStudio.TestTools.UnitTesting; using Titanium.Cli.Config; using Titanium.Web.Proxy; using Titanium.Web.Proxy.Configuration.Models; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; using Titanium.Web.Proxy.Options; +#pragma warning disable TWP001 + namespace Titanium.Cli.Tests; [TestClass] public class ServerConfigApplierTests { + [TestMethod] + public void Apply_NullServer_IsNoOp() + { + using var proxy = new ProxyServer(userTrustRootCertificate: false); + var before = proxy.EnableHttp2; + ServerConfigApplier.Apply(proxy, null); + Assert.AreEqual(before, proxy.EnableHttp2); + } + [TestMethod] public void Apply_PublicFacingProfile_SetsAdmissionAndTimeouts() { @@ -50,13 +66,219 @@ public void Apply_EnableHttp2False() Assert.IsFalse(proxy.EnableHttp2); } + [TestMethod] + public void Apply_ProtocolFlags_TimeoutsPoolingLimitsPolicyTlsUpstreamAndCerts() + { + using var proxy = new ProxyServer(userTrustRootCertificate: false); + ServerConfigApplier.Apply(proxy, new ServerConfig + { + EnableHttp3 = false, + EnableRfc8441 = false, + EnableQpackDynamicTable = false, + EnableHttpsSvcbDnsDiscovery = false, + Enable100ContinueBehaviour = true, + CompatibilityMode100Continue = true, + EnableWinAuth = false, + OriginHttpVersionPolicy = "NormalizeToHttp11", + ViaHeaderPseudonym = "twp-test", + BlockPrivateNetworkDestinations = true, + CheckCertificateRevocation = "Offline", + DnsServerEndPoint = "8.8.8.8:53", + Timeouts = new TimeoutsConfig + { + ConnectionTimeOutSeconds = 11, + ConnectTimeOutSeconds = 12, + ClientHeaderTimeoutSeconds = 13, + ResponseHeaderTimeoutSeconds = 14, + IdleReadTimeoutSeconds = 15, + IdleWriteTimeoutSeconds = 16, + RequestTimeoutSeconds = 17, + NetworkFailureRetryAttempts = 3, + }, + Pooling = new PoolingConfig + { + EnableConnectionPool = true, + EnableTcpServerConnectionPrefetch = false, + EnableIpv6UnreachableSoftSkip = true, + MaxCachedConnections = 9, + MaxConcurrentHttp11HttpsOriginCreates = 4, + MaxConcurrentClientConnections = 123, + NoDelay = true, + EnableTcpKeepAlive = true, + TcpTimeWaitSeconds = 30, + ListenerBackLog = 64, + ReuseSocket = true, + ThreadPoolWorkerThread = 8, + }, + Limits = new LimitsConfig + { + MaxHeaderLineBytes = 2048, + MaxHeaderCount = 50, + MaxHeaderAggregateBytes = 8192, + MaxEncodedBodyBytes = 1_000_000, + MaxDecodedBodyBytes = 2_000_000, + MaxDecompressionRatio = 12.5, + MaxConcurrentClients = 40, + MaxConcurrentStreamsPerConnection = 20, + MaxPeerInitiatedIncompleteStreamResets = 7, + MaxOpenHeaderBlockFrames = 5, + MaxOpenHeaderBlockDurationSeconds = 9, + ConnectionPoolingEnabled = true, + MaxCachedConnectionsPerHost = 6, + MaxOriginHttp2ConnectionsPerAuthority = 3, + MaxCertificateCacheEntries = 100, + MaxCertificateDiskCacheEntries = 50, + MaxBufferedBodyBytes = 4096, + MaxDecodedHeaderListBytes = 1024, + MaxWebSocketFramePayloadBytes = 65536, + }, + PolicyModes = new PolicyModesConfig + { + BodyBudget = "Observe", + DecompressionRatio = "Enforce", + HeaderLimits = "Observe", + AdmissionControl = "Enforce", + Http2AbuseBudget = "Disabled", + AllowAmbiguousFraming = true, + }, + Tls = new TlsConfig + { + SupportedSslProtocols = ["Tls12", "Tls13"], + SupportedServerSslProtocols = ["Tls13"], + }, + Upstream = new UpstreamConfig + { + ForwardToUpstreamGateway = true, + UpstreamProxyConfigurationScript = "https://example.test/proxy.pac", + HttpProxy = new ExternalProxyConfig + { + HostName = "proxy.example", + Port = 8080, + UserName = "u", + Password = "p", + ProxyType = "Http", + BypassLocalhost = true, + ProxyDnsRequests = true, + NextHop = new ExternalProxyConfig + { + HostName = "next.example", + Port = 1080, + ProxyType = "Socks5", + }, + }, + HttpsProxy = new ExternalProxyConfig + { + HostName = "secure-proxy.example", + Port = 8443, + ProxyType = "Http", + }, + UpStreamEndPoint = "0.0.0.0:0", + UpStreamEndPointIPv4 = "127.0.0.1:0", + UpStreamEndPointIPv6 = "[::1]:0", + }, + CertificateManager = new CertificateManagerConfig + { + CertificateEngine = "BouncyCastle", + LeafCertificateKeyAlgorithm = "Rsa2048", + PfxFilePath = "certs/root.pfx", + PfxPassword = "secret", + OverwritePfxFile = true, + CertificateValidDays = 365, + CertificateGraceDays = 7, + CertificateCacheTimeOutMinutes = 60, + RootCertificateName = "TWP Test Root", + RootCertificateIssuerName = "TWP Test Issuer", + SaveFakeCertificates = true, + DisableWildCardCertificates = true, + }, + }); + + Assert.IsFalse(proxy.EnableHttp3); + Assert.IsFalse(proxy.EnableRfc8441); + Assert.IsFalse(proxy.EnableQpackDynamicTable); + Assert.IsFalse(proxy.EnableHttpsSvcbDnsDiscovery); + Assert.IsTrue(proxy.Enable100ContinueBehaviour); + Assert.IsTrue(proxy.CompatibilityMode100Continue); + Assert.IsFalse(proxy.EnableWinAuth); + Assert.AreEqual(OriginHttpVersionPolicy.NormalizeToHttp11, proxy.OriginHttpVersionPolicy); + Assert.AreEqual("twp-test", proxy.ViaHeaderPseudonym); + Assert.IsTrue(proxy.BlockPrivateNetworkDestinations); + Assert.AreEqual(X509RevocationMode.Offline, proxy.CheckCertificateRevocation); + Assert.AreEqual(new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53), proxy.DnsServerEndPoint); + + Assert.AreEqual(11, proxy.ConnectionTimeOutSeconds); + Assert.AreEqual(12, proxy.ConnectTimeOutSeconds); + Assert.AreEqual(13, proxy.ClientHeaderTimeoutSeconds); + Assert.AreEqual(14, proxy.ResponseHeaderTimeoutSeconds); + Assert.AreEqual(15, proxy.IdleReadTimeoutSeconds); + Assert.AreEqual(16, proxy.IdleWriteTimeoutSeconds); + Assert.AreEqual(17, proxy.RequestTimeoutSeconds); + Assert.AreEqual(3, proxy.NetworkFailureRetryAttempts); + + Assert.IsTrue(proxy.EnableConnectionPool); + Assert.IsFalse(proxy.EnableTcpServerConnectionPrefetch); + Assert.IsTrue(proxy.EnableIpv6UnreachableSoftSkip); + Assert.AreEqual(9, proxy.MaxCachedConnections); + Assert.AreEqual(4, proxy.MaxConcurrentHttp11HttpsOriginCreates); + Assert.AreEqual(123, proxy.MaxConcurrentClientConnections); + Assert.IsTrue(proxy.NoDelay); + Assert.IsTrue(proxy.EnableTcpKeepAlive); + Assert.AreEqual(30, proxy.TcpTimeWaitSeconds); + Assert.AreEqual(64, proxy.ListenerBackLog); + Assert.IsTrue(proxy.ReuseSocket); + Assert.AreEqual(8, proxy.ThreadPoolWorkerThread); + + Assert.AreEqual(2048, proxy.ResourceLimits.MaxHeaderLineBytes); + Assert.AreEqual(50, proxy.ResourceLimits.MaxHeaderCount); + Assert.AreEqual(4096, proxy.MaxBufferedBodyBytes); + Assert.AreEqual(1024, proxy.MaxDecodedHeaderListBytes); + Assert.AreEqual(65536, proxy.MaxWebSocketFramePayloadBytes); + + Assert.AreEqual(PolicyMode.Observe, proxy.PolicyModes[PolicyFamily.BodyBudget]); + Assert.AreEqual(PolicyMode.Enforce, proxy.PolicyModes[PolicyFamily.DecompressionRatio]); + Assert.IsTrue(proxy.PolicyModes.AllowAmbiguousFraming); + + Assert.AreEqual(SslProtocols.Tls12 | SslProtocols.Tls13, proxy.SupportedSslProtocols); + Assert.AreEqual(SslProtocols.Tls13, proxy.SupportedServerSslProtocols); + + Assert.IsTrue(proxy.ForwardToUpstreamGateway); + Assert.AreEqual(new Uri("https://example.test/proxy.pac"), proxy.UpstreamProxyConfigurationScript); + Assert.IsNotNull(proxy.UpStreamHttpProxy); + Assert.AreEqual("proxy.example", proxy.UpStreamHttpProxy!.HostName); + Assert.AreEqual(8080, proxy.UpStreamHttpProxy.Port); + Assert.IsNotNull(proxy.UpStreamHttpProxy.NextHop); + Assert.AreEqual(ExternalProxyType.Socks5, proxy.UpStreamHttpProxy.NextHop!.ProxyType); + Assert.IsNotNull(proxy.UpStreamHttpsProxy); + Assert.AreEqual(new IPEndPoint(IPAddress.Any, 0), proxy.UpStreamEndPoint); + Assert.AreEqual(new IPEndPoint(IPAddress.Loopback, 0), proxy.UpStreamEndPointIPv4); + Assert.AreEqual(new IPEndPoint(IPAddress.IPv6Loopback, 0), proxy.UpStreamEndPointIPv6); + + Assert.AreEqual(CertificateEngine.BouncyCastle, proxy.CertificateManager.CertificateEngine); + Assert.AreEqual(CertificateKeyAlgorithm.Rsa2048, proxy.CertificateManager.LeafCertificateKeyAlgorithm); + Assert.AreEqual("certs/root.pfx", proxy.CertificateManager.PfxFilePath); + Assert.AreEqual("secret", proxy.CertificateManager.PfxPassword); + Assert.IsTrue(proxy.CertificateManager.OverwritePfxFile); + Assert.AreEqual(365, proxy.CertificateManager.CertificateValidDays); + Assert.AreEqual(7, proxy.CertificateManager.CertificateGraceDays); + Assert.AreEqual(60, proxy.CertificateManager.CertificateCacheTimeOutMinutes); + Assert.AreEqual("TWP Test Root", proxy.CertificateManager.RootCertificateName); + Assert.AreEqual("TWP Test Issuer", proxy.CertificateManager.RootCertificateIssuerName); + Assert.IsTrue(proxy.CertificateManager.SaveFakeCertificates); + Assert.IsTrue(proxy.CertificateManager.DisableWildCardCertificates); + } + [TestMethod] public void TryParseSslProtocols_OrsFlags() { Assert.IsTrue(ServerConfigApplier.TryParseSslProtocols(["Tls12", "Tls13"], out var protocols)); - Assert.AreEqual( - System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13, - protocols); + Assert.AreEqual(SslProtocols.Tls12 | SslProtocols.Tls13, protocols); + } + + [TestMethod] + public void TryParseSslProtocols_Invalid_ReturnsFalse() + { + Assert.IsFalse(ServerConfigApplier.TryParseSslProtocols(["Tls12", "Nope"], out var protocols)); + Assert.AreEqual(SslProtocols.None, protocols); } [TestMethod] @@ -64,6 +286,21 @@ public void TryParseEndPoint_IPv4() { Assert.IsTrue(ServerConfigApplier.TryParseEndPoint("127.0.0.1:53", out var ep)); Assert.AreEqual(53, ep.Port); - Assert.AreEqual(System.Net.IPAddress.Loopback, ep.Address); + Assert.AreEqual(IPAddress.Loopback, ep.Address); + } + + [TestMethod] + public void TryParseEndPoint_IPv6Bracketed() + { + Assert.IsTrue(ServerConfigApplier.TryParseEndPoint("[::1]:443", out var ep)); + Assert.AreEqual(443, ep.Port); + Assert.AreEqual(IPAddress.IPv6Loopback, ep.Address); + } + + [TestMethod] + public void TryParseEndPoint_Invalid_ReturnsFalse() + { + Assert.IsFalse(ServerConfigApplier.TryParseEndPoint("not-an-endpoint", out _)); + Assert.IsFalse(ServerConfigApplier.TryParseEndPoint("", out _)); } } diff --git a/tests/Titanium.E2E.Tests/Harness/InspectorHeadlessFixture.cs b/tests/Titanium.E2E.Tests/Harness/InspectorHeadlessFixture.cs index 7de8482eb..20d37fa62 100644 --- a/tests/Titanium.E2E.Tests/Harness/InspectorHeadlessFixture.cs +++ b/tests/Titanium.E2E.Tests/Harness/InspectorHeadlessFixture.cs @@ -78,6 +78,28 @@ public async Task DispatchAsync(Func action) => Dispatcher.UIThread.RunJobs(); }, CancellationToken.None); + /// + /// Wait until is true, pumping the Avalonia dispatcher each poll so + /// async RelayCommand continuations (StatusText after file I/O) can run in headless. + /// + public async Task WaitUntilAsync(Func condition, TimeSpan timeout, int pollMs = 50) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + await DispatchAsync(() => { }); + if (condition()) + { + return; + } + + await Task.Delay(pollMs); + } + + // Final pump so callers assert against the latest UI state. + await DispatchAsync(() => { }); + } + public async ValueTask DisposeAsync() { if (_session is not null) diff --git a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs index df51201f3..8d944d22f 100644 --- a/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs +++ b/tests/Titanium.E2E.Tests/UiHeadless/AutomationIdCoverageHeadlessTests.cs @@ -264,12 +264,9 @@ await fx.DispatchAsync(() => fx.Robot.Click("MenuExportArchive"); }); - var exportDeadline = DateTime.UtcNow.AddSeconds(15); - while (DateTime.UtcNow < exportDeadline && - !fx.ViewModel.StatusText.Contains("Exported 1 sessions", StringComparison.Ordinal)) - { - await Task.Delay(50); - } + await fx.WaitUntilAsync( + () => fx.ViewModel.StatusText.Contains("Exported 1 sessions", StringComparison.Ordinal), + TimeSpan.FromSeconds(15)); await fx.DispatchAsync(() => { @@ -296,13 +293,10 @@ await fx.DispatchAsync(() => await fx.DispatchAsync(() => fx.Robot.Click("MenuImportArchive")); - var importDeadline = DateTime.UtcNow.AddSeconds(15); - while (DateTime.UtcNow < importDeadline && - !fx.ViewModel.StatusText.Contains("Appended", StringComparison.Ordinal) && - !fx.ViewModel.StatusText.Contains("Import archive failed", StringComparison.Ordinal)) - { - await Task.Delay(50); - } + await fx.WaitUntilAsync( + () => fx.ViewModel.StatusText.Contains("Appended", StringComparison.Ordinal) + || fx.ViewModel.StatusText.Contains("Import archive failed", StringComparison.Ordinal), + TimeSpan.FromSeconds(15)); await fx.DispatchAsync(() => { diff --git a/tests/Titanium.E2E.Tests/UiHeadless/InspectTabsAndToolsHeadlessTests.cs b/tests/Titanium.E2E.Tests/UiHeadless/InspectTabsAndToolsHeadlessTests.cs index 7c346b7ab..5e0575980 100644 --- a/tests/Titanium.E2E.Tests/UiHeadless/InspectTabsAndToolsHeadlessTests.cs +++ b/tests/Titanium.E2E.Tests/UiHeadless/InspectTabsAndToolsHeadlessTests.cs @@ -89,13 +89,10 @@ await fx.DispatchAsync(() => fx.Robot.Click("MenuExportHar"); }); - // 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)) - { - await Task.Delay(50); - } + // ExportHarCommand is async; pump dispatcher while waiting so StatusText continuations run. + await fx.WaitUntilAsync( + () => fx.ViewModel.StatusText.Contains("Exported 1 sessions", StringComparison.Ordinal), + TimeSpan.FromSeconds(15)); await fx.DispatchAsync(() => { diff --git a/tests/Titanium.Inspector.Tests/ExportAndSystemProxyCoverageTests.cs b/tests/Titanium.Inspector.Tests/ExportAndSystemProxyCoverageTests.cs index dffac6c0a..1b16da456 100644 --- a/tests/Titanium.Inspector.Tests/ExportAndSystemProxyCoverageTests.cs +++ b/tests/Titanium.Inspector.Tests/ExportAndSystemProxyCoverageTests.cs @@ -99,6 +99,53 @@ public async Task ExportCommands_CoverEmptyCancelAndSelectedPaths() } } + [TestMethod] + public async Task ExportHarCommands_ReportFailureWhenPathCannotBeWritten() + { + var settingsPath = Path.Combine(Path.GetTempPath(), "twp-export-fail-" + Guid.NewGuid().ToString("N") + ".json"); + try + { + var settings = new SettingsService(settingsPath); + var registry = new SessionRegistry(); + var picker = new ScriptedInspectorPathPicker(); + using var interception = new InterceptionService(new RecordingSystemProxyController()); + var vm = new MainWindowViewModel( + new SessionStreamBuffer(registry), + registry, + new UpdateService(settings), + settings, + interception, + pathPicker: picker); + + var snap = new SessionSnapshot + { + Id = 7, + Method = "GET", + Url = "https://example.com/", + Host = "example.com", + }; + vm.SeedSession(snap); + vm.SelectedSession = snap; + vm.SetSelectedSessions([snap]); + + var missingDir = Path.Combine(Path.GetTempPath(), "twp-missing-" + Guid.NewGuid().ToString("N"), "out.har"); + picker.SavePath = missingDir; + await ExecuteAsync(vm.ExportHarCommand); + StringAssert.Contains(vm.StatusText, "Export HAR failed"); + + picker.SavePath = missingDir; + await ExecuteAsync(vm.ExportSelectedHarCommand); + StringAssert.Contains(vm.StatusText, "Export HAR failed"); + } + finally + { + if (File.Exists(settingsPath)) + { + File.Delete(settingsPath); + } + } + } + [TestMethod] public async Task StartStopCapture_AndSystemProxyWithoutCapture_AreCovered() { diff --git a/wiki/images/inspector-screenshot.jpg b/wiki/images/inspector-screenshot.jpg index 02cd3c8e9..35921ee0b 100644 Binary files a/wiki/images/inspector-screenshot.jpg and b/wiki/images/inspector-screenshot.jpg differ