Skip to content

Automation(自动化) ​

automation 插件(plugins/automation)让另一个进程驱动一个正在运行的应用: 端到端测试运行器或代理列出它的窗口,把它们的原生 UI 显示的内容作为元素读取, 对它们执行动作,发送指针、滚轮、按键和文本输入,选择应用菜单的项,在页面中求值 脚本,把窗口捕获为 PNG 图像,并等待它们稳定下来。它在 macOS、Linux 和 Windows 上说同一种协议:Unix 域套接字上的按行分隔的 JSON。

元素是 MyGo 提供给辅助技术的那些,因此客户端看到屏幕阅读器所看到的,并像它 那样行动,使用引擎的 ID 和应用给元素的 Key。ui.Tester 仍然是在 go test 内测试一个视图的方式;这个插件是为构建好的应用、从外部 使用的。

开启 ​

应用通过导入该包选择加入。用一个构建标签把它挡在应用的发布版之外,放在它自己的 文件里:

go
//go:build e2e

package main

import _ "github.com/egoist/mygo/plugins/automation"
sh
go build -tags e2e -o app .
MYGO_AUTOMATION=/tmp/app.sock ./app

只有当 MYGO_AUTOMATION 指定套接字的路径时它才监听,否则什么都不做。不带 原生 UI 检查器的构建把它排除在外,就像排除检查器一样:go build -tags mygo_noinspector 和 mygo build 的发布构建,除非 -debug 或 MYGO_INSPECTOR=1 保留检查器。这样的构建即使导入了它也忽略 MYGO_AUTOMATION,并在它的标准错误上说明。

由一个未关闭套接字就退出的应用留在路径上的套接字会被替换;另一个应用正在监听的 不会,不是套接字的文件也不会。应用退出时移除它的套接字。

协议 ​

客户端连接到套接字并写入请求,每行一个 JSON 对象。应用在自己的行上按顺序回答 每一个,每个请求在前一个回答完毕之后才回答;希望请求并行运行的客户端(例如一个 窗口在 idle 而另一个在行动),打开更多连接。

json
{"id": 1, "method": "tree", "params": {"window": 1}}

id 是任何 JSON 值,回答会重复它。回答有一个 result,或一个带可测试的 code 和可阅读的 message 的 error:

json
{"id": 1, "result": {"window": 1, "nodes": [...]}}
{"id": 2, "error": {"code": "no_window", "message": "no window 7"}}
代码含义
parse_error该行不是一个 JSON 对象(此时它的 id 为 null)。
invalid_request没有方法、方法不接受的参数,或它无法使用的值。
unknown_method没有这个方法。
no_window没有窗口拥有该 ID,或应用没有窗口。
no_element窗口的最后一帧中没有元素拥有该 ID 或键。
unsupported窗口做不到,例如读取页面的元素。
timeoutidle 在它的 timeout 之后放弃。
failed应用做不到,例如一个抛出了异常的脚本。

除 windows 之外的每个方法都接受一个 window,即 windows 列出的 ID;省略 时,它是聚焦的窗口,否则是第一个。坐标和大小以 DIP 计,相对于窗口的内容,如同 元素的边界。

方法参数结果
windows{windows: [{id, title, kind, url?, bounds, contentBounds, focused, visible}]}
treewindow{window, focus?, nodes: [...], announcements?}
actwindow、id 或 key、action、text?、from?、to?{}
inputwindow、events{}
menuwindow、path{}
capturewindow{png, width, height}
evalwindow、script{value}
idlewindow、timeout?{}

windows ​

列出打开的窗口。kind 对原生 UI 的窗口是 content,对显示 Web 页面的窗口是 page,后者给出它的 url;bounds 是窗口在屏幕上的框架,contentBounds 是它的内容的,各为 {x, y, width, height}。

json
{"id": 1, "method": "windows"}
{"id": 1, "result": {"windows": [{"id": 1, "title": "Counter", "kind": "content", "bounds": {"x": 440, "y": 362, "width": 400, "height": 300}, "contentBounds": {"x": 440, "y": 387, "width": 400, "height": 275}, "focused": true, "visible": true}]}}

tree ​

窗口的最后一帧的元素,按树顺序,父在子之前:

json
{"id": 2, "method": "tree", "params": {"window": 1}}
{"id": 2, "result": {"window": 1, "focus": "5878896818624359798", "nodes": [
  {"id": "15956256791619451359", "role": "text", "label": "Count: 0", "bounds": {"x": 20, "y": 20, "width": 360, "height": 18}},
  {"id": "5424175759328386547", "role": "button", "key": "add", "label": "Add", "bounds": {"x": 20, "y": 48, "width": 360, "height": 32}, "states": ["focusable"], "actions": ["press", "focus"]},
  {"id": "5878896818624359798", "role": "textField", "key": "name", "label": "Name", "value": "Ada", "bounds": {"x": 20, "y": 90, "width": 360, "height": 32}, "states": ["focusable"], "actions": ["focus", "setValue"], "selection": [3, 3]}
]}}

一个节点有它的 id、除非它在顶部否则 parent 的 ID、它的 role,以及它拥有 的 key、label、value、placeholder、description、bounds、 states、actions、min、max、now 和 step(滑块、进度条、计量条和 步进器的)、selection(文本字段的,以 rune 计)、level、posInSet 和 setSize。ID 是数字字符串:它们是 64 位的,JavaScript 的数字会把它们舍入。

key 是元素的 Key,用 Element.Key 给出,或者对于在创建之前就取用其 Key 的控件,用 Context.Key:字符串、布尔或整数按原样,其他值按 Go 打印的。与一 个可能随帧改变的 ID 不同,它是应用自己给元素起的名字,旨在持久;给测试要查找的 元素它们自己的键:

go
c.Key("name")
ui.TextInput(c, &name).Label("Name")
ui.Button(c, "Add").Key("add")

只有辅助技术读取的元素在树中:一个只用于布局其他元素的行或列没有节点,无论有没有 键。角色有 group、text、button、link、checkBox、radio、 switch、slider、progress、textField、image、list、scroll、 dialog、popup、tooltip、popUpButton、tabList、tab、 splitter、status、table、row、cell、columnHeader、tree、 treeItem、listItem、menuButton、toolbar、radioGroup、 toggleButton、comboBox、disclosure、meter、stepper、colorWell、 alertDialog、menu、menuBar、menuItem、menuItemCheckBox、 menuItemRadio 和 heading。状态有 focusable、disabled、checked、 mixed、selected、expanded、multiline、password、readOnly、 selectable、offscreen、multiselectable、segment、search、 invalid、sortAscending、sortDescending、expandable 和 vertical。

原生 UI 中的一个 Web 视图显示为一个标记 webView 的节点,不带它的页面的元素。 显示 Web 页面的窗口没有树:tree 回答 unsupported,客户端用 eval 读取 页面。

act ​

对一个元素执行一个动作,如同辅助技术所做:元素按它的 id 找到,或当恰好一个 元素拥有它时按它的 key。

json
{"id": 3, "method": "act", "params": {"window": 1, "key": "add", "action": "press"}}
{"id": 4, "method": "act", "params": {"window": 1, "key": "name", "action": "setValue", "text": "Grace"}}
动作作用
press点击按钮、勾选复选框、打开菜单按钮。
focus把键盘焦点给该元素。
increment、decrement步进一个滑块或步进器。
setValue用 text 替换文本字段的文本。
insertText在文本字段的光标处键入 text。
selectText选中文本字段从 from 到 to 的 rune。
scrollIntoView滚动元素周围的容器以显示它。
expand、collapse打开或关闭树的一个项。

input ​

按顺序把事件发送到窗口的内容,通过系统事件所走的路径:

json
{"id": 5, "method": "input", "params": {"window": 1, "events": [
  {"type": "click", "x": 200, "y": 99},
  {"type": "text", "text": "Grace"},
  {"type": "key", "key": "CmdOrCtrl+A"}
]}}
类型字段
pointerMovex、y、mods?
pointerDown、pointerUpx、y、button?、clicks?、mods?
clickx、y、button?、clicks?、mods?:一次移动,然后每次点击一次按下和一次释放
wheelx、y、dx、dy、precise?、mods?:按 dx、dy DIP 滚动,precise 时如同触控板
keyDown、keyUp、keykey:一个键及其修饰键,按菜单快捷键的写法,例如 Enter、Shift+Tab 或 CmdOrCtrl+Z;key 按下并释放它
texttext:键入的文本,或由输入法提交的文本

