Release v0.6.45: fix Far snippets and ship portable Utilities folder

This commit is contained in:
2026-11-05 12:35:44 +02:00
parent 90b2e0841f
commit c109408a98
16 changed files with 422 additions and 34 deletions
+129
View File
@@ -0,0 +1,129 @@
// Explicit Windows integration test. Opens an isolated Far profile and never
// types into an existing Far window. Requires Far Manager at its default path.
#include "app.h"
#include <cstdlib>
#include <cwctype>
#include <fstream>
#include <future>
namespace ks {
struct InputRegressionAccess {
static void runQueued(App &app) {
std::function<void()> job;
{ std::lock_guard lock(app.jobsMutex);
if (app.jobs.empty()) throw std::runtime_error("snippet did not queue");
job=std::move(app.jobs.front());app.jobs.pop_front(); }
auto future=std::async(std::launch::async,[job=std::move(job)] {
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);job();CoUninitialize();
});
while(future.wait_for(std::chrono::milliseconds(5))!=std::future_status::ready) {
MSG message{};while(PeekMessageW(&message,nullptr,0,0,PM_REMOVE)) {
TranslateMessage(&message);DispatchMessageW(&message);
}
}
future.get();app.replayTyping();
}
};
}
int WINAPI wWinMain(HINSTANCE instance,HINSTANCE,PWSTR,int) {
auto base=std::filesystem::path(ks::executable()).parent_path();
std::ofstream log(base/L"far-snippets.log");
int checks=0;
auto require=[&](bool ok,const char *message) {
if(!ok)throw std::runtime_error(message);
++checks;log<<"PASS "<<message<<'\n';log.flush();
};
auto folder=std::filesystem::temp_directory_path()/(L"ks-far-snippets-"+ks::newSnippetId());
std::filesystem::create_directories(folder);
CoInitializeEx(nullptr,COINIT_APARTMENTTHREADED);
ks::Clip saved;bool captured=false;PROCESS_INFORMATION child{};
HANDLE input=INVALID_HANDLE_VALUE,output=INVALID_HANDLE_VALUE;
HWND farWindow{};
HWND owner=CreateWindowExW(0,L"STATIC",L"Far snippet fixture clipboard",0,0,0,0,0,HWND_MESSAGE,nullptr,instance,nullptr);
auto originalForeground=GetForegroundWindow();
int result=1;
try {
saved=ks::readClipboard(owner,128*1024*1024);captured=true;
auto sentinel=ks::textClip(L"KSwitcher Far fixture clipboard");
sentinel.formats.push_back({0,L"ExcludeClipboardContentFromMonitorProcessing",ks::Bytes{0}});
ks::writeClipboard(owner,sentinel);
const auto programFiles=_wgetenv(L"ProgramFiles");
if(!programFiles)throw std::runtime_error("ProgramFiles is unavailable");
auto farExecutable=std::filesystem::path(programFiles)/L"Far Manager/Far.exe";
std::wstring command=L"\""+farExecutable.wstring()+L"\" -m -p -s \""+folder.wstring()+L"\\profile\" \""+folder.wstring()+L"\"";
STARTUPINFOW startup{sizeof(startup)};startup.dwFlags=STARTF_USESHOWWINDOW;startup.wShowWindow=SW_HIDE;
require(CreateProcessW(nullptr,command.data(),nullptr,nullptr,FALSE,CREATE_NEW_CONSOLE,nullptr,folder.c_str(),&startup,&child),"start isolated Far without plugins/macros");
CloseHandle(child.hThread);Sleep(1800);
require(AttachConsole(child.dwProcessId),"attach isolated console for test-only input and observation");
input=CreateFileW(L"CONIN$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
output=CreateFileW(L"CONOUT$",GENERIC_READ|GENERIC_WRITE,FILE_SHARE_READ|FILE_SHARE_WRITE,nullptr,OPEN_EXISTING,0,nullptr);
require(input!=INVALID_HANDLE_VALUE && output!=INVALID_HANDLE_VALUE,"open isolated console buffers");
auto target=GetConsoleWindow();farWindow=target;ShowWindow(target,SW_SHOW);
require(ks::activateWindow(target),"activate only the isolated Far window");
auto key=[&](WORD vk,wchar_t c=0,DWORD mods=0) {
INPUT_RECORD records[2]{};
for(int i=0;i<2;++i) {
auto &r=records[i];r.EventType=KEY_EVENT;
r.Event.KeyEvent={BOOL(i==0),1,vk,WORD(MapVirtualKeyW(vk,MAPVK_VK_TO_VSC)),{c},mods};
}
DWORD written{};
if(!WriteConsoleInputW(input,records,2,&written)||written!=2)throw std::runtime_error("test input failed");
Sleep(60);
};
auto position=[&] {CONSOLE_SCREEN_BUFFER_INFO info{};if(!GetConsoleScreenBufferInfo(output,&info))throw std::runtime_error("console position unavailable");return info.dwCursorPosition;};
auto row=[&] {
CONSOLE_SCREEN_BUFFER_INFO info{};GetConsoleScreenBufferInfo(output,&info);
std::wstring text(info.dwSize.X,L' ');DWORD count{};
if(!ReadConsoleOutputCharacterW(output,text.data(),DWORD(text.size()),{0,info.dwCursorPosition.Y},&count))throw std::runtime_error("console row unavailable");
return text;
};
{
ks::App app(instance,false,folder/L"data");app.window=owner;
app.settings.history=false;app.settings.autoCorrect=false;app.settings.writeLog=true;
auto prepare=[&](wchar_t last=L'r') {
if(!ks::activateWindow(target))throw std::runtime_error("isolated Far lost foreground");
key(VK_ESCAPE);key('Y',0,LEFT_CTRL_PRESSED);key(VK_OEM_1,L':',SHIFT_PRESSED);key(WORD(towupper(last)),last);
app.run.reset();app.run.window=target;app.run.layout=ks::windowLayout(target);
app.run.at=GetTickCount64();app.run.keys=L":r";
};
auto expand=[&](const std::wstring &replacement) {
app.settings.snippets={{L":r",replacement}};
app.transform(0,0);ks::InputRegressionAccess::runQueued(app);Sleep(120);
};
for(int panels=0;panels<2;++panels) {
prepare();auto before=position();auto original=row();expand(L"rider .");
auto expected=original;expected.replace(before.X-2,2,L"rider .");expected.resize(original.size());
// Far's rightmost scroll indicator is redrawn rather than shifted.
require(row().substr(0,before.X+5)==expected.substr(0,before.X+5),"actual snippet replaces :r with rider .");
require(position().X==before.X+5,"successful expansion leaves cursor after replacement");
require(ks::readClipboard(owner).text()==sentinel.text(),"snippet restores original clipboard");
prepare(L'x');before=position();original=row();expand(L"rider .{enter}");
require(row()==original && position().X==before.X,"mismatched trigger restores original caret and never executes Enter");
prepare();before=position();original=row();expand(L"left{cursor}right");
require(row().substr(before.X-2,9)==L"leftright" && position().X==before.X+2,"cursor marker uses Far selection semantics");
prepare();before=position();original=row();expand(L"first\nsecond{enter}");
require(row()==original && position().X==before.X,"multiline console template rejected before changing text or caret");
prepare();before=position();original=row();expand(L"echo KSWITCHER_FAR_FIXTURE{enter}");
for(int wait=0;wait<30 && position().X!=before.X-2;++wait)Sleep(100);
require(position().X==before.X-2 && row().substr(before.X-2,2)==L" ",
"verified Enter runs only the harmless fixture echo and returns to an empty command line");
key('O',0,LEFT_CTRL_PRESSED);
}
app.window=nullptr;
}
log<<"Passed "<<checks<<" Far snippet integration checks\n";result=0;
}catch(const std::exception &e){log<<"FAIL "<<e.what()<<" win32="<<GetLastError()<<'\n';}
// A real user or clipboard synchronizer can copy during this explicit test.
// Never replace that newer content with the fixture's original snapshot.
const auto restoreSequence=GetClipboardSequenceNumber();
const bool restoreClipboard=GetClipboardOwner()==owner || (farWindow && ks::ownsClipboard(farWindow));
if(input!=INVALID_HANDLE_VALUE)CloseHandle(input);
if(output!=INVALID_HANDLE_VALUE)CloseHandle(output);
FreeConsole();
if(child.hProcess){TerminateProcess(child.hProcess,0);WaitForSingleObject(child.hProcess,5000);CloseHandle(child.hProcess);}
if(captured && restoreClipboard && GetClipboardSequenceNumber()==restoreSequence)
try{ks::writeClipboard(owner,saved);}catch(...){result=1;}
DestroyWindow(owner);
if(IsWindow(originalForeground))ks::activateWindow(originalForeground);
CoUninitialize();return result;
}
+1 -1
View File
@@ -124,7 +124,7 @@ int main(int argc, char **argv) {
return result.state == L"current" || result.state == L"available" ? 0 : 1;
} catch (const std::exception &e) { std::cerr << e.what() << '\n'; return 1; }
}
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+std::to_wstring(GetCurrentProcessId()));
auto temp=std::filesystem::temp_directory_path()/(L"KSwitcher-input-tests-"+ks::newSnippetId());
int checks=0;
auto require=[&](bool ok,const char* text) { if(!ok) throw std::runtime_error(text); ++checks; };
try {
+14
View File
@@ -50,6 +50,8 @@ try {
foreach ($name in @('LICENSE','json-LICENSE.txt','README.md')) { [IO.File]::WriteAllText((Join-Path $source $name),'fixture') }
[IO.Directory]::CreateDirectory((Join-Path $source 'dictionaries')) | Out-Null
[IO.File]::WriteAllText((Join-Path $source 'dictionaries/en.txt'),'new dictionary')
[IO.Directory]::CreateDirectory((Join-Path $source 'tools')) | Out-Null
[IO.File]::WriteAllText((Join-Path $source 'tools/README.txt'),'portable utilities instructions')
function Write-TestManifest($Folder) {
$entries=@(Get-ChildItem -LiteralPath $Folder -File -Recurse | Where-Object Name -ne 'release-files.json' | ForEach-Object {
@{path=$_.FullName.Substring($Folder.Length+1).Replace('\','/');size=$_.Length;sha256=(Get-FileHash -LiteralPath $_.FullName).Hash}
@@ -92,8 +94,17 @@ try {
$null=$z.CreateEntry('undeclared.dll'); $z.Dispose()
Reject { Expand-Release $extra (Join-Path $root 'extrastage') $binaryVersion } 'undeclared file rejected'
$target=Join-Path $root 'install'; $backup=Join-Path $root 'backup'
$freshTarget=Join-Path $root 'fresh-install'
[IO.Directory]::CreateDirectory($freshTarget) | Out-Null
$freshChanges=[Collections.Generic.List[string]]::new()
$freshStage=Join-Path $root 'fresh-stage'
Copy-Item -LiteralPath $stage -Destination $freshStage -Recurse
Install-Files $freshStage $freshTarget (Join-Path $root 'fresh-backup') $freshChanges
Assert (Test-Path -LiteralPath (Join-Path $freshTarget 'tools/README.txt')) 'update creates portable tools folder'
[IO.Directory]::CreateDirectory((Join-Path $target 'data')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $target 'dictionaries')) | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $target 'tools')) | Out-Null
[IO.File]::WriteAllText((Join-Path $target 'tools/Personal.exe'),'personal utility fixture')
[IO.File]::WriteAllText((Join-Path $target 'data/settings.json'),'personal settings')
[IO.File]::WriteAllText((Join-Path $target 'dictionaries/en.txt'),'personal dictionary')
[IO.File]::WriteAllText((Join-Path $target 'KSwitcher.exe'),'old binary fixture')
@@ -105,9 +116,12 @@ try {
}
Assert ([IO.File]::ReadAllText((Join-Path $target 'future-tool.exe')) -eq 'future companion fixture') 'new companion name installs without changing updater'
Assert ([IO.File]::ReadAllText((Join-Path $target 'data/settings.json')) -eq 'personal settings') 'settings preserved'
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'personal utilities preserved'
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/README.txt')) -eq 'portable utilities instructions') 'bundled tools documentation installed'
Assert ([IO.File]::ReadAllText((Join-Path $target 'dictionaries/en.txt')) -eq 'personal dictionary') 'dictionary preserved'
Undo-Files $target $backup $changed
Assert ([IO.File]::ReadAllText((Join-Path $target 'KSwitcher.exe')) -eq 'old binary fixture') 'rollback restores original'
Assert ([IO.File]::ReadAllText((Join-Path $target 'tools/Personal.exe')) -eq 'personal utility fixture') 'rollback preserves personal utilities'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'README.md'))) 'rollback removes newly introduced files'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Config.exe'))) 'rollback removes newly introduced configurator'
Assert (!(Test-Path -LiteralPath (Join-Path $target 'KSwitcher.Screenshot.exe'))) 'rollback removes newly introduced screenshot editor'
+43
View File
@@ -0,0 +1,43 @@
#include <windows.h>
#include "../src/utilities.h"
#include <fstream>
#include <iostream>
#include <stdexcept>
int main()
{
namespace fs = std::filesystem;
auto root = fs::temp_directory_path() / (L"KSwitcher-utilities-" + std::to_wstring(GetCurrentProcessId()));
try
{
std::error_code error;
if (!ks::discoverUtilities(root / L"missing", error).empty() || error)
throw std::runtime_error("Missing folder must be empty, not an error");
fs::create_directories(root / L"tools" / L"nested");
fs::create_directory(root / L"tools" / L"directory.exe");
for (auto name : {L"Zed.exe", L"alpha.EXE", L"A & B.exe", L"readme.txt", L"shortcut.lnk"})
std::ofstream(root / L"tools" / name).put('x');
std::ofstream(root / L"tools" / L"nested" / L"hidden.exe").put('x');
auto found = ks::discoverUtilities(root / L"tools", error);
if (error || found.size() != 3 || found[0].filename() != L"A & B.exe" ||
found[1].filename() != L"alpha.EXE" || found[2].filename() != L"Zed.exe")
throw std::runtime_error("Scan must filter direct executable files and sort names");
if (ks::utilityMenuLabel(found[0]) != L"A && B")
throw std::runtime_error("Ampersand must be displayed literally");
fs::remove(found[1]);
if (ks::discoverUtilities(root / L"tools", error).size() != 2)
throw std::runtime_error("Scan must refresh after a file is removed");
ks::discoverUtilities(root / L"tools" / L"readme.txt", error);
if (!error) throw std::runtime_error("Non-directory must report an error");
fs::remove_all(root);
std::cout << "Utilities regression tests passed\n";
return 0;
}
catch (const std::exception &e)
{
std::error_code ignored;
fs::remove_all(root, ignored);
std::cerr << e.what() << '\n';
return 1;
}
}