Automate your remote desktop sessions

Recently I was tasked with ensuring that the end to end tests run from our continuous integration (CI) server.  For that I needed to our CI server to connect to a virtual machine on our network, deploy the latest solution to that machine, and configure it.  Once all of that was complete we needed to run the end to end tests on that virtual machine and collect the results.  As the application grew the screens became more and more complex.  Soon we were getting errors like this:

"Element is not clickable at point (996, 92)"

It would be that or another such error that occurs because the element that you want is off screen.  The solution was to scroll the browser window until the element became visible but getting Protractor to actually accomplish this task was tenuous at best. Any time I would query the remote screen size with say PowerShell I would get the following.

PS C:\Windows\System32> Get-DisplayResolution
1024x768

PS C:\Windows\System32>

I tried setting the screen size using the Set-DisplayResolution to no avail. I even tried writing some Windows API code to change the screen size but the simple fact is that you can’t change the system desktop to a different size. When you connect using Remote PowerShell you will always be using that desktop with the fixed size.

The other option is to design your web application in such a way that everything will be on the screen. With only 1024×768 This isn’t a practical solution as we’ve found out.

What to do, what to do…

Then while thinking to myself, “If I can run the end to end tests in a remote desktop session…”  I had a “Bob the Builder” moment. (Those of you with young children will get the reference) “Can I automate this?  YES I CAN!”

Adding Credentials

First thing I needed to do was to automate the opening of a remote desktop (RDP) session.  While the remote desktop executable has command line parameters, those parameters do not include user name and password.  There is a way around this however.  You need to store a credential for the virtual machine that you want to RDP to in your Windows Credential store. I will be using the Windows Cmdkey command line security tool.  The Windows Cmdkey command creates, lists and deletes stored user names and passwords form a computer. The Cmdkey command helps system administrators and security executives add, list, and delete the user stored credentials.  This tool is handy when administrators want to give users access to a shared resource without exposing any login credentials.  It is also a great way to automate your RDP login.  You can add the credential to the local computer by executing the following line.

C:\> cmdkey /generic:TERMSRV/vm.company.com /user:'COMPANY\Username' 
/pass:'password123'

CMDKEY: Credential added successfully.

C:\>

Once you have entered the credential you can check to make sure that it has been added correctly by going to your Control Panel and clicking on the Credential Manager, then selecting the Windows Credentials.  It should appear under your generic credentials.

Windows Credential

This step is needed so that you try to use terminal services (RDP) with the machine “vm.company.com” it will not ask you for the credentials but use the stored credentials instead.

Start Remote Desktop Session

Now we need to start a remote desktop connection to the target machine.  We need to do this at a specific resolution, you can define this in your command line arguments.  Here is a small snippet of the C# code that I’ve created to accomplish this task.

//  Open the Rdp Session to the target.
var rdpProcess = new Process {
  StartInfo = {
	FileName = Environment.ExpandEnvironmentVariables(@"%SystemRoot%\system32\mstsc.exe"),
	Arguments = string.Format("/v:{0} /admin /w:{1} /h:{2} /control", targetMachine, width, height)
  }
};
rdpProcess.Start();
while (rdpProcess.HasExited == false)
  Thread.Sleep(500);  //  Give the process a moment to startup

if (rdpProcess.ExitCode != S_OK)
  throw new RemotingException(string.Format("Unable to open an RDP window to the following machine: {0}", targetMachine));

Thread.Sleep(5000);   //  Connection Wait

var rdpProcesses = Process.GetProcessesByName("mstsc");
if (rdpProcesses.Any() == false)
  throw new Exception("Remote Desktop Process not found.");

if (rdpProcesses.Count() > 1)
  throw new Exception("Too many Remote Desktop Processes found.");
 

As you can see it will open a connection to the targetMachine at the specified width and height using the Microsoft Terminal Services Client or Remote Desktop (mstsc) executable. Notice the connection wait on line 15. While it may not be elegant, it illustrates the point that you have to wait to actually make a connection to the remote server before proceeding.

