From ae76c1eb84a77c282357c2b229a9e2d4597fe63b Mon Sep 17 00:00:00 2001 From: Fabrice de Gans Date: Mon, 24 Jun 2024 13:47:20 -0700 Subject: [PATCH 1/3] Add an option to put WinAppDriver output in a file This enriches the internal `Win32ProcessTree` API to take `HANDLE` objects for stdin, stdout and stderr, as well as adding an option to use the parent console rather than spawning a new one. --- Sources/WinAppDriver/Win32ProcessTree.swift | 75 +++++++++++++++---- Sources/WinAppDriver/WinAppDriver.swift | 70 +++++++++++++++-- .../AppDriverOptionsTest.swift | 34 +++++++++ 3 files changed, 159 insertions(+), 20 deletions(-) create mode 100644 Tests/WinAppDriverTests/AppDriverOptionsTest.swift diff --git a/Sources/WinAppDriver/Win32ProcessTree.swift b/Sources/WinAppDriver/Win32ProcessTree.swift index 52a6c8e..a00628c 100644 --- a/Sources/WinAppDriver/Win32ProcessTree.swift +++ b/Sources/WinAppDriver/Win32ProcessTree.swift @@ -1,18 +1,33 @@ import struct Foundation.TimeInterval import WinSDK +/// Options for launching a process. +internal struct ProcessLaunchOptions { + /// Spawn a new console for the process. + public var spawnNewConsole: Bool = true + /// Redirect the process's stdout to the given handle. + public var stdoutHandle: HANDLE? = nil + /// Redirect the process's stderr to the given handle. + public var stderrHandle: HANDLE? = nil + /// Redirect the process's stdin to the given handle. + public var stdinHandle: HANDLE? = nil +} + /// Starts and tracks the lifetime of a process tree using Win32 APIs. internal class Win32ProcessTree { internal let jobHandle: HANDLE internal let handle: HANDLE - init(path: String, args: [String]) throws { + init(path: String, args: [String], options: ProcessLaunchOptions = ProcessLaunchOptions()) + throws { // Use a job object to ensure that the process tree doesn't outlive us. jobHandle = try Self.createJobObject() let commandLine = buildCommandLineArgsString(args: [path] + args) - do { handle = try Self.createProcessInJob(commandLine: commandLine, jobHandle: jobHandle) } - catch { + do { + handle = try Self.createProcessInJob( + commandLine: commandLine, jobHandle: jobHandle, options: options) + } catch { CloseHandle(jobHandle) throw error } @@ -64,23 +79,53 @@ internal class Win32ProcessTree { return jobHandle } - private static func createProcessInJob(commandLine: String, jobHandle: HANDLE) throws -> HANDLE { + private static func createProcessInJob( + commandLine: String, + jobHandle: HANDLE, + options: ProcessLaunchOptions = ProcessLaunchOptions() + ) throws -> HANDLE { try commandLine.withCString(encodedAs: UTF16.self) { commandLine throws in var startupInfo = STARTUPINFOW() startupInfo.cb = DWORD(MemoryLayout.size) + var redirectStdHandle = false + + let creationFlags = + DWORD(CREATE_SUSPENDED) | DWORD(CREATE_NEW_PROCESS_GROUP) + | (options.spawnNewConsole ? DWORD(CREATE_NEW_CONSOLE) : 0) + if let stdoutHandle = options.stdoutHandle { + startupInfo.hStdOutput = stdoutHandle + redirectStdHandle = true + } else { + startupInfo.hStdOutput = INVALID_HANDLE_VALUE + } + if let stderrHandle = options.stderrHandle { + startupInfo.hStdError = stderrHandle + redirectStdHandle = true + } else { + startupInfo.hStdError = INVALID_HANDLE_VALUE + } + if let stdinHandle = options.stdinHandle { + startupInfo.hStdInput = stdinHandle + redirectStdHandle = true + } else { + startupInfo.hStdInput = INVALID_HANDLE_VALUE + } + if redirectStdHandle { + startupInfo.dwFlags |= DWORD(STARTF_USESTDHANDLES) + } var processInfo = PROCESS_INFORMATION() guard CreateProcessW( - nil, - UnsafeMutablePointer(mutating: commandLine), - nil, - nil, - false, - DWORD(CREATE_NEW_CONSOLE) | DWORD(CREATE_SUSPENDED) | DWORD(CREATE_NEW_PROCESS_GROUP), - nil, - nil, - &startupInfo, - &processInfo + nil, + UnsafeMutablePointer(mutating: commandLine), + nil, + nil, + redirectStdHandle, // Inherit handles is necessary for redirects. + creationFlags, + nil, + nil, + &startupInfo, + &processInfo ) else { throw Win32Error.getLastError(apiName: "CreateProcessW") } @@ -100,4 +145,4 @@ internal class Win32ProcessTree { return processInfo.hProcess } } -} \ No newline at end of file +} diff --git a/Sources/WinAppDriver/WinAppDriver.swift b/Sources/WinAppDriver/WinAppDriver.swift index 2be1566..a9b2d28 100644 --- a/Sources/WinAppDriver/WinAppDriver.swift +++ b/Sources/WinAppDriver/WinAppDriver.swift @@ -19,10 +19,16 @@ public class WinAppDriver: WebDriver { private let httpWebDriver: HTTPWebDriver private let processTree: Win32ProcessTree? + /// The write end of a pipe that is connected to the child process's stdin. + private let childStdinHandle: HANDLE? - private init(httpWebDriver: HTTPWebDriver, processTree: Win32ProcessTree? = nil) { + private init( + httpWebDriver: HTTPWebDriver, + processTree: Win32ProcessTree? = nil, + childStdinHandle: HANDLE? = nil) { self.httpWebDriver = httpWebDriver self.processTree = processTree + self.childStdinHandle = childStdinHandle } public static func attach(ip: String = defaultIp, port: Int = defaultPort) -> WinAppDriver { @@ -34,12 +40,60 @@ public class WinAppDriver: WebDriver { executablePath: String = defaultExecutablePath, ip: String = defaultIp, port: Int = defaultPort, - waitTime: TimeInterval? = defaultStartWaitTime) throws -> WinAppDriver { - + waitTime: TimeInterval? = defaultStartWaitTime, + outputFile: String? = nil) throws -> WinAppDriver { let processTree: Win32ProcessTree + var childStdinHandle: HANDLE? = nil do { - processTree = try Win32ProcessTree(path: executablePath, args: [ ip, String(port) ]) + var launchOptions = ProcessLaunchOptions() + if let outputFile = outputFile { + // Open the output file for writing to the child stdout. + var securityAttributes = SECURITY_ATTRIBUTES() + securityAttributes.nLength = DWORD(MemoryLayout.size) + securityAttributes.bInheritHandle = true + launchOptions.stdoutHandle = try outputFile.withCString(encodedAs: UTF16.self) { + outputFile throws in + CreateFileW( + UnsafeMutablePointer(mutating: outputFile), DWORD(GENERIC_WRITE), + DWORD(FILE_SHARE_READ), &securityAttributes, + DWORD(OPEN_ALWAYS), DWORD(FILE_ATTRIBUTE_NORMAL), nil) + } + if launchOptions.stdoutHandle == INVALID_HANDLE_VALUE { + // Failed to open the output file for writing. + throw Win32Error.getLastError(apiName: "CreateFileW") + } + + // Use the same handle for stderr. + launchOptions.stderrHandle = launchOptions.stdoutHandle + + // WinAppDriver will close immediately if no stdin is provided so create a dummy + // pipe here to keep stdin open until the child process is closed. + var childReadInputHandle: HANDLE? + if !CreatePipe(&childReadInputHandle, &childStdinHandle, &securityAttributes, 0) { + CloseHandle(launchOptions.stdoutHandle) + throw Win32Error.getLastError(apiName: "CreatePipe") + } + launchOptions.stdinHandle = childReadInputHandle + + // Also use the parent console to stop spurious new consoles from spawning. + launchOptions.spawnNewConsole = false + + } + + // Close our handles when the process has launched. The child process keeps a copy. + defer { + if let handle = launchOptions.stdoutHandle { + CloseHandle(handle) + } + if let handle = launchOptions.stdinHandle { + CloseHandle(handle) + } + } + + processTree = try Win32ProcessTree( + path: executablePath, args: [ip, String(port)], options: launchOptions) } catch let error as Win32Error { + CloseHandle(childStdinHandle) throw StartError(message: "Call to Win32 \(error.apiName) failed with error code \(error.errorCode).") } @@ -55,7 +109,10 @@ public class WinAppDriver: WebDriver { } } - return WinAppDriver(httpWebDriver: httpWebDriver, processTree: processTree) + return WinAppDriver( + httpWebDriver: httpWebDriver, + processTree: processTree, + childStdinHandle: childStdinHandle) } deinit { @@ -66,6 +123,9 @@ public class WinAppDriver: WebDriver { assertionFailure("WinAppDriver did not terminate within the expected time: \(error).") } } + if let childStdinHandle { + CloseHandle(childStdinHandle) + } } @discardableResult diff --git a/Tests/WinAppDriverTests/AppDriverOptionsTest.swift b/Tests/WinAppDriverTests/AppDriverOptionsTest.swift new file mode 100644 index 0000000..e7272c1 --- /dev/null +++ b/Tests/WinAppDriverTests/AppDriverOptionsTest.swift @@ -0,0 +1,34 @@ +import TestsCommon +import WinSDK +import XCTest + +@testable import WebDriver +@testable import WinAppDriver + +class AppDriverOptionsTest: XCTestCase { + func tempFileName() -> String { + return FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathExtension("txt").path + } + + /// Tests that redirecting stdout to a file works. + func testStdoutRedirectToFile() throws { + let outputFile = try { + // Start a new instance of msinfo32 and write the output to a file. + let outputFile = tempFileName() + let _ = try MSInfo32App( + winAppDriver: WinAppDriver.start( + outputFile: outputFile + )) + return outputFile + }() + + // Read the output file. + let output = try String(contentsOfFile: outputFile, encoding: .utf16LittleEndian) + + // Delete the file. + try FileManager.default.removeItem(atPath: outputFile) + + XCTAssert(output.contains("msinfo32")) + } +} From 411f9dfb7a86b1852864ae43195aa19c938dfa62 Mon Sep 17 00:00:00 2001 From: Fabrice de Gans Date: Tue, 25 Jun 2024 10:21:04 -0700 Subject: [PATCH 2/3] Use parent handles in child process --- Sources/WinAppDriver/Win32ProcessTree.swift | 16 ++++++++++++---- Sources/WinAppDriver/WinAppDriver.swift | 1 - 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Sources/WinAppDriver/Win32ProcessTree.swift b/Sources/WinAppDriver/Win32ProcessTree.swift index a00628c..39bde72 100644 --- a/Sources/WinAppDriver/Win32ProcessTree.swift +++ b/Sources/WinAppDriver/Win32ProcessTree.swift @@ -2,6 +2,10 @@ import struct Foundation.TimeInterval import WinSDK /// Options for launching a process. +/// Note that not setting all of the stdoutHandle, stderrHandle, and stdinHandle +/// fields will result in the process inheriting the parent's stdout, stderr or +/// stdin handles, respectively. This may result in the process's output being +/// written to the parent's console, even if `spawnNewConsole` is set to `true`. internal struct ProcessLaunchOptions { /// Spawn a new console for the process. public var spawnNewConsole: Bool = true @@ -82,7 +86,7 @@ internal class Win32ProcessTree { private static func createProcessInJob( commandLine: String, jobHandle: HANDLE, - options: ProcessLaunchOptions = ProcessLaunchOptions() + options: ProcessLaunchOptions ) throws -> HANDLE { try commandLine.withCString(encodedAs: UTF16.self) { commandLine throws in var startupInfo = STARTUPINFOW() @@ -92,23 +96,27 @@ internal class Win32ProcessTree { let creationFlags = DWORD(CREATE_SUSPENDED) | DWORD(CREATE_NEW_PROCESS_GROUP) | (options.spawnNewConsole ? DWORD(CREATE_NEW_CONSOLE) : 0) + + // Populate the startup info struct with the handles to redirect. + // Note that these fields are unused if `STARTF_USESTDHANDLES` is + // not set. if let stdoutHandle = options.stdoutHandle { startupInfo.hStdOutput = stdoutHandle redirectStdHandle = true } else { - startupInfo.hStdOutput = INVALID_HANDLE_VALUE + startupInfo.hStdOutput = GetStdHandle(DWORD(STD_OUTPUT_HANDLE)) } if let stderrHandle = options.stderrHandle { startupInfo.hStdError = stderrHandle redirectStdHandle = true } else { - startupInfo.hStdError = INVALID_HANDLE_VALUE + startupInfo.hStdError = GetStdHandle(DWORD(STD_ERROR_HANDLE)) } if let stdinHandle = options.stdinHandle { startupInfo.hStdInput = stdinHandle redirectStdHandle = true } else { - startupInfo.hStdInput = INVALID_HANDLE_VALUE + startupInfo.hStdInput = GetStdHandle(DWORD(STD_INPUT_HANDLE)) } if redirectStdHandle { startupInfo.dwFlags |= DWORD(STARTF_USESTDHANDLES) diff --git a/Sources/WinAppDriver/WinAppDriver.swift b/Sources/WinAppDriver/WinAppDriver.swift index a9b2d28..e3eb090 100644 --- a/Sources/WinAppDriver/WinAppDriver.swift +++ b/Sources/WinAppDriver/WinAppDriver.swift @@ -77,7 +77,6 @@ public class WinAppDriver: WebDriver { // Also use the parent console to stop spurious new consoles from spawning. launchOptions.spawnNewConsole = false - } // Close our handles when the process has launched. The child process keeps a copy. From 5279f8628056ec035408921f5fbe7338e153ae19 Mon Sep 17 00:00:00 2001 From: Fabrice de Gans Date: Tue, 25 Jun 2024 14:08:44 -0700 Subject: [PATCH 3/3] Revert unintended indent --- Sources/WinAppDriver/Win32ProcessTree.swift | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/WinAppDriver/Win32ProcessTree.swift b/Sources/WinAppDriver/Win32ProcessTree.swift index 39bde72..653796a 100644 --- a/Sources/WinAppDriver/Win32ProcessTree.swift +++ b/Sources/WinAppDriver/Win32ProcessTree.swift @@ -124,16 +124,16 @@ internal class Win32ProcessTree { var processInfo = PROCESS_INFORMATION() guard CreateProcessW( - nil, - UnsafeMutablePointer(mutating: commandLine), - nil, - nil, - redirectStdHandle, // Inherit handles is necessary for redirects. - creationFlags, - nil, - nil, - &startupInfo, - &processInfo + nil, + UnsafeMutablePointer(mutating: commandLine), + nil, + nil, + redirectStdHandle, // Inherit handles is necessary for redirects. + creationFlags, + nil, + nil, + &startupInfo, + &processInfo ) else { throw Win32Error.getLastError(apiName: "CreateProcessW") }