github.com/nya3jp/tast@v0.0.0-20230601000426-85c8e4d83a9b/docs/codelab_3.md (about) 1 # Tast Codelab: Chrome UI Automation (go/tast-codelab-3) 2 3 > This document assumes that you've already gone through [Codelab #1]. 4 5 This codelab follows the creation of a Tast test that uses the the 6 chrome.Automation library to change the wallpaper. It goes over the background 7 of chrome.Automation, how to use it, and some common issues. 8 9 [Codelab #1]: codelab_1.md 10 11 12 ## Background 13 14 The [chrome.automation] library uses the Chrome Accessibility Tree to view and 15 control the current state of the UI. The Accessibility Tree has access to: 16 * The Chrome Browser 17 * The ChromeOS Desktop UI 18 * ChromeOS packaged apps 19 * Web Apps/PWAs 20 21 That being said, it does not have access to UI elements in containers or VMs 22 (like ARC and Crostini). 23 24 The Accessibility Tree is a collection of nodes that map out the entire desktop. 25 Accessibility Tree nodes are similar to HTML nodes, but definitely do not map to 26 HTML nodes. An [Accessibility Node] has many attributes, including but not limited 27 to: 28 * ID -> This changes between tests runs and cannot be used in tests. 29 * [Role] 30 * Class 31 * Name -> This is language dependent but often the only unique identifier. 32 * [Location] 33 * Parent Node 34 * Children Nodes 35 * [States List] 36 37 In Tast, [chrome.automation] is wrapped in [chrome/uiauto] and can be imported like so: 38 ```go 39 import "go.chromium.org/tast-tests/cros/local/chrome/uiauto" 40 ``` 41 [Accessibility Node]: https://developer.chrome.com/docs/extensions/reference/automation/#type-AutomationNode 42 [chrome.automation]: https://developer.chrome.com/docs/extensions/reference/automation/ 43 [chrome/uiauto]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto 44 [Role]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto/role#Role 45 [Location]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/coords#Rect 46 [States List]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto/state#State 47 48 49 ## Simple Starter Test 50 51 A good starting point for most chrome.Automation tests is to use the 52 "chromeLoggedIn" fixture and then force the test to fail and give you a 53 dump of the Accessibility tree. That way you can look at the tree and decide what 54 node you want to interact with. Here is some sample code: 55 ```go 56 func init() { 57 testing.AddTest(&testing.Test{ 58 Func: Change, 59 Desc: "Follows the user flow to change the wallpaper", 60 Contacts: []string{ 61 "my-group@chromium.org", 62 "my-ldap@chromium.org", 63 }, 64 BugComponent: "b:1034625", 65 Attr: []string{"group:mainline", "informational"}, 66 SoftwareDeps: []string{"chrome"}, 67 Fixture: "chromeLoggedIn", 68 }) 69 } 70 71 func Change(ctx context.Context, s *testing.State) { 72 cr := s.FixtValue().(*chrome.Chrome) 73 tconn, err := cr.TestAPIConn(ctx) 74 if err != nil { 75 s.Fatal("Failed to create Test API connection: ", err) 76 } 77 defer faillog.DumpUITreeOnError(ctx, s.OutDir(), s.HasError, tconn) 78 79 // Put test code here. 80 81 s.Fatal("I would like a UI dump") 82 } 83 ``` 84 85 # Interacting with the Accessibility Tree 86 87 After running the test on a device, you should be able to find the UI dump at: 88 `${CHROMEOS_SRC}/chroot/tmp/tast/results/latest/tests/${TEST_NAME}/faillog/ui_tree.txt` 89 90 The tree can be a little complex and unintuitive at times, but it should have 91 nodes for anything we are looking for. 92 93 > Note: You can inspect the standard UI by enabling 94 chrome://flags/#enable-ui-devtools on your device, going to 95 chrome://inspect/#other, and clicking inspect under UiDevToolsClient. More 96 details available [here]. 97 98 > Note: You can interact directly with chrome.Automation on your device by: 99 Opening chrome, clicking Test Api Extension(T in top right) > Manage extensions, 100 Enabling Developer mode toggle, Clicking background page > Console. It has a 101 [Codelab]. 102 103 [here]: https://sites.google.com/a/chromium.org/dev/developers/how-tos/inspecting-ash 104 [Codelab]: https://source.chromium.org/chromiumos/chromiumos/codesearch/+/main:src/third_party/autotest/files/docs/chrome-automation-codelab.md?q=chrome-automation-codelab.md 105 106 107 108 In this case, we want to start by right clicking on the wallpaper. Looking at 109 the tree, it looks like we will want to right click 110 `node id=37 role=unknown state={} parentID=36 childIds=[] className=WallpaperView`. 111 It looks like its class name is a unique identifier we 112 can use to find it, so let's find and right click that node: 113 ```go 114 ui := uiauto.New(tconn) 115 if err := ui.RightClick(nodewith.ClassName("WallpaperView"))(ctx); err != nil { 116 s.Fatal("Failed to right click the wallpaper view: ", err) 117 } 118 ``` 119 Now those few lines are pretty simple, but introduce a lot of library specific information. 120 Lets break that down some. 121 122 Firstly, there is the [nodewith] package that is used to describe a way to find a node. 123 With it, you can specify things like the [Name("")], [Role(role.Button)], or [Focused()]. 124 A chain of nodes can be defined by using [Ancestor(ancestorNode)]. 125 126 [nodewith]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto/nodewith 127 [Name("")]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto/nodewith#Name 128 [Role(role.Button)]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto/nodewith#Role 129 [Focused()]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto/nodewith#Focused 130 [Ancestor(ancestorNode)]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto/nodewith#Ancestor 131 132 The a11y tree can sometimes be hard to interact with directly. 133 From nodes moving around to parts of the tree temporarily disappearing, 134 this instability can often lead to flakes in tests. 135 [uiauto.Context] is focused on creating a flake resistant way to interact with a11y tree. 136 By default, it uses polling to wait for stability before performing actions. 137 These actions include things like [LeftClick], [WaitUntilExists], and [FocusAndWait]. 138 If for some reason the default polling options do not work for your test case, 139 you can modify them with [WithTimeout], [WithInterval], and [WithPollOpts]. 140 For example, if we needed a longer timeout to ensure the location was stable before 141 right clicking, we could write: 142 ```go 143 ui.WithTimeout(time.Minute).RightClick(nodewith.ClassName("WallpaperView")) 144 ``` 145 146 [uiauto.Context]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Context 147 [LeftClick]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Context.LeftClick 148 [WaitUntilExists]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Context.WaitUntilExists 149 [FocusAndWait]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Context.FocusAndWait 150 [WithTimeout]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Context.WithTimeout 151 [WithInterval]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Context.WithInterval 152 [WithPollOpts]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Context.WithPollOpts 153 154 Finally, you may have noticed the slightly strange syntax `(ctx)` after 155 `ui.RightClick(nodewith.ClassName("WallpaperView"))`. 156 This is because `ui.RightClick` returns a `uiauto.Action`. 157 A [uiauto.Action] is just a `func(context.Context) error`. 158 It is used to enable easy chaining of multiple actions. 159 For example, if you wanted to right click a node, left click a different node, 160 and then wait for a third node to exist, you could write: 161 ```go 162 if err := ui.RightClick(node1)(ctx); err != nil { 163 s.Fatal("Failed to right click node1: ", err) 164 } 165 if err := ui.LeftClick(node2)(ctx); err != nil { 166 s.Fatal("Failed to left click node2: ", err) 167 } 168 if err := ui.WaitUntilExists(node3)(ctx); err != nil { 169 s.Fatal("Failed to wait for node3: ", err) 170 } 171 ``` 172 Or, you could use [uiauto.Combine] to deal with these actions as a group: 173 ```go 174 if err := uiauto.Combine("do some bigger action", 175 ui.RightClick(node1), 176 ui.LeftClick(node2), 177 ui.WaitUntilExists(node3), 178 )(ctx); err != nil { 179 s.Fatal("Failed to do some bigger action: ", err) 180 } 181 ``` 182 183 > Note: I generally advise using [uiauto.Combine] if you are doing more 184 than one action in a row. 185 186 [uiauto.Action]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Action 187 [uiauto.Combine]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Combine 188 189 ## Dealing With a Race Condition 190 191 Now if we look at `ui_tree.txt`, we can see the right click menu: 192 ``` 193 node id=219 role=menu state={"vertical":true} parentID=218 childIds=[220,222,225] className=SubmenuView 194 node id=220 role=menuItem state={"focusable":true} parentID=219 childIds=[] name=Autohide shelf className=MenuItemView 195 node id=222 role=menuItem state={"focusable":true} parentID=219 childIds=[] name=Shelf position className=MenuItemView 196 node id=225 role=menuItem state={"focusable":true} parentID=219 childIds=[] name=Set wallpaper style className=MenuItemView 197 ``` 198 199 > Note: If you don't see an update to `ui_tree.txt`, you may need to add 200 `testing.Sleep(time.Second)` before causing the test to fail. Events are 201 asynchronous and might not immediately update the UI tree. 202 203 Next, we want to click on the "Set wallpaper & style" menu item: 204 ```go 205 if err := ui.LeftClick(nodewith.Name("Set wallpaper style").Role(role.MenuItem))(ctx); err != nil { 206 s.Fatal(...) 207 } 208 ``` 209 > Warning: Getting nodes by human-readable name is **strongly discouraged** in general 210 because it requires to keep updating the UI string and the literal in the test in sync. 211 We use `nodewith.Name()` to get the menu item only because all the menu items share the 212 same class name. 213 214 When you run the test, depending on the speed of your device and your luck, the 215 "Set wallpaper & style" menu item may or may not have been clicked. We have just hit a 216 race condition where the menu may not be fully ready to be clicked by 217 the time that we try to click it. To fix this, we will simply keep clicking the 218 menu item until it no longer exists: 219 ```go 220 personalizeMenu := nodewith.Name("Set wallpaper style").Role(role.MenuItem) 221 if err := ui.LeftClickUntil(personalizeMenu, ui.Gone(personalizeMenu))(ctx); err != nil { 222 s.Fatal(...) 223 } 224 ``` 225 226 > Note: Most nodes will not have race conditions and do not require this extra 227 work. The issue is that we do not have a indicator for when the menu 228 button is ready to be clicked. 229 230 After opening the personalization app, we will proceed to the wallpaper subpage to change the wallpaper. 231 ``` 232 changeWallpaperButton := nodewith.Role(role.Button).Name("Change wallpaper") 233 uiauto.Combine("change the wallpaper", 234 ui.WaitUntilExists(changeWallpaperButton), 235 ui.LeftClick(changeWallpaperButton), 236 )(ctx) 237 ``` 238 239 ## More Basic Interactions 240 241 Now that the wallpaper subpage is open, let's set the background to a solid color. 242 We left click for the node corresponding to the 'Solid colors' tab in `ui_tree.txt`: 243 ``` 244 node id=245 role=genericContainer state={} parentID=243 childIds=[250,251] 245 node id=250 role=paragraph state={} parentID=245 childIds=[252] name=Solid colors 246 node id=252 role=staticText state={} parentID=250 childIds=[362] name=Solid colors 247 node id=362 role=inlineTextBox state={} parentID=252 childIds=[] name=Solid colors 248 ``` 249 ```go 250 if err := ui.LeftClick(nodewith.Name("Solid colors").Role(role.StaticText))(ctx); err != nil { 251 s.Fatal(...) 252 } 253 ``` 254 255 Personally, I am a fan of the 'Deep Purple' background, so that is what I am going 256 to pick: 257 ``` 258 node id=410 role=listBoxOption state={"focusable":true} parentID=409 childIds=[477] name=Deep Purple 259 ``` 260 ```go 261 if err := ui.LeftClick(nodewith.Name("Deep Purple").Role(role.ListBoxOption))(ctx); err != nil { 262 s.Fatal(...) 263 } 264 ``` 265 266 ## Scrolling to Target 267 268 We found the above code fails to find the "Deep Purple" node on some device 269 models. We examined and found that the "Solid color" list item was not visible 270 without scrolling. This could be verified either by seeing the DUT screen or 271 by seeing the node having "offscreen" state true: 272 ``` 273 node id=252 role=staticText state={"offscreen":true} parentID=250 childIds=[362] name=Solid colors 274 node id=362 role=inlineTextBox state={"offscreen":true} parentID=252 childIds=[] name=Solid colors 275 ``` 276 277 This happened due to different screen sizes of devices, which affects the 278 window size. In order to make this test more robust, we need to make the item 279 visible before clicking: 280 281 ```go 282 if err := ui.MakeVisible(nodewith.Name("Solid colors").Role(role.StaticText))(ctx); err != nil { 283 s.Fatal(...) 284 } 285 // same as the previsous section 286 if err := ui.LeftClick(nodewith.Name("Solid colors").Role(role.StaticText))(ctx); err != nil { 287 s.Fatal(...) 288 } 289 ``` 290 291 However, there is still a race with this. The list items are loaded 292 asynchronously. (You may be able to see only the first item is shown in the 293 list and then the others are loaded few seconds later.) 294 So the item may not exist in the accessibility tree yet, right after previous 295 step. Therefore we will wait until the item appears: 296 297 ```go 298 solidColorsMenu := nodewith.Name("Solid colors").Role(role.StaticText) 299 if err := ui.WaitUntilExists(solidColorsMenu)(ctx); err != nil { 300 s.Fatal(...) 301 } 302 if err := ui.MakeVisible(solidColorsMenu)(ctx); err != nil { 303 s.Fatal(...) 304 } 305 if err := ui.LeftClick(solidColorsMenu)(ctx); err != nil { 306 s.Fatal(...) 307 } 308 ``` 309 310 Note that `ui.LeftClick` has integrated logic to wait until the target is 311 stable (i.e. exists and its position kept unchanged) but `MakeVisible` 312 doesn't. 313 314 ## Ensuring the Background Changed 315 316 Checking that a test succeeded can often be harder than expected. In this case, 317 we have to decide what demonstrates a successful wallpaper change. A good solution 318 would probably be to check a pixel in the background and make sure it is the 319 same color as deep purple. Sadly, that is not currently easy to do in Tast. A 320 simpler solution for now is to check for the text 'Deep Purple' in the heading 321 because the wallpaper picker displays the name of the currently selected wallpaper: 322 ``` 323 node id=109 role=heading state={} parentID=34 childIds=[] name=Currently set Deep Purple 324 ``` 325 ```go 326 if err := ui.WaitUntilExists(nodewith.NameContaining("Deep Purple").Role(role.Heading))(ctx); err != nil { 327 s.Fatal(...) 328 } 329 ``` 330 331 ## Full Code 332 333 > Note: The code below is using [uiauto.Combine] to simplify all of the steps above into 334 one chain of operations. 335 336 [uiauto.Combine]: https://pkg.go.dev/chromium.googlesource.com/chromiumos/platform/tast-tests.git/src/go.chromium.org/tast-tests/cros/local/chrome/uiauto#Combine 337 338 ```go 339 // Copyright <copyright_year> The ChromiumOS Authors 340 // Use of this source code is governed by a BSD-style license that can be 341 // found in the LICENSE file. 342 343 package wallpaper 344 345 import ( 346 "context" 347 "time" 348 349 "go.chromium.org/tast-tests/cros/local/chrome" 350 "go.chromium.org/tast-tests/cros/local/chrome/uiauto" 351 "go.chromium.org/tast-tests/cros/local/chrome/uiauto/faillog" 352 "go.chromium.org/tast-tests/cros/local/chrome/uiauto/nodewith" 353 "go.chromium.org/tast-tests/cros/local/chrome/uiauto/role" 354 "go.chromium.org/tast/core/testing" 355 ) 356 357 func init() { 358 testing.AddTest(&testing.Test{ 359 Func: Change, 360 LacrosStatus: testing.LacrosVariantUnknown, 361 Desc: "Follows the user flow to change the wallpaper", 362 Contacts: []string{ 363 "chromeos-sw-engprod@google.com", 364 }, 365 BugComponent: "b:1034625", 366 Attr: []string{"group:mainline", "informational"}, 367 SoftwareDeps: []string{"chrome"}, 368 Fixture: "chromeLoggedIn", 369 }) 370 } 371 372 func Change(ctx context.Context, s *testing.State) { 373 cr := s.FixtValue().(*chrome.Chrome) 374 tconn, err := cr.TestAPIConn(ctx) 375 if err != nil { 376 s.Fatal("Failed to create Test API connection: ", err) 377 } 378 defer faillog.DumpUITreeOnError(ctx, s.OutDir(), s.HasError, tconn) 379 380 ui := uiauto.New(tconn) 381 personalizeMenu := nodewith.Name("Set wallpaper style").Role(role.MenuItem) 382 changeWallpaperButton := nodewith.Role(role.Button).Name("Change wallpaper") 383 solidColorsMenu := nodewith.Name("Solid colors").Role(role.StaticText) 384 if err := uiauto.Combine("change the wallpaper", 385 ui.RightClick(nodewith.ClassName("WallpaperView")), 386 // This button takes a bit before it is clickable. 387 // Keep clicking it until the click is received and the menu closes. 388 ui.WithInterval(1*time.Second).LeftClickUntil(personalizeMenu, ui.Gone(personalizeMenu)), 389 ui.Exists(nodewith.NameContaining("Wallpaper & style").Role(role.Window).First()), 390 ui.WaitUntilExists(changeWallpaperButton), 391 ui.LeftClick(changeWallpaperButton), 392 ui.WaitUntilExists(solidColorsMenu), 393 ui.MakeVisible(solidColorsMenu), 394 ui.LeftClick(solidColorsMenu), 395 ui.LeftClick(nodewith.Name("Deep Purple").Role(role.ListBoxOption)), 396 // Ensure that "Deep Purple" text is displayed. 397 // The UI displays the name of the currently set wallpaper. 398 ui.WaitUntilExists(nodewith.NameContaining("Deep Purple").Role(role.Heading)), 399 )(ctx); err != nil { 400 s.Fatal("Failed to change the wallpaper: ", err) 401 } 402 } 403 ```