Run End to End Tests

Once the connection is and we have been logged into the server via our stored credentials we can start doing things on the remote machine. While I am sure that a more formal process can be here, this next section uses a great deal of the Win32 API out of the User32.dll file. I’m going to skip all of the enumerations, declarations and structures in this portion for the sake of brevity. If you want to see all of this code in more depth, you can ping me and I’ll post them later.

Once thing that I’ve done is create a shortcut called “Admin Console”  I then have gone into that shortcut and then made sure that it would run as administrator.

Admin Console Shortcut

Here are some of the helper methods that I’ve built for myself to make the writing and maintenance of the code a little easier.

private IEnumerable<INPUT> ConvertStringToInputs(string message)
{
  var result = new List<INPUT>();

  foreach (var character in message.ToCharArray()) {
	if (char.IsUpper(character) || character == '|')
        result.Add(new INPUT { type = InputType.KEYBOARD, 
                         U = new InputUnion { 
                            ki = new KEYBDINPUT { 
                              wScan = ScanCodeShort.SHIFT, 
                              wVk = VirtualKeyShort.SHIFT 
                         } } });

	result.Add(new INPUT { type = InputType.KEYBOARD, 
	                         U = new InputUnion { 
	                           ki = new KEYBDINPUT { 
	                             wScan = GetScanCodeShort(character), 
	                             wVk = GetVirtualKeyShort(character) 
	                     } } });
	result.Add(new INPUT { type = InputType.KEYBOARD, 
	                         U = new InputUnion { 
	                           ki = new KEYBDINPUT { 
	                             wScan = GetScanCodeShort(character), 
	                             wVk = GetVirtualKeyShort(character), 
	                             dwFlags = KEYEVENTF.KEYUP 
	                     } } });

	if (char.IsUpper(character) || character == '|')
        result.Add(new INPUT { type = InputType.KEYBOARD, 
                         U = new InputUnion { 
                           ki = new KEYBDINPUT { 
                             wScan = ScanCodeShort.SHIFT, 
                             wVk = VirtualKeyShort.SHIFT, 
                             dwFlags = KEYEVENTF.KEYUP 
                         } } });
  }

  return result;
}

private INPUT[] GetAcknowledgementKeys()
{
  return new[] {
	(new INPUT { type = InputType.KEYBOARD, 
	           U = new InputUnion { 
	             ki = new KEYBDINPUT { 
	               wScan = ScanCodeShort.LEFT, 
	               wVk = VirtualKeyShort.LEFT 
	           } } }),
	(new INPUT { type = InputType.KEYBOARD, 
	           U = new InputUnion { 
	             ki = new KEYBDINPUT { 
	               wScan = ScanCodeShort.LEFT, 
	               wVk = VirtualKeyShort.LEFT, 
	               dwFlags = KEYEVENTF.KEYUP 
	           } } }),
	(new INPUT { type = InputType.KEYBOARD, 
	           U = new InputUnion { 
	             ki = new KEYBDINPUT { 
	               wScan = ScanCodeShort.RETURN, 
	               wVk = VirtualKeyShort.RETURN 
	           } } }),
	(new INPUT { type = InputType.KEYBOARD, 
	           U = new InputUnion { 
	             ki = new KEYBDINPUT { 
	               wScan = ScanCodeShort.RETURN, 
	               wVk = VirtualKeyShort.RETURN, 
	               dwFlags = KEYEVENTF.KEYUP 
	           } } }),	           
  };
}
 

As you can see from the helper methods above I need to craft each input message as an array of keyboard scan codes. The GetAcknowledgementKeys function for example will generate a key sequence that is four codes in length. The key down for the left arrow, and the key up for that same key. Next the return key is pressed and then released as well. This varies from keyboard to keyboard and you will need to see what works for you. I’m working on a standard US keyboard so that is what you will see in my examples.

