# Copyright 2024 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. from __future__ import annotations import abc import logging from typing import Dict, Iterable, Iterator, Optional, Tuple from ordered_set import OrderedSet from crossbench import path as pth from crossbench.flags.base import Flags, FlagsData, Freezable from crossbench.flags.js_flags import JSFlags from crossbench.flags.known_js_flags import KNOWN_JS_FLAGS class ChromeFlags(Flags): """Specialized Flags for Chrome/Chromium-based browser. This has special treatment for --js-flags and the feature flags: --enable-features/--disable-features --enable-blink-features/--disable-blink-features """ _JS_FLAG = "--js-flags" def __init__(self, initial_data: FlagsData = None) -> None: self._features = ChromeFeatures() self._blink_features = ChromeBlinkFeatures() self._js_flags = JSFlags() super().__init__(initial_data) def freeze(self) -> ChromeFlags: super().freeze() self._js_flags.freeze() self._features.freeze() self._blink_features.freeze() return self def __getitem__(self, key): if key == self._JS_FLAG and self._js_flags: return self._js_flags if key == ChromeFeatures.ENABLE_FLAG and self._features.enabled: return self._features.enabled_str() if key == ChromeFeatures.DISABLE_FLAG and self._features.disabled: return self._features.disabled_str() if key == ChromeBlinkFeatures.ENABLE_FLAG and self._blink_features.enabled: return self._blink_features.enabled_str() if (key == ChromeBlinkFeatures.DISABLE_FLAG and self._blink_features.disabled): return self._blink_features.disabled_str() return super().__getitem__(key) def _set(self, flag_name: str, flag_value: Optional[str] = None, override: bool = False) -> None: self.assert_not_frozen() # pylint: disable=signature-differs if flag_name == ChromeFeatures.ENABLE_FLAG: if flag_value is None: raise ValueError(f"{ChromeFeatures.ENABLE_FLAG} cannot be None") for feature in flag_value.split(","): self._features.enable(feature) elif flag_name == ChromeFeatures.DISABLE_FLAG: if flag_value is None: raise ValueError(f"{ChromeFeatures.DISABLE_FLAG} cannot be None") for feature in flag_value.split(","): self._features.disable(feature) elif flag_name == ChromeBlinkFeatures.ENABLE_FLAG: if flag_value is None: raise ValueError(f"{ChromeBlinkFeatures.ENABLE_FLAG} cannot be None") for feature in flag_value.split(","): self._blink_features.enable(feature) elif flag_name == ChromeBlinkFeatures.DISABLE_FLAG: if flag_value is None: raise ValueError(f"{ChromeBlinkFeatures.DISABLE_FLAG} cannot be None") for feature in flag_value.split(","): self._blink_features.disable(feature) elif flag_name == self._JS_FLAG: if flag_value is None: raise ValueError(f"{self._JS_FLAG} cannot be None") self._set_js_flag(flag_value, override) else: flag_value = self._verify_flag(flag_name, flag_value) super()._set(flag_name, flag_value, override) def _set_js_flag(self, raw_js_flags: str, override: bool) -> None: new_js_flags = JSFlags(self._js_flags) for js_flag_name, js_flag_value in JSFlags.parse(raw_js_flags).items(): new_js_flags.set(js_flag_name, js_flag_value, override=override) self._js_flags.update(new_js_flags) def _verify_flag(self, name: str, value: Optional[str]) -> Optional[str]: if candidate := self._find_misspelled_flag(name): logging.error( "Potentially misspelled flag: '%s'. " "Did you mean to use %s ?", name, candidate) if candidate := self._find_js_flag(name): js_flags = JSFlags() js_flags.set(candidate, value) logging.error( "Got potential V8 flag that should be used as " "--js-flags=%s", js_flags) if name == "--user-data-dir": if not value or not value.strip(): raise ValueError("--user-data-dir cannot be the empty string.") # TODO: support remote platforms expanded_dir = str(pth.LocalPath(value).expanduser()) if expanded_dir != value: logging.warning( "Chrome Flags: auto-expanding --user-data-dir from '%s' to '%s'", value, expanded_dir) return expanded_dir return value def _find_misspelled_flag(self, name: str) -> Optional[str]: if name in ("--enable-feature", "--enabled-feature", "--enabled-features"): return "--enable-features" if name in ("--disable-feature", "--disabled-feature", "--disabled-features"): return "--disable-features" if name in ("--enable-blink-feature", "--enabled-blink-feature", "--enabled-blink-features"): return "--enable-blink-features" if name in ("--disable-blink-feature", "--disabled-blink-feature", "--disabled-blink-features"): return "--disable-blink-features" return None def _find_js_flag(self, name: str) -> Optional[str]: normalized_name = name if name.startswith("--no-"): normalized_name = f"--{name[5:]}" elif name.startswith("--no"): normalized_name = f"--{name[4:]}" if normalized_name in KNOWN_JS_FLAGS: return name return None @property def features(self) -> ChromeFeatures: return self._features @property def blink_features(self) -> ChromeBlinkFeatures: return self._blink_features @property def js_flags(self) -> JSFlags: return self._js_flags def merge(self, other: FlagsData) -> None: if not isinstance(other, ChromeFlags): other = ChromeFlags(other) self.features.merge(other.features) self.blink_features.merge(other.blink_features) self.js_flags.merge(other.js_flags) for name, value in other.base_items(): self.set(name, value) def base_items(self) -> Iterable[Tuple[str, Optional[str]]]: yield from super().items() def items(self) -> Iterable[Tuple[str, Optional[str]]]: yield from self.base_items() if self._js_flags: yield (self._JS_FLAG, str(self.js_flags)) yield from self.features.items() yield from self.blink_features.items() def __bool__(self) -> bool: return bool(self.data) or bool(self._js_flags) or bool( self._features) or bool(self._blink_features) class ChromeBaseFeatures(Freezable, abc.ABC): ENABLE_FLAG: str = "" DISABLE_FLAG: str = "" def __init__(self) -> None: super().__init__() self._enabled: Dict[str, Optional[str]] = {} self._disabled: OrderedSet[str] = OrderedSet() @property def is_empty(self) -> bool: return len(self._enabled) == 0 and len(self._disabled) == 0 @property def enabled(self) -> Dict[str, Optional[str]]: return dict(self._enabled) @property def disabled(self) -> OrderedSet[str]: return OrderedSet(self._disabled) def _parse_feature(self, feature: str) -> Tuple[str, Optional[str]]: if not feature: raise ValueError("Cannot parse empty feature") if "," in feature: raise ValueError(f"{repr(feature)} contains multiple features. " "Please split them first.") return self._parse_feature_parts(feature) @abc.abstractmethod def _parse_feature_parts(self, feature: str) -> Tuple[str, Optional[str]]: pass def enable(self, feature: str) -> None: name, value = self._parse_feature(feature) self._enable(name, value) def _enable(self, name: str, value: Optional[str]) -> None: self.assert_not_frozen() if name in self._disabled: raise ValueError( f"Cannot enable previously disabled feature={repr(name)}") if name in self._enabled: prev_value = self._enabled[name] if value != prev_value: raise ValueError("Cannot set conflicting values " f"({repr(prev_value)}, vs. {repr(value)}) " f"for the same feature={repr(name)}") else: self._enabled[name] = value def disable(self, feature: str) -> None: self.assert_not_frozen() name, _ = self._parse_feature(feature) if name in self._enabled: raise ValueError( f"Cannot disable previously enabled feature={repr(name)}") self._disabled.add(name) def update(self, other: ChromeBaseFeatures) -> None: if not isinstance(other, type(self)): raise TypeError(f"Cannot merge {type(self)} with {type(other)}") for disabled in other.disabled: self.disable(disabled) for name, value in other.enabled.items(): self._enable(name, value) def merge(self, other: ChromeBaseFeatures) -> None: self.update(other) def items(self) -> Iterable[Tuple[str, str]]: if self._enabled: yield (self.ENABLE_FLAG, self.enabled_str()) if self._disabled: yield (self.DISABLE_FLAG, self.disabled_str()) def enabled_str(self) -> str: return ",".join( k if v is None else f"{k}{v}" for k, v in self._enabled.items()) def disabled_str(self) -> str: return ",".join(self._disabled) def __iter__(self) -> Iterator[str]: for flag_name, features_str in self.items(): yield f"{flag_name}={features_str}" def __bool__(self): return bool(self._enabled) or bool(self._disabled) def __str__(self) -> str: return " ".join(self) class ChromeFeatures(ChromeBaseFeatures): """ Chrome Features set, throws if features are enabled and disabled at the same time. Examples: --disable-features="MyFeature1" --enable-features="MyFeature1,MyFeature2" --enable-features="MyFeature1:k1/v1/k2/v2,MyFeature2" --enable-features="MyFeature3 Tuple[str, Optional[str]]: parts = feature.split("<") if len(parts) == 2: return (parts[0], "<" + parts[1]) if len(parts) != 1: raise ValueError(f"Invalid number of feature parts: {repr(parts)}") parts = feature.split(":") if len(parts) == 2: return (parts[0], ":" + parts[1]) if len(parts) != 1: raise ValueError(f"Invalid number of feature parts: {repr(parts)}") return (feature, None) class ChromeBlinkFeatures(ChromeBaseFeatures): """ Chrome Features set, throws if features are enabled and disabled at the same time. Examples: --disable-blink-features="MyFeature1" --enable-blink-features="MyFeature1,MyFeature2" """ ENABLE_FLAG: str = "--enable-blink-features" DISABLE_FLAG: str = "--disable-blink-features" def _parse_feature_parts(self, feature: str) -> Tuple[str, Optional[str]]: if "<" in feature or ":" in feature: raise ValueError("blink features do not have params, " f"but found param separator in {repr(feature)}") return (feature, None)