From 7b46a7bc8ab7ba16305a813926f4b9ccea9c3b48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:40:46 +0000 Subject: [PATCH 1/5] [tagpr] prepare for the next release From c3624f609cd274e95c69fe07181288e19619dcd0 Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Wed, 4 Mar 2026 11:06:36 +0900 Subject: [PATCH 2/5] playwright: add support for test file prefixes in JSON reports --- launchable/test_runners/playwright.py | 45 ++++++++++++- tests/data/playwright/report_with_prefix.json | 64 +++++++++++++++++++ tests/test_runners/test_playwright.py | 16 +++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/data/playwright/report_with_prefix.json diff --git a/launchable/test_runners/playwright.py b/launchable/test_runners/playwright.py index 87e803213..b18c53262 100644 --- a/launchable/test_runners/playwright.py +++ b/launchable/test_runners/playwright.py @@ -3,7 +3,9 @@ # https://playwright.dev/ # import json +import os from typing import Dict, Generator, List +from pathlib import Path import click from junitparser import TestCase, TestSuite # type: ignore @@ -173,13 +175,54 @@ 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) + test_prefix = self._compute_test_prefix(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", "")), test_prefix) for event in self._parse_suites(test_file, s, []): yield event + def _compute_test_prefix(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_file = str(config.get("configFile", "")) + root_dir = str(config.get("rootDir", "")) + if not config_file or not root_dir: + return "" + + base_dir = Path(config_file).parent + try: + test_prefix = Path(root_dir).relative_to(base_dir).as_posix() + except ValueError: + return "" + + if test_prefix == ".": + return "" + + return test_prefix + + def _resolve_test_file(self, test_file: str, test_prefix: str) -> str: + if not test_prefix or not test_file: + return test_file + + # Guard against duplicate paths when report data is already prefixed. + if test_file.startswith(test_prefix): + return test_file + + return Path(test_prefix, test_file).as_posix() + def _parse_suites(self, test_file: str, suite: Dict[str, Dict], test_case_names: List[str] = []) -> List: events = [] 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..6e361950a 100644 --- a/tests/test_runners/test_playwright.py +++ b/tests/test_runners/test_playwright.py @@ -64,3 +64,19 @@ 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) + + payload = json.loads(gzip.decompress(self.find_request('/events').request.body).decode()) + test_paths = [unparse_test_path(event.get("testPath")) for event in payload.get("events")] + self.assertIn("file=packages/e2e/tests/a.spec.ts#testcase=smoke › passes", test_paths) + self.assertIn("file=packages/e2e/tests/b.spec.ts#testcase=smoke › already prefixed", test_paths) From a392c8608d63f45c30d6ea1937683f261a29f71b Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Wed, 4 Mar 2026 15:52:27 +0900 Subject: [PATCH 3/5] Remove unused imports --- launchable/test_runners/playwright.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/launchable/test_runners/playwright.py b/launchable/test_runners/playwright.py index b18c53262..939920b3c 100644 --- a/launchable/test_runners/playwright.py +++ b/launchable/test_runners/playwright.py @@ -3,9 +3,8 @@ # https://playwright.dev/ # import json -import os -from typing import Dict, Generator, List from pathlib import Path +from typing import Dict, Generator, List import click from junitparser import TestCase, TestSuite # type: ignore From dcd742f6749dc0ac38815859b039f9f21a21c025 Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Wed, 4 Mar 2026 19:06:24 +0900 Subject: [PATCH 4/5] Cleanup naming --- launchable/test_runners/playwright.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/launchable/test_runners/playwright.py b/launchable/test_runners/playwright.py index 939920b3c..d30205d76 100644 --- a/launchable/test_runners/playwright.py +++ b/launchable/test_runners/playwright.py @@ -174,15 +174,15 @@ 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) - test_prefix = self._compute_test_prefix(data) + root_dir_relpath = self._compute_root_dir_relpath(data) for s in suites: # The title of the root suite object contains the file name. - test_file = self._resolve_test_file(str(s.get("title", "")), test_prefix) + test_file = self._resolve_test_file(str(s.get("title", "")), root_dir_relpath) for event in self._parse_suites(test_file, s, []): yield event - def _compute_test_prefix(self, report: Dict) -> str: + 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 @@ -203,24 +203,24 @@ def _compute_test_prefix(self, report: Dict) -> str: base_dir = Path(config_file).parent try: - test_prefix = Path(root_dir).relative_to(base_dir).as_posix() + root_dir_relpath = Path(root_dir).relative_to(base_dir).as_posix() except ValueError: return "" - if test_prefix == ".": + if root_dir_relpath == ".": return "" - return test_prefix + return root_dir_relpath - def _resolve_test_file(self, test_file: str, test_prefix: str) -> str: - if not test_prefix or not test_file: + def _resolve_test_file(self, test_file: str, root_dir_relpath: str) -> str: + if not root_dir_relpath or not test_file: return test_file # Guard against duplicate paths when report data is already prefixed. - if test_file.startswith(test_prefix): + if test_file.startswith(root_dir_relpath): return test_file - return Path(test_prefix, test_file).as_posix() + return Path(root_dir_relpath, test_file).as_posix() def _parse_suites(self, test_file: str, suite: Dict[str, Dict], test_case_names: List[str] = []) -> List: events = [] From 521fcfc228ebc04ad5ebc8d8ad9e35b9f6efe20c Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Wed, 4 Mar 2026 19:22:10 +0900 Subject: [PATCH 5/5] Extract test path logic --- launchable/test_runners/playwright.py | 25 +++----------------- launchable/testpath.py | 25 ++++++++++++++++++++ tests/test_testpath.py | 34 ++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/launchable/test_runners/playwright.py b/launchable/test_runners/playwright.py index d30205d76..b7d0c25fb 100644 --- a/launchable/test_runners/playwright.py +++ b/launchable/test_runners/playwright.py @@ -10,7 +10,7 @@ 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 = " › " @@ -177,7 +177,7 @@ def parse_func(self, report_file: str) -> Generator[CaseEvent, None, None]: root_dir_relpath = self._compute_root_dir_relpath(data) for s in suites: # The title of the root suite object contains the file name. - test_file = self._resolve_test_file(str(s.get("title", "")), root_dir_relpath) + test_file = prepend_path_if_missing(str(s.get("title", "")), root_dir_relpath) for event in self._parse_suites(test_file, s, []): yield event @@ -201,26 +201,7 @@ def _compute_root_dir_relpath(self, report: Dict) -> str: if not config_file or not root_dir: return "" - base_dir = Path(config_file).parent - try: - root_dir_relpath = Path(root_dir).relative_to(base_dir).as_posix() - except ValueError: - return "" - - if root_dir_relpath == ".": - return "" - - return root_dir_relpath - - def _resolve_test_file(self, test_file: str, root_dir_relpath: str) -> str: - if not root_dir_relpath or not test_file: - return test_file - - # Guard against duplicate paths when report data is already prefixed. - if test_file.startswith(root_dir_relpath): - return test_file - - return Path(root_dir_relpath, test_file).as_posix() + return relative_subpath(root_dir, str(Path(config_file).parent)) 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/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()