The other method ConvertStringToInputs is important because this will enable me to generate a keyboard scan code array from the passed in string. This is important because I will need to open this shortcut to run Protractor in the appropriate directory so that the end to end tests will run correctly.

//  Send the key sequence to the Remote Desktop Window <ALT> <HOME>
SendKeys.SendWait("%{HOME}");
Thread.Sleep(500);

var pInputs = ConvertStringToInputs("Admin Console\r").ToArray();
User32.SendInput((uint) pInputs.Length, pInputs, INPUT.Size);
Thread.Sleep(1000);

pInputs = GetAcknowledgementKeys();
User32.SendInput((uint) pInputs.Length, pInputs, INPUT.Size);
Thread.Sleep(1000);

//  UAC Administrator check forces us to refocus the window
RefreshWindowFocus(hWindow);    

//  Change to the folder that houses your code
pInputs = ConvertStringToInputs("cd ProgramFolder\r").ToArray();
User32.SendInput((uint) pInputs.Length, pInputs, INPUT.Size);
Thread.Sleep(1000);

// Start the end to end tests
pInputs = ConvertStringToInputs("protractor end2end.conf.js --no-jasmineNodeOpts.showColors\r")
                .ToArray();
User32.SendInput((uint) pInputs.Length, pInputs, INPUT.Size);
Thread.Sleep(1000);

//  WAIT FOR TEST RUN TO FINISH

//  Close Console Window (Optional)
pInputs = ConvertStringToInputs("exit\r").ToArray();
User32.SendInput((uint) pInputs.Length, pInputs, INPUT.Size);
Thread.Sleep(1000);
 

Now that you are able to run the end to end tests successfully, it is possible to capture the end to end test output. This output can be stored in a text file or written to your continuous integration server so that you will be able to see what happened during your testing. How you do this is completely up to you but I would suggest making use of PowerShell and piping your output to a PowerShell script that can tell you if the unit tests have succeeded or failed.

Log off Remote Session

Now that we are finished with the remote session, we need to log out.  There is no need to script this into mouse movements or keystrokes.  You can do it programmatically using the Remote Desktop Services API (WtsApi32.dll). I’ve created a the following class to assist me with this.

public static class WtsApi32
{
	[DllImport("wtsapi32.dll", SetLastError = true)]
	static extern IntPtr WTSOpenServer(string pServerName);

	[DllImport("Wtsapi32.dll")]
	static extern bool WTSQuerySessionInformation(
		System.IntPtr hServer, int sessionId, WtsInfoClass wtsInfoClass,
		out System.IntPtr ppBuffer, out uint pBytesReturned);

	[DllImport("wtsapi32.dll")]
	static extern void WTSCloseServer(IntPtr hServer);

	[DllImport("wtsapi32.dll", SetLastError = true)]
	static extern int WTSEnumerateSessions(
					System.IntPtr hServer,
					int Reserved,
					int Version,
					ref System.IntPtr ppSessionInfo,
					ref int pCount);

	[DllImport("wtsapi32.dll", SetLastError = true)]
	static extern void WTSEnumerateSessions(
					System.IntPtr hServer,
					ref System.IntPtr ppSessionInfo,
					ref int pCount);

	[DllImport("wtsapi32.dll", ExactSpelling = true, SetLastError = false)]
	static extern void WTSFreeMemory(IntPtr memory);

	[DllImport("wtsapi32.dll", SetLastError = true)]
	static extern bool WTSLogoffSession(IntPtr hServer,
					int SessionId, bool bWait);

	[DllImport("wtsapi32.dll", SetLastError = true)]
	static extern bool WTSDisconnectSession(IntPtr hServer, int sessionId, bool bWait);

