blob: 18a23b27fed971bb736ca46a51be0b3ff201464d [file] [log] [blame]
// Copyright 2018 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 arc
import (
"context"
"regexp"
"time"
"chromiumos/tast/errors"
"chromiumos/tast/local/android/ui"
"chromiumos/tast/local/arc"
"chromiumos/tast/local/chrome"
"chromiumos/tast/local/chrome/ui/mouse"
"chromiumos/tast/testing"
)
func init() {
testing.AddTest(&testing.Test{
Func: Clipboard,
Desc: "Tests copying and pasting from Chrome to Android and vice versa",
Contacts: []string{"ruanc@chromium.org", "yhanada@chromium.org", "arc-framework+tast@google.com"},
SoftwareDeps: []string{"chrome"},
Fixture: "arcBooted",
Params: []testing.Param{{
ExtraAttr: []string{"group:mainline"},
ExtraSoftwareDeps: []string{"android_p"},
}, {
Name: "vm",
ExtraSoftwareDeps: []string{"android_vm"},
ExtraAttr: []string{"group:mainline", "informational"},
}},
})
}
// idPrefix is the prefix to all view IDs in the helper Android app.
const idPrefix = "org.chromium.arc.testapp.clipboard:id/"
// A copyFunc encapsulates a "copy" operation which is predecided (e.g. data to
// be copied and clipboard destination).
type copyFunc func(context.Context) error
// A copyFunc encapsulates a "paste" operation which is predecided (e.g.
// clipboard source and paste mechanism).
type pasteFunc func(context.Context) (string, error)
// prepareCopyInChrome sets up a copy operation with Chrome as the source
// clipboard.
func prepareCopyInChrome(tconn *chrome.TestConn, format, data string) copyFunc {
return func(ctx context.Context) error {
return tconn.Call(ctx, nil, `
(format, data) => {
document.addEventListener('copy', (event) => {
event.clipboardData.setData(format, data);
event.preventDefault();
}, {once: true});
if (!document.execCommand('copy')) {
throw new Error('Failed to execute copy');
}
}`, format, data,
)
}
}
// preparePasteInChrome sets up a paste operation with Chrome as the
// destination clipboard.
func preparePasteInChrome(tconn *chrome.TestConn, format string) pasteFunc {
return func(ctx context.Context) (string, error) {
var result string
if err := tconn.Call(ctx, &result, `
(format) => {
let result;
document.addEventListener('paste', (event) => {
result = event.clipboardData.getData(format);
}, {once: true});
if (!document.execCommand('paste')) {
throw new Error('Failed to execute paste');
}
return result;
}`, format,
); err != nil {
return "", err
}
return result, nil
}
}
// prepareCopyInAndroid sets up a copy operation with Android as the source
// clipboard. The Android helper app we use during this test has a series of
// buttons which populate some views with text (hard-coded into the app); in
// order to execute a copy operation, we first have to click a button (the
// writeDataBtnID) to populate the right text and then we click the
// "copy_button". Since the text to copy is hard-coded into the android app,
// this helper ensures that the app and this test are in-sync with regards to
// the text that we expect to be copied, by checking that the provided
// viewIDForGetText contains the provided expected string.
func prepareCopyInAndroid(d *ui.Device, writeDataBtnID, viewIDForGetText, expected string) copyFunc {
const copyID = idPrefix + "copy_button"
return func(ctx context.Context) error {
if err := d.Object(ui.ID(writeDataBtnID)).Click(ctx); err != nil {
return errors.Wrap(err, "failed to set text in Android EditText")
}
if text, err := d.Object(ui.ID(viewIDForGetText)).GetText(ctx); err != nil {
return errors.Wrap(err, "failed to obtain Android text")
} else if text != expected {
return errors.Errorf("failed to set up the content to be copied in Android: got %q; want %q", text, expected)
}
if err := d.Object(ui.ID(copyID)).Click(ctx); err != nil {
return errors.Wrap(err, "failed to copy the text in Android")
}
return nil
}
}
// preparePasteInAndroid sets up a paste operation with Android as the
// destination clipboard. Specifically: in the Android helper app, the
// "paste_button" is clicked and the contents of the provided view ID is
// returned via GetText.
func preparePasteInAndroid(d *ui.Device, viewIDForGetText string) pasteFunc {
const pasteID = idPrefix + "paste_button"
return func(ctx context.Context) (string, error) {
if err := d.Object(ui.ID(pasteID)).Click(ctx); err != nil {
return "", errors.Wrap(err, "failed to paste")
}
text, err := d.Object(ui.ID(viewIDForGetText)).GetText(ctx)
if err != nil {
return "", errors.Wrap(err, "failed to obtain pasted text")
}
return text, nil
}
}
func Clipboard(ctx context.Context, s *testing.State) {
const (
apk = "ArcClipboardTest.apk"
pkg = "org.chromium.arc.testapp.clipboard"
cls = "org.chromium.arc.testapp.clipboard.ClipboardActivity"
titleID = idPrefix + "text_view"
title = "HTML tags goes here"
editTextID = idPrefix + "edit_message"
textViewID = idPrefix + "text_view"
)
p := s.FixtValue().(*arc.PreData)
cr := p.Chrome
a := p.ARC
tconn, err := cr.TestAPIConn(ctx)
if err != nil {
s.Fatal("Failed to create Test API connection: ", err)
}
s.Log("Starting app")
if err := a.Install(ctx, arc.APKPath(apk)); err != nil {
s.Fatal("Failed installing app: ", err)
}
act, err := arc.NewActivity(a, pkg, cls)
if err != nil {
s.Fatal("Failed to create a new activity: ", err)
}
defer act.Close()
if err := act.Start(ctx, tconn); err != nil {
s.Fatal("Failed to start the activity: ", err)
}
defer act.Stop(ctx, tconn)
s.Log("Waiting for App showing up")
d, err := a.NewUIDevice(ctx)
if err != nil {
s.Fatal("Failed initializing UI Automator: ", err)
}
defer d.Close(ctx)
if err := d.Object(ui.ID(titleID), ui.Text(title)).WaitForExists(ctx, 30*time.Second); err != nil {
s.Fatal("Failed to wait for the app shown: ", err)
}
rect, err := act.WindowBounds(ctx)
if err != nil {
s.Fatal("Failed to get the window bounds: ", err)
}
// Click the center of the activity from Chrome to generate the first mouse event, because
// Wayland's set_selection should be associated with a valid serial number from an actual event.
if err := mouse.Click(ctx, tconn, rect.CenterPoint(), mouse.LeftButton); err != nil {
s.Fatal("Failed to click the center of the app: ", err)
}
s.Log("Waiting for chrome.clipboard API to become available")
if err := tconn.WaitForExpr(ctx, "chrome.clipboard"); err != nil {
s.Fatal("chrome.clipboard API unavailable: ", err)
}
// Copy image from Chrome to Android.
s.Run(ctx, "CopyImageFromChromeToAndroid", func(ctx context.Context, s *testing.State) {
const encodedImage = "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"
// Copy image in Chrome. This creates a temporary dom to be copied,
// and destroys it after the copy operation.
if err := tconn.Call(ctx, nil, `(encodedImage) => {
const img = document.createElement('img');
img.src = 'data:image/png;base64,' + encodedImage;
const container = document.createElement('div');
container.appendChild(img);
document.body.appendChild(container);
try {
const range = document.createRange();
range.selectNodeContents(container);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return document.execCommand('copy');
} finally {
document.body.removeChild(container);
}
}`, encodedImage); err != nil {
s.Fatal("Failed to copy image in Chrome: ", err)
}
pasteAndroid := preparePasteInAndroid(d, textViewID)
// Paste in Android.
androidHTML, err := pasteAndroid(ctx)
if err != nil {
s.Fatal("Failed to obtain pasted text: ", err)
}
// Verify the result.
// Note: style attribute is added by Chrome before the image is copied to Android.
re := regexp.MustCompile(`^<img src="data:image/png;base64,(.+?)" style=".+?">$`)
if m := re.FindStringSubmatch(androidHTML); m == nil {
s.Fatalf("Failed to find pasted image in Android: got %q", androidHTML)
} else if m[1] != encodedImage {
s.Fatalf("Unexpected paste result: got %q; want %q", m[1], encodedImage)
}
})
s.Run(ctx, "CopyHTMLFromChromeToAndroidWithObserver", func(ctx context.Context, s *testing.State) {
const (
observerEnableID = idPrefix + "enable_observer_button"
observerDisableID = idPrefix + "disable_observer_button"
observerTextViewID = idPrefix + "observer_view"
observerReady = "Observer ready"
)
// Enable observer and wait for it to be ready to prevent a possible race.
if err := d.Object(ui.ID(observerEnableID)).Click(ctx); err != nil {
s.Fatal("Failed to enable observer: ", err)
}
defer d.Object(ui.ID(observerDisableID)).Click(ctx)
if err := d.Object(ui.ID(observerTextViewID)).WaitForText(ctx, observerReady, 5*time.Second); err != nil {
s.Fatal("Failed to wait for observer ready: ", err)
}
// Copy in Chrome, so the registered observer should paste the clipboard content in Android.
const content = "<b>observer</b> should paste this"
chromeCopy := prepareCopyInChrome(tconn, "text/html", content)
if err := chromeCopy(ctx); err != nil {
s.Fatal("Failed to copy in Chrome: ", err)
}
// Paste and Verify the result.
pasteAndroid := preparePasteInAndroid(d, textViewID)
if html, err := pasteAndroid(ctx); err != nil {
s.Fatal("Failed to obtain pasted text: ", err)
} else if html != content {
s.Errorf("Failed to copy HTML from Chrome to Android: got %q; want %q", html, content)
}
})
const (
// clicking writeTextBtnID causes the "Test Text 1234" message to show up
// in the editTextID.
writeTextBtnID = idPrefix + "write_text_button"
expectedTextFromAndroid = "Test Text 1234"
// clicking writeHTMLBtnID causes the following HTML to show up in the
// textViewID.
writeHTMLBtnID = idPrefix + "write_html_button"
expectedHTMLFromAndroid = `<p dir="ltr">test <b>HTML</b> 1234</p>`
testTextFromChrome = "Text to be copied from Chrome to Android"
testHTMLFromChrome = "<b>bold</b><i>italics</i>"
)
for _, row := range []struct {
name string
copyFunc copyFunc
pasteFunc pasteFunc
wantPastedData string
}{{
"CopyTextFromChromeToAndroid",
prepareCopyInChrome(tconn, "text/plain", testTextFromChrome),
preparePasteInAndroid(d, editTextID),
testTextFromChrome,
}, {
"CopyTextFromAndroidToChrome",
prepareCopyInAndroid(d, writeTextBtnID, editTextID, expectedTextFromAndroid),
preparePasteInChrome(tconn, "text/plain"),
expectedTextFromAndroid,
}, {
"CopyHTMLFromChromeToAndroid",
prepareCopyInChrome(tconn, "text/plain", testHTMLFromChrome),
preparePasteInAndroid(d, textViewID),
testHTMLFromChrome,
}, {
"CopyHTMLFromAndroidToChrome",
prepareCopyInAndroid(d, writeHTMLBtnID, textViewID, expectedHTMLFromAndroid),
preparePasteInChrome(tconn, "text/html"),
expectedHTMLFromAndroid,
}} {
s.Run(ctx, row.name, func(ctx context.Context, s *testing.State) {
start := time.Now()
if err := row.copyFunc(ctx); err != nil {
s.Fatal("Failed to copy: ", err)
}
// Rather than assuming the copy is effective ~immediately, we have to
// poll because the latency of the operation is slower in newer
// kernels (e.g. 5.4, at time of writing). See b/157615371 for
// historical background.
afterCopy := time.Now()
attempt := 0
err := testing.Poll(ctx, func(ctx context.Context) error {
got, err := row.pasteFunc(ctx)
if err != nil {
// We never expect pasting to fail: break from the poll.
return testing.PollBreak(errors.Wrap(err, "failed to paste"))
}
attempt++
if got == row.wantPastedData {
msSinceStart := time.Since(start).Seconds() * 1000
msToPaste := time.Since(afterCopy).Seconds() * 1000
msToCopy := msSinceStart - msToPaste
s.Logf("Found expected paste data on attempt #%d; copy took %0.3f ms, paste worked after %0.3f ms", attempt, msToCopy, msToPaste)
return nil
}
return errors.Errorf("after %d paste attempts, the pasted value was %q instead of %q", attempt, got, row.wantPastedData)
}, &testing.PollOptions{
// Running the actual paste operation from Chrome to Android can
// take a long time even for a single iteration (e.g. around 1
// second), so we are forced to give a relatively high upper bound
// for the overall timeout.
Timeout: 5 * time.Second,
// The latencies we are interested in observing are in the
// magnitude of tens of milliseconds (for some test cases), so
// sleep a very short amount of time.
Interval: 5 * time.Millisecond,
})
if err != nil {
s.Fatal("Failed during paste retry loop: ", err)
}
})
}
// TODO(ruanc): Copying big text (500Kb) is blocked by https://bugs.chromium.org/p/chromium/issues/detail?id=916882
}