button 是 left(默认)、right 或 middle;clicks 统计双击或三击的 按下次数;mods 列出 shift、ctrl、alt、super 和 cmdOrCtrl。一个键 事件不输入文本:用 text 事件发送文本,如同后端对一个会输入文本的键发送两者。

内容在一个请求的事件之间一个接一个处理它们,在绘制它的下一帧之前,如同它们一起 到来的事件。当一个事件依赖于更早的事件显示的东西(例如在一个点击聚焦的字段中 键入),在中间用 idle 等待。输入发送到原生 UI 的窗口;页面回答 unsupported,客户端用 eval 驱动它们。

沿着它的标签路径选择应用菜单的一个项,如同菜单栏所做,并在该项的 Click 返回 后回答:

json
{"id": 6, "method": "menu", "params": {"path": ["File", "New Window"]}}

在 Linux 和 Windows 上,它从窗口的菜单栏选择,菜单栏显示应用菜单。Dock 的菜 单和托盘图标的菜单,以及打开的上下文菜单,不包括在内:系统在自己的循环中显示上下 文菜单,插件无法在每个平台上从中选择,所以不要用 input 的右键打开一个。用 ui.Tester 测试它们,它的 RightClick 和 ChooseMenuItem 打开并从视图的上下文菜单中选择。

capture ​

窗口作为 PNG 图像,base64 编码,width × height 设备像素:按原样渲染的原 生 UI,或页面的截图。

eval ​

在一个显示页面的窗口的页面中求值 JavaScript,如同 Page.Eval 所做:一个表达 式的值(Promise 会被等待),或语句 return 的内容。

json
{"id": 7, "method": "eval", "params": {"window": 2, "script": "document.title"}}
{"id": 7, "result": {"value": "Todo"}}

idle ​

在窗口稳定后回答,以便客户端等待 UI 而不是睡眠:对原生 UI,当内容不再请求它尚未 绘制的帧时,如同 Tester.FramePending 所述;对页面,当它已加载并回答了一个脚本 后。它不等待 Context.After 或 Painter.After 之后要求的任何东西,如同测试器 的帧不等。永不稳定的内容(例如一个持续运动的指示器)永不回答:给出 timeout (毫秒),让它以 timeout 失败代替。

json
{"id": 8, "method": "idle", "params": {"window": 1, "timeout": 5000}}

一个客户端 ​

任何能打开 Unix 域套接字的语言都可以作为客户端。一个 Go 测试:

go
conn, err := net.Dial("unix", "/tmp/app.sock")
if err != nil {
	t.Fatal(err)
}
r := bufio.NewReader(conn)
call := func(method string, params any) json.RawMessage {
	line, _ := json.Marshal(map[string]any{"id": 1, "method": method, "params": params})
	conn.Write(append(line, '\n'))
	answer, _ := r.ReadBytes('\n')
	var res struct {
		Result json.RawMessage
		Error  *struct{ Code, Message string }
	}
	json.Unmarshal(answer, &res)
	if res.Error != nil {
		t.Fatalf("%s: %s", method, res.Error.Message)
	}
	return res.Result
}
call("act", map[string]any{"key": "add", "action": "press"})
call("idle", nil)
tree := call("tree", nil)

以及在 shell 中,用 socat:

sh
echo '{"id": 1, "method": "windows"}' | socat - UNIX-CONNECT:/tmp/app.sock

安全 ​

任何打开套接字的人都能像它的用户那样驱动应用,并在它的页面中运行脚本:把你发布的 构建排除该插件。它是双重选择加入的:应用只有通过导入它才链接它(用一个构建标签把 它限制在测试构建),并且只有当设置了 MYGO_AUTOMATION 时才监听。不带检查器的 构建无论导入什么都把它排除。

套接字只属于用户:在 macOS 和 Linux 上以 0600 模式创建,在 Windows 上(套接 字的文件没有模式)它继承其目录的访问控制列表,在用户的配置目录和临时目录中该列表只 属于用户。把它放在只有用户可写的目录中,不要放在与他人共享的目录。

平台 ​

Windows 需要 Windows 10 1803 或更高版本,它有 Unix 域套接字。浏览器 (GOOS=js)没有套接字:该插件在那里可以编译,但不监听任何地方。