October 11, 2022
By: Kevin

Etaoin用户手册(翻译)

  1. 简介
    1. 历史
    2. 支持的操作系统和浏览器
  2. 安装
    1. 添加etaoin库作为项目依赖
      1. 对于Clojure用户
      2. 对于Bababashka用户
    2. 为要使用Etaoin控制的每个网络浏览器安装WebDriver
  3. 入门
    1. 示例代码
    2. 在REPL中尝试示例
  4. 进阶
    1. 使用fill-multi简化代码
    2. 处理浏览器会话中的异常
  5. 单元测试作为文档
  6. 创建和退出驱动程序
  7. 选择元素
    1. 示例操作
    2. 查询相关提示
    3. 简单查询, XPath, CSS
    4. Map语法查询
    5. Vector语法查询
    6. 高级查询
      1. 查询第n个匹配的元素
      2. 查询树
    7. 查询Shadow DOM
    8. 与查询到的元素进行交互
  8. 交互操作
    1. Unicode和表情符号
    2. 模拟真人输入方式
    3. 鼠标点击
    4. 从下拉列表中选择一个选项
    5. 键盘组合键
    6. 文件上传
    7. 滚动
    8. 处理框架和iframe
    9. 执行JavaScript
      1. 异步脚本
    10. 等待函数
    11. 加载策略
    12. 动作
  9. 截取屏幕截图
    1. 特定元素的屏幕截图
    2. 每次表单操作后的屏幕截图
  10. 将页面打印为PDF
  11. 深入探究
    1. 调用特定于WebDriver实现的功能
    2. 读取浏览器的控制台日志
    3. DevTools: 跟踪HTTP请求, XHR(Ajax)
    4. 事后分析: 在发生异常时自动保存相关文件
  12. 驱动程序选项
    1. :host用于WebDriver
    2. :port用于WebDriver
    3. :webdriver-url
    4. :path-driver指向WebDriver二进制文件
    5. :args-driver用于WebDriver二进制文件
    6. :webdriver-failed-launch-retries
    7. :path-browser指向网页浏览器二进制文件
    8. :args用于网页浏览器二进制文件
    9. :log-level用于网页浏览器控制台
    10. :driver-log-level
    11. :log-stdout:log-stderr用于WebDriver输出
    12. :driver-log-file用于发现的WebDriver日志文件
    13. :post-stop-fns用于在驱动程序停止时插入行为
    14. :profile指向网页浏览器配置文件的路径
    15. :env用于WebDriver进程的变量
    16. :size初始网页浏览器窗口的大小
    17. :url在网页浏览器中打开的网址
    18. :user-agent用于网页浏览器
    19. :download-dir用于网页浏览器
    20. :headless网页浏览器
    21. :prefs用于网页浏览器(续)
    22. :proxy用于网页浏览器
    23. :load-strategy
    24. :capabilities
    25. 使用无头驱动程序
    26. 文件下载目录
    27. 管理用户代理
    28. HTTP代理
    29. 连接到现有正在运行的WebDriver
    30. 设置浏览器配置文件
      1. 在Chrome中创建和查找配置文件
      2. 在Firefox中创建和查找配置文件
      3. 运行带有配置文件的驱动程序
  13. 为你的应用编写和运行集成测试
    1. 无头测试
    2. 测试的Fixture
    3. 多驱动Fixture
    4. 事后分析处理程序以收集相关文件
      1. 按标签运行测试
    5. 检查是否有文件已被下载
  14. Etaoin 可以播放 Selenium IDE 生成的文件
    1. CLI 参数
  15. 在 Docker 中使用 WebDriver
  16. 常见问题排查
    1. WebDriver 的支持在不同厂商之间不一致
    2. 旧版本 WebDriver 可能有局限性
      1. 示例
    3. 新版本 WebDriver 可能引入新 Bug
      1. 示例
    4. 其他常见问题及解决方法

简介

Etaoin为Clojure社区提供了一种从Clojure和Babashka编写脚本以实现与网页浏览器交互的简单方法. 它是基于W3C WebDriver协议的一个精简抽象层, 同时也致力于解决现实世界中的细微差别和实现差异.

历史

Ivan Grishaev 创建了Etaoin, 并于2017年2月将其首个版本发布到Clojars上. 他和他的一群忠实贡献者将Etaoin发展成为一个备受尊敬的浏览器自动化的库.

2022年5月, 由于Ivan的时间更多地转向后端开发, 他将Etaoin提供给clj-commons进行维护.

支持的操作系统和浏览器

Etaoin的测试套件涵盖了Clojure和Babashka在以下操作系统和浏览器上的情况:

操作系统ChromeFirefoxSafariEdge
Linux (ubuntu)--
macOS
Windows-

安装

安装过程分为两步:

添加etaoin库作为项目依赖

对于Clojure用户

Etaoin支持在JDK11及以上版本中使用Clojure v1.10及以上版本.

project.clj文件的:dependencies向量中添加以下内容:

[etaoin "{lib-version}"]

或者在deps.edn文件的:deps下添加以下内容:

etaoin/etaoin {:mvn/version "{lib-version}"}

对于Bababashka用户

我们推荐使用当前版本的https://book.babashka.org/#_installation[babashka].

bb.edn文件的:deps下添加以下内容:

etaoin/etaoin {:mvn/version "{lib-version}"}

提示:

Babashka使用timbre进行日志记录. Timbre的默认日志级别是debug. 为了在使用Babashka时获得更安静的Etaoin体验, 可以将Timbre的默认日志级别设置为info:

