From e4de6f43057e3e0a2e5675c203c74d71507a5b8b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Sep 2025 04:30:29 +0000 Subject: [PATCH 01/29] [tagpr] prepare for the next release From 839f5eb32e0d3ad9763f9ebae7787b41741a09da Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Tue, 24 Jun 2025 18:29:01 +0900 Subject: [PATCH 02/29] fix: an error message in a test --- tests/commands/record/test_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/record/test_session.py b/tests/commands/record/test_session.py index b3206b802..50fb36e1d 100644 --- a/tests/commands/record/test_session.py +++ b/tests/commands/record/test_session.py @@ -65,7 +65,7 @@ def test_run_session_with_flavor(self): result = self.cli("record", "session", "--build", self.build_name, "--flavor", "only-key") self.assert_exit_code(result, 2) - self.assertIn("Expected a key-value pair formatted as --option key=value", result.output) + self.assertIn("Expected a key-value pair formatted as --option key=value, but got 'only-key'", result.output) @responses.activate @mock.patch.dict(os.environ, { From 220d3604dd51f0631a240156fac5c905df3968cd Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Tue, 24 Jun 2025 19:17:38 +0900 Subject: [PATCH 03/29] fix: no color on tests --- tests/cli_test_case.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/cli_test_case.py b/tests/cli_test_case.py index e79611cc5..8b962bc3a 100644 --- a/tests/cli_test_case.py +++ b/tests/cli_test_case.py @@ -216,7 +216,17 @@ def cli(self, *args, **kwargs) -> click.testing.Result: mix_stderr = kwargs['mix_stderr'] del kwargs['mix_stderr'] - return CliRunner(mix_stderr=mix_stderr).invoke(main, args, catch_exceptions=False, **kwargs) + # Disable rich colors for testing by setting the environment variable + import os + old_no_color = os.environ.get('NO_COLOR') + os.environ['NO_COLOR'] = '1' + try: + return CliRunner(mix_stderr=mix_stderr).invoke(main, args, catch_exceptions=False, **kwargs) + finally: + if old_no_color is None: + os.environ.pop('NO_COLOR', None) + else: + os.environ['NO_COLOR'] = old_no_color def assert_success(self, result: click.testing.Result): self.assert_exit_code(result, 0) From 7c9014b38a539060c6078b13fc5c44e9b5f11778 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Wed, 25 Jun 2025 11:16:03 +0900 Subject: [PATCH 04/29] fix: test --- tests/commands/record/test_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/record/test_session.py b/tests/commands/record/test_session.py index 50fb36e1d..5c5833f6e 100644 --- a/tests/commands/record/test_session.py +++ b/tests/commands/record/test_session.py @@ -65,7 +65,7 @@ def test_run_session_with_flavor(self): result = self.cli("record", "session", "--build", self.build_name, "--flavor", "only-key") self.assert_exit_code(result, 2) - self.assertIn("Expected a key-value pair formatted as --option key=value, but got 'only-key'", result.output) + self.assertIn("but got 'only-key'", result.output) @responses.activate @mock.patch.dict(os.environ, { From b917b58ba3e85e0ae507554087d24e669f41888a Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Fri, 27 Jun 2025 10:26:30 +0900 Subject: [PATCH 05/29] test: revive test_click.py as test_typer.py --- tests/utils/test_typer.py | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/utils/test_typer.py diff --git a/tests/utils/test_typer.py b/tests/utils/test_typer.py new file mode 100644 index 000000000..202c6f492 --- /dev/null +++ b/tests/utils/test_typer.py @@ -0,0 +1,59 @@ +import datetime +from datetime import timezone +from unittest import TestCase + +import typer +from dateutil.tz import tzlocal + +from launchable.utils.typer_types import (DATETIME_WITH_TZ, KEY_VALUE, convert_to_seconds, + validate_datetime_with_tz, validate_key_value) + + +class DurationTypeTest(TestCase): + def test_convert_to_seconds(self): + self.assertEqual(convert_to_seconds('30s'), 30) + self.assertEqual(convert_to_seconds('5m'), 300) + self.assertEqual(convert_to_seconds('1h30m'), 5400) + self.assertEqual(convert_to_seconds('1d10h15m'), 123300) + self.assertEqual(convert_to_seconds('15m 1d 10h'), 123300) + + with self.assertRaises(ValueError): + convert_to_seconds('1h30k') + + +class KeyValueTypeTest(TestCase): + def test_conversion(self): + # Test the validate_key_value function directly + self.assertEqual(validate_key_value('bar=zot'), ('bar', 'zot')) + self.assertEqual(validate_key_value('a=b'), ('a', 'b')) + self.assertEqual(validate_key_value('key:value'), ('key', 'value')) + + with self.assertRaises(typer.BadParameter): + validate_key_value('invalid') + + # Test the parser class + parser = KEY_VALUE + self.assertEqual(parser('bar=zot'), ('bar', 'zot')) + self.assertEqual(parser('a=b'), ('a', 'b')) + + +class TimestampTypeTest(TestCase): + def test_conversion(self): + # Test the validate_datetime_with_tz function directly + result1 = validate_datetime_with_tz('2023-10-01 12:00:00') + expected1 = datetime.datetime(2023, 10, 1, 12, 0, 0, tzinfo=tzlocal()) + self.assertEqual(result1, expected1) + + result2 = validate_datetime_with_tz('2023-10-01 20:00:00+00:00') + expected2 = datetime.datetime(2023, 10, 1, 20, 0, 0, tzinfo=timezone.utc) + self.assertEqual(result2, expected2) + + result3 = validate_datetime_with_tz('2023-10-01T20:00:00Z') + expected3 = datetime.datetime(2023, 10, 1, 20, 0, 0, tzinfo=timezone.utc) + self.assertEqual(result3, expected3) + + # Test the parser class + parser = DATETIME_WITH_TZ + result4 = parser('2023-10-01 12:00:00') + expected4 = datetime.datetime(2023, 10, 1, 12, 0, 0, tzinfo=tzlocal()) + self.assertEqual(result4, expected4) From 7028a929583f765cf07fd7ebfbed34f05435dbb1 Mon Sep 17 00:00:00 2001 From: Konboi Date: Thu, 19 Jun 2025 09:58:40 +0900 Subject: [PATCH 06/29] Groovy lang works on JVM but we haven't supported it as jvm_test_pattern so, fixed the regexp to support groovy From b5bacedf13f2657ac796ac0c0116099245e8b69d Mon Sep 17 00:00:00 2001 From: Konboi Date: Thu, 19 Jun 2025 14:24:13 +0900 Subject: [PATCH 07/29] support groovy file in the maven profile From 53c5ebb82bee906a570f3f1dedd224439bd7f71d Mon Sep 17 00:00:00 2001 From: Konboi Date: Mon, 23 Jun 2025 18:05:44 +0900 Subject: [PATCH 08/29] install latest version --- setup.cfg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index d86cc5b35..176e2e11d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,9 @@ [metadata] -name = launchable +name = smart-tests author = Launchable, Inc. author_email = info@launchableinc.com license = Apache Software License v2 -description = Launchable CLI +description = Smart Tests CLI url = https://launchableinc.com/ long_description = file: README.md long_description_content_type = text/markdown @@ -30,7 +30,7 @@ setup_requires = setuptools-scm [options.entry_points] -console_scripts = launchable = launchable.__main__:main +console_scripts = smart-tests = smart_tests.__main__:main [options.package_data] -launchable = jar/exe_deploy.jar +smart_tests = jar/exe_deploy.jar From e2660460573412c027a70efe765a4dd2956279d7 Mon Sep 17 00:00:00 2001 From: Konboi Date: Mon, 23 Jun 2025 18:09:03 +0900 Subject: [PATCH 09/29] from junitparser v4.0.0 returns JUnitXml instead of testsuite From 4400e9dc1966798e32633094895c1d4946b2a511 Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Fri, 13 Jun 2025 10:59:54 +0900 Subject: [PATCH 10/29] [LCHIB-612] Add a workaround for handling timezone abbreviations in dateutil From 92f81d355a8978c672f04f91589f4c1f2b60f0e4 Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Wed, 25 Jun 2025 11:03:06 +0900 Subject: [PATCH 11/29] Use assertEqual instead of assertTrue and assertIn From 9164968bcaae4dedfd5fa725b6ff5435f14e31b7 Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Wed, 25 Jun 2025 11:07:33 +0900 Subject: [PATCH 12/29] Add comment From 55de2306cb7809e2707263b31b500291c1be10fc Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Fri, 27 Jun 2025 10:26:30 +0900 Subject: [PATCH 13/29] test: revive test_click.py as test_typer.py From 47c19cec8baeb00afef2b374d9f1e4dc33eb19b7 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Fri, 4 Jul 2025 17:22:11 +0900 Subject: [PATCH 14/29] feature: replace LAUNCHABLE_ env in Java code --- .../ingest/commits/CommitIngester.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchableinc/ingest/commits/CommitIngester.java b/src/main/java/com/launchableinc/ingest/commits/CommitIngester.java index 2efff19c0..302b30759 100644 --- a/src/main/java/com/launchableinc/ingest/commits/CommitIngester.java +++ b/src/main/java/com/launchableinc/ingest/commits/CommitIngester.java @@ -88,18 +88,18 @@ public void setNoCommitMessage(boolean b) { private void parseConfiguration() throws CmdLineException { String apiToken = launchableToken; if (launchableToken == null) { - apiToken = System.getenv("LAUNCHABLE_TOKEN"); + apiToken = System.getenv("SMART_TESTS_TOKEN"); } if (apiToken == null || apiToken.isEmpty()) { if (System.getenv("GITHUB_ACTIONS") != null) { - String o = System.getenv("LAUNCHABLE_ORGANIZATION"); + String o = System.getenv("SMART_TESTS_ORGANIZATION"); if (org == null && o == null) { - throw new CmdLineException("LAUNCHABLE_ORGANIZATION env variable is not set"); + throw new CmdLineException("SMART_TESTS_ORGANIZATION env variable is not set"); } - String w = System.getenv("LAUNCHABLE_WORKSPACE"); + String w = System.getenv("SMART_TESTS_WORKSPACE"); if (ws == null && w == null) { - throw new CmdLineException("LAUNCHABLE_WORKSPACE env variable is not set"); + throw new CmdLineException("SMART_TESTS_WORKSPACE env variable is not set"); } if (org == null) { @@ -118,7 +118,7 @@ private void parseConfiguration() throws CmdLineException { return; } - throw new CmdLineException("LAUNCHABLE_TOKEN env variable is not set"); + throw new CmdLineException("SMART_TESTS_TOKEN env variable is not set"); } this.parseLaunchableToken(apiToken); @@ -163,11 +163,11 @@ private void parseLaunchableToken(String token) throws CmdLineException { if (token.startsWith("v1:")) { String[] v = token.split(":"); if (v.length != 3) { - throw new IllegalStateException("Malformed LAUNCHABLE_TOKEN"); + throw new IllegalStateException("Malformed SMART_TESTS_TOKEN"); } v = v[1].split("/"); if (v.length != 2) { - throw new IllegalStateException("Malformed LAUNCHABLE_TOKEN"); + throw new IllegalStateException("Malformed SMART_TESTS_TOKEN"); } // for backward compatibility, allow command line options to take precedence From 59cd141d8395d2c93cb2fbbf322bdca8d6e1eb79 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Fri, 4 Jul 2025 21:43:24 +0900 Subject: [PATCH 15/29] test: fix tests --- tests/commands/record/test_session.py | 87 ++++++++++++++++++++++++++- tests/commands/test_api_error.py | 33 ++++++++-- 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/tests/commands/record/test_session.py b/tests/commands/record/test_session.py index 5c5833f6e..61c53a482 100644 --- a/tests/commands/record/test_session.py +++ b/tests/commands/record/test_session.py @@ -24,7 +24,18 @@ class SessionTest(CliTestCase): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_without_flavor(self): - result = self.cli("record", "session", "--build", self.build_name) + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + + result = self.cli("record", "session", "--build", self.build_name, "--session", self.session_name) self.assert_success(result) payload = json.loads(responses.calls[1].request.body.decode()) @@ -44,7 +55,19 @@ def test_run_session_without_flavor(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_flavor(self): + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + result = self.cli("record", "session", "--build", self.build_name, + "--session", self.session_name, "--flavor", "key=value", "--flavor", "k:v", "--flavor", "k e y = v a l u e") self.assert_success(result) @@ -63,7 +86,18 @@ def test_run_session_with_flavor(self): "timestamp": None, }, payload) - result = self.cli("record", "session", "--build", self.build_name, "--flavor", "only-key") + # Mock session name check for second call + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + + result = self.cli("record", "session", "--build", self.build_name, "--session", self.session_name, "--flavor", "only-key") self.assert_exit_code(result, 2) self.assertIn("but got 'only-key'", result.output) @@ -73,7 +107,18 @@ def test_run_session_with_flavor(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_observation(self): - result = self.cli("record", "session", "--build", self.build_name, "--observation") + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + + result = self.cli("record", "session", "--build", self.build_name, "--session", self.session_name, "--observation") self.assert_success(result) payload = json.loads(responses.calls[1].request.body.decode()) @@ -133,7 +178,19 @@ def test_run_session_with_session_name(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_lineage(self): + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + result = self.cli("record", "session", "--build", self.build_name, + "--session", self.session_name, "--lineage", "example-lineage") self.assert_success(result) @@ -154,7 +211,19 @@ def test_run_session_with_lineage(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_test_suite(self): + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + result = self.cli("record", "session", "--build", self.build_name, + "--session", self.session_name, "--test-suite", "example-test-suite") self.assert_success(result) @@ -175,7 +244,19 @@ def test_run_session_with_test_suite(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_timestamp(self): + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + result = self.cli("record", "session", "--build", self.build_name, + "--session", self.session_name, "--timestamp", "2023-10-01T12:00:00Z") self.assert_success(result) diff --git a/tests/commands/test_api_error.py b/tests/commands/test_api_error.py index 8a9249a35..69bc3dfe3 100644 --- a/tests/commands/test_api_error.py +++ b/tests/commands/test_api_error.py @@ -175,6 +175,16 @@ def test_record_build(self): @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) def test_record_session(self): build = "internal_server_error" + # Mock session name check + responses.add( + responses.GET, + "{base}/intake/organizations/{org}/workspaces/{ws}/builds/{build}/test_sessions/{session}".format( + base=get_base_url(), + org=self.organization, + ws=self.workspace, + build=build, + session=self.session_name), + status=404) responses.add( responses.POST, "{base}/intake/organizations/{org}/workspaces/{ws}/builds/{build}/test_sessions".format( @@ -189,12 +199,22 @@ def test_record_session(self): base=get_base_url()), body=ReadTimeout("error")) - result = self.cli("record", "session", "--build", build) + result = self.cli("record", "session", "--build", build, "--session", self.session_name) self.assert_success(result) - # Since HTTPError is occurred outside of LaunchableClient, the count is 1. - self.assert_tracking_count(tracking=tracking, count=1) + # Since HTTPError is occurred outside of LaunchableClient, the count is 2 (one for GET check, one for POST). + self.assert_tracking_count(tracking=tracking, count=2) build = "not_found" + # Mock session name check + responses.add( + responses.GET, + "{base}/intake/organizations/{org}/workspaces/{ws}/builds/{build}/test_sessions/{session}".format( + base=get_base_url(), + org=self.organization, + ws=self.workspace, + build=build, + session=self.session_name), + status=404) responses.add( responses.POST, "{base}/intake/organizations/{org}/workspaces/{ws}/builds/{build}/test_sessions".format( @@ -209,13 +229,14 @@ def test_record_session(self): base=get_base_url()), body=ReadTimeout("error")) - result = self.cli("record", "session", "--build", build) + result = self.cli("record", "session", "--build", build, "--session", self.session_name) self.assert_exit_code(result, 1) self.assert_tracking_count(tracking=tracking, count=1) - responses.replace( + # Mock session name check with ReadTimeout error + responses.add( responses.GET, - "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_session_names/{}".format( + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( get_base_url(), self.organization, self.workspace, From 05f898aebbdf2f7714d9dc8635f2a01d0974e339 Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Fri, 25 Jul 2025 14:08:57 +0900 Subject: [PATCH 16/29] Enhance PytestJSONReportParser to handle user properties as JSON From 5ea15f1b0a184a82e2f442d9aa21a3abb3188a5d Mon Sep 17 00:00:00 2001 From: Kohsuke Kawaguchi Date: Mon, 4 Aug 2025 19:57:42 -0700 Subject: [PATCH 17/29] Follow up fix to f85f624d3816716a220bcd4123edad30ce88babc The caller side was not removed. Oof. From e9506e91d8dc56faf21b5e2a2155adc11b9193eb Mon Sep 17 00:00:00 2001 From: Kohsuke Kawaguchi Date: Tue, 5 Aug 2025 10:49:27 -0700 Subject: [PATCH 18/29] Merge pull request #1044 from launchableinc/renovate/actions-attest-build-provenance-2.x Update actions/attest-build-provenance action to v2.4.0 From 3e652d5d10edddd31ddbaf722afc19da86f0945f Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Tue, 19 Aug 2025 11:31:46 +0900 Subject: [PATCH 19/29] refactor: remove intermidiate documents From d639ea6369b38db8285eeb5f6139254735717812 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Wed, 20 Aug 2025 15:12:36 +0900 Subject: [PATCH 20/29] fix: import paths --- tests/utils/test_fail_fast_mode.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/utils/test_fail_fast_mode.py b/tests/utils/test_fail_fast_mode.py index c150e5f8f..8fc2c8437 100644 --- a/tests/utils/test_fail_fast_mode.py +++ b/tests/utils/test_fail_fast_mode.py @@ -1,8 +1,8 @@ import io from contextlib import contextmanager, redirect_stderr -from launchable.utils.commands import Command -from launchable.utils.fail_fast_mode import FailFastModeValidateParams, fail_fast_mode_validate +from smart_tests.utils.commands import Command +from smart_tests.utils.fail_fast_mode import FailFastModeValidateParams, fail_fast_mode_validate from tests.cli_test_case import CliTestCase @@ -30,7 +30,7 @@ def test_fail_fast_mode_validate(self): @contextmanager def tmp_set_fail_fast_mode(enabled: bool): - from launchable.utils.fail_fast_mode import _fail_fast_mode_cache, set_fail_fast_mode + from smart_tests.utils.fail_fast_mode import _fail_fast_mode_cache, set_fail_fast_mode original = _fail_fast_mode_cache try: set_fail_fast_mode(enabled) From cdf48cc7a109c4e8e769a3fe438505a268de980d Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Wed, 20 Aug 2025 16:06:32 +0900 Subject: [PATCH 21/29] test: add test_typer_types.py --- tests/utils/test_typer_types.py | 251 ++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 tests/utils/test_typer_types.py diff --git a/tests/utils/test_typer_types.py b/tests/utils/test_typer_types.py new file mode 100644 index 000000000..9b605e33e --- /dev/null +++ b/tests/utils/test_typer_types.py @@ -0,0 +1,251 @@ +import datetime +import sys +from datetime import timezone +from unittest import TestCase + +import typer +from dateutil.tz import tzlocal + +from smart_tests.utils.typer_types import (DATETIME_WITH_TZ, EMOJI, KEY_VALUE, DateTimeWithTimezone, Duration, Fraction, + KeyValue, Percentage, convert_to_seconds, emoji, parse_datetime_with_timezone, + parse_duration, parse_fraction, parse_key_value, parse_percentage, + validate_datetime_with_tz, validate_key_value, validate_past_datetime) + + +class PercentageTest(TestCase): + def test_parse_valid_percentage(self): + pct = parse_percentage("50%") + self.assertIsInstance(pct, Percentage) + self.assertEqual(pct.value, 0.5) + self.assertEqual(float(pct), 0.5) + self.assertEqual(str(pct), "50.0%") + + def test_parse_edge_cases(self): + # Test 0% and 100% + self.assertEqual(parse_percentage("0%").value, 0.0) + self.assertEqual(parse_percentage("100%").value, 1.0) + + # Test decimal percentages + self.assertEqual(parse_percentage("25.5%").value, 0.255) + + def test_parse_invalid_percentage_missing_percent(self): + orig_platform = sys.platform + try: + # Test Windows behavior + sys.platform = "win32" + with self.assertRaises(typer.BadParameter) as cm: + parse_percentage("50") + msg = str(cm.exception) + self.assertIn("Expected percentage like 50% but got '50'", msg) + self.assertIn("please write '50%%' to pass in '50%'", msg) + + # Test non-Windows behavior + sys.platform = "linux" + with self.assertRaises(typer.BadParameter) as cm: + parse_percentage("50") + msg = str(cm.exception) + self.assertIn("Expected percentage like 50% but got '50'", msg) + self.assertNotIn("please write '50%%' to pass in '50%'", msg) + finally: + sys.platform = orig_platform + + def test_parse_invalid_percentage_non_numeric(self): + with self.assertRaises(typer.BadParameter) as cm: + parse_percentage("abc%") + msg = str(cm.exception) + self.assertIn("Expected percentage like 50% but got 'abc%'", msg) + + def test_percentage_class_methods(self): + pct = Percentage(0.75) + self.assertEqual(str(pct), "75.0%") + self.assertEqual(float(pct), 0.75) + + +class DurationTest(TestCase): + def test_convert_to_seconds(self): + self.assertEqual(convert_to_seconds('30s'), 30) + self.assertEqual(convert_to_seconds('5m'), 300) + self.assertEqual(convert_to_seconds('1h30m'), 5400) + self.assertEqual(convert_to_seconds('1d10h15m'), 123300) + self.assertEqual(convert_to_seconds('15m 1d 10h'), 123300) + self.assertEqual(convert_to_seconds('1w'), 604800) # 7 days + + # Test numeric only + self.assertEqual(convert_to_seconds('3600'), 3600) + + def test_convert_to_seconds_invalid(self): + with self.assertRaises(ValueError): + convert_to_seconds('1h30k') + + def test_parse_duration(self): + duration = parse_duration("30s") + self.assertIsInstance(duration, Duration) + self.assertEqual(duration.seconds, 30) + self.assertEqual(float(duration), 30) + self.assertEqual(str(duration), "30.0s") + + def test_parse_duration_invalid(self): + # Note: convert_to_seconds returns 0.0 for invalid input instead of raising ValueError + # So parse_duration returns Duration(0.0) for invalid input + duration = parse_duration("invalid") + self.assertEqual(duration.seconds, 0.0) + + +class KeyValueTest(TestCase): + def test_parse_key_value_equals(self): + kv = parse_key_value("key=value") + self.assertIsInstance(kv, KeyValue) + self.assertEqual(kv.key, "key") + self.assertEqual(kv.value, "value") + self.assertEqual(str(kv), "key=value") + + # Test tuple-like behavior + self.assertEqual(kv[0], "key") + self.assertEqual(kv[1], "value") + self.assertEqual(list(kv), ["key", "value"]) + + def test_parse_key_value_colon(self): + kv = parse_key_value("key:value") + self.assertEqual(kv.key, "key") + self.assertEqual(kv.value, "value") + + def test_parse_key_value_with_spaces(self): + kv = parse_key_value(" key = value ") + self.assertEqual(kv.key, "key") + self.assertEqual(kv.value, "value") + + def test_parse_key_value_with_multiple_delimiters(self): + # Should split on first occurrence only + kv = parse_key_value("key=value=extra") + self.assertEqual(kv.key, "key") + self.assertEqual(kv.value, "value=extra") + + def test_parse_key_value_invalid(self): + with self.assertRaises(typer.BadParameter) as cm: + parse_key_value("invalid") + msg = str(cm.exception) + self.assertIn("Expected a key-value pair formatted as --option key=value, but got 'invalid'", msg) + + def test_validate_key_value_compat(self): + # Test backward compatibility function + result = validate_key_value("key=value") + self.assertEqual(result, ("key", "value")) + + def test_key_value_compat_function(self): + # Test the KEY_VALUE constant + result = KEY_VALUE("key=value") + self.assertEqual(result, ("key", "value")) + + +class FractionTest(TestCase): + def test_parse_fraction(self): + frac = parse_fraction("3/4") + self.assertIsInstance(frac, Fraction) + self.assertEqual(frac.numerator, 3) + self.assertEqual(frac.denominator, 4) + self.assertEqual(str(frac), "3/4") + self.assertEqual(float(frac), 0.75) + + # Test tuple-like behavior + self.assertEqual(frac[0], 3) + self.assertEqual(frac[1], 4) + self.assertEqual(list(frac), [3, 4]) + + def test_parse_fraction_with_spaces(self): + frac = parse_fraction(" 1 / 2 ") + self.assertEqual(frac.numerator, 1) + self.assertEqual(frac.denominator, 2) + + def test_parse_fraction_invalid(self): + with self.assertRaises(typer.BadParameter) as cm: + parse_fraction("invalid") + msg = str(cm.exception) + self.assertIn("Expected fraction like 1/2 but got 'invalid'", msg) + + def test_parse_fraction_invalid_numbers(self): + with self.assertRaises(typer.BadParameter): + parse_fraction("a/b") + + +class DateTimeWithTimezoneTest(TestCase): + def test_parse_datetime_with_timezone(self): + dt_str = "2023-10-01 12:00:00+00:00" + dt_obj = parse_datetime_with_timezone(dt_str) + self.assertIsInstance(dt_obj, DateTimeWithTimezone) + self.assertEqual(dt_obj.dt.year, 2023) + self.assertEqual(dt_obj.dt.month, 10) + self.assertEqual(dt_obj.dt.day, 1) + self.assertEqual(dt_obj.dt.hour, 12) + # dateutil.parser creates tzutc() which is equivalent to but not equal to timezone.utc + self.assertEqual(dt_obj.dt.utcoffset(), timezone.utc.utcoffset(None)) + + def test_parse_datetime_without_timezone(self): + dt_str = "2023-10-01 12:00:00" + dt_obj = parse_datetime_with_timezone(dt_str) + self.assertEqual(dt_obj.dt.tzinfo, tzlocal()) + + def test_parse_datetime_iso_format(self): + dt_str = "2023-10-01T20:00:00Z" + dt_obj = parse_datetime_with_timezone(dt_str) + # dateutil.parser creates tzutc() which is equivalent to but not equal to timezone.utc + self.assertEqual(dt_obj.dt.utcoffset(), timezone.utc.utcoffset(None)) + + def test_parse_datetime_invalid(self): + with self.assertRaises(typer.BadParameter) as cm: + parse_datetime_with_timezone("invalid") + msg = str(cm.exception) + self.assertIn("Expected datetime like 2023-10-01T12:00:00 but got 'invalid'", msg) + + def test_datetime_with_timezone_methods(self): + dt_obj = parse_datetime_with_timezone("2023-10-01T12:00:00Z") + self.assertEqual(dt_obj.datetime(), dt_obj.dt) + # Test string representation + self.assertIn("2023-10-01T12:00:00", str(dt_obj)) + + def test_validate_datetime_with_tz_compat(self): + # Test backward compatibility function + result = validate_datetime_with_tz("2023-10-01T12:00:00Z") + self.assertIsInstance(result, datetime.datetime) + # dateutil.parser creates tzutc() which is equivalent to but not equal to timezone.utc + self.assertEqual(result.utcoffset(), timezone.utc.utcoffset(None)) + + def test_datetime_with_tz_compat_function(self): + # Test the DATETIME_WITH_TZ constant + result = DATETIME_WITH_TZ("2023-10-01T12:00:00Z") + self.assertIsInstance(result, datetime.datetime) + + def test_validate_past_datetime(self): + # Test with None + self.assertIsNone(validate_past_datetime(None)) + + # Test with past datetime + past_dt = datetime.datetime(2020, 1, 1, tzinfo=tzlocal()) + self.assertEqual(validate_past_datetime(past_dt), past_dt) + + # Test with future datetime + future_dt = datetime.datetime(2030, 1, 1, tzinfo=tzlocal()) + with self.assertRaises(typer.BadParameter) as cm: + validate_past_datetime(future_dt) + msg = str(cm.exception) + self.assertIn("The provided timestamp must be in the past", msg) + + # Test with non-datetime object + with self.assertRaises(typer.BadParameter) as cm: + validate_past_datetime("not a datetime") + msg = str(cm.exception) + self.assertIn("Expected a datetime object", msg) + + +class EmojiTest(TestCase): + def test_emoji_function(self): + # Test with fallback + result = emoji("🎉", "!") + self.assertIn(result, ["🎉", "!"]) # Depends on system capability + + # Test without fallback + result = emoji("🎉") + self.assertIn(result, ["🎉", ""]) # Depends on system capability + + def test_emoji_constant(self): + # EMOJI should be a boolean + self.assertIsInstance(EMOJI, bool) From c8d6b7cab685c89f6714f2b3d277899fbad56588 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Wed, 20 Aug 2025 16:48:25 +0900 Subject: [PATCH 22/29] fix: test --- tests/commands/record/test_build.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/commands/record/test_build.py b/tests/commands/record/test_build.py index 6f6591146..95f5eb7f3 100644 --- a/tests/commands/record/test_build.py +++ b/tests/commands/record/test_build.py @@ -195,20 +195,20 @@ def test_commit_option_and_build_option(self): result = self.cli( "record", "build", + "--build", + self.build_name, "--no-commit-collection", "--commit", "A=abc12", "--branch", - "B=feature-yyy", - "--name", - self.build_name) + "B=feature-yyy") self.assert_success(result) payload = json.loads(responses.calls[1].request.body.decode()) self.assert_json_orderless_equal( { "buildNumber": "123", - "lineage": None, + "lineage": "B=feature-yyy", "commitHashes": [ { "repositoryName": "A", @@ -226,6 +226,8 @@ def test_commit_option_and_build_option(self): result = self.cli( "record", "build", + "--build", + self.build_name, "--no-commit-collection", "--commit", "A=abc12", @@ -234,9 +236,7 @@ def test_commit_option_and_build_option(self): "--commit", "B=56cde", "--branch", - "A=feature-xxx", - "--name", - self.build_name) + "A=feature-xxx") self.assert_success(result) payload = json.loads(responses.calls[1].request.body.decode()) From 34c48315b91e65b43b5add10bf570e81a8fe0e49 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Wed, 20 Aug 2025 19:45:13 +0900 Subject: [PATCH 23/29] fix: convert value --- launchable/commands/subset.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/launchable/commands/subset.py b/launchable/commands/subset.py index f93b0c3d6..bb415f719 100644 --- a/launchable/commands/subset.py +++ b/launchable/commands/subset.py @@ -491,17 +491,17 @@ def get_payload( if target is not None: payload["goal"] = { "type": "subset-by-percentage", - "percentage": target, + "percentage": float(target), } elif duration is not None: payload["goal"] = { "type": "subset-by-absolute-time", - "duration": duration, + "duration": float(duration), } elif confidence is not None: payload["goal"] = { "type": "subset-by-confidence", - "percentage": confidence + "percentage": float(confidence) } elif goal_spec is not None: payload["goal"] = { From 549be0ad563f17c86745d01a2b1144902b71db96 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Thu, 21 Aug 2025 15:38:44 +0900 Subject: [PATCH 24/29] fix: a test file --- tests/commands/record/test_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/commands/record/test_session.py b/tests/commands/record/test_session.py index 61c53a482..2a18df06b 100644 --- a/tests/commands/record/test_session.py +++ b/tests/commands/record/test_session.py @@ -161,7 +161,7 @@ def test_run_session_with_session_name(self): result = self.cli("record", "session", "--build", self.build_name, "--session-name", self.session_name) self.assert_success(result) - payload = json.loads(responses.calls[5].request.body.decode()) + payload = json.loads(responses.calls[3].request.body.decode()) self.assert_json_orderless_equal({ "flavors": {}, "isObservation": False, From f547bac870000a9ba65379ecdc451e73f3471b36 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Thu, 21 Aug 2025 16:12:39 +0900 Subject: [PATCH 25/29] fix: a test file --- tests/commands/record/test_build.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/commands/record/test_build.py b/tests/commands/record/test_build.py index 95f5eb7f3..a7aa69df7 100644 --- a/tests/commands/record/test_build.py +++ b/tests/commands/record/test_build.py @@ -191,7 +191,7 @@ def test_commit_option_and_build_option(self): }, payload) responses.calls.reset() - # case --commit option and --branch option but another one + # case --commit option and --repo-branch-map option with invalid repo result = self.cli( "record", "build", @@ -201,6 +201,8 @@ def test_commit_option_and_build_option(self): "--commit", "A=abc12", "--branch", + "main", + "--repo-branch-map", "B=feature-yyy") self.assert_success(result) @@ -208,7 +210,7 @@ def test_commit_option_and_build_option(self): self.assert_json_orderless_equal( { "buildNumber": "123", - "lineage": "B=feature-yyy", + "lineage": "main", "commitHashes": [ { "repositoryName": "A", @@ -231,11 +233,11 @@ def test_commit_option_and_build_option(self): "--no-commit-collection", "--commit", "A=abc12", - "--branch", + "--repo-branch-map", "B=feature-yyy", "--commit", "B=56cde", - "--branch", + "--repo-branch-map", "A=feature-xxx") self.assert_success(result) From df81318c959391d60271889fc1e78e359e30f8e8 Mon Sep 17 00:00:00 2001 From: ninjinkun Date: Thu, 21 Aug 2025 18:37:58 +0900 Subject: [PATCH 26/29] fix: tracking event count for reducing traking event in SmartTests CLI --- tests/commands/test_api_error.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/commands/test_api_error.py b/tests/commands/test_api_error.py index 69bc3dfe3..69602c85c 100644 --- a/tests/commands/test_api_error.py +++ b/tests/commands/test_api_error.py @@ -132,7 +132,7 @@ def test_record_build(self): self.assert_success(result) self.assertEqual(result.exception, None) # Since HTTPError is occurred outside of LaunchableClient, the count is 1. - self.assert_tracking_count(tracking=tracking, count=3) + self.assert_tracking_count(tracking=tracking, count=2) success_server.shutdown() thread.join(timeout=3) @@ -166,7 +166,7 @@ def test_record_build(self): self.assert_success(result) self.assertEqual(result.exception, None) # Since HTTPError is occurred outside of LaunchableClient, the count is 1. - self.assert_tracking_count(tracking=tracking, count=3) + self.assert_tracking_count(tracking=tracking, count=2) error_server.shutdown() thread.join(timeout=3) @@ -459,7 +459,7 @@ def test_all_workflow_when_server_down(self): self.assert_success(result) # Since Timeout error is caught inside of LaunchableClient, the tracking event is sent twice. - self.assert_tracking_count(tracking=tracking, count=6) + self.assert_tracking_count(tracking=tracking, count=5) # set delete=False to solve the error `PermissionError: [Errno 13] Permission denied:` on Windows. with tempfile.NamedTemporaryFile(delete=False) as rest_file: @@ -476,12 +476,12 @@ def test_all_workflow_when_server_down(self): self.assert_success(result) # Since Timeout error is caught inside of LaunchableClient, the tracking event is sent twice. - self.assert_tracking_count(tracking=tracking, count=9) + self.assert_tracking_count(tracking=tracking, count=7) result = self.cli("record", "tests", "--session", self.session, "minitest", str(self.test_files_dir) + "/") self.assert_success(result) # Since Timeout error is caught inside of LaunchableClient, the tracking event is sent twice. - self.assert_tracking_count(tracking=tracking, count=13) + self.assert_tracking_count(tracking=tracking, count=9) def assert_tracking_count(self, tracking, count: int): # Prior to 3.6, `Response` object can't be obtained. From 3ee791d99efe139b2c40bcc5a19c31081bcac966 Mon Sep 17 00:00:00 2001 From: Konboi Date: Thu, 4 Sep 2025 13:56:51 +0900 Subject: [PATCH 27/29] add util method to print error message and exit as 1 --- launchable/utils/exceptions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/launchable/utils/exceptions.py b/launchable/utils/exceptions.py index 7504d0333..2fc2f4c12 100644 --- a/launchable/utils/exceptions.py +++ b/launchable/utils/exceptions.py @@ -1,4 +1,10 @@ # TODO: add cli-specific custom exceptions +import sys + +import typer + +from smart_tests.utils.tracking import Tracking, TrackingClient + class ParseSessionException(Exception): def __init__( @@ -20,3 +26,9 @@ def __init__( self.filename = filename self.message = "{message}: {filename}".format(message=message, filename=self.filename) super().__init__(self.message) + + +def print_error_and_die(msg: str, tracking_client: TrackingClient, event: Tracking.ErrorEvent): + typer.secho(msg, fg=typer.colors.RED, err=True) + tracking_client.send_error_event(event_name=event, stack_trace=msg) + sys.exit(1) From a8a5f00bebcf7ef7890026d21e79fb0db0163985 Mon Sep 17 00:00:00 2001 From: Kohsuke Kawaguchi Date: Fri, 5 Sep 2025 10:37:14 -0700 Subject: [PATCH 28/29] [chore] report an error in subset call It looks like c41d9a6270d448168e722155b25ac04b7bdad5fa removed the `raise_for_status` check, without which error message from the server won't be reported, even as a warning. This breaks the subset call in case of the server failure, since `res.json()` looks as if it's returning an empty subset From e32bfba95e071d0bc7409b8f47428eaa9e2fce06 Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Wed, 10 Sep 2025 14:57:18 +0900 Subject: [PATCH 29/29] Add tests --- tests/commands/test_flake_detection.py | 83 ++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/commands/test_flake_detection.py diff --git a/tests/commands/test_flake_detection.py b/tests/commands/test_flake_detection.py new file mode 100644 index 000000000..4589b8c1f --- /dev/null +++ b/tests/commands/test_flake_detection.py @@ -0,0 +1,83 @@ +import os +from unittest import mock + +import responses # type: ignore + +from launchable.utils.http_client import get_base_url +from tests.cli_test_case import CliTestCase + + +class FlakeDetectionTest(CliTestCase): + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_flake_detection_success(self): + mock_json_response = { + "testPaths": [ + [{"type": "file", "name": "test_flaky_1.py"}], + [{"type": "file", "name": "test_flaky_2.py"}], + ] + } + responses.add( + responses.GET, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/retry/flake-detection", + json=mock_json_response, + status=200, + ) + result = self.cli( + "retry", + "flake-detection", + "--session", + self.session, + "--confidence", + "high", + "file", + mix_stderr=False, + ) + self.assert_success(result) + self.assertIn("test_flaky_1.py", result.stdout) + self.assertIn("test_flaky_2.py", result.stdout) + + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_flake_detection_no_flakes(self): + mock_json_response = {"testPaths": []} + responses.add( + responses.GET, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/retry/flake-detection", + json=mock_json_response, + status=200, + ) + result = self.cli( + "retry", + "flake-detection", + "--session", + self.session, + "--confidence", + "low", + "file", + mix_stderr=False, + ) + self.assert_success(result) + self.assertEqual(result.stdout, "") + + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_flake_detection_api_error(self): + responses.add( + responses.GET, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/retry/flake-detection", + status=500, + ) + result = self.cli( + "retry", + "flake-detection", + "--session", + self.session, + "--confidence", + "medium", + "file", + mix_stderr=False, + ) + self.assert_exit_code(result, 0) + self.assertIn("Error", result.stderr) + self.assertEqual(result.stdout, "")