| // Copyright 2020 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "components/webui/flags/flags_test_helpers.h" |
| |
| #include <gtest/gtest.h> |
| |
| #include <map> |
| #include <string> |
| #include <string_view> |
| #include <vector> |
| |
| #include "base/base_paths.h" |
| #include "base/containers/contains.h" |
| #include "base/files/file_path.h" |
| #include "base/json/json_file_value_serializer.h" |
| #include "base/path_service.h" |
| #include "base/strings/string_util.h" |
| #include "base/strings/stringprintf.h" |
| #include "base/values.h" |
| #include "components/webui/flags/feature_entry.h" |
| #include "components/webui/flags/flags_state.h" |
| |
| namespace { |
| |
| constexpr char kMetadataFileName[] = "flag-metadata.json"; |
| constexpr char kNeverExpireFileName[] = "flag-never-expire-list.json"; |
| |
| // Returns the file contents of a named file under $SRC/chrome/browser |
| // interpreted as a JSON value. |
| base::Value ReadFileContentsAsJSON(const std::string& filename) { |
| base::FilePath metadata_path; |
| base::PathService::Get(base::DIR_SRC_TEST_DATA_ROOT, &metadata_path); |
| JSONFileValueDeserializer deserializer( |
| metadata_path.AppendASCII("chrome").AppendASCII("browser").AppendASCII( |
| filename)); |
| int error_code; |
| std::string error_message; |
| std::unique_ptr<base::Value> json = |
| deserializer.Deserialize(&error_code, &error_message); |
| CHECK(json) << "Failed to load " << filename << ": " << error_code << " " |
| << error_message; |
| return std::move(*json); |
| } |
| |
| // Data structure capturing the metadata of the flag. |
| struct FlagMetadataEntry { |
| std::vector<std::string> owners; |
| int expiry_milestone; |
| }; |
| |
| // Lookup of metadata by flag name. |
| using FlagMetadataMap = std::map<std::string, FlagMetadataEntry>; |
| |
| // Reads the flag metadata file. |
| FlagMetadataMap LoadFlagMetadata() { |
| base::Value metadata_json = ReadFileContentsAsJSON(kMetadataFileName); |
| |
| FlagMetadataMap metadata; |
| for (const auto& entry_val : metadata_json.GetList()) { |
| const base::Value::Dict& entry = entry_val.GetDict(); |
| std::string name = *entry.FindString("name"); |
| std::vector<std::string> owners; |
| if (const base::Value::List* e = entry.FindList("owners")) { |
| for (const auto& owner : *e) { |
| owners.push_back(owner.GetString()); |
| } |
| } |
| int expiry_milestone = entry.FindInt("expiry_milestone").value(); |
| metadata[name] = FlagMetadataEntry{owners, expiry_milestone}; |
| } |
| |
| return metadata; |
| } |
| |
| std::vector<std::string> LoadFlagNeverExpireList() { |
| base::Value list_json = ReadFileContentsAsJSON(kNeverExpireFileName); |
| |
| std::vector<std::string> result; |
| for (const auto& entry : list_json.GetList()) { |
| result.push_back(entry.GetString()); |
| } |
| return result; |
| } |
| |
| bool IsValidLookingOwner(std::string_view owner) { |
| // Never allow ',' or ' ' in owner names, regardless of all other constraints. |
| // It is otherwise too easy to accidentally do this: |
| // "owners": [ "[email protected],[email protected]" ] |
| // or this: |
| // "owners": [ "[email protected] [email protected]" ] |
| // Apologies to those who have spaces in their email addresses or OWNERS file |
| // path names :) |
| if (owner.find_first_of(", ") != std::string::npos) { |
| return false; |
| } |
| |
| // Per the specification at the top of flag-metadata.json, an owner is one of: |
| // 1) A string containing '@', which is treated as a full email address |
| // 2) A string beginning with '//', which is a path to an OWNERS file |
| |
| const size_t at_pos = owner.find("@"); |
| if (at_pos != std::string::npos) { |
| // If there's an @, check for a . after it. This catches one common error: |
| // writing "foo@" in the owners list rather than bare "foo" or full |
| // "[email protected]". |
| return owner.find(".", at_pos) != std::string::npos; |
| } |
| |
| if (base::StartsWith(owner, "//")) { |
| // Looks like a path to a file. It would be nice to check that the file |
| // actually exists here, but that's not possible because when this test |
| // runs it runs in an isolated environment. To check for the presence of the |
| // file the test would need a build-time declaration that it depends on that |
| // file. Instead, just assume any file path ending in 'OWNERS' is valid. |
| // This doesn't check that the entire filename part of the path is 'OWNERS' |
| // because sometimes it is instead 'IPC_OWNERS' or similar. |
| return base::EndsWith(owner, "OWNERS"); |
| } |
| |
| return false; |
| } |
| |
| std::string NormalizeName(const std::string& name) { |
| std::string normalized_name = base::ToLowerASCII(name); |
| std::replace(normalized_name.begin(), normalized_name.end(), '_', '-'); |
| |
| return normalized_name; |
| } |
| |
| constexpr char kStartSentinel[] = "(start of file)"; |
| |
| using NameNameMap = std::map<std::string, std::string>; |
| using NameVector = std::vector<std::string>; |
| |
| // Given a NameVector, returns a map from each name n to the name preceding n in |
| // the NameVector. The returned map maps the first name to kStartSentinel. |
| // Preconditions: |
| // * There are no duplicates in |strings| |
| // * No entry in |strings| equals kStartSentinel |
| // Postconditions: |
| // * Every entry in |strings| appears as a key in the result map |
| // * Every entry in |strings| maps to another entry in |strings| or to |
| // kStartSentinel in the result map |
| NameNameMap BuildAfterMap(const NameVector& strings) { |
| NameNameMap after_map; |
| CHECK_NE(strings[0], kStartSentinel); |
| after_map[strings[0]] = kStartSentinel; |
| for (size_t i = 1; i < strings.size(); ++i) { |
| CHECK_NE(strings[i], kStartSentinel); |
| CHECK(!after_map.contains(strings[i])); |
| after_map[strings[i]] = strings[i - 1]; |
| } |
| |
| // Postconditions: |
| for (const auto& entry : strings) { |
| CHECK(after_map.contains(entry)); |
| } |
| |
| return after_map; |
| } |
| |
| // Given a vector of names, returns a vector of normalized names, and an inverse |
| // mapping from normalized name to previous name. The inverse mapping is |
| // populated only for names which were altered when normalized. |
| // Preconditions: none |
| // Postconditions: |
| // * Every (key, value) pair in |denormalized| have key != value |
| // * Every (key, value) pair in |denormalized| have key = NormalizeName(value) |
| std::pair<NameVector, NameNameMap> NormalizeNames(const NameVector& names) { |
| NameNameMap denormalized; |
| NameVector normalized; |
| for (const auto& name : names) { |
| std::string n = NormalizeName(name); |
| normalized.push_back(n); |
| if (n != name) { |
| denormalized[n] = name; |
| } |
| } |
| |
| // Postconditions: |
| for (const auto& pair : denormalized) { |
| CHECK_NE(pair.first, pair.second); |
| CHECK_EQ(pair.first, NormalizeName(pair.second)); |
| } |
| |
| return std::tie(normalized, denormalized); |
| } |
| |
| // Given a list of flag names, adds test failures for any that do not appear in |
| // alphabetical order. This is more complex than simply sorting the list and |
| // checking whether the order changed - this function is supposed to emit error |
| // messages which tell the user specifically which flags need to be moved and to |
| // where in the file. |
| void EnsureNamesAreAlphabetical(const NameVector& names, |
| const std::string& filename) { |
| auto [normalized, denormalized] = NormalizeNames(names); |
| auto was_after = BuildAfterMap(normalized); |
| |
| std::sort(normalized.begin(), normalized.end()); |
| auto goes_after = BuildAfterMap(normalized); |
| |
| auto denormalize = [&](const std::string& name) { |
| return denormalized.contains(name) ? denormalized[name] : name; |
| }; |
| |
| for (const auto& n : normalized) { |
| if (was_after[n] != goes_after[n]) { |
| ADD_FAILURE() << "In '" << filename << "': flag '" << denormalize(n) |
| << "' should be right after '" << denormalize(goes_after[n]) |
| << "'"; |
| } |
| } |
| } |
| |
| bool IsUnexpireFlagFor(const flags_ui::FeatureEntry& entry, int milestone) { |
| std::string expected_flag = |
| base::StringPrintf("temporary-unexpire-flags-m%d", milestone); |
| if (entry.internal_name != expected_flag) { |
| return false; |
| } |
| if (!(entry.supported_platforms & flags_ui::kFlagInfrastructure)) { |
| return false; |
| } |
| if (entry.type != flags_ui::FeatureEntry::FEATURE_VALUE) { |
| return false; |
| } |
| std::string expected_feature = |
| base::StringPrintf("UnexpireFlagsM%d", milestone); |
| const auto* feature = entry.feature.feature; |
| if (!feature || feature->name != expected_feature) { |
| return false; |
| } |
| return true; |
| } |
| |
| } // namespace |
| |
| namespace flags_ui { |
| |
| namespace testing { |
| |
| void EnsureEveryFlagHasMetadata( |
| const base::span<const flags_ui::FeatureEntry>& entries) { |
| FlagMetadataMap metadata = LoadFlagMetadata(); |
| std::vector<std::string> missing_flags; |
| |
| for (const auto& entry : entries) { |
| // Flags that are part of the flags system itself (like unexpiry meta-flags) |
| // don't have metadata, so skip them here. |
| if (entry.supported_platforms & flags_ui::kFlagInfrastructure) { |
| continue; |
| } |
| |
| if (metadata.count(entry.internal_name) == 0) { |
| missing_flags.push_back(entry.internal_name); |
| } |
| } |
| |
| std::sort(missing_flags.begin(), missing_flags.end()); |
| |
| EXPECT_EQ(0u, missing_flags.size()) |
| << "Missing flags: " << base::JoinString(missing_flags, "\n "); |
| } |
| |
| void EnsureOnlyPermittedFlagsNeverExpire() { |
| FlagMetadataMap metadata = LoadFlagMetadata(); |
| std::vector<std::string> listed_flags = LoadFlagNeverExpireList(); |
| std::vector<std::string> missing_flags; |
| |
| for (const auto& entry : metadata) { |
| if (entry.second.expiry_milestone == -1 && |
| !base::Contains(listed_flags, entry.first)) { |
| missing_flags.push_back(entry.first); |
| } |
| } |
| |
| std::sort(missing_flags.begin(), missing_flags.end()); |
| |
| EXPECT_EQ(0u, missing_flags.size()) |
| << "Flags not listed for no-expire: " |
| << base::JoinString(missing_flags, "\n "); |
| } |
| |
| void EnsureEveryFlagHasNonEmptyOwners() { |
| FlagMetadataMap metadata = LoadFlagMetadata(); |
| std::vector<std::string> sad_flags; |
| |
| for (const auto& it : metadata) { |
| if (it.second.owners.empty()) { |
| sad_flags.push_back(it.first); |
| } |
| } |
| |
| std::sort(sad_flags.begin(), sad_flags.end()); |
| |
| EXPECT_EQ(0u, sad_flags.size()) |
| << "Flags missing owners: " << base::JoinString(sad_flags, "\n "); |
| } |
| |
| void EnsureOwnersLookValid() { |
| FlagMetadataMap metadata = LoadFlagMetadata(); |
| std::vector<std::string> sad_flags; |
| |
| for (const auto& flag : metadata) { |
| for (const auto& owner : flag.second.owners) { |
| if (!IsValidLookingOwner(owner)) { |
| sad_flags.push_back(flag.first); |
| } |
| } |
| } |
| |
| EXPECT_EQ(0u, sad_flags.size()) << "Flags with invalid-looking owners: " |
| << base::JoinString(sad_flags, "\n"); |
| } |
| |
| void EnsureFlagsAreListedInAlphabeticalOrder() { |
| { |
| auto json = ReadFileContentsAsJSON(kMetadataFileName); |
| std::vector<std::string> names; |
| for (const auto& entry : json.GetList()) { |
| names.push_back(*entry.GetDict().FindString("name")); |
| } |
| EnsureNamesAreAlphabetical(names, kMetadataFileName); |
| } |
| |
| { |
| auto json = ReadFileContentsAsJSON(kNeverExpireFileName); |
| std::vector<std::string> names; |
| for (const auto& entry : json.GetList()) { |
| names.push_back(entry.GetString()); |
| } |
| |
| EnsureNamesAreAlphabetical(names, kNeverExpireFileName); |
| } |
| } |
| |
| // TODO(crbug.com/40785799): Call this from the iOS flags unittests once |
| // flag expiration is supported there. |
| void EnsureRecentUnexpireFlagsArePresent( |
| const base::span<const flags_ui::FeatureEntry>& entries, |
| int current_milestone) { |
| auto contains_unexpire_for = [&](int mstone) { |
| for (const auto& entry : entries) { |
| if (IsUnexpireFlagFor(entry, mstone)) { |
| return true; |
| } |
| } |
| return false; |
| }; |
| |
| EXPECT_FALSE(contains_unexpire_for(current_milestone)); |
| EXPECT_TRUE(contains_unexpire_for(current_milestone - 1)); |
| EXPECT_TRUE(contains_unexpire_for(current_milestone - 2)); |
| EXPECT_FALSE(contains_unexpire_for(current_milestone - 3)); |
| } |
| |
| } // namespace testing |
| |
| } // namespace flags_ui |