diff --git a/launchable/test_runners/playwright.py b/launchable/test_runners/playwright.py index 87e803213..2b306b185 100644 --- a/launchable/test_runners/playwright.py +++ b/launchable/test_runners/playwright.py @@ -3,13 +3,14 @@ # https://playwright.dev/ # import json +from pathlib import Path from typing import Dict, Generator, List import click from junitparser import TestCase, TestSuite # type: ignore from ..commands.record.case_event import CaseEvent -from ..testpath import TestPath +from ..testpath import TestPath, prepend_path_if_missing, relative_subpath from . import launchable TEST_CASE_DELIMITER = " › " @@ -173,13 +174,56 @@ def parse_func(self, report_file: str) -> Generator[CaseEvent, None, None]: click.echo("Can't find test results from {}. Make sure to confirm report file.".format( report_file), err=True) + root_dir_relpath = self._compute_root_dir_relpath(data) + config_dir = self._config_dir(data) for s in suites: # The title of the root suite object contains the file name. - test_file = str(s.get("title", "")) + test_file = self._resolve_test_file(str(s.get("title", "")), root_dir_relpath, config_dir) for event in self._parse_suites(test_file, s, []): yield event + def _compute_root_dir_relpath(self, report: Dict) -> str: + """ + Playwright JSON stores test `file` paths relative to `config.rootDir`. + Our CLI wants paths relative to the Playwright config directory + (usually the project/repo root), so we compute: + relpath(root_dir, base_dir) + where base_dir = dirname(configFile). + + Example: + configFile = /repo/playwright.config.ts + rootDir = /repo/tests + relpath(...) -> "tests" + """ + config: Dict = report.get("config", {}) + config_dir = self._config_dir(report) + root_dir = str(config.get("rootDir", "")) + # TODO: We currently haven't supported the following sibling/parent path cases. + # configFile = /repo/foo/playwright.config.ts + # rootDir = /repo/tests + if config_dir and root_dir: + return relative_subpath(root_dir, config_dir) + + return "" + + def _config_dir(self, report: Dict) -> str: + config: Dict = report.get("config", {}) + config_file = str(config.get("configFile", "")) + if config_file: + return Path(config_file).parent.as_posix() + + return "" + + def _resolve_test_file(self, test_file: str, root_dir_relpath: str, config_dir: str) -> str: + test_file = prepend_path_if_missing(test_file, root_dir_relpath) + if self.client.base_path and config_dir: + # When --base is set, hand an absolute path to make_file_path_component() + # so its existing base_path relativization works as intended. + return Path(config_dir, test_file).absolute().as_posix() + + return test_file + def _parse_suites(self, test_file: str, suite: Dict[str, Dict], test_case_names: List[str] = []) -> List: events = [] diff --git a/launchable/testpath.py b/launchable/testpath.py index 5a9456fc6..a362a7914 100644 --- a/launchable/testpath.py +++ b/launchable/testpath.py @@ -95,6 +95,31 @@ def _relative_to(p: pathlib.Path, base: str) -> pathlib.Path: return resolved.relative_to(base) +def relative_subpath(path: str, base_path: str) -> str: + if not path or not base_path: + return "" + + try: + relpath = pathlib.Path(path).relative_to(pathlib.Path(base_path)).as_posix() + except ValueError: + return "" + + if relpath == ".": + return "" + + return relpath + + +def prepend_path_if_missing(path: str, prefix: str) -> str: + if not path or not prefix: + return path + + if path.startswith(prefix): + return path + + return pathlib.Path(prefix, path).as_posix() + + class FilePathNormalizer: """Normalize file paths based on the Git repository root diff --git a/tests/data/playwright/packages/e2e/.gitkeep b/tests/data/playwright/packages/e2e/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/data/playwright/playwright.config.ts b/tests/data/playwright/playwright.config.ts new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/data/playwright/playwright.config.ts @@ -0,0 +1 @@ + diff --git a/tests/data/playwright/record_test_result_with_json_base.json b/tests/data/playwright/record_test_result_with_json_base.json new file mode 100644 index 000000000..394417be0 --- /dev/null +++ b/tests/data/playwright/record_test_result_with_json_base.json @@ -0,0 +1,49 @@ +{ + "events": [ + { + "type": "case", + "testPath": [ + { + "type": "file", + "name": "tests/a.spec.ts" + }, + { + "type": "testcase", + "name": "smoke \u203a passes" + } + ], + "duration": 0.012, + "status": 1, + "stdout": "", + "stderr": "", + "data": { + "lineNumber": 10 + } + }, + { + "type": "case", + "testPath": [ + { + "type": "file", + "name": "tests/b.spec.ts" + }, + { + "type": "testcase", + "name": "smoke \u203a already prefixed" + } + ], + "duration": 0.015, + "status": 1, + "stdout": "", + "stderr": "", + "data": { + "lineNumber": 20 + } + } + ], + "testRunner": "playwright", + "group": "", + "noBuild": false, + "flavors": [], + "testSuite": "" +} diff --git a/tests/data/playwright/record_test_result_with_prefix.json b/tests/data/playwright/record_test_result_with_prefix.json new file mode 100644 index 000000000..69a6fcf3c --- /dev/null +++ b/tests/data/playwright/record_test_result_with_prefix.json @@ -0,0 +1,49 @@ +{ + "events": [ + { + "type": "case", + "testPath": [ + { + "type": "file", + "name": "packages/e2e/tests/a.spec.ts" + }, + { + "type": "testcase", + "name": "smoke \u203a passes" + } + ], + "duration": 0.012, + "status": 1, + "stdout": "", + "stderr": "", + "data": { + "lineNumber": 10 + } + }, + { + "type": "case", + "testPath": [ + { + "type": "file", + "name": "packages/e2e/tests/b.spec.ts" + }, + { + "type": "testcase", + "name": "smoke \u203a already prefixed" + } + ], + "duration": 0.015, + "status": 1, + "stdout": "", + "stderr": "", + "data": { + "lineNumber": 20 + } + } + ], + "testRunner": "playwright", + "group": "", + "noBuild": false, + "flavors": [], + "testSuite": "" +} diff --git a/tests/data/playwright/report_with_json_base.json b/tests/data/playwright/report_with_json_base.json new file mode 100644 index 000000000..0608a1360 --- /dev/null +++ b/tests/data/playwright/report_with_json_base.json @@ -0,0 +1,64 @@ +{ + "config": { + "configFile": "tests/data/playwright/playwright.config.ts", + "rootDir": "tests/data/playwright/packages/e2e" + }, + "suites": [ + { + "title": "tests/a.spec.ts", + "specs": [], + "suites": [ + { + "title": "smoke", + "specs": [ + { + "title": "passes", + "line": 10, + "tests": [ + { + "results": [ + { + "status": "passed", + "duration": 12, + "stdout": [], + "errors": [] + } + ] + } + ] + } + ], + "suites": [] + } + ] + }, + { + "title": "packages/e2e/tests/b.spec.ts", + "specs": [], + "suites": [ + { + "title": "smoke", + "specs": [ + { + "title": "already prefixed", + "line": 20, + "tests": [ + { + "results": [ + { + "status": "passed", + "duration": 15, + "stdout": [], + "errors": [] + } + ] + } + ] + } + ], + "suites": [] + } + ] + } + ] +} diff --git a/tests/data/playwright/report_with_prefix.json b/tests/data/playwright/report_with_prefix.json new file mode 100644 index 000000000..870a9ba21 --- /dev/null +++ b/tests/data/playwright/report_with_prefix.json @@ -0,0 +1,64 @@ +{ + "config": { + "configFile": "/repo/playwright.config.ts", + "rootDir": "/repo/packages/e2e" + }, + "suites": [ + { + "title": "tests/a.spec.ts", + "specs": [], + "suites": [ + { + "title": "smoke", + "specs": [ + { + "title": "passes", + "line": 10, + "tests": [ + { + "results": [ + { + "status": "passed", + "duration": 12, + "stdout": [], + "errors": [] + } + ] + } + ] + } + ], + "suites": [] + } + ] + }, + { + "title": "packages/e2e/tests/b.spec.ts", + "specs": [], + "suites": [ + { + "title": "smoke", + "specs": [ + { + "title": "already prefixed", + "line": 20, + "tests": [ + { + "results": [ + { + "status": "passed", + "duration": 15, + "stdout": [], + "errors": [] + } + ] + } + ] + } + ], + "suites": [] + } + ] + } + ] +} diff --git a/tests/test_runners/test_playwright.py b/tests/test_runners/test_playwright.py index 95ab8751d..cff26d296 100644 --- a/tests/test_runners/test_playwright.py +++ b/tests/test_runners/test_playwright.py @@ -3,6 +3,7 @@ import os import sys import unittest +from pathlib import Path from unittest import mock import responses # type: ignore @@ -64,3 +65,30 @@ def _test_test_path_status(payload, test_path: str, status: CaseEvent) -> bool: 'playwright', '--json', str(self.test_files_dir.joinpath("report.json"))) json_payload = json.loads(gzip.decompress(self.find_request('/events', 1).request.body).decode()) self.assertEqual(_test_test_path_status(json_payload, target_test_path, CaseEvent.TEST_FAILED), True) + + @responses.activate + @mock.patch.dict(os.environ, + {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_record_test_with_json_option_adds_prefix_from_config(self): + report_file = str(self.test_files_dir.joinpath("report_with_prefix.json")) + + result = self.cli('record', 'tests', '--session', self.session, + 'playwright', '--json', report_file) + + self.assert_success(result) + self.assert_record_tests_payload('record_test_result_with_prefix.json') + + @responses.activate + @mock.patch.dict(os.environ, + {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + @unittest.skipIf( + sys.platform.startswith("win"), + "The base path fixture uses POSIX-style paths" + ) + def test_record_test_with_json_option_respects_base_path(self): + result = self.cli('record', 'tests', '--session', self.session, + '--base', str(self.test_files_dir.joinpath("packages", "e2e")), + 'playwright', '--json', str(self.test_files_dir.joinpath("report_with_json_base.json"))) + + self.assert_success(result) + self.assert_record_tests_payload('record_test_result_with_json_base.json') diff --git a/tests/test_testpath.py b/tests/test_testpath.py index 32d6253ad..635290d78 100644 --- a/tests/test_testpath.py +++ b/tests/test_testpath.py @@ -4,8 +4,9 @@ import sys import tempfile import unittest + # hello smart tests -from launchable.testpath import FilePathNormalizer, parse_test_path, unparse_test_path +from launchable.testpath import FilePathNormalizer, parse_test_path, prepend_path_if_missing, relative_subpath, unparse_test_path class TestPathEncodingTest(unittest.TestCase): @@ -136,5 +137,36 @@ def _run_command(self, args, cwd=None): self.fail("Failed to execute a command: {}\nSTDOUT: {}\nSTDERR: {}\n". format(e, e.stdout, e.stderr)) +class TestPathHelpers(unittest.TestCase): + def test_relative_subpath(self): + self.assertEqual( + "tests", + relative_subpath(str(pathlib.Path("repo", "tests")), str(pathlib.Path("repo")))) + + def test_relative_subpath_returns_empty_when_same_path(self): + self.assertEqual( + "", + relative_subpath(str(pathlib.Path("repo")), str(pathlib.Path("repo")))) + + def test_relative_subpath_returns_empty_when_not_under_base(self): + self.assertEqual( + "", + relative_subpath(str(pathlib.Path("repo", "tests")), str(pathlib.Path("other")))) + + def test_prepend_path_if_missing(self): + self.assertEqual( + "tests/a.spec.ts", + prepend_path_if_missing("a.spec.ts", "tests")) + + def test_prepend_path_if_missing_when_already_prefixed(self): + self.assertEqual( + "tests/a.spec.ts", + prepend_path_if_missing("tests/a.spec.ts", "tests")) + + def test_prepend_path_if_missing_when_empty_input(self): + self.assertEqual("", prepend_path_if_missing("", "tests")) + self.assertEqual("a.spec.ts", prepend_path_if_missing("a.spec.ts", "")) + + if __name__ == '__main__': unittest.main()