chromium/ios/chrome/browser/sessions/model/session_loading_unittest.mm

// Copyright 2023 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/browser/sessions/model/session_loading.h"

#import "base/containers/span.h"
#import "base/files/file_path.h"
#import "base/files/scoped_temp_dir.h"
#import "base/strings/stringprintf.h"
#import "base/test/metrics/histogram_tester.h"
#import "ios/chrome/browser/sessions/model/proto_util.h"
#import "ios/chrome/browser/sessions/model/session_constants.h"
#import "ios/chrome/browser/sessions/model/session_internal_util.h"
#import "ios/web/public/session/proto/metadata.pb.h"
#import "ios/web/public/session/proto/storage.pb.h"
#import "ios/web/public/web_state_id.h"
#import "testing/gtest_mac.h"
#import "testing/platform_test.h"

namespace {

// Information about a single tab.
//
// The default constructor represents a valid tab without opener.
struct TabInfo {
  const int opener_index = -1;
  const int opener_navigation_index = -1;
  const int navigation_item_count = 1;
  const std::optional<web::WebStateID> unique_identifier;
};

// Information about a tab group.
//
// The default constructor represents an invalid tab group.
struct GroupInfo {
  const int start = -1;
  const int count = 0;
};

// Information about a session.
//
// The default constructor represents a valid session without tabs.
struct SessionInfo {
  const int active_index = -1;
  const int pinned_tab_count = 0;
  const base::span<const TabInfo> tabs;
  const base::span<const GroupInfo> groups;
};

// Constants representing the default session used for tests.
constexpr TabInfo kTabs[] = {
    TabInfo{},
    TabInfo{},
    TabInfo{},
    TabInfo{.opener_index = 1, .opener_navigation_index = 1},
    TabInfo{.opener_index = 3, .opener_navigation_index = 1},
};

constexpr SessionInfo kSessionInfo = {
    .active_index = 1,
    .pinned_tab_count = 2,
    .tabs = base::make_span(kTabs),
};

// Returns a test URL (as a string) for a item with `identifier`.
std::string TestUrlForIdentifier(web::WebStateID identifier) {
  return base::StringPrintf("https://example.com/%d", identifier.identifier());
}

// Returns a WebStateListStorage representing an empty session.
ios::proto::WebStateListStorage EmptySessionStorage() {
  ios::proto::WebStateListStorage storage;
  storage.set_active_index(-1);
  return storage;
}

// Creates a WebStateListStorage from a SessionInfo.
ios::proto::WebStateListStorage StorageFromSessionInfo(SessionInfo info) {
  DCHECK_LT(info.active_index, static_cast<int>(info.tabs.size()));
  DCHECK_LE(info.pinned_tab_count, static_cast<int>(info.tabs.size()));

  ios::proto::WebStateListStorage storage;
  storage.set_active_index(info.active_index);
  storage.set_pinned_item_count(info.pinned_tab_count);
  for (const TabInfo& tab : info.tabs) {
    ios::proto::WebStateListItemStorage* item_storage = storage.add_items();
    const web::WebStateID web_state_id = tab.unique_identifier.has_value()
                                             ? tab.unique_identifier.value()
                                             : web::WebStateID::NewUnique();
    item_storage->set_identifier(web_state_id.identifier());
    if (tab.opener_index != -1 && tab.opener_navigation_index != -1) {
      DCHECK_GE(tab.opener_index, 0);
      DCHECK_LT(tab.opener_index, static_cast<int>(info.tabs.size()));

      ios::proto::OpenerStorage* opener_storage =
          item_storage->mutable_opener();

      opener_storage->set_index(tab.opener_index);
      opener_storage->set_navigation_index(tab.opener_navigation_index);
    }

    if (tab.navigation_item_count > 0) {
      web::proto::WebStateMetadataStorage* item_metadata =
          item_storage->mutable_metadata();

      item_metadata->set_navigation_item_count(tab.navigation_item_count);
      item_metadata->mutable_active_page()->set_page_url(
          TestUrlForIdentifier(web_state_id));
    }
  }
  for (const GroupInfo& group : info.groups) {
    ios::proto::TabGroupStorage* group_storage = storage.add_groups();
    ios::proto::RangeIndex* range = group_storage->mutable_range();
    range->set_start(group.start);
    range->set_count(group.count);
  }
  return storage;
}

// Writes the session described by `session_info` to disk at `path`, and
// stores the session metadata to `storage` (can be used to compare what
// is read to what is saved).
bool WriteSessionStorage(const base::FilePath& path,
                         const SessionInfo& session_info,
                         ios::proto::WebStateListStorage& storage) {
  storage = StorageFromSessionInfo(session_info);

  const base::FilePath metadata_path = path.Append(kSessionMetadataFilename);
  bool success = ios::sessions::WriteProto(metadata_path, storage);

  const int item_size = storage.items_size();
  for (int index = 0; index < item_size; ++index) {
    const web::WebStateID item_id =
        web::WebStateID::FromSerializedValue(storage.items(index).identifier());

    const base::FilePath item_dir =
        ios::sessions::WebStateDirectory(path, item_id);

    // Write the item storage (can be a default protobuf message since the
    // value is never read by LoadSessionStorage, only the presence of the
    // file is checked).
    const base::FilePath item_storage_path =
        item_dir.Append(kWebStateStorageFilename);

    web::proto::WebStateStorage item_storage;
    success |= ios::sessions::WriteProto(item_storage_path, item_storage);
  }

  return success;
}

}  // namespace

