Chef - Add BUILD.gn and unit tests for stateful_shell.py (#19205)

diff --git a/BUILD.gn b/BUILD.gn
index 5af9493..f8aa0cc 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -166,6 +166,7 @@
     if (chip_link_tests) {
       deps = [
         "//:fake_platform_tests",
+        "//examples/chef:chef.tests",
         "//scripts/build:build_examples.tests",
         "//scripts/idl:idl.tests",
         "//src:tests_run",
diff --git a/examples/chef/BUILD.gn b/examples/chef/BUILD.gn
new file mode 100644
index 0000000..5ba346e
--- /dev/null
+++ b/examples/chef/BUILD.gn
@@ -0,0 +1,32 @@
+# Copyright (c) 2022 Project CHIP Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import("//build_overrides/build.gni")
+import("//build_overrides/chip.gni")
+
+import("//build_overrides/pigweed.gni")
+import("$dir_pw_build/python.gni")
+
+pw_python_package("chef") {
+  setup = [ "setup.py" ]
+
+  sources = [
+    "__init__.py",
+    "chef.py",
+    "constants.py",
+    "stateful_shell.py",
+  ]
+
+  tests = [ "test_stateful_shell.py" ]
+}
diff --git a/examples/chef/__init__.py b/examples/chef/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/examples/chef/__init__.py
diff --git a/examples/chef/setup.py b/examples/chef/setup.py
new file mode 100644
index 0000000..e80c86f
--- /dev/null
+++ b/examples/chef/setup.py
@@ -0,0 +1,28 @@
+# Copyright (c) 2022 Project CHIP Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+"""The chef package."""
+
+import setuptools  # type: ignore
+
+setuptools.setup(
+    name='chef',
+    version='0.0.1',
+    author='Project CHIP Authors',
+    description='Build custom sample apps for supported platforms',
+    packages=setuptools.find_packages(),
+    package_data={'chef': ['py.typed']},
+    zip_safe=False,
+)
diff --git a/examples/chef/stateful_shell.py b/examples/chef/stateful_shell.py
index 066fa0b..8007b9a 100644
--- a/examples/chef/stateful_shell.py
+++ b/examples/chef/stateful_shell.py
@@ -16,6 +16,7 @@
 import shlex
 import subprocess
 import sys
+import time
 from typing import Dict, Optional
 
 import constants
@@ -23,12 +24,18 @@
 _ENV_FILENAME = ".shell_env"
 _OUTPUT_FILENAME = ".shell_output"
 _HERE = os.path.dirname(os.path.abspath(__file__))
+_TEE_WAIT_TIMEOUT = 3
 
 TermColors = constants.TermColors
 
 
 class StatefulShell:
-    """A Shell that tracks state changes of the environment."""
+    """A Shell that tracks state changes of the environment.
+
+    Attributes:
+        env: Env variables passed to command. It gets updated after every command.
+        cwd: Current working directory of shell.
+    """
 
     def __init__(self) -> None:
         if sys.platform == "linux" or sys.platform == "linux2":
@@ -44,8 +51,8 @@
 
         # This file holds the env after running a command. This is a better approach
         # than writing to stdout because commands could redirect the stdout.
-        self.envfile_path: str = os.path.join(_HERE, _ENV_FILENAME)
-        self.cmd_output_path: str = os.path.join(_HERE, _OUTPUT_FILENAME)
+        self._envfile_path: str = os.path.join(_HERE, _ENV_FILENAME)
+        self._cmd_output_path: str = os.path.join(_HERE, _OUTPUT_FILENAME)
 
     def print_env(self) -> None:
         """Print environment variables in commandline friendly format for export.
@@ -87,13 +94,25 @@
         if return_cmd_output:
             # Piping won't work here because piping will affect how environment variables
             # are propagated. This solution uses tee without piping to preserve env variables.
-            redirect = f" > >(tee \"{self.cmd_output_path}\") 2>&1 "  # include stderr
+            redirect = f" > >(tee \"{self._cmd_output_path}\") 2>&1 "  # include stderr
+
+            # Delete the file before running the command so we can later check if the file
+            # exists as a signal that tee has finished writing to the file.
+            if os.path.isfile(self._cmd_output_path):
+                os.remove(self._cmd_output_path)
         else:
             redirect = ""
 
+        # TODO: Use env -0 when `macos-latest` refers to macos-12 in github actions.
+        # env -0 is ideal because it will support cases where an env variable that has newline
+        # characters. The flag "-0" is requires MacOS 12 which is still in beta in Github Actions.
+        # The less ideal `env` command is used by itself, with the caveat that newline chars
+        # are unsupported in env variables.
+        save_env_cmd = f"env > {self._envfile_path}"
+
         command_with_state = (
-            f"OLDPWD={self.env.get('OLDPWD', '')}; {cmd} {redirect}; RETCODE=$?;"
-            f" env -0 > {self.envfile_path}; exit $RETCODE")
+            f"OLDPWD={self.env.get('OLDPWD', '')}; {cmd} {redirect}; RETCODE=$?; "
+            f"{save_env_cmd}; exit $RETCODE")
         with subprocess.Popen(
             [command_with_state],
             env=self.env, cwd=self.cwd,
@@ -102,9 +121,9 @@
             returncode = proc.wait()
 
         # Load env state from envfile.
-        with open(self.envfile_path, encoding="latin1") as f:
-            # Split on null char because we use env -0.
-            env_entries = f.read().split("\0")
+        with open(self._envfile_path, encoding="latin1") as f:
+            # TODO: Split on null char after updating to env -0 - requires MacOS 12.
+            env_entries = f.read().split("\n")
             for entry in env_entries:
                 parts = entry.split("=")
                 # Handle case where an env variable contains text with '='.
@@ -119,6 +138,21 @@
                 f"\nCmd: {cmd}")
 
         if return_cmd_output:
-            with open(self.cmd_output_path, encoding="latin1") as f:
-                output = f.read()
+            # Poll for file due to give 'tee' time to close.
+            # This is necessary because 'tee' waits for all subshells to finish before writing.
+            start_time = time.time()
+            while time.time() - start_time < _TEE_WAIT_TIMEOUT:
+                try:
+                    with open(self._cmd_output_path, encoding="latin1") as f:
+                        output = f.read()
+                    break
+                except FileNotFoundError:
+                    pass
+                time.sleep(0.1)
+            else:
+                raise TimeoutError(
+                    f"Error. Output file: {self._cmd_output_path} not created within "
+                    f"the alloted time of: {_TEE_WAIT_TIMEOUT}s"
+                )
+
             return output
diff --git a/examples/chef/test_stateful_shell.py b/examples/chef/test_stateful_shell.py
new file mode 100644
index 0000000..b2e7d7c
--- /dev/null
+++ b/examples/chef/test_stateful_shell.py
@@ -0,0 +1,48 @@
+"""Tests for stateful_shell.py
+
+Usage:
+python -m unittest
+"""
+
+import unittest
+
+import stateful_shell
+
+
+class TestStatefulShell(unittest.TestCase):
+    """Testcases for stateful_shell.py."""
+
+    def setUp(self):
+        """Prepares stateful shell instance for tests."""
+        self.shell = stateful_shell.StatefulShell()
+
+    def test_cmd_output(self):
+        """Tests shell command output."""
+        resp = self.shell.run_cmd("echo test123", return_cmd_output=True).strip()
+        self.assertEqual(resp, "test123")
+
+    def test_set_env_in_shell(self):
+        """Tests setting env variables in shell."""
+        self.shell.run_cmd("export TESTVAR=123")
+        self.assertEqual(self.shell.env["TESTVAR"], "123")
+
+    def test_set_env_outside_shell(self):
+        """Tests setting env variables outside shell call."""
+        self.shell.env["TESTVAR"] = "1234"
+        resp = self.shell.run_cmd("echo $TESTVAR", return_cmd_output=True).strip()
+        self.assertEqual(resp, "1234")
+
+    def test_env_var_set_get(self):
+        """Tests setting and getting env vars between calls."""
+        self.shell.run_cmd("export TESTVAR=123")
+        resp = self.shell.run_cmd("echo $TESTVAR", return_cmd_output=True).strip()
+        self.assertEqual(resp, "123")
+
+    def test_raise_on_returncode(self):
+        """Tests raising errors when returncode is nonzero."""
+        with self.assertRaises(RuntimeError):
+            self.shell.run_cmd("invalid_cmd > /dev/null 2>&1", raise_on_returncode=True)
+
+
+if __name__ == "__main__":
+    unittest.main()