// 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.

#include "ui/base/test/skia_gold_pixel_diff.h"

#include <memory>
#include <string_view>
#include <utility>
#include <vector>

#if BUILDFLAG(IS_WIN)
#include <windows.h>
#endif

#include "base/auto_reset.h"
#include "base/base_paths.h"
#include "base/command_line.h"
#include "base/containers/lru_cache.h"
#include "base/containers/span.h"
#include "base/environment.h"
#include "base/files/file.h"
#include "base/files/file_enumerator.h"
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/json/json_reader.h"
#include "base/json/json_writer.h"
#include "base/logging.h"
#include "base/memory/ptr_util.h"
#include "base/notreached.h"
#include "base/path_service.h"
#include "base/process/launch.h"
#include "base/process/process.h"
#include "base/sequence_checker.h"
#include "base/strings/strcat.h"
#include "base/strings/string_split.h"
#include "base/strings/string_util.h"
#include "base/test/test_switches.h"
#include "base/threading/thread_restrictions.h"
#include "base/values.h"
#include "build/build_config.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/base/test/skia_gold_matching_algorithm.h"
#include "ui/gfx/codec/png_codec.h"
#include "ui/gfx/image/image.h"
#include "ui/snapshot/snapshot.h"

namespace ui::test {

constexpr char kSkiaGoldInstance[] = "chrome";
constexpr char kSkiaGoldPublicInstance[] = "chrome-public";

#if BUILDFLAG(IS_WIN)
constexpr wchar_t kSkiaGoldCtl[] = L"tools/skia_goldctl/win/goldctl.exe";
#elif BUILDFLAG(IS_APPLE)
#if defined(ARCH_CPU_ARM64)
constexpr char kSkiaGoldCtl[] = "tools/skia_goldctl/mac_arm64/goldctl";
#else
constexpr char kSkiaGoldCtl[] = "tools/skia_goldctl/mac_amd64/goldctl";
#endif  // defined(ARCH_CPU_ARM64)
#else
constexpr char kSkiaGoldCtl[] = "tools/skia_goldctl/linux/goldctl";
#endif

constexpr char kBuildRevisionKey[] = "git-revision";

// A dummy build revision used only under a dry run.
constexpr char kDummyBuildRevision[] = "12345";

// The switch keys for tryjob.
constexpr char kIssueKey[] = "gerrit-issue";
constexpr char kPatchSetKey[] = "gerrit-patchset";
constexpr char kJobIdKey[] = "buildbucket-id";
constexpr char kCodeReviewSystemKey[] = "code-review-system";

constexpr char kNoLuciAuth[] = "no-luci-auth";
constexpr char kBypassSkiaGoldFunctionality[] =
    "bypass-skia-gold-functionality";
constexpr char kDryRun[] = "dryrun";

// The switch key for saving png file locally for debugging. This will allow
// the framework to save the screenshot png file to this path.
constexpr char kPngFilePathDebugging[] = "skia-gold-local-png-write-directory";

constexpr char kGoldOutputTriageFormat[] =
    "Untriaged or negative image: https://chrome-gold.skia.org";
constexpr char kPublicTriageLink[] = "https://chrome-public-gold.skia.org";

// The separator used in the names of the screenshots taken on Ash platform.
constexpr char kAshSeparator[] = ".";

// The separator used by non-Ash platforms.
constexpr char kNonAshSeparator[] = "_";

namespace {

base::FilePath GetAbsoluteSrcRelativePath(base::FilePath::StringType path) {
  base::FilePath root_path;
  base::PathService::Get(base::DIR_SRC_TEST_DATA_ROOT, &root_path);
  return base::MakeAbsoluteFilePath(root_path.Append(path));
}

// Append args after program.
// The base::Commandline.AppendArg append the arg at
// the end which doesn't work for us.
void AppendArgsJustAfterProgram(base::CommandLine& cmd,
                                base::CommandLine::StringVector args) {
  base::CommandLine::StringVector& argv =
      const_cast<base::CommandLine::StringVector&>(cmd.argv());
  argv.insert(argv.begin() + 1, args.begin(), args.end());
}

const char* GetPlatformName() {
#if BUILDFLAG(IS_WIN)
  return "windows";
#elif BUILDFLAG(IS_APPLE)
  return "macOS";
#elif BUILDFLAG(IS_LINUX)
  return "linux";
#elif BUILDFLAG(IS_CHROMEOS)
  return "ash";
#endif
}

const char* GetArchName() {
#if defined(ARCH_CPU_X86)
  return "x86";
#elif defined(ARCH_CPU_X86_64)
  return "x86_64";
#elif defined(ARCH_CPU_ARM64)
  return "Arm64";
#else
  LOG(WARNING) << "Unknown Processor.";
  return "unknown";
#endif
}

void FillInSystemEnvironment(TestEnvironmentMap& test_environment) {
  // Fill in a default key and assert it was not already filled in.
  auto CheckInsertDefaultKey = [&test_environment](TestEnvironmentKey key,
                                                   std::string value) {
    bool did_insert = false;
    std::tie(std::ignore, did_insert) = test_environment.insert({key, value});
    CHECK(did_insert);
  };

  CheckInsertDefaultKey(TestEnvironmentKey::kSystem, GetPlatformName());
  CheckInsertDefaultKey(TestEnvironmentKey::kProcessor, GetArchName());
}

const char* TestEnvironmentKeyToString(TestEnvironmentKey key) {
  switch (key) {
    case TestEnvironmentKey::kSystem:
      return "system";
    case TestEnvironmentKey::kProcessor:
      return "processor";
    case TestEnvironmentKey::kSystemVersion:
      return "system_version";
    case TestEnvironmentKey::kGpuDriverVendor:
      return "driver_vendor";
    case TestEnvironmentKey::kGpuDriverVersion:
      return "driver_version";
    case TestEnvironmentKey::kGlRenderer:
      return "gl_renderer";
  }

  NOTREACHED();
}

bool WriteTestEnvironmentToFile(const TestEnvironmentMap& test_environment,
                                const base::FilePath& keys_file) {
  base::DictValue ds;
  for (const auto& [key, value] : test_environment) {
    ds.Set(TestEnvironmentKeyToString(key), value);
  }

  base::Value root(std::move(ds));
  std::string content = base::WriteJson(root).value_or("");
  base::ScopedAllowBlockingForTesting allow_blocking;
  base::File file(keys_file, base::File::Flags::FLAG_CREATE_ALWAYS |
                                 base::File::Flags::FLAG_WRITE);
  bool ok = file.WriteAndCheck(0, base::as_byte_span(content));
  file.Close();
  if (!ok) {
    LOG(ERROR) << "Writing the keys file to temporary file failed."
               << "File path:" << keys_file.AsUTF8Unsafe();
    return false;
  }
  return true;
}

bool BotModeEnabled(const base::CommandLine* command_line) {
  std::unique_ptr<base::Environment> env(base::Environment::Create());
  return command_line->HasSwitch(switches::kTestLauncherBotMode) ||
         env->HasVar("CHROMIUM_TEST_LAUNCHER_BOT_MODE");
}

const char* GetDiffGoldInstance() {
  // TODO(skbug.com/10610): Decide whether to use the public or non-public
  // instance once authentication is fixed for the non-public instance.
  return kSkiaGoldPublicInstance;
}

// Non-empty test corpus and environment map.
using SessionCacheKey = std::pair<std::string, TestEnvironmentMap>;
using SessionCache =
    base::LRUCache<SessionCacheKey, std::unique_ptr<SkiaGoldPixelDiff>>;
SessionCache g_sessions(SessionCache::NO_AUTO_EVICT);

// If present, overrides |LaunchProcess|.
SkiaGoldPixelDiff::LaunchProcessCallback g_custom_launch_process;

int LaunchProcess(const base::CommandLine& cmdline) {
  if (g_custom_launch_process) {
    return g_custom_launch_process.Run(cmdline);
  }

  std::string output;
  int exit_code = 0;
  CHECK(base::GetAppOutputWithExitCode(cmdline, &output, &exit_code));
  LOG(INFO) << output;
  // Gold binary only provides internal triage link which doesn't work
  // for non-Googlers. So we construct another link that works for
  // non google account committers.
  size_t triage_location_start = output.find(kGoldOutputTriageFormat);
  if (triage_location_start != std::string::npos) {
    size_t triage_location_end = output.find("\n", triage_location_start);
    LOG(WARNING) << "For committers not using @google.com account, triage "
                 << "using the following link: " << kPublicTriageLink
                 << output.substr(
                        triage_location_start + strlen(kGoldOutputTriageFormat),
                        triage_location_end - triage_location_start -
                            strlen(kGoldOutputTriageFormat));
  }
  return exit_code;
}

}  // namespace

SkiaGoldPixelDiff::SkiaGoldPixelDiff() = default;

SkiaGoldPixelDiff::~SkiaGoldPixelDiff() = default;

SkiaGoldPixelDiff::ScopedSessionCacheForTesting::
    ScopedSessionCacheForTesting() {
  g_sessions.Clear();
}

SkiaGoldPixelDiff::ScopedSessionCacheForTesting::
    ~ScopedSessionCacheForTesting() {
  g_sessions.Clear();
}

// static
SkiaGoldPixelDiff* SkiaGoldPixelDiff::GetSession(
    const std::optional<std::string>& corpus,
    TestEnvironmentMap test_environment) {
  FillInSystemEnvironment(test_environment);
  const std::string corpus_name = corpus.value_or("gtest-pixeltests");
  CHECK(!corpus_name.empty());

  SessionCacheKey key(corpus_name, std::move(test_environment));
  auto it = g_sessions.Get(key);
  if (it == g_sessions.end()) {
    // private ctor.
    auto pixel_diff = base::WrapUnique(new SkiaGoldPixelDiff());
    pixel_diff->Init(corpus_name, key.second);
    it = g_sessions.Put(std::move(key), std::move(pixel_diff));
  }

  CHECK(it != g_sessions.end());
  return it->second.get();
}

// static
base::AutoReset<SkiaGoldPixelDiff::LaunchProcessCallback>
SkiaGoldPixelDiff::OverrideLaunchProcessForTesting(
    SkiaGoldPixelDiff::LaunchProcessCallback custom_launch_process) {
  base::AutoReset auto_reset(&g_custom_launch_process,
                             std::move(custom_launch_process));
  return auto_reset;
}

// static
std::string SkiaGoldPixelDiff::GetPlatform() {
  return GetPlatformName();
}

void SkiaGoldPixelDiff::InitSkiaGold() const {
  if (base::CommandLine::ForCurrentProcess()->HasSwitch(
          kBypassSkiaGoldFunctionality)) {
    LOG(WARNING) << "Bypassing Skia Gold initialization due to "
                 << "--bypass-skia-gold-functionality being present.";
    return;
  }
  base::ScopedAllowBlockingForTesting allow_blocking;
  base::CommandLine cmd(GetAbsoluteSrcRelativePath(kSkiaGoldCtl));
  cmd.AppendSwitchPath("work-dir", working_dir_);
  if (luci_auth_) {
    cmd.AppendArg("--luci");
  }
  AppendArgsJustAfterProgram(cmd, {FILE_PATH_LITERAL("auth")});
  base::CommandLine::StringType cmd_str = cmd.GetCommandLineString();
  LOG(INFO) << "Skia Gold Auth Commandline: " << cmd_str;
  int exit_code = LaunchProcess(cmd);
  ASSERT_EQ(exit_code, 0);

  base::FilePath json_temp_file =
      working_dir_.Append(FILE_PATH_LITERAL("keys_file.txt"));
  ASSERT_TRUE(WriteTestEnvironmentToFile(test_environment_, json_temp_file));
  base::FilePath failure_temp_file =
      working_dir_.Append(FILE_PATH_LITERAL("failure.log"));
  cmd = base::CommandLine(GetAbsoluteSrcRelativePath(kSkiaGoldCtl));
  cmd.AppendSwitchASCII("instance", kSkiaGoldInstance);
  cmd.AppendSwitchPath("work-dir", working_dir_);
  cmd.AppendSwitchPath("keys-file", json_temp_file);
  cmd.AppendSwitchPath("failure-file", failure_temp_file);
  cmd.AppendSwitch("passfail");
  cmd.AppendSwitchASCII("commit", build_revision_);
  // This handles the logic for tryjob.
  if (issue_.length()) {
    cmd.AppendSwitchASCII("issue", issue_);
    cmd.AppendSwitchASCII("patchset", patchset_);
    cmd.AppendSwitchASCII("jobid", job_id_);
    cmd.AppendSwitchASCII("crs", code_review_system_);
    cmd.AppendSwitchASCII("cis", "buildbucket");
  }

  AppendArgsJustAfterProgram(
      cmd, {FILE_PATH_LITERAL("imgtest"), FILE_PATH_LITERAL("init")});
  cmd_str = cmd.GetCommandLineString();
  LOG(INFO) << "Skia Gold imgtest init Commandline: " << cmd_str;
  exit_code = LaunchProcess(cmd);
  ASSERT_EQ(exit_code, 0);
}

void SkiaGoldPixelDiff::Init(const std::string& corpus,
                             TestEnvironmentMap test_environment) {
  auto* cmd_line = base::CommandLine::ForCurrentProcess();
  if (!BotModeEnabled(base::CommandLine::ForCurrentProcess())) {
    is_dry_run_ = true;
  }

  ASSERT_TRUE(cmd_line->HasSwitch(kBuildRevisionKey) || is_dry_run_)
      << "Missing switch " << kBuildRevisionKey;

  // Use the dummy revision code for dry run.
  build_revision_ = is_dry_run_
                        ? kDummyBuildRevision
                        : cmd_line->GetSwitchValueASCII(kBuildRevisionKey);

  ASSERT_TRUE(
      cmd_line->HasSwitch(kIssueKey) && cmd_line->HasSwitch(kPatchSetKey) &&
          cmd_line->HasSwitch(kJobIdKey) ||
      !cmd_line->HasSwitch(kIssueKey) && !cmd_line->HasSwitch(kPatchSetKey) &&
          !cmd_line->HasSwitch(kJobIdKey))
      << "Missing switch. If it's running for tryjob, you should pass --"
      << kIssueKey << " --" << kPatchSetKey << " --" << kJobIdKey
      << ". Otherwise, do not pass any one of them.";
  if (cmd_line->HasSwitch(kIssueKey)) {
    issue_ = cmd_line->GetSwitchValueASCII(kIssueKey);
    patchset_ = cmd_line->GetSwitchValueASCII(kPatchSetKey);
    job_id_ = cmd_line->GetSwitchValueASCII(kJobIdKey);
    code_review_system_ = cmd_line->GetSwitchValueASCII(kCodeReviewSystemKey);
    if (code_review_system_.empty()) {
      code_review_system_ = "gerrit";
    }
  }
  if (cmd_line->HasSwitch(kNoLuciAuth) || is_dry_run_) {
    luci_auth_ = false;
  }
  initialized_ = true;
  corpus_ = corpus;
  test_environment_ = std::move(test_environment);
  base::ScopedAllowBlockingForTesting allow_blocking;
  base::CreateNewTempDirectory(FILE_PATH_LITERAL("SkiaGoldTemp"),
                               &working_dir_);

  InitSkiaGold();
}

bool SkiaGoldPixelDiff::UploadToSkiaGoldServer(
    const base::FilePath& local_file_path,
    const std::string& remote_golden_image_name,
    const SkiaGoldMatchingAlgorithm* algorithm) const {
  // Copy the png file to another place for local debugging.
  base::CommandLine* process_command_line =
      base::CommandLine::ForCurrentProcess();
  if (process_command_line->HasSwitch(kPngFilePathDebugging)) {
    base::FilePath path =
        process_command_line->GetSwitchValuePath(kPngFilePathDebugging);
    if (!base::PathExists(path)) {
      base::CreateDirectory(path);
    }
    base::FilePath filepath;
    if (remote_golden_image_name.length() <= 4 ||
        (remote_golden_image_name.length() > 4 &&
         remote_golden_image_name.substr(remote_golden_image_name.length() -
                                         4) != ".png")) {
      filepath = path.AppendASCII(remote_golden_image_name + ".png");
    } else {
      filepath = path.AppendASCII(remote_golden_image_name);
    }
    base::CopyFile(local_file_path, filepath);
  }

  if (process_command_line->HasSwitch(kBypassSkiaGoldFunctionality)) {
    LOG(WARNING) << "Bypassing Skia Gold comparison due to "
                 << "--bypass-skia-gold-functionality being present.";
    return true;
  }

  base::ScopedAllowBlockingForTesting allow_blocking;
  base::CommandLine cmd(GetAbsoluteSrcRelativePath(kSkiaGoldCtl));
  cmd.AppendSwitchASCII("test-name", remote_golden_image_name);
  cmd.AppendSwitchASCII("corpus", corpus_);
  cmd.AppendSwitchPath("png-file", local_file_path);
  cmd.AppendSwitchPath("work-dir", working_dir_);
  if (is_dry_run_) {
    cmd.AppendSwitch(kDryRun);
  }

  if (algorithm)
    algorithm->AppendAlgorithmToCmdline(cmd);

  AppendArgsJustAfterProgram(
      cmd, {FILE_PATH_LITERAL("imgtest"), FILE_PATH_LITERAL("add")});
  base::CommandLine::StringType cmd_str = cmd.GetCommandLineString();
  LOG(INFO) << "Skia Gold Commandline: " << cmd_str;
  int exit_code = LaunchProcess(cmd);
  return exit_code == 0;
}

// static
std::string SkiaGoldPixelDiff::GetGoldenImageName(
    const std::string& test_suite_name,
    const std::string& test_name,
    const std::optional<std::string>& suffix) {
  std::vector<std::string_view> parts;

  // Test suites can have "/" in their names from a parameterization
  // instantiation, which isn't allowed in file names.
  auto test_suite_parts = base::SplitStringPiece(
      test_suite_name, "/", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL);
  for (const auto& test_suite_part : test_suite_parts) {
    parts.push_back(test_suite_part);
  }

  // Tests can have "/" in their names from a parameterization value, which
  // isn't allowed in file names.
  auto test_name_parts = base::SplitStringPiece(
      test_name, "/", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL);
  for (const auto& test_name_part : test_name_parts) {
    parts.push_back(test_name_part);
  }

  if (suffix.has_value()) {
    parts.push_back(suffix.value());
  }

  const char* separator =
      GetPlatform() == std::string("ash") ? kAshSeparator : kNonAshSeparator;
  return base::JoinString(parts, separator);
}

// static
std::string SkiaGoldPixelDiff::GetGoldenImageName(
    const ::testing::TestInfo* test_info,
    const std::optional<std::string>& suffix) {
  return GetGoldenImageName(test_info->test_suite_name(), test_info->name(),
                            suffix);
}

bool SkiaGoldPixelDiff::CompareScreenshot(
    const std::string& golden_image_name,
    const SkBitmap& bitmap,
    const SkiaGoldMatchingAlgorithm* algorithm) const {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  CHECK(initialized_) << "Initialize the class before using this method.";
  std::optional<std::vector<uint8_t>> output =
      gfx::PNGCodec::EncodeBGRASkBitmap(bitmap, /*discard_transparency=*/true);
  if (!output) {
    LOG(ERROR) << "Encoding SkBitmap to PNG format failed.";
    return false;
  }

  CHECK_EQ(golden_image_name.find_first_of(" /"), std::string::npos)
      << " a golden image name should not contain any space or back slash: "
      << golden_image_name;

  base::ScopedAllowBlockingForTesting allow_blocking;
  base::FilePath temporary_path = working_dir_.Append(
      base::FilePath::FromUTF8Unsafe(golden_image_name + ".png"));
  base::File file(temporary_path, base::File::Flags::FLAG_CREATE_ALWAYS |
                                      base::File::Flags::FLAG_WRITE);
  bool ok = file.WriteAndCheck(0, output.value());
  file.Close();
  if (!ok) {
    LOG(ERROR) << "Writing the PNG image to temporary file failed."
               << "File path:" << temporary_path.AsUTF8Unsafe();
    return false;
  }
  bool success =
      UploadToSkiaGoldServer(temporary_path, golden_image_name, algorithm);
  if (!success && is_dry_run_) {
    GenerateLocalDiff(golden_image_name, temporary_path);
  }

  return success;
}

void SkiaGoldPixelDiff::GenerateLocalDiff(
    const std::string& remote_golden_image_name,
    const base::FilePath& test_output_path) const {
  base::CommandLine* process_command_line =
      base::CommandLine::ForCurrentProcess();
  if (!process_command_line->HasSwitch(kPngFilePathDebugging)) {
    LOG(WARNING)
        << "Please use --" << kPngFilePathDebugging
        << " to generate local diff images for this screenshot mismatch.";
    return;
  }
  base::FilePath path =
      process_command_line->GetSwitchValuePath(kPngFilePathDebugging);
  if (!base::PathExists(path)) {
    base::CreateDirectory(path);
  }

  auto output_dir = path.AppendASCII(remote_golden_image_name);
  if (!base::PathExists(output_dir)) {
    base::CreateDirectory(output_dir);
  }

  // TODO(skbug.com/10611): Remove this temporary work dir and instead just use
  // |working_dir_| once `goldctl diff` stops clobbering the auth files in the
  // provided work directory.
  auto temp_dir = base::ScopedTempDir();
  if (!temp_dir.CreateUniqueTempDir()) {
    LOG(WARNING) << "Failed to create a local diff temp work dir.";
    return;
  }
  if (!base::CopyDirectory(working_dir_, temp_dir.GetPath(), true)) {
    LOG(WARNING) << "Failed to copy working dir to local diff temp work dir.";
    return;
  }
  // |CopyDirectory| will copy the source directory itself, rather than its
  // contents, so we need to locate the copy.
  base::FilePath temp_work_dir =
      base::FileEnumerator(temp_dir.GetPath(), false,
                           base::FileEnumerator::DIRECTORIES,
                           working_dir_.BaseName().value())
          .Next();
  CHECK(!temp_work_dir.empty());

  base::CommandLine cmd(GetAbsoluteSrcRelativePath(kSkiaGoldCtl));
  cmd.AppendSwitchASCII("corpus", corpus_);
  cmd.AppendSwitchASCII("instance", GetDiffGoldInstance());
  cmd.AppendSwitchASCII("test", remote_golden_image_name);
  cmd.AppendSwitchPath("input", test_output_path);
  cmd.AppendSwitchPath("work-dir", temp_work_dir);
  cmd.AppendSwitchPath("out-dir", output_dir);
  AppendArgsJustAfterProgram(cmd, {FILE_PATH_LITERAL("diff")});

  base::CommandLine::StringType cmd_str = cmd.GetCommandLineString();
  LOG(INFO) << "Skia Gold Commandline: " << cmd_str;
  int exit_code = LaunchProcess(cmd);
  CHECK_EQ(exit_code, 0);

  struct DiffLink {
    base::FilePath png_path;
    base::Time mtime;
  };
  struct DiffLinks {
    std::optional<DiffLink> given_image;
    std::optional<DiffLink> closest_image;
    std::optional<DiffLink> diff_image;
  };

  auto AssignIfNewer = [](std::optional<DiffLink>& image,
                          const base::FilePath& png_path,
                          const base::Time& mtime) {
    if (!image.has_value() || mtime > image->mtime) {
      image = {
          .png_path = png_path,
          .mtime = mtime,
      };
    }
  };

  DiffLinks results;
  base::FileEnumerator e(output_dir, false, base::FileEnumerator::FILES,
                         FILE_PATH_LITERAL("*.png"));
  for (base::FilePath name = e.Next(); !name.empty(); name = e.Next()) {
    base::Time mtime = e.GetInfo().GetLastModifiedTime();
    std::string png_file_name = name.BaseName().MaybeAsASCII();
    if (png_file_name.starts_with("input-")) {
      AssignIfNewer(results.given_image, name, mtime);
    } else if (png_file_name.starts_with("closest-")) {
      AssignIfNewer(results.closest_image, name, mtime);
    } else if (png_file_name == "diff.png") {
      AssignIfNewer(results.diff_image, name, mtime);
    }
  }

  auto FormatPathForTerminalOutput =
      [](std::optional<DiffLink>& path) -> std::optional<std::string> {
    if (!path.has_value()) {
      return std::nullopt;
    }

    base::FilePath path_absolute = path.value().png_path;
    if (!path_absolute.IsAbsolute()) {
      path_absolute = base::PathService::CheckedGet(base::DIR_CURRENT)
                          .Append(path_absolute);
    }

    base::FilePath path_normalized;
    if (!base::NormalizeFilePath(path_absolute, &path_normalized)) {
      return {path->png_path.MaybeAsASCII()};
    }

    return base::StrCat(
        {path_normalized.IsNetwork() ? "file:" : "file:///",
         path_normalized.NormalizePathSeparatorsTo(FILE_PATH_LITERAL('/'))
             .MaybeAsASCII()});
  };

  static constexpr char kFailureMessage[] = "Unable to retrieve link";
  LOG(INFO) << "\n  Generated image: "
            << FormatPathForTerminalOutput(results.given_image)
                   .value_or(kFailureMessage)
            << "\n  Closest image: "
            << FormatPathForTerminalOutput(results.closest_image)
                   .value_or(kFailureMessage)
            << "\n  Diff image: "
            << FormatPathForTerminalOutput(results.diff_image)
                   .value_or(kFailureMessage)
            << "\n";
}

}  // namespace ui::test