class SessionLoadingTest : public PlatformTest {
 protected:
  // Used to verify histogram logging.
  base::HistogramTester histogram_tester_;
};

// Tests that WebStateDirectory returns a correct value that depends on the
// identifier and is a sub-directory of the input.
TEST_F(SessionLoadingTest, WebStateDirectory) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  // Create two distinct WebStateID values.
  const web::WebStateID id1 = web::WebStateID::NewUnique();
  const web::WebStateID id2 = web::WebStateID::NewUnique();
  ASSERT_NE(id1, id2);

  // Check that the directories are distinct and are below `root`.
  const base::FilePath dir1 = ios::sessions::WebStateDirectory(root, id1);
  const base::FilePath dir2 = ios::sessions::WebStateDirectory(root, id2);

  // Returned directories should be below `root`.
  EXPECT_TRUE(root.IsParent(dir1));
  EXPECT_TRUE(root.IsParent(dir2));

  // Returned directies should be distinct if ids are distincts.
  EXPECT_NE(dir1, dir2);

  // Returned directory must only depend on `root` and the identifier.
  EXPECT_EQ(dir1, ios::sessions::WebStateDirectory(root, id1));
  EXPECT_EQ(dir2, ios::sessions::WebStateDirectory(root, id2));
}

// Tests that FilterItems correctly remove items according to RemovingIndexes
// and updates the WebStateListStorage invariants.
TEST_F(SessionLoadingTest, FilterItems) {
  const ios::proto::WebStateListStorage storage =
      StorageFromSessionInfo(kSessionInfo);

  // Check that the session is not modified if no item is closed.
  EXPECT_EQ(storage, ios::sessions::FilterItems(storage, RemovingIndexes{}));

  // Check that closing all items return an empty session.
  const ios::proto::WebStateListStorage empty_storage = EmptySessionStorage();
  EXPECT_EQ(empty_storage, ios::sessions::FilterItems(
                               storage, RemovingIndexes{0, 1, 2, 3, 4}));

  // Check that the opener indexes are correctly updated when removing
  // or changing indexes of openers.
  const ios::proto::WebStateListStorage filtered =
      ios::sessions::FilterItems(storage, RemovingIndexes{1, 2});

  EXPECT_EQ(filtered.active_index(), 1);
  EXPECT_EQ(filtered.pinned_item_count(), 1);
  ASSERT_EQ(filtered.items_size(), 3);

  EXPECT_EQ(filtered.items(0).identifier(), storage.items(0).identifier());
  EXPECT_EQ(filtered.items(1).identifier(), storage.items(3).identifier());
  EXPECT_EQ(filtered.items(2).identifier(), storage.items(4).identifier());

  // The opener of tab 3 was closed, so the corresponding item (at index 1)
  // should have no opener.
  EXPECT_FALSE(filtered.items(1).has_opener());

  // The opener of tab 4 was moved to index 1, so the opener index should
  // have been updated.
  EXPECT_EQ(filtered.items(2).opener().index(), 1);
  EXPECT_EQ(filtered.items(2).opener().navigation_index(), 1);
}

