From a864099ba77157090c4cd12817245122c163ec24 Mon Sep 17 00:00:00 2001 From: Egor Tensin Date: Sat, 24 Oct 2020 21:33:44 +0300 Subject: rework Process API & tests * Add separate classes ProcessParameters & ShellParameters for Process::create() and Process::shell() methods. * Add a separate "worker" executable. It's used in tests via a fairly complicated scheme, receiving orders to execute via a shared memory region. * Add tests that utilize the Console API, reading console window's screen buffer directly, making for more reliable tests & broader coverage. --- .appveyor.yml | 28 +++- include/winapi/process.hpp | 31 +++- include/winapi/window_style.hpp | 27 ++++ src/process.cpp | 156 ++++++++++++++----- test/unit_tests/CMakeLists.txt | 18 ++- test/unit_tests/console.cpp | 40 +++++ test/unit_tests/fixtures.hpp | 31 ++-- test/unit_tests/process.cpp | 3 +- test/unit_tests/process_worker.cpp | 278 ++++++++++++++++++++++++++++++++++ test/unit_tests/shared/command.hpp | 142 +++++++++++++++++ test/unit_tests/shared/console.hpp | 138 +++++++++++++++++ test/unit_tests/shared/fixed_size.hpp | 79 ++++++++++ test/unit_tests/shared/test_data.hpp | 24 +++ test/unit_tests/shared/worker.hpp | 86 +++++++++++ test/unit_tests/worker/worker.cpp | 118 +++++++++++++++ 15 files changed, 1145 insertions(+), 54 deletions(-) create mode 100644 include/winapi/window_style.hpp create mode 100644 test/unit_tests/console.cpp create mode 100644 test/unit_tests/process_worker.cpp create mode 100644 test/unit_tests/shared/command.hpp create mode 100644 test/unit_tests/shared/console.hpp create mode 100644 test/unit_tests/shared/fixed_size.hpp create mode 100644 test/unit_tests/shared/test_data.hpp create mode 100644 test/unit_tests/shared/worker.hpp create mode 100644 test/unit_tests/worker/worker.cpp diff --git a/.appveyor.yml b/.appveyor.yml index 2624062..2978e33 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -9,6 +9,7 @@ image: environment: python_exe: C:\Python36-x64\python.exe install_dir: C:\Projects\install\winapi-common + test_logs_dir: C:\Projects\test_logs platform: - Win32 @@ -45,7 +46,32 @@ after_build: - appveyor.exe PushArtifact "%APPVEYOR_PROJECT_NAME%-%PLATFORM%-%CONFIGURATION%.zip" test_script: - - '"%install_dir%\bin\winapi-common-unit-tests.exe" -- "--echo_exe=%install_dir%\bin\winapi-common-test-echo.exe"' + - mkdir "%test_logs_dir%" + - >- + "%install_dir%\bin\winapi-common-unit-tests.exe" + --log_level=all + "--log_sink=%test_logs_dir%\tests.log" + "--report_sink=%test_logs_dir%\report.txt" + --run_test=!console_tests,process_console_tests + -- + "--echo_exe=%install_dir%\bin\winapi-common-test-echo.exe" + "--worker_exe=%install_dir%\bin\winapi-common-test-worker.exe" + - type "%test_logs_dir%\report.txt" + - >- + start "Console tests" /wait + "%install_dir%\bin\winapi-common-unit-tests.exe" + --log_level=all + "--log_sink=%test_logs_dir%\tests_console.log" + "--report_sink=%test_logs_dir%\report_console.txt" + --run_test=console_tests,process_console_tests + -- + "--echo_exe=%install_dir%\bin\winapi-common-test-echo.exe" + "--worker_exe=%install_dir%\bin\winapi-common-test-worker.exe" + - type "%test_logs_dir%\report_console.txt" + +on_finish: + - 7z.exe a test_logs.zip "%test_logs_dir%" + - appveyor.exe PushArtifact test_logs.zip for: # Build Release on master only to speed things up: diff --git a/include/winapi/process.hpp b/include/winapi/process.hpp index 5c9e3c9..095b50c 100644 --- a/include/winapi/process.hpp +++ b/include/winapi/process.hpp @@ -11,6 +11,7 @@ #include "resource.hpp" #include +#include #include @@ -19,12 +20,40 @@ namespace winapi { +struct ProcessParameters { + enum ConsoleCreationMode { + ConsoleNone, + ConsoleInherit, + ConsoleNew, + }; + + explicit ProcessParameters(const CommandLine& cmd_line) : cmd_line{cmd_line} {} + + CommandLine cmd_line; + boost::optional io; + ConsoleCreationMode console_mode = ConsoleNew; +}; + +struct ShellParameters : ProcessParameters { + explicit ShellParameters(const CommandLine& cmd_line) : ProcessParameters{cmd_line} {} + + static ShellParameters runas(const CommandLine& cmd_line) { + ShellParameters params{cmd_line}; + params.verb = "runas"; + return params; + } + + boost::optional verb; +}; + class Process { public: + static Process create(ProcessParameters); static Process create(const CommandLine&); static Process create(const CommandLine&, process::IO); - static Process runas(const CommandLine&); + static Process shell(const ShellParameters&); + static Process shell(const CommandLine&); bool is_running() const; void wait() const; diff --git a/include/winapi/window_style.hpp b/include/winapi/window_style.hpp new file mode 100644 index 0000000..dd12a56 --- /dev/null +++ b/include/winapi/window_style.hpp @@ -0,0 +1,27 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#pragma once + +namespace winapi { + +enum class WindowStyle { + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-showwindow + ForceMinimize = 11, + Hide = 0, + Maximize = 3, + Minimize = 6, + Restore = 9, + Show = 5, + ShowDefault = 10, + ShowMaximized = 3, + ShowMinimized = 2, + ShowMinNoActive = 7, + ShowNA = 8, + ShowNoActivate = 4, + ShowNormal = 1, +}; + +} // namespace winapi diff --git a/src/process.cpp b/src/process.cpp index c5e68d7..1eca85a 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -30,28 +30,87 @@ namespace { typedef std::vector EscapedCommandLine; -Handle create_process(EscapedCommandLine cmd_line, process::IO& io) { - BOOST_STATIC_CONSTEXPR DWORD flags = /*CREATE_NO_WINDOW | */ CREATE_UNICODE_ENVIRONMENT; +EscapedCommandLine escape_command_line(const CommandLine& cmd_line) { + const auto unicode_cmd_line = widen(cmd_line.to_string()); + EscapedCommandLine buffer; + buffer.reserve(unicode_cmd_line.size() + 1); + buffer.assign(unicode_cmd_line.cbegin(), unicode_cmd_line.cend()); + buffer.emplace_back(L'\0'); + return buffer; +} + +Handle create_process(ProcessParameters& params) { + /* + * When creating a new console process, the options are: + * 1) inherit the parent console (the default), + * 2) CREATE_NO_WINDOW, + * 3) CREATE_NEW_CONSOLE, + * 4) DETACHED_PROCESS. + * + * Child processes can inherit the console. + * By that I mean they will display their output in the same window. + * If both the child process and the parent process read from stdin, there + * is no way to say which process will read any given input byte. + * + * There's an excellent guide into all the intricacies of the CreateProcess + * system call at + * + * https://github.com/rprichard/win32-console-docs/blob/master/README.md + * + * Another useful link is https://ikriv.com/dev/cpp/ConsoleProxy/flags. + */ + BOOST_STATIC_CONSTEXPR DWORD default_dwCreationFlags = CREATE_UNICODE_ENVIRONMENT; STARTUPINFOW startup_info; std::memset(&startup_info, 0, sizeof(startup_info)); startup_info.cb = sizeof(startup_info); - startup_info.dwFlags = STARTF_USESTDHANDLES; - startup_info.hStdInput = static_cast(io.std_in.handle); - startup_info.hStdOutput = static_cast(io.std_out.handle); - startup_info.hStdError = static_cast(io.std_err.handle); + + if (params.io) { + startup_info.dwFlags |= STARTF_USESTDHANDLES; + startup_info.hStdInput = static_cast(params.io->std_in.handle); + startup_info.hStdOutput = static_cast(params.io->std_out.handle); + startup_info.hStdError = static_cast(params.io->std_err.handle); + } + + auto dwCreationFlags = default_dwCreationFlags; + + switch (params.console_mode) { + case ProcessParameters::ConsoleNone: + dwCreationFlags |= CREATE_NO_WINDOW; + break; + case ProcessParameters::ConsoleInherit: + // This is the default. + break; + case ProcessParameters::ConsoleNew: + dwCreationFlags |= CREATE_NEW_CONSOLE; + break; + } PROCESS_INFORMATION child_info; std::memset(&child_info, 0, sizeof(child_info)); - const auto ret = ::CreateProcessW( - NULL, cmd_line.data(), NULL, NULL, TRUE, flags, NULL, NULL, &startup_info, &child_info); - - if (!ret) { - throw error::windows(GetLastError(), "CreateProcessW"); + { + auto cmd_line = escape_command_line(params.cmd_line); + + const auto ret = ::CreateProcessW(NULL, + cmd_line.data(), + NULL, + NULL, + TRUE, + dwCreationFlags, + NULL, + NULL, + &startup_info, + &child_info); + + if (!ret) { + throw error::windows(GetLastError(), "CreateProcessW"); + } } - io.close(); + if (params.io) { + params.io->close(); + } Handle process{child_info.hProcess}; Handle thread{child_info.hThread}; @@ -59,35 +118,38 @@ Handle create_process(EscapedCommandLine cmd_line, process::IO& io) { return process; } -EscapedCommandLine escape_command_line(const CommandLine& cmd_line) { - const auto unicode_cmd_line = widen(cmd_line.to_string()); - EscapedCommandLine buffer; - buffer.reserve(unicode_cmd_line.size() + 1); - buffer.assign(unicode_cmd_line.cbegin(), unicode_cmd_line.cend()); - buffer.emplace_back(L'\0'); - return buffer; -} - -Handle create_process(const CommandLine& cmd_line, process::IO& io) { - return create_process(escape_command_line(cmd_line), io); -} - -Handle shell_execute(const CommandLine& cmd_line) { - BOOST_STATIC_CONSTEXPR unsigned long flags = - SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI | SEE_MASK_NO_CONSOLE; - - const auto exe_path = widen(cmd_line.get_argv0()); - const auto args = widen(cmd_line.args_to_string()); +Handle shell_execute(const ShellParameters& params) { + const auto lpVerb = params.verb ? widen(*params.verb) : L"open"; + const auto lpFile = widen(params.cmd_line.get_argv0()); + const auto lpParameters = widen(params.cmd_line.args_to_string()); + + BOOST_STATIC_CONSTEXPR unsigned long default_fMask = + SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI; + + auto fMask = default_fMask; + auto nShow = SW_SHOWDEFAULT; + + switch (params.console_mode) { + case ProcessParameters::ConsoleNone: + nShow = SW_HIDE; + break; + case ProcessParameters::ConsoleInherit: + fMask |= SEE_MASK_NO_CONSOLE; + break; + case ProcessParameters::ConsoleNew: + // This is the default. + break; + } SHELLEXECUTEINFOW info; std::memset(&info, 0, sizeof(info)); info.cbSize = sizeof(info); - info.fMask = flags; - info.lpVerb = L"runas"; - info.lpFile = exe_path.c_str(); - if (!args.empty()) - info.lpParameters = args.c_str(); - info.nShow = SW_SHOWDEFAULT; + info.fMask = fMask; + info.lpVerb = lpVerb.c_str(); + info.lpFile = lpFile.c_str(); + if (!lpParameters.empty()) + info.lpParameters = lpParameters.c_str(); + info.nShow = nShow; if (!::ShellExecuteExW(&info)) { throw error::windows(GetLastError(), "ShellExecuteExW"); @@ -98,16 +160,28 @@ Handle shell_execute(const CommandLine& cmd_line) { } // namespace +Process Process::create(ProcessParameters params) { + return Process{create_process(params)}; +} + Process Process::create(const CommandLine& cmd_line) { - return create(cmd_line, {}); + ProcessParameters params{cmd_line}; + return create(std::move(params)); } Process Process::create(const CommandLine& cmd_line, process::IO io) { - return Process{create_process(cmd_line, io)}; + ProcessParameters params{cmd_line}; + params.io = std::move(io); + return create(std::move(params)); +} + +Process Process::shell(const ShellParameters& params) { + return Process{shell_execute(params)}; } -Process Process::runas(const CommandLine& cmd_line) { - return Process{shell_execute(cmd_line)}; +Process Process::shell(const CommandLine& cmd_line) { + ShellParameters params{cmd_line}; + return shell(params); } bool Process::is_running() const { diff --git a/test/unit_tests/CMakeLists.txt b/test/unit_tests/CMakeLists.txt index fb87230..7311d75 100644 --- a/test/unit_tests/CMakeLists.txt +++ b/test/unit_tests/CMakeLists.txt @@ -1,5 +1,7 @@ +file(GLOB shared_src "shared/*.hpp") + file(GLOB unit_tests_src "*.cpp" "*.h" "*.hpp" "*.rc") -add_executable(unit_tests ${unit_tests_src}) +add_executable(unit_tests ${unit_tests_src} ${shared_src}) set_target_properties(unit_tests PROPERTIES OUTPUT_NAME winapi-common-unit-tests) target_link_libraries(unit_tests PRIVATE winapi_common winapi_utf8) @@ -11,3 +13,17 @@ install(TARGETS unit_tests RUNTIME DESTINATION bin) if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") install(FILES "$" DESTINATION bin OPTIONAL) endif() + +file(GLOB worker_src "worker/*.cpp") +add_executable(worker ${worker_src} ${shared_src}) +set_target_properties(worker PROPERTIES OUTPUT_NAME winapi-common-test-worker) + +target_link_libraries(worker PRIVATE winapi_common winapi_utf8) + +find_package(Boost REQUIRED) +target_link_libraries(worker PRIVATE Boost::disable_autolinking Boost::boost) + +install(TARGETS worker RUNTIME DESTINATION bin) +if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + install(FILES "$" DESTINATION bin OPTIONAL) +endif() diff --git a/test/unit_tests/console.cpp b/test/unit_tests/console.cpp new file mode 100644 index 0000000..9eb787c --- /dev/null +++ b/test/unit_tests/console.cpp @@ -0,0 +1,40 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#include "shared/console.hpp" + +#include + +#include +#include +#include + +BOOST_AUTO_TEST_SUITE(console_tests) + +BOOST_AUTO_TEST_CASE(read_last_lines) { + std::cout << "abc\ndef" << std::endl; + console::Buffer buffer; + const auto last = buffer.read_last_line(); + const auto last2 = buffer.read_last_lines(2); + const std::string expected{"def"}; + const std::vector expected2{"abc", "def"}; + BOOST_TEST(last == expected); + BOOST_TEST(last2 == expected2); +} + +BOOST_AUTO_TEST_CASE(read_last_lines_overflow) { + console::Buffer buffer; + const std::string output(buffer.get_columns() + 5, 'X'); + std::cout << output << std::endl; + buffer.update(); + const auto last = buffer.read_last_lines(1); + const auto last2 = buffer.read_last_lines(2); + const std::vector expected{"XXXXX"}; + const std::vector expected2{std::string(buffer.get_columns(), 'X'), "XXXXX"}; + BOOST_TEST(last == expected); + BOOST_TEST(last2 == expected2); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/unit_tests/fixtures.hpp b/test/unit_tests/fixtures.hpp index 293ad35..aaf6571 100644 --- a/test/unit_tests/fixtures.hpp +++ b/test/unit_tests/fixtures.hpp @@ -5,6 +5,8 @@ #pragma once +#include "shared/command.hpp" + #include #include #include @@ -34,18 +36,13 @@ private: RemoveFileGuard& operator=(const RemoveFileGuard&) = delete; }; -class WithEchoExe { +class WithParam { public: - WithEchoExe() : m_echo_exe(find_echo_exe()) {} + WithParam(const std::string& param_prefix) : m_value(find_param_value(param_prefix)) {} - const std::string& get_echo_exe() { return m_echo_exe; } + const std::string& get_value() { return m_value; } private: - static std::string find_echo_exe() { - static const std::string prefix{"--echo_exe="}; - return find_param_value(prefix); - } - static std::string find_param_value(const std::string& param_prefix) { const auto cmd_line = winapi::CommandLine::query(); const auto& args = cmd_line.get_args(); @@ -57,5 +54,21 @@ private: throw std::runtime_error{"couldn't find parameter " + param_prefix}; } - std::string m_echo_exe; + std::string m_value; +}; + +class WithEchoExe : public WithParam { +public: + WithEchoExe() : WithParam{"--echo_exe="} {} + + const std::string& get_echo_exe() { return get_value(); } +}; + +class WithWorkerExe : public WithParam { +public: + WithWorkerExe() : WithParam{"--worker_exe="}, m_cmd{worker::Command::create()} {} + + const std::string& get_worker_exe() { return get_value(); } + + worker::Command::Shared m_cmd; }; diff --git a/test/unit_tests/process.cpp b/test/unit_tests/process.cpp index cfc50f4..a048c84 100644 --- a/test/unit_tests/process.cpp +++ b/test/unit_tests/process.cpp @@ -86,7 +86,8 @@ BOOST_FIXTURE_TEST_CASE(echo_stdin_from_file, WithEchoExe) { BOOST_FIXTURE_TEST_CASE(echo_runas, WithEchoExe) { const CommandLine cmd_line{get_echo_exe(), {"foo", "bar"}}; - const auto process = Process::runas(cmd_line); + const auto params = ShellParameters::runas(cmd_line); + const auto process = Process::shell(params); process.wait(); BOOST_TEST(process.get_exit_code() == 0); } diff --git a/test/unit_tests/process_worker.cpp b/test/unit_tests/process_worker.cpp new file mode 100644 index 0000000..492ffff --- /dev/null +++ b/test/unit_tests/process_worker.cpp @@ -0,0 +1,278 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#include "fixtures.hpp" +#include "shared/command.hpp" +#include "shared/console.hpp" +#include "shared/test_data.hpp" +#include "shared/worker.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include + +using winapi::Buffer; +using winapi::CommandLine; +using winapi::Handle; +using winapi::Pipe; +using winapi::Process; +using winapi::ProcessParameters; +namespace process = winapi::process; + +using worker::StdHandles; +using worker::Worker; + +namespace { + +void check_window_same(Worker& worker) { + BOOST_TEST(::GetConsoleWindow()); + BOOST_TEST(worker.get_console_window() == ::GetConsoleWindow()); + BOOST_TEST(worker.is_window_visible()); +} + +void check_window_none(Worker& worker) { + BOOST_TEST(::GetConsoleWindow()); + BOOST_TEST(!worker.get_console_window()); + BOOST_TEST(!worker.is_window_visible()); +} + +void check_window_new(Worker& worker) { + BOOST_TEST(::GetConsoleWindow()); + BOOST_TEST(worker.get_console_window()); + BOOST_TEST(worker.get_console_window() != ::GetConsoleWindow()); + BOOST_TEST(worker.is_window_visible()); +} + +void check_std_handles(Worker& worker, const StdHandles& expected) { + const auto actual = worker.get_std_handles(); + BOOST_TEST(actual.in == expected.in); + BOOST_TEST(actual.out == expected.out); + BOOST_TEST(actual.err == expected.err); +} + +void check_std_handles_same(Worker& worker) { + check_std_handles(worker, + {::GetStdHandle(STD_INPUT_HANDLE), + ::GetStdHandle(STD_OUTPUT_HANDLE), + ::GetStdHandle(STD_ERROR_HANDLE)}); +} + +void check_std_handles_different(Worker& worker) { + const auto actual = worker.get_std_handles(); + BOOST_TEST(actual.in); + BOOST_TEST(actual.out); + BOOST_TEST(actual.err); + BOOST_TEST(actual.in != ::GetStdHandle(STD_INPUT_HANDLE)); + BOOST_TEST(actual.out != ::GetStdHandle(STD_OUTPUT_HANDLE)); + BOOST_TEST(actual.err != ::GetStdHandle(STD_ERROR_HANDLE)); +} + +void check_write(Worker& worker) { + const auto handles = worker.test_write(); + BOOST_TEST(!Handle::is_invalid(handles.in)); + BOOST_TEST(!Handle::is_invalid(handles.out)); + BOOST_TEST(!Handle::is_invalid(handles.err)); +} + +void check_redirected_output(const Buffer& buffer, const std::vector& expected_lines) { + const auto actual = buffer.as_utf8(); + std::ostringstream oss; + for (const auto& line : expected_lines) { + oss << line << "\r\n"; + } + const std::string expected{oss.str()}; + BOOST_TEST(actual == expected); +} + +const std::string PLACEHOLDER{"This is a placeholder line."}; + +void check_console_buffer_inherit(Worker& worker, const std::vector& expected) { + // Check that + // 1) the worker process writes the expected lines to its console window, + // 2) the lines appear in this process's window, since they're the same. + + const auto numof_lines = expected.size(); + // Write some lines in this process's window, they should be overwritten + // by worker's writes. + for (std::size_t i = 0; i < numof_lines; ++i) { + std::cout << PLACEHOLDER << std::endl; + } + // Order worker to write the lines: + const auto handles = worker.test_write(); + // Query worker process's window: + const auto worker_actual = worker.read_last_lines(numof_lines); + // Query this process's window: + const auto this_actual = console::Buffer{}.read_last_lines(numof_lines); + + // They should look the same: + const auto& worker_expected = expected; + const auto& this_expected = worker_expected; + + BOOST_TEST(worker_actual == worker_expected); + BOOST_TEST(this_actual == this_expected); +} + +void check_console_buffer_new(Worker& worker, const std::vector& expected) { + // Check that + // 1) the worker process writes the expected lines in its console window, + // 2) the lines _do not_ appear in this process's window, since they're + // different windows. + + const auto numof_lines = expected.size(); + // Write some lines in this process's window, they _should not_ be + // overwritten by worker's writes. + for (std::size_t i = 0; i < numof_lines; ++i) { + std::cout << PLACEHOLDER << std::endl; + } + // Order worker to write the lines: + const auto handles = worker.test_write(); + // Query worker process's window: + const auto worker_actual = worker.read_last_lines(numof_lines); + // Query this process's window: + const auto this_actual = console::Buffer{}.read_last_lines(numof_lines); + + // Placeholder lines are still last in this process's window: + const auto& worker_expected = expected; + const std::vector this_expected(numof_lines, PLACEHOLDER); + + BOOST_TEST(worker_actual == worker_expected); + BOOST_TEST(this_actual == this_expected); +} + +// CREATE_NO_WINDOW actually does create a new buffer, it's just invisible. +void check_console_buffer_none(Worker& worker, const std::vector& expected) { + check_console_buffer_new(worker, expected); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(process_console_tests) + +BOOST_FIXTURE_TEST_CASE(create_inherit, WithWorkerExe) { + const CommandLine cmd_line{get_worker_exe()}; + ProcessParameters params{cmd_line}; + params.console_mode = ProcessParameters::ConsoleInherit; + + Worker worker{Process::create(std::move(params))}; + check_window_same(worker); + check_std_handles_same(worker); + check_write(worker); + check_console_buffer_inherit(worker, {worker::test_data::out(), worker::test_data::err()}); + BOOST_TEST(worker.exit() == 0); +} + +BOOST_FIXTURE_TEST_CASE(create_inherit_override, WithWorkerExe) { + const CommandLine cmd_line{get_worker_exe()}; + ProcessParameters params{cmd_line}; + params.console_mode = ProcessParameters::ConsoleInherit; + + Pipe stdin_pipe, stderr_pipe; + const StdHandles expected_handles{ + stdin_pipe.read_end().get(), Handle::std_out().get(), stderr_pipe.write_end().get()}; + + process::IO io; + io.std_in = process::Stdin{stdin_pipe}; + io.std_err = process::Stderr{stderr_pipe}; + params.io = std::move(io); + + Worker worker{Process::create(std::move(params))}; + check_window_same(worker); + check_std_handles(worker, expected_handles); + check_write(worker); + check_console_buffer_inherit(worker, {worker::test_data::out()}); + BOOST_TEST(worker.exit() == 0); + check_redirected_output(stderr_pipe.read_end().read(), + {worker::test_data::err(), worker::test_data::err()}); +} + +BOOST_FIXTURE_TEST_CASE(create_none, WithWorkerExe) { + const CommandLine cmd_line{get_worker_exe()}; + ProcessParameters params{cmd_line}; + params.console_mode = ProcessParameters::ConsoleNone; + + Worker worker{Process::create(std::move(params))}; + check_window_none(worker); + check_std_handles_different(worker); + check_write(worker); + check_console_buffer_none(worker, {worker::test_data::out(), worker::test_data::err()}); + BOOST_TEST(worker.exit() == 0); +} + +BOOST_FIXTURE_TEST_CASE(create_none_override, WithWorkerExe) { + const CommandLine cmd_line{get_worker_exe()}; + ProcessParameters params{cmd_line}; + params.console_mode = ProcessParameters::ConsoleNone; + + Pipe stdin_pipe, stderr_pipe; + const StdHandles expected_handles{ + stdin_pipe.read_end().get(), Handle::std_out().get(), stderr_pipe.write_end().get()}; + + process::IO io; + io.std_in = process::Stdin{stdin_pipe}; + io.std_err = process::Stderr{stderr_pipe}; + params.io = std::move(io); + + Worker worker{Process::create(std::move(params))}; + check_window_none(worker); + check_std_handles(worker, expected_handles); + check_write(worker); + check_console_buffer_none(worker, {worker::test_data::out()}); + BOOST_TEST(worker.exit() == 0); + check_redirected_output(stderr_pipe.read_end().read(), + {worker::test_data::err(), worker::test_data::err()}); +} + +BOOST_FIXTURE_TEST_CASE(create_new, WithWorkerExe) { + const CommandLine cmd_line{get_worker_exe()}; + ProcessParameters params{cmd_line}; + params.console_mode = ProcessParameters::ConsoleNew; + + Worker worker{Process::create(std::move(params))}; + check_window_new(worker); + check_std_handles_different(worker); + check_write(worker); + check_console_buffer_new(worker, {worker::test_data::out(), worker::test_data::err()}); + BOOST_TEST(worker.exit() == 0); +} + +BOOST_FIXTURE_TEST_CASE(create_new_override, WithWorkerExe) { + const CommandLine cmd_line{get_worker_exe()}; + ProcessParameters params{cmd_line}; + params.console_mode = ProcessParameters::ConsoleNew; + + Pipe stdin_pipe, stderr_pipe; + const StdHandles expected_handles{ + stdin_pipe.read_end().get(), Handle::std_out().get(), stderr_pipe.write_end().get()}; + + process::IO io; + io.std_in = process::Stdin{stdin_pipe}; + io.std_err = process::Stderr{stderr_pipe}; + params.io = std::move(io); + + Worker worker{Process::create(std::move(params))}; + check_window_new(worker); + check_std_handles(worker, expected_handles); + check_write(worker); + check_console_buffer_new(worker, {worker::test_data::out()}); + BOOST_TEST(worker.exit() == 0); + check_redirected_output(stderr_pipe.read_end().read(), + {worker::test_data::err(), worker::test_data::err()}); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/unit_tests/shared/command.hpp b/test/unit_tests/shared/command.hpp new file mode 100644 index 0000000..de7c704 --- /dev/null +++ b/test/unit_tests/shared/command.hpp @@ -0,0 +1,142 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#pragma once + +#include "fixed_size.hpp" + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace worker { + +struct StdHandles { + HANDLE in; + HANDLE out; + HANDLE err; +}; + +class Command { +public: + BOOST_STATIC_CONSTEXPR auto SHMEM_NAME = "shmem-test-cmd"; + + typedef winapi::SharedObject Shared; + + static Shared create() { return Shared::create(SHMEM_NAME); } + static Shared open() { return Shared::open(SHMEM_NAME); } + + typedef boost::interprocess::interprocess_mutex mutex; + typedef boost::interprocess::interprocess_condition condition_variable; + typedef boost::interprocess::scoped_lock lock; + + enum Action { + EXIT = 1, + GET_CONSOLE_WINDOW, + IS_WINDOW_VISIBLE, + GET_STD_HANDLES, + TEST_WRITE, + GET_CONSOLE_BUFFER, + }; + + union Args { + std::size_t numof_lines; + }; + + union Result { + HWND console_window; + bool is_window_visible; + StdHandles std_handles; + fixed_size::StringList<> console_buffer; + }; + + typedef std::function SetArgs; + typedef std::function ReadResult; + + void get_result(Action action, const SetArgs& set_args, const ReadResult& read_result) { + { + lock lck{m_mtx}; + m_action = action; + set_args(m_args); + + m_action_requested = true; + m_result_ready = false; + m_error_occured = false; + } + m_cv.notify_all(); + + lock lck{m_mtx}; + m_cv.wait(lck, [this]() { return m_error_occured || m_result_ready; }); + + const auto error = m_error_occured; + + m_action_requested = false; + m_result_ready = false; + m_error_occured = false; + + if (error) { + throw std::runtime_error{"Worker error: " + m_error.extract()}; + } + + read_result(m_result); + } + + void get_result(Action action, const ReadResult& read_result) { + return get_result( + action, [](Args&) {}, read_result); + } + + void get_result(Action action) { + return get_result(action, [](const Result&) {}); + } + + typedef std::function ProcessAction; + + void process_action(const ProcessAction& callback) { + { + lock lck{m_mtx}; + m_cv.wait(lck, [this]() { return m_action_requested; }); + + m_action_requested = false; + m_result_ready = false; + m_error_occured = false; + + try { + callback(m_action, m_args, m_result); + m_result_ready = true; + } catch (const std::exception& e) { + m_error_occured = true; + m_error = m_error.convert(e.what()); + } + } + m_cv.notify_all(); + } + +private: + Action m_action; + Args m_args; + bool m_action_requested = false; + + Result m_result; + bool m_result_ready = false; + + bool m_error_occured = false; + fixed_size::String<> m_error; + + mutex m_mtx; + condition_variable m_cv; +}; + +} // namespace worker diff --git a/test/unit_tests/shared/console.hpp b/test/unit_tests/shared/console.hpp new file mode 100644 index 0000000..0a415e5 --- /dev/null +++ b/test/unit_tests/shared/console.hpp @@ -0,0 +1,138 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#pragma once + +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace console { + +class Buffer { +public: + typedef CONSOLE_SCREEN_BUFFER_INFO Info; + + Buffer() : m_handle{winapi::Handle::std_out()}, m_info{get_info(m_handle)} {} + + Buffer(winapi::Handle&& handle) : m_handle{std::move(handle)}, m_info{get_info(m_handle)} {} + + std::size_t get_columns() const { return m_info.dwSize.X; } + + std::size_t get_lines() const { return m_info.dwSize.Y; } + + std::size_t get_cursor_column() const { return m_info.dwCursorPosition.X; } + + std::size_t get_cursor_line() const { return m_info.dwCursorPosition.Y; } + + void update() { m_info = get_info(m_handle); } + + /* + * This is a stupid little function to read the console screen buffer. + * It's fragile and will break whenever anything happens (like, if the + * console screen is resized). + * + * The screen buffer: + * 1) doesn't preserve line breaks, + * 2) pads text lines with spaces (ASCII 0x20) for storage. + * + * Hence, the "lines" read are one-dimensional arrays, right-trimmed, and + * there's no way to learn whether the whole line printed by the user was + * read back in its entirety. + * + * For example, let the console window be 80 columns wide, and the user + * prints 85 consecutive characters 'a' using + * + * std::cout << std::string(85, 'a') << '\n'; + * + * The following holds: + * 1) read_lines(-1, -1) == {"aaaaa"}, + * 2) read_lines(-2, -2) == {std::string(80, 'a'), "aaaaa"}. + * + * I also don't know how it interacts with tab characters '\t', encodings, + * etc. It sucks, don't use it. + */ + std::vector read_lines(int top, int bottom) const { + if (top < 0) { + top = get_cursor_line() + top; + } + if (bottom < 0) { + bottom = get_cursor_line() + bottom; + } + if (top > bottom) { + std::swap(top, bottom); + } + int numof_lines = bottom - top + 1; + + COORD buffer_size; + buffer_size.X = get_columns(); + buffer_size.Y = numof_lines; + + COORD buffer_coord; + buffer_coord.X = 0; + buffer_coord.Y = 0; + + std::vector buffer; + buffer.resize(buffer_size.X * buffer_size.Y); + + SMALL_RECT read_region; + read_region.Top = top; + read_region.Left = 0; + read_region.Bottom = bottom; + read_region.Right = buffer_size.X - 1; + + if (!::ReadConsoleOutputW( + m_handle.get(), buffer.data(), buffer_size, buffer_coord, &read_region)) { + throw winapi::error::windows(GetLastError(), "ReadConsoleOutputW"); + } + + std::vector result; + for (std::size_t i = 0; i < numof_lines; ++i) { + std::wostringstream oss; + for (std::size_t c = 0; c < buffer_size.X; ++c) { + oss << buffer[i * buffer_size.X + c].Char.UnicodeChar; + } + result.emplace_back(boost::trim_right_copy(winapi::narrow(oss.str()))); + } + + return result; + } + + std::vector read_last_lines(int numof_lines = 1) const { + return read_lines(-numof_lines, -1); + } + + std::string read_last_line() const { return read_lines(-1, -1)[0]; } + + std::string read_line(int n) const { return read_lines(n, n)[0]; } + +private: + static Info get_info(const winapi::Handle& handle) { + Info dest; + std::memset(&dest, 0, sizeof(dest)); + + if (!::GetConsoleScreenBufferInfo(static_cast(handle), &dest)) { + throw winapi::error::windows(GetLastError(), "GetConsoleScreenBufferInfo"); + } + + return dest; + } + + winapi::Handle m_handle; + Info m_info; +}; + +} // namespace console diff --git a/test/unit_tests/shared/fixed_size.hpp b/test/unit_tests/shared/fixed_size.hpp new file mode 100644 index 0000000..e4b11c7 --- /dev/null +++ b/test/unit_tests/shared/fixed_size.hpp @@ -0,0 +1,79 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#pragma once + +#include +#include +#include +#include +#include + +namespace fixed_size { + +/* + * These are fixed-size classes, to be used as part of an interprocess shared + * memory region. + */ + +// 120 characters is an arbitrary limit, strings are cut to this many +// characters for storage. +template +class String : public std::array { +public: + static String convert(const std::string& src) { + String dest; + std::size_t nch = dest.size() - 1; + if (src.size() < nch) { + nch = src.size(); + } + std::memcpy(dest.data(), src.c_str(), nch); + dest[nch] = '\0'; + return dest; + } + + std::string extract() const { + // Lines are null-terminated, and don't store their lenghts, so... + return data(); + } + +private: +}; + +// 5 lines to store is also arbitrary, set it higher if needed. +template +struct StringList : public std::array, Length> { + static StringList convert(const std::vector& src) { + // If src.size() > Length, only the last Length lines from the source + // list are stored. + + StringList dest; + std::memset(&dest, 0, sizeof(dest)); + + std::size_t src_offset = 0; + if (src.size() > dest.size()) { + src_offset = src.size() - dest.size(); + } + + for (std::size_t i = 0, j = src_offset; i < dest.size() && j < src.size(); + ++i, ++j, ++dest.numof_lines) { + dest[i] = dest[i].convert(src[j]); + } + + return dest; + } + + std::vector extract() const { + std::vector dest; + for (std::size_t i = 0; i < numof_lines; ++i) { + dest.emplace_back(at(i).extract()); + } + return dest; + } + + std::size_t numof_lines; +}; + +} // namespace fixed_size diff --git a/test/unit_tests/shared/test_data.hpp b/test/unit_tests/shared/test_data.hpp new file mode 100644 index 0000000..0094f0b --- /dev/null +++ b/test/unit_tests/shared/test_data.hpp @@ -0,0 +1,24 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#pragma once + +#include + +namespace worker { +namespace test_data { + +BOOST_STATIC_CONSTEXPR auto str = "Test output."; + +inline std::string out() { + return "stdout: " + std::string{str}; +} + +inline std::string err() { + return "stderr: " + std::string{str}; +} + +} // namespace test_data +} // namespace worker diff --git a/test/unit_tests/shared/worker.hpp b/test/unit_tests/shared/worker.hpp new file mode 100644 index 0000000..320a924 --- /dev/null +++ b/test/unit_tests/shared/worker.hpp @@ -0,0 +1,86 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#pragma once + +#include "command.hpp" + +#include + +#include + +#include +#include +#include +#include + +namespace worker { + +class Worker { +public: + Worker(winapi::Process&& process) : m_cmd{Command::create()}, m_process{std::move(process)} {} + + ~Worker() { + try { + if (m_process.is_running()) { + exit(); + } + } catch (const std::exception&) { + } + } + + HWND get_console_window() { + HWND ret = NULL; + m_cmd->get_result(Command::GET_CONSOLE_WINDOW, + [&ret](const Command::Result& result) { ret = result.console_window; }); + return ret; + } + + bool is_window_visible() { + bool ret = false; + m_cmd->get_result(Command::IS_WINDOW_VISIBLE, [&ret](const Command::Result& result) { + ret = result.is_window_visible; + }); + return ret; + } + + StdHandles get_std_handles() { + StdHandles ret; + m_cmd->get_result(Command::GET_STD_HANDLES, + [&ret](const Command::Result& result) { ret = result.std_handles; }); + return ret; + } + + StdHandles test_write() { + StdHandles ret; + m_cmd->get_result(Command::TEST_WRITE, + [&ret](const Command::Result& result) { ret = result.std_handles; }); + return ret; + } + + std::vector read_last_lines(std::size_t numof_lines) { + std::vector ret; + const auto set_args = [numof_lines](Command::Args& args) { + args.numof_lines = numof_lines; + }; + const auto read_result = [&ret](const Command::Result& result) { + ret = result.console_buffer.extract(); + }; + m_cmd->get_result(Command::GET_CONSOLE_BUFFER, set_args, read_result); + return ret; + } + + int exit() { + m_cmd->get_result(Command::EXIT); + m_process.wait(); + return m_process.get_exit_code(); + } + +private: + Command::Shared m_cmd; + winapi::Process m_process; +}; + +} // namespace worker diff --git a/test/unit_tests/worker/worker.cpp b/test/unit_tests/worker/worker.cpp new file mode 100644 index 0000000..9e48deb --- /dev/null +++ b/test/unit_tests/worker/worker.cpp @@ -0,0 +1,118 @@ +// Copyright (c) 2020 Egor Tensin +// This file is part of the "winapi-common" project. +// For details, see https://github.com/egor-tensin/winapi-common. +// Distributed under the MIT License. + +#include "../shared/command.hpp" +#include "../shared/console.hpp" +#include "../shared/test_data.hpp" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using worker::Command; + +namespace { + +bool is_window_visible() { + HWND window = ::GetConsoleWindow(); + + if (window == NULL) { + return false; + } + + const auto mask = ::GetWindowLongW(window, GWL_STYLE); + + if (!mask) { + throw winapi::error::windows(GetLastError(), "GetWindowLongW"); + } + + return mask & WS_VISIBLE; +} + +winapi::Handle write_to(winapi::Handle dest, const std::string& msg) { + try { + dest.write(msg + "\r\n"); + } catch (const std::exception& e) { + return winapi::Handle{}; + } + return dest; +} + +std::vector get_console_buffer(std::size_t numof_lines) { + return console::Buffer{}.read_last_lines(numof_lines); +} + +void process_action(Command::Action action, const Command::Args& args, Command::Result& result) { + switch (action) { + case Command::EXIT: + break; + + case Command::GET_CONSOLE_WINDOW: + result.console_window = ::GetConsoleWindow(); + break; + + case Command::IS_WINDOW_VISIBLE: + result.is_window_visible = is_window_visible(); + break; + + case Command::GET_STD_HANDLES: + result.std_handles.in = ::GetStdHandle(STD_INPUT_HANDLE); + result.std_handles.out = ::GetStdHandle(STD_OUTPUT_HANDLE); + result.std_handles.err = ::GetStdHandle(STD_ERROR_HANDLE); + break; + + case Command::TEST_WRITE: + result.std_handles.in = winapi::Handle::std_in().get(); + result.std_handles.out = + write_to(winapi::Handle::std_out(), worker::test_data::out()).get(); + result.std_handles.err = + write_to(winapi::Handle::std_err(), worker::test_data::err()).get(); + break; + + case Command::GET_CONSOLE_BUFFER: + result.console_buffer = + result.console_buffer.convert(get_console_buffer(args.numof_lines)); + break; + + default: + throw std::runtime_error{"invalid worker command"}; + } +} + +int loop() { + auto exit_loop = false; + + const auto cmd = Command::open(); + + while (!exit_loop) { + cmd->process_action([&exit_loop](Command::Action action, + const Command::Args& args, + Command::Result& result) { + if (action == Command::EXIT) { + exit_loop = true; + } else { + process_action(action, args, result); + } + }); + } + return 0; +} + +} // namespace + +int main(int argc, char* argv[]) { + int ec = loop(); + std::this_thread::sleep_for(std::chrono::milliseconds{1000}); + return ec; +} -- cgit v1.2.3