blob: 8a6ee5cb6e59e6c5109dbbfe0555e33c32322841 [file] [view] [edit]
# Tast Codelab: Chrome UI Automation (go/tast-codelab-3)
> This document assumes that you've already gone through [Codelab #1].
This codelab follows the creation of a Tast test that uses the the
chrome.Automation library to change the wallpaper. It goes over the background
of chrome.Automation, how to use it, and some common issues.
[Codelab #1]: codelab_1.md
## Background
The [chrome.automation] library uses the Chrome Accessibility Tree to view and
control the current state of the UI. The Accessibility Tree has access to:
* The Chrome Browser
* The ChromeOS Desktop UI
* ChromeOS packaged apps
* Web Apps/PWAs
That being said, it does not have access to UI elements in containers or VMs
(like ARC and Crostini).
The Accessibility Tree is a collection of nodes that map out the entire desktop.
Accessibility Tree nodes are similar to HTML nodes, but definitely do not map to
HTML nodes. An [Accessibility Node] has many attributes, including but not limited
to:
* ID -> This changes between tests runs and cannot be used in tests.
* [Role]
* Class
* Name -> This is language dependent but often the only unique identifier.
* [Location]
* Parent Node
* Children Nodes
* [States List]
In Tast, [chrome.automation] is wrapped in [chrome/uiauto] and can be imported like so:
```go
import "go.chromium.org/tast-tests/cros/local/chrome/uiauto"
```
[Accessibility Node]: https://chromium.googlesource.com/chromium/src/+/refs/heads/main/extensions/common/api/automation.idl#872
[chrome.automation]: https://chromium.googlesource.com/chromium/src/+/refs/heads/main/extensions/common/api/automation.idl
[chrome/uiauto]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto
[Role]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto/role#Role
[Location]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/coords#Rect
[States List]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto/state#State
## Simple Starter Test
A good starting point for most chrome.Automation tests is to use the
"chromeLoggedIn" fixture and then force the test to fail and give you a
dump of the Accessibility tree. That way you can look at the tree and decide what
node you want to interact with. Here is some sample code:
```go
func init() {
testing.AddTest(&testing.Test{
Func: Change,
Desc: "Follows the user flow to change the wallpaper",
Contacts: []string{
"my-group@chromium.org",
"my-ldap@chromium.org",
},
BugComponent: "b:1034625",
Attr: []string{"group:mainline", "informational"},
SoftwareDeps: []string{"chrome"},
Fixture: "chromeLoggedIn",
})
}
func Change(ctx context.Context, s *testing.State) {
cr := s.FixtValue().(chrome.HasChrome).Chrome()
tconn, err := cr.TestAPIConn(ctx)
if err != nil {
s.Fatal("Failed to create Test API connection: ", err)
}
defer faillog.DumpUITreeOnError(ctx, s.OutDir(), s.HasError, tconn)
// Put test code here.
s.Fatal("I would like a UI dump")
}
```
# Interacting with the Accessibility Tree
After running the test on a device, you should be able to find the UI dump at:
`${CHROMEOS_SRC}/chroot/tmp/tast/results/latest/tests/${TEST_NAME}/faillog/ui_tree.txt`
The tree can be a little complex and unintuitive at times, but it should have
nodes for anything we are looking for.
> Note: You can inspect the standard UI by enabling
chrome://flags/#enable-ui-devtools on your device, going to
chrome://inspect/#other, and clicking inspect under UiDevToolsClient. More
details available [here].
> Note: You can interact directly with chrome.Automation on your device by:
Opening chrome, clicking Test Api Extension(T in top right) > Manage extensions,
Enabling Developer mode toggle, Clicking background page > Console. It has a
[Codelab].
[here]: https://www.chromium.org/developers/how-tos/inspecting-ash/
[Codelab]: https://source.chromium.org/chromiumos/chromiumos/codesearch/+/main:src/third_party/autotest/files/docs/chrome-automation-codelab.md?q=chrome-automation-codelab.md
In this case, we want to start by right clicking on the wallpaper. Looking at
the tree, it looks like we will want to right click
`node id=37 role=unknown state={} parentID=36 childIds=[] className=WallpaperView`.
It looks like its class name is a unique identifier we
can use to find it, so let's find and right click that node:
```go
ui := uiauto.New(tconn)
if err := ui.RightClick(nodewith.ClassName("WallpaperView"))(ctx); err != nil {
s.Fatal("Failed to right click the wallpaper view: ", err)
}
```
Now those few lines are pretty simple, but introduce a lot of library specific information.
Lets break that down some.
Firstly, there is the [nodewith] package that is used to describe a way to find a node.
With it, you can specify things like the [Name("")], [Role(role.Button)], or [Focused()].
A chain of nodes can be defined by using [Ancestor(ancestorNode)].
[nodewith]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto/nodewith
[Name("")]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto/nodewith#Name
[Role(role.Button)]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto/nodewith#Role
[Focused()]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto/nodewith#Focused
[Ancestor(ancestorNode)]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto/nodewith#Ancestor
The a11y tree can sometimes be hard to interact with directly.
From nodes moving around to parts of the tree temporarily disappearing,
this instability can often lead to flakes in tests.
[uiauto.Context] is focused on creating a flake resistant way to interact with a11y tree.
By default, it uses polling to wait for stability before performing actions.
These actions include things like [LeftClick], [WaitUntilExists], and [FocusAndWait].
If for some reason the default polling options do not work for your test case,
you can modify them with [WithTimeout], [WithInterval], and [WithPollOpts].
For example, if we needed a longer timeout to ensure the location was stable before
right clicking, we could write:
```go
ui.WithTimeout(time.Minute).RightClick(nodewith.ClassName("WallpaperView"))
```
[uiauto.Context]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Context
[LeftClick]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Context.LeftClick
[WaitUntilExists]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Context.WaitUntilExists
[FocusAndWait]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Context.FocusAndWait
[WithTimeout]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Context.WithTimeout
[WithInterval]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Context.WithInterval
[WithPollOpts]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Context.WithPollOpts
Finally, you may have noticed the slightly strange syntax `(ctx)` after
`ui.RightClick(nodewith.ClassName("WallpaperView"))`.
This is because `ui.RightClick` returns a `uiauto.Action`.
A [uiauto.Action] is just a `func(context.Context) error`.
It is used to enable easy chaining of multiple actions.
For example, if you wanted to right click a node, left click a different node,
and then wait for a third node to exist, you could write:
```go
if err := ui.RightClick(node1)(ctx); err != nil {
s.Fatal("Failed to right click node1: ", err)
}
if err := ui.LeftClick(node2)(ctx); err != nil {
s.Fatal("Failed to left click node2: ", err)
}
if err := ui.WaitUntilExists(node3)(ctx); err != nil {
s.Fatal("Failed to wait for node3: ", err)
}
```
Or, you could use [uiauto.Combine] to deal with these actions as a group:
```go
if err := uiauto.Combine("do some bigger action",
ui.RightClick(node1),
ui.LeftClick(node2),
ui.WaitUntilExists(node3),
)(ctx); err != nil {
s.Fatal("Failed to do some bigger action: ", err)
}
```
> Note: I generally advise using [uiauto.Combine] if you are doing more
than one action in a row.
[uiauto.Action]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Action
[uiauto.Combine]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Combine
## Dealing With a Race Condition
Now if we look at `ui_tree.txt`, we can see the right click menu:
```
node id=219 role=menu state={"vertical":true} parentID=218 childIds=[220,222,225] className=SubmenuView
node id=220 role=menuItem state={"focusable":true} parentID=219 childIds=[] name=Autohide shelf className=MenuItemView
node id=222 role=menuItem state={"focusable":true} parentID=219 childIds=[] name=Shelf position className=MenuItemView
node id=225 role=menuItem state={"focusable":true} parentID=219 childIds=[] name=Set wallpaper style className=MenuItemView
```
> Note: If you don't see an update to `ui_tree.txt`, you may need to add
`testing.Sleep(time.Second)` before causing the test to fail. Events are
asynchronous and might not immediately update the UI tree.
Next, we want to click on the "Set wallpaper & style" menu item:
```go
if err := ui.LeftClick(nodewith.Name("Set wallpaper style").Role(role.MenuItem))(ctx); err != nil {
s.Fatal(...)
}
```
> Warning: Getting nodes by human-readable name is **strongly discouraged** in general
because it requires to keep updating the UI string and the literal in the test in sync.
We use `nodewith.Name()` to get the menu item only because all the menu items share the
same class name.
When you run the test, depending on the speed of your device and your luck, the
"Set wallpaper & style" menu item may or may not have been clicked. We have just hit a
race condition where the menu may not be fully ready to be clicked by
the time that we try to click it. To fix this, we will simply keep clicking the
menu item until it no longer exists:
```go
personalizeMenu := nodewith.Name("Set wallpaper style").Role(role.MenuItem)
if err := ui.LeftClickUntil(personalizeMenu, ui.Gone(personalizeMenu))(ctx); err != nil {
s.Fatal(...)
}
```
> Note: Most nodes will not have race conditions and do not require this extra
work. The issue is that we do not have a indicator for when the menu
button is ready to be clicked.
After opening the personalization app, we will proceed to the wallpaper subpage to change the wallpaper.
```
changeWallpaperButton := nodewith.Role(role.Button).Name("Change wallpaper")
uiauto.Combine("change the wallpaper",
ui.WaitUntilExists(changeWallpaperButton),
ui.LeftClick(changeWallpaperButton),
)(ctx)
```
## More Basic Interactions
Now that the wallpaper subpage is open, let's set the background to a solid color.
We left click for the node corresponding to the 'Solid colors' tab in `ui_tree.txt`:
```
node id=245 role=genericContainer state={} parentID=243 childIds=[250,251]
node id=250 role=paragraph state={} parentID=245 childIds=[252] name=Solid colors
node id=252 role=staticText state={} parentID=250 childIds=[362] name=Solid colors
node id=362 role=inlineTextBox state={} parentID=252 childIds=[] name=Solid colors
```
```go
if err := ui.LeftClick(nodewith.Name("Solid colors").Role(role.StaticText))(ctx); err != nil {
s.Fatal(...)
}
```
Personally, I am a fan of the 'Deep Purple' background, so that is what I am going
to pick:
```
node id=410 role=listBoxOption state={"focusable":true} parentID=409 childIds=[477] name=Deep Purple
```
```go
if err := ui.LeftClick(nodewith.Name("Deep Purple").Role(role.ListBoxOption))(ctx); err != nil {
s.Fatal(...)
}
```
## Scrolling to Target
We found the above code fails to find the "Deep Purple" node on some device
models. We examined and found that the "Solid color" list item was not visible
without scrolling. This could be verified either by seeing the DUT screen or
by seeing the node having "offscreen" state true:
```
node id=252 role=staticText state={"offscreen":true} parentID=250 childIds=[362] name=Solid colors
node id=362 role=inlineTextBox state={"offscreen":true} parentID=252 childIds=[] name=Solid colors
```
This happened due to different screen sizes of devices, which affects the
window size. In order to make this test more robust, we need to make the item
visible before clicking:
```go
if err := ui.MakeVisible(nodewith.Name("Solid colors").Role(role.StaticText))(ctx); err != nil {
s.Fatal(...)
}
// same as the previsous section
if err := ui.LeftClick(nodewith.Name("Solid colors").Role(role.StaticText))(ctx); err != nil {
s.Fatal(...)
}
```
However, there is still a race with this. The list items are loaded
asynchronously. (You may be able to see only the first item is shown in the
list and then the others are loaded few seconds later.)
So the item may not exist in the accessibility tree yet, right after previous
step. Therefore we will wait until the item appears:
```go
solidColorsMenu := nodewith.Name("Solid colors").Role(role.StaticText)
if err := ui.WaitUntilExists(solidColorsMenu)(ctx); err != nil {
s.Fatal(...)
}
if err := ui.MakeVisible(solidColorsMenu)(ctx); err != nil {
s.Fatal(...)
}
if err := ui.LeftClick(solidColorsMenu)(ctx); err != nil {
s.Fatal(...)
}
```
Note that `ui.LeftClick` has integrated logic to wait until the target is
stable (i.e. exists and its position kept unchanged) but `MakeVisible`
doesn't.
## Ensuring the Background Changed
Checking that a test succeeded can often be harder than expected. In this case,
we have to decide what demonstrates a successful wallpaper change. A good solution
would probably be to check a pixel in the background and make sure it is the
same color as deep purple. Sadly, that is not currently easy to do in Tast. A
simpler solution for now is to check for the text 'Deep Purple' in the heading
because the wallpaper picker displays the name of the currently selected wallpaper:
```
node id=109 role=heading state={} parentID=34 childIds=[] name=Currently set Deep Purple
```
```go
if err := ui.WaitUntilExists(nodewith.NameContaining("Deep Purple").Role(role.Heading))(ctx); err != nil {
s.Fatal(...)
}
```
## Full Code
> Note: The code below is using [uiauto.Combine] to simplify all of the steps above into
one chain of operations.
[uiauto.Combine]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/chromiumos/tast/local/chrome/uiauto#Combine
```go
// Copyright <copyright_year> The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package wallpaper
import (
"context"
"time"
"go.chromium.org/tast-tests/cros/local/chrome"
"go.chromium.org/tast-tests/cros/local/chrome/uiauto"
"go.chromium.org/tast-tests/cros/local/chrome/uiauto/faillog"
"go.chromium.org/tast-tests/cros/local/chrome/uiauto/nodewith"
"go.chromium.org/tast-tests/cros/local/chrome/uiauto/role"
"go.chromium.org/tast/core/testing"
)
func init() {
testing.AddTest(&testing.Test{
Func: Change,
Desc: "Follows the user flow to change the wallpaper",
Contacts: []string{
"chromeos-sw-engprod@google.com",
},
BugComponent: "b:1034625",
Attr: []string{"group:mainline", "informational"},
SoftwareDeps: []string{"chrome"},
Fixture: "chromeLoggedIn",
})
}
func Change(ctx context.Context, s *testing.State) {
cr := s.FixtValue().(chrome.HasChrome).Chrome()
tconn, err := cr.TestAPIConn(ctx)
if err != nil {
s.Fatal("Failed to create Test API connection: ", err)
}
defer faillog.DumpUITreeOnError(ctx, s.OutDir(), s.HasError, tconn)
ui := uiauto.New(tconn)
personalizeMenu := nodewith.Name("Set wallpaper style").Role(role.MenuItem)
changeWallpaperButton := nodewith.Role(role.Button).Name("Change wallpaper")
solidColorsMenu := nodewith.Name("Solid colors").Role(role.StaticText)
if err := uiauto.Combine("change the wallpaper",
ui.RightClick(nodewith.ClassName("WallpaperView")),
// This button takes a bit before it is clickable.
// Keep clicking it until the click is received and the menu closes.
ui.WithInterval(1*time.Second).LeftClickUntil(personalizeMenu, ui.Gone(personalizeMenu)),
ui.Exists(nodewith.NameContaining("Wallpaper & style").Role(role.Window).First()),
ui.WaitUntilExists(changeWallpaperButton),
ui.LeftClick(changeWallpaperButton),
ui.WaitUntilExists(solidColorsMenu),
ui.MakeVisible(solidColorsMenu),
ui.LeftClick(solidColorsMenu),
ui.LeftClick(nodewith.Name("Deep Purple").Role(role.ListBoxOption)),
// Ensure that "Deep Purple" text is displayed.
// The UI displays the name of the currently set wallpaper.
ui.WaitUntilExists(nodewith.NameContaining("Deep Purple").Role(role.Heading)),
)(ctx); err != nil {
s.Fatal("Failed to change the wallpaper: ", err)
}
}
```