diff --git a/providers/base/bin/suspend_stats.py b/providers/base/bin/suspend_stats.py new file mode 100755 index 0000000000..becc3c217a --- /dev/null +++ b/providers/base/bin/suspend_stats.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2025 Canonical Ltd. +# Written by: +# Hanhsuan Lee +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Checkbox. If not, see . +from pathlib import Path +import argparse +import sys + + +class SuspendStats: + """ + This class is used to parse the information under + /sys/power/suspend_stats/ + + """ + + contents = {} + + def __init__(self): + suspend_stat_path = "/sys/power/suspend_stats/" + try: + self.contents = self.collect_content_under_directory( + suspend_stat_path + ) + except FileNotFoundError: + print( + "There is no {}, use the information in debugfs".format( + suspend_stat_path + ) + ) + self.contents = self.parse_suspend_stats_in_debugfs() + + def parse_suspend_stats_in_debugfs(self): + """ + Collect needed content in /sys/kernel/debug/suspend_stats + + :param search_directory: The directory to search through. + + :returns: collected content by each line + """ + debugfs = "/sys/kernel/debug/suspend_stats" + content = {} + + with open(debugfs, "r") as d: + for p in filter(None, (line.strip() for line in d.readlines())): + if p != "failures:" and ":" in p: + kv = p.split(":") + if len(kv) > 1: + content[kv[0]] = kv[1].strip() + else: + content[kv[0]] = "" + return content + + def collect_content_under_directory(self, search_directory: str) -> dict: + """ + Collect all content under specific directory by filename + + :param search_directory: The directory to search through. + + :returns: collected content by filename + """ + content = {} + + search_directory = Path(search_directory) + for p in filter(lambda x: x.is_file(), search_directory.iterdir()): + content[p.name], *_ = p.read_text().splitlines() + return content + + def print_all_content(self): + """ + Print all contents under suspend_stats + + """ + for c, v in self.contents.items(): + print("{}:{}".format(c, v)) + + def is_after_suspend(self) -> bool: + """ + The system is under after suspend status or not + + :returns: return Ture while system is under after suspend status + """ + return self.contents["success"] != "0" + + def is_any_failed(self) -> bool: + """ + Is any failed during suspend + + :returns: return Ture while one failed during suspend + """ + for c, v in self.contents.items(): + if c.startswith("fail") and v != "0": + return True + return False + + def parse_args(self, args=sys.argv[1:]): + """ + command line arguments parsing + + :param args: arguments from sys + :type args: sys.argv + """ + parser = argparse.ArgumentParser( + prog="suspend status validator", + description="Get and valid the content" + "under /sys/power/suspend_stats/" + "or /sys/kernel/debug/suspend_stats", + ) + + parser.add_argument( + "check_type", + help="The type to take e.g. after_suspend or any_failure.", + ) + + return parser.parse_args(args) + + def main(self): + args = self.parse_args() + self.print_all_content() + if args.check_type == "after_suspend": + if not self.is_after_suspend(): + raise SystemExit("System is not under after suspend status") + else: + if self.is_any_failed(): + raise SystemExit( + "There are [{}] failed".format(self.contents["fail"]) + ) + + +if __name__ == "__main__": + SuspendStats().main() diff --git a/providers/base/tests/test_suspend_stats.py b/providers/base/tests/test_suspend_stats.py new file mode 100644 index 0000000000..8ee0b3bd35 --- /dev/null +++ b/providers/base/tests/test_suspend_stats.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# This file is part of Checkbox. +# +# Copyright 2025 Canonical Ltd. +# Written by: +# Hanhsuan Lee +# +# Checkbox is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# Checkbox is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Checkbox. If not, see . + +from unittest.mock import patch, mock_open, MagicMock +from pathlib import Path +import tempfile +import unittest + +from suspend_stats import SuspendStats + +debugfs = """ +success: 1 +fail: 0 +failed_freeze: 0 +failed_prepare: 0 +failed_suspend: 0 +failed_suspend_late: 0 +failed_suspend_noirq: 0 +failed_resume: 0 +failed_resume_early: 0 +failed_resume_noirq: 0 +failures: + last_failed_dev: + + last_failed_errno: 0 + 0 + last_failed_step: +""" + + +class TestSuspendStats(unittest.TestCase): + @patch("suspend_stats.SuspendStats.collect_content_under_directory") + @patch("suspend_stats.SuspendStats.parse_suspend_stats_in_debugfs") + def test_init_with_existing_directory(self, mock_parse, mock_collect): + mock_collect.return_value = "mocked content" + + collector = SuspendStats() + + mock_collect.assert_called_once_with("/sys/power/suspend_stats/") + self.assertIsNotNone(collector) + + @patch("suspend_stats.SuspendStats.collect_content_under_directory") + @patch("suspend_stats.SuspendStats.parse_suspend_stats_in_debugfs") + def test_init_with_non_existing_directory(self, mock_parse, mock_collect): + mock_collect.side_effect = FileNotFoundError + mock_parse.return_value = "parsed debugfs content" + + SuspendStats() + + mock_collect.assert_called_once_with("/sys/power/suspend_stats/") + mock_parse.assert_called_once_with() + + @patch("suspend_stats.SuspendStats.__init__") + @patch("builtins.open", new_callable=mock_open, read_data=debugfs) + def test_parse_suspend_stats(self, mock_file, mock_init): + mock_init.return_value = None + stats = SuspendStats() + expected_output = { + "success": "1", + "fail": "0", + "failed_freeze": "0", + "failed_prepare": "0", + "failed_suspend": "0", + "failed_suspend_late": "0", + "failed_suspend_noirq": "0", + "failed_resume": "0", + "failed_resume_early": "0", + "failed_resume_noirq": "0", + "last_failed_dev": "", + "last_failed_errno": "0", + "last_failed_step": "", + } + result = stats.parse_suspend_stats_in_debugfs() + self.assertEqual(result, expected_output) + + @patch("suspend_stats.SuspendStats.__init__") + def test_empty_directory(self, mock_init): + mock_init.return_value = None + stats = SuspendStats() + with tempfile.TemporaryDirectory() as tmp_dir: + self.assertEqual( + stats.collect_content_under_directory(tmp_dir), {} + ) + + @patch("suspend_stats.SuspendStats.__init__") + def test_single_file(self, mock_init): + mock_init.return_value = None + stats = SuspendStats() + with tempfile.TemporaryDirectory() as tmp_dir: + file_path = Path(tmp_dir) / "test.txt" + file_path.write_text("Line1\nLine2") + + result = stats.collect_content_under_directory(tmp_dir) + self.assertEqual(result, {"test.txt": "Line1"}) + + @patch("suspend_stats.SuspendStats.__init__") + def test_multiple_files(self, mock_init): + mock_init.return_value = None + stats = SuspendStats() + with tempfile.TemporaryDirectory() as tmp_dir: + file_path_1 = Path(tmp_dir) / "file1.txt" + file_path_2 = Path(tmp_dir) / "file2.txt" + + file_path_1.write_text("Line11\nLine12") + file_path_2.write_text("Line21\nLine22") + + result = stats.collect_content_under_directory(tmp_dir) + self.assertEqual( + result, {"file1.txt": "Line11", "file2.txt": "Line21"} + ) + + @patch("suspend_stats.SuspendStats.__init__") + @patch("pathlib.Path.iterdir") + def test_invalid_search_directories(self, mock_path, mock_init): + mock_init.return_value = None + stats = SuspendStats() + mock_path.side_effect = FileNotFoundError + + with self.assertRaises(FileNotFoundError): + stats.collect_content_under_directory("/non/existent/directory") + + @patch("suspend_stats.SuspendStats.__init__") + def test_is_after_suspend(self, mock_init): + mock_init.return_value = None + stats = SuspendStats() + stats.contents = { + "success": "1", + "failed_prepare": "0", + "failed_suspend": "0", + "failed_resume": "0", + "fail": "0", + "last_failed_dev": "", + } + self.assertTrue(stats.is_after_suspend()) + + stats.contents["failed_prepare"] = "1" + self.assertTrue(stats.is_after_suspend()) + + stats.contents["failed_prepare"] = "0" + stats.contents["failed_suspend"] = "1" + self.assertTrue(stats.is_after_suspend()) + + stats.contents["failed_suspend"] = "0" + stats.contents["failed_resume"] = "1" + self.assertTrue(stats.is_after_suspend()) + + @patch("suspend_stats.SuspendStats.__init__") + def test_is_any_failed(self, mock_init): + mock_init.return_value = None + stats = SuspendStats() + stats.contents = { + "success": "1", + "failed_suspend": "0", + "fail": "1", + "last_failed_dev": "", + } + self.assertTrue(stats.is_any_failed()) + + stats.contents["fail"] = "0" + self.assertFalse(stats.is_any_failed()) + + stats.contents["failed_prepare"] = "1" + self.assertTrue(stats.is_any_failed()) + + stats.contents["failed_prepare"] = "0" + stats.contents["failed_suspend"] = "1" + self.assertTrue(stats.is_any_failed()) + + stats.contents["failed_suspend"] = "0" + stats.contents["failed_resume"] = "1" + self.assertTrue(stats.is_any_failed()) + + @patch("suspend_stats.SuspendStats.__init__") + def test_parse_args_valid(self, mock_init): + mock_init.return_value = None + stats = SuspendStats() + args = ["after_suspend"] + rv = stats.parse_args(args) + + self.assertEqual(rv.check_type, "after_suspend") + + @patch("suspend_stats.SuspendStats.__init__") + def test_parse_args_any(self, mock_init): + mock_init.return_value = None + stats = SuspendStats() + args = ["any_failure"] + rv = stats.parse_args(args) + + self.assertEqual(rv.check_type, "any_failure") + + +class MainTests(unittest.TestCase): + @patch("suspend_stats.SuspendStats.__init__") + @patch("suspend_stats.SuspendStats.parse_args") + @patch("suspend_stats.SuspendStats.is_after_suspend") + @patch("suspend_stats.SuspendStats.print_all_content") + def test_run_valid_succ( + self, mock_print, mock_after, mock_parse_args, mock_init + ): + mock_init.return_value = None + args_mock = MagicMock() + args_mock.check_type = "after_suspend" + mock_parse_args.return_value = args_mock + mock_after.return_value = True + self.assertEqual(SuspendStats().main(), None) + + @patch("suspend_stats.SuspendStats.__init__") + @patch("suspend_stats.SuspendStats.parse_args") + @patch("suspend_stats.SuspendStats.is_after_suspend") + @patch("suspend_stats.SuspendStats.print_all_content") + def test_run_valid_fail( + self, mock_print, mock_after, mock_parse_args, mock_init + ): + mock_init.return_value = None + args_mock = MagicMock() + args_mock.check_type = "after_suspend" + mock_parse_args.return_value = args_mock + mock_after.return_value = False + with self.assertRaises(SystemExit): + SuspendStats().main() + + @patch("suspend_stats.SuspendStats.__init__") + @patch("suspend_stats.SuspendStats.parse_args") + @patch("suspend_stats.SuspendStats.is_any_failed") + @patch("suspend_stats.SuspendStats.print_all_content") + def test_run_any_succ( + self, mock_print, mock_any, mock_parse_args, mock_init + ): + mock_init.return_value = None + args_mock = MagicMock() + args_mock.check_type = "any_failure" + mock_parse_args.return_value = args_mock + mock_any.return_value = False + self.assertEqual(SuspendStats().main(), None) + + @patch("suspend_stats.SuspendStats.__init__") + @patch("suspend_stats.SuspendStats.parse_args") + @patch("suspend_stats.SuspendStats.is_any_failed") + @patch("suspend_stats.SuspendStats.print_all_content") + def test_run_any_fail( + self, mock_print, mock_any, mock_parse_args, mock_init + ): + mock_init.return_value = None + args_mock = MagicMock() + args_mock.check_type = "any_failure" + mock_parse_args.return_value = args_mock + mock_any.return_value = True + stats = SuspendStats() + stats.contents["fail"] = "0" + with self.assertRaises(SystemExit): + stats.main() + + +if __name__ == "__main__": + unittest.main() diff --git a/providers/base/units/suspend/suspend.pxu b/providers/base/units/suspend/suspend.pxu index 7e73de4df0..b9a5060ec9 100644 --- a/providers/base/units/suspend/suspend.pxu +++ b/providers/base/units/suspend/suspend.pxu @@ -1716,3 +1716,32 @@ command: [ -e "${PLAINBOX_SESSION_SHARE}"/fwts_oops_results_after_s3.log ] && xz -c "${PLAINBOX_SESSION_SHARE}"/fwts_oops_results_after_s3.log _purpose: Attaches the FWTS oops results log to the submission after suspend _summary: Attach FWTS oops results log post-suspend. + +id: suspend/validate_suspend_status +plugin: shell +category_id: com.canonical.plainbox::suspend +depends: suspend/suspend_advanced_auto +estimated_duration: 5s +user: root +command: suspend_stats.py after_suspend +summary: + Tests that the machine suspended correctly +description: + Query sysfs and debugfs for the amount of times the system was suspended. + We expect the count in /sys/power/suspend_stats/success + and suscess in /sys/kernel/debugfs/suspend_stats to be non zero + +id: suspend/any_suspend_failure +plugin: shell +category_id: com.canonical.plainbox::suspend +depends: suspend/suspend_advanced_auto +estimated_duration: 5s +user: root +command: suspend_stats.py any_failure +summary: + Tests if any device in this machine reports a suspend failure +description: + If the DUT suspended successfully, /sys/power/suspend_stats/fail* + and fail* in /sys/kernel/debug/suspend_stats will be zero. + If a device failed /sys/power/suspend_stats/last_failed_dev + and last_failed_dev in /sys/kernel/debug/suspend_stats will show the failed device diff --git a/providers/base/units/suspend/test-plan.pxu b/providers/base/units/suspend/test-plan.pxu index b93f1844d8..5b1cd87b03 100644 --- a/providers/base/units/suspend/test-plan.pxu +++ b/providers/base/units/suspend/test-plan.pxu @@ -46,6 +46,8 @@ include: suspend/audio_after_suspend_auto certification-status=blocker suspend/cpu_after_suspend_auto certification-status=blocker suspend/memory_after_suspend_auto certification-status=blocker + suspend/validate_suspend_status + suspend/any_suspend_failure bootstrap_include: device