返回教程正文

配套源码

test_app_lifecycle.py

app/tests/test_app_lifecycle.py
websocket_server Brick:可复用的 WebSocket 传输层app/tests/test_app_lifecycle.py
Python108 行
"""在真实 app_utils 环境运行示例 App 子进程,不模拟 AppController 的错误处理。"""

import os
import socket
import subprocess
import sys
import time
import unittest
from pathlib import Path

from websockets.sync.client import connect

try:
    from arduino.app_utils.app import AppController
    HAS_APP_UTILS = True
except ImportError:
    HAS_APP_UTILS = False

APP_DIR = Path(__file__).resolve().parents[1]


def child_environment(port):
    """
    @description         : 为独立 App 子进程设置测试端口和本 App 的 Brick 搜索目录
    @param port          : 本机测试端口
    @return              : 不改变父进程环境的字典
    """
    environment = dict(os.environ)
    environment["PYTHONPATH"] = str(APP_DIR / "bricks") + os.pathsep + environment.get("PYTHONPATH", "")
    environment["PYTHONUNBUFFERED"] = "1"
    environment["WEBSOCKET_SERVER_HOST"] = "127.0.0.1"
    environment["WEBSOCKET_SERVER_PORT"] = str(port)
    environment["WEBSOCKET_SERVER_PATH"] = "/ws"
    return environment


@unittest.skipUnless(HAS_APP_UTILS, "requires real arduino.app_utils; run sh tests/run.sh --unit on the board")
class AppLifecycleTests(unittest.TestCase):
    """验证真实生命周期控制器与示例入口的组合行为。"""

    def test_occupied_port_exits_nonzero_with_reason(self):
        """
        @description         : 即使 AppController 捕获 start 异常,示例仍以非零退出并记录具体原因
        @param               : 无参数
        @return              : 无返回值
        """
        with socket.create_server(("127.0.0.1", 0)) as occupied:
            result = subprocess.run(
                [sys.executable, "-B", str(APP_DIR / "python/main.py")],
                env=child_environment(occupied.getsockname()[1]),
                capture_output=True,
                text=True,
                encoding="utf-8",
                timeout=10,
            )
        self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
        logs = result.stdout + result.stderr
        self.assertIn("WebSocket startup failed", logs)
        self.assertIn("WebSocket service unavailable", logs)
        self.assertIn("OSError", logs)

    @unittest.skipIf(sys.platform == "win32", "SIGTERM lifecycle is validated on Linux")
    def test_sigterm_releases_port_and_process_can_restart(self):
        """
        @description         : 真实 App 接收 SIGTERM 后在五秒内退出,并可在相同端口重新启动
        @param               : 无参数
        @return              : 无返回值
        """
        with socket.socket() as reservation:
            reservation.bind(("127.0.0.1", 0))
            port = reservation.getsockname()[1]
        for _ in range(2):
            process = subprocess.Popen(
                [sys.executable, "-B", str(APP_DIR / "python/main.py")],
                env=child_environment(port),
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                encoding="utf-8",
            )
            try:
                deadline = time.monotonic() + 5
                while True:
                    try:
                        with connect(f"ws://127.0.0.1:{port}/ws", open_timeout=0.3, close_timeout=0.3) as client:
                            client.send("ready")
                            self.assertEqual(client.recv(timeout=1), "ready")
                        break
                    except OSError:
                        if process.poll() is not None or time.monotonic() >= deadline:
                            self.fail("App did not become ready")
                        time.sleep(0.02)
                stopped_at = time.monotonic()
                process.terminate()
                output, _ = process.communicate(timeout=5)
                self.assertLess(time.monotonic() - stopped_at, 5)
                self.assertEqual(process.returncode, 143, output)
                self.assertNotIn("stop incomplete", output)
                with socket.create_server(("127.0.0.1", port)):
                    pass
            finally:
                if process.poll() is None:
                    process.kill()
                    process.communicate(timeout=3)


if __name__ == "__main__":
    unittest.main()