From 354676a1e7e119180624e89551e6b9c01c042bbe Mon Sep 17 00:00:00 2001 From: PatrykLs98 Date: Thu, 27 Aug 2026 09:25:59 +0200 Subject: [PATCH 1/5] Add UI regression tests and test runner --- run-ui-tests.ps1 | 148 ++++++++++++++++++ .../Infrastructure/NativeCommands.cs | 13 ++ .../Infrastructure/UiTestSettings.cs | 20 +++ tests/FileManager.UiTests/README.md | 29 +++- .../ReportedDefectCharacterizationUiTests.cs | 104 ++++++++++++ .../SChannelTlsIntegrationTests.cs | 90 ++++++++--- 6 files changed, 376 insertions(+), 28 deletions(-) create mode 100644 run-ui-tests.ps1 create mode 100644 tests/FileManager.UiTests/ReportedDefectCharacterizationUiTests.cs diff --git a/run-ui-tests.ps1 b/run-ui-tests.ps1 new file mode 100644 index 0000000..cbdfe2c --- /dev/null +++ b/run-ui-tests.ps1 @@ -0,0 +1,148 @@ +[CmdletBinding()] +param( + [string]$ExecutablePath, + [string]$Filter = 'TestCategory=UI', + [string]$Logger = 'console;verbosity=normal', + [switch]$NoBuild, + [switch]$ListTests, + [switch]$ConfirmIsolatedProfile, + [string[]]$AdditionalDotNetArguments = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = $PSScriptRoot +$testProject = Join-Path $repositoryRoot 'tests\FileManager.UiTests\FileManager.UiTests.csproj' + +function Resolve-FileManagerExecutable { + param([string]$RequestedPath) + + if (-not [string]::IsNullOrWhiteSpace($RequestedPath)) { + # An explicit selection is authoritative so a typo cannot silently run a different executable. + if (-not (Test-Path -LiteralPath $RequestedPath -PathType Leaf)) { + throw "The requested FileManager executable was not found: $RequestedPath" + } + return (Resolve-Path -LiteralPath $RequestedPath).Path + } + + $candidates = [System.Collections.Generic.List[string]]::new() + if (-not [string]::IsNullOrWhiteSpace($env:FILEMANAGER_UI_EXE)) { + # Preserve an explicit caller selection before checking this checkout's standard build locations. + $candidates.Add($env:FILEMANAGER_UI_EXE) + } + foreach ($relativePath in @( + 'src\vcxproj\salamander\Debug_x64\salamand.exe', + 'src\vcxproj\salamander\Release_x64\salamand.exe', + 'src\vcxproj\salamander\Debug_x86\salamand.exe', + 'src\vcxproj\salamander\Release_x86\salamand.exe' + )) { + # Checkout-relative discovery remains portable across users, drive letters, and repository locations. + $candidates.Add((Join-Path $repositoryRoot $relativePath)) + } + + foreach ($candidate in $candidates) { + if (-not [string]::IsNullOrWhiteSpace($candidate) -and + (Test-Path -LiteralPath $candidate -PathType Leaf)) { + return (Resolve-Path -LiteralPath $candidate).Path + } + } + + # Do not silently fall back to an installed copy: a stale executable makes every native-command test fail misleadingly. + throw 'No checkout salamand.exe was found. Build the current branch or pass -ExecutablePath explicitly.' +} + +if (-not $ConfirmIsolatedProfile) { + # Explicit acknowledgement keeps interactive UI work out of a normal unattended desktop session. + throw 'Re-run with -ConfirmIsolatedProfile to use the guarded filesystem and registry test sandbox.' +} +if (-not (Test-Path -LiteralPath $testProject -PathType Leaf)) { + throw "The UI test project was not found: $testProject" +} +if ($null -eq (Get-Command dotnet -ErrorAction SilentlyContinue)) { + throw 'dotnet was not found on PATH.' +} + +$resolvedExecutable = Resolve-FileManagerExecutable $ExecutablePath +$executableDirectory = Split-Path -Parent $resolvedExecutable +$crashReporter = Join-Path $executableDirectory 'salmon.exe' +# Fail once in preflight instead of letting every UI case stall on the application's missing-reporter dialog. +if (-not (Test-Path -LiteralPath $crashReporter -PathType Leaf)) { + throw "The selected test artifact is incomplete; salmon.exe is missing beside salamand.exe: $crashReporter" +} + +$sandboxParent = Join-Path ([IO.Path]::GetTempPath()) ('OpenSalamanderUiTests-' + [Guid]::NewGuid().ToString('N')) +$testDataRoot = Join-Path $sandboxParent 'filemanager-testdata' +$configurationRoot = 'Software\Open Salamander\6.0-filemanager-testdata' +$savedEnvironment = @{ + FILEMANAGER_UI_ISOLATED = $env:FILEMANAGER_UI_ISOLATED + FILEMANAGER_UI_EXE = $env:FILEMANAGER_UI_EXE + FILEMANAGER_UI_TESTDATA_ROOT = $env:FILEMANAGER_UI_TESTDATA_ROOT + FILEMANAGER_UI_CONFIG_ROOT = $env:FILEMANAGER_UI_CONFIG_ROOT +} +$testExitCode = 1 + +try { + $env:FILEMANAGER_UI_ISOLATED = '1' + $env:FILEMANAGER_UI_EXE = $resolvedExecutable + $env:FILEMANAGER_UI_TESTDATA_ROOT = $testDataRoot + $env:FILEMANAGER_UI_CONFIG_ROOT = $configurationRoot + + $arguments = [System.Collections.Generic.List[string]]::new() + $arguments.Add('test') + $arguments.Add($testProject) + if ($NoBuild) { + # A no-build rerun also avoids restore, which would regenerate ignored NuGet intermediates. + $arguments.Add('--no-build') + $arguments.Add('--no-restore') + } + if ($ListTests) { + $arguments.Add('--list-tests') + } + if (-not [string]::IsNullOrWhiteSpace($Filter)) { + $arguments.Add('--filter') + $arguments.Add($Filter) + } + if (-not [string]::IsNullOrWhiteSpace($Logger)) { + $arguments.Add('--logger') + $arguments.Add($Logger) + } + foreach ($argument in $AdditionalDotNetArguments) { + $arguments.Add($argument) + } + + Write-Host 'Running FileManager UI tests' + Write-Host " Project: $testProject" + Write-Host " Executable: $resolvedExecutable" + Write-Host " Sandbox: $testDataRoot" + Write-Host " Filter: $Filter" + + Push-Location $repositoryRoot + try { + & dotnet @($arguments.ToArray()) + $testExitCode = $LASTEXITCODE + } + finally { + Pop-Location + } +} +finally { + # Restore the caller's shell exactly so focused reruns cannot contaminate later commands. + foreach ($name in $savedEnvironment.Keys) { + $value = $savedEnvironment[$name] + if ($null -eq $value) { + Remove-Item -Path "Env:$name" -ErrorAction SilentlyContinue + } + else { + Set-Item -Path "Env:$name" -Value $value + } + } + + # NUnit removes the marked child on a healthy run; remove only the unique empty parent created above. + if ((Test-Path -LiteralPath $sandboxParent -PathType Container) -and + @(Get-ChildItem -LiteralPath $sandboxParent -Force).Count -eq 0) { + Remove-Item -LiteralPath $sandboxParent + } +} + +exit $testExitCode diff --git a/tests/FileManager.UiTests/Infrastructure/NativeCommands.cs b/tests/FileManager.UiTests/Infrastructure/NativeCommands.cs index 2701ff0..f483288 100644 --- a/tests/FileManager.UiTests/Infrastructure/NativeCommands.cs +++ b/tests/FileManager.UiTests/Infrastructure/NativeCommands.cs @@ -13,10 +13,14 @@ internal static class NativeCommands internal const int MoveFiles = 728; internal const int DeleteFiles = 729; internal const int CreateDirectory = 730; + // CM_OPEN drives the same archive-opening path as the Files > Open action without localized menu lookup. + internal const int OpenFile = 732; // These stable host commands cover the three distinct file-discovery and file-opening paths. internal const int FindFiles = 741; internal const int ViewFile = 742; internal const int EditFile = 743; + // CM_HELP_SEARCH opens the HTML Help search tab while keeping the test independent of translated menu labels. + internal const int HelpSearch = 2212; internal const int RenameFile = 754; // CM_ACTIVEREFRESH synchronously refreshes the active file panel before a test quick-searches newly created files. internal const int RefreshActivePanel = 740; @@ -39,6 +43,8 @@ internal static class NativeCommands private const int ConfigurationClearReadOnlyCheckBox = 304; internal const int OperationPathControl = 210; private const int VkEscape = 0x1B; + // HTML Help recognizes VK_RETURN from its search edit even when UIA exposes no reliable submit action. + private const int VkReturn = 0x0D; private const uint WmSetText = 0x000C; private const uint WmGetText = 0x000D; private const uint WmGetTextLength = 0x000E; @@ -524,6 +530,13 @@ internal static void QuickSearch(nint listHandle, string name) SendMessage(listHandle, WmChar, character, 1); } + internal static void PressEnter(nint controlHandle) + { + // HTML Help commits its search field through the native Enter key path rather than a reliable UIA action. + SetFocus(controlHandle); + SendMessage(controlHandle, WmKeyDown, VkReturn, 0); + } + internal static void ToggleFocusedSelection(nint listHandle) { // Insert is the native panel gesture that selects the focused item while preserving prior selections. diff --git a/tests/FileManager.UiTests/Infrastructure/UiTestSettings.cs b/tests/FileManager.UiTests/Infrastructure/UiTestSettings.cs index 3ec5f2a..a79c68a 100644 --- a/tests/FileManager.UiTests/Infrastructure/UiTestSettings.cs +++ b/tests/FileManager.UiTests/Infrastructure/UiTestSettings.cs @@ -50,6 +50,26 @@ internal static void RequireConfigurationFaultInjection() Assert.Ignore("Set FILEMANAGER_UI_CONFIG_FAULT_INJECTION=1 to run the structural configuration write-boundary recovery test."); } + internal static void RequireZipPlugin() + { + RequireTestSandbox(); + // The opt-in confirms deployment and enablement because the public UI cannot distinguish a disabled plug-in from an absent one. + if (!string.Equals(Environment.GetEnvironmentVariable("FILEMANAGER_UI_ZIP_PLUGIN"), "1", StringComparison.Ordinal)) + Assert.Ignore("Install and enable the Zip plug-in, then set FILEMANAGER_UI_ZIP_PLUGIN=1 to run ZIP navigation characterization."); + } + + internal static (string SearchTerm, string ExpectedResult) RequireHelpSearchFixture() + { + RequireTestSandbox(); + var term = Environment.GetEnvironmentVariable("FILEMANAGER_UI_HELP_SEARCH_TERM"); + var expected = Environment.GetEnvironmentVariable("FILEMANAGER_UI_HELP_EXPECTED_RESULT"); + // Language-specific input keeps the contract meaningful for every deployed CHM rather than assuming English result text. + if (string.IsNullOrWhiteSpace(term) || string.IsNullOrWhiteSpace(expected)) + Assert.Ignore("Deploy salamand.chm and set FILEMANAGER_UI_HELP_SEARCH_TERM plus FILEMANAGER_UI_HELP_EXPECTED_RESULT for its language."); + + return (term!, expected!); + } + internal static string RequireCrossVolumeRoot() { var root = Environment.GetEnvironmentVariable("FILEMANAGER_UI_CROSS_VOLUME_ROOT"); diff --git a/tests/FileManager.UiTests/README.md b/tests/FileManager.UiTests/README.md index 858f56e..b0d2d3c 100644 --- a/tests/FileManager.UiTests/README.md +++ b/tests/FileManager.UiTests/README.md @@ -1,10 +1,10 @@ # FileManager UI tests -This project contains 100 basic, parameterized FlaUI/UIA3 NUnit cases plus focused file-operation characterization cases for the native FileManager UI. The cases cover application launch, accessibility-tree discovery, Configuration dialog cancel/commit/restart flows, a committed setting verified after restart, FTP bookmark creation plus edit verified after restart, and native disk create/copy/rename/move/delete/find/view/edit commands. +This project contains parameterized FlaUI/UIA3 lifecycle cases together with focused file-operation, recovery, plug-in, toolbar, TLS, and native-safety characterization tests for the native FileManager UI. The cases cover application launch, accessibility-tree discovery, Configuration dialog cancel/commit/restart flows, a committed setting verified after restart, FTP bookmark creation plus edit verified after restart, and native disk create/copy/rename/move/delete/find/view/edit commands. The tests run as the current interactive Windows user only when a guarded filesystem and registry sandbox are selected. The harness creates and deletes its own `filemanager-testdata` directory and the suffixed registry key after each run. -Set these environment variables before running: +The repository runner configures the guarded test-data root, configuration root, and executable automatically. Set variables manually only when invoking `dotnet test` directly or enabling an optional lane: - `FILEMANAGER_UI_TESTDATA_ROOT` — absolute path ending in `filemanager-testdata`; all test-created files live below it. - `FILEMANAGER_UI_CONFIG_ROOT=Software\Open Salamander\6.0-filemanager-testdata` — selects the registry tree created and removed by the harness. @@ -12,9 +12,12 @@ Set these environment variables before running: - `FILEMANAGER_UI_ARGUMENTS` — optional command-line arguments, for example a test-only `-c` configuration file. - FTP menu IDs are published with the launching process ID below the owned test-data root, so each fixture waits for the exact FileManager instance it controls before issuing a plug-in command. - `FILEMANAGER_UI_CONFIG_FAULT_INJECTION=1` — explicitly enables the exhaustive transactional-configuration crash-recovery lane described below. -- `FILEMANAGER_UI_CROSS_VOLUME_ROOT` — selected automatically as `D:\filemanager-testdata` when fixed writable `D:\` is available. This enables the cross-volume move characterization fixture; the fixture creates and removes only a GUID-named child below this directory. -- `FILEMANAGER_UI_ADS_UNSUPPORTED_TARGET_ROOT` — selected automatically when fixed writable `D:\` uses FAT/FAT32/exFAT. On NTFS `D:\`, the ADS-unsupported scenario is reported as an allowed capability skip. +- `FILEMANAGER_UI_CROSS_VOLUME_ROOT` — selected automatically by `scripts/runtests.ps1` when fixed writable `D:\` is available, or supplied manually for the focused runner. This enables the cross-volume move characterization fixture; the fixture creates and removes only a GUID-named child below this directory. +- `FILEMANAGER_UI_ADS_UNSUPPORTED_TARGET_ROOT` — selected automatically by `scripts/runtests.ps1` when fixed writable `D:\` uses FAT/FAT32/exFAT, or supplied manually for the focused runner. On NTFS, the ADS-unsupported scenario is reported as an allowed capability skip. - `FILEMANAGER_UI_RECYCLE_BIN=1` — explicitly enables the recycle-bin characterization test. It requires the default recycle-bin delete setting in the isolated profile and adds one disposable file to that profile's recycle bin. +- `FILEMANAGER_UI_ZIP_PLUGIN=1` — confirms that the Zip plug-in is deployed and enabled for the reported ZIP-navigation characterization test. +- `FILEMANAGER_UI_HELP_SEARCH_TERM` — a search term known to exist in the language-specific deployed `salamand.chm`. +- `FILEMANAGER_UI_HELP_EXPECTED_RESULT` — result text expected for that Help search term. The test runner never mounts test virtual disks. If fixed writable `D:\` is unavailable, all second-volume-dependent tests are reported as successful, explicit capability skips. @@ -22,18 +25,32 @@ The complete UI suite also requires `SeCreateSymbolicLinkPrivilege` for its disp runner lacks that privilege, it reports the missing privilege and skips the complete UI suite as an allowed environment limitation; the UI tests are not reported as completed. -Run the suite on an interactive Windows desktop session: +The repository runner creates the guarded filesystem and registry sandbox automatically. On an interactive Windows desktop session, its default filter runs the complete UI category: ```powershell -dotnet test tests/FileManager.UiTests/FileManager.UiTests.csproj --filter TestCategory=UI +.\run-ui-tests.ps1 -ConfirmIsolatedProfile ``` +Run every discovered test in the project, including non-UI contract and TLS cases, by clearing the default filter: + +```powershell +.\run-ui-tests.ps1 -ConfirmIsolatedProfile -Filter '' +``` + +The runner derives its paths from the checkout and deliberately does not fall back to an installed application, because a stale executable can turn all native-command cases into misleading failures. Build the current branch first, or pass its artifact explicitly with `-ExecutablePath`. `-NoBuild` skips both managed build and restore for a repeated run; it does not build the native application. + The configuration dialog is opened through its stable native command ID only to avoid locale-dependent menu text. All window discovery, control inspection, focus, dialog lifecycle, and restart assertions use FlaUI/UIA3. File-operation cases create a fresh GUID directory below `filemanager-testdata` for every test, start the left and right panels with `-l`/`-r`, and use the host's stable native command IDs. They verify files and nested directory trees after normal operations, copy and move overwrite/skip conflict choices, rename overwrite-decline and case-only behavior, mixed-selection deletion, continuation after a skipped delete error, file search, internal viewing, configured-editor launch, metadata preservation, cancellation after the worker has started, and destination/name failures. ADS cases create named, empty, large, and edge-named streams; exercise overwrite and retry after a temporarily denied stream; and verify cross-volume preservation or explicit source retention when an ADS-unsupported target reports metadata loss. The recovery fixture seeds an incomplete durable journal with a ready transactional sibling file before launch, then verifies the real startup reconciliation flow. The tests never empty the recycle bin; they only inspect it after moving test-root data to it. The FTP plug-in menu command has no compile-time host command ID: FileManager allocates it while loading plug-ins. Keep the value in the isolated test environment rather than hard-coding it into the test project. The dialog controls themselves are located by their stable plug-in resource IDs and their persistence is asserted through UIA3 after a full application restart. +## Reported-defect characterization + +`ReportedDefectCharacterizationUiTests` records ZIP navigation after its information dialog and language-specific Help Search results without changing application behavior. A failed product assertion is valid evidence that the reported defect remains; a stalled or crashed test host is not an acceptable result. Existing focused cases cover the other two reports: `Move_overwrite_replaces_the_existing_target_and_removes_the_source` verifies move-collision replacement, and `Edit_file_opens_the_selected_file_in_the_configured_editor` verifies the Files > Edit command through a harness-owned editor. + +ZIP is skipped unless `FILEMANAGER_UI_ZIP_PLUGIN=1` explicitly confirms deployment and enablement. Help Search is skipped unless `help//salamand.chm` is deployed beside the selected executable and both Help fixture variables are set. The tests must not install or reconfigure plug-ins, replace help content, or modify application behavior to make an assertion pass. + ## Live MojeRzeczy FTPS UI lane `MojeRzeczyFtpsUiTests` contacts an external server and is deliberately marked `Explicit` and excluded from `scripts/runtests.ps1` and CI. It reads its credentials only from the variables used by `C:\Projects\FtpMojerzeczy`, configures explicit FTPS on port 21 with passive and binary transfer, accepts a hostname-invalid certificate for the disposable session only, dismisses the plug-in's modeless welcome message, then downloads `/skan.txt` into the disposable test-data root. It waits for the worker to release the file and verifies the downloaded size against `C:\Projects\FtpMojerzeczy\skan.txt`. When the Debug build shows an error dialog, the test records its native text under `TestResults\ftp-debug-error-dialogs`, dismisses it, and fails rather than waiting for desktop input. If either credential is absent, it passes without opening an FTP connection and reports that FTP UI tests have not been performed due to missing credentials. diff --git a/tests/FileManager.UiTests/ReportedDefectCharacterizationUiTests.cs b/tests/FileManager.UiTests/ReportedDefectCharacterizationUiTests.cs new file mode 100644 index 0000000..7d08867 --- /dev/null +++ b/tests/FileManager.UiTests/ReportedDefectCharacterizationUiTests.cs @@ -0,0 +1,104 @@ +using System.IO.Compression; +using FileManager.UiTests.Infrastructure; +using FlaUI.Core.AutomationElements; +using NUnit.Framework; + +namespace FileManager.UiTests; + +[TestFixture] +public sealed class ReportedDefectCharacterizationUiTests : FileOperationUiTestBase +{ + private const string ZipName = "zip-open-characterization.zip"; + private const string ZipPayloadName = "zip-open-payload.txt"; + + protected override void SeedWorkspaceBeforeFileManagerStart(FileOperationWorkspace workspace) + { + // Seed the archive before launch so the native panel's initial enumeration contains the reported fixture. + using var archive = ZipFile.Open(workspace.SourcePath(ZipName), ZipArchiveMode.Create); + var entry = archive.CreateEntry(ZipPayloadName); + using var writer = new StreamWriter(entry.Open()); + writer.Write("zip-characterization-content"); + } + + [Test] + public void Zip_open_after_information_dialog_navigates_into_archive() + { + UiTestSettings.RequireZipPlugin(); + + SelectSourceItem(ZipName); + NativeCommands.Execute(MainWindow.Properties.NativeWindowHandle.Value, NativeCommands.OpenFile); + DismissOptionalInformationDialog(); + + WaitForFileSystem( + () => NativeCommands.GetWindowTitle(MainWindow.Properties.NativeWindowHandle.Value) + .Contains(ZipName, StringComparison.OrdinalIgnoreCase), + "After accepting the ZIP-open message, FileManager did not navigate into the selected archive."); + + // Quick search exercises the archive listing after navigation instead of accepting a decorative title change alone. + SelectSourceItem(ZipPayloadName); + } + + [Test] + public void Help_search_returns_the_configured_existing_help_result() + { + var (searchTerm, expectedResult) = UiTestSettings.RequireHelpSearchFixture(); + RequireDeployedMainHelp(); + Window? helpWindow = null; + try + { + NativeCommands.Execute(MainWindow.Properties.NativeWindowHandle.Value, NativeCommands.HelpSearch); + helpWindow = WaitForDesktopWindow(window => + string.Equals(window.Properties.ClassName.ValueOrDefault, "HH Parent", StringComparison.Ordinal), + "The HTML Help Search window did not open."); + + var searchInput = helpWindow.FindAllDescendants() + .FirstOrDefault(element => element.ControlType == FlaUI.Core.Definitions.ControlType.Edit && element.IsEnabled) + ?.AsTextBox(); + Assert.That(searchInput, Is.Not.Null, "The Help Search panel did not expose an enabled search input."); + + searchInput!.Text = searchTerm; + NativeCommands.PressEnter(searchInput.Properties.NativeWindowHandle.Value); + + WaitForFileSystem( + () => helpWindow.FindAllDescendants() + .Any(element => element.Name.Contains(expectedResult, StringComparison.OrdinalIgnoreCase)), + $"Help Search did not return the configured existing result '{expectedResult}' for '{searchTerm}'."); + } + finally + { + // HTML Help runs outside the FileManager process, so close only the window captured for this test. + if (helpWindow is not null && NativeCommands.WindowExists(helpWindow.Properties.NativeWindowHandle.Value)) + { + helpWindow.Close(); + WaitForWindowToClose(helpWindow); + } + } + } + + private void DismissOptionalInformationDialog() + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(3); + while (DateTime.UtcNow < deadline) + { + var dialog = NativeCommands.GetTopLevelWindows(Application.ProcessId) + .FirstOrDefault(handle => handle != MainWindow.Properties.NativeWindowHandle.Value && + NativeCommands.HasDialogButton(handle, 1)); + if (dialog != 0) + { + NativeCommands.ClickDialogButton(dialog, 1); // IDOK + return; + } + Thread.Sleep(100); + } + } + + private static void RequireDeployedMainHelp() + { + var executableDirectory = Path.GetDirectoryName(UiTestSettings.ExecutablePath)!; + var helpDirectory = Path.Combine(executableDirectory, "help"); + // Searching cannot be characterized when the selected artifact has no compiled main help archive. + if (!Directory.Exists(helpDirectory) || + !Directory.EnumerateFiles(helpDirectory, "salamand.chm", SearchOption.AllDirectories).Any()) + Assert.Ignore("Deploy help//salamand.chm beside the tested executable to run Help Search characterization."); + } +} diff --git a/tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs b/tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs index 217a99d..ec1389b 100644 --- a/tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs +++ b/tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs @@ -12,17 +12,21 @@ namespace FileManager.UiTests; [NonParallelizable] public sealed class SChannelTlsIntegrationTests { + // Bound both peers so an SChannel negotiation failure is reported instead of pinning the test host indefinitely. + private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(15); + [TestCase(SslProtocols.Tls12)] [TestCase(SslProtocols.Tls13)] public async Task LocalTlsServerNegotiatesTheRequiredProtocol(SslProtocols protocol) { + using var timeout = new CancellationTokenSource(HandshakeTimeout); using var certificate = CreateCertificate(); using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); - var server = AcceptAndAuthenticateAsync(listener, certificate, protocol); + var server = AcceptAndAuthenticateAsync(listener, certificate, protocol, timeout.Token); using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port); + await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port, timeout.Token); using var stream = new SslStream(client.GetStream(), false, (_, _, _, _) => true); try { @@ -31,45 +35,81 @@ await stream.AuthenticateAsClientAsync(new SslClientAuthenticationOptions TargetHost = "localhost", EnabledSslProtocols = protocol, CertificateRevocationCheckMode = X509RevocationMode.NoCheck - }); + }, timeout.Token); + + Assert.That(stream.SslProtocol, Is.EqualTo(protocol)); + Assert.That(await server.WaitAsync(timeout.Token), Is.EqualTo(protocol)); + } + catch (OperationCanceledException error) when (timeout.IsCancellationRequested) + { + throw new AssertionException($"The local {protocol} SChannel handshake did not complete within {HandshakeTimeout.TotalSeconds:0} seconds.", error); } catch (Exception clientError) { - try { await server; } + try { await server.WaitAsync(timeout.Token); } + catch (OperationCanceledException serverError) when (timeout.IsCancellationRequested) + { + // Preserve the initiating client failure while classifying the peer cancellation as the shared deadline. + throw new AssertionException($"The local {protocol} SChannel handshake did not complete within {HandshakeTimeout.TotalSeconds:0} seconds.", + new AggregateException(clientError, serverError)); + } catch (Exception serverError) { throw new AssertionException($"TLS server failed: {serverError}", clientError); } throw; } - - Assert.That(stream.SslProtocol, Is.EqualTo(protocol)); - Assert.That(await server, Is.EqualTo(protocol)); + finally + { + await StopAndObserveServerAsync(timeout, listener, server); + } } [Test] public async Task SelfSignedServerCertificateIsRejectedWithoutAnExplicitUserException() { + using var timeout = new CancellationTokenSource(HandshakeTimeout); using var certificate = CreateCertificate(); using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); - var server = AcceptAndAuthenticateAsync(listener, certificate, SslProtocols.Tls12); + var server = AcceptAndAuthenticateAsync(listener, certificate, SslProtocols.Tls12, timeout.Token); using var client = new TcpClient(); - await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port); + await client.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port, timeout.Token); using var stream = new SslStream(client.GetStream(), false); - Assert.That(async () => await stream.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + try { - TargetHost = "localhost", - EnabledSslProtocols = SslProtocols.Tls12, - CertificateRevocationCheckMode = X509RevocationMode.NoCheck - }), Throws.TypeOf()); + Assert.That(async () => await stream.AuthenticateAsClientAsync(new SslClientAuthenticationOptions + { + TargetHost = "localhost", + EnabledSslProtocols = SslProtocols.Tls12, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck + }, timeout.Token), Throws.TypeOf()); - try { await server; } - catch (AuthenticationException) { } + try { await server.WaitAsync(timeout.Token); } + catch (AuthenticationException) { } + } + catch (OperationCanceledException error) when (timeout.IsCancellationRequested) + { + throw new AssertionException($"The self-signed certificate rejection did not complete within {HandshakeTimeout.TotalSeconds:0} seconds.", error); + } + finally + { + await StopAndObserveServerAsync(timeout, listener, server); + } } - private static async Task AcceptAndAuthenticateAsync(TcpListener listener, X509Certificate2 certificate, SslProtocols protocol) + private static async Task StopAndObserveServerAsync(CancellationTokenSource timeout, TcpListener listener, Task server) { - using var server = await listener.AcceptTcpClientAsync(); + // Cancel, close, and observe every peer task so a failed handshake cannot retain a listener or test-host thread. + timeout.Cancel(); + listener.Stop(); + try { await server.WaitAsync(TimeSpan.FromSeconds(1)); } + catch { } + } + + private static async Task AcceptAndAuthenticateAsync(TcpListener listener, X509Certificate2 certificate, + SslProtocols protocol, CancellationToken cancellationToken) + { + using var server = await listener.AcceptTcpClientAsync(cancellationToken); using var stream = new SslStream(server.GetStream(), false); await stream.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { @@ -77,7 +117,7 @@ await stream.AuthenticateAsServerAsync(new SslServerAuthenticationOptions EnabledSslProtocols = protocol, ClientCertificateRequired = false, CertificateRevocationCheckMode = X509RevocationMode.NoCheck - }); + }, cancellationToken); return stream.SslProtocol; } @@ -92,8 +132,14 @@ private static X509Certificate2 CreateCertificate() request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); using var generated = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); - // Windows SChannel cannot use an ephemeral private-key handle for a - // server credential, so use the .NET 10-compatible PFX loader with UserKeySet. - return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.UserKeySet, null); + // SChannel requires a persisted server key; prefer the current-user store so the tests do not require machine-key write access. + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(appData)) + { + Directory.CreateDirectory(Path.Combine(appData, "Microsoft", "Crypto", "RSA")); + Directory.CreateDirectory(Path.Combine(appData, "Microsoft", "Crypto", "Keys")); + } + return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null, + X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable, null); } } From e4ab383f3d53bb5643bd4f1ed857f534d1484d18 Mon Sep 17 00:00:00 2001 From: PatrykLs98 Date: Thu, 27 Aug 2026 09:26:36 +0200 Subject: [PATCH 2/5] Fix file panel navigation behavior --- src/fileswindow_navigation.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/fileswindow_navigation.cpp b/src/fileswindow_navigation.cpp index 536d5f6..36ec818 100644 --- a/src/fileswindow_navigation.cpp +++ b/src/fileswindow_navigation.cpp @@ -2001,7 +2001,10 @@ BOOL IsWin64RedirectedDirAux(const char* subDir, const char* redirectedDir, cons BOOL IsWin64RedirectedDir(const char* path, char** lastSubDir, BOOL failIfDirWithSameNameExists) { CWidePath pathW(path); - return IsWin64RedirectedDirW(pathW.CStr(), NULL, failIfDirWithSameNameExists); + // Redirection comparisons need the original spelling; reject failed UTF-16 conversion instead of using a removed ambiguous accessor. + if (!pathW.IsValid()) + return FALSE; + return IsWin64RedirectedDirW(pathW.GetDisplayPath(), NULL, failIfDirWithSameNameExists); } BOOL ContainsWin64RedirectedDir(CFilesWindow* panel, int* indexes, int count, char* redirectedDir, BOOL onlyAdded) @@ -2020,7 +2023,9 @@ BOOL ContainsWin64RedirectedDir(CFilesWindow* panel, int* indexes, int count, ch { CPathW fullPathW(pathW); CWidePath dirNameW(dir->Name); - fullPathW.Append(dirNameW.CStr()); + // Preserve the display spelling while composing the path and stop safely if conversion or allocation fails. + if (!dirNameW.IsValid() || !fullPathW.Append(dirNameW.GetDisplayPath())) + continue; if (IsWin64RedirectedDirW(fullPathW.CStr(), NULL, onlyAdded)) { if (FAILED(StringCchCopyA(redirectedDir, MAX_PATH, dir->Name))) From 0839b1a030cf4862624fae584224be89ac9218e3 Mon Sep 17 00:00:00 2001 From: PatrykLs98 Date: Thu, 27 Aug 2026 09:26:58 +0200 Subject: [PATCH 3/5] Fix CI build configuration for Visual Studio 2026 --- .github/workflows/pr-msbuild.yml | 35 ++++++++++++++++++++++++++----- tools/setup-vs2026-buildtools.ps1 | 19 +++++++---------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pr-msbuild.yml b/.github/workflows/pr-msbuild.yml index 1bbcb2d..effdcd7 100644 --- a/.github/workflows/pr-msbuild.yml +++ b/.github/workflows/pr-msbuild.yml @@ -54,9 +54,31 @@ jobs: - name: Setup MSBuild uses: microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce # v2 - - name: Setup MSVC Developer Command Prompt (x64 toolchain) - # Establish the VS 2026 environment before invoking every native build. - run: .\tools\setup-vs2026-buildtools.ps1 + - name: Setup MSVC Developer Command Prompt (${{ matrix.platform }} target) + run: | + # VsDevCmd controls target CRT/LIB paths; PreferredToolArchitecture below controls only the compiler host. + $targetArchitecture = if ('${{ matrix.platform }}' -eq 'Win32') { 'x86' } else { 'x64' } + .\tools\setup-vs2026-buildtools.ps1 -TargetArchitecture $targetArchitecture + + - name: Verify MSVC target environment + run: | + # Fail before compilation if a persisted runner environment exposes the wrong target libraries. + $expectedArchitecture = if ('${{ matrix.platform }}' -eq 'Win32') { 'x86' } else { 'x64' } + if ($env:VSCMD_ARG_TGT_ARCH -ne $expectedArchitecture) { + throw "Expected MSVC target '$expectedArchitecture', but VsDevCmd exported '$env:VSCMD_ARG_TGT_ARCH'." + } + # Check executable resolution as well as metadata so an unrelated PATH entry cannot select the opposite-architecture cross tools. + $expectedToolSuffix = "\bin\Hostx64\$expectedArchitecture" + foreach ($toolName in @('cl.exe', 'link.exe')) { + # Select and stringify one command so verification remains stable when Get-Command exposes multiple application candidates. + [string]$toolPath = (Get-Command $toolName -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source + if ($toolPath -notlike "*$expectedToolSuffix*") { + throw "Expected $toolName below '$expectedToolSuffix', but PATH resolved '$toolPath'." + } + Write-Host "$toolName path: $toolPath" + } + Write-Host "MSVC target architecture: $env:VSCMD_ARG_TGT_ARCH" + Write-Host "MSVC host architecture: $env:VSCMD_ARG_HOST_ARCH" # Run the verifier's synthetic Git-history cases once per matrix to keep # vendor scoping, comment parsing, and actionable diagnostics stable. @@ -125,7 +147,10 @@ jobs: $txtLog = Join-Path $logDir "msbuild-$($env:BUILD_CONFIGURATION)-$suffix.log" Add-Content -Path $env:GITHUB_ENV -Value "LOG_PATH=$txtLog" - + + # Keep MSBuild on the same x64-hosted tools verified above; Platform and VsDevCmd -arch still select the target libraries. + $preferredToolArchitecture = 'x64' + $arguments = @( $env:SOLUTION_PATH '/m' @@ -133,7 +158,7 @@ jobs: "/p:Configuration=$($env:BUILD_CONFIGURATION)" "/p:Platform=${{ matrix.platform }}" "/p:PlatformToolset=$($env:PLATFORM_TOOLSET)" - '/p:PreferredToolArchitecture=x64' + "/p:PreferredToolArchitecture=$preferredToolArchitecture" '/warnaserror' '/nr:false' '/v:m' diff --git a/tools/setup-vs2026-buildtools.ps1 b/tools/setup-vs2026-buildtools.ps1 index fae3825..c368e88 100644 --- a/tools/setup-vs2026-buildtools.ps1 +++ b/tools/setup-vs2026-buildtools.ps1 @@ -1,7 +1,10 @@ # Configure the self-hosted runner from the verified VS 2026 Build Tools path because # the third-party action's version range excludes the installed 18.9 release. [CmdletBinding()] -param() +param( + [ValidateSet('x86', 'x64')] + [string]$TargetArchitecture = 'x64' +) $ErrorActionPreference = 'Stop' @@ -19,8 +22,8 @@ if ([string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) { $initialEnvironment = @{} Get-ChildItem Env: | ForEach-Object { $initialEnvironment[$_.Name] = $_.Value } -# Capture the standard x64 developer environment; the x86 MASM path is added below for the mixed-architecture solution. -$command = 'call "' + $vsDevCmd + '" -arch=x64 -host_arch=x64 >nul && set' +# Select target libraries for the matrix platform while retaining the faster x64-hosted compiler for both architectures. +$command = 'call "' + $vsDevCmd + '" -arch=' + $TargetArchitecture + ' -host_arch=x64 >nul && set' $developerEnvironment = & $env:ComSpec /d /s /c $command if ($LASTEXITCODE -ne 0) { throw "VsDevCmd failed with exit code $LASTEXITCODE." @@ -34,15 +37,7 @@ $masm = Get-ChildItem -LiteralPath (Join-Path $visualStudioRoot 'VC\Tools\MSVC') if ($null -eq $masm) { throw "The VS 2026 x86 MASM assembler was not found below '$visualStudioRoot'." } -# Preserve the x64 developer environment while adding the x86 MASM directory used by sfx7zip. -$developerEnvironment = @($developerEnvironment | ForEach-Object { - if ($_ -like 'Path=*') { - 'Path=' + $masm.DirectoryName + ';' + $_.Substring(5) - } - else { - $_ - } -}) +# MSBuild locates this assembler through its MASM tool path; exporting its directory would also shadow cl.exe and link.exe with x86 tools. foreach ($line in $developerEnvironment) { $separator = $line.IndexOf('=') From d65f26cae6406e9434fbc3830247781f823b6de2 Mon Sep 17 00:00:00 2001 From: Patryk Leszczak <69416889+PatrykLs98@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:44:20 +0200 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs b/tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs index ec1389b..fa39e7e 100644 --- a/tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs +++ b/tests/FileManager.UiTests/SChannelTlsIntegrationTests.cs @@ -140,6 +140,6 @@ private static X509Certificate2 CreateCertificate() Directory.CreateDirectory(Path.Combine(appData, "Microsoft", "Crypto", "Keys")); } return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null, - X509KeyStorageFlags.UserKeySet | X509KeyStorageFlags.Exportable, null); + X509KeyStorageFlags.UserKeySet, null); } } From a60eeaf35d076e786e593bdfd919a478b8332d2d Mon Sep 17 00:00:00 2001 From: PatrykLs98 Date: Thu, 27 Aug 2026 10:59:09 +0200 Subject: [PATCH 5/5] Improved tests --- run-ui-tests.ps1 | 103 ++++++++++++++++-- .../Infrastructure/FileManagerUiTestBase.cs | 18 +++ .../NativeSafetyRegressionTests.cs | 25 +++++ tests/FileManager.UiTests/README.md | 8 +- .../ReparsePointTopologyUiTests.cs | 10 +- 5 files changed, 150 insertions(+), 14 deletions(-) diff --git a/run-ui-tests.ps1 b/run-ui-tests.ps1 index cbdfe2c..36ce266 100644 --- a/run-ui-tests.ps1 +++ b/run-ui-tests.ps1 @@ -52,6 +52,84 @@ function Resolve-FileManagerExecutable { throw 'No checkout salamand.exe was found. Build the current branch or pass -ExecutablePath explicitly.' } +function Test-FtpUiRuntime { + param([Parameter(Mandatory = $true)][string]$ResolvedExecutable) + + $runtimeRoot = Split-Path -Parent $ResolvedExecutable + $requiredFiles = @( + (Join-Path $runtimeRoot 'salmon.exe'), + (Join-Path $runtimeRoot 'plugins\ftp\ftp.spl'), + (Join-Path $runtimeRoot 'plugins\ftp\lang\english.slg') + ) + + # FTP command IDs exist only after the plug-in loads, so an executable alone is not a valid runtime for the default UI suite. + return @($requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }).Count -eq 0 +} + +function New-FtpUiTestRuntime { + param( + [Parameter(Mandatory = $true)][string]$ResolvedExecutable, + [Parameter(Mandatory = $true)][string]$StagingRoot + ) + + if (Test-FtpUiRuntime $ResolvedExecutable) { + return $ResolvedExecutable + } + + $sourceRuntimeRoot = Split-Path -Parent $ResolvedExecutable + $repositoryPrefix = ([IO.Path]::GetFullPath($repositoryRoot).TrimEnd('\') + '\') + if (-not $ResolvedExecutable.StartsWith($repositoryPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "The selected FileManager runtime is incomplete. Required files include salmon.exe, plugins\ftp\ftp.spl, and plugins\ftp\lang\english.slg beside: $ResolvedExecutable" + } + + $configuration = Split-Path -Leaf $sourceRuntimeRoot + $ftpBuildRoot = Join-Path $repositoryRoot "src\plugins\ftp\vcxproj\salamander\$configuration\plugins\ftp" + $ftpPlugin = Join-Path $ftpBuildRoot 'ftp.spl' + $ftpLanguage = Join-Path $ftpBuildRoot 'lang\english.slg' + $crashReporter = Join-Path $sourceRuntimeRoot 'salmon.exe' + $requiredBuildArtifacts = @($crashReporter, $ftpPlugin, $ftpLanguage) + $missingBuildArtifacts = @($requiredBuildArtifacts | + Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) }) + if ($missingBuildArtifacts.Count -ne 0) { + throw "The checkout build is incomplete. Build the complete $configuration solution before running UI tests. Missing: $($missingBuildArtifacts -join ', ')" + } + + try { + New-Item -ItemType Directory -Path $StagingRoot -Force | Out-Null + foreach ($fileName in @('salamand.exe', 'salmon.exe', 'salbroker.exe')) { + $sourceFile = Join-Path $sourceRuntimeRoot $fileName + if (Test-Path -LiteralPath $sourceFile -PathType Leaf) { + Copy-Item -LiteralPath $sourceFile -Destination $StagingRoot -Force + } + } + foreach ($directoryName in @('lang', 'toolbars', 'utils')) { + $sourceDirectory = Join-Path $sourceRuntimeRoot $directoryName + if (Test-Path -LiteralPath $sourceDirectory -PathType Container) { + Copy-Item -LiteralPath $sourceDirectory -Destination $StagingRoot -Recurse -Force + } + } + + $stagedFtpRoot = Join-Path $StagingRoot 'plugins\ftp' + $stagedFtpLanguageRoot = Join-Path $stagedFtpRoot 'lang' + New-Item -ItemType Directory -Path $stagedFtpLanguageRoot -Force | Out-Null + # Stage only runtime payloads from the matching checkout configuration; build intermediates must not leak into the test installation. + Copy-Item -LiteralPath $ftpPlugin -Destination $stagedFtpRoot -Force + Copy-Item -LiteralPath $ftpLanguage -Destination $stagedFtpLanguageRoot -Force + + $stagedExecutable = Join-Path $StagingRoot 'salamand.exe' + if (-not (Test-FtpUiRuntime $stagedExecutable)) { + throw "The temporary FileManager runtime could not be staged completely below: $StagingRoot" + } + return $stagedExecutable + } + catch { + if (Test-Path -LiteralPath $StagingRoot -PathType Container) { + Remove-Item -LiteralPath $StagingRoot -Recurse -Force + } + throw + } +} + if (-not $ConfirmIsolatedProfile) { # Explicit acknowledgement keeps interactive UI work out of a normal unattended desktop session. throw 'Re-run with -ConfirmIsolatedProfile to use the guarded filesystem and registry test sandbox.' @@ -63,16 +141,20 @@ if ($null -eq (Get-Command dotnet -ErrorAction SilentlyContinue)) { throw 'dotnet was not found on PATH.' } -$resolvedExecutable = Resolve-FileManagerExecutable $ExecutablePath -$executableDirectory = Split-Path -Parent $resolvedExecutable -$crashReporter = Join-Path $executableDirectory 'salmon.exe' -# Fail once in preflight instead of letting every UI case stall on the application's missing-reporter dialog. -if (-not (Test-Path -LiteralPath $crashReporter -PathType Leaf)) { - throw "The selected test artifact is incomplete; salmon.exe is missing beside salamand.exe: $crashReporter" -} - $sandboxParent = Join-Path ([IO.Path]::GetTempPath()) ('OpenSalamanderUiTests-' + [Guid]::NewGuid().ToString('N')) $testDataRoot = Join-Path $sandboxParent 'filemanager-testdata' +$runtimeStagingRoot = Join-Path $sandboxParent 'runtime' +$resolvedExecutable = Resolve-FileManagerExecutable $ExecutablePath +$ftpRuntimeRequired = [string]::IsNullOrWhiteSpace($Filter) -or + $Filter -match '(?i)(TestCategory\s*=\s*UI|BasicUiTests|UI_007|Ftp|Quick_connect)' +if ($ftpRuntimeRequired) { + # Raw Visual Studio output scatters plug-ins by project; use a disposable coherent runtime without modifying the caller's build tree. + $resolvedExecutable = New-FtpUiTestRuntime -ResolvedExecutable $resolvedExecutable -StagingRoot $runtimeStagingRoot +} +elseif (-not (Test-Path -LiteralPath (Join-Path (Split-Path -Parent $resolvedExecutable) 'salmon.exe') -PathType Leaf)) { + # Every UI launch needs its sibling crash reporter even when the selected fixture does not exercise FTP. + throw "The selected FileManager runtime is incomplete; salmon.exe is missing beside: $resolvedExecutable" +} $configurationRoot = 'Software\Open Salamander\6.0-filemanager-testdata' $savedEnvironment = @{ FILEMANAGER_UI_ISOLATED = $env:FILEMANAGER_UI_ISOLATED @@ -138,6 +220,11 @@ finally { } } + if (Test-Path -LiteralPath $runtimeStagingRoot -PathType Container) { + # The temporary runtime contains only files copied by this invocation below its GUID-owned parent. + Remove-Item -LiteralPath $runtimeStagingRoot -Recurse -Force + } + # NUnit removes the marked child on a healthy run; remove only the unique empty parent created above. if ((Test-Path -LiteralPath $sandboxParent -PathType Container) -and @(Get-ChildItem -LiteralPath $sandboxParent -Force).Count -eq 0) { diff --git a/tests/FileManager.UiTests/Infrastructure/FileManagerUiTestBase.cs b/tests/FileManager.UiTests/Infrastructure/FileManagerUiTestBase.cs index 01454f1..53f005e 100644 --- a/tests/FileManager.UiTests/Infrastructure/FileManagerUiTestBase.cs +++ b/tests/FileManager.UiTests/Infrastructure/FileManagerUiTestBase.cs @@ -225,6 +225,7 @@ private static bool TryFindPersistedFtpBookmark(string bookmarkName) protected Window OpenFtpBookmarksDialog() { + RequireFtpPluginRuntime(); // FTP IDs are process-local, so wait for the instance under test rather than reusing a predecessor's SUID. NativeCommands.Execute(MainWindow.Properties.NativeWindowHandle.Value, WaitForFtpPluginCommand(pluginCommand: 7, "Organize Bookmarks")); @@ -233,6 +234,7 @@ protected Window OpenFtpBookmarksDialog() protected Window OpenFtpConnectDialog() { + RequireFtpPluginRuntime(); // The quick-connect SUID must come from this launch for the protocol fixture to drive the real plug-in command. NativeCommands.Execute(MainWindow.Properties.NativeWindowHandle.Value, WaitForFtpPluginCommand(pluginCommand: 1, "Connect to FTP Server")); @@ -356,6 +358,22 @@ private static void EnsureCrashReporterIsStaged(string executableDirectory) $"FileManager UI tests require salmon.exe beside salamand.exe. Run scripts\\runtests.ps1 to stage the complete Debug x64 test artifact. Missing: {crashReporterPath}"); } + private static void RequireFtpPluginRuntime() + { + var runtimeRoot = Path.GetDirectoryName(UiTestSettings.ExecutablePath) ?? string.Empty; + var requiredFiles = new[] + { + Path.Combine(runtimeRoot, "plugins", "ftp", "ftp.spl"), + Path.Combine(runtimeRoot, "plugins", "ftp", "lang", "english.slg"), + }; + var missingFiles = requiredFiles.Where(path => !File.Exists(path)).ToArray(); + if (missingFiles.Length != 0) + { + // An absent plug-in means this scenario was not exercised; do not misreport its missing dynamic command as an application defect. + Assert.Ignore($"FTP UI tests require a complete deployed FTP runtime. Missing: {string.Join(", ", missingFiles)}"); + } + } + private int WaitForFtpPluginCommand(int pluginCommand, string commandName) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20); diff --git a/tests/FileManager.UiTests/NativeSafetyRegressionTests.cs b/tests/FileManager.UiTests/NativeSafetyRegressionTests.cs index f39b6d2..1498cb5 100644 --- a/tests/FileManager.UiTests/NativeSafetyRegressionTests.cs +++ b/tests/FileManager.UiTests/NativeSafetyRegressionTests.cs @@ -168,6 +168,31 @@ public void Root_test_runner_collects_every_documented_automated_test_layer() }); } + [Test] + public void Focused_ui_runner_stages_ftp_payloads_and_reparse_setup_treats_only_missing_privilege_as_optional() + { + var root = FindRepositoryRoot(); + var focusedRunner = File.ReadAllText(Path.Combine(root, "run-ui-tests.ps1")); + var uiTestBase = File.ReadAllText(Path.Combine(root, "tests", "FileManager.UiTests", "Infrastructure", "FileManagerUiTestBase.cs")); + var reparseTests = File.ReadAllText(Path.Combine(root, "tests", "FileManager.UiTests", "ReparsePointTopologyUiTests.cs")); + + // Keep focused local runs from converting missing runtime payloads or host privileges into false application failures. + Assert.Multiple(() => + { + Assert.That(focusedRunner, Does.Contain("New-FtpUiTestRuntime")); + Assert.That(focusedRunner, Does.Contain("plugins\\ftp\\ftp.spl")); + Assert.That(focusedRunner, Does.Contain("plugins\\ftp\\lang\\english.slg")); + Assert.That(focusedRunner, Does.Contain("$ftpRuntimeRequired")); + Assert.That(focusedRunner, Does.Contain("Raw Visual Studio output scatters plug-ins by project")); + Assert.That(focusedRunner, Does.Contain("Remove-Item -LiteralPath $runtimeStagingRoot -Recurse -Force")); + Assert.That(uiTestBase, Does.Contain("RequireFtpPluginRuntime")); + Assert.That(uiTestBase, Does.Contain("FTP UI tests require a complete deployed FTP runtime")); + Assert.That(reparseTests, Does.Contain("ErrorPrivilegeNotHeld = 1314")); + Assert.That(reparseTests, Does.Contain("exception is IOException && (exception.HResult & 0xFFFF) == ErrorPrivilegeNotHeld")); + Assert.That(reparseTests, Does.Contain("Assert.Ignore(\"The current test host does not permit disposable directory symbolic links.\")")); + }); + } + [Test] public void Unchecked_string_calls_are_ratchet_gated_and_external_boundaries_report_capacity_and_encoding_failures() { diff --git a/tests/FileManager.UiTests/README.md b/tests/FileManager.UiTests/README.md index b0d2d3c..dd2b814 100644 --- a/tests/FileManager.UiTests/README.md +++ b/tests/FileManager.UiTests/README.md @@ -8,7 +8,7 @@ The repository runner configures the guarded test-data root, configuration root, - `FILEMANAGER_UI_TESTDATA_ROOT` — absolute path ending in `filemanager-testdata`; all test-created files live below it. - `FILEMANAGER_UI_CONFIG_ROOT=Software\Open Salamander\6.0-filemanager-testdata` — selects the registry tree created and removed by the harness. -- `FILEMANAGER_UI_EXE` — absolute path to `salamand.exe` or a debug build of the executable. +- `FILEMANAGER_UI_EXE` — absolute path to `salamand.exe` in a deployed runtime. The runtime must include `salmon.exe` and the plug-in payloads used by the selected tests; an executable by itself is not a complete UI test artifact. - `FILEMANAGER_UI_ARGUMENTS` — optional command-line arguments, for example a test-only `-c` configuration file. - FTP menu IDs are published with the launching process ID below the owned test-data root, so each fixture waits for the exact FileManager instance it controls before issuing a plug-in command. - `FILEMANAGER_UI_CONFIG_FAULT_INJECTION=1` — explicitly enables the exhaustive transactional-configuration crash-recovery lane described below. @@ -21,9 +21,7 @@ The repository runner configures the guarded test-data root, configuration root, The test runner never mounts test virtual disks. If fixed writable `D:\` is unavailable, all second-volume-dependent tests are reported as successful, explicit capability skips. -The complete UI suite also requires `SeCreateSymbolicLinkPrivilege` for its disposable reparse-point fixtures. If the -runner lacks that privilege, it reports the missing privilege and skips the complete UI suite as an allowed environment -limitation; the UI tests are not reported as completed. +The release-equivalent repository runner requires `SeCreateSymbolicLinkPrivilege` for its disposable reparse-point fixtures. If that runner lacks the privilege, it reports the missing privilege and skips the complete UI lane as an allowed environment limitation; the UI tests are not reported as completed. A focused or direct NUnit run remains useful on such a host: junction coverage continues, while only the directory-symbolic-link case is reported as an explicit capability skip. The repository runner creates the guarded filesystem and registry sandbox automatically. On an interactive Windows desktop session, its default filter runs the complete UI category: @@ -37,7 +35,7 @@ Run every discovered test in the project, including non-UI contract and TLS case .\run-ui-tests.ps1 -ConfirmIsolatedProfile -Filter '' ``` -The runner derives its paths from the checkout and deliberately does not fall back to an installed application, because a stale executable can turn all native-command cases into misleading failures. Build the current branch first, or pass its artifact explicitly with `-ExecutablePath`. `-NoBuild` skips both managed build and restore for a repeated run; it does not build the native application. +The runner derives its paths from the checkout and deliberately does not fall back to an installed application, because a stale executable can turn all native-command cases into misleading failures. Build the complete current-branch solution first, or pass a deployed artifact explicitly with `-ExecutablePath`. When the selected filter can run FTP coverage and standard Visual Studio output has scattered the matching FTP build under the plug-in project, the focused runner copies `salamand.exe`, `salmon.exe`, runtime resources, `ftp.spl`, and its English language file into a disposable coherent runtime and removes that runtime afterward. It fails before FTP testing if those same-configuration artifacts are unavailable; it never combines an external executable with checkout plug-ins. `-NoBuild` skips both managed build and restore for a repeated run; it does not build the native application. The configuration dialog is opened through its stable native command ID only to avoid locale-dependent menu text. All window discovery, control inspection, focus, dialog lifecycle, and restart assertions use FlaUI/UIA3. diff --git a/tests/FileManager.UiTests/ReparsePointTopologyUiTests.cs b/tests/FileManager.UiTests/ReparsePointTopologyUiTests.cs index bcd05bd..8ea6252 100644 --- a/tests/FileManager.UiTests/ReparsePointTopologyUiTests.cs +++ b/tests/FileManager.UiTests/ReparsePointTopologyUiTests.cs @@ -12,6 +12,7 @@ namespace FileManager.UiTests; [Category("ReparsePoints")] public sealed class ReparsePointTopologyUiTests : FileOperationUiTestBase { + private const int ErrorPrivilegeNotHeld = 1314; private string operationRoot = null!; private string firstOutsideTarget = null!; private string changedOutsideTarget = null!; @@ -50,7 +51,7 @@ protected override void BeforeFileManagerStarted() Directory.CreateSymbolicLink(Path.Combine(operationRoot, "outside-symlink"), changedOutsideTarget); directorySymlinkAvailable = true; } - catch (UnauthorizedAccessException) + catch (Exception ex) when (IsMissingSymbolicLinkPrivilege(ex)) { // Junction coverage remains available on hosts that have not // enabled the Windows symbolic-link developer privilege. @@ -120,4 +121,11 @@ private static void CreateJunction(string linkPath, string targetPath) Assert.That(process.ExitCode, Is.Zero, "Creating the disposable junction failed."); Assert.That(Directory.Exists(linkPath), Is.True, "The disposable junction was not created."); } + + private static bool IsMissingSymbolicLinkPrivilege(Exception exception) + { + // Windows may surface ERROR_PRIVILEGE_NOT_HELD as IOException instead of UnauthorizedAccessException on different .NET hosts. + return exception is UnauthorizedAccessException || + exception is IOException && (exception.HResult & 0xFFFF) == ErrorPrivilegeNotHeld; + } }