Kea2-python 0.3.5__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of Kea2-python might be problematic. Click here for more details.

kea2/__init__.py CHANGED
@@ -1 +1 @@
1
- from .keaUtils import KeaTestRunner, precondition, prob, max_tries, Options
1
+ from .keaUtils import KeaTestRunner, precondition, prob, max_tries, Options, interruptable,HybridTestRunner,kea2_breakpoint
@@ -0,0 +1,16 @@
1
+ {
2
+ "compatibility infos": [
3
+ {
4
+ "name": "previous version",
5
+ "description": "The default initial version, <=0.3.6",
6
+ "from": "0.0.0",
7
+ "to": "0.3.6"
8
+ },
9
+ {
10
+ "name": "Hybrid test version 1.0.0",
11
+ "description": "Hybrid test was added in version 1.0.0. hybrid_test_config.py is required.",
12
+ "from": "1.0.0",
13
+ "to": ""
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,18 @@
1
+ from uiautomator2 import Device
2
+ import time
3
+
4
+
5
+ class HybridTestCase:
6
+ d: Device
7
+
8
+ PACKAGE_NAME = "it.feio.android.omninotes.alpha"
9
+ MAIN_ACTIVITY = "it.feio.android.omninotes.MainActivity"
10
+
11
+
12
+ def setUp(self: HybridTestCase):
13
+ self.d.app_start(PACKAGE_NAME, MAIN_ACTIVITY)
14
+ time.sleep(2)
15
+
16
+
17
+ def tearDown(self: HybridTestCase):
18
+ self.d.app_stop(PACKAGE_NAME)
kea2/assets/monkeyq.jar CHANGED
Binary file
kea2/assets/quicktest.py CHANGED
@@ -9,9 +9,27 @@ from kea2.u2Driver import U2Driver
9
9
  class Omni_Notes_Sample(unittest.TestCase):
10
10
 
11
11
  def setUp(self):
12
- self.d = u2.connect()
12
+ self.d = u2.connect()
13
+
14
+ @prob(0.2)
15
+ @precondition(
16
+ lambda self: self.d(description="Navigate up").exists
17
+ )
18
+ def test_goBack(self):
19
+ print("Navigate back")
20
+ self.d(description="Navigate up").click()
21
+ sleep(0.5)
22
+
23
+ @prob(0.2)
24
+ @precondition(
25
+ lambda self: self.d(description="drawer closed").exists
26
+ )
27
+ def test_openDrawer(self):
28
+ print("Open drawer")
29
+ self.d(description="drawer closed").click()
30
+ sleep(0.5)
13
31
 
14
- @prob(0.7) # The probability of executing the function when precondition is satisfied.
32
+ @prob(0.5) # The probability of executing the function when precondition is satisfied.
15
33
  @precondition(
16
34
  lambda self: self.d(text="Omni Notes Alpha").exists
17
35
  and self.d(text="Settings").exists
@@ -45,6 +63,7 @@ class Omni_Notes_Sample(unittest.TestCase):
45
63
  assertion:
46
64
  The search input box is still being opened
47
65
  """
66
+ print("rotate the device")
48
67
  self.d.set_orientation("l")
49
68
  sleep(2)
50
69
  self.d.set_orientation("n")
@@ -375,21 +375,32 @@ class BugReportGenerator:
375
375
  minutes, seconds = divmod(remainder, 60)
376
376
  data["total_testing_time"] = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
377
377
 
378
- # Calculate bug count directly from result data
378
+ # Enrich property statistics with derived metrics and calculate bug count
379
+ enriched_property_stats = {}
379
380
  for property_name, test_result in self.test_result.items():
380
381
  # Check if failed or error
381
- if test_result["fail"] > 0 or test_result["error"] > 0:
382
+ if test_result.get("fail", 0) > 0 or test_result.get("error", 0) > 0:
382
383
  data["bugs_found"] += 1
383
384
 
384
- # Store the raw result data for direct use in HTML template
385
- data["property_stats"] = self.test_result
385
+ executed_count = test_result.get("executed", 0)
386
+ fail_count = test_result.get("fail", 0)
387
+ error_count = test_result.get("error", 0)
388
+ pass_count = max(executed_count - fail_count - error_count, 0)
389
+
390
+ enriched_property_stats[property_name] = {
391
+ **test_result,
392
+ "pass_count": pass_count
393
+ }
394
+
395
+ # Store the enriched result data for direct use in HTML template
396
+ data["property_stats"] = enriched_property_stats
386
397
 
387
398
  # Calculate properties statistics
388
399
  data["all_properties_count"] = len(self.test_result)
389
400
  data["executed_properties_count"] = sum(1 for result in self.test_result.values() if result.get("executed", 0) > 0)
390
401
 
391
402
  # Calculate detailed property statistics for table headers
392
- property_stats_summary = self._calculate_property_stats_summary(self.test_result)
403
+ property_stats_summary = self._calculate_property_stats_summary(enriched_property_stats)
393
404
  data["property_stats_summary"] = property_stats_summary
394
405
 
395
406
  # Process coverage data
@@ -415,6 +426,11 @@ class BugReportGenerator:
415
426
 
416
427
  # Load crash and ANR events from crash-dump.log
417
428
  crash_events, anr_events = self._load_crash_dump_data()
429
+
430
+ # Add screenshot ID information to crash and ANR events
431
+ self._add_screenshot_ids_to_events(crash_events)
432
+ self._add_screenshot_ids_to_events(anr_events)
433
+
418
434
  data["crash_events"] = crash_events
419
435
  data["anr_events"] = anr_events
420
436
 
@@ -460,10 +476,14 @@ class BugReportGenerator:
460
476
  """
461
477
  screenshot_path: Path = self.data_path.screenshots_dir / screenshot_name
462
478
  if not screenshot_path.exists():
463
- logger.error(f"Screenshot file {screenshot_path} not exists.")
479
+ logger.debug(f"Screenshot file {screenshot_path} not exists.")
464
480
  return False
465
481
 
466
- img = Image.open(screenshot_path).convert("RGB")
482
+ try:
483
+ img = Image.open(screenshot_path).convert("RGB")
484
+ except OSError as e:
485
+ logger.debug(f"Error opening image {screenshot_path}: {e}")
486
+ return False
467
487
  draw = ImageDraw.Draw(img)
468
488
  line_width = 5
469
489
 
@@ -538,7 +558,7 @@ class BugReportGenerator:
538
558
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
539
559
 
540
560
  # Ensure coverage_trend has data
541
- if not data["coverage_trend"]:
561
+ if not data.get("coverage_trend"):
542
562
  logger.warning("No coverage trend data")
543
563
  # Use the same field names as in coverage.log file
544
564
  data["coverage_trend"] = [{"stepsCount": 0, "coverage": 0, "testedActivitiesCount": 0}]
@@ -612,6 +632,13 @@ class BugReportGenerator:
612
632
  caption = f"{prop_name}: {state}" if prop_name else f"{state}"
613
633
 
614
634
  screenshot_name = step_data["Screenshot"]
635
+
636
+ # Check if the screenshot file actually exists
637
+ screenshot_file_path = self.data_path.screenshots_dir / screenshot_name
638
+ if not screenshot_file_path.exists():
639
+ # Skip adding this screenshot if the file doesn't exist
640
+ return
641
+
615
642
  # Use relative path string instead of Path object
616
643
  relative_screenshot_path = f"output_{self.log_timestamp}/screenshots/{screenshot_name}"
617
644
 
@@ -841,20 +868,28 @@ class BugReportGenerator:
841
868
  "total_properties": 0,
842
869
  "total_precond_satisfied": 0,
843
870
  "total_executed": 0,
871
+ "total_passes": 0,
844
872
  "total_fails": 0,
845
873
  "total_errors": 0,
846
874
  "properties_with_errors": 0
847
875
  }
848
876
 
849
877
  for property_name, result in test_result.items():
878
+ executed_count = result.get("executed", result.get("executed_total", 0))
879
+ fail_count = result.get("fail", 0)
880
+ error_count = result.get("error", 0)
881
+ pass_count = result.get("pass_count",
882
+ max(executed_count - fail_count - error_count, 0))
883
+
850
884
  stats_summary["total_properties"] += 1
851
885
  stats_summary["total_precond_satisfied"] += result.get("precond_satisfied", 0)
852
- stats_summary["total_executed"] += result.get("executed", 0)
853
- stats_summary["total_fails"] += result.get("fail", 0)
854
- stats_summary["total_errors"] += result.get("error", 0)
886
+ stats_summary["total_executed"] += executed_count
887
+ stats_summary["total_passes"] += pass_count
888
+ stats_summary["total_fails"] += fail_count
889
+ stats_summary["total_errors"] += error_count
855
890
 
856
891
  # Count properties that have errors or fails
857
- if result.get("fail", 0) > 0 or result.get("error", 0) > 0:
892
+ if fail_count > 0 or error_count > 0:
858
893
  stats_summary["properties_with_errors"] += 1
859
894
 
860
895
  return stats_summary
@@ -877,11 +912,11 @@ class BugReportGenerator:
877
912
  with open(self.data_path.crash_dump_log, "r", encoding="utf-8") as f:
878
913
  content = f.read()
879
914
 
880
- # Parse crash events
881
- crash_events = self._parse_crash_events(content)
915
+ # Parse crash events with screenshot mapping
916
+ crash_events = self._parse_crash_events_with_screenshots(content)
882
917
 
883
- # Parse ANR events
884
- anr_events = self._parse_anr_events(content)
918
+ # Parse ANR events with screenshot mapping
919
+ anr_events = self._parse_anr_events_with_screenshots(content)
885
920
 
886
921
  logger.debug(f"Found {len(crash_events)} crash events and {len(anr_events)} ANR events")
887
922
 
@@ -889,26 +924,29 @@ class BugReportGenerator:
889
924
 
890
925
  except Exception as e:
891
926
  logger.error(f"Error reading crash dump file: {e}")
927
+ return crash_events, anr_events
892
928
 
893
-
894
- def _parse_crash_events(self, content: str) -> List[Dict]:
929
+ def _parse_crash_events_with_screenshots(self, content: str) -> List[Dict]:
895
930
  """
896
- Parse crash events from crash-dump.log content
931
+ Parse crash events from crash-dump.log content with screenshot mapping
897
932
 
898
933
  Args:
899
934
  content: Content of crash-dump.log file
900
935
 
901
936
  Returns:
902
- List[Dict]: List of crash event dictionaries
937
+ List[Dict]: List of crash event dictionaries with screenshot information
903
938
  """
904
939
  crash_events = []
905
940
 
906
- # Pattern to match crash blocks
907
- crash_pattern = r'(\d{14})\ncrash:\n(.*?)\n// crash end'
941
+ # Pattern to match crash blocks with optional screenshot information
942
+ # Look for StepsCount and CrashScreen before the timestamp
943
+ crash_pattern = r'(?:StepsCount:\s*(\d+)\s*\nCrashScreen:\s*([^\n]+)\s*\n)?(\d{14})\ncrash:\n(.*?)\n// crash end'
908
944
 
909
945
  for match in re.finditer(crash_pattern, content, re.DOTALL):
910
- timestamp_str = match.group(1)
911
- crash_content = match.group(2)
946
+ steps_count = match.group(1)
947
+ crash_screen = match.group(2)
948
+ timestamp_str = match.group(3)
949
+ crash_content = match.group(4)
912
950
 
913
951
  # Parse timestamp (format: YYYYMMDDHHMMSS)
914
952
  try:
@@ -924,31 +962,35 @@ class BugReportGenerator:
924
962
  "time": formatted_time,
925
963
  "exception_type": crash_info.get("exception_type", "Unknown"),
926
964
  "process": crash_info.get("process", "Unknown"),
927
- "stack_trace": crash_info.get("stack_trace", "")
965
+ "stack_trace": crash_info.get("stack_trace", ""),
966
+ "steps_count": steps_count,
967
+ "crash_screen": crash_screen.strip() if crash_screen else None
928
968
  }
929
969
 
930
970
  crash_events.append(crash_event)
931
971
 
932
972
  return crash_events
933
973
 
934
- def _parse_anr_events(self, content: str) -> List[Dict]:
974
+ def _parse_anr_events_with_screenshots(self, content: str) -> List[Dict]:
935
975
  """
936
- Parse ANR events from crash-dump.log content
976
+ Parse ANR events from crash-dump.log content with screenshot mapping
937
977
 
938
978
  Args:
939
979
  content: Content of crash-dump.log file
940
980
 
941
981
  Returns:
942
- List[Dict]: List of ANR event dictionaries
982
+ List[Dict]: List of ANR event dictionaries with screenshot information
943
983
  """
944
984
  anr_events = []
945
985
 
946
- # Pattern to match ANR blocks
947
- anr_pattern = r'(\d{14})\nanr:\n(.*?)\nanr end'
986
+ # Pattern to match ANR blocks with optional screenshot information
987
+ anr_pattern = r'(?:StepsCount:\s*(\d+)\s*\nCrashScreen:\s*([^\n]+)\s*\n)?(\d{14})\nanr:\n(.*?)\nanr end'
948
988
 
949
989
  for match in re.finditer(anr_pattern, content, re.DOTALL):
950
- timestamp_str = match.group(1)
951
- anr_content = match.group(2)
990
+ steps_count = match.group(1)
991
+ crash_screen = match.group(2)
992
+ timestamp_str = match.group(3)
993
+ anr_content = match.group(4)
952
994
 
953
995
  # Parse timestamp (format: YYYYMMDDHHMMSS)
954
996
  try:
@@ -964,13 +1006,51 @@ class BugReportGenerator:
964
1006
  "time": formatted_time,
965
1007
  "reason": anr_info.get("reason", "Unknown"),
966
1008
  "process": anr_info.get("process", "Unknown"),
967
- "trace": anr_info.get("trace", "")
1009
+ "trace": anr_info.get("trace", ""),
1010
+ "steps_count": steps_count,
1011
+ "crash_screen": crash_screen.strip() if crash_screen else None
968
1012
  }
969
1013
 
970
1014
  anr_events.append(anr_event)
971
1015
 
972
1016
  return anr_events
973
1017
 
1018
+ def _find_screenshot_id_by_filename(self, screenshot_filename: str) -> str:
1019
+ """
1020
+ Find screenshot ID by filename in the screenshots list
1021
+
1022
+ Args:
1023
+ screenshot_filename: Name of the screenshot file
1024
+
1025
+ Returns:
1026
+ str: Screenshot ID if found, empty string otherwise
1027
+ """
1028
+ if not screenshot_filename:
1029
+ return ""
1030
+
1031
+ for screenshot in self.screenshots:
1032
+ # Extract filename from path
1033
+ screenshot_path = screenshot.get('path', '')
1034
+ if screenshot_path.endswith(screenshot_filename):
1035
+ return str(screenshot.get('id', ''))
1036
+
1037
+ return ""
1038
+
1039
+ def _add_screenshot_ids_to_events(self, events: List[Dict]):
1040
+ """
1041
+ Add screenshot ID information to crash/ANR events
1042
+
1043
+ Args:
1044
+ events: List of crash or ANR event dictionaries
1045
+ """
1046
+ for event in events:
1047
+ crash_screen = event.get('crash_screen')
1048
+ if crash_screen:
1049
+ screenshot_id = self._find_screenshot_id_by_filename(crash_screen)
1050
+ event['screenshot_id'] = screenshot_id
1051
+ else:
1052
+ event['screenshot_id'] = ""
1053
+
974
1054
  def _extract_crash_info(self, crash_content: str) -> Dict:
975
1055
  """
976
1056
  Extract crash information from crash content
@@ -1109,8 +1189,8 @@ class BugReportGenerator:
1109
1189
  if __name__ == "__main__":
1110
1190
  print("Generating bug report")
1111
1191
  # OUTPUT_PATH = "<Your output path>"
1112
- OUTPUT_PATH = "P:/Python/Kea2/output/res_2025072011_5048015228"
1192
+ OUTPUT_PATH = "/Users/drifter327/Code/Kea2/output/res_2025090122_1216279438"
1113
1193
 
1114
1194
  report_generator = BugReportGenerator()
1115
1195
  report_path = report_generator.generate_report(OUTPUT_PATH)
1116
- print(f"bug report generated: {report_path}")
1196
+ print(f"bug report generated: {report_path}")
kea2/cli.py CHANGED
@@ -2,9 +2,11 @@
2
2
  # cli.py
3
3
 
4
4
  from __future__ import absolute_import, print_function
5
+ from datetime import datetime
5
6
  import sys
6
7
  from .utils import getProjectRoot, getLogger
7
8
  from .kea_launcher import run
9
+ from .version_manager import check_config_compatibility, get_cur_version
8
10
  import argparse
9
11
 
10
12
  import os
@@ -15,8 +17,7 @@ logger = getLogger(__name__)
15
17
 
16
18
 
17
19
  def cmd_version(args):
18
- from importlib.metadata import version
19
- print(version("Kea2-python"), flush=True)
20
+ print(get_cur_version(), flush=True)
20
21
 
21
22
 
22
23
  def cmd_init(args):
@@ -36,9 +37,16 @@ def cmd_init(args):
36
37
  src = Path(__file__).parent / "assets" / "quicktest.py"
37
38
  dst = cwd / "quicktest.py"
38
39
  shutil.copyfile(src, dst)
40
+
41
+ def save_version():
42
+ import json
43
+ version_file = configs_dir / "version.json"
44
+ with open(version_file, "w") as fp:
45
+ json.dump({"version": get_cur_version(), "init date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}, fp, indent=4)
39
46
 
40
47
  copy_configs()
41
48
  copy_samples()
49
+ save_version()
42
50
  logger.info("Kea2 project initialized.")
43
51
 
44
52
 
@@ -94,11 +102,9 @@ def cmd_merge(args):
94
102
  for path in args.paths:
95
103
  path_obj = Path(path)
96
104
  if not path_obj.exists():
97
- logger.error(f"Test report path does not exist: {path}")
98
- return
105
+ raise FileNotFoundError(f"{path_obj}")
99
106
  if not path_obj.is_dir():
100
- logger.error(f"Path is not a directory: {path}")
101
- return
107
+ raise NotADirectoryError(f"{path_obj}")
102
108
 
103
109
  logger.debug(f"Merging {len(args.paths)} test report directories...")
104
110
 
@@ -118,7 +124,7 @@ def cmd_merge(args):
118
124
  print(f"📈 Merged {merge_summary.get('merged_directories', 0)} directories", flush=True)
119
125
 
120
126
  except Exception as e:
121
- logger.error(f"Error during merge operation: {e}")
127
+ logger.error(f"Error during merge operation: {e}")
122
128
 
123
129
 
124
130
  def cmd_run(args):
@@ -126,6 +132,9 @@ def cmd_run(args):
126
132
  if base_dir is None:
127
133
  logger.error("kea2 project not initialized. Use `kea2 init`.")
128
134
  return
135
+
136
+ check_config_compatibility()
137
+
129
138
  run(args)
130
139
 
131
140
 
@@ -210,9 +219,10 @@ def main():
210
219
  args = parser.parse_args()
211
220
 
212
221
  import logging
213
- logging.basicConfig(level=logging.INFO)
222
+ from .utils import LoggingLevel
223
+ LoggingLevel.set_level(logging.INFO)
214
224
  if args.debug:
215
- logging.basicConfig(level=logging.DEBUG)
225
+ LoggingLevel.set_level(logging.DEBUG)
216
226
  logger.debug("args: %s", args)
217
227
 
218
228
  if args.subparser:
kea2/fastbotManager.py CHANGED
@@ -1,13 +1,15 @@
1
+ import itertools
1
2
  from retry import retry
2
3
  from retry.api import retry_call
3
4
  from dataclasses import asdict
4
5
  import requests
5
6
  from packaging.version import parse as parse_version
7
+ from time import sleep
6
8
 
7
9
  from uiautomator2.core import HTTPResponse, _http_request
8
10
  from kea2.adbUtils import ADBDevice, ADBStreamShell_V2
9
11
  from pathlib import Path
10
- from kea2.utils import getLogger
12
+ from kea2.utils import getLogger, getProjectRoot
11
13
 
12
14
 
13
15
  from typing import IO, TYPE_CHECKING, Dict
@@ -28,6 +30,7 @@ class FastbotManager:
28
30
  ADBDevice.setDevice(options.serial, options.transport_id)
29
31
  self.dev = ADBDevice()
30
32
  self.android_release = parse_version(self.dev.getprop("ro.build.version.release"))
33
+ self.executed_prop = False
31
34
 
32
35
  def _activateFastbot(self) -> ADBStreamShell_V2:
33
36
  """
@@ -70,14 +73,15 @@ class FastbotManager:
70
73
  "/data/local/tmp/x86_64/libfastbot_native.so",
71
74
  )
