diff --git a/Sources/WinAppDriver/Win32ProcessTree.swift b/Sources/WinAppDriver/Win32ProcessTree.swift index 52a6c8e..653796a 100644 --- a/Sources/WinAppDriver/Win32ProcessTree.swift +++ b/Sources/WinAppDriver/Win32ProcessTree.swift @@ -1,18 +1,37 @@ 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 + /// 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,10 +83,44 @@ 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 + ) 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) + + // 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 = GetStdHandle(DWORD(STD_OUTPUT_HANDLE)) + } + if let stderrHandle = options.stderrHandle { + startupInfo.hStdError = stderrHandle + redirectStdHandle = true + } else { + startupInfo.hStdError = GetStdHandle(DWORD(STD_ERROR_HANDLE)) + } + if let stdinHandle = options.stdinHandle { + startupInfo.hStdInput = stdinHandle + redirectStdHandle = true + } else { + startupInfo.hStdInput = GetStdHandle(DWORD(STD_INPUT_HANDLE)) + } + if redirectStdHandle { + startupInfo.dwFlags |= DWORD(STARTF_USESTDHANDLES) + } var processInfo = PROCESS_INFORMATION() guard CreateProcessW( @@ -75,8 +128,8 @@ internal class Win32ProcessTree { UnsafeMutablePointer(mutating: commandLine), nil, nil, - false, - DWORD(CREATE_NEW_CONSOLE) | DWORD(CREATE_SUSPENDED) | DWORD(CREATE_NEW_PROCESS_GROUP), + redirectStdHandle, // Inherit handles is necessary for redirects. + creationFlags, nil, nil, &startupInfo, @@ -100,4 +153,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..e3eb090 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,59 @@ 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 +108,10 @@ public class WinAppDriver: WebDriver { } } - return WinAppDriver(httpWebDriver: httpWebDriver, processTree: processTree) + return WinAppDriver( + httpWebDriver: httpWebDriver, + processTree: processTree, + childStdinHandle: childStdinHandle) } deinit { @@ -66,6 +122,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")) + } +}