blob: 2928cc26f86d0bded76afbd153c333fda825b847 [file]
// Copyright 2021 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package ossettings supports controlling the Settings App on Chrome OS.
// This differs from Chrome settings (chrome://settings vs chrome://os-settings)
package ossettings
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"chromiumos/tast/errors"
"chromiumos/tast/local/apps"
"chromiumos/tast/local/chrome"
"chromiumos/tast/local/chrome/ash"
"chromiumos/tast/local/chrome/uiauto"
"chromiumos/tast/local/chrome/uiauto/nodewith"
"chromiumos/tast/local/chrome/uiauto/role"
"chromiumos/tast/local/chrome/webutil"
"chromiumos/tast/local/input"
"chromiumos/tast/testing"
)
const uiTimeout = 15 * time.Second
var defaultPollOpts = &testing.PollOptions{Timeout: 10 * time.Second, Interval: 1 * time.Second}
const urlPrefix = "chrome://os-settings/"
// OSSettings represents an instance of the Settings app.
type OSSettings struct {
ui *uiauto.Context
tconn *chrome.TestConn
}
// New returns a new ossettings context.
// OSSettings can be launched from a page or app.
func New(tconn *chrome.TestConn) *OSSettings {
return &OSSettings{ui: uiauto.New(tconn), tconn: tconn}
}
// Launch launches the Settings app.
// An error is returned if the app fails to launch.
func Launch(ctx context.Context, tconn *chrome.TestConn) (*OSSettings, error) {
app := apps.Settings
if err := apps.Launch(ctx, tconn, app.ID); err != nil {
return nil, errors.Wrapf(err, "failed to launch %s", app.Name)
}
testing.ContextLog(ctx, "Waiting for settings app shown in shelf")
if err := ash.WaitForApp(ctx, tconn, app.ID, time.Minute); err != nil {
return nil, errors.Wrapf(err, "%s did not appear in shelf after launch", app.Name)
}
return &OSSettings{ui: uiauto.New(tconn), tconn: tconn}, nil
}
// Close closes the Settings app.
// This is automatically done when chrome resets and is not necessary to call.
func (s *OSSettings) Close(ctx context.Context) error {
app := apps.Settings
if err := apps.Close(ctx, s.tconn, app.ID); err != nil {
return errors.Wrap(err, "failed to close settings app")
}
if err := ash.WaitForAppClosed(ctx, s.tconn, app.ID); err != nil {
return errors.Wrap(err, "failed waiting for settings app to close")
}
return nil
}
// LaunchAtPage launches the Settings app at a particular page.
// An error is returned if the app fails to launch.
// TODO (b/189055966): Fix the failure to launch the right subpage.
func LaunchAtPage(ctx context.Context, tconn *chrome.TestConn, subpage *nodewith.Finder) (*OSSettings, error) {
// Launch Settings App.
s, err := Launch(ctx, tconn)
if err != nil {
return nil, err
}
// Wait until either the subpage or main menu exist.
// On small screens the sidebar is collapsed, and the main menu must be clicked.
subPageInApp := subpage.FinalAncestor(WindowFinder)
menuButton := MenuButton.Ancestor(WindowFinder)
if err := testing.Poll(ctx, func(ctx context.Context) error {
if err := s.ui.Exists(subPageInApp)(ctx); err == nil {
return nil
}
if err := s.ui.Exists(menuButton)(ctx); err == nil {
return nil
}
return errors.New("neither subpage nor main menu exist")
}, &testing.PollOptions{Interval: 1 * time.Second, Timeout: 30 * time.Second}); err != nil {
return nil, err
}
// If the subpage doesn't exist, click the main menu.
// Focus the subpage to ensure it is on-screen.
// Then click the subpage that we want in the sidebar.
if err := uiauto.Combine("click subpage",
uiauto.IfSuccessThen(s.ui.Gone(subPageInApp), s.ui.LeftClick(menuButton)),
s.ui.FocusAndWait(subPageInApp),
s.ui.LeftClick(subPageInApp),
)(ctx); err != nil {
return nil, errors.Wrapf(err, "failed to click subpage with %v", subpage)
}
return s, nil
}
// LaunchAtPageURL launches the Settings app at a particular page via changing URL in javascript.
// It uses a condition check to make sure the function completes correctly.
// It is high recommended to use UI validation in condition check.
func LaunchAtPageURL(ctx context.Context, tconn *chrome.TestConn, cr *chrome.Chrome, pageShortURL string, condition func(context.Context) error) (*OSSettings, error) {
// Launch Settings App.
s, err := Launch(ctx, tconn)
if err != nil {
return nil, err
}
return s, s.NavigateToPageURL(ctx, cr, pageShortURL, condition)
}
// LaunchAtAppMgmtPage launches the Settings app at a particular app management page under app
// via changing URL in javascript.
// The URL includes an App ID.
// It calls LaunchAtPageURL.
func LaunchAtAppMgmtPage(ctx context.Context, tconn *chrome.TestConn, cr *chrome.Chrome, appID string, condition func(context.Context) error) (*OSSettings, error) {
return LaunchAtPageURL(ctx, tconn, cr, fmt.Sprintf("app-management/detail?id=%s", appID), condition)
}
// OpenMobileDataSubpage navigates Settings app to mobile data subpage.
func OpenMobileDataSubpage(ctx context.Context, tconn *chrome.TestConn, cr *chrome.Chrome) (*OSSettings, error) {
ui := uiauto.New(tconn)
if _, err := LaunchAtPageURL(ctx, tconn, cr, "Network", ui.Exists(networkFinder)); err != nil {
return nil, errors.Wrap(err, "failed to launch settings page")
}
if err := uiauto.Combine("Go to mobile data page",
ui.LeftClick(networkFinder),
ui.LeftClick(mobileButton),
)(ctx); err != nil {
return nil, errors.Wrap(err, "failed to go to mobile data page")
}
return &OSSettings{tconn: tconn, ui: ui}, nil
}
// NavigateToPageURL navigates the Settings app to a particular page.
func (s *OSSettings) NavigateToPageURL(ctx context.Context, cr *chrome.Chrome, pageShortURL string, condition func(context.Context) error) error {
settingsConn, err := s.ChromeConn(ctx, cr)
if err != nil {
return errors.Wrap(err, "failed to connect to OS settings target")
}
defer settingsConn.Close()
return webutil.NavigateToURLInApp(settingsConn, urlPrefix+pageShortURL, condition, 20*time.Second)(ctx)
}
// LaunchHelpApp returns a function that launches Help app by clicking "Get help with Chrome OS".
func (s *OSSettings) LaunchHelpApp() uiauto.Action {
return s.ui.LeftClick(nodewith.Name("Get help with Chrome OS").Role(role.Link).Ancestor(WindowFinder))
}
// LaunchWhatsNew returns a function that launches Help app by clicking "See what's new".
func (s *OSSettings) LaunchWhatsNew() uiauto.Action {
return s.ui.LeftClick(nodewith.Name("See what's new").Role(role.Link).Ancestor(WindowFinder))
}
// ChromeConn returns a Chrome connection to the Settings app.
func (s *OSSettings) ChromeConn(ctx context.Context, cr *chrome.Chrome) (*chrome.Conn, error) {
targetFilter := func(t *chrome.Target) bool { return strings.HasPrefix(t.URL, urlPrefix) }
settingsConn, err := cr.NewConnForTarget(ctx, targetFilter)
if err != nil {
return nil, err
}
if err := chrome.AddTastLibrary(ctx, settingsConn); err != nil {
settingsConn.Close()
return nil, errors.Wrap(err, "failed to introduce tast library")
}
return settingsConn, nil
}
// AuthenticationToken represents an authentication token.
type AuthenticationToken struct {
Token string `json:"token"`
LifetimeSeconds int `json:"lifetimeSeconds"`
}
// AuthToken retrieves an authentication token that is needed to toggle some protected settings.
// It allows us to automatically change things that would require a user to type their password if done manually.
func (s *OSSettings) AuthToken(ctx context.Context, settingsConn *chrome.Conn, password string) (*AuthenticationToken, error) {
// Wait for chrome.quickUnlockPrivate to be available.
if err := settingsConn.WaitForExpr(ctx, `chrome.quickUnlockPrivate !== undefined`); err != nil {
return nil, errors.Wrap(err, "failed waiting for chrome.quickUnlockPrivate to load")
}
// Wait for tast to be available.
if err := settingsConn.WaitForExpr(ctx, `tast !== undefined`); err != nil {
return nil, errors.Wrap(err, "failed waiting for tast to load")
}
var token AuthenticationToken
if err := settingsConn.Call(ctx, &token,
`password => tast.promisify(chrome.quickUnlockPrivate.getAuthToken)(password)`, password,
); err != nil {
return nil, errors.Wrap(err, "failed to get auth token")
}
return &token, nil
}
// EnablePINUnlock returns a function that enables unlocking the device with the specified PIN.
func (s *OSSettings) EnablePINUnlock(cr *chrome.Chrome, password, PIN string, autosubmit bool) uiauto.Action {
return func(ctx context.Context) error {
settingsConn, err := s.ChromeConn(ctx, cr)
if err != nil {
return errors.Wrap(err, "failed to connect to OS settings target")
}
token, err := s.AuthToken(ctx, settingsConn, password)
if err != nil {
return errors.Wrap(err, "failed to get auth token")
}
if err := settingsConn.Call(ctx, nil,
`(token, PIN) => tast.promisify(chrome.quickUnlockPrivate.setModes)(token, [chrome.quickUnlockPrivate.QuickUnlockMode.PIN], [PIN])`, token.Token, PIN,
); err != nil {
return errors.Wrap(err, "failed to get auth token")
}
if err := settingsConn.Call(ctx, nil,
`tast.promisify(chrome.quickUnlockPrivate.setPinAutosubmitEnabled)`, token.Token, PIN, autosubmit,
); err != nil {
return errors.Wrap(err, "failed to get auth token")
}
return nil
}
}
// WaitForSearchBox returns a function that waits for the search box to appear.
// Useful for checking that some content has loaded and Settings is ready to use.
func (s *OSSettings) WaitForSearchBox() uiauto.Action {
return s.ui.WaitUntilExists(SearchBoxFinder)
}
// EvalJSWithShadowPiercer executes javascript in Settings app web page.
func (s *OSSettings) EvalJSWithShadowPiercer(ctx context.Context, cr *chrome.Chrome, expr string, out interface{}) error {
conn, err := s.ChromeConn(ctx, cr)
if err != nil {
return errors.Wrap(err, "failed to connect to Settings web page")
}
defer conn.Close()
return webutil.EvalWithShadowPiercer(ctx, conn, expr, out)
}
// SetToggleOption clicks toggle option to enable or disable an option.
// It does nothing if the option is already expected.
func (s *OSSettings) SetToggleOption(cr *chrome.Chrome, optionName string, expected bool) uiauto.Action {
return func(ctx context.Context) error {
if isEnabled, err := s.IsToggleOptionEnabled(ctx, cr, optionName); err != nil {
return err
} else if isEnabled == expected {
return nil
}
optionFinder := nodewith.Name(optionName).Role(role.ToggleButton)
return uiauto.Combine("set toggle option",
s.LeftClick(optionFinder),
s.WaitUntilToggleOption(cr, optionName, expected),
)(ctx)
}
}
// SetDropDownOption sets dropdown option to a value.
func (s *OSSettings) SetDropDownOption(cr *chrome.Chrome, optionName, expected string) uiauto.Action {
optionFinder := nodewith.Name(optionName).Role(role.PopUpButton)
settingFinder := nodewith.Name(expected).Role(role.ListBoxOption)
return uiauto.Combine("set drop down option",
s.LeftClick(optionFinder),
s.LeftClick(settingFinder),
uiauto.Sleep(time.Second),
)
}
// IsToggleOptionEnabled checks whether the toggle option is enabled or not.
func (s *OSSettings) IsToggleOptionEnabled(ctx context.Context, cr *chrome.Chrome, optionName string) (bool, error) {
toggleButtonCSSSelector := fmt.Sprintf(`cr-toggle[aria-label=%q]`, optionName)
expr := fmt.Sprintf(`
var optionNode = shadowPiercingQuery(%q);
if(optionNode == undefined){
throw new Error("%s setting item is not found.");
}
optionNode.getAttribute("aria-pressed")==="true";
`, toggleButtonCSSSelector, optionName)
var isEnabled bool
if err := s.EvalJSWithShadowPiercer(ctx, cr, expr, &isEnabled); err != nil {
return isEnabled, errors.Wrapf(err, "failed to get status of option: %q", optionName)
}
return isEnabled, nil
}
// DropdownValue returns the value of a dropdown setting.
func (s *OSSettings) DropdownValue(ctx context.Context, cr *chrome.Chrome, dropdownName string) (string, error) {
dropdownElementSelector := fmt.Sprintf(`select[aria-label=%q]`, dropdownName)
expr := fmt.Sprintf(`
var optionNode = shadowPiercingQuery(%q);
if(optionNode == undefined){
throw new Error("%s dropdown setting is not found.");
}
optionNode.value;
`, dropdownElementSelector, dropdownName)
var value string
if err := s.EvalJSWithShadowPiercer(ctx, cr, expr, &value); err != nil {
return value, errors.Wrapf(err, "failed to get the value of dropdown: %q", dropdownName)
}
return value, nil
}
// WaitUntilToggleOption returns an action to wait until the toggle option enabled or disabled.
func (s *OSSettings) WaitUntilToggleOption(cr *chrome.Chrome, optionName string, expected bool) uiauto.Action {
return func(ctx context.Context) error {
return testing.Poll(ctx, func(ctx context.Context) error {
if isEnabled, err := s.IsToggleOptionEnabled(ctx, cr, optionName); err != nil {
// JS evaluation is not always reliable. So do not break if failed.
return err
} else if isEnabled != expected {
return errors.Errorf("Option %q is unpected: got %v; want %v", optionName, isEnabled, expected)
}
return nil
}, &testing.PollOptions{Timeout: 3 * time.Second})
}
}
// SearchWithKeyword searches the demand keyword by input text in the `SearchBox`.
func (s *OSSettings) SearchWithKeyword(ctx context.Context, kb *input.KeyboardEventWriter,
keyword string) (results []uiauto.NodeInfo, mismatched bool, err error) {
if err := uiauto.Combine(fmt.Sprintf("query with keywords %q", keyword),
kb.TypeAction(keyword),
s.WaitUntilExists(nodewith.HasClass("ContentsWebView").Focused()),
// WaitUntilExists returns once the node is found, while WaitForLocation waits
// until the node exists and the location is not changing for two iterations of polling.
// In this case, the node will show the previous result first, then hide and reappear with the new result,
// so use WaitForLocation to wait until it stabilizes.
s.WaitForLocation(searchResultFinder.First()),
)(ctx); err != nil {
return nil, false, err
}
results, err = s.NodesInfo(ctx, searchResultFinder)
if len(results) <= 0 {
return nil, false, errors.New("no search result found")
} else if regexp.MustCompile(searchMismatched).MatchString(results[0].Name) {
mismatched = true
}
return results, mismatched, err
}
// ClearSearch clears text in `SearchBox` and waits for the search results to be gone.
func (s *OSSettings) ClearSearch() uiauto.Action {
clearSearchBtn := nodewith.NameContaining("Clear search").Role(role.Button)
return uiauto.Combine("clear text in search box",
uiauto.IfSuccessThen(s.ui.WaitUntilExists(clearSearchBtn), s.LeftClick(clearSearchBtn)),
s.WaitUntilGone(clearSearchBtn),
s.WaitUntilGone(searchResultFinder),
)
}
// UninstallApp uninstalls an app from the Settings app.
// It first opens the Settings app on the apps page.
// If it fails to open the page, it thinks the app is not there so no need to uninstall and returns nil.
// Then it clicks the Uninstall button to uninstall.
func UninstallApp(ctx context.Context, tconn *chrome.TestConn, cr *chrome.Chrome, appName, appID string) error {
ui := uiauto.New(tconn)
appNode := nodewith.Name(appName).ClassName("cr-title-text")
if _, err := LaunchAtPageURL(ctx, tconn, cr, "app-management/detail?id="+appID, ui.WaitUntilExists(appNode)); err != nil {
testing.ContextLogf(ctx, "Failed to open Settings app at the app %s, it may not exist: %s", appName, err)
return nil
}
uninstall := nodewith.Name("Uninstall").Role(role.Button)
uninstallWindow := nodewith.NameStartingWith("Uninstall").Role(role.Window)
return uiauto.Combine("uninstall the app",
ui.LeftClick(uninstall),
ui.LeftClick(uninstall.Ancestor(uninstallWindow)))(ctx)
}
// CommonSections returns a map that contains *nodewith.Finder for OS-Settings UI elements of common sections.
func CommonSections(advanceExpanded bool) map[string]*nodewith.Finder {
sections := map[string]*nodewith.Finder{
"Network": Network,
"Bluetooth": Bluetooth,
"Connected Devices": ConnectedDevices,
"Accounts": Accounts,
"Device": Device,
"Personalization": Personalization,
"Security And Privacy": SecurityAndPrivacy,
"Apps": Apps,
"About ChromeOS": AboutChromeOS,
}
if advanceExpanded {
sections["Date And Time"] = DateAndTime
sections["Languages And Inputs"] = LanguagesAndInputs
sections["Files"] = Files
sections["Print And Scan"] = PrintAndScan
sections["Developers"] = Developers
sections["Accessibility"] = Accessibility
sections["Reset Settings"] = ResetSettings
}
return sections
}