Skip to content

Commit

Permalink
Add an option to put WinAppDriver output in a file (#151)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Steelskin authored Jun 25, 2024
1 parent 084d1ef commit 79541bb
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 12 deletions.
67 changes: 60 additions & 7 deletions Sources/WinAppDriver/Win32ProcessTree.swift
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down Expand Up @@ -64,19 +83,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
) throws -> HANDLE {
try commandLine.withCString(encodedAs: UTF16.self) { commandLine throws in
var startupInfo = STARTUPINFOW()
startupInfo.cb = DWORD(MemoryLayout<STARTUPINFOW>.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(
nil,
UnsafeMutablePointer<WCHAR>(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,
Expand All @@ -100,4 +153,4 @@ internal class Win32ProcessTree {
return processInfo.hProcess
}
}
}
}
69 changes: 64 additions & 5 deletions Sources/WinAppDriver/WinAppDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<SECURITY_ATTRIBUTES>.size)
securityAttributes.bInheritHandle = true
launchOptions.stdoutHandle = try outputFile.withCString(encodedAs: UTF16.self) {
outputFile throws in
CreateFileW(
UnsafeMutablePointer<WCHAR>(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).")
}

Expand All @@ -55,7 +108,10 @@ public class WinAppDriver: WebDriver {
}
}

return WinAppDriver(httpWebDriver: httpWebDriver, processTree: processTree)
return WinAppDriver(
httpWebDriver: httpWebDriver,
processTree: processTree,
childStdinHandle: childStdinHandle)
}

deinit {
Expand All @@ -66,6 +122,9 @@ public class WinAppDriver: WebDriver {
assertionFailure("WinAppDriver did not terminate within the expected time: \(error).")
}
}
if let childStdinHandle {
CloseHandle(childStdinHandle)
}
}

@discardableResult
Expand Down
34 changes: 34 additions & 0 deletions Tests/WinAppDriverTests/AppDriverOptionsTest.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}

0 comments on commit 79541bb

Please sign in to comment.