// Tests that LoadSessionStorage correctly load a valid session.
TEST_F(SessionLoadingTest, LoadSessionStorage) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  // Write the session described by kSessionInfo.
  ios::proto::WebStateListStorage session;
  ASSERT_TRUE(WriteSessionStorage(root, kSessionInfo, session));

  // Load the session and check it is correct.
  const ios::proto::WebStateListStorage loaded =
      ios::sessions::LoadSessionStorage(root);
  EXPECT_EQ(loaded, session);

  for (const auto& item : loaded.items()) {
    const web::WebStateID item_id =
        web::WebStateID::FromSerializedValue(item.identifier());

    // Check that the item has been correctly loaded.
    ASSERT_TRUE(item.has_metadata());
    ASSERT_EQ(item.metadata().active_page().page_url(),
              TestUrlForIdentifier(item_id));
  }

  // Expect a log of 0 duplicate.
  histogram_tester_.ExpectUniqueSample(
      "Tabs.DroppedDuplicatesCountOnSessionRestore", 0, 1);
}

// Tests that LoadSessionStorage correctly loads a valid session, removing
// items with no navigation item count.
TEST_F(SessionLoadingTest, LoadSessionStorage_FilterEmptyItems) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  // A session with some tabs without any navigation (expected to be
  // dropped when loading the session from disk).
  const TabInfo tabs[] = {
      TabInfo{.navigation_item_count = 0},
      TabInfo{},
      TabInfo{.navigation_item_count = 0},
      TabInfo{.opener_index = 1, .opener_navigation_index = 1},
      TabInfo{.opener_index = 3, .opener_navigation_index = 1},
  };

  const SessionInfo session_info = {
      .active_index = 1,
      .pinned_tab_count = 2,
      .tabs = base::make_span(tabs),
  };

  // Write the session.
  ios::proto::WebStateListStorage session;
  ASSERT_TRUE(WriteSessionStorage(root, session_info, session));

  // Load the session and check it is correct.
  const ios::proto::WebStateListStorage loaded =
      ios::sessions::LoadSessionStorage(root);
  EXPECT_EQ(loaded,
            ios::sessions::FilterItems(session, RemovingIndexes({0, 2})));

  for (const auto& item : loaded.items()) {
    const web::WebStateID item_id =
        web::WebStateID::FromSerializedValue(item.identifier());

    // Check that the item has been correctly loaded.
    ASSERT_TRUE(item.has_metadata());
    EXPECT_EQ(item.metadata().active_page().page_url(),
              TestUrlForIdentifier(item_id));
  }

  // Expect a log of 0 duplicate.
  histogram_tester_.ExpectUniqueSample(
      "Tabs.DroppedDuplicatesCountOnSessionRestore", 0, 1);
}

// Tests that LoadSessionStorage correctly loads a valid session, removing
// duplicates (keeping the first occurrence).
TEST_F(SessionLoadingTest, LoadSessionStorage_FilterDuplicateItems) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  const web::WebStateID same_web_state_id = web::WebStateID::NewUnique();
  const TabInfo tabs[] = {
      TabInfo{.unique_identifier = same_web_state_id},
      TabInfo{.unique_identifier = same_web_state_id},
  };
  const SessionInfo session_info = {
      .active_index = 1,
      .pinned_tab_count = 1,
      .tabs = base::make_span(tabs),
  };

  // Write the session described by session_info.
  ios::proto::WebStateListStorage session;
  ASSERT_TRUE(WriteSessionStorage(root, session_info, session));

  // Load the session and check it is correct.
  const ios::proto::WebStateListStorage loaded =
      ios::sessions::LoadSessionStorage(root);
  // Check that the duplicate tab (the second occurrence) is dropped.
  EXPECT_EQ(loaded, ios::sessions::FilterItems(session, RemovingIndexes({1})));

  for (const auto& item : loaded.items()) {
    const web::WebStateID item_id =
        web::WebStateID::FromSerializedValue(item.identifier());

    // Check that the item has been correctly loaded.
    ASSERT_TRUE(item.has_metadata());
    EXPECT_EQ(item.metadata().active_page().page_url(),
              TestUrlForIdentifier(item_id));
  }

  // Expect a log of 1 duplicate.
  histogram_tester_.ExpectUniqueSample(
      "Tabs.DroppedDuplicatesCountOnSessionRestore", 1, 1);
}