(require '[taoensso.timbre :as timbre])
(timbre/set-level! :info)

为要使用Etaoin控制的每个网络浏览器安装WebDriver

Etaoin通过WebDriver来控制网络浏览器. 每个浏览器都有自己的WebDriver实现, 必须进行安装.

提示:

  • 如果尚未安装, 你需要安装网络浏览器(Chrome, Firefox, Edge). 这通常是通过从浏览器的官方网站下载来完成的. Safari是与macOS捆绑在一起的.
  • WebDriver和浏览器会定期更新以修复漏洞. 请使用最新版本.

以下是一些安装WebDriver的方法:

  • Google Chrome Driver:

  • Firefox的Geckodriver:

  • Safari Driver:

    • 仅macOS: 按照Webkit页面的指示设置Safari选项(向下滚动到" Running the Example in Safari" 部分).
  • Microsoft Edge Driver:

    • macOS: (手动下载)
    • Windows: scoop install edgedriver, 并且Edge和msedgedriver必须匹配, 所以可能需要指定版本, 例如: scoop install edgedriver@101.0.1210.0
    • 下载: 官方Microsoft下载站点

通过启动以下命令来检查你的WebDriver安装情况. 每个命令都应该启动一个包含本地HTTP服务器的进程. 使用Ctrl-C来终止进程.

chromedriver
geckodriver
safaridriver -p 0
msedgedriver

你可以选择运行Etaoin测试套件来验证你的安装.

提示: 一些Etaoin的API测试依赖于ImageMagick. 在运行测试之前请安装它.

Etaoin GitHub仓库的克隆版本中:

  • 要检查Etaoin感兴趣的工具:
bb tools-versions
  • 运行所有测试:
bb test:bb
  • 对于一个较小的完整性测试, 你可能想要针对你特别感兴趣的浏览器运行API测试. 例如:
bb test:bb --suites api --browsers chrome

在测试运行期间, 浏览器窗口将依次打开和关闭. 测试使用一个本地手工制作的HTML文件来验证大多数交互操作.

如果你遇到问题, 请参阅<>部分, 或者在Clojurians Slack #etaoinEtaoin GitHub issues上寻求帮助.

入门

好消息是, 你可以直接从Babashka或Clojure的REPL中自动化你的浏览器操作. 让我们来与维基百科进行交互:

示例代码

以下是一段示例代码, 用于在Firefox浏览器中进行一系列操作, 如打开维基百科页面, 搜索内容, 点击链接等, 并在操作完成后关闭浏览器窗口.

(require '[etaoin.api :as e]
         '[etaoin.keys :as k]
         '[clojure.string :as str])

;; 启动Firefox的WebDriver, 此时应该会出现一个Firefox窗口
(def driver (e/firefox))
(e/driver-type driver)
;; => :firefox

;; 进行一个快速的维基百科操作会话

;; 导航到维基百科
(e/go driver "https://en.wikipedia.org/")

;; 确保我们没有使用大屏幕布局
(e/set-window-size driver {:width 1280 :height 800})

;; 等待搜索输入框加载完成
(e/wait-visible driver [{:tag :input :name :search}])

;; 搜索一些有趣的内容
(e/fill driver {:tag :input :name :search} "Clojure programming language")
(e/wait driver 1)
(e/fill driver {:tag :input :name :search} k/enter)
(e/wait-visible driver {:class :mw-search-results})

;; 点击第一个匹配结果
(e/click driver [{:class :mw-search-results} {:class :mw-search-result-heading} {:tag :a}])
(e/wait-visible driver {:id :firstHeading})

;; 检查我们的新网址位置
;; (维基百科可能会添加查询字符串, 为了结果的一致性, 我们将忽略它)
(-> (e/get-url driver) (str/split #"\?") first)
;; => "https://en.wikipedia.org/wiki/Clojure"

;; 检查我们的新标题
(e/get-title driver)
;; => "Clojure - Wikipedia"

;; 检查页面是否包含"Clojure"
(e/has-text? driver "Clojure")
;; => true

;; 浏览历史记录
(e/back driver)
(e/forward driver)
(e/refresh driver)
(e/get-title driver)
;; => "Clojure - Wikipedia"

;; 探索信息框
;; 它的标题是什么? 让我们用CSS查询来选择它:
(e/get-element-text driver {:css "table.infobox caption"})
;; => "Clojure"

;; 好的, 现在让我们尝试一些更复杂的操作
;; 也许我们对信息框中" Family" 行的值感兴趣:
(let [wikitable (e/query driver {:css "table.infobox.vevent tbody"})
      row-els (e/query-all-from driver wikitable {:tag :tr})]
  (for [row row-els
        :let [header-col-text (e/with-http-error
                                (e/get-element-text-el driver
                                                       (e/query-from driver row {:tag :th})))]
        :when (= "Family" header-col-text)]
    (e/get-element-text-el driver (e/query-from driver row {:tag :td}))))
;; => ("Lisp")

;; Etaoin提供了很多选择; 我们也可以用XPath一次性完成类似的操作:
(e/get-element-text driver "//table[@class='infobox vevent']/tbody/tr/th[text()='Family']/../td")
;; => "Lisp"

;; 当我们完成操作后, 退出, 这将停止Firefox的WebDriver
(e/quit driver) ;; 此时Firefox窗口应该关闭

大多数API函数都需要将驱动程序作为第一个参数. doto宏可以让你的代码具有领域特定语言(DSL)的感觉. 以下是上述代码的一部分用doto重写后的示例:

(require '[etaoin.api :as e]
         '[etaoin.keys :as k])

(def driver (e/firefox))

(doto driver
  (e/go "https://en.wikipedia.org/")
  (e/set-window-size {:width 1280 :height 800})
  (e/wait-visible [{:tag :input :name :search}])
  (e/fill {:tag :input :name :search} "Clojure programming language")
  (e/wait 1)
  (e/fill {:tag :input :name :search} k/enter)
  (e/wait-visible {:class :mw-search-results})
  (e/click [{:class :mw-search-results} {:class :mw-search-result-heading} {:tag :a}])
  (e/wait-visible {:id :firstHeading})
  (e/quit))

在REPL中尝试示例

我们鼓励你在自己的REPL中尝试本用户指南中的示例.

在我们想出更巧妙的方法之前, 可能最简单的方法是克隆Etaoin的GitHub仓库, 并从其项目根目录运行一个REPL.

除非另有说明, 本指南其余部分的示例将假定你已经执行了类似以下的操作:

(require '[etaoin.api :as e]
         '[etaoin.keys :as k]
         '[clojure.java.io :as io])

(def sample-page (-> "doc/user-guide-sample.html" io/file.toURI str))

(def driver (e/chrome)) ;; 或者根据你的喜好替换为其他浏览器
(e/go driver sample-page)

进阶

使用fill-multi简化代码

你可以使用fill-multi来简化代码, 如下所示:

原始代码:

(e/fill driver :uname "username")
(e/fill driver :pw "pass")
(e/fill driver :text "some text")

;; 获取我们刚刚设置的值
(mapv #(e/get-element-value driver %) [:uname :pw :text])
;; => ["username" "pass" "some text"]

简化后代码:

;; 刷新浏览器
(e/refresh driver)
(e/fill-multi driver {:uname "username2"
                      :pw "pass2"
                      :text "some text2"})

;; 获取我们刚刚设置的值
(mapv #(e/get-element-value driver %) [:uname :pw :text])
;; => ["username2" "pass2" "some text2"]

处理浏览器会话中的异常

如果在浏览器会话期间发生任何异常, WebDriver进程可能会一直运行, 直到你手动终止它. 为了防止这种情况, 我们推荐使用with-<browser>宏:

(e/with-firefox driver
  (doto driver
    (e/go "https://google.com")
    ;;... 在此处添加你的代码
    ))

这样可以确保无论发生什么情况, WebDriver进程都会被关闭.

单元测试作为文档

以下各节将更深入地描述如何使用Etaoin. 除了这些文档之外, Etaoin的API测试也是一个很好的参考.

创建和退出驱动程序

Etaoin提供了多种创建WebDriver实例的方法.

提示: 如前所述, 当你需要进行适当的清理时, 我们推荐使用with-<browser>约定.

假设我们要创建一个无头的Chrome驱动程序:

(require '[etaoin.api :as e])

;; 最基本的方式
(def driver (e/boot-driver :chrome {:headless true}))
;; 进行一些操作
(e/quit driver)

;; 也可以这样表达
(def driver (e/chrome {:headless true}))
;; 进行一些操作
(e/quit driver)

;; 或者...
(def driver (e/chrome-headless))
;; 进行一些操作
(e/quit driver)

with-<browser>函数可以很好地处理清理工作:

(e/with-chrome {:headless true} driver
  (e/go driver "https://clojure.org"))

(e/with-chrome-headless driver
  (e/go driver "https://clojure.org"))

chrome替换为firefox, edgesafari可用于其他变体. 有关详细信息, 请参阅API文档.

有关创建驱动程序时可用的所有选项, 请参阅<>部分.

选择元素

查询(也称为选择器)用于选择页面上Etaoin将与之交互的元素.

示例操作

;; 通过刷新页面重新开始
(e/refresh driver)
;; 选择具有html属性id为'uname'的元素并填充文本
(e/fill driver {:id "uname"} "Etaoin")
;; 选择第一个具有html按钮标签的元素并点击它
(e/click driver {:tag :button})

查询相关提示

提示:

  • 一个查询会返回一个唯一的元素标识符, 通常仅作为传递给其他函数的选择器才有意义.
  • 许多函数可以直接接受一个查询. 例如:
;; 直接指定查询
(e/get-element-text driver {:tag :button})
;; => "Submit Form"
;; 指定查询的结果(注意这里的 -el 函数变体)
(e/get-element-text-el driver (e/query driver {:tag :button}))
;; => "Submit Form"

提示:

如果一个查询没有找到元素, 将会抛出一个异常. 可以使用exists?来检查元素是否存在:

(e/exists? driver {:tag :button})
;; => true
(e/exists? driver {:id "wont-find-me"})
;; => false

简单查询, XPath, CSS

  • :active用于查找当前活动的元素. 需要注意的是, 查询:active已被弃用. 建议调用get-active-element来获取活动元素. 例如, 谷歌首页会自动将焦点放在搜索输入框上, 所以不需要先点击它.
;; 弃用的方式
(e/go driver "https://google.com")
(e/fill driver :active "Let's search for something" k/enter)
;; 更好的方式
(e/go driver "https://google.com")
(e/fill-el driver (e/get-active-element driver) "Let's search for something" k/enter)
;; 或者最佳方式... 这种情况很常见, API中包含了 `fill-active`
(e/go driver "https://google.com")
(e/fill-active driver "Let's search for something" k/enter)
  • 任何其他关键字会被转换为一个HTML ID属性. 需要注意的是, 不能使用这种关键字语法查询ID为" active" 的HTML元素, 因为特殊关键字:active会与之冲突. 如果要查询ID为" active" 的元素, 可以使用下面描述的映射语法(例如{:id "active"}{:id :active}).
(e/go driver sample-page)
(e/fill driver :uname "Etaoin" k/enter)
;; 或者也可以这样
(e/fill driver {:id "uname"} "Etaoin Again" k/enter)
  • 一个包含XPathCSS表达式的字符串. query函数对这个字符串的解释取决于驱动程序的:locator设置. 默认情况下, 驱动程序的:locator设置为"xpath". 可以使用use-css, with-css, use-xpathwith-xpath函数及宏来更改:locator. (手动编写XPath时要小心; 请参阅<>部分. )

以下是一个示例, 用于查找具有属性iduname且属性nameusernameinput标签:

(e/refresh driver)
(e/fill driver ".//input[@id='uname'][@name='username']" "XPath can be tricky")

;; 检查是否按预期工作
(e/get-element-value driver :uname)
;; => "XPath can be tricky"

;; 修改驱动程序以使用CSS而不是XPath
;; 注意这会返回一个新的, 修改后的驱动程序副本
;; 但旧的驱动程序仍然有效
(def driver-css (e/use-css driver))
(e/refresh driver-css)
(e/fill driver-css "input#uname[name='username']" "CSS can be tricky, too")
(e/get-element-value driver-css :uname)
;; => "CSS can be tricky, too"
  • 一个带有:xpath:css键且对应语法中有字符串的映射:
(e/refresh driver)
(e/fill driver {:xpath ".//input[@id='uname']"} "XPath selector")
(e/fill driver {:css "input#uname[name='username']"} " CSS selector")

;; 这是我们现在应该在用户名输入字段中看到的内容
(e/get-element-value driver :uname)
;; => "XPath selector CSS selector"

CSS选择器参考可能会有所帮助.

Map语法查询

一个查询也可以是一个表示XPath表达式的Map. 规则如下:

  • :tag键表示标签的名称, 默认值为*.
  • 任何非特殊键表示一个属性及其值.
  • :fn/是一个前缀, 后面跟着一个支持的查询函数.

有几种形式为:fn/*的查询函数. 每个查询函数都接受一个与映射中查询函数关键字相关联的值作为参数.

  • :fn/index: 接受一个正整数参数. 这会扩展为一个尾随的XPath [x]子句, 在需要选择表格中的特定行等情况下很有用.
  • :fn/text: 接受一个字符串参数. 如果元素具有指定的精确文本, 则匹配.
  • :fn/has-text: 接受一个字符串参数. 如果元素包含指定的文本, 则匹配.
  • :fn/has-string: 接受一个字符串参数. 如果元素字符串包含指定的字符串, 则匹配. :fn/has-text:fn/has-string的区别在于XPath的text()string()函数之间的区别(text()是给定元素内的文本, string()是按文档顺序连接在一起的所有后代元素的文本). 一般来说, 如果要定位层次结构顶部的元素, 可能需要使用:fn/has-string; 如果要定位层次结构底部的单个元素, 可能需要使用:fn/has-text.
  • :fn/has-class: 接受一个字符串参数. 如果元素的class属性包含该字符串, 则匹配. 与在映射中使用:class键不同, :fn/has-class可以匹配单个类, 而:class是对整个类字符串的精确匹配.
  • :fn/has-classes: 接受一个字符串向量参数. 如果元素的class属性包含所有指定的类字符串, 则匹配.
  • :fn/link: 接受一个字符串参数. 如果元素的href属性包含指定的字符串, 则匹配.
  • :fn/enabled: 接受一个布尔值(truefalse)参数. 如果参数为true, 则匹配启用的元素; 如果参数为false, 则匹配禁用的元素.
  • :fn/disabled: 接受一个布尔值(truefalse)参数. 如果参数为true, 则匹配禁用的元素; 如果参数为true, 则匹配启用的元素.

以下是一些映射语法的示例:

  • 查找第一个div标签:
(= (e/query driver {:tag :div})
   ;; 等效的XPath方式
   (e/query driver ".//div"))
;; => true
  • 查找第n个(基于1的)div标签:
(= (e/query driver {:tag :div :fn/index 1})
   ;; 等效的XPath方式
   (e/query driver ".//div[1]"))
;; => true
  • 查找class属性等于activea标签:
(= (e/query driver {:tag :a :class "active"})
   ;; 等效的XPath方式
   (e/query driver ".//a[@class='active']"))
  • 查找具有特定属性的表单:
(= (e/query driver {:tag :form :method :GET :class :formy})
   ;; 等效的XPath方式
   (e/query driver ".//form[@method=\"GET\"][@class='formy']"))
  • 查找按文本精确匹配的按钮:
(= (e/query driver {:tag :button :fn/text "Submit Form"})
   ;; 等效的XPath方式
   (e/query driver ".//button[text() = 'Submit Form']"))
  • 查找包含" blarg" 文本的第n个元素(p, div等, 元素类型不重要):
(e/get-element-text driver {:fn/has-text "blarg" :fn/index 3})
;; => "blarg in a p"

;; 等效的XPath方式
(e/get-element-text driver ".//*[contains(text(), 'blarg')][3]")
;; => "blarg in a p"
  • 查找包含一个类的元素:
(e/get-element-text driver {:tag :span :fn/has-class "class1"})
;; => "blarg in a span"

;; 等效的XPath方式
(e/get-element-text driver ".//span[contains(@class, 'class1')]")
  • 查找具有特定域名在href中的元素:
(e/get-element-text driver {:tag :a :fn/link "clojure.org"})
;; => "link 3 (clojure.org)"

;; 等效的XPath方式
(e/get-element-text driver ".//a[contains(@href, \"clojure.org\")]")
  • 查找包含所有指定类的元素:
(e/get-element-text driver {:fn/has-classes [:class2 :class3 :class5]})
;; => "blarg in a div"

;; 等效的XPath方式
(e/get-element-text driver ".//*[contains(@class, 'class2')][contains(@class, 'class3')][contains(@class, 'class5')]")
  • 查找明确启用/禁用的输入小部件:
;; 第一个启用的输入
(= (e/query driver {:tag :input :fn/enabled true})
   ;; 等效的XPath方式
   (e/query driver ".//input[@enabled=true()]"))
;; => true

;; 第一个禁用的输入
(= (e/query driver {:tag :input :fn/disabled true})
   ;; 等效的XPath方式
   (e/query driver ".//input[@disabled=true()]"))
;; => true

;; 返回所有禁用输入的向量
(= (e/query-all driver {:tag :input :fn/disabled true})
   ;; 等效的XPath方式
   (e/query-all driver ".//input[@disabled=true()]"))
;; => true

Vector语法查询

一个查询可以是任何有效查询表达式的向量. 对于向量查询, 每个表达式都与前一个表达式的输出相匹配.

一个简单的, 有点刻意构造的示例:

(e/click driver [{:tag :html} {:tag :body} {:tag :button}])
;; 我们的示例页面显示表单提交; 这样做是否有效?
(e/get-element-text driver :submit-count)
;; => "1"

你可以组合XPath和CSS表达式.

提示: 提醒: XPath表达式中的前导点表示从当前节点开始.

;; 在html标签下(使用映射查询语法),
;; 在一个包含某些链接的div标签下(使用CSS查询),
;; 点击一个具有
;; 等于active的class属性的标签(使用XPath语法):
(e/click driver [{:tag :html} {:css "div.some-links"} ".//a[@class='active']"])
;; 我们的示例页面显示链接点击, 这样做是否有效?
(e/get-element-text driver :clicked)
;; => "link 2 (active)"

高级查询

查询第n个匹配的元素

有时, 你可能想要与一个查询的第n个元素进行交互. 例如, 你可能想要点击以下列表中的第二个链接:

<ul>
    <li class="search-result">
        <a href="a">a</a>
    </li>
    <li class="search-result">
        <a href="b">b</a>
    </li>
    <li class="search-result">
        <a href="c">c</a>
    </li>
</ul>

你可以像这样使用:fn/index:

(e/click driver [{:tag :li :class :search-result :fn/index 2} {:tag :a}])
;; 检查示例页面中的点击跟踪器
(e/get-element-text driver :clicked)
;; => "b"

或者你也可以使用nth-child技巧与CSS表达式一起使用, 如下所示:

;; 重新加载页面
(e/refresh driver)
(e/click driver {:css "li.search-result:nth-child(2) a"})
(e/get-element-text driver :clicked)
;; => "b"

最后, 也可以通过使用query-all直接获取第n个元素:

;; 重新加载页面
(e/refresh driver)
(e/click-el driver (nth (e/query-all driver {:css "li.search-result a"}) 1))
(e/get-element-text driver :clicked)
;; => "b"

注意:

  • 这里使用了click-el. query-all函数返回一个元素, 而不是一个可以直接传递给click的选择器.
  • nth的偏移量是1而不是2. 因为Clojure的nth是基于0的, 而我们的搜索索引是基于1的.

查询树

query-tree函数可以连接选择器. 每个选择器都会在前一个选择器查询的元素基础上进行查询. 第一个选择器从根节点查找元素, 后续的选择器从之前找到的每个元素向下查找元素.

给定以下HTML:

<div id="query-tree-example">
  <div id="one">
    <a href="#">a1</a>
    <a href="#">a2</a>
    <a href="#">a3</a>
  </div>
  <div id="through">
    <a href="#">a4</a>
    <a href="#">a5</a>
    <a href="#">a6</a>
  </div>
  <div id="three">
    <a href="#">a7</a>
    <a href="#">a8</a>
    <a href="#">a9</a>
  </div>
</div>

以下查询将找到一个div标签的向量, 然后返回这些div标签下所有a标签的集合:

(->> (e/query-tree driver :query-tree-example {:tag :div} {:tag :a})
     (map #(e/get-element-text-el driver %))
     sort)
;; => ("a1" "a2" "a3" "a4" "a5" "a6" "a7" "a8" "a9")

查询Shadow DOM

Shadow DOM提供了一种将另一个DOM树附加到普通DOM中的指定元素的方法, 并且该树的内部结构对同一页面上的JavaScript和CSS是隐藏的. 当浏览器渲染DOM时, Shadow DOM中的元素会出现在普通DOM中树的根节点所在的位置. 这提供了一种封装级别, 允许Shadow DOM中的" 组件" 与页面的其他部分采用不同的样式, 并且防止了普通页面CSS和组件CSS之间的冲突.

Shadow DOM也对普通的Web Driver查询(query)是隐藏的, 因此需要一组单独的API调用来查询它. 有关Shadow DOM的更多详细信息, 请参阅Mozilla Developer Network (MDN)上的这篇文章.

在处理Shadow DOM时, 有几个重要的术语需要理解. " Shadow root host" 是标准DOM中作为属性附加了Shadow root的元素. " Shadow root" 是Shadow DOM树的顶部, 它以Shadow root host为根节点.

以下示例使用了用户指南示例HTML中的这个HTML片段, 其中包含了一些Shadow DOM内容.

<span id="not-in-shadow">I'm not in the shadow DOM</span>
<div id="shadow-root-host">
    <template shadowrootmode="open">
        <span id="in-shadow">I'm in the shadow DOM</span>
        <span id="also-in-shadow">I'm also in the shadow DOM</span>
    </template>
</div>

template元素中的所有内容都是Shadow DOM的一部分. div元素, 其idshadow-root-host, 正如其ID所暗示的, 是Shadow root host元素.

给定这个HTML, 你可以运行一个标准的query来找到Shadow root host, 然后使用get-element-property-el来返回"shadowRoot"属性. 需要注意的是, 以下示例中返回的元素ID对于特定的Etaoin驱动程序和驱动程序会话将是唯一的, 你不会看到相同的ID.

(e/query driver {:id "shadow-root-host"})
;; 一个类似于(但不等于)
;; "78344155-7a53-46fb-a46e-e864210e501d"的元素ID

(e/get-element-property-el driver (e/query driver {:id "shadow-root-host"}) "shadowRoot")
;; 类似于
;; {:shadow-6066-11e4-a52e-4f7
(e/get-element-property driver {:id "shadow-root-host"} "shadowRoot")
;; 类似于
;; {:shadow-6066-11e4-a52e-4f735466cecf "ac5ab914-7f93-427f-a0bf-f7e91098fd37"}

如果你走这条路线, 就需要拆分返回值. Shadow root的元素ID是第一个映射键的字符串值.

你可以使用Etaoinget-element-shadow-root API更直接地获取Shadow root元素ID. 查询参数会在标准DOM中查找匹配的元素, 并返回其Shadow root属性.

(e/get-element-shadow-root driver {:id "shadow-root-host"})
;; 类似于
;; "ac5ab914-7f93-427f-a0bf-f7e91098fd37"

如果你已经有了Shadow root host元素, 可以使用get-element-shadow-root-el返回其对应的Shadow root元素ID.

(def host (e/query driver {:id "shadow-root-host"}))
(e/get-element-shadow-root-el driver host)
;; 类似于
;; "ac5ab914-7f93-427f-a0bf-f7e91098fd37"

你可以使用has-shadow-root?has-shadow-root-el?来测试一个元素是否是Shadow root host.

(e/has-shadow-root? driver {:id "shadow-root-host"})
;; => true
(e/has-shadow-root-el? driver host)
;; => true
(e/has-shadow-root? driver {:id "not-in-shadow"})
;; => false

现在你知道了如何获取Shadow root, 就可以使用query-from-shadow-root, query-all-from-shadow-root, query-from-shadow-root-elquery-all-from-shadow-root-el来查询Shadow DOM中的元素.

对于query-from-shadow-rootquery-all-from-shadow-root, q参数指定在普通DOM中查找Shadow root host的查询. 如果找到了host, shadow-q参数就是在以Shadow root host为根的Shadow DOM中执行的查询.

query-from-shadow-root-elquery-all-from-shadow-root-el允许你直接指定Shadow root host元素, 而不是通过查询来获取它.

(def in-shadow (e/query-from-shadow-root driver {:id "shadow-root-host"} {:css "#in-shadow"}))
(e/get-element-text-el driver in-shadow)
;; => "I'm in the shadow DOM"

(->> (e/query-all-from-shadow-root driver {:id "shadow-root-host"} {:css "span"})
     (map #(e/get-element-text-el driver %)))
;; => ("I'm in the shadow DOM", "I'm also in the shadow DOM")

(def shadow-root (e/get-element-shadow-root-el driver host))
(e/get-element-text-el driver (e/query-from-shadow-root-el driver shadow-root {:css "#in-shadow"}))
;; => "I'm in the shadow DOM"

(->> (e/query-all-from-shadow-root-el driver shadow-root {:css "span"})
     (map #(e/get-element-text-el driver %)))
;; => ("I'm in the shadow DOM", "I'm also in the shadow DOM")

注意:

在前面的Shadow root查询中, 应该注意到我们在每种情况下都使用了CSS选择器作为shadow-q参数. 这是因为当前的浏览器不支持XPath, 而Etaoin的映射语法通常在底层会被转换为XPath. 虽然预计浏览器将来会支持对Shadow DOM的XPath查询, 但不清楚何时会出现这种支持. 目前, 请使用CSS.

如需更多信息, 请参阅Web Platforms Test Dashobard.

与查询到的元素进行交互

要与通过queryquery-all函数调用找到的元素进行交互, 必须将查询结果传递给click-elfill-el(注意-el后缀):

(e/click-el driver (first (e/query-all driver {:tag :a})))

你可以将元素收集到一个向量中, 并在任何时候随意与它们进行交互:

(e/refresh driver)
(def elements (e/query-all driver {:tag :input :type :text :fn/disabled false}))

(e/fill-el driver (first elements) "This is a test")
(e/fill-el driver (rand-nth elements) "I like tests!")

交互操作

一些基本的交互操作在<<查询>>部分已经涉及, 这里我们将深入探讨其他类型的交互操作.

Unicode和表情符号

在撰写本文时, Chrome和Edge仅支持基本多语言平面内用Unicode填充输入框. 这包括很多字符, 但不包括很多表情符号😢.

Firefox和Safari似乎更普遍地支持Unicode🙂.

(e/with-chrome driver
  (e/go driver sample-page)
  (e/fill driver :uname "ⱾⱺⱮⱸ ᢹⓂ Ᵽ")
  (e/get-element-value driver :uname))
;; => "ⱾⱺⱮⱸ ᢹⓂ Ᵽ"

(e/with-firefox driver
  (e/go driver sample-page)
  (e/fill driver :uname "ⱾⱺⱮⱸ ᢹⓂ Ᵽ plus 👍🔥🙂")
  (e/get-element-value driver :uname))
;; => "ⱾⱺⱮⱸ ᢹⓂ Ᵽ plus 👍🔥🙂"

模拟真人输入方式

真人输入速度慢且会犯错. 要模拟这些特征, 可以使用fill-human函数. 以下选项默认是启用的:

{:mistake-prob 0.1 ;; 一个从0.1到0.9的实数, 数值越高, 出现的拼写错误就越多
 :pause-max    0.2} ;; 最大输入延迟(秒)

如果你愿意, 可以选择覆盖这些默认值:

(e/refresh driver)
(e/fill-human driver :uname "soslowsobad"
              {:mistake-prob 0.5
               :pause-max 1})

;; 或者通过省略这些参数来使用默认选项
(e/fill-human driver :uname " typing human defaults")

(e/get-element-value driver :uname)
;; => "soslowsobad typing human defaults"

对于多个输入, 可以使用fill-human-multi:

(e/refresh driver)
(e/fill-human-multi driver {:uname "login"
                            :pw "password"
                            :text "some text"}
                           {:mistake-prob 0.1
                            :pause-max 0.1})

鼠标点击

click函数会在通过查询条件找到的元素上触发鼠标左键点击操作:

(e/click driver {:tag :button})

click函数只使用查询找到的第一个元素, 这有时会导致点击到错误的项目. 要确保只找到一个且唯一的元素, 可以使用click-single函数. 它的作用相同, 但在查询页面返回多个元素时会抛出异常:

(e/click-single driver {:tag :button :name "submit"})

虽然在网站上很少有意使用双击操作, 但一些不熟悉的用户可能会认为双击是点击按钮或链接的正确方式.

可以使用double-click函数来模拟双击操作. 例如, 它可以用于检查你对禁止多次提交表单的处理情况.

(e/double-click driver {:tag :button :name "submit"})

以下是一些在点击之前将鼠标指针移动到指定元素的函数:

(e/left-click-on driver {:tag :a})
(e/middle-click-on driver {:tag :a})
(e/right-click-on driver {:tag :a})

鼠标中键点击可以在新的后台标签中打开一个链接. 右键点击有时用于在Web应用程序中模拟上下文菜单.

从下拉列表中选择一个选项

可以通过click函数从<select>元素的<option>中选择一个选项.

给定以下HTML:

<select id="dropdown" name="options">
  <option value="o1">foo one</option>
  <option value="o2">bar two</option>
  <option value="o3">bar three</option>
  <option value="o4">bar four</option>
</select>

点击值为o4的选项:

(e/click driver [{:id :dropdown} {:value "o4"}])
(e/get-element-value driver :dropdown)
;; => "o4"

点击文本为bar three的选项:

(e/click driver [{:id :dropdown} {:fn/text "bar three"}])
(e/get-element-value driver :dropdown)
;; => "o3"

提示: Safari的特殊情况: 你可能需要先点击<select>元素, 然后再点击选项.

注意:

Etaoin还包括select便捷函数. 它会从包含指定文本的下拉列表中选择第一个选项. 它也会自动处理Safari的特殊情况.

点击第一个匹配文本为bar的选项:

(e/select driver :dropdown "bar")
(e/get-element-value driver :dropdown)
;; => "o2"

click函数表达相同的操作:

(e/click driver :dropdown) ;; 仅用于处理Safari的特殊情况
(e/click driver [{:id :dropdown} {:fn/has-text "bar"}])
(e/get-element-value driver :dropdown)
;; => "o2"

键盘组合键

可以输入一系列同时按下的键. 这在模拟按住系统键(如Control, Shift等)进行输入时很有用.

etaoin.keys命名空间包含了键常量以及一组与键盘输入相关的函数.

(require '[etaoin.keys :as k])

以下是一个在按住Shift键的同时输入普通字符的简单示例:

(e/refresh driver)
(e/wait 1) ;; 也许我们需要一秒钟让活动元素获得焦点
(e/fill-active driver (k/with-shift "caps is great"))
(e/get-element-value-el driver (e/get-active-element driver))
;; => "CAPS IS GREAT"

让我们通过全选, 复制和粘贴键盘快捷键来复制文本:

(if (= "Mac OS X" (System/getProperty "os.name"))
  (e/fill-active driver (k/with-command "a") (k/with-command "c") k/arrow-right " " (k/with-command "v"))
  (e/fill-active driver (k/with-ctrl "a") (k/with-ctrl "c") k/arrow-right " " (k/with-ctrl "v")))
(e/get-element-value_el driver (e/get-active-element driver))
;; => "CAPS IS GREAT CAPS IS GREAT"

现在让我们通过以下步骤清除输入框内容:

  1. 使用Home键将光标移动到输入框的开头.
  2. 在按住Shift键的同时将光标移动到输入框末尾以选中所有文本.
  3. 使用Delete键删除选中的文本.
(e/fill-active driver k/home (k/with-shift k/end) k/delete)
(e/get-element-value_el driver (e/get-active-element driver))
;; => ""

注意: 这些函数不适用于全局浏览器的快捷键. 例如, " Command + R" 和" Command + T" 不会重新加载页面或打开新标签.

etaoin.keys/with-*函数只是etaoin.keys/chord函数的包装器, 可能在复杂情况下使用.

文件上传

点击文件输入按钮会打开一个特定于操作系统的对话框. 从技术上讲, 无法使用WebDriver协议与这个对话框进行交互. 可以使用upload-file函数将本地文件附加到文件输入小部件上. 如果本地文件未找到, 将会抛出一个异常.

;; 打开一个用于上传文件的网页
(e/go driver "http://nervgh.github.io/pages/angular-file-upload/examples/simple/")

;; 将元素选择器绑定到变量; 你也可以指定id, class等
(def file-input {:tag :input :type :file})

;; 从你的系统上传一个文件到第一个文件输入框
(def my-file "env/test/resources/static/drag-n-drop/images/document.png")
(e/upload-file driver file-input my-file)

;; 或者传递一个原生的Java File对象:
(require '[clojure.java.io :as io])
(def my-file (io/file "env/test/resources/static/drag-n-drop/images/document.png"))
(e/upload-file driver file-input my-file)

当与远程WebDriver进程交互时, 你需要通过使用remote-file来避免本地文件存在性检查, 如下所示:

(e/upload-file driver file-input (e/remote-file "/yes/i/really/do/exist.png"))

远程文件被假定在WebDriver运行的地方存在. 如果不存在, WebDriver将会抛出一个错误.

滚动

Etaoin包含了用于滚动网页的函数.

最重要的一个函数scroll-query会跳转到通过查询条件找到的第一个元素:

(e/go driver sample-page)
;; 滚动到第5个h2标题
(e/scroll-query driver {:tag :h2} {:fn/index 5})

;; 然后回到第一个h1标题
(e/scroll-query driver {:tag :h1})

要跳转到绝对像素位置, 可以使用scroll函数:

(e/scroll driver 100 600)
;; 或者传递一个带有x和y键的映射
(e/scroll driver {:x 100 :y 600})

要按像素相对滚动, 可以使用scroll-by函数并指定偏移值:

;; 向右滚动100像素并向下滚动300像素
(e/scroll-by driver 100 300)
;; 使用映射语法向左滚动50像素并向上滚动200像素
(e/scroll-by driver {:x -50 :y -200})

有两个便捷函数可以垂直滚动到页面的顶部或底部:

(e/scroll-bottom driver) ;; 你会看到页脚...
(e/scroll-top driver)    ;;...以及再次看到页眉

以下函数可以在各个方向滚动页面:

(e/scroll driver [0 0])     ;; 让我们从左上角开始

(e/scroll-down driver 200)  ;; 向下滚动200像素
(e/scroll-down driver)      ;; 向下滚动默认(100)像素数

(e/scroll-up driver 200)    ;; 向上滚动200像素
(e/scroll-up driver)

(e/scroll-right driver 200) ;; 向右滚动200像素
(e/scroll-right driver)

(e/scroll-left driver 200)  ;; 向左滚动200像素
(e/scroll-left driver)

注意: 所有的滚动操作都是通过JavaScript执行的. 确保你的浏览器启用了JavaScript.

处理框架和iframe

你只能通过首先切换到框架或iframe内部, 才能与其中的项目进行交互.

假设你有如下的HTML布局:

<iframe id="frame1" src="...">
  <p id="in-frame1">In frame2 paragraph</p>
  <iframe id="frame2" src="...">
    <p id="in-frame2">In frame2 paragraph</p>
  </iframe>
</iframe>

让我们来探索切换到:frame1的操作:

(e/go driver sample-page)
;; 我们从主页面开始, 无法看到frame1内部:
(e/exists? driver :in-frame1)
;; => false

;; 切换上下文到id为frame1的框架:
(e/switch-frame driver :frame1)

;; 现在我们可以与frame1中的元素进行交互:
(e/exists? driver :in-frame1)
;; => true
(e/get-element-text driver :in-frame1)
;; => "In frame1 paragraph"

;; 切换回顶部框架(主页面)
(e/switch-frame-top driver)

要访问嵌套的框架, 可以如下操作:

;; 切换到主页面中的第一个顶级iframe: frame1
(e/switch-frame-first driver)
;; 向下切换到frame1中的第一个iframe: frame2
(e/switch-frame-first driver)
(e/get-element-text driver :in-frame2)
;; => "In frame2 paragraph"
;; 回到frame1
(e/switch-frame-parent driver)
;; 回到主页面
(e/switch-frame-parent driver)

可以使用with-frame宏临时切换到目标框架, 完成一些操作后返回最后一个表达式的值, 同时保留原始框架上下文.

(e/with-frame driver {:id :frame1}
  (e/with-frame driver {:id :frame2}
    (e/get-element-text driver :in-frame2)))
;; => "In frame2 paragraph"

执行JavaScript

使用js-execute函数在浏览器中执行JavaScript代码:

(e/js-execute driver "alert('Hello from Etaoin!')")
(e/dismiss-alert driver)

可以通过arguments数组对象向脚本传递额外的参数:

(e/js-execute driver "alert(arguments[2].foo)" 1 false {:foo "hello again!"})
(e/dismiss-alert driver)

这里我们传递了3个参数:

  • 1
  • false
  • {:foo "hello again!"}, 它会自动转换为JSON格式{"foo": "hello again!"}

然后警告框会显示第3个(索引为2)参数的foo字段的值, 即"hello again!".

要将数据返回给Clojure, 可以在脚本中添加return关键字:

(e/js-execute driver "return {foo: arguments[2].foo, bar: [1, 2, 3]}"
                     ;; 与前面示例相同的参数:
                     1 false {:foo "hello again!"})
;; => {:bar [1, 2, 3], :foo "hello again!"}

注意, JSON数据已经自动转换为EDN格式.

异步脚本

使用js-async函数来处理依赖异步策略(如setTimeout)的脚本. WebDriver会创建并将一个回调函数作为最后一个参数传递给脚本. 要指示操作完成, 必须调用这个回调函数.

示例如下:

(e/js-async
  driver
  "var args = arguments; // 保存全局参数
  // WebDriver添加了回调函数作为最后一个参数, 我们在这里获取它
  var callback = args[args.length-1];
  setTimeout(function() {
    // 我们调用WebDriver的回调函数, 传递我们希望它返回的值
    // 在这个例子中, 我们选择从传入的参数中返回42
    callback(args[0].foo.bar.baz);
  },
  1000);"
  {:foo {:bar {:baz 42}}})
;; => 42

如果想覆盖默认的脚本超时时间, 可以针对WebDriver会话进行设置:

;; 可选地保存当前值以便稍后恢复
(def orig-script-timeout (e/get-script-timeout driver))
(e/set-script-timeout driver 5) ;; 以秒为单位
;; 执行一些操作
(e/set-script-timeout driver orig-script-timeout)

或者通过with-script-timeout针对一段代码设置超时时间:

(e/with-script-timeout driver 30
  (e/js-async driver "var callback = arguments[arguments.length-1];
                      // 一些长时间的操作
                      callback('phew,done!');"))
;; => "phew,done!"

等待函数

程序和人类的主要区别在于, 程序运行速度非常快. 计算机运行速度如此之快, 以至于有时浏览器无法及时渲染新的HTML. 在每次操作之后, 你可能需要考虑包含一个wait-<something>函数, 它会轮询浏览器, 直到谓词的计算结果为真. 或者如果你不关心优化, 也可以直接使用(wait <seconds>).

with-wait宏在你需要在每个操作前添加(wait n)时可能会很有用. 例如, 以下形式:

(e/with-wait 1
  (e/refresh driver)
  (e/fill driver :uname "my username")
  (e/fill driver :text "some text"))

其执行过程大致如下:

(e/wait 1)
(e/refresh driver)
(e/wait 1)
(e/fill driver :uname "my username")
(e/wait 1)
(e/fill driver :text "some text")

并且会返回原始代码块中最后一个表达式的结果.

(doto-wait n driver & body)的作用类似于标准的doto, 但会在每个表达式前添加(wait n). 上面的例子用doto-wait重新表达如下:

(e/doto-wait 1 driver
  (e/refresh)
  (e/fill :uname "my username")
  (e/fill :text "some text"))

这实际上与以下代码等效:

(doto driver
  (e/wait 1)
  (e/refresh)
  (e/wait 1)
  (e/fill :uname "my username")
  (e/wait 1)
  (e/fill :text "some text"))

除了with-waitdo-wait, 还有许多等待函数, 如wait-visible, wait-has-alert, wait-predicate等(详见API文档). 它们接受默认的超时/间隔值, 可以使用with-wait-timeoutwith-wait-interval宏重新定义. 如果等待超时, 它们都会抛出异常.

(e/with-wait-timeout 15 ;; 超时时间, 单位为秒
  (doto driver
    (e/refresh)
    (e/wait-visible {:id :last-section})
    (e/click {:tag :a})
    (e/wait-has-text :clicked "link 1")))

等待文本相关的函数:

  • wait-has-text: 等待直到一个元素内部的任何位置(包括内部HTML)有文本.
(e/click driver {:tag :a})
(e/wait-has-text driver :clicked "link 1")
  • wait-has-text-everywhere: 与wait-has-text类似, 但会在整个页面中搜索文本.
(e/wait-has-text-everywhere driver "ipsum")

加载策略

当你导航到一个页面时, 驱动程序会等待直到整个页面完全加载. 在大多数情况下这没问题, 但这并不能反映人类与互联网交互的方式.

可以使用:load-strategy选项来改变这种默认行为:

  • :normal(默认值): 等待整个页面加载(包括所有内容, 如图像等).
  • :none: 完全不等待.
  • :eager: 只等待DOM内容加载.

例如, 默认的:normal策略:

(e/with-chrome driver
  (e/go driver sample-page)
  ;; 默认情况下, 你会在这一行暂停, 直到页面加载完成
  ;; (do-something)
)

:load-strategy选项设置为:none的情况:

(e/with-chrome {:load-strategy :none} driver
  (e/go driver sample-page)
  ;; 不会暂停, 立即执行
  ;; (do-something)
)

需要注意的是, 目前:eager选项仅在Firefox中有效.

动作

Etaoin支持Webdriver Actions. 它们被描述为" 虚拟输入设备" . 它们就像是可以同时运行的小型设备输入脚本.

以下是两个动作的原始示例. 一个控制键盘, 另一个控制指针(鼠标).

;; 一个键盘输入
{:type    "key"
 :id      "some name"
 :actions [{:type "keyDown" :value "a"}
           {:type "keyUp" :value "a"}
           {:type "pause" :duration 100}]}
;; 一些指针输入
{:type       "pointer"
 :id         "UUID or some name"
 :parameters {:pointerType "mouse"}
 :actions    [{:type "pointerMove" :origin "pointer" :x 396 :y 323}
              ;; 双击
              {:type "pointerDown" :duration 0 :button 0}
              {:type "pointerUp" :duration 0 :button 0}
              {:type "pointerDown" :duration 0 :button 0}
              {:type "pointerUp" :duration 0 :button 0}]}

你可以手动创建一个映射并将其发送到perform-actions方法:

(def keyboard-input {:type    "key"
                     :id      "some name"
                     +:actions [{:type "keyDown" :value "e"}
                               {:type "keyUp" :value "e"}
                               {:type "keyDown" +:value "t"}
                               {:type "keyUp" +:value "t"}
                               ;; 持续时间以毫秒为单位
                               {:type "pause" +:duration 100}]})
;; 刷新以便处于活动输入字段
(e/refresh driver)
;; 执行我们的键盘输入动作
(e/perform-actions driver keyboard-input)

或者, 你也可以选择使用Etaoin的动作辅助函数. 首先, 创建虚拟输入设备:

(def keyboard (e/make-key-input))

然后填充动作:

(-> keyboard
    (e/add-key-down k/shift-left)
    (e/add-key-down "a")
    (e/add-key-up "a")
    (e/add-key-up k/shift-left))

以下是一个稍微复杂一些的带注释的工作示例:

;; 多个虚拟输入可以同时运行, 所以我们创建一个小助手函数来生成n个暂停
(defn add-pauses [input n]
  (->> (iterate e/add-pause input)
       (take (inc n))
       last))

(let [username (e/query driver :uname)
      submit-button (e/query driver {:tag :button})
      mouse (-> (e/make-mouse-input)
                ;; 点击用户名
                (e/add-pointer-click-el
                  username k/mouse-left)
                ;; 暂停10次点击, 以便键盘动作可以输入用户名
                ;; (对于每个按键按下和抬起, 在etaoin中)
                (add-pauses 10)
                ;; 点击提交按钮
                (e/add-pointer-click-el
                  submit-button k/mouse-left))
      keyboard (-> (e/make-key-input)
                   ;; 暂停2次滴答, 以便鼠标动作可以先点击用户名
                   ;; (移动到用户名元素并点击它)
                   (add-pauses 2)
                   (e/with-key-down k/shift-left)
                   (e/add-key-press "e")
                   (e/add-key-press "t")
                   (e/add-key-press "a")
                   (e/add-key-press "o")
                   (e/add-key-press "i")
                   (e/add-key-press "n")) ]
  (e/perform-actions driver keyboard mouse))

要清除虚拟输入设备的状态, 释放所有当前按下的键等, 可以使用release-actions方法:

(e/release-actions driver)

以下是翻译后的内容:

截取屏幕截图

screenshot函数会将当前可见页面转储为磁盘上的PNG图像文件. 可以指定任何绝对路径或相对路径. 指定一个字符串:

(e/screenshot driver "target/etaoin-play/screens1/page.png")

或者一个File对象:

(require '[clojure.java.io :as io])
(e/screenshot driver (io/file "target/etaoin-play/screens2/test.png"))

特定元素的屏幕截图

在Firefox和Chrome中, 还可以截取页面内的单个元素, 比如一个div, 一个输入部件等等. 目前在其他浏览器中此功能不可用.

(e/screenshot-element driver {:tag :form :class :formy} "target/etaoin-play/screens3/form-element.png")

每次表单操作后的屏幕截图

使用with-screenshots可以在代码块中每次执行完一个表单后, 在指定目录下截取屏幕截图. 文件命名约定是<webdriver-name>-<milliseconds-since-1970>.png.

(e/refresh driver)
(e/with-screenshots driver "target/etaoin-play/saved-screenshots"
  (e/fill driver :uname "et")
  (e/fill driver :uname "ao")
  (e/fill driver :uname "in"))

这等同于类似如下的操作:

(e/refresh driver)
(e/fill driver :uname "et")
(e/screenshot driver "target/etaoin-play/saved-screenshots/chrome-1.png")
(e/fill driver :uname "ao")
(e/screenshot driver "target/etaoin-play/saved-screenshots/chrome-2.png")
(e/fill driver :uname "in")
(e/screenshot driver "target/etaoin-play/saved-screenshots/chrome-3.png")

将页面打印为PDF

使用print-page函数可以将当前页面打印为一个PDF文件:

(e/with-firefox-headless driver
  (e/go driver sample-page)
  (e/print-page driver "target/etaoin-play/printed.pdf"))

详情请参阅API文档.

深入探究

有时候深入探究一下会很有用.

调用特定于WebDriver实现的功能

Etaoin API公开了W3C WebDriver协议的一个抽象层. 通常这就是你所需要的全部, 但有时你可能想要调用不属于WebDriver协议的WebDriver实现功能.

Etaoin通过其execute函数与WebDriver进程进行通信. 你可以使用这个底层函数向WebDriver进程发送任何你想要发送的内容.

举一个实际的例子, Chrome支持截取具有透明背景的屏幕截图. 在这里我们使用Etaoinexecute函数来让Chrome执行此操作:

(e/with-chrome driver
  ;; 导航到示例页面
  (e/go driver sample-page)
  ;; 发送针对透明背景的Chrome特定请求
  (e/execute {:driver driver
              :method :post
              :path [:session (:session driver) "chromium" "send_command_and_get_result"]
              :data {:cmd "Emulation.setDefaultBackgroundColorOverride"
                     :params {:color {:r 0 :g 0 :b 0 :a 0}}}})
  ;; 然后像往常一样截取一个元素的屏幕截图
  (e/screenshot-element driver
                        {:tag :form}
                        (str "target/etaoin-play/saved-screenshots/form.png")))

读取浏览器的控制台日志

get-logs函数会将浏览器的控制台日志作为一个映射向量返回. 每个映射具有以下结构:

// 注意, 我们这里没有通过测试文档块来验证get-logs的输出, 所以省略了" =>"

(e/js-execute driver "console.log('foo')")
(e/get-logs driver)
;; [{:level :info,
;;   :message "console-api 2:32 \"foo\"",
;;   :source :console-api,
;;   :timestamp 1654358994253,
;;   :datetime #inst "2022-06-04T16:09:54.253-00:00"}]

;; 第二次调用(针对Chrome)时, 我们会发现日志为空
(e/get-logs driver)
;; => []

目前, 仅在Chrome中可以获取日志. 消息文本和来源类型会因浏览器供应商而异. Chrome在日志被读取后会将其清除.

DevTools: 跟踪HTTP请求, XHR(Ajax)

你可以追踪来自DevTools面板的事件. 这意味着你现在在开发者控制台中看到的所有内容都可以通过Etaoin API获取. 目前这仅适用于谷歌Chrome.

要启动一个启用了DevTools支持的驱动程序, 需要指定一个:dev映射.

// 让我们将这个驱动程序放在它自己的命名空间中 // {:test-doc-blocks/test-ns user-guide-devtools-test}

(require '[etaoin.api :as e])

(e/with-chrome driver {:dev {}}
  ;; 执行一些操作
)

该值不能是一个映射(也不能是nil). 当:dev为空映射时, 会使用以下默认值:

{:perf
 {:level :all
  :network? true
  :page? false
  :categories [:devtools.network]
  :interval 1000}}

我们将使用一个启用了所有功能的驱动程序:

// {:test-doc-blocks/test-ns user-guide-devtools-test}

(require '[etaoin.api :as e])

(def driver (e/chrome {:dev
                       {:perf
                        {:level :all
                         :network? true
                         :page? true
                         :interval 1000
                         :categories [:devtools
                                      :devtools.network
                                      :devtools.timeline]}}}))

在底层, Etaoin会在chromeOptions对象内部设置一个特殊的perfLoggingPrefs字典.

现在你的浏览器正在累积这些事件, 你可以使用一个特殊的dev命名空间来读取它们.

当你尝试这样做时, 结果会有所不同, 但以下是我所得到的情况:

// {:test-doc-blocks/test-ns user-guide-devtools-test}

(require '[etaoin.dev :as dev])

(e/go driver "https://google.com")

(def reqs (dev/get-requests driver))

;; reqs是一个映射向量
(count reqs)
;; 23

;; 请求类型有哪些?
(frequencies (map :type reqs))
;; {:script 6,
;;  :other 2,
;;  :xhr 4,
;;  :image 5,
;;  :stylesheet 1,
;;  :ping 3,
;;  :document 1,
;;  :manifest 1}

;; 有意思的是, 我们得到了JavaScript请求, 图像, AJAX以及其他内容

// {:test-doc-blocks/test-ns user-guide-devtools-test}

;; 让我们看一下最后一张图像:
(last (filter #(= :image (:type %)) reqs))
;;    {:state 4,
;;     :id "14535.6",
;;     :type :image,
;;     :xhr? false,
;;     :url
;;     "https://www.google.com/images/searchbox/desktop_searchbox_sprites318_hr.webp",
;;     :with-data? nil,
;;     :request
;;     {:method :get,
;;      :headers
;;      {:Referer "https://www.google.com/?gws_rd=ssl",
;;       :sec-ch-ua-full-version-list
;;       "\" Not A;Brand\";v=\"99.0.0.0\", \"Chromium\";v=\"102.0.5005.61\", \"Google Chrome\";v=\"102.0.5005.61\"",
;;       :sec-ch-viewport-width "1200",
;;       :sec-ch-ua-platform-version "\"10.15.7\"",
;;       :sec-ch-ua
;;       "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"102\", \"Google Chrome\";v=\"102\"",
;;       :sec-ch-ua-platform "\"macOS\"",
;;       :sec-ch-ua-full-version "\"102.0.5005.61\"",
;;       :sec-ch-ua-wow64 "?0",
;;       :sec-ch-ua-model "",
;;       :sec-ch-ua-bitness "\"64\"",
;;       :sec-ch-ua-mobile "?0",
;;       :sec-ch-dpr "1",
;;       :sec-ch-ua-arch "\"x86\"",
;;       :User-Agent
;;       "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36"}},
;;     :response
;;     {:status nil,
;;      :headers
;;      {:date "Sat, 04 Jun 2022 00:11:36 GMT",
;;       :x-xss-protection "0",
;;       :x-content-type-options "nosniff",
;;       :server "sffe",
;;       :cross-origin-opener-policy-report-only
;;       "same-origin; report-to=\"static-on-bigtable\"",
;;       :last-modified "Wed, 22 Apr 2022 00:00:00 GMT",
;;       :expires "Sat, 04 Jun 2022 00:11:36 GMT",
;;       :cache-control "private, max-age=31536000",
;;       :content-length "660",
;;       :report-to
;;       "{\"group\":\"static-on-bigtable\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/static-on-bigtable\"}]}",
;;       :alt-svc
;;       "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"",
;;       :cross-origin-resource-policy "cross-origin",
;;       :content-type "image/webp",
;;       :accept-ranges "bytes"},
;;      :mime "image/webp",
;;      :remote-ip "142.251.41.68"},
;;     :done? true}

提示: 这些响应的详细信息来自Chrome, 并且可能会随着Chrome的更新而改变.

由于我们主要对AJAX请求感兴趣, 所以有一个get-ajax函数, 它执行相同的操作但会过滤出XHR请求:

// {:test-doc-blocks/test-ns user-guide-devtools-test}

;; 刷新以重新填充日志
(e/go driver "https://google.com")
(e/wait 2) ;; 给AJAX请求完成的机会

(last (dev/get-ajax driver))
;; {:state 4,
;;  :id "14535.59",
;;  :type :xhr,
;;  :xhr? true,
;;  :url
;;    "https://www.google.com/complete/search?q&cp=0&client=gws-wiz&xssi=t&hl=en-CA&authuser=0&psi=OtuaYq-xHNeMtQbkjo6gBg.1654315834852&nolsbt=1&dpr=1",
;;  :with-data? nil,
;;  :request
;;  {:method :get,
;;   :headers
;;   {:Referer "https://www.google.com/",
;;    :sec-ch-ua-full-version-list
;;    "\" Not A;Brand\";v=\"99.0.0.0\", \"Chromium\";v=\"102.0.5005.61\", \"Google Chrome\";v=\"102.0.5005.61\"",
;;    :sec-ch-viewport-width "1200",
;;    :sec-ch-ua-platform-version "\"10.15.7\"",
;;    :sec-ch-ua
;;    "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"102\", \"Google Chrome\";v=\"102\"",
;;    :sec-ch-ua-platform "\"macOS\"",
;;    :sec-ch-ua-full-version "\"102.0.5005.61\"",
;;    :sec-ch-ua-wow64 "?0",
;;    :sec-ch-ua-model "",
;;    :sec-ch-ua-bitness "\"64\"",
;;    :sec-ch-ua-mobile "?0",
;;    :sec-ch-dpr "1",
;;    :sec-ch-ua-arch "\"x86\"",
;;    :User-Agent
;;    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36"}},
;;  :response
;;  {:status nil,
;;   :headers
;;   {:bfcache-opt-in "unload",
;;    :date "Sat, 04 Jun 2022 04:10:35 GMT",
;;    :content-disposition "attachment; filename=\"f.txt\"",
;;    :x-xss-protection "0",
;;    :server "gws",
;;    :expires "Sat, 04 Jun 2022 04:10:35 GMT",
;;    :accept-ch
;;    "Sec-CH-Viewport-Width, Sec-CH-Viewport-Height, Sec-CH-DPR, Sec-CH-UA-Platform, Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version, Sec-CH-UA-Arch, Sec-CH-UA-Model, Sec-CH-UA-Bitness, Sec-CH-UA-Full-Version-List, Sec-CH-UA-WoW64",
;;    :cache-control "private, max-age=3600",
;;    :report-to
;;    "{\"group\":\"gws\",\"max_age\":2592000,\"endpoints\":[{\"url\":\"https://csp.withgoogle.com/csp/report-to/gws/cdt1\"}]}",
;;    :x-frame-options "SAMEORIGIN",
;;    :strict-transport-security "max-age=31536000",
;;    :content-security-policy
;;    "object-src 'none';base-uri 'self';script-src 'nonce-xM7BqmSpeu5Zd6usKOP4JA' 'strict-dynamic' 'report-sample' 'unsafe-eval' 'unsafe-inline' https: http:;report-uri https://csp.withgoogle.com/csp/gws/cdt1",
;;    :alt-svc
;;    "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"",
;;    :content-type "application/json; charset=UTF-8",
;;    :cross-origin-opener-policy "same-origin-allow-popups; report-to=\"gws\"",
;;    :content-encoding "br"},
;;   :mime "application/json",
;;   :remote-ip "142.251.41.36"},
;;  :done? true};; => nil

get-ajax的一种典型使用模式如下. 你想要检查是否有某个特定请求已发送到服务器. 所以你按下一个按钮, 等待一段时间, 然后读取浏览器发出的请求. 有了请求列表后, 你搜索你需要的那个请求(比如通过其URL), 然后检查它的状态. :state字段与XMLHttpRequest.readyState具有相同的语义. 它是一个从1到4的整数, 行为相同.

要检查一个请求是否已完成, 成功或失败, 可以使用以下这些谓词:

// 在持续集成环境中对此有太多失败情况,

// 暂时跳过 // :test-doc-blocks/skip

;; 填充日志
(e/go driver "https://google.com")
(e/wait 2) ;; 给AJAX请求完成的机会

(def reqs (dev/get-ajax driver))
;; 在这里你会搜索你感兴趣的请求
(def req (last reqs))

(dev/request-done? req)
;; => true

(dev/request-failed? req)
;; => nil

(dev/request-success? req)
;; => true

请注意, request-done?并不意味着请求已成功. 它仅表示其处理流程已到达最后一步.

提示: 当你读取开发日志时, 你是从一个内部缓冲区读取的, 该缓冲区会被刷新. 第二次调用get-requestsget-ajax将返回一个空列表.

也许你想收集这些日志. 函数dev/get-performance-logs会返回一个日志列表, 所以你可以将它们累积在一个原子(atom)或其他数据结构中:

// {:test-doc-blocks/test-ns user-guide-devtools-test}

;; 设置一个收集器
(def logs (atom []))

;; 发起请求
(e/refresh driver)

;; 根据需要进行收集
(do (swap! logs concat (dev/get-performance-logs driver))
    true)

(count @logs)
;; 136

+logs->requests++logs->ajax+函数会将已经获取的日志转换为请求. 与get-requestsget-ajax不同, 它们是纯函数, 不会刷新任何内容.

// {:test-doc-blocks/test-ns user-guide-devtools-test}

;; 转换我们从收集器原子中获取的请求
(dev/logs->requests @logs)
(last (dev/logs->requests @logs))
;;    {:state 4,
;;     :id "14535.162",
;;     :type :ping,
;;     :xhr? false,
;;     :url
;;     "https://www.google.com/gen_204?atyp=i&r=1&ei=Zd2aYsrzLozStQbzgbqIBQ&ct=slh&v=t1&m=HV&pv=0.48715273690818806&me=1:1654316389931,V,0,0,1200,1053:0,B,1053:0,N,1,Zd2aYsrzLozStQbzgbqIBQ:0,R,1,1,0,0,1200,1053:93,x:42832,e,U&zx=1654316432856",
;;     :with-data? true,
;;     :request
;;     {:method :post,
;;      :headers
;;      {:Referer "https://www.google.com/",
;;       :sec-ch-ua-full-version-list
;;       "\" Not A;Brand\";v=\"99.0.0.0\", \"Chromium\";v=\"102.0.5005.61\", \"Google Chrome\";v=\"102.0.5005.61\"",
;;       :sec-ch-viewport-width "1200",
;;       :sec-ch-ua-platform-version "\"10.15.7\"",
;;       :sec-ch-ua
;;       "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"102\", \"Google Chrome\";v=\"102\"",
;;       :sec-ch-ua-platform "\"macOS\"",
;;       :sec-ch-ua-full-version "\"102.0.5005.61\"",
;;       :sec-ch-ua-wow64 "?0",
;;       :sec-ch-ua-model "",
;;       :sec-ch-ua-bitness "\"64\"",
;;       :sec-ch-ua-mobile "?0",
;;       :sec-ch-dpr "1",
;;       :sec-ch-ua-arch "\"x86\"",
;;       :User-Agent
;;       "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.61 Safari/537.36"}},
;;     :response
;;     {:status nil,
;;      :headers
;;      {:alt-svc
;;       "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\"",
;;       :bfcache-opt-in "unload",
;;       :content-length "0",
;;       :content-type "text/html; charset=UTF-8",
;;       :date "Sat, 04 Jun 2022 04:20:32 GMT",
;;       :server "gws",
;;       :x-frame-options "SAMEORIGIN",
;;       :x-xss-protection "0"},
;;      :mime "text/html",
;;      :remote-ip "142.251.41.36"},
;;     :done? true}

在处理日志和请求时, 要注意它们的数量和大小. 这些映射有很多键, 集合中的项目数量可能很大. 打印大量事件可能会使你的编辑器卡住. 考虑使用clojure.pprint/pprint, 因为它依赖于最大层级和长度限制.

// 隐藏的清理我们的DevTools驱动程序的操作 ifdef::env-test-doc-blocks[] // {:test-doc-blocks/test-ns user-guide-devtools-test}

(e/quit driver)

endif::[]

事后分析: 在发生异常时自动保存相关文件

有时候, 在UI测试运行失败时诊断出问题所在可能会很困难. 使用with-postmortem可以在触发异常之前将有用的数据保存到磁盘:

  • 可见浏览器页面的屏幕截图
  • 当前浏览器页面的HTML代码
  • JS控制台日志(如果你的浏览器支持的话)

示例:

(try
  (e/with-postmortem driver {:dir "target/etaoin-play/postmortem"}
    (e/click driver :non-existing-element))
  (catch Exception _e
    "yup, we threw!"))
;; => "yup, we threw!"

将会发生一个异常. 在target/etaoin-postmortem下你会找到三个事后分析文件, 命名格式如下: <browser>-<host>-<port>-<datetime>.<ext>, 例如:

$ tree target
target
└── etaoin-postmortem
    ├── chrome-127.0.0.1-49766-2022-06-04-12-26-31.html
    ├── chrome-127.0.0.1-49766-2022-06-04-12-26-31.json
    └── chrome-127.0.0.1-49766-2022-06-04-12-26-31.png

with-postmortem可用的选项如下:

{;; 保存相关文件的目录
 ;; 如果不存在将会创建, 默认是当前工作目录
 :dir "/home/ivan/UI-tests"

 ;; 保存屏幕截图的目录; 默认是:dir
 :dir-img "/home/ivan/UI-tests/screenshots"

 ;; 保存HTML源文件的目录; 与上面类似
 :dir-src "/home/ivan/UI-tests/HTML"

 ;; 保存控制台日志的目录; 与上面类似
 :dir-log "/home/ivan/UI-tests/console"

 ;; 用于格式化时间戳的字符串模板; 参考SimpleDateFormat Java类
 :date-format "yyyy-MM-dd-HH-mm-ss"}

驱动程序选项

在创建一个WebDriver实例时, 你可以可选地包含一个选项映射来调整WebDriver网页浏览器的行为.

例如, 在这里我们为Chrome WebDriver二进制文件设置一个明确的路径:

// :test-doc-blocks/skip

(def driver (e/chrome {:path-driver "/Users/ivan/downloads/chromedriver"}))

:host用于WebDriver

默认值: 未设置

示例: :host "192.68.1.12"

当:

  • 指定了:host时, Etaoin会尝试连接到一个已存在并正在运行的WebDriver进程.
  • 未指定:host时, Etaoin会创建一个新的本地WebDriver进程(除非指定了<>).

另请参阅<>, <<连接到现有进程>>.

替代方案: 请参阅<>.

:port用于WebDriver

默认值: 当Etaoin启动一个本地WebDriver进程时, 会选择一个随机未使用的端口. 当连接到一个远程WebDriver进程时, 因供应商而异:

  • Chrome: 9515
  • Firefox: 4444
  • Safari: 4445
  • Edge: 17556

示例: :port 9997

另请参阅<>, <<连接到现有进程>>.

:webdriver-url

默认值: 未设置

示例: :web-driver-url "https://chrome.browserless.io/webdriver"

当:

  • 指定了:webdriver-url时, Etaoin会尝试连接到一个预先存在且正在运行的WebDriver进程.
  • 未指定:webdriver-url时, 会创建一个新的本地WebDriver进程(除非指定了<>).

请参阅<<连接到现有进程>>.

替代方案: 请参阅上文的<<host,:host>>.

:path-driver指向WebDriver二进制文件

默认值: 因浏览器供应商而异:

  • Chrome: "chromedriver"
  • Firefox: "geckodriver"
  • Safari: "safaridriver"
  • Edge: "msedgedriver"

示例: :path-driver "/Users/ivan/Downloads/geckodriver"

通常在你的WebDriver二进制文件不在PATH环境变量中时使用.

:args-driver用于WebDriver二进制文件

默认值: 未设置

示例: :args-driver ["--binary" "/path/to/firefox/binary"] + (这是针对geckodriver的特定设置, 你可能更倾向于使用<>来替代)

指定给WebDriver二进制文件的额外命令行参数.

:webdriver-failed-launch-retries

默认值:

  • :safari驱动程序: 4
  • 其他所有驱动程序: 0

示例: :webdriver-failed-launch-retries 3

引入此选项是为了弥补safaridriver在启动时出现的一些神秘但可恢复的失败情况.

:path-browser指向网页浏览器二进制文件

默认值: 未设置, WebDriver实现会尝试找到网页浏览器二进制文件.

示例: :path-browser "/Users/ivan/Downloads/firefox/firefox

通常在你的网页浏览器二进制文件不在PATH环境变量中时使用.

:args用于网页浏览器二进制文件

默认值: 未设置

示例: :args ["--incognito" "--app" "http://example.com"]

指定给网页浏览器二进制文件的额外命令行参数; 具体可用参数请参考供应商文档.

:log-level用于网页浏览器控制台

默认值: :all

示例: :log-level :err

网页浏览器控制台的最小日志级别. 只有达到此级别及以上的消息才会被收集. 从最不详细到最详细依次为:

  • nil, :off:none: 不收集任何消息
  • :err, :error, :severe, :crit:critical
  • :warn:warning
  • :debug
  • :all: 收集所有消息.

仅适用于Chrome和Edge.

请参阅<<控制台日志>>.

:driver-log-level

默认值: 未设置

示例: :driver-log-level "INFO"

WebDriver的最小日志级别. 值因浏览器驱动程序供应商而异:

  • Chrome和Edge: "OFF", "SEVERE", "WARNING", "INFO""DEBUG"
  • Firefox: "fatal", "error", "warn", "info", "config", "debug""trace"
  • Safari: "debug" - safaridriver ** 只有一个详细的日志级别, 我们通过其--diagnose选项启用并通过"debug"进行抽象表示 ** 仅记录到一个日志文件中, Etaoin会自动发现并在driver映射中填充为<> ** 请参阅<>了解一种转储此日志文件的方法.

:log-stdout:log-stderr用于WebDriver输出

默认值: 不进行日志记录: /dev/null, 在Windows上为NUL

示例:

  :log-stdout "target/chromedriver-out.log"
  :log-stderr "target/chromedriver-err.log"

指定:inherit可使WebDriver进程的输出目的地继承自其调用进程(例如, 控制台或对某个文件的现有重定向).

:driver-log-file用于发现的WebDriver日志文件

默认值: 未设置, Etaoin会为你设置此值.

示例: <n/a>(未由用户设置)

仅在<>设置为"debug"时为safaridriver填充此值.

:post-stop-fns用于在驱动程序停止时插入行为

默认值: 未设置

示例: 一种用法是转储safaridriver的<>.

// :test-doc-blocks/skip

:post-stop-fns [(fn dump-discovered-log [driver]
                  (if-let [log (:driver-log-file driver)]
                    (do
                      (println "-[start]-safaridriver log file" log)
                      (with-open [in (io/input-stream log)]
                        (io/copy in *out*))
                      (println "-[end]-safaridriver log file" log))
                    (println "-no safaridriver log file discovered-")))]

:profile指向网页浏览器配置文件的路径

默认值: 未设置

示例: :profile "/Users/ivan/Library/Application Support/Firefox/Profiles/iy4iitbg.Test"

指向自定义网页浏览器配置文件的路径, 参阅<<浏览器配置文件>>.

:env用于WebDriver进程的变量

默认值: 未设置

示例: :env {:MOZ_CRASHREPORTER_URL "http://test.com"}

启动WebDriver进程时要使用的额外环境变量的映射.

:size初始网页浏览器窗口的大小

默认值: [1024 680]

示例: size: [640 480]

初始网页浏览器窗口的宽度和高度(以像素为单位).

:url在网页浏览器中打开的网址

默认值: 未设置

示例: :url "https://clojure.org"

目前仅适用于Firefox.

:user-agent用于网页浏览器

默认值: 未设置, 由WebDriver/网页浏览器供应商决定.

示例: :user-agent "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)"

覆盖网页浏览器返回的User-Agent标头.

:download-dir用于网页浏览器

默认值: 未设置, 由网页浏览器供应商决定.

示例: :download-dir "target/chrome-downloads"

网页浏览器下载文件的目录.

:headless网页浏览器

默认值: 通常为false, 但对于像chrome-headless, with-chrome-headless等驱动程序创建函数, 会自动设置为true.

示例: :headless true

在无用户界面的情况下运行网页浏览器. 请参阅<<无头模式>>.

:prefs用于网页浏览器(续)

默认值: 未设置

示例: 请参阅<<下载目录>>中的一种用法.

网页浏览器特定偏好设置的映射.

:proxy用于网页浏览器

默认值: 未设置

示例: 请参阅<<HTTP代理>>.

:load-strategy

默认值: :normal

示例: :load-strategy :none

控制WebDriver在与页面交互之前应该等待的时长. 请参阅<<加载策略>>.

:capabilities

默认值: 未设置

示例: 请参阅<<HTTP代理>>中的示例用法.

WebDriver的功能可能因供应商而异, 并且可以指定首选选项. 在设置任何内容之前, 请先阅读WebDriver供应商文档. 在阅读文档时, 请注意Etaoin会将:capabilitiesfirstMatch下传递.

使用无头驱动程序

谷歌Chrome, Firefox和微软Edge可以在无头模式下运行. 在无头模式下, 屏幕上不会出现任何UI窗口. 在以下情况下, 无头运行很有帮助:

  • 在没有图形输出设备的服务器上运行集成测试时.
  • 在本地运行测试而不让它们占用本地UI时.

通过在终端中运行浏览器时检查它是否接受--headless命令行参数, 来确保你的浏览器支持无头模式.

启动驱动程序时, 传递:headless布尔标志以切换到无头模式. 对于Safari, 此标志会被忽略, 截至2024年8月, Safari仍然不支持无头模式.

// {:test-doc-blocks/test-ns user-guide-headless-test}

(require '[etaoin.api :as e])

(def driver (e/chrome {:headless true})) ;; 运行无头Chrome
;; 执行一些操作
(e/quit driver)

或者

// {:test-doc-blocks/test-ns user-guide-headless-test}

(def driver (e/firefox {:headless true})) ;; 运行无头Firefox
;; 你也可以检查驱动程序是否处于无头模式:
(e/headless? driver)
;; => true
(e/wait 1) ;; 在Linux上似乎可以安抚Firefox
(e/quit driver)

有几种快捷方式可以在无头模式下运行Chrome或Firefox:

// {:test-doc-blocks/test-ns user-guide-headless-test}

(def driver (e/chrome-headless))
;; 执行一些操作
(e/quit driver)

;; 或者

(def driver (e/firefox-headless {:log-level :all})) ;; 带有额外设置
;; 执行一些操作
(e/quit driver)

;; 或者

(require '[etaoin.api :as e])

(e/with-chrome-headless driver
  (e/go driver "https://clojure.org"))

(e/with-firefox-headless {:log-level :all} driver ;; 注意额外设置
  (e/go driver "https://clojure.org"))

还有when-headlesswhen-not-headless宏, 它们可以有条件地执行一段命令:

// {:test-doc-blocks/test-ns user-guide-headless-test}

(e/with-chrome driver
  (e/when-not-headless driver
    ;;... 一些在无头模式下可能不可用的操作
    )
  ;;... 适用于两种版本的通用操作
  )

文件下载目录

要指定浏览器下载文件的目录, 可以使用:download-dir选项:

// :test-doc-blocks/skip

(def driver (e/chrome {:download-dir "target/etaoin-play/chrome-downloads"}))
;; 执行一些下载操作
(e/quit driver)

现在, 当你点击下载链接时, 文件将被保存到该文件夹中. 目前仅支持Chrome, Edge和Firefox.

Firefox需要指定应在不显示系统对话框的情况下下载的文件的MIME类型. 默认情况下, 当传递:download-dir参数时, 库会添加最常见的MIME类型: 存档, 媒体文件, 办公文档等. 如果你需要添加自己的类型, 可以通过:prefs选项手动覆盖该Firefox偏好设置:

// :test-doc-blocks/skip

(def driver (e/firefox {:download-dir "target/etaoin-play/firefox-downloads"
                        :prefs {:browser.helperApps.neverAsk.saveToDisk


// :test-doc-blocks/skip
```clojure
                        :prefs {:browser.helperApps.neverAsk.saveToDisk
                                "some-mime/type-1;other-mime/type-2"}}))
;; 执行一些下载操作
(e/quit driver)

要检查在UI测试期间是否有文件被下载, 请参阅<<测试文件下载>>.

管理用户代理

在创建驱动程序时, 可以使用:user-agent选项设置自定义的User-Agent标头, 例如:

(e/with-firefox {:user-agent "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)"}
                driver
  (e/wait 1) ;; 在Linux上似乎可以安抚Firefox
  (e/get-user-agent driver))
;; => "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)"

在使用<<无头模式, 无头浏览器>>时设置此标头很重要, 因为许多网站在User-Agent包含" headless" 字符串时会实施某种拦截. 这可能会导致出现403响应或网站上的一些奇怪行为.

HTTP代理

要设置代理设置, 可以使用环境变量HTTP_PROXY/HTTPS_PROXY, 或者传递如下类型的映射:

// :test-doc-blocks/skip

{:proxy {:http "some.proxy.com:8080"
         :ftp "some.proxy.com:8080"
         :ssl "some.proxy.com:8080"
         :socks {:host "myproxy:1080" :version 5}
         :bypass ["http://this.url" "http://that.url"]
         :pac-url "localhost:8888"}}

;; 示例
(e/chrome {:proxy {:http "some.proxy.com:8080"
                   :ssl "some.proxy.com:8080"}})

注意: :pac-url是用于代理自动配置文件的. 在Safari中使用, 因为其他代理选项在Safari中不起作用.

要微调代理, 可以使用原始的WebDriver代理对象并将其作为功能传递:

// :test-doc-blocks/skip

(e/chrome {:capabilities
           {:proxy
            {:proxyType "manual"
             :proxyAutoconfigUrl "some.proxy.com:8080"
             :ftpProxy "some.proxy.com:8080"
             :httpProxy "some.proxy.com:8080"
             :noProxy ["http://this.url" "http://that.url"]
             :sslProxy "some.proxy.com:8080"
             :socksProxy "some.proxy.com:1080"
             :socksVersion 5}}})

连接到现有正在运行的WebDriver

要连接到现有的WebDriver, 需要指定:host参数.

提示: 当既未指定:host也未指定:webdriver-url参数时, Etaoin将启动一个新的WebDriver进程.

:host可以是主机名(如localhost, some.remote.host.net)或IP地址(如127.0.0.1, 183.102.156.31). 如果未指定端口, 则采用<<驱动程序选项, 默认>>中的:port.

如果指定了:webdriver-url, 则:host:port都会被忽略.

示例:

// :test-doc-blocks/skip

;; 连接到本地主机上端口为9515的现有chromedriver进程
(def driver (e/chrome {:host "127.0.0.1" :port 9515})) ;; 用于连接到本地主机上端口为9515的驱动程序

;; 连接到远程主机上默认端口的现有geckodriver进程
(def driver (e/firefox {:host "192.168.1.11"})) ;; Firefox的默认端口是4444

;; 通过:webdriver-url连接到browserless.io上的Chrome实例
;; (如果要尝试此操作, 请将YOUR-API-TOKEN替换为有效的browserless.io API令牌)
(e/with-chrome {:webdriver-url "https://chrome.browserless.io/webdriver"
                :capabilities {"browserless:token" "YOUR-API-TOKEN"
                               "chromeOptions" {"args" ["--no-sandbox"]}}}
               driver
  (e/go driver "https://en.wikipedia.org/")
  (e/wait-visible driver [{:id :simpleSearch} {:tag :input :name :search}])
  (e/fill driver {:tag :input :name :search} "Clojure programming language")
  (e/fill driver {:tag :input :name :search} k/enter)
  (e/get-title driver))
;; => "Clojure programming language - Search results - Wikipedia"

设置浏览器配置文件

在运行Chrome或Firefox时, 你可以指定一个为测试目的而创建的特殊网页浏览器配置文件. 配置文件是一个保存浏览器设置, 历史记录, 书签和其他用户特定数据的文件夹.

例如, 想象一下你想针对一个关闭了JavaScript执行或图像渲染的用户来运行你的集成测试.

提示: 这只是一个假设的例子. 关闭JavaScript会影响/破坏某些WebDriver功能. 并且它可能会影响某些WebDriver实现, 例如.

在Chrome中创建和查找配置文件

  • 在主窗口的右上角, 点击用户按钮.
  • 在下拉菜单中, 选择" 管理用户" .
  • 点击" 添加用户" , 提交一个名称, 然后点击" 保存" .
  • 新的浏览器窗口将会出现. 现在, 根据你的需要设置新的配置文件.
  • 打开chrome://version/页面. 复制" 配置文件路径" 标题下的文件路径.

在Firefox中创建和查找配置文件

  • 按照官方页面的描述, 使用-P, -p-ProfileManager键运行Firefox.
  • 创建一个新的配置文件并运行浏览器.
  • 根据需要设置配置文件.
  • 打开about:support页面. 在" 配置文件文件夹" 标题附近, 点击" 在访达中显示" 按钮. 一个新的文件夹窗口将会出现. 复制从那里得到的路径.

运行带有配置文件的驱动程序

一旦你得到了配置文件路径, 就可以按照如下方式使用:profile键启动驱动程序:

// :test-doc-blocks/skip

;; Chrome
(def chrome-profile
  "/Users/ivan/Library/Application Support/Google/Chrome/Profile 2/Default")

(def chrome-driver (e/chrome {:profile chrome-profile}))

;; Firefox
(def ff-profile
  "/Users/ivan/Library/Application Support/Firefox/Profiles/iy4iitbg.Test")

(def firefox-driver (e/firefox {:profile ff-profile}))

为你的应用编写和运行集成测试

无头测试

对于持续集成服务来说, 没有显示器是很常见的情况. 对于Linux运行器来说尤其如此.

当在没有显示器的Linux上运行你的测试时, 你有两种选择:

  • 在<<无头模式, 无头>>模式下运行WebDriver.
  • 使用虚拟显示器.

注意: 实际情况是, WebDriver在无头运行时可能会表现出不同的行为.

我们在GitHub Actions上针对Linux进行Etaoin的持续集成测试所使用的技术有:

  • Xvfb - 充当一个X虚拟显示器.
  • fluxbox - 一个轻量级的窗口管理器(geckodriver/Firefox需要它来支持窗口定位操作).

你可以在Etaoin测试脚本中看到我们如何使用这些工具, 但简而言之:

安装:

sudo apt get install -y xvfb fluxbox

提示: 在撰写本文时, Xvfb在GitHub Actions的Linux运行器上是预安装的, 但fluxbox不是.

确保DISPLAY环境变量已设置:

export DISPLAY=:99.0

启动虚拟显示器和fluxbox:

Xvfb :99 -screen 0 1024x768x24 &
fluxbox -display :99 &

测试的Fixture

理想情况下, 你的测试应该相互独立. 实现这一点的一种方法是使用测试Fixture. 夹具的任务是, 对于每个测试:

  1. 创建一个新的驱动程序.
  2. 使用该驱动程序运行测试.
  3. 关闭驱动程序.

可以使用一个动态的+*driver*+变量来保存驱动程序.

// :test-doc-blocks/skip

(ns project.test.integration
  "A module for integration tests"
  (:require [clojure.test :refer [deftest is use-fixtures]]
            [etaoin.api :as e]))

(def ^:dynamic *driver*)

(defn fixture-driver
  "Executes a test running a driver. Bounds a driver
   with the global *driver* variable."
  [f]
  (e/with-chrome [driver]
    (binding [*driver* driver]
      (f))))

(use-fixtures
  :each ;; 为每个测试启动和停止驱动程序
  fixture-driver)

;; 现在声明你的测试

(deftest ^:integration
  test-some-case
  (doto *driver*
    (e/go url-project)
    (e/click :some-button)
    (e/refresh)
   ...
    ))

如果出于某种原因, 你想在所有测试中重用一个单一的驱动程序实例:

// :test-doc-blocks/skip

(ns project.test.integration
  "A module for integration tests"
  (:require [clojure.test :refer [deftest is use-fixtures]]
            [etaoin.api :as e]))

(def ^:dynamic *driver*)

(defn fixture-browser [f]
  (e/with-chrome-headless driver
    (e/disconnect-driver driver)
    (binding [*driver* driver]
      (f))
    (e/connect-driver driver)))

;; 创建一个每次自动清除资源的会话
(defn fixture-clear-browser [f]
  (e/connect-driver *driver*)
  (e/go *driver* "http://google.com")
  (f)
  (e/disconnect-driver *driver*)
)

;; 这在运行测试之前运行一次
(use-fixtures
  :once
  fixture-browser)

;; 这在每次测试之前运行
(use-fixtures
  :each
  fixture-clear-browser)

...some tests

为了更快地进行测试, 你可以使用以下示例:

// :test-doc-blocks/skip

.....

(defn fixture-browser [f]
  (e/with-chrome-headless driver
    (binding [*driver* driver]
      (f))))

;; 注意, 资源(如cookie)是手动删除的,
;; 所以这不能保证测试环境是干净的
(defn fixture-clear-browser [f]
  (e/delete-cookies *driver*)
  (e/go *driver* "http://google.com")
  (f)
)

......

多驱动Fixture

在上述示例中, 我们研究了针对单一类型驱动程序运行测试的情况. 然而, 你可能想在多个驱动程序上测试你的网站, 比如Chrome和Firefox. 在这种情况下, 你的Fixture可能会变得稍微复杂一些:

// :test-doc-blocks/skip

(def driver-type [:firefox :chrome])

(defn fixture-drivers [f]
  (doseq [type driver-types]
    (e/with-driver type {} driver
      (binding [*driver* driver]
        (testing (format "Testing in %s browser" (name type))
          (f))))))

现在, 每个测试将运行两次. 一次针对Firefox, 然后一次针对Chrome. 请注意, 测试调用前会加上testing宏, 它会将驱动程序名称添加到报告中. 当一个测试失败时, 你将知道是哪个驱动程序导致了测试失败.

提示: 请参阅Etaoin的API测试以了解此策略的示例.

事后分析处理程序以收集相关文件

为了在发生异常时保存一些相关文件, 将你的测试主体包裹在with-postmortem处理程序中, 如下所示:

// :test-doc-blocks/skip

(deftest test-user-login
  (e/with-postmortem *driver* {:dir "/path/to/folder"}
    (doto *driver*
      (e/go "http://127.0.0.1:8080")
      (e/click-visible :login)
      ;; 其他任何操作...
      )))

如果在该测试中发生任何异常, 相关文件将被保存.

为了避免复制和粘贴选项映射, 可以在模块顶部声明它. 如果你使用Circle CI, 最好将数据保存到一个特殊的相关文件目录中, 一旦构建完成, 该目录可以作为一个ZIP文件下载:

// :test-doc-blocks/skip

(def pm-dir
  (or (System/getenv "CIRCLE_ARTIFACTS") ;; 你在持续集成环境中
      "/some/local/path"))               ;; 本地机器

(def pm-opt
  {:dir pm-dir})

现在将该映射在各处传递给事后分析处理程序:

// :test-doc-blocks/skip

  ;; 测试声明
  (e/with-postmortem *driver* pm-opt
    ;; 测试主体在此处
    )

当发生错误时, 你将在异常发生时找到浏览器页面的PNG图像以及一个HTML转储文件.

请参阅<<事后分析>>.

按标签运行测试

由于UI测试可能需要很长时间才能通过, 所以分别独立地运行服务器端和UI端测试绝对是一个好的做法.

如果你使用Leiningen, 以下是一些提示.

首先, 将+^:integration+标签添加到所有在浏览器下运行的测试中, 如下所示:

// :test-doc-blocks/skip

(deftest ^:integration
  test-password-reset-pipeline
  (doto *driver*
    (go url-password-reset)
    (click :reset-btn)
    ;; 等等...
  ))

然后, 打开你的project.clj文件并添加测试选择器:

:test-selectors {:default (complement :integration)
                 :integration :integration}

现在, 当你运行lein test时, 你将运行除浏览器集成测试之外的所有测试. 要运行集成测试, 运行lein test :integration.

检查是否有文件已被下载

有时候, 当你点击一个链接或访问一个页面时, 文件会自动下载. 在测试中, 你可能需要确保文件已成功下载. 一个典型的场景如下:

  • 在运行浏览器时提供一个自定义的空下载文件夹(请参阅<<下载目录>>).
  • 点击一个链接或执行任何需要的操作来启动文件下载.
  • 等待一段时间; 对于小文件, 5 - 10秒就足够了.
  • 使用文件API, 扫描该目录并尝试找到一个新文件. 检查它是否匹配正确的扩展名, 名称, 创建日期等.

示例:

// :test-doc-blocks/skip

(require '[clojure.java.io :as io]
         '[clojure.string :as str])

;; 本地辅助函数, 用于检查是否真的是一个Excel文件
(defn xlsx? [file]
  (-> file
     .getAbsolutePath
      (str/ends-with? ".xlsx")))

;; 顶级声明
(def DL-DIR "/Users/ivan/Desktop")
(def driver (e/chrome {:download-dir DL-DIR}))

;; 稍后, 在测试中...
(e/click-visible driver :download-that-application)
(e/wait driver 7) ;; 等待文件已被下载

;; 现在, 扫描目录并尝试找到一个文件:
(let [files (file-seq (io/file DL-DIR))
      found (some xlsx? files)]
  (is found (format "No *.xlsx file found in %s directory." DL-DIR)))

Etaoin 可以播放 Selenium IDE 生成的文件

Selenium IDE 允许您记录 Web 操作以供稍后播放。该工具作为浏览器的可选扩展安装。

一旦安装并激活,Selenium IDE 会将您的操作记录到一个 .side 后缀的 JSON 文件中。您可以保存该文件并使用 Etaoin 运行它。

假设您已经安装了 IDE 并根据 Selenium IDE 文档记录了一些操作。现在,您有了一个 test.side 文件,可以执行如下操作:

(require '[clojure.java.io :as io]
         '[etaoin.api :as e]
         '[etaoin.ide.flow :as flow])

(def driver (e/chrome))

(def ide-file (io/resource "ide/test.side"))

(def opt
    {;; base URL 用于重新定义文件中的 URL,例如,文件是在本地机器上创建的
     ;; (http://localhost:8080),而我们希望在预生产环境中执行场景
     :base-url "https://preprod-001.company.com"

     ;; `:test-..` 和 `:suite-..`(id、ids、name、names)关键字用于选择特定的测试。
     ;; 未指定时,将运行所有测试。示例:

     :test-id "xxxx-xxxx..."         ;; 通过 UUID 选择单个测试
     :test-name "some-test"          ;; 通过名称选择单个测试
     :test-ids ["xxxx-xxxx...", ...] ;; 通过 id 数组选择多个测试
     :test-names ["some-test1", ...] ;; 通过名称数组选择多个测试

     ;; 对于套件 (suite) 也可以同样指定:
     :suite-id    ...
     :suite-name  ...
     :suite-ids   [...]
     :suite-names [...]})

(flow/run-ide-script driver ide-file opt)

有关 IDE 功能的所有内容都在 etaoin.ide 命名空间中。

CLI 参数

您也可以通过命令行运行 .side 脚本。以下是 Clojure 的示例:

clojure -M -m etaoin.ide.main -d firefox -p '{:port 8888}' -r ide/test.side

或者通过 uberjar 运行。在这种情况下,Etaoin 必须是主依赖项,而不是 :dev:test 的依赖项。

java -cp .../project.jar -m etaoin.ide.main -d firefox -p '{:port 8888}' -f ide/test.side

支持的参数如下(使用 clojure -M -m etaoin.ide.main -h 命令查看):

  • -d, --driver-name name :chrome 驱动程序名称,默认为 :chrome
  • -p, --params params {} 驱动程序的参数,表示为 EDN 字符串,例如 '{:port 8080}'
  • -f, --file path 磁盘上的 IDE 文件路径
  • -r, --resource path IDE 资源路径
  • --test-ids ids 逗号分隔的测试 ID
  • --suite-ids ids 逗号分隔的套件 ID
  • --test-names names 逗号分隔的测试名称
  • --suite-names names 逗号分隔的套件名称
  • --base-url url 测试的基 URL
  • -h, --help

注意--params 必须是一个表示 Clojure 映射的 EDN 字符串,与创建驱动程序时传入的映射相同。

请注意,IDE 支持仍处于实验阶段。如果遇到意外行为,请随时提出问题。目前仅支持 Chrome 和 Firefox。

在 Docker 中使用 WebDriver

要在 Docker 中使用 WebDrivers,可以使用现成的镜像:

Chrome 示例

docker run --name chromedriver -p 9515:4444 -d -e CHROMEDRIVER_WHITELISTED_IPS='' robcherry/docker-chromedriver:latest

Firefox 示例

docker run --name geckodriver -p 4444:4444 -d instrumentisto/geckodriver

要连接到正在运行的 WebDriver 进程,需要指定 :host。在此示例中,:hostlocalhost127.0.0.1:port 则为 Docker 暴露的 WebDriver 进程的端口。如果未指定端口,将使用默认端口。

(def driver (e/chrome-headless {:host "localhost" :port 9515 :args ["--no-sandbox"]}))
(def driver (e/firefox-headless {:host "localhost"})) ;; 默认连接到端口 4444

常见问题排查

WebDriver 的支持在不同厂商之间不一致

厂商并不总是完全遵循或实现 W3C WebDriver 规范。web-platform-tests 仪表板是了解厂商对该规范支持情况的好方法。

旧版本 WebDriver 可能有局限性

示例

(e/with-chrome driver
  (e/maximize driver))
;; 异常抛出 "cannot get automation extension"

原因:这是 chromedriver 的一个 bug,已在 v2.28 修复。

解决方案:更新到当前 WebDriver 版本解决了该问题。

新版本 WebDriver 可能引入新 Bug

示例

chromedriver v103 开始偶尔抛出 unknown error: cannot determine loading status\nfrom unknown error: unexpected command response

原因:chromedriver 的一个可能 bug

解决方案:升级到修复该 bug 的新版本。等待新版本时,可以重试失败的测试。


其他常见问题及解决方法

  • Safari 中点击链接无效:可以通过 JavaScript 方式点击。
  • XPath 搜索根节点与当前节点的差异:可使用 . 前缀。
  • 点击不可见元素:不可见元素不能被交互。
  • 选择器无法定位元素:可能原因是窗口切换,可用 switch-window-next 切换窗口。
  • Chrome 非活动窗口中的不可预测错误:保持 Chrome 窗口活动以避免测试失败。
  • Firefox 无法杀死已退出的进程错误:运行时使用日志和 trace 日志级别排查。
  • Chrome 启动时出现 DevToolsActivePort 文件不存在错误:可以尝试使用 --no-sandbox 参数。
Tags: clojure ut