diff --git a/testing/web-platform/tests/tools/manifest/sourcefile.py b/testing/web-platform/tests/tools/manifest/sourcefile.py index 832968e632340..23aa7f491fcf0 100644 --- a/testing/web-platform/tests/tools/manifest/sourcefile.py +++ b/testing/web-platform/tests/tools/manifest/sourcefile.py @@ -29,6 +29,10 @@ WebDriverSpecTest) from .utils import cached_property +# Cannot do `from ..metadata.webfeatures.schema import WEB_FEATURES_YML_FILENAME` +# because relative import beyond toplevel throws *ImportError*! +from metadata.webfeatures.schema import WEB_FEATURES_YML_FILENAME # type: ignore + wd_pattern = "*.py" js_meta_re = re.compile(br"//\s*META:\s*(\w*)=(.*)$") python_meta_re = re.compile(br"#\s*META:\s*(\w*)=(.*)$") @@ -302,6 +306,7 @@ def name_is_non_test(self) -> bool: return (self.is_dir() or self.name_prefix("MANIFEST") or self.filename == "META.yml" or + self.filename == WEB_FEATURES_YML_FILENAME or self.filename.startswith(".") or self.filename.endswith(".headers") or self.filename.endswith(".ini") or diff --git a/testing/web-platform/tests/tools/manifest/tests/test_sourcefile.py b/testing/web-platform/tests/tools/manifest/tests/test_sourcefile.py index 298e480c142c5..8a9d8c36ee1c6 100644 --- a/testing/web-platform/tests/tools/manifest/tests/test_sourcefile.py +++ b/testing/web-platform/tests/tools/manifest/tests/test_sourcefile.py @@ -42,6 +42,8 @@ def items(s): "crashtests/foo.html.ini", "css/common/test.html", "css/CSS2/archive/test.html", + "css/WEB_FEATURES.yml", + "css/META.yml", ]) def test_name_is_non_test(rel_path): s = create(rel_path) diff --git a/testing/web-platform/tests/tools/web_features/MANIFEST_SCHEMA.json b/testing/web-platform/tests/tools/web_features/MANIFEST_SCHEMA.json new file mode 100644 index 0000000000000..9fe4b68eb4587 --- /dev/null +++ b/testing/web-platform/tests/tools/web_features/MANIFEST_SCHEMA.json @@ -0,0 +1,35 @@ +{ + "$schema":"http://json-schema.org/draft-06/schema#", + "$ref":"#/definitions/File", + "definitions":{ + "File":{ + "type":"object", + "additionalProperties":false, + "properties":{ + "version":{ + "type":"integer", + "description":"Schema version of the file.", + "enum":[ + 1 + ] + }, + "data":{ + "type":"object", + "description":"High level container for the data. Object key is the web-features identifier.", + "additionalProperties":{ + "type":"array", + "items":{ + "type":"string", + "description":"The url field in tools.manifest.item.URLManifestItem" + } + } + } + }, + "required":[ + "data", + "version" + ], + "title":"File" + } + } +} \ No newline at end of file diff --git a/testing/web-platform/tests/tools/web_features/__init__.py b/testing/web-platform/tests/tools/web_features/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/testing/web-platform/tests/tools/web_features/commands.json b/testing/web-platform/tests/tools/web_features/commands.json new file mode 100644 index 0000000000000..9a54b1b00da67 --- /dev/null +++ b/testing/web-platform/tests/tools/web_features/commands.json @@ -0,0 +1,12 @@ +{ + "web-features-manifest": { + "path": "manifest.py", + "script": "main", + "parser": "create_parser", + "help": "Create the WEB_FEATURES_MANIFEST.json", + "virtualenv": true, + "requirements": [ + "../metadata/yaml/requirements.txt" + ] + } +} diff --git a/testing/web-platform/tests/tools/web_features/manifest.py b/testing/web-platform/tests/tools/web_features/manifest.py new file mode 100644 index 0000000000000..3a4ec1a6f2718 --- /dev/null +++ b/testing/web-platform/tests/tools/web_features/manifest.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 + +import argparse +import json +import logging +import os + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, List, Optional + +from ..manifest.item import SupportFile +from ..manifest.sourcefile import SourceFile +from ..metadata.yaml.load import load_data_to_dict +from ..web_features.web_feature_map import WebFeatureToTestsDirMapper, WebFeaturesMap +from .. import localpaths +from ..metadata.webfeatures.schema import WEB_FEATURES_YML_FILENAME, WebFeaturesFile + +""" +This command generates a manifest file containing a mapping of web-feature +identifiers to test paths. + +The web-feature identifiers are sourced from https://github.com/web-platform-dx/web-features. +They are used in WEB_FEATURES.yml files located throughout the WPT source code. +Each file defines which test files correspond to a specific identifier. +Refer to RFC 163 (https://github.com/web-platform-tests/rfcs/pull/163) for more +file details. + +This command processes all WEB_FEATURES.yml files, extracts the list of test +paths from the test files, and writes them to a manifest file. The manifest +file maps web-feature identifiers to their corresponding test paths. + +The file written is a JSON file. An example file looks like: + +{ + "version": 1, + "data": { + "async-clipboard": [ + "/clipboard-apis/async-custom-formats-write-fail.tentative.https.html", + "/clipboard-apis/async-custom-formats-write-read-web-prefix.tentative.https.html" + ], + "idle-detection": [ + "/idle-detection/basics.tentative.https.window.html", + "/idle-detection/idle-detection-allowed-by-permissions-policy-attribute-redirect-on-load.https.sub.html" + ] + } +} + + +The JSON Schema for the file format can be found at MANIFEST_SCHEMA.json + +This file does not follow the same format as the original manifest file, +MANIFEST.json. +""" + +logger = logging.getLogger(__name__) + +MANIFEST_FILE_NAME = "WEB_FEATURES_MANIFEST.json" + + +def abs_path(path: str) -> str: + return os.path.abspath(os.path.expanduser(path)) + +def create_parser() -> argparse.ArgumentParser: + """ + Creates an argument parser for the script. + + Returns: + argparse.ArgumentParser: The configured argument parser. + """ + parser = argparse.ArgumentParser( + description="Maps tests to web-features within a repo root." + ) + parser.add_argument( + "-p", "--path", type=abs_path, help="Path to manifest file.") + return parser + + +def find_all_test_files_in_dir(root_dir: str, rel_dir_path: str, url_base: str) -> List[SourceFile]: + """ + Finds all test files within a given directory. + + Ignores any SourceFiles that are marked as non_test or the type + is SupportFile.item_type + + Args: + root_dir (str): The root directory of the repository. + rel_dir_path (str): The relative path of the directory to search. + url_base (str): Base url to use as the mount point for tests in this manifest. + + Returns: + List[SourceFile]: A list of SourceFile objects representing the found test files. + """ + rv: List[SourceFile] = [] + full_dir_path = os.path.join(root_dir, rel_dir_path) + for file in os.listdir(full_dir_path): + full_path = os.path.join(full_dir_path, file) + rel_file_path = os.path.relpath(full_path, root_dir) + source_file = SourceFile(root_dir, rel_file_path, url_base) + if not source_file.name_is_non_test and source_file.type != SupportFile.item_type: + rv.append(source_file) + return rv + +@dataclass +class CmdConfig(): + """ + Configuration for the command-line options. + """ + + repo_root: str # The root directory of the WPT repository + url_base: str # Base URL used when converting file paths to urls + + +def map_tests_to_web_features( + cmd_cfg: CmdConfig, + rel_dir_path: str, + result: WebFeaturesMap, + prev_inherited_features: List[str] = []) -> None: + """ + Recursively maps tests to web-features within a directory structure. + + Args: + cmd_cfg (CmdConfig): The configuration for the command-line options. + rel_dir_path (str): The relative path of the directory to process. + result (WebFeaturesMap): The object to store the mapping results. + prev_inherited_features (List[str], optional): A list of inherited web-features from parent directories. Defaults to []. + """ + # Sometimes it will add a . at the beginning. Let's resolve the absolute path to disambiguate. + # current_path = Path(os.path.join(cmd_cfg.repo_root, rel_dir_path)).resolve() + current_dir = str(Path(os.path.join(cmd_cfg.repo_root, rel_dir_path)).resolve()) + + # Create a copy that may be built upon or cleared during this iteration. + inherited_features = prev_inherited_features.copy() + + rel_dir_path = os.path.relpath(current_dir, cmd_cfg.repo_root) + + web_feature_yml_full_path = os.path.join(current_dir, WEB_FEATURES_YML_FILENAME) + web_feature_file: Optional[WebFeaturesFile] = None + if os.path.isfile(web_feature_yml_full_path): + try: + web_feature_file = WebFeaturesFile(load_data_to_dict( + open(web_feature_yml_full_path, "rb"))) + except Exception as e: + raise e + + WebFeatureToTestsDirMapper( + find_all_test_files_in_dir(cmd_cfg.repo_root, rel_dir_path, cmd_cfg.url_base), + web_feature_file + ).run(result, inherited_features) + + sub_dirs = [f for f in os.listdir(current_dir) if os.path.isdir(os.path.join(current_dir, f))] + for sub_dir in sub_dirs: + map_tests_to_web_features( + cmd_cfg, + os.path.join(rel_dir_path, sub_dir), + result, + inherited_features + ) + +class WebFeatureManifestEncoder(json.JSONEncoder): + """ + Custom JSON encoder. + + WebFeaturesMap contains a dictionary where the value is of type set. + Sets cannot serialize to JSON by default. This encoder handles that by + calling WebFeaturesMap's to_dict() method. + """ + def default(self, obj: Any) -> Any: + if isinstance(obj, WebFeaturesMap): + return obj.to_dict() + return super().default(obj) + + +def write_manifest_file(path: str, web_features_map: WebFeaturesMap) -> None: + """ + Serializes the WebFeaturesMap to a JSON manifest file at the specified path. + + The generated JSON file adheres to the schema defined in the "MANIFEST_SCHEMA.json" file. The + serialization process uses the custom `WebFeatureManifestEncoder` to ensure correct formatting. + + Args: + path (str): The file path where the manifest file will be created or overwritten. + web_features_map (WebFeaturesMap): The object containing the mapping between + web-features and their corresponding test paths. + """ + with open(path, "w") as outfile: + outfile.write( + json.dumps( + { + "version": 1, + "data": web_features_map + }, cls=WebFeatureManifestEncoder)) + + +def main(venv: Any = None, **kwargs: Any) -> int: + + assert logger is not None + + repo_root = localpaths.repo_root + url_base = "/" + path = kwargs.get("path") or os.path.join(repo_root, MANIFEST_FILE_NAME) + + cmd_cfg = CmdConfig(repo_root, url_base) + feature_map = WebFeaturesMap() + map_tests_to_web_features(cmd_cfg, "", feature_map) + write_manifest_file(path, feature_map) + + return 0 diff --git a/testing/web-platform/tests/tools/web_features/tests/__init__.py b/testing/web-platform/tests/tools/web_features/tests/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/testing/web-platform/tests/tools/web_features/tests/test_manifest.py b/testing/web-platform/tests/tools/web_features/tests/test_manifest.py new file mode 100644 index 0000000000000..8b656876ff3a1 --- /dev/null +++ b/testing/web-platform/tests/tools/web_features/tests/test_manifest.py @@ -0,0 +1,260 @@ +# mypy: ignore-errors + +import json +import os +from jsonschema import validate +from unittest.mock import ANY, Mock, call, mock_open, patch + +import pytest + +from ..manifest import create_parser, find_all_test_files_in_dir, main, map_tests_to_web_features, write_manifest_file, CmdConfig +from ..web_feature_map import WebFeatureToTestsDirMapper, WebFeaturesMap +from ...metadata.webfeatures.schema import WEB_FEATURES_YML_FILENAME +from ...manifest.sourcefile import SourceFile +from ...manifest.item import SupportFile, URLManifestItem +from ... import localpaths + + +@patch("os.listdir") +@patch("tools.web_features.manifest.SourceFile") +def test_find_all_test_files_in_dir(mock_source_file_class, mock_listdir): + mock_listdir.return_value = ["test1.html", "support.py", "test2.html", "test3.html"] + + def create_source_file_mock(root_dir, rel_file_path, separator): + source_file = Mock(spec=SourceFile) + if rel_file_path.endswith("support.py"): + source_file.name_is_non_test = True + source_file.type = SupportFile.item_type + else: + source_file.name_is_non_test = False + return source_file + + mock_source_file_class.side_effect = create_source_file_mock + + test_files = find_all_test_files_in_dir("root_dir", "rel_dir_path", "/") + + # Assert calls to the mocked constructor with expected arguments + mock_source_file_class.assert_has_calls([ + call("root_dir", os.path.join("rel_dir_path", "test1.html"), "/"), + call("root_dir", os.path.join("rel_dir_path", "support.py"), "/"), + call("root_dir", os.path.join("rel_dir_path", "test2.html"), "/"), + call("root_dir", os.path.join("rel_dir_path", "test3.html"), "/"), + ]) + assert mock_source_file_class.call_count == 4 + + + # Assert attributes of the resulting test files + assert all( + not file.name_is_non_test and file.type != SupportFile.item_type + for file in test_files + ) + + # Should only have 3 items instead of the original 4 + assert len(test_files) == 3 + +@patch("builtins.open", new_callable=mock_open, read_data="data") +@patch("os.listdir") +@patch("os.path.isdir") +@patch("os.path.isfile") +@patch("tools.web_features.manifest.load_data_to_dict", return_value={}) +@patch("tools.web_features.manifest.find_all_test_files_in_dir") +@patch("tools.web_features.manifest.WebFeaturesFile") +@patch("tools.web_features.manifest.WebFeatureToTestsDirMapper", spec=WebFeatureToTestsDirMapper) +def test_map_tests_to_web_features_recursive( + mock_mapper, + mock_web_features_file, + mock_find_all_test_files_in_dir, + mock_load_data_to_dict, + mock_isfile, + mock_isdir, + mock_listdir, + mock_file +): + def fake_listdir(path): + if path.endswith("repo_root"): + return ["subdir1", "subdir2"] + elif path.endswith(os.path.join("repo_root", "subdir1")): + return ["subdir1_1", "subdir1_2", WEB_FEATURES_YML_FILENAME] + elif path.endswith(os.path.join("repo_root", "subdir1", "subdir1_1")): + return [WEB_FEATURES_YML_FILENAME] + elif path.endswith(os.path.join("repo_root", "subdir1", "subdir1_2")): + return [] + elif path.endswith(os.path.join("repo_root", "subdir2")): + return [WEB_FEATURES_YML_FILENAME] + else: + [] + mock_listdir.side_effect = fake_listdir + + def fake_isdir(path): + if (path.endswith(os.path.join("repo_root", "subdir1")) or + path.endswith(os.path.join("repo_root", "subdir1", "subdir1_1")) or + path.endswith(os.path.join("repo_root", "subdir1", "subdir1_2")) or + path.endswith(os.path.join("repo_root", "subdir2"))): + return True + return False + mock_isdir.side_effect = fake_isdir + + def fake_isfile(path): + if (path.endswith(os.path.join("repo_root", "subdir1", "WEB_FEATURES.yml")) or + path.endswith(os.path.join("repo_root", "subdir1", "subdir1_1", "WEB_FEATURES.yml")) or + path.endswith(os.path.join("repo_root", "subdir2", "WEB_FEATURES.yml"))): + return True + return False + mock_isfile.side_effect = fake_isfile + + + expected_root_files = [ + Mock(name="root_test_1"), + ] + + expected_subdir1_files = [ + Mock(name="subdir1_test_1"), + Mock(name="subdir1_test_2"), + ] + + expected_subdir2_files = [ + Mock(name="subdir2_test_1"), + ] + + expected_subdir1_1_files = [ + Mock(name="subdir1_1_test_1"), + Mock(name="subdir1_1_test_2"), + ] + + expected_subdir1_2_files = [ + Mock(name="subdir1_2_test_1"), + Mock(name="subdir1_2_test_2"), + ] + + expected_subdir1_web_feature_file = Mock() + expected_subdir1_1_web_feature_file = Mock() + expected_subdir2_web_feature_file = Mock() + mock_web_features_file.side_effect = [ + expected_subdir1_web_feature_file, + expected_subdir1_1_web_feature_file, + expected_subdir2_web_feature_file, + ] + + def fake_find_all_test_files_in_dir(root, rel_path, url_root): + # All cases should use url_root == "/" + if url_root != "/": + return None + elif (root == "repo_root" and rel_path == "."): + return expected_root_files + elif (root == "repo_root" and rel_path == "subdir1"): + return expected_subdir1_files + elif (root == "repo_root" and rel_path == os.path.join("subdir1", "subdir1_1")): + return expected_subdir1_1_files + elif (root == "repo_root" and rel_path == os.path.join("subdir1", "subdir1_2")): + return expected_subdir1_2_files + elif (root == "repo_root" and rel_path == "subdir2"): + return expected_subdir2_files + mock_find_all_test_files_in_dir.side_effect = fake_find_all_test_files_in_dir + cmd_cfg = CmdConfig("repo_root", "/") + result = WebFeaturesMap() + + map_tests_to_web_features(cmd_cfg, "", result) + + assert mock_isfile.call_count == 5 + assert mock_mapper.call_count == 5 + + # Check for the constructor calls. + # In between also assert that the run() call is executed. + mock_mapper.assert_has_calls([ + call(expected_root_files, None), + call().run(ANY, []), + call(expected_subdir1_files, expected_subdir1_web_feature_file), + call().run(ANY, []), + call(expected_subdir1_1_files, expected_subdir1_1_web_feature_file), + call().run(ANY, []), + call(expected_subdir1_2_files, None), + call().run(ANY, []), + call(expected_subdir2_files, expected_subdir2_web_feature_file), + call().run(ANY, []), + ]) + + + # Only five times to the constructor + assert mock_mapper.call_count == 5 + + +def test_parser_with_path_provided_abs_path(): + parser = create_parser() + args = parser.parse_args(["--path", f"{os.path.abspath(os.sep)}manifest-path"]) + assert args.path == f"{os.path.abspath(os.sep)}manifest-path" + +def populate_test_web_features_map(web_features_map): + web_features_map.add("grid", [ + Mock(spec=URLManifestItem, url="/grid_test1.js"), + Mock(spec=URLManifestItem, url="/grid_test2.js"), + ]) + web_features_map.add("avif", [Mock(spec=URLManifestItem, url="/avif_test1.js")]) + + +def test_valid_schema(): + with open(os.path.join(os.path.dirname(__file__), '..', 'MANIFEST_SCHEMA.json'), 'r') as schema_file: + schema_dict = json.load(schema_file) + + web_features_map = WebFeaturesMap() + populate_test_web_features_map(web_features_map) + + with patch('builtins.open', new_callable=mock_open) as mock_file: + write_manifest_file("test_file.json", web_features_map) + mock_file.assert_called_once_with("test_file.json", "w") + mock_file.return_value.write.assert_called_once_with( + ('{"version": 1,' + ' "data": {"grid": ["/grid_test1.js", "/grid_test2.js"], "avif": ["/avif_test1.js"]}}')) + args = mock_file.return_value.write.call_args + file_dict = json.loads(args[0][0]) + # Should not throw an exception + try: + validate(file_dict, schema_dict) + except Exception as e: + assert False, f"'validate' raised an exception {e}" + + +@pytest.mark.parametrize('main_kwargs,expected_repo_root,expected_path', [ + # No flags. All default + ( + {}, + localpaths.repo_root, + os.path.join(localpaths.repo_root, "WEB_FEATURES_MANIFEST.json") + ), + # Provide the path flag + ( + { + "path": os.path.join(os.sep, "test_path", "WEB_FEATURES_MANIFEST.json"), + }, + localpaths.repo_root, + os.path.join(os.sep, "test_path", "WEB_FEATURES_MANIFEST.json") + ), +]) +@patch("tools.web_features.manifest.map_tests_to_web_features") +@patch("tools.web_features.manifest.write_manifest_file") +def test_main( + mock_write_manifest_file, + mock_map_tests_to_web_features, + main_kwargs, + expected_repo_root, + expected_path): + + def fake_map_tests_to_web_features( + cmd_cfg, + rel_dir_path, + result, + prev_inherited_features = []): + populate_test_web_features_map(result) + + default_kwargs = {"url_base": "/"} + main_kwargs.update(default_kwargs) + mock_map_tests_to_web_features.side_effect = fake_map_tests_to_web_features + main(**main_kwargs) + mock_map_tests_to_web_features.assert_called_once_with(CmdConfig(repo_root=expected_repo_root, url_base="/"), "", ANY) + mock_write_manifest_file.assert_called_once() + args = mock_write_manifest_file.call_args + path = args[0][0] + file = args[0][1] + assert path == expected_path + assert file.to_dict() == { + 'avif': ['/avif_test1.js'], + 'grid': ['/grid_test1.js', '/grid_test2.js']} diff --git a/testing/web-platform/tests/tools/web_features/tests/test_web_feature_map.py b/testing/web-platform/tests/tools/web_features/tests/test_web_feature_map.py new file mode 100644 index 0000000000000..06afa181fe570 --- /dev/null +++ b/testing/web-platform/tests/tools/web_features/tests/test_web_feature_map.py @@ -0,0 +1,157 @@ +# mypy: allow-untyped-defs + +from unittest.mock import Mock, patch + +from ...manifest.item import URLManifestItem +from ...metadata.webfeatures.schema import FeatureFile +from ..web_feature_map import WebFeaturesMap, WebFeatureToTestsDirMapper + + +TEST_FILES = [ + Mock( + path="root/blob-range.any.js", + manifest_items=Mock( + return_value=( + None, + [ + Mock(spec=URLManifestItem, url="/root/blob-range.any.html"), + Mock(spec=URLManifestItem, url="/root/blob-range.any.worker.html"), + ]) + ) + ), + Mock( + path="root/foo-range.any.js", + manifest_items=Mock( + return_value=( + None, + [ + Mock(spec=URLManifestItem, url="/root/foo-range.any.html"), + Mock(spec=URLManifestItem, url="/root/foo-range.any.worker.html"), + ]) + ) + ), +] + +def test_process_recursive_feature(): + mapper = WebFeatureToTestsDirMapper(TEST_FILES, None) + result = WebFeaturesMap() + inherited_features = [] + + feature_entry = Mock() + feature_entry.name = "grid" + mapper._process_recursive_feature(inherited_features, feature_entry, result) + + assert result.to_dict() == { + "grid": [ + "/root/blob-range.any.html", + "/root/blob-range.any.worker.html", + "/root/foo-range.any.html", + "/root/foo-range.any.worker.html", + ], + } + assert inherited_features == ["grid"] + + +def test_process_non_recursive_feature(): + feature_name = "feature1" + feature_files = [ + FeatureFile("blob-range.any.js"), # Matches blob-range.any.js + FeatureFile("blob-range.html"), # Doesn't match any test file + ] + + mapper = WebFeatureToTestsDirMapper(TEST_FILES, None) + result = WebFeaturesMap() + + mapper._process_non_recursive_feature(feature_name, feature_files, result) + + assert result.to_dict() == { + "feature1": [ + "/root/blob-range.any.html", + "/root/blob-range.any.worker.html", + ] + } + + +def test_process_inherited_features(): + mapper = WebFeatureToTestsDirMapper(TEST_FILES, None) + result = WebFeaturesMap() + result.add("avif", [ + Mock(spec=URLManifestItem, path="root/bar-range.any.html", url="/root/bar-range.any.html"), + Mock(spec=URLManifestItem, path="root/bar-range.any.worker.html", url="/root/bar-range.any.worker.html"), + ]) + inherited_features = ["avif", "grid"] + + mapper._process_inherited_features(inherited_features, result) + + assert result.to_dict() == { + "avif": [ + "/root/bar-range.any.html", + "/root/bar-range.any.worker.html", + "/root/blob-range.any.html", + "/root/blob-range.any.worker.html", + "/root/foo-range.any.html", + "/root/foo-range.any.worker.html", + ], + "grid": [ + "/root/blob-range.any.html", + "/root/blob-range.any.worker.html", + "/root/foo-range.any.html", + "/root/foo-range.any.worker.html", + ], + } + assert inherited_features == ["avif", "grid"] + +def create_feature_entry(name, recursive=False, files=None): + rv = Mock(does_feature_apply_recursively=Mock(return_value=recursive)) + rv.name = name + rv.files = files + return rv + + +@patch("tools.web_features.web_feature_map.WebFeatureToTestsDirMapper._process_recursive_feature") +@patch("tools.web_features.web_feature_map.WebFeatureToTestsDirMapper._process_non_recursive_feature") +@patch("tools.web_features.web_feature_map.WebFeatureToTestsDirMapper._process_inherited_features") +def test_run_with_web_feature_file( + _process_inherited_features, + _process_non_recursive_feature, + _process_recursive_feature): + feature_entry1 = create_feature_entry("feature1", True) + feature_entry2 = create_feature_entry("feature2", files=[FeatureFile("test_file1.py")]) + mock_web_feature_file = Mock( + features=[ + feature_entry1, + feature_entry2, + ]) + mapper = WebFeatureToTestsDirMapper(TEST_FILES, mock_web_feature_file) + + + result = WebFeaturesMap() + mapper.run(result, ["foo", "bar"]) + + _process_recursive_feature.assert_called_once_with( + [], feature_entry1, result + ) + _process_non_recursive_feature.assert_called_once_with( + "feature2", [FeatureFile("test_file1.py")], result + ) + + assert not _process_inherited_features.called + +@patch("tools.web_features.web_feature_map.WebFeatureToTestsDirMapper._process_recursive_feature") +@patch("tools.web_features.web_feature_map.WebFeatureToTestsDirMapper._process_non_recursive_feature") +@patch("tools.web_features.web_feature_map.WebFeatureToTestsDirMapper._process_inherited_features") +def test_run_without_web_feature_file( + _process_inherited_features, + _process_non_recursive_feature, + _process_recursive_feature): + mapper = WebFeatureToTestsDirMapper(TEST_FILES, None) + + result = WebFeaturesMap() + mapper.run(result, ["foo", "bar"]) + + assert not _process_recursive_feature.called + assert not _process_non_recursive_feature.called + + _process_inherited_features.assert_called_once_with( + ["foo", "bar"], result + ) diff --git a/testing/web-platform/tests/tools/web_features/web_feature_map.py b/testing/web-platform/tests/tools/web_features/web_feature_map.py new file mode 100644 index 0000000000000..d66b07e1146f9 --- /dev/null +++ b/testing/web-platform/tests/tools/web_features/web_feature_map.py @@ -0,0 +1,119 @@ +import itertools + +from collections import OrderedDict +from os.path import basename +from typing import Dict, List, Optional, Sequence, Set + +from ..manifest.item import ManifestItem, URLManifestItem +from ..manifest.sourcefile import SourceFile +from ..metadata.webfeatures.schema import FeatureEntry, FeatureFile, WebFeaturesFile + + +class WebFeaturesMap: + """ + Stores a mapping of web-features to their associated test paths. + """ + + def __init__(self) -> None: + """ + Initializes the WebFeaturesMap with an OrderedDict to maintain feature order. + """ + self._feature_tests_map_: OrderedDict[str, Set[str]] = OrderedDict() + + + def add(self, feature: str, manifest_items: List[ManifestItem]) -> None: + """ + Adds a web feature and its associated test paths to the map. + + Args: + feature: The web-features identifier. + manifest_items: The ManifestItem objects representing the test paths. + """ + tests = self._feature_tests_map_.get(feature, set()) + self._feature_tests_map_[feature] = tests.union([ + manifest_item.url for manifest_item in manifest_items if isinstance(manifest_item, URLManifestItem)]) + + + def to_dict(self) -> Dict[str, List[str]]: + """ + Returns: + The plain dictionary representation of the map. + """ + rv: Dict[str, List[str]] = {} + for feature, manifest_items in self._feature_tests_map_.items(): + # Sort the list to keep output stable + rv[feature] = sorted(manifest_items) + return rv + + +class WebFeatureToTestsDirMapper: + """ + Maps web-features to tests within a specified directory. + """ + + def __init__( + self, + all_test_files_in_dir: List[SourceFile], + web_feature_file: Optional[WebFeaturesFile]): + """ + Initializes the mapper with test paths and web feature information. + """ + + self.all_test_files_in_dir = all_test_files_in_dir + self.test_path_to_manifest_items_map = dict([(basename(f.path), f.manifest_items()[1]) for f in self.all_test_files_in_dir]) + # Used to check if the current directory has a WEB_FEATURE_FILENAME + self.web_feature_file = web_feature_file + # Gets the manifest items for each test path and returns them into a single list. + self. get_all_manifest_items_for_dir = list(itertools.chain.from_iterable([ + items for _, items in self.test_path_to_manifest_items_map.items()])) + + + def _process_inherited_features( + self, + inherited_features: List[str], + result: WebFeaturesMap) -> None: + # No WEB_FEATURE.yml in this directory. Simply add the current features to the inherited features + for inherited_feature in inherited_features: + result.add(inherited_feature, self.get_all_manifest_items_for_dir) + + def _process_recursive_feature( + self, + inherited_features: List[str], + feature: FeatureEntry, + result: WebFeaturesMap) -> None: + inherited_features.append(feature.name) + result.add(feature.name, self.get_all_manifest_items_for_dir) + + def _process_non_recursive_feature( + self, + feature_name: str, + files: Sequence[FeatureFile], + result: WebFeaturesMap) -> None: + # If the feature does not apply recursively, look at the individual + # files and match them against all_test_files_in_dir. + test_file_paths: List[ManifestItem] = [] + base_test_file_names = [basename(f.path) for f in self.all_test_files_in_dir] + for test_file in files: + matched_base_file_names = test_file.match_files(base_test_file_names) + test_file_paths.extend(itertools.chain.from_iterable([ + self.test_path_to_manifest_items_map[f] for f in matched_base_file_names])) + + result.add(feature_name, test_file_paths) + + def run(self, result: WebFeaturesMap, inherited_features: List[str]) -> None: + if self.web_feature_file: + # Do not copy the inherited features because the presence of a + # WEB_FEATURES.yml file indicates new instructions. + inherited_features.clear() + + # Iterate over all the features in this new file + for feature in self.web_feature_file.features: + # Handle the "**" case + if feature.does_feature_apply_recursively(): + self._process_recursive_feature(inherited_features, feature, result) + + # Handle the non recursive case. + elif isinstance(feature.files, List) and feature.files: + self._process_non_recursive_feature(feature.name, feature.files, result) + else: + self._process_inherited_features(inherited_features, result) diff --git a/testing/web-platform/tests/tools/wpt/paths b/testing/web-platform/tests/tools/wpt/paths index 7e9ae837ecf40..5a1303362b368 100644 --- a/testing/web-platform/tests/tools/wpt/paths +++ b/testing/web-platform/tests/tools/wpt/paths @@ -5,3 +5,4 @@ tools/lint/ tools/manifest/ tools/serve/ tools/wpt/ +tools/web_features/