diff --git a/.github/workflows/y.yml b/.github/workflows/y.yml index 8faf1a8..aad9d31 100644 --- a/.github/workflows/y.yml +++ b/.github/workflows/y.yml @@ -42,3 +42,40 @@ jobs: run: | bundle install bundle exec rake test + windows-yamatanooroti: + needs: ruby-versions + name: >- + ${{ matrix.os }} ${{ matrix.ruby }} ${{ matrix.console }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ windows-2019, windows-2022 ] + ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }} + console: [ conhost, legacy-conhost, stable ] + exclude: + - ruby: 2.5 + - console: stable + os: windows-2019 + - console: legacy-conhost + os: windows-2022 + include: + - os: windows-2022 + ruby: 3.3 + console: preview + - os: windows-2022 + ruby: 3.3 + console: canary + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - name: rake test + run: | + bundle install + bundle exec rake test TESTOPTS="-v --wt_dir=./tmp --windows=${{ matrix.console }}" diff --git a/Gemfile b/Gemfile index fa59d53..cb9828c 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,17 @@ unless RbConfig::CONFIG['host_os'].match?(/mswin|msys|mingw|cygwin|bccwin|wince| gem "vterm", github: "ruby/vterm-gem" end +if Gem.win_platform? + gem "fiddle", '>= 1.0.8' if + (RUBY_ENGINE == "ruby" && RUBY_VERSION >= '3.4') || + Gem::Version.new("1.0.8") > begin + require 'fiddle' + Gem::Version.new(Fiddle::VERSION) + rescue + Gem::Version.new("0.0.0") + end +end + # Specify your gem's dependencies in reline.gemspec gemspec diff --git a/README.md b/README.md index d8a19b8..0463dfd 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ Tasks: TOP => default => test (See full trace by running task with --trace) ``` +### Commandline Options + +Yamatanooroti provides some additional TESTOPTS options. + +This is more important when running on Windows because of the type of console to be tested is specified in TESTOPTS. + +Please see ```rake TESTOPTS="-h"```. + ### Advanced Usage If you want to specify vterm environment that needs vterm gem, you can use `Yamatanooroti::VTermTestCase`: diff --git a/bin/simple_repl b/bin/simple_repl old mode 100755 new mode 100644 index e4bb5c3..210e7be --- a/bin/simple_repl +++ b/bin/simple_repl @@ -3,5 +3,10 @@ loop { print 'prompt> ' result = gets + result.force_encoding('locale').encode!(Encoding.default_external) unless result.valid_encoding? puts "=> #{eval(result).inspect}" } + +# On older windows ruby versions(< 3.1 ?), STDIN.gets returns locale code bytes with default external encoding. https://bugs.ruby-lang.org/issues/18353 +# Not same versions(< 3.2 ?), STDIN.gets returns invalid bytes if input with leading DBCS characters. https://bugs.ruby-lang.org/issues/18588 +# Because of this, input key stroke must start with SBCS character to support older ruby versions. diff --git a/lib/yamatanooroti.rb b/lib/yamatanooroti.rb index f9c4367..ba6af92 100644 --- a/lib/yamatanooroti.rb +++ b/lib/yamatanooroti.rb @@ -1,4 +1,5 @@ require 'test/unit' +require_relative 'yamatanooroti/options' class Yamatanooroti def self.load_vterm diff --git a/lib/yamatanooroti/options.rb b/lib/yamatanooroti/options.rb new file mode 100644 index 0000000..900e52c --- /dev/null +++ b/lib/yamatanooroti/options.rb @@ -0,0 +1,157 @@ +class Yamatanooroti + module Options + options = [ + :default_wait, + :default_timeout, + + # windows console selection + :windows, + + # true if conhost(classic) or conhost(legacy) + :conhost, + + # true if windows terminal + :terminal, + + # windows terminal download/extract dir + :terminal_workdir, + + # show console window on windows + :show_console, + + # conditional close console window on windows + :close_console, + ] + self.singleton_class.instance_eval do + attr_reader(*options) + end + + Accessor = Module.new do |mod| + options.each do |name| + mod.define_method name do + Yamatanooroti::Options.public_send(name) + end + end + end + + module WindowsTerminal + ALIAS = { + stable: :"1.21", + preview: :"1.22preview" + } + RELEASES = { + "1.22preview": { + url: "https://github.com/microsoft/terminal/releases/download/v1.22.2702.0/Microsoft.WindowsTerminalPreview_1.22.2702.0_x64.zip", + sha256: "CE8EED54D120775F31E3572A76EF5AE461B9E2D8887AB5DFF2F1859E24F4CE0B" + }, + "1.21": { + url: "https://github.com/microsoft/terminal/releases/download/v1.21.2701.0/Microsoft.WindowsTerminal_1.21.2701.0_x64.zip", + sha256: "2F712872ED7F552763F3776EA7A823C9E7413CFD5EC65B88E95162E93ACEF899" + }, + "1.21preview": { + url: "https://github.com/microsoft/terminal/releases/download/v1.21.1772.0/Microsoft.WindowsTerminalPreview_1.21.1772.0_x64.zip", + sha256: "6AA37175E2B09170829A39DAF3357D4B88A3965C3C529A45B1B0781B8F3425F0" + }, + "1.20": { + url: "https://github.com/microsoft/terminal/releases/download/v1.20.11781.0/Microsoft.WindowsTerminal_1.20.11781.0_x64.zip", + sha256: "B7A6046903CE33D75250DA7E40AD2929E51703AB66E9C3A0B02A839C2E868FEC" + }, + "1.19": { + url: "https://github.com/microsoft/terminal/releases/download/v1.19.11213.0/Microsoft.WindowsTerminal_1.19.11213.0_x64.zip", + sha256: "E32D7E72F8490AD94174708BB0B420E1EF4467B92F442D40DFAFDF42202A16A7" + }, + "1.18": { + url: "https://github.com/microsoft/terminal/releases/download/v1.18.10301.0/Microsoft.WindowsTerminal_1.18.10301.0_x64.zip", + sha256: "38B0E38B545D9C61F1B4214EA3EC6117C0EC348FEB18476D04ECEFB4D7DA723D" + }, + # v1.17 : the first windows terminal supports portable mode + "1.17": { + url: "https://github.com/microsoft/terminal/releases/download/v1.17.11461.0/Microsoft.WindowsTerminal_1.17.11461.0_x64.zip", + sha256: "F2B1539649D17752888D7944F97D6372F8D48EB1CEB024501DF8D8E9D3352F25" + }, + } + + def self.interpret(name) + if ALIAS.has_key?(name) + name = ALIAS[name] + end + if RELEASES.has_key?(name) + return name.to_s + elsif name == :canary + return name + end + raise "bug! #{name} is unknows" + end + end + + CONHOST_TYPES = [:conhost, :"legacy-conhost"] + TERMINAL_TYPES = [:stable, :preview, :canary] + TERMINAL_VERSIONS = WindowsTerminal::RELEASES.keys + CLOSE_WHEN = [:always, :pass, :never] + + ::Test::Unit::AutoRunner.setup_option do |autorunner, o| + @default_wait = 0.01 + @default_timeout = 2.0 + @windows = Yamatanooroti.win? ? :conhost : nil + @conhost = true + @terminal = false + @terminal_workdir = nil + @show_console = nil + @close_console = :always + + o.on_tail("yamatanooroti options") + o.on_tail("--wait=#{@default_wait}", Float, + "Specify yamatanooroti wait time in seconds.") do |seconds| + @default_wait = seconds + end + + o.on_tail("--timeout=#{@default_timeout}", Float, + "Specify yamatanooroti timeout in seconds.") do |seconds| + @default_timeout = seconds + end + + o.on_tail("windows specific yamatanooroti options") + + o.on_tail("--windows=TYPE", CONHOST_TYPES + TERMINAL_TYPES + TERMINAL_VERSIONS, + "Specify console type", + "(#{autorunner.keyword_display(CONHOST_TYPES + TERMINAL_TYPES)})", + "(#{TERMINAL_VERSIONS.sort.join(", ")})") do |type| + @conhost = CONHOST_TYPES.include?(type) + @terminal = !@conhost + @windows = @conhost ? type : WindowsTerminal.interpret(type) + if @terminal + if @show_console == false + puts "Windows Terminal is always visible. --no-show-console is ignored." + end + @show_console = true + end + end + + o.on_tail("--wt-dir=DIR", String, + "Specify Windows Terminal working dir.", + "Automatically determined if not specified and treated temporary.", + "DIR is treaded permanent if specified and download files are remains.") do |dir| + @terminal_workdir = dir + end + + o.on_tail("--[no-]show-console", + "Show test ongoing console.") do |show| + if show == false and @terminal + puts "Windows Terminal is always visible. --no-show-console is ignored." + else + @show_console = show + end + end + + o.on_tail("--[no-]close-console[=COND]", CLOSE_WHEN, + "Close test target console when COND met", + "(#{autorunner.keyword_display(CLOSE_WHEN)})") do |cond| + @close_console = (cond.nil? ? :always : cond) || :never + end + end + end + + def self.options + Options + end +end diff --git a/lib/yamatanooroti/vterm.rb b/lib/yamatanooroti/vterm.rb index 7c29fe2..4329d12 100644 --- a/lib/yamatanooroti/vterm.rb +++ b/lib/yamatanooroti/vterm.rb @@ -5,9 +5,9 @@ require 'io/wait' module Yamatanooroti::VTermTestCaseModule - def start_terminal(height, width, command, wait: 0.01, timeout: 2, startup_message: nil) - @timeout = timeout - @wait = wait + def start_terminal(height, width, command, wait: nil, timeout: nil, startup_message: nil) + @timeout = timeout || Yamatanooroti.options.default_timeout + @wait = wait || Yamatanooroti.options.default_wait @result = nil @pty_output, @pty_input, @pid = PTY.spawn(*command) diff --git a/lib/yamatanooroti/windows.rb b/lib/yamatanooroti/windows.rb index d0f1d3e..196fd08 100644 --- a/lib/yamatanooroti/windows.rb +++ b/lib/yamatanooroti/windows.rb @@ -1,521 +1,85 @@ require 'test/unit' -require 'fiddle/import' -require 'fiddle/types' - -module Yamatanooroti::WindowsDefinition - extend Fiddle::Importer - dlload 'kernel32.dll', 'psapi.dll', 'user32.dll' - include Fiddle::Win32Types - - FREE = Fiddle::Function.new(Fiddle::RUBY_FREE, [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID) - - typealias 'SHORT', 'short' - typealias 'HPCON', 'HANDLE' - typealias 'HWND', 'HANDLE' - typealias 'HRESULT', 'HANDLE' - typealias 'LPVOID', 'void*' - typealias 'SIZE_T', 'size_t' - typealias 'LPWSTR', 'void*' - typealias 'LPBYTE', 'void*' - typealias 'LPCWSTR', 'void*' - typealias 'LPPROC_THREAD_ATTRIBUTE_LIST', 'void*' - typealias 'PSIZE_T', 'void*' - typealias 'DWORD_PTR', 'void*' - typealias 'LPCVOID', 'void*' - typealias 'LPDWORD', 'void*' - typealias 'LPOVERLAPPED', 'void*' - typealias 'WCHAR', 'unsigned short' - typealias 'LPCWCH', 'void*' - typealias 'LPSTR', 'void*' - typealias 'LPCCH', 'void*' - typealias 'LPBOOL', 'void*' - typealias 'LPWORD', 'void*' - typealias 'ULONG_PTR', 'ULONG*' - typealias 'LONG', 'int' - - Fiddle::SIZEOF_HANDLE = Fiddle::SIZEOF_LONG - Fiddle::SIZEOF_HPCON = Fiddle::SIZEOF_LONG - Fiddle::SIZEOF_HRESULT = Fiddle::SIZEOF_LONG - Fiddle::SIZEOF_DWORD = Fiddle::SIZEOF_LONG - Fiddle::SIZEOF_WORD = Fiddle::SIZEOF_SHORT - - COORD = struct [ - 'SHORT X', - 'SHORT Y' - ] - typealias 'COORD', 'DWORD32' - - SMALL_RECT = struct [ - 'SHORT Left', - 'SHORT Top', - 'SHORT Right', - 'SHORT Bottom' - ] - typealias 'SMALL_RECT*', 'DWORD64*' - typealias 'PSMALL_RECT', 'SMALL_RECT*' - - CONSOLE_SCREEN_BUFFER_INFO = struct [ - 'COORD dwSize', - 'COORD dwCursorPosition', - 'WORD wAttributes', - 'SHORT Left', 'SHORT Top', 'SHORT Right', 'SHORT Bottom', # 'SMALL_RECT srWindow', - 'SHORT MaxWidth', 'SHORT MaxHeight' # 'COORD dwMaximumWindowSize' - ] - typealias 'PCONSOLE_SCREEN_BUFFER_INFO', 'CONSOLE_SCREEN_BUFFER_INFO*' - - SECURITY_ATTRIBUTES = struct [ - 'DWORD nLength', - 'LPVOID lpSecurityDescriptor', - 'BOOL bInheritHandle' - ] - typealias 'PSECURITY_ATTRIBUTES', 'SECURITY_ATTRIBUTES*' - typealias 'LPSECURITY_ATTRIBUTES', 'SECURITY_ATTRIBUTES*' - - STARTUPINFOW = struct [ - 'DWORD cb', - 'LPWSTR lpReserved', - 'LPWSTR lpDesktop', - 'LPWSTR lpTitle', - 'DWORD dwX', - 'DWORD dwY', - 'DWORD dwXSize', - 'DWORD dwYSize', - 'DWORD dwXCountChars', - 'DWORD dwYCountChars', - 'DWORD dwFillAttribute', - 'DWORD dwFlags', - 'WORD wShowWindow', - 'WORD cbReserved2', - 'LPBYTE lpReserved2', - 'HANDLE hStdInput', - 'HANDLE hStdOutput', - 'HANDLE hStdError' - ] - typealias 'LPSTARTUPINFOW', 'STARTUPINFOW*' - - PROCESS_INFORMATION = struct [ - 'HANDLE hProcess', - 'HANDLE hThread', - 'DWORD dwProcessId', - 'DWORD dwThreadId' - ] - typealias 'PPROCESS_INFORMATION', 'PROCESS_INFORMATION*' - typealias 'LPPROCESS_INFORMATION', 'PROCESS_INFORMATION*' - - INPUT_RECORD_WITH_KEY_EVENT = struct [ - 'WORD EventType', - 'BOOL bKeyDown', - 'WORD wRepeatCount', - 'WORD wVirtualKeyCode', - 'WORD wVirtualScanCode', - 'WCHAR UnicodeChar', - ## union 'CHAR AsciiChar', - 'DWORD dwControlKeyState' - ] - - CHAR_INFO = struct [ - 'WCHAR UnicodeChar', - 'WORD Attributes' - ] - typealias 'PCHAR_INFO', 'CHAR_INFO*' - - PROCESSENTRY32W = struct [ - 'DWORD dwSize', - 'DWORD cntUsage', - 'DWORD th32ProcessID', - 'ULONG_PTR th32DefaultHeapID', - 'DWORD th32ModuleID', - 'DWORD cntThreads', - 'DWORD th32ParentProcessID', - 'LONG pcPriClassBase', - 'DWORD dwFlags', - 'WCHAR szExeFile[260]' - ] - typealias 'LPPROCESSENTRY32W', 'PROCESSENTRY32W*' - - CONSOLE_FONT_INFOEX = struct [ - 'ULONG cbSize', - 'DWORD nFont', - 'DWORD32 dwFontSize', - 'UINT FontFamily', - 'UINT FontWeight', - 'WCHAR FaceName[32]' - ] - typealias 'PCONSOLE_FONT_INFOEX', 'CONSOLE_FONT_INFOEX*' - - STD_INPUT_HANDLE = -10 - STD_OUTPUT_HANDLE = -11 - STD_ERROR_HANDLE = -12 - ATTACH_PARENT_PROCESS = -1 - KEY_EVENT = 0x0001 - TH32CS_SNAPPROCESS = 0x00000002 - PROCESS_ALL_ACCESS = 0x001FFFFF - SW_HIDE = 0 - LEFT_ALT_PRESSED = 0x0002 - - # HANDLE GetStdHandle(DWORD nStdHandle); - extern 'HANDLE GetStdHandle(DWORD);', :stdcall - # BOOL CloseHandle(HANDLE hObject); - extern 'BOOL CloseHandle(HANDLE);', :stdcall - - # BOOL FreeConsole(void); - extern 'BOOL FreeConsole(void);', :stdcall - # BOOL AllocConsole(void); - extern 'BOOL AllocConsole(void);', :stdcall - # BOOL AttachConsole(DWORD dwProcessId); - extern 'BOOL AttachConsole(DWORD);', :stdcall - # BOOL ShowWindow(HWND hWnd, int nCmdShow); - extern 'BOOL ShowWindow(HWND hWnd,int nCmdShow);', :stdcall - # HWND WINAPI GetConsoleWindow(void); - extern 'HWND GetConsoleWindow(void);', :stdcall - # BOOL WINAPI SetConsoleScreenBufferSize(HANDLE hConsoleOutput, COORD dwSize); - extern 'BOOL SetConsoleScreenBufferSize(HANDLE, COORD);', :stdcall - # BOOL WINAPI SetConsoleWindowInfo(HANDLE hConsoleOutput, BOOL bAbsolute, const SMALL_RECT *lpConsoleWindow); - extern 'BOOL SetConsoleWindowInfo(HANDLE, BOOL, PSMALL_RECT);', :stdcall - # BOOL WriteConsoleInputW(HANDLE hConsoleInput, const INPUT_RECORD *lpBuffer, DWORD nLength, LPDWORD lpNumberOfEventsWritten); - extern 'BOOL WriteConsoleInputW(HANDLE, const INPUT_RECORD*, DWORD, LPDWORD);', :stdcall - # SHORT VkKeyScanW(WCHAR ch); - extern 'SHORT VkKeyScanW(WCHAR);', :stdcall - # UINT MapVirtualKeyW(UINT uCode, UINT uMapType); - extern 'UINT MapVirtualKeyW(UINT, UINT);', :stdcall - # BOOL WINAPI ReadConsoleOutputCharacterW(HANDLE hConsoleOutput, LPWSTR lpCharacter, DWORD nLength, COORD dwReadCoord, LPDWORD lpNumberOfCharsRead); - extern 'BOOL ReadConsoleOutputCharacterW(HANDLE, LPWSTR, DWORD, COORD, LPDWORD);', :stdcall - # BOOL WINAPI GetConsoleScreenBufferInfo(HANDLE hConsoleOutput, PCONSOLE_SCREEN_BUFFER_INFO lpConsoleScreenBufferInfo); - extern 'BOOL GetConsoleScreenBufferInfo(HANDLE, PCONSOLE_SCREEN_BUFFER_INFO);', :stdcall - # BOOL WINAPI GetCurrentConsoleFontEx(HANDLE hConsoleOutput, BOOL bMaximumWindow, PCONSOLE_FONT_INFOEX lpConsoleCurrentFontEx); - extern 'BOOL GetCurrentConsoleFontEx(HANDLE, BOOL, PCONSOLE_FONT_INFOEX);', :stdcall - # BOOL WINAPI SetCurrentConsoleFontEx(HANDLE hConsoleOutput, BOOL bMaximumWindow, PCONSOLE_FONT_INFOEX lpConsoleCurrentFontEx); - extern 'BOOL SetCurrentConsoleFontEx(HANDLE, BOOL, PCONSOLE_FONT_INFOEX);', :stdcall - - # BOOL CreateProcessW(LPCWSTR lpApplicationName, LPWSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, LPSTARTUPINFOW lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation); - extern 'BOOL CreateProcessW(LPCWSTR lpApplicationName, LPWSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, LPSTARTUPINFOW lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation);', :stdcall - # HANDLE CreateToolhelp32Snapshot(DWORD dwFlags, DWORD th32ProcessID); - extern 'HANDLE CreateToolhelp32Snapshot(DWORD, DWORD);', :stdcall - # BOOL Process32First(HANDLE hSnapshot, LPPROCESSENTRY32W lppe); - extern 'BOOL Process32FirstW(HANDLE, LPPROCESSENTRY32W);', :stdcall - # BOOL Process32Next(HANDLE hSnapshot, LPPROCESSENTRY32 lppe); - extern 'BOOL Process32NextW(HANDLE, LPPROCESSENTRY32W);', :stdcall - # DWORD GetCurrentProcessId(); - extern 'DWORD GetCurrentProcessId();', :stdcall - # HANDLE OpenProcess(DWORD dwDesiredAccess, BOOL bInheritHandle, DWORD dwProcessId); - extern 'HANDLE OpenProcess(DWORD, BOOL, DWORD);', :stdcall - # BOOL TerminateProcess(HANDLE hProcess, UINT uExitCode); - extern 'BOOL TerminateProcess(HANDLE, UINT);', :stdcall - #BOOL TerminateThread(HANDLE hThread, DWORD dwExitCode); - extern 'BOOL TerminateThread(HANDLE, DWORD);', :stdcall - - # int MultiByteToWideChar(UINT CodePage, DWORD dwFlags, LPCSTR lpMultiByteStr, int cbMultiByte, LPWSTR lpWideCharStr, int cchWideChar); - extern 'int MultiByteToWideChar(UINT, DWORD, LPCSTR, int, LPWSTR, int);', :stdcall - # int WideCharToMultiByte(UINT CodePage, DWORD dwFlags, _In_NLS_string_(cchWideChar)LPCWCH lpWideCharStr, int cchWideChar, LPSTR lpMultiByteStr, int cbMultiByte, LPCCH lpDefaultChar, LPBOOL lpUsedDefaultChar); - extern 'int WideCharToMultiByte(UINT, DWORD, LPCWCH, int, LPSTR, int, LPCCH, LPBOOL);', :stdcall - - typealias 'LPTSTR', 'void*' - typealias 'HLOCAL', 'HANDLE' - extern 'DWORD FormatMessage(DWORD dwFlags, LPCVOID lpSource, DWORD dwMessageId, DWORD dwLanguageId, LPTSTR lpBuffer, DWORD nSize, va_list *Arguments);', :stdcall - extern 'HLOCAL LocalFree(HLOCAL hMem);', :stdcall - extern 'DWORD GetLastError();', :stdcall - FORMAT_MESSAGE_ALLOCATE_BUFFER = 0x00000100 - FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 - LANG_NEUTRAL = 0x00 - SUBLANG_DEFAULT = 0x01 - extern 'int GetSystemMetrics(int);', :stdcall - SM_CXMIN = 28 - SM_CYMIN = 29 -end +require_relative 'windows/windows-definition' +require_relative 'windows/windows-setup' +require_relative 'windows/windows' +require_relative 'windows/conhost' +require_relative 'windows/terminal' module Yamatanooroti::WindowsTestCaseModule - DL = Yamatanooroti::WindowsDefinition - - private def setup_console(height, width) - - r = DL.FreeConsole - error_message(r, 'FreeConsole') - r = DL.AllocConsole - error_message(r, 'AllocConsole') - @output_handle = DL.GetStdHandle(DL::STD_OUTPUT_HANDLE) - - font = DL::CONSOLE_FONT_INFOEX.malloc - font.cbSize = DL::CONSOLE_FONT_INFOEX.size - - r = DL.GetCurrentConsoleFontEx(@output_handle, 0, font) - error_message(r, 'GetCurrentConsoleFontEx') - fontsize = (font.dwFontSize & 0xffff0000) / 65536 - fontwidth = font.dwFontSize & 0xffff - newsize = fontsize - newwidth = fontwidth - - csbi = DL::CONSOLE_SCREEN_BUFFER_INFO.malloc - r = DL.GetConsoleScreenBufferInfo(@output_handle, csbi) - error_message(r, 'GetConsoleScreenBufferInfo') - - if (width < (csbi.Right - csbi.Left + 1) / 4) - newsize = fontsize * (csbi.Right - csbi.Left + 1) / width - newwidth = fontwidth * (csbi.Right - csbi.Left + 1) / width - end - if newsize * height > fontsize * csbi.MaxHeight - newsize = fontsize * csbi.MaxHeight / height - newwidth = fontwidth * newsize / fontsize - end - - font.dwFontSize = newsize * 65536 + newwidth - r = DL.SetCurrentConsoleFontEx(@output_handle, 0, font) - error_message(r, 'SetCurrentConsoleFontEx') - - rect = DL::SMALL_RECT.malloc - rect.Left = 0 - rect.Top = 0 - rect.Right = width - 1 - rect.Bottom = height - 1 - r = DL.SetConsoleWindowInfo(@output_handle, 1, rect) - error_message(r, 'SetConsoleWindowInfo') - -# size = DL.GetSystemMetrics(DL::SM_CYMIN) * 65536 + DL.GetSystemMetrics(DL::SM_CXMIN) -# r = DL.SetConsoleScreenBufferSize(@output_handle, size) -# error_message(r, 'SetConsoleScreenBufferSize') - - csbi = DL::CONSOLE_SCREEN_BUFFER_INFO.malloc - r = DL.GetConsoleScreenBufferInfo(@output_handle, csbi) - error_message(r, 'GetConsoleScreenBufferInfo') - - size = height * 65536 + width - r = DL.SetConsoleScreenBufferSize(@output_handle, size) - error_message(r, "SetConsoleScreenBufferSize " \ - "(#{width} #{height}) " \ - "(#{csbi.Right - csbi.Left + 1} #{csbi.Bottom - csbi.Top + 1}) " \ - "(#{csbi.dwSize & 65535} #{csbi.dwSize / 65536}) " \ - "(#{csbi.Left} #{csbi.Top}) " \ - "(#{csbi.Right} #{csbi.Bottom})") - r = DL.ShowWindow(DL.GetConsoleWindow(), DL::SW_HIDE) - error_message(r, 'ShowWindow') - end - - private def mb2wc(str) - size = DL.MultiByteToWideChar(65001, 0, str, str.bytesize, '', 0) - converted_str = String.new("\x00" * (size * 2), encoding: 'ASCII-8BIT') - DL.MultiByteToWideChar(65001, 0, str, str.bytesize, converted_str, size) - converted_str - end - - private def wc2mb(str) - size = DL.WideCharToMultiByte(65001, 0, str, str.bytesize / 2, '', 0, 0, 0) - converted_str = "\x00" * size - DL.WideCharToMultiByte(65001, 0, str, str.bytesize / 2, converted_str, converted_str.bytesize, 0, 0) - converted_str + def write(str) + @terminal.write(str) end - private def quote_command_arg(arg) - if not arg.match?(/[ \t"]/) - # No quotation needed. - return arg - end - - if not arg.match?(/["\\]/) - # No embedded double quotes or backlashes, so I can just wrap quote - # marks around the whole thing. - return %{"#{arg}"} - end - - quote_hit = true - result = +'"' - arg.chars.reverse.each do |c| - result << c - if quote_hit and c == '\\' - result << '\\' - elsif c == '"' - quote_hit = true - result << '\\' - else - quote_hit = false - end - end - result << '"' - result.reverse + def close + @result = @terminal.close end - private def launch(command) - command = "#{command}\0" - converted_command = mb2wc(command) - @pi = DL::PROCESS_INFORMATION.malloc - (@pi.to_ptr + 0)[0, DL::PROCESS_INFORMATION.size] = "\x00" * DL::PROCESS_INFORMATION.size - @startup_info_ex = DL::STARTUPINFOW.malloc - (@startup_info_ex.to_ptr + 0)[0, DL::STARTUPINFOW.size] = "\x00" * DL::STARTUPINFOW.size - r = DL.CreateProcessW( - Fiddle::NULL, converted_command, - Fiddle::NULL, Fiddle::NULL, 0, 0, Fiddle::NULL, Fiddle::NULL, - @startup_info_ex, @pi - ) - error_message(r, 'CreateProcessW') - sleep @wait - rescue => e - pp e + def result + @terminal.result end - private def error_message(r, method_name) - return if not r.zero? - err = DL.GetLastError - string = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP) - DL.FormatMessage( - DL::FORMAT_MESSAGE_ALLOCATE_BUFFER | DL::FORMAT_MESSAGE_FROM_SYSTEM, - Fiddle::NULL, - err, - 0x0, - string, - 0, - Fiddle::NULL - ) - log "ERROR(#{method_name}): #{err.to_s}: #{string.ptr.to_s}" - DL.LocalFree(string) + def codepage_success? + @terminal.codepage_success? end - private def log(str) - puts str - open('aaa', 'a') do |fp| - fp.puts str - end + def identify + @terminal.identify end - def write(str) - sleep @wait - records = Fiddle::Pointer.malloc(DL::INPUT_RECORD_WITH_KEY_EVENT.size * str.size * 2, DL::FREE) - str.chars.each_with_index do |c, i| - c = "\r" if c == "\n" - byte = c.getbyte(0) - if c.bytesize == 1 and byte.allbits?(0x80) # with Meta key - c = (byte ^ 0x80).chr - control_key_state = DL::LEFT_ALT_PRESSED - else - control_key_state = 0 + def start_terminal(height, width, command, wait: nil, timeout: nil, startup_message: nil, codepage: nil) + @timeout = timeout || Yamatanooroti.options.default_timeout + @startup_timeout = @timeout + 2 + @wait = wait || Yamatanooroti.options.default_wait + @result = nil + if @terminal + if !Yamatanooroti.options.show_console || Yamatanooroti.options.close_console != :never + @terminal.close_console end - record_index = i * 2 - r = DL::INPUT_RECORD_WITH_KEY_EVENT.new(records + DL::INPUT_RECORD_WITH_KEY_EVENT.size * record_index) - set_input_record(r, c, true, control_key_state) - record_index = i * 2 + 1 - r = DL::INPUT_RECORD_WITH_KEY_EVENT.new(records + DL::INPUT_RECORD_WITH_KEY_EVENT.size * record_index) - set_input_record(r, c, false, control_key_state) end - written_size = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DWORD, DL::FREE) - r = DL.WriteConsoleInputW(DL.GetStdHandle(DL::STD_INPUT_HANDLE), records, str.size * 2, written_size) - error_message(r, 'WriteConsoleInput') - end - - private def set_input_record(r, c, key_down, control_key_state) - begin - code = c.unpack('U').first - rescue ArgumentError - code = c.bytes.first + if Yamatanooroti.options.conhost + @terminal = Yamatanooroti::ConhostTerm.setup_console(height, width, codepage, @wait, @timeout, local_name) + else + @terminal = Yamatanooroti::WindowsTerminalTerm.setup_console(height, width, codepage, @wait, @timeout, local_name) end - r.EventType = DL::KEY_EVENT - r.bKeyDown = key_down ? 1 : 0 - r.wRepeatCount = 1 - r.wVirtualKeyCode = DL.VkKeyScanW(code) - r.wVirtualScanCode = DL.MapVirtualKeyW(code, 0) - r.UnicodeChar = code - r.dwControlKeyState = control_key_state - end - - private def free_resources - h_snap = DL.CreateToolhelp32Snapshot(DL::TH32CS_SNAPPROCESS, 0) - pe = DL::PROCESSENTRY32W.malloc - (pe.to_ptr + 0)[0, DL::PROCESSENTRY32W.size] = "\x00" * DL::PROCESSENTRY32W.size - pe.dwSize = DL::PROCESSENTRY32W.size - r = DL.Process32FirstW(h_snap, pe) - error_message(r, "Process32First") - process_table = {} - loop do - #log "a #{pe.th32ParentProcessID.inspect} -> #{pe.th32ProcessID.inspect} #{wc2mb(pe.szExeFile.pack('S260')).unpack('Z*').pack('Z*')}" - process_table[pe.th32ParentProcessID] ||= [] - process_table[pe.th32ParentProcessID] << pe.th32ProcessID - break if DL.Process32NextW(h_snap, pe).zero? - end - process_table[DL.GetCurrentProcessId].each do |child_pid| - kill_process_tree(process_table, child_pid) - end - #r = DL.TerminateThread(@pi.hThread, 0) - #error_message(r, "TerminateThread") - #sleep @wait - r = DL.FreeConsole() - #error_message(r, "FreeConsole") - r = DL.AttachConsole(DL::ATTACH_PARENT_PROCESS) - error_message(r, 'AttachConsole') - end - - private def kill_process_tree(process_table, pid) - process_table[pid]&.each do |child_pid| - kill_process_tree(process_table, child_pid) - end - h_proc = DL.OpenProcess(DL::PROCESS_ALL_ACCESS, 0, pid) - if (h_proc) - r = DL.TerminateProcess(h_proc, 0) - error_message(r, "TerminateProcess") - r = DL.CloseHandle(h_proc) - error_message(r, "CloseHandle") - end - end - - def close - sleep 0.3 - # read first before kill the console process including output - @result = retrieve_screen - - free_resources - end - - private def retrieve_screen - buffer_chars = @width * 8 - buffer = Fiddle::Pointer.malloc(Fiddle::SIZEOF_SHORT * buffer_chars, DL::FREE) - n = Fiddle::Pointer[0] - lines = (0...@height).map do |y| - r = DL.ReadConsoleOutputCharacterW(@output_handle, buffer, @width, y << 16, -n) - error_message(r, "ReadConsoleOutputCharacterW") - r == 0 ? "" : wc2mb(buffer[0, n.to_i * 2]).gsub(/ *$/, "") - end - lines - end - - def result - @result || retrieve_screen - end - - def start_terminal(height, width, command, wait: 0.01, timeout: 2, startup_message: nil) - @timeout = timeout - @wait = wait - @result = nil - - @height = height - @width = width - setup_console(height, width) - launch(command.map{ |c| quote_command_arg(c) }.join(' ')) + @terminal.launch(command) case startup_message when String - wait_startup_message { |message| message.start_with?(startup_message) } + wait_startup_message { |message| message.start_with?(startup_message.chars.each_slice(width).map { |l| l.join.rstrip }.join("\n")) } when Regexp wait_startup_message { |message| startup_message.match?(message) } end end private def wait_startup_message - wait_until = Time.now + @timeout + wait_until = Time.now + @startup_timeout + chunks = +'' loop do wait = wait_until - Time.now if wait.negative? raise "Startup message didn't arrive within timeout: #{chunks.inspect}" end - break if yield retrieve_screen.join("\n").sub(/\n*\z/, "\n") + chunks = @terminal.retrieve_screen.join("\n").sub(/\n*\z/, "\n") + break if yield chunks sleep @wait end end private def retryable_screen_assertion_with_proc(check_proc, assert_proc, convert_proc = :itself.to_proc) retry_until = Time.now + @timeout - while Time.now < retry_until - break if @result - - break if check_proc.call(convert_proc.call(retrieve_screen)) - sleep @wait + screen = if @result + convert_proc.call(@result) + else + loop do + screen = convert_proc.call(@terminal.retrieve_screen) + break screen if Time.now >= retry_until + break screen if check_proc.call(screen) + sleep @wait + end end - assert_proc.call(convert_proc.call(@result || retrieve_screen)) + @terminal.clear_need_wait_flag + assert_proc.call(screen) end def assert_screen(expected_lines, message = nil) @@ -540,6 +104,18 @@ def assert_screen(expected_lines, message = nil) ) end end + + def self.included(cls) + cls.instance_exec do + teardown do + @terminal&.close_console( + !Yamatanooroti.options.show_console || + Yamatanooroti.options.close_console == :always || + Yamatanooroti.options.close_console == :pass && passed? + ) + end + end + end end class Yamatanooroti::WindowsTestCase < Test::Unit::TestCase diff --git a/lib/yamatanooroti/windows/conhost.rb b/lib/yamatanooroti/windows/conhost.rb new file mode 100644 index 0000000..1e8fd89 --- /dev/null +++ b/lib/yamatanooroti/windows/conhost.rb @@ -0,0 +1,66 @@ +class Yamatanooroti::ConhostTerm + include Yamatanooroti::WindowsTermMixin + + def self.setup_console(height, width, codepage, wait, timeout, name) + new(height, width, codepage, wait, timeout, name) + end + + attr_reader :console_process_id + + def initialize(height, width, codepage, wait, timeout, name) + check_interrupt + @wait = wait + @timeout = timeout + @name = name + @result = nil + @codepage_success_p = nil + @wrote_and_not_yet_waited = false + + countup_testcase_title(name) + pipename = get_pipename(name, "open") + pipe_handle = DL.create_named_pipe(pipename) + DL.create_console(keeper_commandline(pipename), show_console_param()) + + # wait for console startup complete + with_timeout("Console opening process startup timed out.") do + @console_process_id = DL.get_named_pipe_client_processid(pipe_handle, maybe_fail: true) + end + + pipename = get_pipename(name, "main") + @pipe_handle = DL.create_named_pipe(pipename) + attach_terminal(open: false) do + spawn(keeper_commandline(pipename)) + end + + # wait for console startup complete + with_timeout("Console keeping process startup timed out.") do + @console_process_id = DL.get_named_pipe_client_processid(@pipe_handle, maybe_fail: true) + end + + DL.close_handle(pipe_handle) + sleep 0.1 + setup_cp(codepage) if codepage + + attach_terminal do |conin, conout| + DL.set_console_window_size(conout, height, width) + end + end + + def close_console(need_to_close = true) + if @console_process_id + if need_to_close || DL.interrupted? + if @target && !@target.closed? + @target.close + end + DL.close_handle(@pipe_handle) + else + castling(@pipe_handle) + end + @console_process_id = @pipe_handle = nil + end + end + + def close! + close_console(!Yamatanooroti.options.show_console) + end +end diff --git a/lib/yamatanooroti/windows/terminal.rb b/lib/yamatanooroti/windows/terminal.rb new file mode 100644 index 0000000..3ec9073 --- /dev/null +++ b/lib/yamatanooroti/windows/terminal.rb @@ -0,0 +1,104 @@ +require_relative "./wt" + +class Yamatanooroti::WindowsTerminalTerm + include Yamatanooroti::WindowsTermMixin + Self = self + + def self.window_title + @count = @count ? @count + 1 : 0 + "yamatanooroti##{@count}@#{Process.pid}" + end + + def self.testcase_title(title) + count = @iter_count&.fetch(title) + count ||= countup_testcase_title(title) + "#{title}##{count}@#{Process.pid}" + end + + def self.countup_testcase_title(title) + counter = (@iter_count ||= {}) + count = counter[title] || 0 + counter[title] = count + 1 + end + + class << self + attr_accessor :wt, :max_size, :min_size, :split_cache_h, :split_cache_v + end + + def self.setup_console(height, width, codepage, wait, timeout, name) + if !Self.wt + Self.wt = self.new(*max_size, wait, timeout, Self.window_title) + end + + countup_testcase_title(name) + new_tab = Self.wt.new_tab(height, width, name) + new_tab.setup_cp(codepage) if codepage + Self.wt + end + + def codepage_success? + @wt.active_tab.codepage_success? + end + + def console_process_id + @wt.active_tab&.console_process_id + end + + def get_size + (@wt.active_tab || @wt.base_tab).get_size + end + + def initialize(height, width, wait, timeout, title = "yamatanooroti") + check_interrupt + @wait = wait + @timeout = timeout + @result = nil + @codepage_success_p = nil + @wrote_and_not_yet_waited = false + + @wt = Yamatanooroti::WindowsTerminal.new(height, width, title, title, wait, timeout, self) + end + + def new_tab(height, width, name) + @result = nil + @codepage_success_p = nil + @wrote_and_not_yet_waited = false + @name = name + + @wt.new_tab(height, width, testcase_title(name)) + end + + def close_console(need_to_close = true) + if need_to_close || DL.interrupted? + if @target && !@target.closed? + @target.close + end + @wt&.close_tab + else + @wt&.detach_tab + end + end + + def close! + @wt&.close + @wt = nil + end + + def self.diagnose_size_capability + wt = self.new(999, 999, 0.01, 5.0) + max_size = wt.get_size + Self.max_size = [[max_size[0], 60].min, [max_size[1], 200].min] + puts max_size.then { |r, c| "Windows Terminal maximum size: rows: #{r}, columns: #{c}" } + wt.close! + #wt = self.new(2, 2, 0.01, 5.0) + #min_size = wt.get_size + #Self.min_size = min_size + #puts min_size.then { |r, c| "Windows Terminal smallest size: rows: #{r}, columns: #{c}" } + #wt.close! + puts Self.max_size.then {|r, c| "Use test window size: rows: #{r}, columns: #{c}" } + end + + Test::Unit.at_exit do + Self.wt&.close! + end +end diff --git a/lib/yamatanooroti/windows/windows-definition.rb b/lib/yamatanooroti/windows/windows-definition.rb new file mode 100644 index 0000000..8b56e59 --- /dev/null +++ b/lib/yamatanooroti/windows/windows-definition.rb @@ -0,0 +1,571 @@ +require 'fiddle/import' +require 'fiddle/types' +class Yamatanooroti; end + +module Yamatanooroti::WindowsDefinition + extend Fiddle::Importer + dlload 'kernel32.dll', 'user32.dll' + include Fiddle::Win32Types + + FREE = Fiddle::Function.new(Fiddle::RUBY_FREE, [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID) + + typealias 'SHORT', 'short' + typealias 'HWND', 'HANDLE' + typealias 'LPVOID', 'void*' + typealias 'LPWSTR', 'void*' + typealias 'LPBYTE', 'void*' + typealias 'LPCWSTR', 'void*' + typealias 'LPCVOID', 'void*' + typealias 'LPDWORD', 'void*' + typealias 'WCHAR', 'unsigned short' + typealias 'LPCWCH', 'void*' + typealias 'LPSTR', 'void*' + typealias 'LPCCH', 'void*' + typealias 'LPBOOL', 'void*' + typealias 'PULONG', 'ULONG*' + typealias 'LONG', 'int' + typealias 'HLOCAL', 'HANDLE' + + Fiddle::SIZEOF_DWORD = Fiddle::SIZEOF_LONG + Fiddle::SIZEOF_WORD = Fiddle::SIZEOF_SHORT + Fiddle::SIZEOF_ULONG = Fiddle::SIZEOF_LONG if !Fiddle.const_defined?(:SIZEOF_ULONG) + + COORD = struct [ + 'SHORT X', + 'SHORT Y' + ] + typealias 'COORD', 'DWORD32' + + SMALL_RECT = struct [ + 'SHORT Left', + 'SHORT Top', + 'SHORT Right', + 'SHORT Bottom' + ] + typealias 'PSMALL_RECT', 'SMALL_RECT*' + + CONSOLE_SCREEN_BUFFER_INFO = struct [ + 'SHORT dwSize_X', 'SHORT dwSize_Y', # 'COORD dwSize', + 'SHORT dwCursorPosition_X', 'SHORT dwCursorPosition_Y', #'COORD dwCursorPosition', + 'WORD wAttributes', + 'SHORT Left', 'SHORT Top', 'SHORT Right', 'SHORT Bottom', # 'SMALL_RECT srWindow', + 'SHORT MaxWidth', 'SHORT MaxHeight' # 'COORD dwMaximumWindowSize' + ] + typealias 'PCONSOLE_SCREEN_BUFFER_INFO', 'CONSOLE_SCREEN_BUFFER_INFO*' + + typealias 'COLORREF', 'DWORD' + CONSOLE_SCREEN_BUFFER_INFOEX = struct [ + 'ULONG cbSize', + 'SHORT dwSize_X', 'SHORT dwSize_Y', # 'COORD dwSize', + 'SHORT dwCursorPosition_X', 'SHORT dwCursorPosition_Y', #'COORD dwCursorPosition', + 'WORD wAttributes', + 'SHORT Left', 'SHORT Top', 'SHORT Right', 'SHORT Bottom', # 'SMALL_RECT srWindow', + 'SHORT MaxWidth', 'SHORT MaxHeight', # 'COORD dwMaximumWindowSize', + 'BOOL bFullScreenSupported', + 'COLORREF ColorTable[16]' + ] + typealias 'PCONSOLE_SCREEN_BUFFER_INFOEX', 'CONSOLE_SCREEN_BUFFER_INFOEX*' + + SECURITY_ATTRIBUTES = struct [ + 'DWORD nLength', + 'LPVOID lpSecurityDescriptor', + 'BOOL bInheritHandle' + ] + typealias 'LPSECURITY_ATTRIBUTES', 'SECURITY_ATTRIBUTES*' + + STARTUPINFOW = struct [ + 'DWORD cb', + 'LPWSTR lpReserved', + 'LPWSTR lpDesktop', + 'LPWSTR lpTitle', + 'DWORD dwX', + 'DWORD dwY', + 'DWORD dwXSize', + 'DWORD dwYSize', + 'DWORD dwXCountChars', + 'DWORD dwYCountChars', + 'DWORD dwFillAttribute', + 'DWORD dwFlags', + 'WORD wShowWindow', + 'WORD cbReserved2', + 'LPBYTE lpReserved2', + 'HANDLE hStdInput', + 'HANDLE hStdOutput', + 'HANDLE hStdError' + ] + typealias 'LPSTARTUPINFOW', 'STARTUPINFOW*' + + PROCESS_INFORMATION = struct [ + 'HANDLE hProcess', + 'HANDLE hThread', + 'DWORD dwProcessId', + 'DWORD dwThreadId' + ] + typealias 'LPPROCESS_INFORMATION', 'PROCESS_INFORMATION*' + + INPUT_RECORD_WITH_KEY_EVENT = struct [ + 'WORD EventType', + 'BOOL bKeyDown', + 'WORD wRepeatCount', + 'WORD wVirtualKeyCode', + 'WORD wVirtualScanCode', + 'WCHAR UnicodeChar', + ## union 'CHAR AsciiChar', + 'DWORD dwControlKeyState' + ] + + STD_INPUT_HANDLE = -10 + STD_OUTPUT_HANDLE = -11 + STD_ERROR_HANDLE = -12 + + STARTF_USESHOWWINDOW = 1 + CREATE_NEW_CONSOLE = 0x10 + CREATE_NEW_PROCESS_GROUP = 0x200 + CREATE_UNICODE_ENVIRONMENT = 0x400 + CREATE_NO_WINDOW = 0x08000000 + ATTACH_PARENT_PROCESS = -1 + KEY_EVENT = 0x0001 + SW_HIDE = 0 + SW_SHOWNORMAL = 1 + SW_SHOWNOACTIVE = 4 + SW_SHOWMINNOACTIVE = 7 + SW_SHOWNA = 8 + LEFT_ALT_PRESSED = 0x0002 + ENABLE_PROCESSED_INPUT = 0x0001 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + + # HANDLE GetStdHandle(DWORD nStdHandle); + extern 'HANDLE GetStdHandle(DWORD);', :stdcall + # BOOL CloseHandle(HANDLE hObject); + extern 'BOOL CloseHandle(HANDLE);', :stdcall + + # BOOL FreeConsole(void); + extern 'BOOL FreeConsole(void);', :stdcall + # BOOL AttachConsole(DWORD dwProcessId); + extern 'BOOL AttachConsole(DWORD);', :stdcall + # HWND WINAPI GetConsoleWindow(void); + extern 'HWND GetConsoleWindow(void);', :stdcall + # BOOL WINAPI SetConsoleWindowInfo(HANDLE hConsoleOutput, BOOL bAbsolute, const SMALL_RECT *lpConsoleWindow); + extern 'BOOL SetConsoleWindowInfo(HANDLE, BOOL, PSMALL_RECT);', :stdcall + # BOOL WriteConsoleInputW(HANDLE hConsoleInput, const INPUT_RECORD *lpBuffer, DWORD nLength, LPDWORD lpNumberOfEventsWritten); + extern 'BOOL WriteConsoleInputW(HANDLE, const INPUT_RECORD*, DWORD, LPDWORD);', :stdcall + # SHORT VkKeyScanW(WCHAR ch); + extern 'SHORT VkKeyScanW(WCHAR);', :stdcall + # UINT MapVirtualKeyW(UINT uCode, UINT uMapType); + extern 'UINT MapVirtualKeyW(UINT, UINT);', :stdcall + # BOOL GetNumberOfConsoleInputEvents(HANDLE hConsoleInput, LPDWORD lpcNumberOfEvents); + extern 'BOOL GetNumberOfConsoleInputEvents(HANDLE hConsoleInput, LPDWORD lpcNumberOfEvents);', :stdcall + # BOOL WINAPI ReadConsoleOutputCharacterW(HANDLE hConsoleOutput, LPWSTR lpCharacter, DWORD nLength, COORD dwReadCoord, LPDWORD lpNumberOfCharsRead); + extern 'BOOL ReadConsoleOutputCharacterW(HANDLE, LPWSTR, DWORD, COORD, LPDWORD);', :stdcall + # BOOL WINAPI GetConsoleScreenBufferInfo(HANDLE hConsoleOutput, PCONSOLE_SCREEN_BUFFER_INFO lpConsoleScreenBufferInfo); + extern 'BOOL GetConsoleScreenBufferInfo(HANDLE, PCONSOLE_SCREEN_BUFFER_INFO);', :stdcall + # BOOL WINAPI GetConsoleScreenBufferInfoEx(HANDLE hConsoleOutput, PCONSOLE_SCREEN_BUFFER_INFOEX lpConsoleScreenBufferInfoEx); + extern 'BOOL GetConsoleScreenBufferInfoEx(HANDLE, PCONSOLE_SCREEN_BUFFER_INFOEX);', :stdcall + # BOOL WINAPI SetConsoleScreenBufferInfoEx(HANDLE hConsoleOutput, PCONSOLE_SCREEN_BUFFER_INFOEX lpConsoleScreenBufferInfoEx); + extern 'BOOL SetConsoleScreenBufferInfoEx(HANDLE, PCONSOLE_SCREEN_BUFFER_INFOEX);', :stdcall + # BOOL WINAPI GetConsoleMode(HANDLE hConsoleHandle, LPDWORD lpMode); + extern 'BOOL GetConsoleMode(HANDLE, LPDWORD);', :stdcall + # BOOL WINAPI SetConsoleMode(HANDLE hConsoleHandle, DWORD dwMode); + extern 'BOOL SetConsoleMode(HANDLE, DWORD);', :stdcall + # BOOL WINAPI SetConsoleCP(UINT wCodePageID); + extern 'BOOL SetConsoleCP(UINT);', :stdcall + # BOOL WINAPI SetConsoleOutputCP(UINT wCodePageID); + extern 'BOOL SetConsoleOutputCP(UINT);', :stdcall + # UINT WINAPI GetConsoleCP(void); + extern 'UINT GetConsoleCP(void);', :stdcall + # UINT WINAPI GetConsoleOutputCP(void); + extern 'UINT GetConsoleOutputCP(void);', :stdcall + + # BOOL CreateProcessW(LPCWSTR lpApplicationName, LPWSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, LPSTARTUPINFOW lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation); + extern 'BOOL CreateProcessW(LPCWSTR lpApplicationName, LPWSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCWSTR lpCurrentDirectory, LPSTARTUPINFOW lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation);', :stdcall + + # int MultiByteToWideChar(UINT CodePage, DWORD dwFlags, LPCSTR lpMultiByteStr, int cbMultiByte, LPWSTR lpWideCharStr, int cchWideChar); + extern 'int MultiByteToWideChar(UINT, DWORD, LPCSTR, int, LPWSTR, int);', :stdcall + # int WideCharToMultiByte(UINT CodePage, DWORD dwFlags, _In_NLS_string_(cchWideChar)LPCWCH lpWideCharStr, int cchWideChar, LPSTR lpMultiByteStr, int cbMultiByte, LPCCH lpDefaultChar, LPBOOL lpUsedDefaultChar); + extern 'int WideCharToMultiByte(UINT, DWORD, LPCWCH, int, LPSTR, int, LPCCH, LPBOOL);', :stdcall + + # HANDLE CreateFileA(LPCSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile); + extern 'HANDLE CreateFileA(LPCSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE);', :stdcall + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + FILE_SHARE_READ = 0x00000001 + FILE_SHARE_WRITE = 0x00000002 + OPEN_EXISTING = 3 + INVALID_HANDLE_VALUE = 0xffffffff + + # HANDLE CreateNamedPipeA(LPCSTR lpName, DWORD dwOpenMode, DWORD dwPipeMode, DWORD nMaxInstances, DWORD nOutBufferSize, DWORD nInBufferSize, DWORD nDefaultTimeOut, LPSECURITY_ATTRIBUTES lpSecurityAttributes); + extern 'HANDLE CreateNamedPipeA(LPCSTR, DWORD, DWORD, DWORD, DWORD, DWORD, DWORD, LPSECURITY_ATTRIBUTES);', :stdcall + # BOOL ConnectNamedPipe(HANDLE hNamedPipe, LPOVERLAPPED lpOverlapped); + extern 'BOOL ConnectNamedPipe(HANDLE, LPVOID);', :stdcall + # BOOL DisconnectNamedPipe(HANDLE hNamedPipe); + extern 'BOOL DisconnectNamedPipe(HANDLE);', :stdcall + # BOOL GetNamedPipeClientProcessId(HANDLE Pipe, PULONG ClientProcessId); + extern 'BOOL GetNamedPipeClientProcessId(HANDLE, PULONG);', :stdcall + PIPE_ACCESS_INBOUND = 0x00000001 + PIPE_ACCESS_OUTBOUND = 0x00000002 + PIPE_ACCESS_DUPLEX = 0x00000003 + + # DWORD FormatMessageW(DWORD dwFlags, LPCVOID lpSource, DWORD dwMessageId, DWORD dwLanguageId, LPWSTR lpBuffer, DWORD nSize, va_list *Arguments); + extern 'DWORD FormatMessageW(DWORD dwFlags, LPCVOID lpSource, DWORD dwMessageId, DWORD dwLanguageId, LPWSTR lpBuffer, DWORD nSize, va_list *Arguments);', :stdcall + # HLOCAL LocalFree(HLOCAL hMem); + extern 'HLOCAL LocalFree(HLOCAL hMem);', :stdcall + FORMAT_MESSAGE_ALLOCATE_BUFFER = 0x00000100 + FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 + + # BOOL WINAPI SetConsoleCtrlHandler(PHANDLER_ROUTINE HandlerRoutine, BOOL Add); + extern 'BOOL SetConsoleCtrlHandler(void *, BOOL Add);', :stdcall + # BOOL WINAPI GenerateConsoleCtrlEvent(DWORD dwCtrlEvent, DWORD dwProcessGroupId); + extern 'BOOL GenerateConsoleCtrlEvent(DWORD, DWORD);', :stdcall + + private def successful_or_if_not_messageout(r, method_name, exception: true) + return true if not r.zero? + err = Fiddle.win32_last_error + if err == 0 + $stderr.puts "Info: #{method_name} returns zero but win32_last_error has successful zero." + return true + end + string = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP, FREE) + n = FormatMessageW( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, + Fiddle::NULL, + err, + 0x409, # en-US + string, + 0, + Fiddle::NULL + ) + if n > 0 + str = wc2mb(string.ptr[0, n * 2]) + LocalFree(string) + else + msgerr = Fiddle.win32_last_error + str = "error description unknow (FormatMessageW failed with #{msgerr})" + end + msg = "ERROR(#{method_name}): #{err}: #{str}" + if exception + raise msg + else + $stderr.puts msg + return false + end + end + + def get_console_screen_buffer_info(handle) + csbi = CONSOLE_SCREEN_BUFFER_INFO.malloc(FREE) + r = GetConsoleScreenBufferInfo(handle, csbi) + successful_or_if_not_messageout(r, 'GetConsoleScreenBufferInfo') + return csbi + end + + def set_console_screen_buffer_info_ex(handle, h, w, buffer_height) + csbi = CONSOLE_SCREEN_BUFFER_INFOEX.malloc(FREE) + csbi.cbSize = CONSOLE_SCREEN_BUFFER_INFOEX.size + r = GetConsoleScreenBufferInfoEx(handle, csbi) + successful_or_if_not_messageout(r, 'GetConsoleScreenBufferSize') + csbi.dwSize_X = w + csbi.dwSize_Y = buffer_height + csbi.Left = 0 + csbi.Right = w - 1 + csbi.Top = [csbi.Top, buffer_height - h].min + csbi.Bottom = csbi.Top + h - 1 + r = SetConsoleScreenBufferInfoEx(handle, csbi) + return successful_or_if_not_messageout(r, 'SetConsoleScreenBufferInfoEx') + end + + def set_console_window_info(handle, h, w) + rect = SMALL_RECT.malloc(FREE) + rect.Left = 0 + rect.Top = 0 + rect.Right = w - 1 + rect.Bottom = h - 1 + r = SetConsoleWindowInfo(handle, 1, rect) + return successful_or_if_not_messageout(r, 'SetConsoleWindowInfo') + end + + def set_console_window_size(handle, h, w) + # expand buffer size to keep scrolled away lines + buffer_h = h + 100 + + r = set_console_screen_buffer_info_ex(handle, h, w, buffer_h) + return false unless r + + r = set_console_window_info(handle, h, w) + return false unless r + + return true + end + + def create_console_file_handle(name) + fh = CreateFileA( + name, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nil, + OPEN_EXISTING, + 0, + 0 + ) + fh = [fh].pack("J").unpack1("J") + successful_or_if_not_messageout(0, name) if fh == INVALID_HANDLE_VALUE + fh + end + + def close_handle(handle) + r = CloseHandle(handle) + return successful_or_if_not_messageout(r, "CloseHandle") + end + + def free_console + r = FreeConsole() + return successful_or_if_not_messageout(r, "FreeConsole") + end + + def attach_console(pid = ATTACH_PARENT_PROCESS, maybe_fail: false) + r = AttachConsole(pid) + return successful_or_if_not_messageout(r, 'AttachConsole') unless maybe_fail + return r != 0 # || Fiddle.win32_last_error == 0 # this case couses problem + end + + SHOWWINDOW_MAP = { + conhost: { + show: SW_SHOWNOACTIVE, + hide: SW_HIDE, + }, + "legacy-conhost": { + show: SW_SHOWNOACTIVE, + hide: SW_SHOWMINNOACTIVE, + }, + "terminal": { + show: SW_SHOWNOACTIVE, + hide: SW_HIDE, + } + } + + def create_console(command, show = SW_SHOWNORMAL) + converted_command = mb2wc("#{command}\0") + console_process_info = PROCESS_INFORMATION.malloc(FREE) + console_process_info.to_ptr[0, PROCESS_INFORMATION.size] = "\0".b * PROCESS_INFORMATION.size + startup_info = STARTUPINFOW.malloc(FREE) + startup_info.to_ptr[0, STARTUPINFOW.size] = "\0".b * STARTUPINFOW.size + startup_info.cb = STARTUPINFOW.size + startup_info.dwFlags = STARTF_USESHOWWINDOW + startup_info.wShowWindow = show + + restore_console_control_handler do + r = CreateProcessW( + Fiddle::NULL, converted_command, + Fiddle::NULL, Fiddle::NULL, + 0, + CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, + Fiddle::NULL, Fiddle::NULL, + startup_info, console_process_info + ) + successful_or_if_not_messageout(r, 'CreateProcessW') + end + close_handle(console_process_info.hProcess) + close_handle(console_process_info.hThread) + return console_process_info.dwProcessId + end + + def get_std_handle(stdhandle) + fh = GetStdHandle(stdhandle) + successful_or_if_not_messageout(0, name) if fh == INVALID_HANDLE_VALUE + fh + end + + def mb2wc(str) + size = MultiByteToWideChar(65001, 0, str, str.bytesize, '', 0) + converted_str = "\x00".b * (size * 2) + MultiByteToWideChar(65001, 0, str, str.bytesize, converted_str, size) + converted_str + end + + def wc2mb(str) + size = WideCharToMultiByte(65001, 0, str, str.bytesize / 2, '', 0, 0, 0) + converted_str = "\x00".b * size + WideCharToMultiByte(65001, 0, str, str.bytesize / 2, converted_str, converted_str.bytesize, 0, 0) + converted_str.force_encoding("UTF-8") + end + + def read_console_output(handle, row, width) + buffer = Fiddle::Pointer.malloc(Fiddle::SIZEOF_SHORT * width, FREE) + n = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DWORD, FREE) + r = ReadConsoleOutputCharacterW(handle, buffer, width, row << 16, n) + successful_or_if_not_messageout(r, "ReadConsoleOutputCharacterW") + return wc2mb(buffer[0, n.to_str.unpack1("L") * 2]).gsub(/ *$/, "") + end + + def set_input_record(r, code) + r.EventType = KEY_EVENT + # r.bKeyDown = 1 + r.wRepeatCount = 1 + r.dwControlKeyState = code < 0 ? LEFT_ALT_PRESSED : 0 + code = code.abs + r.wVirtualKeyCode = VkKeyScanW(code) + r.wVirtualScanCode = MapVirtualKeyW(code, 0) + r.UnicodeChar = code + return r + end + + def build_key_input_record(str) + codes = str.chars.map do |c| + c = "\r" if c == "\n" + byte = c.getbyte(0) + if c.bytesize == 1 and byte.allbits?(0x80) # with Meta key + [-(byte ^ 0x80)] + else + mb2wc(c).unpack("S*") + end + end.flatten + record = INPUT_RECORD_WITH_KEY_EVENT.malloc(FREE) + records = codes.reduce("".b) do |records, code| + set_input_record(record, code) + record.bKeyDown = 1 + records << record.to_ptr.to_str + record.bKeyDown = 0 + records << record.to_ptr.to_str + end + [records, codes.size * 2] + end + + def write_console_input(handle, records, n) + written = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DWORD, FREE) + r = WriteConsoleInputW(handle, records, n, written) + successful_or_if_not_messageout(r, 'WriteConsoleInput') + return written.to_str.unpack1('L') + end + + def get_number_of_console_input_events(handle) + n = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DWORD, FREE) + r = GetNumberOfConsoleInputEvents(handle, n) + successful_or_if_not_messageout(r, 'GetNumberOfConsoleInputEvents') + return n.to_str.unpack1('L') + end + + def get_console_mode(handle) + mode = Fiddle::Pointer.malloc(Fiddle::SIZEOF_DWORD, FREE) + mode[0, Fiddle::SIZEOF_DWORD] = "\0".b * Fiddle::SIZEOF_DWORD + r = GetConsoleMode(handle, mode) + successful_or_if_not_messageout(r, 'GetConsoleMode', exception: false) ? mode.to_str.unpack1('L') : nil + end + + def set_console_mode(handle, mode) + 0 != SetConsoleMode(handle, mode) + end + + def set_console_codepage(cp) + r = SetConsoleCP(cp) + return successful_or_if_not_messageout(r, 'SetConsoleCP', exception: false) + end + + def set_console_output_codepage(cp) + r = SetConsoleOutputCP(cp) + return successful_or_if_not_messageout(r, 'SetConsoleOutputCP', exception: false) + end + + def get_console_codepage() + GetConsoleCP() + end + + def get_console_output_codepage() + GetConsoleOutputCP() + end + + def generate_console_ctrl_event(event, pgrp) + r = GenerateConsoleCtrlEvent(event, pgrp) + return successful_or_if_not_messageout(r, 'GenerateConsoleCtrlEvent') + end + + def create_named_pipe(name) + ph = CreateNamedPipeA(%Q[\\\\.\\\pipe\\#{name}], PIPE_ACCESS_OUTBOUND, 0, 1, 0, 0, 0, nil) + ph = [ph].pack("J").unpack1("L") + successful_or_if_not_messageout(0, name) if ph == INVALID_HANDLE_VALUE + ph + end + + def connect_named_pipe(pipe_handle) + r = ConnectNamedPipe(pipe_handle, nil) + return successful_or_if_not_messageout(r, 'ConnectNamedPipe') + end + + def disconnect_named_pipe(pipe_handle) + r = DisconnectNamedPipe(pipe_handle) + return successful_or_if_not_messageout(r, 'DisconnectNamedPipe') + end + + def get_named_pipe_client_processid(pipe_handle, maybe_fail: false) + pid = Fiddle::Pointer.malloc(Fiddle::SIZEOF_ULONG, FREE) + r = GetNamedPipeClientProcessId(pipe_handle, pid) + if !maybe_fail + successful_or_if_not_messageout(r, "GetNamedPipeClientProcessId") + return pid.to_str.unpack1("L") + else + return pid.to_str.unpack1("L") if r != 0 + return nil + end + end + + # Ctrl+C trap support + # FreeConsole(), AttachConsole() clears console control handlers. + # Make matter worse, C runtime does not provide the function to restore that handlers. + # Yamatanooroti will ignore Ctrl+C and monitors the end of decoy process. + + def self.ignore_console_control_handler + SetConsoleCtrlHandler(0, 1) + end + + def self.restore_console_control_handler(&block) + SetConsoleCtrlHandler(0, 0) + if block_given? + yield + SetConsoleCtrlHandler(0, 1) + end + end + + @pipe = IO.pipe + @interrupt_catcher_pid = spawn("choice /m #InterruptCatcher", {:in => @pipe[0], [:out, :err] => "NUL"}) + @interrupt_catcher = Process.detach(@interrupt_catcher_pid) + ignore_console_control_handler + @interrupted_p = nil + @pipe[0].close + + def self.interrupted? + @interrupted_p || + unless @interrupt_catcher.alive? + @interrupted_p = (@interrupt_catcher.value.exitstatus == 0) + end + end + + def self.at_exit + @pipe[1].close unless @pipe[1].closed? + if @interrupt_catcher.alive? + sleep 0.01 + begin + Process.kill("KILL", @interrupt_catcher_pid) + rescue Errno::ESRCH # No such process + end + end + end + + if Object.const_defined?(:Test) && Test.const_defined?(:Unit) + Test::Unit.at_exit do + Yamatanooroti::WindowsDefinition.at_exit + end + end + + extend self +end + +if __FILE__ == $0 + at_exit { Yamatanooroti::WindowsDefinition.at_exit } + DL = Yamatanooroti::WindowsDefinition + def invoke_key(conin, str) + DL.write_console_input(conin, *DL.build_key_input_record(str)) + end + cin = DL.get_std_handle(DL::STD_INPUT_HANDLE) + cout = DL.get_std_handle(DL::STD_OUTPUT_HANDLE) + cerr = DL.get_std_handle(DL::STD_ERROR_HANDLE) + + binding.irb + [cin, cout, cerr] +end diff --git a/lib/yamatanooroti/windows/windows-setup.rb b/lib/yamatanooroti/windows/windows-setup.rb new file mode 100644 index 0000000..98e4149 --- /dev/null +++ b/lib/yamatanooroti/windows/windows-setup.rb @@ -0,0 +1,226 @@ +require 'win32/registry' +require 'tmpdir' +require 'fileutils' +require 'uri' +require 'digest/sha2' + +module Yamatanooroti::WindowsConsoleSetup + DelegationConsoleSetting = { + conhost: "{B23D10C0-E52E-411E-9D5B-C09FDF709C7D}", + terminal: "{2EACA947-7F5F-4CFA-BA87-8F7FBEEFBE69}", + preview: "{06EC847C-C0A5-46B8-92CB-7C92F6E35CD5}", + }.freeze + DelegationTerminalSetting = { + conhost: "{B23D10C0-E52E-411E-9D5B-C09FDF709C7D}", + terminal: "{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}", + preview: "{86633F1F-6454-40EC-89CE-DA4EBA977EE2}", + }.freeze + + def self.wt_exe + @wt_exe + end + + def self.wt_wait + 0 + end + + begin + Win32::Registry::HKEY_CURRENT_USER.open('Console') do |reg| + @orig_conhost = reg['ForceV2'] + end + rescue Win32::Registry::Error + end + begin + Win32::Registry::HKEY_CURRENT_USER.open('Console\%%Startup') do |reg| + @orig_console = reg['DelegationConsole'] + @orig_terminal = reg['DelegationTerminal'] + end + rescue Win32::Registry::Error + end + + Test::Unit.at_start do + case Yamatanooroti.options.windows + when :conhost + puts "use conhost(classic, conhostV2) for windows console" + Win32::Registry::HKEY_CURRENT_USER.open('Console', Win32::Registry::KEY_WRITE) do |reg| + reg['ForceV2', Win32::Registry::REG_DWORD] = 1 + end + Win32::Registry::HKEY_CURRENT_USER.open('Console\%%Startup', Win32::Registry::KEY_WRITE) do |reg| + reg['DelegationConsole', Win32::Registry::REG_SZ] = DelegationConsoleSetting[:conhost] + reg['DelegationTerminal', Win32::Registry::REG_SZ] = DelegationTerminalSetting[:conhost] + end if @orig_console && @orig_terminal + when :"legacy-conhost" + puts "use conhost(legacy, conhostV1) for windows console" + Win32::Registry::HKEY_CURRENT_USER.open('Console', Win32::Registry::KEY_WRITE) do |reg| + reg['ForceV2', Win32::Registry::REG_DWORD] = 0 + end + Win32::Registry::HKEY_CURRENT_USER.open('Console\%%Startup', Win32::Registry::KEY_WRITE) do |reg| + reg['DelegationConsole', Win32::Registry::REG_SZ] = DelegationConsoleSetting[:conhost] + reg['DelegationTerminal', Win32::Registry::REG_SZ] = DelegationTerminalSetting[:conhost] + end if @orig_console && @orig_terminal + when :canary + @wt_exe = extract_terminal(prepare_terminal_canary) + else + @wt_exe = extract_terminal(prepare_terminal_portable) + end + if @wt_exe + Yamatanooroti::WindowsTerminalTerm.diagnose_size_capability + end + end + + Test::Unit.at_exit do + Win32::Registry::HKEY_CURRENT_USER.open('Console', Win32::Registry::KEY_WRITE) do |reg| + reg['ForceV2', Win32::Registry::REG_DWORD] = @orig_conhost + end if @orig_conhost + Win32::Registry::HKEY_CURRENT_USER.open('Console\%%Startup', Win32::Registry::KEY_WRITE) do |reg| + reg['DelegationConsole', Win32::Registry::REG_SZ] = @orig_console + reg['DelegationTerminal', Win32::Registry::REG_SZ] = @orig_terminal + end if @orig_console && @orig_terminal + end + + def self.tmpdir + return @tmpdir if @tmpdir + dir = nil + if Yamatanooroti.options.terminal_workdir + dir = Yamatanooroti.options.terminal_workdir + FileUtils.mkdir_p(dir) + else + @tmpdir_t = Thread.new do + Thread.current.abort_on_exception = true + Dir.mktmpdir do |tmpdir| + dir = tmpdir + sleep + ensure + sleep 0.5 # wait for terminate windows terminal + end + end + Thread.pass while dir == nil + end + return @tmpdir = dir + end + + def self.extract_terminal(path) + tar = File.join(ENV['SystemRoot'], "system32", "tar.exe") + extract_dir = File.join(tmpdir, "wt") + running_wt_exist = false + if File.exist?(extract_dir) + wt = Dir["**/OpenConsole.exe", base: extract_dir] + running_wt_exist = wt.reduce(false) do |result, path| + result ||= begin + File.delete(File.join(extract_dir, path)) + false + rescue SystemCallError + true + end + end + FileUtils.remove_entry(extract_dir) if !running_wt_exist + end + if !running_wt_exist + FileUtils.mkdir_p(extract_dir) + puts "extracting #{File.basename(path)}" + system tar, "xf", path, "-C", extract_dir + wt = Dir["**/wt.exe", base: extract_dir] + raise "not found wt.exe. aborted." if wt.size < 1 + raise "found wt.exe #{wt.size} times unexpectedly. aborted." if wt.size > 1 + wt = File.join(extract_dir, wt[0]) + wt_dir = File.dirname(wt) + portable_mark = File.join(wt_dir, ".portable") + open(portable_mark, "w") { |f| f.puts } unless File.exist?(portable_mark) + settings = File.join(wt_dir, "settings", "settings.json") + FileUtils.mkdir_p(File.dirname(settings)) + open(settings, "wb") do |settings| + settings.write <<~'JSON' + { + "defaultProfile": "{0caa0dad-35be-5f56-a8ff-afceeeaa6101}", + "disableAnimations": true, + "experimental.detectURLs": false, + "minimizeToNotificationArea": false, + "profiles": + { + "defaults": + { + "bellStyle": "none", + "closeOnExit": "always", + "font": + { + "size": 9 + }, + "padding": "0", + "scrollbarState": "always" + }, + "list": + [ + { + "commandline": "%SystemRoot%\\System32\\cmd.exe", + "guid": "{0caa0dad-35be-5f56-a8ff-afceeeaa6101}", + "name": "cmd.exe" + } + ] + }, + "showTabsInTitlebar": false, + "tabWidthMode": "compact", + "warning.confirmCloseAllTabs": false, + "warning.largePaste": false, + "warning.multiLinePaste": false + } + JSON + end + puts "use #{wt} for windows console" + else + puts "running Windows Terminal found." + wt = Dir["**/wt.exe", base: extract_dir] + raise "not found wt.exe. aborted." if wt.size < 1 + raise "found wt.exe #{wt.size} times unexpectedly. aborted." if wt.size > 1 + wt = File.join(extract_dir, wt[0]) + wt_dir = File.dirname(wt) + puts "use existing #{wt} for windows console" + end + wt + end + + def self.prepare_terminal_canary + dir = tmpdir + header = `curl --head -sS -o #{tmpdir}/header -L -w "%{url_effective}\n%header{ETag}\n%header{Content-Length}\n%header{Last-Modified}" https://aka.ms/terminal-canary-zip-x64` + url, etag, length, timestamp = *header.lines.map(&:chomp) + puts "Windows Terminal canary #{timestamp}" + name = File.basename(URI.parse(url).path) + path = File.join(dir, "wt_dists", "canary", etag.delete('"'), name) + if File.exist?(path) + if File.size(path) == length.to_i + puts "use existing #{path}" + return path + else + FileUtils.remove_entry(path) + end + else + if Dir.empty?(dir) + puts "removing old canary zip" + Dir.entries.each { |olddir| FileUtils.remove_entry(olddir) } + end + end + FileUtils.mkdir_p(File.dirname(path)) + system "curl #{$stdin.isatty ? "" : "-sS "}-L -o #{path} https://aka.ms/terminal-canary-zip-x64" + path + end + + def self.prepare_terminal_portable + releases = Yamatanooroti::Options::WindowsTerminal::RELEASES + url = releases[Yamatanooroti.options.windows.to_sym][:url] + sha256 = releases[Yamatanooroti.options.windows.to_sym][:sha256] + dir = tmpdir + name = File.basename(URI.parse(url).path) + path = File.join(dir, "wt_dists", Yamatanooroti.options.windows, name) + if File.exist?(path) + if Digest::SHA256.new.file(path).hexdigest.upcase == sha256 + puts "use existing #{path}" + return path + else + FileUtils.remove_entry(path) + end + end + FileUtils.mkdir_p(File.dirname(path)) + system "curl #{$stdin.isatty ? "" : "-sS "}-L -o #{path} #{url}" + raise "not match windows terminal distribution zip sha256" unless Digest::SHA256.new.file(path).hexdigest.upcase == sha256 + path + end +end diff --git a/lib/yamatanooroti/windows/windows.rb b/lib/yamatanooroti/windows/windows.rb new file mode 100644 index 0000000..2913b9e --- /dev/null +++ b/lib/yamatanooroti/windows/windows.rb @@ -0,0 +1,337 @@ +require 'stringio' + +module Yamatanooroti::WindowsTermMixin + DL = Yamatanooroti::WindowsDefinition + + CONSOLE_KEEPING_COMMANDLINE_CONHOST = %Q[#{ENV["SystemRoot"]||"C:\\Windows"}\\System32\\more.com \\\\.\\pipe\\PIPENAME] + CONSOLE_KEEPING_COMMANDLINE_WT = %Q[cmd.exe /c start "" /B #{ENV["SystemRoot"]||"C:\\Windows"}\\System32\\more \\\\.\\pipe\\PIPENAME] + CONSOLE_LEAVING_COMMANDLINE = %q[ruby.exe --disable=gems -e "Signal.trap(:INT, nil) or true and sleep"] + + module_function def keeper_commandline(pipename) + if Yamatanooroti.options.conhost + CONSOLE_KEEPING_COMMANDLINE_CONHOST.sub("PIPENAME", pipename) + else + CONSOLE_KEEPING_COMMANDLINE_WT.sub("PIPENAME", pipename) + end + end + + module_function def show_console_param + map = DL::SHOWWINDOW_MAP[Yamatanooroti.options.windows] || DL::SHOWWINDOW_MAP[:terminal] + map.fetch(Yamatanooroti.options.show_console ? :show : :hide) + end + + module_function def testcase_title(title) + count = (@@iter_count ||= {})[title] + count ||= countup_testcase_title(title) + "#{title}##{count}@#{Process.pid}".strip + end + + module_function def countup_testcase_title(title) + counter = (@@iter_count ||= {}) + count = counter[title] || 0 + counter[title] = count + 1 + end + + module_function def get_pipename(title, suffix = nil) + "yamatanooroti_#{testcase_title(title)}#{suffix ? ":#{suffix}" : ""}" + end + + private def get_pid_from_pipe(pipehandle) + with_timeout("Waiting pipe connection timed out.") do + DL.get_named_pipe_client_processid(pipehandle, maybe_fail: true) + end + end + + private def castling(pipehandle) + pid = get_pid_from_pipe(pipehandle) + begin + DL.free_console + DL.attach_console(pid) + spawn CONSOLE_LEAVING_COMMANDLINE + ensure + DL.free_console + DL.attach_console + end + DL.close_handle(pipehandle) + end + + private def with_timeout(timeout_message, timeout = @timeout, &block) + wait_until = Time.now + timeout + loop do + result = block.call + break result if result + raise timeout_message if wait_until < Time.now + sleep @wait + end + end + + private def attach_terminal(open: true, exception: true) + stderr = $stderr + $stderr = StringIO.new + + conin = conout = nil + check_interrupt + return nil if !console_process_id + DL.free_console + # this can be fail while new process is starting + r = DL.attach_console(console_process_id, maybe_fail: !exception) + return nil unless r + + if open + # if error occurred, causes exception regardless of exception: false + conin = DL.create_console_file_handle("conin$") + conout = DL.create_console_file_handle("conout$") + end + + yield(conin, conout) + rescue => evar + ensure + DL.close_handle(conin) if conin && conin != DL::INVALID_HANDLE_VALUE + DL.close_handle(conout) if conout && conout != DL::INVALID_HANDLE_VALUE + DL.free_console + DL.attach_console + stderr.write $stderr.string + $stderr = stderr + raise evar if evar + end + + private def quote_command_arg(arg) + if not arg.match?(/[ \t"<>|()]/) + # No quotation needed. + return arg + end + + if not arg.match?(/["\\]/) + # No embedded double quotes or backlashes, so I can just wrap quote + # marks around the whole thing. + return %{"#{arg}"} + end + + quote_hit = true + result = +'"' + arg.chars.reverse.each do |c| + result << c + if quote_hit and c == '\\' + result << '\\' + elsif c == '"' + quote_hit = true + result << '\\' + else + quote_hit = false + end + end + result << '"' + result.reverse + end + + class SubProcess + def initialize(command) + @errin, err = IO.pipe + DL.restore_console_control_handler do + @pid = spawn(command, {in: ["conin$", File::RDWR | File::BINARY], out: ["conout$", File::RDWR | File::BINARY], err: err}) + end + @mon = Process.detach(@pid) + err.close + @closed = false + @status = nil + @q = Thread::Queue.new + @t = Thread.new do + begin + err = @errin.gets + @q << err if err + rescue IOError + # target process already terminated + next + end + end + end + + def close + unless closed? + system("taskkill /PID #{@pid} /F /T", {[:out, :err] => "NUL"}) + @status = @mon.join.value.exitstatus + sync + @errin.close + end + end + + def closed? + !@mon.alive? + end + + private def consume(buffer) + while !@q.empty? + buffer << @q.shift + end + end + + def sync + buffer = +"" + if closed? + if !@errin.closed? + @t.kill + @t.join + consume(buffer) + rest = "".b + while ((str = @errin.read_nonblock(1024, exception: false)).is_a?(String)) do + rest << str + end + buffer << rest.force_encoding(Encoding.default_external) << "\n" if rest != "" + end + else + consume(buffer) + end + $stderr.write buffer if buffer != "" + end + end + + def launch(command) + check_interrupt + attach_terminal(open: false) do + @target = SubProcess.new(command.map{ |c| quote_command_arg(c) }.join(' ')) + end + end + + def setup_cp(cp) + @codepage_success_p = attach_terminal(open: false) do + system("chcp #{Integer(cp)} > NUL") + DL.get_console_codepage() == cp && DL.get_console_output_codepage() == cp + end + end + + def codepage_success? + @codepage_success_p + end + + private def do_write(str) + check_interrupt + records, count = DL.build_key_input_record(str) + attach_terminal do |conin, conout| + DL.write_console_input(conin, records, count) + loop do + sleep @wait + n = DL.get_number_of_console_input_events(conin) + break if n <= 1 # maybe keyup event still be there + break if n.nil? + @target.sync + break if @target.closed? + end + end + end + + def write(str) + mode = attach_terminal { |conin, conout| DL.get_console_mode(conin) } + if 0 == (mode & DL::ENABLE_PROCESSED_INPUT) + do_write(str) + else + str.dup.force_encoding(Encoding::ASCII_8BIT).split(/(\C-c)/).each do |chunk| + if chunk == "\C-c" + attach_terminal(open: false) do + # generate Ctrl+C event to process on same console + DL.generate_console_ctrl_event(0, 0) + end + else + do_write(chunk.force_encoding(str.encoding)) if chunk != "" + end + end + end + @wrote_and_not_yet_waited = true + end + + def retrieve_screen(top_of_buffer: false) + return @result if @result + check_interrupt + @target.sync + attach_terminal do |conin, conout| + csbi = DL.get_console_screen_buffer_info(conout) + top, bottom, width = if top_of_buffer + [0, csbi.Bottom, csbi.Right - csbi.Left + 1] + else + [csbi.Top, csbi.Bottom, csbi.Right - csbi.Left + 1] + end + + return (top..bottom).map do |y| + DL.read_console_output(conout, y, width) || "" + end + end + end + + def result + @result || retrieve_screen + end + + def close + close_request = @target && !@target.closed? + retrieve_request = !DL.interrupted? && console_process_id + + if close_request && retrieve_request && !@result + if @wrote_and_not_yet_waited # wait a long. avoid write();close() sequence + sleep @timeout + puts "\r#{@name}: close() just after write() will ultimately slow test down. use close() after assert_screen()." + end + end + + @target.close if close_request + @result = retrieve_screen if retrieve_request + @result ||= "" + check_interrupt + end + + def clear_need_wait_flag + @wrote_and_not_yet_waited = false + end + + # identify windows console + # conhost(legacy) + # compatible with older windows + # lacks newer features (VT sequence support) + # conhost(classic) + # conhost with supports VT + # terminal + # fully VT support + # focused on modern features over compatibility + # can't access screen buffer outside of view + # change winsize using win32api + def identify + attach_terminal do |conin, conout| + orig_mode = DL.get_console_mode(conout) + DL.set_console_mode(conout, orig_mode ^ DL::ENABLE_VIRTUAL_TERMINAL_PROCESSING) + alt_mode = DL.get_console_mode(conout) + if ((orig_mode | alt_mode) & DL::ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0 + # consolemode unchanged, ENABLE_VIRTUAL_TERMINAL_PROCESSING == 0 + return :"legacy-conhost" + end + DL.set_console_mode(conout, orig_mode) + + orig_buffer_info = DL.get_console_screen_buffer_info(conout) + view_w = orig_buffer_info.dwSize_X + view_h = orig_buffer_info.Bottom - orig_buffer_info.Top + 1 + buffer_height = orig_buffer_info.dwSize_Y + if buffer_height != view_h + # buffer size != view size + return :conhost + end + + DL.set_console_screen_buffer_info_ex(conout, view_h, view_w, buffer_height + 1) + alt_buffer_info = DL.get_console_screen_buffer_info(conout) + if alt_buffer_info.dwSize_Y == buffer_height + 1 + # now screen buffer size can be diffrent to view size + DL.set_console_screen_buffer_info_ex(conout, view_h, view_w, buffer_height) + return :conhost + else + DL.set_console_window_info(conout, view_h, view_w) + return :terminal + end + end + end + + def check_interrupt + raise_interrupt if DL.interrupted? + end + + def raise_interrupt + @target&.close + close! + raise Interrupt, "Interrupt: Interrupt catcher process died." + end +end diff --git a/lib/yamatanooroti/windows/wmi.rb b/lib/yamatanooroti/windows/wmi.rb new file mode 100644 index 0000000..3126e05 --- /dev/null +++ b/lib/yamatanooroti/windows/wmi.rb @@ -0,0 +1,77 @@ +require "win32ole" + +class Yamatanooroti + module WMI + WIN32OLE.codepage = WIN32OLE::CP_UTF8 + @locator = WIN32OLE.new('WbemScripting.SWbemLocator') + @server = @locator.ConnectServer('.', 'root\cimv2') + @server.Security_.ImpersonationLevel = 3 + + def self.server + @server + end + + module Win32_Process + class << self + def eq(k, v) + "#{k} = '#{v}'" + end + + def like(k, v) + "#{k} LIKE \"#{v}\"" + end + + def &(k, v) + "#{k} AND #{v}" + end + + def query(filters, *properties) + properties << "*" if properties.empty? + filters = Array(filters) + where = filters.empty? ? "" : " WHERE #{filters.join(" OR ")}" + query = "SELECT #{properties&.join(",") || "*"} FROM Win32_Process#{where}" + list = WMI.server.ExecQuery(query) + list.each.map do |process| + process.GetObjectText_.lines.reduce({}) do |hash, line| + if kv = line.match(/(\w*) = (.*);/) + key = kv[1] + value = kv[2].match(/\A".*"\z/) + if value == nil + value = kv[2]&.to_i + else + value = value[0].undump + end + hash[key] = value + end + hash + end + end || [] + end + + def query_name(name, *properties) + query(eq("Name", name), *properties) + end + + def query_name_and_commandline(name, commandline, *properties) + query(self.&(eq("Name", name), like("CommandLine", "%#{commandline}%")), *properties) + end + + def query_pid(pid, *properties) + query(eq("ProcessId"), pid, *properties) + end + + def query_ppid(ppid, *properties) + query(eq("ParentProcessId"), ppid, *properties) + end + end + end + end +end + +# ary = WMI::Win32_Process.query_ppid(Process.pid, "Name", "CommandLine", "ProcessId", "ParentProcessId", "CreationDate", "UserModeTime", "KernelModeTime") +# ary.each { |e| puts [e["Name"], e["ProcessId"]] } + +if $0 == __FILE__ + WMI = Yamatanooroti::WMI + binding.irb +end diff --git a/lib/yamatanooroti/windows/wt.rb b/lib/yamatanooroti/windows/wt.rb new file mode 100644 index 0000000..8a08443 --- /dev/null +++ b/lib/yamatanooroti/windows/wt.rb @@ -0,0 +1,278 @@ +class Yamatanooroti + + ### Yamatanooroti::WindowsTerminal + # + # represents windows terminal window + # + + class WindowsTerminal + Self = self + class << self + attr_reader :split_cache_h, :split_cache_v + end + + @split_cache_h = {} + @split_cache_v = {} + + attr_reader :name, :title, :testcase, :wt_command, :wait, :timeout, :active_tab, :base_tab + + def initialize(rows, cols, name, title, wait, timeout, testcase) + @name = name + @title = title + @wait = wait + @timeout = timeout + @testcase = testcase + @wt_command = "#{Yamatanooroti::WindowsConsoleSetup.wt_exe} " \ + "-w #{title} " \ + "--size #{cols},#{rows} " + @tabs = [] + @active_tab = nil + @base_tab = Tab.new_tab(self, title) + end + + def new_tab(height, width, title) + base_height, base_width = @base_tab.get_size + if height > base_height + raise "console height #{height} grater than maximum(#{base_height})" + end + if width > base_width + raise "console width #{width} grater than maximum(#{base_width})" + end + + hsplitter = Self.split_cache_h[base_height] || SplitSizeManager.new(base_height) + vsplitter = Self.split_cache_v[base_width] || SplitSizeManager.new(base_width) + + hsplit = hsplitter.query(height) + vsplit = vsplitter.query(width) + begin + if hsplit && vsplit + tab = Tab.new_tab_hv(self, title, hsplit[0].to_f / hsplit[1], vsplit[0].to_f / vsplit[1]) + if tab.get_size == [height, width] + return tab + else + tab.close_pane + tab.close_pane + end + else + tab = Tab.new_tab(self, title) + end + + if height != base_height + hsplit = hsplitter.search_div(height) do |div| + tab.split_pane(div, splitter: :h) + h = tab.get_size[0] + tab.close_pane if h != height + h + end + raise "console height deviding to #{height} failed" if !hsplit + end + + if width != base_width + vsplit = vsplitter.search_div(width) do |div| + tab.split_pane(div, splitter: :v) + w = tab.get_size[1] + tab.close_pane if w != width + w + end + raise "console widtht deviding to #{width} failed" if !vsplit + end + + return tab + ensure + @tabs << tab + @active_tab = tab + Self.split_cache_h[base_height] = hsplitter + Self.split_cache_v[base_width] = vsplitter + end + end + + def close_tab + if @active_tab + @active_tab.close + @active_tab = nil + @tabs.pop + end + end + + def detach_tab + if @active_tab + @active_tab.detach + @active_tab = nil + @tabs.pop + end + end + + def close + @active_tab&.close + @base_tab&.close + @base_tab = @active_tab = nil + end + + ### Yamatanooroti::WindowsTerminal::Tab + # + # represents and manipulates windows terminal tab + # + + class Tab + include Yamatanooroti::WindowsTermMixin + M = WindowsTermMixin + attr_reader :wt, :name, :title + + def raise_interrupt + wt.testcase.raise_interrupt + end + + private_class_method :new + + def initialize(wt, title, *handles) + @wt = wt + @wait = wt.wait + @timeout = wt.timeout + @name = wt.name + @title = title + @closed = false + @handles = handles + @pid = nil + end + + def self.new_tab(wt, title) + pipename = M.get_pipename(title, "main") + pipe_handle = DL.create_named_pipe(pipename) + keeper_command = M.keeper_commandline(pipename) + command = "#{wt.wt_command} " \ + "nt --title #{title} " \ + "#{keeper_command}" + + DL.create_console(command, M.show_console_param()) + new(wt, title, pipe_handle) + end + + def self.new_tab_hv(wt, title, hsplit, vsplit) + main_pipename = M.get_pipename(title, "main") + h_pipename = M.get_pipename(title, "h") + v_pipename = M.get_pipename(title, "v") + main_pipe_handle = DL.create_named_pipe(main_pipename) + h_pipe_handle = DL.create_named_pipe(h_pipename) + v_pipe_handle = DL.create_named_pipe(v_pipename) + keeper_command = M.keeper_commandline(main_pipename) + keeper_command_h = M.keeper_commandline(h_pipename) + keeper_command_v = M.keeper_commandline(v_pipename) + command = "#{wt.wt_command} " \ + "nt --title #{title} " \ + "#{keeper_command}" \ + "; " \ + "sp -H "\ + "--title #{title} " \ + "-s #{hsplit} " \ + "#{keeper_command_h}" \ + "; " \ + "move-focus first; " \ + "sp -V "\ + "--title #{title} " \ + "-s #{vsplit} " \ + "#{keeper_command_v}" + + DL.create_console(command, M.show_console_param()) + new(wt, title, main_pipe_handle, h_pipe_handle, v_pipe_handle) + end + + def split_pane(div = 0.5, splitter: :v) + pipename = get_pipename(title, ":#{splitter}") + pipe_handle = DL.create_named_pipe(pipename) + keeper_command = M.keeper_commandline(pipename) + command = "#{@wt.wt_command} " \ + "move-focus first; " \ + "sp #{splitter == :v ? "-V" : "-H"} "\ + "--title #{title} " \ + "-s #{div} " \ + "#{keeper_command}" + + orig_size = get_size() + DL.create_console(command, show_console_param()) + @handles.push pipe_handle + with_timeout("split console timed out.") { orig_size != get_size() } + end + + def close_pane + orig_size = get_size() + DL.close_handle(@handles.pop) + with_timeout("close pane timed out.") { orig_size != get_size() } + end + + def close + unless @closed + @handles.each do |pipe_handle| + DL.close_handle(pipe_handle) + end + @handles.clear + end + @closed = true + end + + def detach + unless @closed + @handles.each do |pipe_handle| + castling(pipe_handle) + end + @handles.clear + end + @closed = true + end + + def console_process_id + @pid ||= get_pid_from_pipe(@handles[0]) + end + + def get_size + attach_terminal do |conin, conout| + csbi = DL.get_console_screen_buffer_info(conout) + [csbi.Bottom + 1, csbi.Right + 1] + end + end + end + + ### Yamatanooroti::WindowsTerminal::SplitSizeManager + # + # cache manager of windows terminal pane splitter divisor parameter + # + + class SplitSizeManager + def initialize(total) + @total = total + @div_to_x = {} + @x_to_div = {} + end + + def query(x) + @x_to_div[x] + end + + def search_div(x, &block) + denominator = 200 + div, denominator = @x_to_div[x] if @x_to_div[x] + div ||= 2 * ((@total - x) * (denominator * 97) / @total + denominator * 2) / 200 + loop do + result = block.call(div.to_f / denominator) + @div_to_x[div.to_f / denominator] = result + @x_to_div[x] = [div, denominator] + return result if result == x + if result < x + div -= 1 + return nil if div <= 0 + if @div_to_x[div.to_f / denominator] && @div_to_x[div.to_f / denominator] != x + div = div * 2 + 1 + denominator *= 2 + end + else + div += 1 + return nil if div >= denominator + if @div_to_x[div.to_f / denominator] && @div_to_x[div.to_f / denominator] != x + div = div * 2 - 1 + denominator *= 2 + end + end + end + end + end + end +end diff --git a/test/yamatanooroti/test_multiplatform.rb b/test/yamatanooroti/test_multiplatform.rb index 85fe0fd..5eda8a7 100644 --- a/test/yamatanooroti/test_multiplatform.rb +++ b/test/yamatanooroti/test_multiplatform.rb @@ -29,13 +29,13 @@ def test_result_repeatedly end def test_assert_screen_retries - write("sleep 1\n") + write("sleep 1 && 1\n") assert_screen(/=> 1\nprompt>/) - assert_equal(['prompt> sleep 1', '=> 1', 'prompt>', '', ''], result) + assert_equal(['prompt> sleep 1 && 1', '=> 1', 'prompt>', '', ''], result) end def test_assert_screen_timeout - write("sleep 3\n") + write("sleep 3 && 3\n") assert_raise do assert_screen(/=> 3\nprompt>/) end @@ -51,14 +51,26 @@ def test_auto_wrap prompt> EOC end +end + +class Yamatanooroti::TestMultiplatformMultiByte < Yamatanooroti::TestCase + def setup + if Yamatanooroti.win? + start_terminal(5, 30, ['ruby', 'bin/simple_repl'], startup_message: 'prompt>', codepage: 932) + else + start_terminal(5, 30, ['ruby', 'bin/simple_repl'], startup_message: 'prompt>') + end + end def test_fullwidth + omit "multibyte char not supported by env" if Yamatanooroti.win? and !codepage_success? write(":あ\n") assert_screen(/=> :あ\nprompt>/) assert_equal(['prompt> :あ', '=> :あ', 'prompt>', '', ''], result) end def test_two_fullwidth + omit "multibyte char not supported by env" if Yamatanooroti.win? and !codepage_success? write(":あい\n") assert_screen(/=> :あい\nprompt>/) assert_equal(['prompt> :あい', '=> :あい', 'prompt>', '', ''], result) diff --git a/test/yamatanooroti/test_run_ruby.rb b/test/yamatanooroti/test_run_ruby.rb index 14f244a..f6710d4 100644 --- a/test/yamatanooroti/test_run_ruby.rb +++ b/test/yamatanooroti/test_run_ruby.rb @@ -11,6 +11,7 @@ def test_winsize assert_screen(<<~EOC) [5, 30] EOC + close end def test_wait_for_startup_message @@ -32,7 +33,13 @@ def test_move_cursor_and_render def test_meta_key get_into_tmpdir - start_terminal(5, 30, ['ruby', '-rreline', '-e', 'Reline.readline(%{>>>})'], startup_message: />{3}/) + if !Yamatanooroti.win? || (RUBY_VERSION >= '3' && RUBY_VERSION < '3.4.0') + command = ['ruby', '-rreline', '-e', 'Reline.readline(%{>>>})'] + else + command = ['bundle', 'exec', 'ruby', '-e', 'require "reline"; Reline.readline(%{>>>})'] + # older ruby inboxed reline (0.1.5) has a windows specific bug. use newer reline + end + start_terminal(5, 30, command, startup_message: />{3}/) write('aaa ccc') write("\M-b") write('bbb ') diff --git a/test/yamatanooroti/test_windows.rb b/test/yamatanooroti/test_windows.rb index b037651..47914e2 100644 --- a/test/yamatanooroti/test_windows.rb +++ b/test/yamatanooroti/test_windows.rb @@ -13,3 +13,26 @@ def test_load end end end + +class Yamatanooroti::TestWindowsSpecific < Yamatanooroti::TestCase + if Yamatanooroti.win? + def test_console_selection + start_terminal(5, 30, ['ruby', 'bin/simple_repl'], startup_message: 'prompt>') + assert_equal(Yamatanooroti.options.terminal ? :terminal : Yamatanooroti.options.windows, identify) + end + + def test_codepage_932 + start_terminal(5, 30, ['ruby', '-e', 'puts(%Q!Encoding:#{Encoding.find(%Q[locale]).name}!)'], startup_message: 'Encoding:', codepage: 932) + omit "codepage 932 not supported" if !codepage_success? + assert_equal(['Encoding:Windows-31J', '', '', '', ''], result) + close + end + + def test_codepage_437 + start_terminal(5, 30, ['ruby', '-e', 'puts(%Q!Encoding:#{Encoding.find(%Q[locale]).name}!)'], startup_message: 'Encoding:', codepage: 437) + omit "codepage 437 not supported" if !codepage_success? + assert_equal(['Encoding:IBM437', '', '', '', ''], result) + close + end + end +end