72
75
 
76
+ cwd = getProjectRoot()
73
77
  whitelist = self.options.act_whitelist_file
74
78
  blacklist = self.options.act_blacklist_file
75
79
  if bool(whitelist) ^ bool(blacklist):
76
80
  if whitelist:
77
- file_to_push = cur_dir.parent / 'configs' / 'awl.strings'
81
+ file_to_push = cwd / 'configs' / 'awl.strings'
78
82
  remote_path = whitelist
79
83
  else:
80
- file_to_push = cur_dir.parent / 'configs' / 'abl.strings'
84
+ file_to_push = cwd / 'configs' / 'abl.strings'
81
85
  remote_path = blacklist
82
86
 
83
87
  self.dev.sync.push(
@@ -99,7 +103,9 @@ class FastbotManager:
99
103
  _http_request(dev=self.dev, device_port=8090, method="GET", path="/ping")
100
104
 
101
105
  try:
102
- retry_call(_check_alive_request, tries=10, delay=2)
106
+ logger.info("Connecting to fastbot server...")
107
+ retry_call(_check_alive_request, tries=10, delay=2, logger=logger)
108
+ logger.info("Connected to fastbot server.")
103
109
  except requests.ConnectionError:
104
110
  raise RuntimeError("Failed to connect fastbot")
105
111
 
@@ -110,7 +116,8 @@ class FastbotManager:
110
116
  def init(self, options: "Options", stamp):
111
117
  post_data = {
112
118
  "takeScreenshots": options.take_screenshots,
113
- "Stamp": stamp,
119
+ "preFailureScreenshots": options.pre_failure_screenshots,
120
+ "logStamp": stamp,
114
121
  "deviceOutputRoot": options.device_output_root,
115
122
  }
116
123
  r = _http_request(
@@ -160,6 +167,15 @@ class FastbotManager:
160
167
  res = r.text
161
168
  if res != "OK":
162
169
  print(f"[ERROR] Error when logging script: {execution_info}", flush=True)
170
+
171
+ @retry(Exception, tries=2, delay=2)
172
+ def dumpHierarchy(self):
173
+ sleep(self.options.throttle / 1000)
174
+ r = self.request(
175
+ method="GET",
176
+ path="/dumpHierarchy",
177
+ )
178
+ return r.json()['result']
163
179
 
164
180
  @property
165
181
  def device_output_dir(self):
@@ -172,10 +188,8 @@ class FastbotManager:
172
188
  "/sdcard/framework.jar:"
173
189
  "/sdcard/fastbot-thirdpart.jar:"
174
190
  "/sdcard/kea2-thirdpart.jar",
175
-
176
191
  "exec", "app_process",
177
192
  "/system/bin", "com.android.commands.monkey.Monkey",
178
- "-p", *self.options.packageNames,
179
193
  "--agent-u2" if self.options.agent == "u2" else "--agent",
180
194
  "reuseq",
181
195
  "--running-minutes", f"{self.options.running_mins}",
@@ -184,6 +198,9 @@ class FastbotManager:
184
198
  "--output-directory", f"{self.options.device_output_root}/output_{self.options.log_stamp}",
185
199
  ]
186
200
 
201
+ pkgs = itertools.chain.from_iterable(["-p", pkg] for pkg in self.options.packageNames)
202
+ shell_command.extend(pkgs)
203
+
187
204
  if self.options.profile_period:
188
205
  shell_command += ["--profile-period", f"{self.options.profile_period}"]
189
206
 
@@ -197,6 +214,9 @@ class FastbotManager:
197
214
 
198
215
  shell_command += ["-v", "-v", "-v"]
199
216
 
217
+ if self.options.extra_args:
218
+ shell_command += self.options.extra_args
219
+
200
220
  full_cmd = ["adb"] + (["-s", self.options.serial] if self.options.serial else []) + ["shell"] + shell_command
201
221
 
202
222