Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions .github/workflows/pr-msbuild.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -125,15 +147,18 @@ 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'
'/t:Rebuild'
"/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'
Expand Down
235 changes: 235 additions & 0 deletions run-ui-tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
[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.'
}

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.'
}
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.'
}

$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
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
}
}

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) {
Remove-Item -LiteralPath $sandboxParent
}
}

exit $testExitCode
9 changes: 7 additions & 2 deletions src/fileswindow_navigation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)))
Expand Down
18 changes: 18 additions & 0 deletions tests/FileManager.UiTests/Infrastructure/FileManagerUiTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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"));
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions tests/FileManager.UiTests/Infrastructure/NativeCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading