chromium/ios/chrome/test/wpt/cwt_request_handler.mm

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/test/wpt/cwt_request_handler.h"

#import <XCTest/XCTest.h>

#import <optional>
#import <string>

#import "base/debug/stack_trace.h"
#import "base/files/file_path.h"
#import "base/files/file_util.h"
#import "base/json/json_reader.h"
#import "base/json/json_writer.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/ios/wait_util.h"
#import "base/uuid.h"
#import "base/values.h"
#import "components/version_info/version_info.h"
#import "ios/chrome/test/wpt/cwt_constants.h"
#import "ios/chrome/test/wpt/cwt_webdriver_app_interface.h"
#import "ios/third_party/edo/src/Service/Sources/EDOClientService.h"
#import "net/http/http_status_code.h"

EDO_STUB_CLASS(CWTWebDriverAppInterface, kCwtEdoPortNumber)

using net::test_server::HttpRequest;
using net::test_server::HttpResponse;

namespace {

constexpr base::TimeDelta kDefaultScriptTimeout = base::Seconds(30);
constexpr base::TimeDelta kDefaultPageLoadTimeout = base::Seconds(300);

// WebDriver commands.
const char kWebDriverSessionCommand[] = "session";
const char kWebDriverNavigationCommand[] = "url";
const char kWebDriverTimeoutsCommand[] = "timeouts";
const char kWebDriverWindowCommand[] = "window";
const char kWebDriverWindowHandlesCommand[] = "handles";
const char kWebDriverSyncScriptCommand[] = "sync";
const char kWebDriverAsyncScriptCommand[] = "async";
const char kWebDriverScreenshotCommand[] = "screenshot";
const char kWebDriverWindowRectCommand[] = "rect";
const char kWebDriverActionsCommand[] = "actions";

// Non-standard commands used only for testing Chrome.
// This command is similar to the standard "url" command. It loads the URL
// specified by the kWebDriverURLRequestField argument, waits up till the
// currently-set page load time (see the standard "timeouts" command) for the
// page to finish loading, but then waits an additional amount of time
// (specified in seconds by the kChromeCrashWaitTime argument) for the page to
// crash. It then returns the stderr produced by the app during this time, in
// the kChromeStderrValueField response field. If the given URL is a file URL,
// the given file is copied and served from a local EmbeddedTestServer.
const char kChromeCrashTestCommand[] = "chrome_crashtest";

// This returns the Chrome version (e.g., "92.0.4483.0") along with the
// revision number (e.g, "872495") used for the current build.
const char kChromeVersionInfoCommand[] = "chrome_versionInfo";

// WebDriver error codes.
const char kWebDriverInvalidArgumentError[] = "invalid argument";
const char kWebDriverInvalidSessionError[] = "invalid session id";
const char kWebDriverSessionCreationError[] = "session not created";
const char kWebDriverTimeoutError[] = "timeout";
const char kWebDriverScriptTimeoutError[] = "script timeout";
const char kWebDriverNoSuchWindowError[] = "no such window";
const char kWebDriverUnknownCommandError[] = "unknown command";

// WebDriver error messages. The content of each message is implementation-
// defined, not prescribed the by the spec.
const char kWebDriverMissingRequestMessage[] = "Missing request body";
const char kWebDriverMissingURLMessage[] = "No url argument";
const char kWebDriverNoActiveSessionMessage[] = "No currently active session";
const char kWebDriverPageLoadTimeoutMessage[] = "Page load timed out";
const char kWebDriverSessionAlreadyExistsMessage[] = "A session already exists";
const char kWebDriverUnknownCommandMessage[] = "No such command";
const char kWebDriverInvalidTimeoutMessage[] =
    "Timeouts must be non-negative integers";
const char kWebDriverNoTargetWindowMessage[] = "Target window has been closed";
const char kWebDriverMissingWindowHandleMessage[] = "No handle argument";
const char kWebDriverNoMatchingWindowMessage[] =
    "No window with the given handle";
const char kWebDriverMissingScriptMessage[] = "No script argument";
const char kWebDriverScriptTimeoutMessage[] = "Script execution timed out";

// Non-standard error messages, used only for testing Chrome.
const char kChromeInvalidExtraWaitMessage[] =
    "Extra wait must be a non-negative integer";
const char kChromeInvalidUrlMessage[] = "The provided URL is not valid";
const char kChromeFileCannotBeCopiedMessage[] =
    "The provided input file cannot be copied";

// WebDriver request field names. These are fields that are contained within
// the body of a POST request.
const char kWebDriverURLRequestField[] = "url";
const char kWebDriverScriptTimeoutRequestField[] = "script";
const char kWebDriverPageLoadTimeoutRequestField[] = "pageLoad";
const char kWebDriverWindowHandleRequestField[] = "handle";
const char kWebDriverScriptRequestField[] = "script";
const char kWebDriverArgsRequestField[] = "args";

// Non-standard request field names, used only for testing Chrome.
// The additional time (in seconds) to wait for a crash after a successful page
// load.
const char kChromeCrashWaitTime[] = "chrome_crashWaitTime";

// WebDriver response field name. This is the top-level field in the JSON object
// contained in a response.
const char kWebDriverValueResponseField[] = "value";

// WebDriver value field names. These fields are contained within the 'value'
// field in a WebDriver reponse. Each response value has zero or more of these
// fields.
const char kWebDriverCapabilitiesValueField[] = "capabilities";
const char kWebDriverErrorCodeValueField[] = "error";
const char kWebDriverErrorMessageValueField[] = "message";
const char kWebDriverSessionIdValueField[] = "sessionId";
const char kWebDriverStackTraceValueField[] = "stacktrace";

// Non-standard value field names, used only when testing Chrome.
// Stderr output from the app.
const char kChromeStderrValueField[] = "chrome_stderr";
// The revision number used for the current build.
const char kChromeRevisionNumberField[] = "chrome_revisionNumber";

// Field names for the "capabilities" struct that's included in the response
// when creating a session.
const char kCapabilitiesBrowserNameField[] = "browserName";
const char kCapabilitiesBrowserVersionField[] = "browserVersion";
const char kCapabilitiesPlatformNameField[] = "platformName";
const char kCapabilitiesPageLoadStrategyField[] = "pageLoadStrategy";
const char kCapabilitiesProxyField[] = "proxy";
const char kCapabilitiesScriptTimeoutField[] = "timeouts.script";
const char kCapabilitiesPageLoadTimeoutField[] = "timeouts.pageLoad";
const char kCapabilitiesImplicitTimeoutField[] = "timeouts.implicit";
const char kCapabilitiesCanResizeWindowsField[] = "setWindowRect";

// Field values for the "capabilities" struct that's included in the response
// when creating a session.
const char kCapabilitiesBrowserName[] = "chrome_ios";
const char kCapabilitiesPlatformName[] = "iOS";
const char kCapabilitiesPageLoadStrategy[] = "normal";

base::Value CreateErrorValue(const std::string& error,
                             const std::string& message) {
  return base::Value(base::Value::Dict()
                         .Set(kWebDriverErrorCodeValueField, error)
                         .Set(kWebDriverErrorMessageValueField, message)
                         .Set(kWebDriverStackTraceValueField,
                              base::debug::StackTrace().ToString()));
}

bool IsErrorValue(const base::Value& value) {
  return value.is_dict() &&
         value.GetDict().contains(kWebDriverErrorCodeValueField);
}

}  // namespace

CWTRequestHandler::CWTRequestHandler(ProceduralBlock session_completion_handler)
    : session_completion_handler_(session_completion_handler),
      script_timeout_(kDefaultScriptTimeout),
      page_load_timeout_(kDefaultPageLoadTimeout) {
  application_ = [[XCUIApplication alloc] init];
  [application_ launch];
  base::CreateNewTempDirectory(base::FilePath::StringType(),
                               &test_case_directory_);
  test_case_server_.ServeFilesFromDirectory(test_case_directory_);
  if (!test_case_server_.Start()) {
    XCTFail("Unable to start test case server.");
  }
}

CWTRequestHandler::~CWTRequestHandler() = default;

std::optional<base::Value> CWTRequestHandler::ProcessCommand(
    const std::string& command,
    net::test_server::HttpMethod http_method,
    const std::string& request_content) {
  if (http_method == net::test_server::METHOD_GET) {
    if (session_id_.empty()) {
      return CreateErrorValue(kWebDriverInvalidSessionError,
                              kWebDriverNoActiveSessionMessage);
    }

    if (command == kWebDriverWindowCommand)
      return GetTargetTabId();

    if (command == kWebDriverWindowHandlesCommand)
      return GetAllTabIds();

    if (command == kWebDriverScreenshotCommand)
      return GetSnapshot();

    if (command == kChromeVersionInfoCommand)
      return GetVersionInfo();

    return std::nullopt;
  }

  if (http_method == net::test_server::METHOD_POST) {
    std::optional<base::Value> content =
        base::JSONReader::Read(request_content);
    if (!content || !content->is_dict()) {
      return CreateErrorValue(kWebDriverInvalidArgumentError,
                              kWebDriverMissingRequestMessage);
    }
    const base::Value::Dict& content_dict = content->GetDict();

    if (command == kWebDriverSessionCommand)
      return InitializeSession();

    if (session_id_.empty()) {
      return CreateErrorValue(kWebDriverInvalidSessionError,
                              kWebDriverNoActiveSessionMessage);
    }

    if (command == kChromeCrashTestCommand)
      return NavigateToUrlForCrashTest(*content);

    if (command == kWebDriverNavigationCommand)
      return NavigateToUrl(content_dict.FindString(kWebDriverURLRequestField));

    if (command == kWebDriverTimeoutsCommand)
      return SetTimeouts(*content);

    if (command == kWebDriverWindowCommand) {
      return SwitchToTabWithId(
          content_dict.FindString(kWebDriverWindowHandleRequestField));
    }

    if (command == kWebDriverSyncScriptCommand) {
      return ExecuteScript(
          content_dict.FindString(kWebDriverScriptRequestField),
          /*is_async_function=*/false,
          content_dict.FindList(kWebDriverArgsRequestField));
    }

    if (command == kWebDriverAsyncScriptCommand) {
      return ExecuteScript(
          content_dict.FindString(kWebDriverScriptRequestField),
          /*is_async_function=*/true,
          content_dict.FindList(kWebDriverArgsRequestField));
    }

    if (command == kWebDriverWindowRectCommand)
      return SetWindowRect(*content);

    return std::nullopt;
  }

  if (http_method == net::test_server::METHOD_DELETE) {
    if (session_id_.empty()) {
      return CreateErrorValue(kWebDriverInvalidSessionError,
                              kWebDriverNoActiveSessionMessage);
    }

    if (command == session_id_)
      return CloseSession();

    if (command == kWebDriverWindowCommand)
      return CloseTargetTab();

    if (command == kWebDriverActionsCommand)
      return ReleaseActions();

    return std::nullopt;
  }

  return std::nullopt;
}

std::unique_ptr<net::test_server::HttpResponse>
CWTRequestHandler::HandleRequest(const net::test_server::HttpRequest& request) {
  std::string command = request.GetURL().ExtractFileName();
  std::optional<base::Value> result =
      ProcessCommand(command, request.method, request.content);

  auto response = std::make_unique<net::test_server::BasicHttpResponse>();
  response->set_content_type("application/json; charset=utf-8");
  response->AddCustomHeader("Cache-Control", "no-cache");
  if (!result) {
    response->set_code(net::HTTP_NOT_FOUND);
    result = CreateErrorValue(kWebDriverUnknownCommandError,
                              kWebDriverUnknownCommandMessage);
  } else if (IsErrorValue(*result)) {
    response->set_code(net::HTTP_INTERNAL_SERVER_ERROR);
  } else {
    response->set_code(net::HTTP_OK);
  }

  auto response_content =
      base::Value::Dict().Set(kWebDriverValueResponseField, std::move(*result));
  std::string response_content_string;
  base::JSONWriter::Write(response_content, &response_content_string);
  response->set_content(response_content_string);

  return std::move(response);
}

base::Value CWTRequestHandler::InitializeSession() {
  if (!session_id_.empty()) {
    return CreateErrorValue(kWebDriverSessionCreationError,
                            kWebDriverSessionAlreadyExistsMessage);
  }

  [CWTWebDriverAppInterface enablePopups];
  target_tab_id_ =
      base::SysNSStringToUTF8([CWTWebDriverAppInterface currentTabID]);

  base::Value::Dict result;
  session_id_ = base::Uuid::GenerateRandomV4().AsLowercaseString();
  result.Set(kWebDriverSessionIdValueField, session_id_);

  base::Value::Dict capabilities;
  capabilities.Set(kCapabilitiesBrowserNameField, kCapabilitiesBrowserName);
  capabilities.Set(kCapabilitiesBrowserVersionField,
                   version_info::GetVersionNumber());
  capabilities.Set(kCapabilitiesPlatformNameField, kCapabilitiesPlatformName);
  capabilities.Set(kCapabilitiesPageLoadStrategyField,
                   kCapabilitiesPageLoadStrategy);
  capabilities.Set(kCapabilitiesProxyField,
                   base::Value(base::Value::Type::DICT));
  capabilities.SetByDottedPath(
      kCapabilitiesScriptTimeoutField,
      static_cast<int>(script_timeout_.InMilliseconds()));
  capabilities.SetByDottedPath(
      kCapabilitiesPageLoadTimeoutField,
      static_cast<int>(page_load_timeout_.InMilliseconds()));
  capabilities.SetByDottedPath(kCapabilitiesImplicitTimeoutField, 0);
  capabilities.Set(kCapabilitiesCanResizeWindowsField, base::Value(false));

  result.Set(kWebDriverCapabilitiesValueField, std::move(capabilities));
  return base::Value(std::move(result));
}

base::Value CWTRequestHandler::CloseSession() {
  session_id_.clear();
  session_completion_handler_();
  return base::Value(base::Value::Type::NONE);
}

base::Value CWTRequestHandler::ReleaseActions() {
  return base::Value(base::Value::Type::NONE);
}

base::Value CWTRequestHandler::NavigateToUrl(const std::string* url) {
  if (!url) {
    return CreateErrorValue(kWebDriverInvalidArgumentError,
                            kWebDriverMissingURLMessage);
  }

  NSError* error =
      [CWTWebDriverAppInterface loadURL:base::SysUTF8ToNSString(*url)
                                  inTab:base::SysUTF8ToNSString(target_tab_id_)
                                timeout:page_load_timeout_];
  if (!error)
    return base::Value(base::Value::Type::NONE);

  return CreateErrorValue(kWebDriverTimeoutError,
                          kWebDriverPageLoadTimeoutMessage);
}

base::Value CWTRequestHandler::NavigateToUrlForCrashTest(
    const base::Value& input) {
  const base::Value::Dict& input_dict = input.GetDict();
  const std::string* url_str = input_dict.FindString(kWebDriverURLRequestField);
  if (!url_str) {
    return CreateErrorValue(kWebDriverInvalidArgumentError,
                            kWebDriverMissingURLMessage);
  }

  GURL url(*url_str);
  if (!url.is_valid()) {
    return CreateErrorValue(kWebDriverInvalidArgumentError,
                            kChromeInvalidUrlMessage);
  }

  if (url.SchemeIsFile()) {
    // Copy the file to the directory being served by the test server.
    base::FilePath test_input_file(url.GetContent());
    base::FilePath test_destination_file =
        test_case_directory_.Append(test_input_file.BaseName());
    bool copied_file = base::CopyFile(test_input_file, test_destination_file);
    if (!copied_file) {
      return CreateErrorValue(kWebDriverInvalidArgumentError,
                              kChromeFileCannotBeCopiedMessage);
    }
    url = test_case_server_.GetURL("/" + test_input_file.BaseName().value());
  }

  base::FilePath log_file;
  base::CreateTemporaryFile(&log_file);
  [CWTWebDriverAppInterface
      logStderrToFilePath:base::SysUTF8ToNSString(log_file.value())];
  [CWTWebDriverAppInterface installCleanExitHandlerForAbortSignal];

  // Once the test page is loaded, the app might crash at any time until the
  // tab is closed. Re-launch the app if it crashes.
  @try {
    NSError* error = [CWTWebDriverAppInterface
        loadURL:base::SysUTF8ToNSString(url.spec())
          inTab:base::SysUTF8ToNSString(target_tab_id_)
        timeout:page_load_timeout_];

    if (!error) {
      const std::optional<int> extra_wait =
          input_dict.FindInt(kChromeCrashWaitTime);
      if (extra_wait) {
        if (!extra_wait || extra_wait.value() < 0) {
          return CreateErrorValue(kWebDriverInvalidArgumentError,
                                  kChromeInvalidExtraWaitMessage);
        }
        base::test::ios::SpinRunLoopWithMinDelay(
            base::Seconds(extra_wait.value()));
      }
    }

    [CWTWebDriverAppInterface openNewTab];
    [CWTWebDriverAppInterface
        closeTabWithID:base::SysUTF8ToNSString(target_tab_id_)];
    target_tab_id_ =
        base::SysNSStringToUTF8([CWTWebDriverAppInterface currentTabID]);

    [CWTWebDriverAppInterface stopLoggingStderr];
  } @catch (NSException* exception) {
    dispatch_sync(dispatch_get_main_queue(), ^{
      [application_ launch];
    });
    target_tab_id_ =
        base::SysNSStringToUTF8([CWTWebDriverAppInterface currentTabID]);
  }

  std::string stderr_contents;
  base::ReadFileToString(log_file, &stderr_contents);

  return base::Value(
      base::Value::Dict().Set(kChromeStderrValueField, stderr_contents));
}

base::Value CWTRequestHandler::SetTimeouts(const base::Value& timeouts) {
  for (const auto pair : timeouts.GetDict()) {
    if (!pair.second.is_int() || pair.second.GetInt() < 0) {
      return CreateErrorValue(kWebDriverInvalidArgumentError,
                              kWebDriverInvalidTimeoutMessage);
    }

    const base::TimeDelta timeout = base::Milliseconds(pair.second.GetInt());

    // Only script and page load timeouts are supported in CWTChromeDriver.
    // Other values are ignored.
    if (pair.first == kWebDriverScriptTimeoutRequestField)
      script_timeout_ = timeout;
    else if (pair.first == kWebDriverPageLoadTimeoutRequestField)
      page_load_timeout_ = timeout;
  }
  return base::Value(base::Value::Type::NONE);
}

base::Value CWTRequestHandler::GetTargetTabId() {
  NSArray* tab_ids = [CWTWebDriverAppInterface tabIDs];
  if ([tab_ids indexOfObject:base::SysUTF8ToNSString(target_tab_id_)] ==
      NSNotFound) {
    return CreateErrorValue(kWebDriverNoSuchWindowError,
                            kWebDriverNoTargetWindowMessage);
  }

  return base::Value(target_tab_id_);
}

base::Value CWTRequestHandler::GetAllTabIds() {
  base::Value::List id_list;
  NSArray* tab_ids = [CWTWebDriverAppInterface tabIDs];
  for (NSString* tab_id in tab_ids) {
    id_list.Append(base::Value(base::SysNSStringToUTF8(tab_id)));
  }
  return base::Value(std::move(id_list));
}