// Tests that LoadSessionStorage correctly load an empty session.
TEST_F(SessionLoadingTest, LoadSessionStorage_EmptySession) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  // Write the session described by an empty SessionInfo object.
  ios::proto::WebStateListStorage session;
  ASSERT_TRUE(WriteSessionStorage(root, SessionInfo{}, session));

  // Load the session and check it is correct.
  const ios::proto::WebStateListStorage loaded =
      ios::sessions::LoadSessionStorage(root);
  EXPECT_EQ(loaded, session);
  EXPECT_EQ(loaded.items_size(), 0);

  // Expect a log of 0 duplicate.
  histogram_tester_.ExpectUniqueSample(
      "Tabs.DroppedDuplicatesCountOnSessionRestore", 0, 1);
}

// Tests that LoadSessionStorage returns an empty session if the session is
// missing.
TEST_F(SessionLoadingTest, LoadSessionStorage_MissingSession) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  // Load the session and check it is empty.
  const ios::proto::WebStateListStorage loaded =
      ios::sessions::LoadSessionStorage(root);
  EXPECT_EQ(loaded, EmptySessionStorage());
  EXPECT_EQ(loaded.items_size(), 0);

  // Expect no log as there was no session. We never got to complete the
  // filtering stage.
  histogram_tester_.ExpectTotalCount(
      "Tabs.DroppedDuplicatesCountOnSessionRestore", 0);
}

// Tests that LoadSessionStorage returns an empty session if some of the
// items data is missing.
TEST_F(SessionLoadingTest, LoadSessionStorage_MissingItemStorage) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  // Write the session described by kSessionInfo.
  ios::proto::WebStateListStorage session;
  ASSERT_TRUE(WriteSessionStorage(root, kSessionInfo, session));

  // Delete one item storage.
  ASSERT_GT(session.items_size(), 0);
  const web::WebStateID item_id =
      web::WebStateID::FromSerializedValue(session.items(0).identifier());

  const base::FilePath item_metadata_path =
      ios::sessions::WebStateDirectory(root, item_id)
          .Append(kWebStateStorageFilename);

  ASSERT_TRUE(ios::sessions::DeleteRecursively(item_metadata_path));

  // Load the session and check it is empty.
  const ios::proto::WebStateListStorage loaded =
      ios::sessions::LoadSessionStorage(root);
  EXPECT_EQ(loaded, EmptySessionStorage());
  EXPECT_EQ(loaded.items_size(), 0);

  // Expect no log as there was a missing item storage. We never got to complete
  // the filtering stage.
  histogram_tester_.ExpectTotalCount(
      "Tabs.DroppedDuplicatesCountOnSessionRestore", 0);
}

// Tests that LoadSessionStorage returns an empty session if the identifiers
// are invalid (corrupt session).
TEST_F(SessionLoadingTest, LoadSessionStorage_InvalidIdentifiers) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  // Session with an invalid unique identifier.
  const TabInfo tabs[] = {
      TabInfo{.unique_identifier = web::WebStateID()},
  };

  const SessionInfo session_info = {
      .active_index = 0,
      .tabs = base::make_span(tabs),
  };

  ios::proto::WebStateListStorage session;
  ASSERT_TRUE(WriteSessionStorage(root, session_info, session));

  // Load the session and check it is correct.
  const ios::proto::WebStateListStorage loaded =
      ios::sessions::LoadSessionStorage(root);
  EXPECT_EQ(loaded, EmptySessionStorage());
  EXPECT_EQ(loaded.items_size(), 0);

  // Expect no log as there was an invalid identifier. We never got to complete
  // the filtering stage.
  histogram_tester_.ExpectTotalCount(
      "Tabs.DroppedDuplicatesCountOnSessionRestore", 0);
}