	public static IEnumerable<WTS_SESSION_INFO> GetSessionInfos(string targetServer)
	{
		var sessionInfos = new List<WTS_SESSION_INFO>();

		WithWtsServerConnection(targetServer, (srvHandle) => {
			var ppSessionInfo = IntPtr.Zero;
			var count = 0;
			var retval = WTSEnumerateSessions(srvHandle, 0, 1, ref ppSessionInfo, ref count);
			if (retval == 0)
				return;

			var dataSize = Marshal.SizeOf(typeof (WTS_SESSION_INFO));
			var current = (int) ppSessionInfo;
			for (var i = 0; i < count; i++) { 				var si = (WTS_SESSION_INFO) Marshal.PtrToStructure((IntPtr) current, typeof (WTS_SESSION_INFO)); 				sessionInfos.Add(si); 				current += dataSize; 			} 			WTSFreeMemory(ppSessionInfo); 		}); 		return sessionInfos; 	} 	public static void LogoffRemoteSession(string targetServer, int sessionId,  										   bool wait = true, bool throwOnFailure = false) 	{ 		WithWtsServerConnection(targetServer, (srvHandle) => {
			var logoffSuccess = WTSLogoffSession(srvHandle, sessionId, wait);
			if (logoffSuccess == false && throwOnFailure)
				throw new Exception(string.Format("Unable to log off session '{0}' on server '{1}'", sessionId, targetServer));
		});
	}

	public static void DisconnectRemoteSession(string targetServer, int sessionId,
											   bool wait = true, bool throwOnFailure = false)
	{
		WithWtsServerConnection(targetServer, (srvHandle) => {
			var disconnectSuccess = WTSDisconnectSession(srvHandle, sessionId, wait);
			if (disconnectSuccess == false && throwOnFailure)
				throw new Exception(string.Format("Unable to disconnect session '{0}' on server '{1}'", sessionId, targetServer));
		});
	}

	private static void WithWtsServerConnection(string serverNameOrIpAddress, Action<IntPtr> action)
	{
		var srvHandle = IntPtr.Zero;

		try
		{
			srvHandle = WTSOpenServer(serverNameOrIpAddress);
			if (srvHandle == IntPtr.Zero)
				throw new ServerException(string.Format("The target server '{0}' was not found.", serverNameOrIpAddress));

			action(srvHandle);
		}
		finally
		{
			WTSCloseServer(srvHandle);
		}
	}
}
 

Now that we’ve written all of our Platform Invoke (P/Invoke) code, we can use this code to log off and disconnect gracefully from the remote system.

var sessionInfos = WtsApi32.GetSessionInfos(TargetMachine);
var remoteRdpSessions = sessionInfos.Where(si => si.State == WTS_CONNECTSTATE_CLASS.WTSActive
        && si.pWinStationName.StartsWith("RDP-Tcp#", StringComparison.CurrentCultureIgnoreCase))
        .ToArray();
if (remoteRdpSessions.Any() == false)
    return;

foreach (var rdpSession in remoteRdpSessions)
    WtsApi32.LogoffRemoteSession(TargetMachine, rdpSession.SessionID);
 

Deleting Credentials

Now it is time to clean up our stored credential.  You may or may not want to perform this step.  Depending on your companies policies you may need to remote this credential from the machine once you are finished with it. You can use the Windows Cmdkey command line security tool to remove the stored generic credential from your system.  The command line below demonstrates this.

C:\> cmdkey /delete:TERMSRV/vm.company.com

CMDKEY: Credential deleted successfully.

C:\>

Now if you go and look in the Credential Manager you will no longer see the stored credential.

Summary

This got me over the hump in terms of defining the screen size on my remote machine. Hopefully this has given you some insight into solving your problem with end to end testing. While I realize that this solution is far from being the perfect solution; until Microsoft allows you to change the size of the display and a remote PowerShell session, this is what I am forced to do.

Perhaps you can adapt this code for your own use. Once you take control of your remote session through automation there is truly no limit as to what you can accomplish. Until next time.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s