base::Value CWTRequestHandler::SwitchToTabWithId(const std::string* tab_id) {
  if (!tab_id) {
    return CreateErrorValue(kWebDriverInvalidArgumentError,
                            kWebDriverMissingWindowHandleMessage);
  }

  NSError* error = [CWTWebDriverAppInterface
      switchToTabWithID:base::SysUTF8ToNSString(*tab_id)];

  if (!error) {
    target_tab_id_ = *tab_id;
    return base::Value(base::Value::Type::NONE);
  }

  return CreateErrorValue(kWebDriverNoSuchWindowError,
                          kWebDriverNoMatchingWindowMessage);
}

base::Value CWTRequestHandler::CloseTargetTab() {
  NSError* error = [CWTWebDriverAppInterface
      closeTabWithID:base::SysUTF8ToNSString(target_tab_id_)];
  target_tab_id_.clear();

  if (error) {
    return CreateErrorValue(kWebDriverNoSuchWindowError,
                            kWebDriverNoTargetWindowMessage);
  }

  return GetAllTabIds();
}

base::Value CWTRequestHandler::ExecuteScript(const std::string* script,
                                             bool is_async_function,
                                             const base::Value::List* args) {
  if (!script) {
    return CreateErrorValue(kWebDriverInvalidArgumentError,
                            kWebDriverMissingScriptMessage);
  }

  NSString* function_to_execute;
  if (is_async_function) {
    // The provided `script` is a function body that already calls its last
    // argument with the result of its computation.
    NSString* updated_script = base::SysUTF8ToNSString(*script);
    // Update the url if exists in the args
    if (args && args->size() > 0) {
      NSString* script_url = [NSString
          stringWithFormat:@"\"%s\"", args->front().GetString().c_str()];
      updated_script =
          [updated_script stringByReplacingOccurrencesOfString:@"arguments[0]"
                                                    withString:script_url];
    }
    function_to_execute =
        [NSString stringWithFormat:@"function f(completionHandler) { %@ }",
                                   updated_script];
  } else {
    // The provided `script` directly computes a result. Convert to a function
    // that calls a completion handler with the result of its computation.
    NSString* input_function =
        [NSString stringWithFormat:@"() => { %s }", script->c_str()];
    function_to_execute =
        [NSString stringWithFormat:@"function f(completionHandler) { "
                                   @"  completionHandler((%@).call()); "
                                   @"} ",
                                   input_function];
  }

  NSString* result_as_json = [CWTWebDriverAppInterface
      executeAsyncJavaScriptFunction:function_to_execute
                               inTab:base::SysUTF8ToNSString(target_tab_id_)
                             timeout:script_timeout_];

  if (!result_as_json) {
    return CreateErrorValue(kWebDriverScriptTimeoutError,
                            kWebDriverScriptTimeoutMessage);
  }

  std::optional<base::Value> result =
      base::JSONReader::Read(base::SysNSStringToUTF8(result_as_json));
  DCHECK(result);
  return std::move(*result);
}

base::Value CWTRequestHandler::GetSnapshot() {
  NSString* snapshot_image = [CWTWebDriverAppInterface
      takeSnapshotOfTabWithID:base::SysUTF8ToNSString(target_tab_id_)];
  if (!snapshot_image) {
    return CreateErrorValue(kWebDriverNoSuchWindowError,
                            kWebDriverNoMatchingWindowMessage);
  }

  return base::Value(base::SysNSStringToUTF8(snapshot_image));
}

base::Value CWTRequestHandler::SetWindowRect(const base::Value& rect) {
  return base::Value();
}

base::Value CWTRequestHandler::GetVersionInfo() {
  auto result = base::Value::Dict().Set(kCapabilitiesBrowserVersionField,
                                        version_info::GetVersionNumber());

  // The full revision starts with a git hash and ends with the revision
  // number in the following format: @{#123456}
  std::string full_revision(version_info::GetLastChange());
  size_t start_position = full_revision.rfind("#") + 1;

  if (start_position == std::string::npos) {
    result.Set(kChromeRevisionNumberField, "0");
  } else {
    size_t length = full_revision.size() - start_position - 1;
    result.Set(kChromeRevisionNumberField,
               full_revision.substr(start_position, length));
  }
  return base::Value(std::move(result));
}