// Tests that LoadSessionStorage correctly recreates the groups even after the
// duplicates are dropped.
TEST_F(SessionLoadingTest, LoadSessionStorage_FilterDuplicateItemsWithGroups) {
  base::ScopedTempDir scoped_temp_dir;
  ASSERT_TRUE(scoped_temp_dir.CreateUniqueTempDir());
  const base::FilePath root = scoped_temp_dir.GetPath();

  web::WebStateID same_web_state_id = web::WebStateID::NewUnique();
  web::WebStateID other_web_state_id = web::WebStateID::NewUnique();
  web::WebStateID another_web_state_id = web::WebStateID::NewUnique();
  web::WebStateID yet_another_web_state_id = web::WebStateID::NewUnique();
  web::WebStateID and_another_web_state_id = web::WebStateID::NewUnique();
  const TabInfo tabs[] = {
      TabInfo{.unique_identifier = same_web_state_id},
      TabInfo{.unique_identifier = same_web_state_id},
      TabInfo{.unique_identifier = other_web_state_id},        // In 1st group
      TabInfo{.unique_identifier = same_web_state_id},         // In 1st group
      TabInfo{.unique_identifier = another_web_state_id},      // In 2nd group
      TabInfo{.unique_identifier = yet_another_web_state_id},  // In 2nd group
      TabInfo{.unique_identifier = same_web_state_id},         // In 3rd group
      TabInfo{.unique_identifier = same_web_state_id},         // In 4th group
      TabInfo{.unique_identifier = and_another_web_state_id},  // In 4th group
  };
  const GroupInfo groups[] = {
      GroupInfo{.start = 2, .count = 2},
      GroupInfo{.start = 4, .count = 2},
      GroupInfo{.start = 6, .count = 1},
      GroupInfo{.start = 7, .count = 2},
  };
  const SessionInfo session_info = {
      .active_index = 0,
      .pinned_tab_count = 0,
      .tabs = base::make_span(tabs),
      .groups = base::make_span(groups),
  };

  // Write the session described by session_info.
  ios::proto::WebStateListStorage session;
  ASSERT_TRUE(WriteSessionStorage(root, session_info, session));

  // Load the session and check it is correct.
  const ios::proto::WebStateListStorage loaded =
      ios::sessions::LoadSessionStorage(root);
  // Check that the duplicate tabs (the later occurrences) are dropped.
  EXPECT_EQ(loaded,
            ios::sessions::FilterItems(session, RemovingIndexes({1, 3, 6, 7})));

  // Check that there are two items, and the second item is in a group.
  EXPECT_EQ(5, loaded.items().size());
  EXPECT_EQ(3, loaded.groups().size());
  EXPECT_EQ(1, loaded.groups()[0].range().start());
  EXPECT_EQ(1, loaded.groups()[0].range().count());
  EXPECT_EQ(2, loaded.groups()[1].range().start());
  EXPECT_EQ(2, loaded.groups()[1].range().count());
  EXPECT_EQ(4, loaded.groups()[2].range().start());
  EXPECT_EQ(1, loaded.groups()[2].range().count());

  for (const auto& item : loaded.items()) {
    const web::WebStateID item_id =
        web::WebStateID::FromSerializedValue(item.identifier());

    // Check that the item has been correctly loaded.
    ASSERT_TRUE(item.has_metadata());
    EXPECT_EQ(item.metadata().active_page().page_url(),
              TestUrlForIdentifier(item_id));
  }

  // Expect a log of 4 duplicates.
  histogram_tester_.ExpectUniqueSample(
      "Tabs.DroppedDuplicatesCountOnSessionRestore", 4, 1);
}