blob: f70d5a6ecee36be7c47bd4e1b0c916ca655f2347 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>
#import "base/apple/foundation_util.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/ios/wait_util.h"
#import "base/values.h"
#import "ios/web/js_features/clipboard/clipboard_constants.h"
#import "ios/web/public/js_messaging/web_view_js_utils.h"
#import "ios/web/public/test/javascript_test.h"
#import "ios/web/public/test/js_test_util.h"
#import "ios/web/test/fakes/crw_fake_script_message_handler.h"
#import "testing/gtest/include/gtest/gtest.h"
#import "testing/gtest_mac.h"
using base::test::ios::kWaitForJSCompletionTimeout;
using base::test::ios::WaitUntilConditionOrTimeout;
namespace web {
namespace {
// Url used for tests. Must be https as the Clipboard api is not available in
// insecure contexts.
NSString* const kHttpsTestUrl = @"https://chromium.test/";
} // namespace
// Test fixture for clipboard.ts tests.
class ClipboardJsTest : public web::JavascriptTest {
public:
ClipboardJsTest()
: clipboard_handler_([[CRWFakeScriptMessageHandler alloc] init]) {
[web_view().configuration.userContentController
addScriptMessageHandler:clipboard_handler_
name:base::SysUTF8ToNSString(
kScriptMessageHandlerName)];
}
void SetUp() override {
web::JavascriptTest::SetUp();
AddGCrWebScript();
AddCommonScript();
AddUserScript(@"clipboard");
AddUserScript(@"paste_handler");
ASSERT_TRUE(web::test::LoadHtml(web_view(), @"<html></html>",
[NSURL URLWithString:kHttpsTestUrl]));
web::test::ExecuteJavaScriptInWebView(web_view(),
@"window.testState = {};");
ASSERT_TRUE(web::test::WaitForInjectedScripts(web_view()));
}
protected:
// Simulates a call to navigator.clipboard.writeText from the webpage.
// The result of the promise is stored in `window.testState[result_key]`.
void WriteText(const std::string& text,
const std::string& result_key = "lastResult") {
NSString* script = [NSString
stringWithFormat:@"(() => {"
"navigator.clipboard.writeText('%s')"
".then(() => {"
" window.testState.%s = 'resolved';"
"}).catch((err) => {"
" window.testState.%s = err.message;"
"});"
"})();",
text.c_str(), result_key.c_str(), result_key.c_str()];
web::test::ExecuteJavaScriptInWebView(web_view(), script);
}
// Simulates a call to navigator.clipboard.write from the webpage.
// The result of the promise is stored in `window.testState[result_key]`.
void Write(const std::string& result_key = "lastResult") {
NSString* script =
[NSString stringWithFormat:@"(() => {"
@"const item = new ClipboardItem({"
@" 'text/plain': Promise.resolve('test')"
@"});"
"navigator.clipboard.write([item])"
".then(() => {"
" window.testState.%s = 'resolved';"
"}).catch((err) => {"
" window.testState.%s = err.message;"
"});"
"})();",
result_key.c_str(), result_key.c_str()];
web::test::ExecuteJavaScriptInWebView(web_view(), script);
}
// Simulates a call to navigator.clipboard.readText from the webpage.
// The result of the promise is stored in `window.testState[result_key]`.
void ReadText(const std::string& result_key = "lastResult") {
NSString* script =
[NSString stringWithFormat:@"(() => {"
"navigator.clipboard.readText()"
".then((text) => {"
" window.testState.%s = text;"
"}).catch((err) => {"
" window.testState.%s = err.message;"
"});"
"})();",
result_key.c_str(), result_key.c_str()];
web::test::ExecuteJavaScriptInWebView(web_view(), script);
}
// Simulates a call to navigator.clipboard.read from the webpage.
// The result of the promise is stored in `window.testState[result_key]`.
void Read(const std::string& result_key = "lastResult") {
NSString* script = [NSString
stringWithFormat:@"(() => {"
@"navigator.clipboard.read()"
@".then(async (items) => {"
@" if (items.length === 0) {"
@" window.testState.%s = 'empty';"
@" return;"
@" }"
@" const blob = await items[0].getType('text/plain');"
@" const text = await blob.text();"
@" window.testState.%s = text;"
@"}).catch((err) => {"
@" window.testState.%s = err.message;"
@"});"
@"})();",
result_key.c_str(), result_key.c_str(),
result_key.c_str()];
web::test::ExecuteJavaScriptInWebView(web_view(), script);
}
// Simulates the native code's response to a clipboard request.
void ResolveRequest(int request_id, bool is_allowed) {
NSString* script = [NSString
stringWithFormat:@"__gCrWeb.getRegisteredApi('clipboard').getFunction("
@"'resolveRequest')(%d, %s);",
request_id, is_allowed ? "true" : "false"];
web::test::ExecuteJavaScriptInWebView(web_view(), script);
}
// Retrieves a result from the global test state object.
std::string GetTestStateResult(const std::string& key) {
NSString* script =
[NSString stringWithFormat:@"window.testState.%s;", key.c_str()];
id result = web::test::ExecuteJavaScript(web_view(), script);
if ([result isKindOfClass:[NSString class]]) {
return base::SysNSStringToUTF8(result);
}
return "";
}
CRWFakeScriptMessageHandler* clipboard_handler_;
};
// Tests that a single writeText request can be successfully fulfilled.
TEST_F(ClipboardJsTest, TestWriteTextAndAllow) {
WriteText(/*text_to_write=*/"test");
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !!clipboard_handler_.lastReceivedScriptMessage;
}));
// Verify the message sent to the native code.
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
EXPECT_NSEQ(base::SysUTF8ToNSString(kWriteCommand),
message[base::SysUTF8ToNSString(kCommandKey)]);
EXPECT_NSEQ(@0, message[base::SysUTF8ToNSString(kRequestIdKey)]);
// Simulate the browser allowing the request.
ResolveRequest(/*request_id=*/0, /*is_allowed=*/true);
// Verify that the promise was resolved.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("lastResult").empty();
}));
EXPECT_EQ("resolved", GetTestStateResult("lastResult"));
}
// Tests that a single write request can be successfully fulfilled.
TEST_F(ClipboardJsTest, TestWriteAndAllow) {
Write();
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !!clipboard_handler_.lastReceivedScriptMessage;
}));
// Verify the message sent to the native code.
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
EXPECT_NSEQ(base::SysUTF8ToNSString(kWriteCommand),
message[base::SysUTF8ToNSString(kCommandKey)]);
EXPECT_NSEQ(@0, message[base::SysUTF8ToNSString(kRequestIdKey)]);
// Simulate the browser allowing the request.
ResolveRequest(/*request_id=*/0, /*is_allowed=*/true);
// Verify that the promise was resolved.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("lastResult").empty();
}));
EXPECT_EQ("resolved", GetTestStateResult("lastResult"));
}
// Tests that a single readText request can be successfully fulfilled.
TEST_F(ClipboardJsTest, TestReadTextAndAllow) {
// First, write text to the clipboard to ensure there's something to read.
WriteText("test");
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !!clipboard_handler_.lastReceivedScriptMessage;
}));
ResolveRequest(/*request_id=*/0, /*is_allowed=*/true);
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return GetTestStateResult("lastResult") == "resolved";
}));
// Now, read the text back.
ReadText("readResult");
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
if (!clipboard_handler_.lastReceivedScriptMessage) {
return false;
}
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
return [message[base::SysUTF8ToNSString(kRequestIdKey)] intValue] == 1;
}));
// Verify the message sent to the native code.
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
EXPECT_NSEQ(base::SysUTF8ToNSString(kReadCommand),
message[base::SysUTF8ToNSString(kCommandKey)]);
EXPECT_NSEQ(@1, message[base::SysUTF8ToNSString(kRequestIdKey)]);
// Simulate the browser allowing the request.
ResolveRequest(/*request_id=*/1, /*is_allowed=*/true);
// Verify that the promise was resolved with the correct text.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("readResult").empty();
}));
EXPECT_EQ("test", GetTestStateResult("readResult"));
}
// Tests that a single read request can be successfully fulfilled.
TEST_F(ClipboardJsTest, TestReadAndAllow) {
// First, write text to the clipboard to ensure there's something to read.
WriteText("test");
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !!clipboard_handler_.lastReceivedScriptMessage;
}));
ResolveRequest(/*request_id=*/0, /*is_allowed=*/true);
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return GetTestStateResult("lastResult") == "resolved";
}));
// Now, read the text back.
Read("readResult");
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
if (!clipboard_handler_.lastReceivedScriptMessage) {
return false;
}
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
return [message[base::SysUTF8ToNSString(kRequestIdKey)] intValue] == 1;
}));
// Verify the message sent to the native code.
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
EXPECT_NSEQ(base::SysUTF8ToNSString(kReadCommand),
message[base::SysUTF8ToNSString(kCommandKey)]);
EXPECT_NSEQ(@1, message[base::SysUTF8ToNSString(kRequestIdKey)]);
// Simulate the browser allowing the request.
ResolveRequest(/*request_id=*/1, /*is_allowed=*/true);
// Verify that the promise was resolved with the correct text.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("readResult").empty();
}));
EXPECT_EQ("test", GetTestStateResult("readResult"));
}
// Tests that a single writeText request can be denied.
TEST_F(ClipboardJsTest, TestWriteTextAndDeny) {
WriteText("test");
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !!clipboard_handler_.lastReceivedScriptMessage;
}));
// Simulate the browser denying the request.
ResolveRequest(/*request_id=*/0, /*is_allowed=*/false);
// Verify that the promise was rejected with the correct error.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("lastResult").empty();
}));
EXPECT_EQ("Clipboard access denied.", GetTestStateResult("lastResult"));
}
// Tests that a single readText request can be denied.
TEST_F(ClipboardJsTest, TestReadTextAndDeny) {
ReadText();
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !!clipboard_handler_.lastReceivedScriptMessage;
}));
// Simulate the browser denying the request.
ResolveRequest(/*request_id=*/0, /*is_allowed=*/false);
// Verify that the promise was rejected with the correct error.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("lastResult").empty();
}));
EXPECT_EQ("Clipboard access denied.", GetTestStateResult("lastResult"));
}
// Tests that fulfilling a newer request correctly rejects an older one.
TEST_F(ClipboardJsTest, TestSupersededRequest) {
WriteText(/*text_to_write=*/"A", /*result_key=*/"result0");
WriteText(/*text_to_write=*/"B", /*result_key=*/"result1");
// Wait for the second message to arrive to ensure both have been processed.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
if (!clipboard_handler_.lastReceivedScriptMessage) {
return false;
}
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
return [message[@"requestId"] intValue] == 1;
}));
// Fulfill the NEWER request (ID 1).
ResolveRequest(/*request_id=*/1, /*is_allowed=*/true);
// Verify that the older request (ID 0) was rejected as superseded.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("result0").empty();
}));
EXPECT_EQ("Clipboard request was superseded by a newer one.",
GetTestStateResult("result0"));
// Verify that the newer request (ID 1) was resolved successfully.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("result1").empty();
}));
EXPECT_EQ("resolved", GetTestStateResult("result1"));
}
// Tests that a superseded request is still rejected even if the newer request
// is denied.
TEST_F(ClipboardJsTest, TestSupersededRequestWhenNewerIsDenied) {
WriteText(/*text_to_write=*/"A", /*result_key=*/"result0");
WriteText(/*text_to_write=*/"B", /*result_key=*/"result1");
// Wait for the second message to arrive to ensure both have been processed.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
if (!clipboard_handler_.lastReceivedScriptMessage) {
return false;
}
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
return [message[@"requestId"] intValue] == 1;
}));
// Deny the NEWER request (ID 1).
ResolveRequest(/*request_id=*/1, /*is_allowed=*/false);
// Verify that the older request (ID 0) was still rejected as superseded.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("result0").empty();
}));
EXPECT_EQ("Clipboard request was superseded by a newer one.",
GetTestStateResult("result0"));
// Verify that the newer request (ID 1) was rejected with the correct error.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("result1").empty();
}));
EXPECT_EQ("Clipboard access denied.", GetTestStateResult("result1"));
}
// Tests that the oldest request is evicted when the queue is full.
TEST_F(ClipboardJsTest, TestEvictionLogic) {
// Create 101 pending requests.
WriteText(/*text_to_write=*/"A", /*result_key=*/"result0");
for (int i = 1; i < 100; ++i) {
WriteText("B");
}
WriteText(/*text_to_write=*/"C", /*result_key=*/"result100");
// Wait for the 101st message to arrive to ensure all have been processed.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
if (!clipboard_handler_.lastReceivedScriptMessage) {
return false;
}
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
return [message[base::SysUTF8ToNSString(kRequestIdKey)] intValue] == 100;
}));
// Verify that the oldest request (ID 0) was rejected with the correct error.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("result0").empty();
}));
EXPECT_EQ("Too many pending clipboard requests.",
GetTestStateResult("result0"));
// Fulfill the NEWER request (ID 100).
ResolveRequest(/*request_id=*/100, /*is_allowed=*/true);
// Verify that the newest request (ID 100) was resolved successfully.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !GetTestStateResult("result100").empty();
}));
EXPECT_EQ("resolved", GetTestStateResult("result100"));
}
// Tests that a paste event sends a message to the browser.
TEST_F(ClipboardJsTest, TestPasteEvent) {
// Simulate a paste event.
web::test::ExecuteJavaScriptInWebView(
web_view(), @"document.dispatchEvent(new Event('paste'));");
// Verify that a message was sent to the native code.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return !!clipboard_handler_.lastReceivedScriptMessage;
}));
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
EXPECT_NSEQ(base::SysUTF8ToNSString(kDidFinishClipboardReadCommand),
message[base::SysUTF8ToNSString(kCommandKey)]);
}
// Tests that a successful readText() sends a `didFinishClipboardRead`
// notification.
TEST_F(ClipboardJsTest, TestReadFulfillmentSendsNotification) {
// Set up the clipboard with text to read, then read it. This is necessary to
// test that the `didFinishClipboardRead` message is sent after a successful
// read operation.
WriteText("test");
ResolveRequest(/*request_id=*/0, /*is_allowed=*/true);
ReadText("readResult");
ResolveRequest(/*request_id=*/1, /*is_allowed=*/true);
// Verify that the `didFinishClipboardRead` message was sent.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
if (!clipboard_handler_.lastReceivedScriptMessage) {
return false;
}
NSDictionary* message = base::apple::ObjCCastStrict<NSDictionary>(
clipboard_handler_.lastReceivedScriptMessage.body);
return [base::SysUTF8ToNSString(kDidFinishClipboardReadCommand)
isEqualToString:message[base::SysUTF8ToNSString(kCommandKey)]];
}));
}
} // namespace web