使用JNA解决自动化测试无法做密码输入操作的问题

在做页面自动化(以使用selenium为例)的时候,很常见的一个场景就是输入密码。往往对于输入框都使用WebElement的sendKeys(CharSequence... keysToSend)的方法。

  Java代码

1./**

    • Use this method to simulate typing into an element, which may set its value.
  1. */
  2. void sendKeys(CharSequence... keysToSend);
      一般情况下这个方法是可以胜任的,但是现在很多网站为了安全性的考虑都会对密码输入框做特殊的处理,而且不同的浏览器也不同。例如支付宝。

  支付宝输入密码控件在Chrome浏览器下

  支付宝输入密码控件在Firefox浏览器下

  支付宝输入密码控件在IE(IE8)浏览器下

  可见在不同的浏览器下是有差异的。那么现在存在两个问题。首先,selenium的sendKeys方法无法操作这样特殊的控件;其次,不同浏览器又存在差异,搞定了chrome,在IE下又不能用,这样又要解决浏览器兼容性问题。

如何解决这两个问题呢?

  我们可以发现平时人工使用键盘输入密码的时候是没有这些问题的,那么我们是否可以模拟人工操作时的键盘输入方式呢?答案是肯定的,使用操作系统的API,模拟键盘发送消息事件给操作系统,可以避免所有浏览器等差异和安全性带来的问题。

  我个人建议使用JNA(https://github.com/twall/jna),JNA是一种和JNI类似的技术,但是相对JNI来说更加易用。 JNA共有jna.jar和platform.jar两个依赖库,都需要引入,我们需要用到的在platform.jar中。从包结构可以看出,JNA中包含了mac、unix、win32等各类操作系统的系统API映射。如下图:

  系统API映射关系在JNA的文章中有描述,如下:

  数据类型的映射参见:https://github.com/twall/jna/blob/master/www/Mappings.md

  本文中以windows为例演示下如何在支付宝的密码安全控件中输入密码。

  JNA中关于windows平台的是com.sun.jna.platform.win32包中User32这个接口。这里映射了很多windows系统API可以使用。但是我们需要用到的SendMessage却没有。所以需要新建一个接口,映射SendMessage函数。代码如下:

1.import com.sun.jna.Native;
2.import com.sun.jna.platform.win32.User32;
3.import com.sun.jna.win32.W32APIOptions;

  1. 5.public interface User32Ext extends User32 {
  2. User32Ext USER32EXT = (User32Ext) Native.loadLibrary("user32", User32Ext.class, W32APIOptions.DEFAULT_OPTIONS);
  3. /**
    • 查找窗口
    • @param lpParent 需要查找窗口的父窗口
    • @param lpChild 需要查找窗口的子窗口
    • @param lpClassName 类名
    • @param lpWindowName 窗口名
    • @return 找到的窗口的句柄
  4. */
  5. HWND FindWindowEx(HWND lpParent, HWND lpChild, String lpClassName, String lpWindowName);
  6. /**
    • 获取桌面窗口,可以理解为所有窗口的root
    • @return 获取的窗口的句柄
  7. */
  8. HWND GetDesktopWindow();
  9. /**
    • 发送事件消息
    • @param hWnd 控件的句柄
    • @param dwFlags 事件类型
    • @param bVk 虚拟按键码
    • @param dwExtraInfo 扩展信息,传0即可
    • @return
  10. */
  11. int SendMessage(HWND hWnd, int dwFlags, byte bVk, int dwExtraInfo);
  12. /**
    • 发送事件消息
    • @param hWnd 控件的句柄
    • @param Msg 事件类型
    • @param wParam 传0即可
    • @param lParam 需要发送的消息,如果是点击操作传null
    • @return
  13. */
  14. int SendMessage(HWND hWnd, int Msg, int wParam, String lParam);
  15. /**
    • 发送键盘事件
    • @param bVk 虚拟按键码
    • @param bScan 传 ((byte)0) 即可
    • @param dwFlags 键盘事件类型
    • @param dwExtraInfo 传0即可
  16. */
  17. void keybd_event(byte bVk, byte bScan, int dwFlags, int dwExtraInfo);
  18. /**
    • 激活指定窗口(将鼠标焦点定位于指定窗口)
    • @param hWnd 需激活的窗口的句柄
    • @param fAltTab 是否将最小化窗口还原
  19. */
  20. void SwitchToThisWindow(HWND hWnd, boolean fAltTab);
  21. 61.}

 系统API映射好以后,利用这个接口写了如下的工具类,包含点击和输入各种操作。代码如下:

1.import java.util.concurrent.Callable;
2.import java.util.concurrent.ExecutorService;
3.import java.util.concurrent.Executors;
4.import java.util.concurrent.Future;
5.import java.util.concurrent.TimeUnit;

  1. 7.import com.sun.jna.Native;

8.import com.sun.jna.Pointer;
9.import com.sun.jna.platform.win32.WinDef.HWND;
10.import com.sun.jna.platform.win32.WinUser.WNDENUMPROC;

  1. 12./**
    • Window组件操作工具类
    • @author sunju
  2. */
    18.public class Win32Util {
  3. private static final int N_MAX_COUNT = 512;
  4. private Win32Util() {
  5. }
  6. /**
    • 从桌面开始查找指定类名的组件,在超时的时间范围内,如果未找到任何匹配的组件则反复查找
    • @param className 组件的类名
    • @param timeout 超时时间
    • @param unit 超时时间的单位
    • @return 返回匹配的组件的句柄,如果匹配的组件大于一个,返回第一个查找的到的;如果未找到或超时则返回null
  7. */
  8. public static HWND findHandleByClassName(String className, long timeout, TimeUnit unit) {
  9. return findHandleByClassName(USER32EXT.GetDesktopWindow(), className, timeout, unit);
  10. }
  11. /**
    • 从桌面开始查找指定类名的组件
    • @param className 组件的类名
    • @return 返回匹配的组件的句柄,如果匹配的组件大于一个,返回第一个查找的到的;如果未找到任何匹配则返回null
  12. */
  13. public static HWND findHandleByClassName(String className) {
  14. return findHandleByClassName(USER32EXT.GetDesktopWindow(), className);
  15. }
  16. /**
    • 从指定位置开始查找指定类名的组件
    • @param root 查找组件的起始位置的组件的句柄,如果为null则从桌面开始查找
    • @param className 组件的类名
    • @param timeout 超时时间
    • @param unit 超时时间的单位
    • @return 返回匹配的组件的句柄,如果匹配的组件大于一个,返回第一个查找的到的;如果未找到或超时则返回null
  17. */
  18. public static HWND findHandleByClassName(HWND root, String className, long timeout, TimeUnit unit) {
  19. if(null == className || className.length() <= 0) {
  20. return null;
  21. }
  22. long start = System.currentTimeMillis();
  23. HWND hwnd = findHandleByClassName(root, className);
  24. while(null == hwnd && (System.currentTimeMillis() - start < unit.toMillis(timeout))) {
  25. hwnd = findHandleByClassName(root, className);
  26. }
  27. return hwnd;
  28. }
  29. /**
    • 从指定位置开始查找指定类名的组件
    • @param root 查找组件的起始位置的组件的句柄,如果为null则从桌面开始查找
    • @param className 组件的类名
    • @return 返回匹配的组件的句柄,如果匹配的组件大于一个,返回第一个查找的到的;如果未找到任何匹配则返回null
  30. */
  31. public static HWND findHandleByClassName(HWND root, String className) {
  32. if(null == className || className.length() <= 0) {
  33. return null;
  34. }
  35. HWND[] result = new HWND[1];
  36. findHandle(result, root, className);
  37. return result[0];
  38. }
  39. private static boolean findHandle(final HWND[] target, HWND root, final String className) {
  40. if(null == root) {
  41. root = USER32EXT.GetDesktopWindow();
  42. }
  43. return USER32EXT.EnumChildWindows(root, new WNDENUMPROC() {
  44. @Override
  45. public boolean callback(HWND hwnd, Pointer pointer) {
  46. char[] winClass = new char[N_MAX_COUNT];
  47. USER32EXT.GetClassName(hwnd, winClass, N_MAX_COUNT);
  48. if(USER32EXT.IsWindowVisible(hwnd) && className.equals(Native.toString(winClass))) {
  49. target[0] = hwnd;
  50. return false;
  51. } else {
  52. return target[0] == null || findHandle(target, hwnd, className);
  53. }
  54. }
  55. }, Pointer.NULL);
  56. }
  57. /**
    • 模拟键盘按键事件,异步事件。使用win32 keybd_event,每次发送KEYEVENTF_KEYDOWN、KEYEVENTF_KEYUP两个事件。默认10秒超时
    • @param hwnd 被键盘操作的组件句柄
    • @param keyCombination 键盘的虚拟按键码(Virtual-Key Code),或者使用{@link java.awt.event.KeyEvent}
    • 二维数组第一维中的一个元素为一次按键操作,包含组合操作,第二维中的一个元素为一个按键事件,即一个虚拟按键码
    • @return 键盘按键事件放入windows消息队列成功返回true,键盘按键事件放入windows消息队列失败或超时返回false
  58. */
  59. public static boolean simulateKeyboardEvent(HWND hwnd, int[][] keyCombination) {
  60. if(null == hwnd) {
  61. return false;
  62. }
  63. USER32EXT.SwitchToThisWindow(hwnd, true);
  64. USER32EXT.SetFocus(hwnd);
  65. for(int[] keys : keyCombination) {
  66. for(int i = 0; i < keys.length; i++) {
  67. USER32EXT.keybd_event((byte) keys[i], (byte) 0, KEYEVENTF_KEYDOWN, 0); // key down
  68. }
  69. for(int i = keys.length - 1; i >= 0; i--) {
  70. USER32EXT.keybd_event((byte) keys[i], (byte) 0, KEYEVENTF_KEYUP, 0); // key up
  71. }
  72. }
  73. return true;
  74. }
  75. /**
    • 模拟字符输入,同步事件。使用win32 SendMessage API发送WM_CHAR事件。默认10秒超时
    • @param hwnd 被输入字符的组件的句柄
    • @param content 输入的内容。字符串会被转换成char[]后逐个字符输入
    • @return 字符输入事件发送成功返回true,字符输入事件发送失败或超时返回false
  76. */
  77. public static boolean simulateCharInput(final HWND hwnd, final String content) {
  78. if(null == hwnd) {
  79. return false;
  80. }
  81. try {
  82. return execute(new Callable() {
  83. @Override
  84. public Boolean call() throws Exception {
  85. USER32EXT.SwitchToThisWindow(hwnd, true);
  86. USER32EXT.SetFocus(hwnd);
  87. for(char c : content.toCharArray()) {
  88. Thread.sleep(5);
  89. USER32EXT.SendMessage(hwnd, WM_CHAR, (byte) c, 0);
  90. }
  91. return true;
  92. }
  93. });
  94. } catch(Exception e) {
  95. return false;
  96. }
  97. }
  98. public static boolean simulateCharInput(final HWND hwnd, final String content, final long sleepMillisPreCharInput) {
  99. if(null == hwnd) {
  100. return false;
  101. }
  102. try {
  103. return execute(new Callable() {
  104. @Override
  105. public Boolean call() throws Exception {
  106. USER32EXT.SwitchToThisWindow(hwnd, true);
  107. USER32EXT.SetFocus(hwnd);
  108. for(char c : content.toCharArray()) {
  109. Thread.sleep(sleepMillisPreCharInput);
  110. USER32EXT.SendMessage(hwnd, WM_CHAR, (byte) c, 0);
  111. }
  112. return true;
  113. }
  114. });
  115. } catch(Exception e) {
  116. return false;
  117. }
  118. }
  119. /**
    • 模拟文本输入,同步事件。使用win32 SendMessage API发送WM_SETTEXT事件。默认10秒超时
    • @param hwnd 被输入文本的组件的句柄
    • @param content 输入的文本内容
    • @return 文本输入事件发送成功返回true,文本输入事件发送失败或超时返回false
  120. */
  121. public static boolean simulateTextInput(final HWND hwnd, final String content) {
  122. if(null == hwnd) {
  123. return false;
  124. }
  125. try {
  126. return execute(new Callable() {
  127. @Override
  128. public Boolean call() throws Exception {
  129. USER32EXT.SwitchToThisWindow(hwnd, true);
  130. USER32EXT.SetFocus(hwnd);
  131. USER32EXT.SendMessage(hwnd, WM_SETTEXT, 0, content);
  132. return true;
  133. }
  134. });
  135. } catch(Exception e) {
  136. return false;
  137. }
  138. }
  139. /**
    • 模拟鼠标点击,同步事件。使用win32 SendMessage API发送BM_CLICK事件。默认10秒超时
    • @param hwnd 被点击的组件的句柄
    • @return 点击事件发送成功返回true,点击事件发送失败或超时返回false
  140. */
  141. public static boolean simulateClick(final HWND hwnd) {
  142. if(null == hwnd) {
  143. return false;
  144. }
  145. try {
  146. return execute(new Callable() {
  147. @Override
  148. public Boolean call() throws Exception {
  149. USER32EXT.SwitchToThisWindow(hwnd, true);
  150. USER32EXT.SendMessage(hwnd, BM_CLICK, 0, null);
  151. return true;
  152. }
  153. });
  154. } catch(Exception e) {
  155. return false;
  156. }
  157. }
  158. private static T execute(Callable callable) throws Exception {
  159. ExecutorService executor = Executors.newSingleThreadExecutor();
  160. try {
  161. Future task = executor.submit(callable);
  162. return task.get(10, TimeUnit.SECONDS);
  163. } finally {
  164. executor.shutdown();
  165. }
  166. }
    240.}

其中用到的各种事件类型定义如下:

1.public class Win32MessageConstants {

  1. public static final int WM_SETTEXT = 0x000C; //输入文本
  2. public static final int WM_CHAR = 0x0102; //输入字符
  3. public static final int BM_CLICK = 0xF5; //点击事件,即按下和抬起两个动作
  4. public static final int KEYEVENTF_KEYUP = 0x0002; //键盘按键抬起
  5. public static final int KEYEVENTF_KEYDOWN = 0x0; //键盘按键按下
  6. 13.}

  下面写一段测试代码来测试支付宝密码安全控件的输入,测试代码如下:

1.import java.util.concurrent.TimeUnit;

  1. 3.import static org.hamcrest.core.Is.is;

4.import static org.junit.Assert.assertThat;

  1. 6.import static org.hamcrest.core.IsNull.notNullValue;

7.import org.junit.Test;

  1. 9.import com.sun.jna.platform.win32.WinDef;

10.import com.sun.jna.platform.win32.WinDef.HWND;

  1. 12.public class AlipayPasswordInputTest {
  2. @Test
  3. public void testAlipayPasswordInput() {
  4. String password = "your password";
  5. HWND alipayEdit = findHandle("Chrome_RenderWidgetHostHWND", "Edit"); //Chrome浏览器,使用Spy++可以抓取句柄的参数
  6. assertThat("获取支付宝密码控件失败。", alipayEdit, notNullValue());
  7. boolean isSuccess = Win32Util.simulateCharInput(alipayEdit, password);
  8. assertThat("输入支付宝密码["+ password +"]失败。", isSuccess, is(true));
  9. }
  10. private WinDef.HWND findHandle(String browserClassName, String alieditClassName) {
  11. WinDef.HWND browser = Win32Util.findHandleByClassName(browserClassName, 10, TimeUnit.SECONDS);
  12. return Win32Util.findHandleByClassName(browser, alieditClassName, 10, TimeUnit.SECONDS);
  13. }
    27.}

  测试一下,看看是不是输入成功了!

  最后说下这个方法的缺陷,任何方法都有不可避免的存在一些问题,完美的事情很少。

  1、sendMessage和postMessage有很多重载的函数,不是每种都有效,从上面的Win32Util中就能看出,实现了很多个方法,需要尝试下,成本略高;

  2、输入时需要注意频率,输入太快可能导致浏览器中安全控件崩溃,支付宝的安全控件在Firefox下输入太快就会崩溃;

  3、因为是系统API,所以MAC、UNIX、WINDOWS下都不同,如果只是在windows环境下运行,可以忽略;

  4、从测试代码可以看到,是针对Chrome浏览器的,因为每种浏览器的窗口句柄不同,所以要区分,不过这个相对简单,只是名称不同;

  5、如果你使用Selenium的RemoteDriver,并且是在远程机器上运行脚本,这个方法会失效。因为remoteDriver最终是http操作,对操作系统API的操作是客户端行为,不能被翻译成Http Command,所以会失效。

最新内容请见作者的GitHub页:http://qaseven.github.io/

上一篇:Nio2Endpoint组件:Tomcat如何实现异步I/O?(下)


下一篇:从 Storm 迁移到 Flink,美团外卖实时数仓建设实践