blob: 7b530c7a4a17805e662452dc6b4aec016a974a65 [file] [log] [blame]
#!/usr/bin/env -S python3 -B
# Copyright (c) 2024 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 glob
import logging
import os
import signal
import sys
import tempfile
import time
from dataclasses import dataclass
from typing import List, Optional
import click
from linux.log_line_processing import ProcessOutputCapture
from linux.tv_casting_test_sequence_utils import App, Sequence, Step
from linux.tv_casting_test_sequences import START_APP, STOP_APP
"""
This script can be used to validate the casting experience between the Linux tv-casting-app and the Linux tv-app.
It runs a series of test sequences that check for expected output lines from the tv-casting-app and the tv-app in
a deterministic order. If these lines are not found, it indicates an issue with the casting experience.
"""
@dataclass
class RunningProcesses:
tv_casting: ProcessOutputCapture = None
tv_app: ProcessOutputCapture = None
# Configure logging format.
logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s")
# File names of logs for the Linux tv-casting-app and the Linux tv-app.
LINUX_TV_APP_LOGS = "Linux-tv-app-logs.txt"
LINUX_TV_CASTING_APP_LOGS = "Linux-tv-casting-app-logs.txt"
class TestStepException(Exception):
"""Thrown when a test fails, contains information about the test step that faied"""
def __init__(self, message, sequence_name: str, step: Optional[Step]):
super().__init__(message)
self.sequence_name = sequence_name
self.step = step
logging.error("EXCEPTION at %s/%r: %s", sequence_name, step, message)
def remove_cached_files(cached_file_pattern: str):
"""Remove any cached files that match the provided pattern."""
cached_files = glob.glob(
cached_file_pattern
) # Returns a list of paths that match the pattern.
for cached_file in cached_files:
try:
os.remove(cached_file)
except OSError as e:
logging.error(
f"Failed to remove cached file `{cached_file}` with error: `{e.strerror}`"
)
raise # Re-raise the OSError to propagate it up.
def stop_app(test_sequence_name: str, app_name: str, app: ProcessOutputCapture):
"""Stop the given `app` subprocess."""
app.process.terminate()
app_exit_code = app.process.wait()
if app.process.poll() is None:
raise TestStepException(
f"{test_sequence_name}: Failed to stop running {app_name}. Process is still running.",
test_sequence_name,
None,
)
if app_exit_code >= 0:
raise TestStepException(
f"{test_sequence_name}: {app_name} exited with unexpected exit code {app_exit_code}.",
test_sequence_name,
None,
)
signal_number = -app_exit_code
if signal_number != signal.SIGTERM.value:
raise TestStepException(
f"{test_sequence_name}: {app_name} stopped by signal {signal_number} instead of {signal.SIGTERM.value} (SIGTERM).",
test_sequence_name,
None,
)
logging.info(
f"{test_sequence_name}: {app_name} stopped by {signal_number} (SIGTERM) signal."
)
def parse_output_msg_in_subprocess(
processes: RunningProcesses, test_sequence_name: str, test_sequence_step: Step
):
"""Parse the output of a given `app` subprocess and validate its output against the expected `output_msg` in the given `Step`."""
if not test_sequence_step.output_msg:
raise TestStepException(
f"{test_sequence_name} - No output message provided in the test sequence step.",
test_sequence_name,
test_sequence_step,
)
app_subprocess = (
processes.tv_casting
if test_sequence_step.app == App.TV_CASTING_APP
else processes.tv_app
)
start_wait_time = time.time()
msg_block = []
current_index = 0
while current_index < len(test_sequence_step.output_msg):
# Check if we exceeded the maximum wait time to parse for the output string(s).
max_wait_time = start_wait_time + test_sequence_step.timeout_sec - time.time()
if max_wait_time < 0:
raise TestStepException(
f"{test_sequence_name} - Did not find the expected output string(s) in the {test_sequence_step.app.value} subprocess within the timeout: {test_sequence_step.output_msg}",
test_sequence_name,
test_sequence_step,
)
output_line = app_subprocess.next_output_line(max_wait_time)
if output_line:
if test_sequence_step.output_msg[current_index] in output_line:
msg_block.append(output_line.rstrip("\n"))
current_index += 1
elif msg_block:
msg_block.append(output_line.rstrip("\n"))
if test_sequence_step.output_msg[0] in output_line:
msg_block.clear()
msg_block.append(output_line.rstrip("\n"))
current_index = 1
# Sanity check that `Discovered Commissioner #0` is the valid commissioner.
elif "Discovered Commissioner #" in output_line:
raise TestStepException(
f"{test_sequence_name} - The valid discovered commissioner should be `Discovered Commissioner #0`.",
test_sequence_name,
test_sequence_step,
)
if current_index == len(test_sequence_step.output_msg):
logging.info(
f"{test_sequence_name} - Found the expected output string(s) in the {test_sequence_step.app.value} subprocess:"
)
for line in msg_block:
logging.info(f"{test_sequence_name} - {line}")
# successful completion
return
raise TestStepException("Unexpected exit", test_sequence_name, test_sequence_step)
def send_input_cmd_to_subprocess(
processes: RunningProcesses,
test_sequence_name: str,
test_sequence_step: Step,
):
"""Send a given input command (`input_cmd`) from the `Step` to its given `app` subprocess."""
if not test_sequence_step.input_cmd:
raise TestStepException(
f"{test_sequence_name} - No input command provided in the test sequence step.",
test_sequence_step,
test_sequence_step,
)
app_subprocess = (
processes.tv_casting
if test_sequence_step.app == App.TV_CASTING_APP
else processes.tv_app
)
app_name = test_sequence_step.app.value
input_cmd = test_sequence_step.input_cmd
app_subprocess.send_to_program(input_cmd)
input_cmd = input_cmd.rstrip("\n")
logging.info(
f"{test_sequence_name} - Sent `{input_cmd}` to the {app_name} subprocess."
)
def handle_input_cmd(
processes: RunningProcesses, test_sequence_name: str, test_sequence_step: Step
):
"""Handle the input command (`input_cmd`) from a test sequence step."""
if test_sequence_step.input_cmd == STOP_APP:
if test_sequence_step.app == App.TV_CASTING_APP:
stop_app(
test_sequence_name, test_sequence_step.app.value, processes.tv_casting
)
elif test_sequence_step.app == App.TV_APP:
stop_app(test_sequence_name, test_sequence_step.app.value, processes.tv_app)
else:
raise TestStepException(
"Unknown stop app", test_sequence_name, test_sequence_step
)
return
send_input_cmd_to_subprocess(processes, test_sequence_name, test_sequence_step)
def run_test_sequence_steps(
current_index: int,
test_sequence_name: str,
test_sequence_steps: List[Step],
processes: RunningProcesses,
):
"""Run through the test steps from a test sequence starting from the current index and perform actions based on the presence of `output_msg` or `input_cmd`."""
if test_sequence_steps is None:
logging.error("No test sequence steps provided.")
while current_index < len(test_sequence_steps):
# Current step in the list of steps.
test_sequence_step = test_sequence_steps[current_index]
# A test sequence step contains either an output_msg or input_cmd entry.
if test_sequence_step.output_msg:
parse_output_msg_in_subprocess(
processes,
test_sequence_name,
test_sequence_step,
)
elif test_sequence_step.input_cmd:
handle_input_cmd(
processes,
test_sequence_name,
test_sequence_step,
)
current_index += 1
def cmd_execute_list(app_path):
"""Returns the list suitable to pass to a ProcessOutputCapture/subprocess.run for execution."""
cmd = []
# On Unix-like systems, use stdbuf to disable stdout buffering.
# Configure command options to disable stdout buffering during tests.
if sys.platform == "darwin" or sys.platform == "linux":
cmd = ["stdbuf", "-o0", "-i0"]
cmd.append(app_path)
# Our applications support better debugging logs. Enable them
cmd.append("--trace-to")
cmd.append("json:log")
return cmd
@click.command()
@click.option(
"--tv-app-rel-path",
type=str,
default="out/tv-app/chip-tv-app",
help="Path to the Linux tv-app executable.",
)
@click.option(
"--tv-casting-app-rel-path",
type=str,
default="out/tv-casting-app/chip-tv-casting-app",
help="Path to the Linux tv-casting-app executable.",
)
@click.option(
"--commissioner-generated-passcode",
type=bool,
default=False,
help="Enable the commissioner generated passcode test flow.",
)
@click.option(
"--log-directory",
type=str,
default=None,
help="Where to place output logs",
)
def test_casting_fn(
tv_app_rel_path, tv_casting_app_rel_path, commissioner_generated_passcode, log_directory
):
"""Test if the casting experience between the Linux tv-casting-app and the Linux tv-app continues to work.
By default, it uses the provided executable paths and the commissionee generated passcode flow as the test sequence.
Example usages:
1. Use default paths and test sequence:
python3 run_tv_casting_test.py
2. Use custom executable paths and default test sequence:
python3 run_tv_casting_test.py --tv-app-rel-path=path/to/tv-app --tv-casting-app-rel-path=path/to/tv-casting-app
3. Use default paths and a test sequence that is not the default test sequence (replace `test-sequence-name` with the actual name of the test sequence):
python3 run_tv_casting_test.py --test-sequence-name=True
4. Use custom executable paths and a test sequence that is not the default test sequence (replace `test-sequence-name` with the actual name of the test sequence):
python3 run_tv_casting_test.py --tv-app-rel-path=path/to/tv-app --tv-casting-app-rel-path=path/to/tv-casting-app --test-sequence-name=True
Note: In order to enable a new test sequence, we also need to define a @click.option() entry for the test sequence.
"""
# Store the log files to a temporary directory.
with tempfile.TemporaryDirectory() as temp_dir:
if log_directory:
linux_tv_app_log_path = os.path.join(log_directory, LINUX_TV_APP_LOGS)
linux_tv_casting_app_log_path = os.path.join(
log_directory, LINUX_TV_CASTING_APP_LOGS
)
else:
linux_tv_app_log_path = os.path.join(temp_dir, LINUX_TV_APP_LOGS)
linux_tv_casting_app_log_path = os.path.join(
temp_dir, LINUX_TV_CASTING_APP_LOGS
)
# Get all the test sequences.
test_sequences = Sequence.get_test_sequences()
# Get the test sequence that we are interested in validating.
test_sequence_name = "commissionee_generated_passcode_test"
if commissioner_generated_passcode:
test_sequence_name = "commissioner_generated_passcode_test"
test_sequence = Sequence.get_test_sequence_by_name(
test_sequences, test_sequence_name
)
if not test_sequence:
raise TestStepException(
"No test sequence found by the test sequence name provided.",
test_sequence_name,
None,
)
# At this point, we have retrieved the test sequence of interest.
test_sequence_steps = test_sequence.steps
current_index = 0
if test_sequence_steps[current_index].input_cmd != START_APP:
raise ValueError(
f"{test_sequence_name}: The first step in the test sequence must contain `START_APP` as `input_cmd` to indicate starting the tv-app."
)
elif test_sequence_steps[current_index].app != App.TV_APP:
raise ValueError(
f"{test_sequence_name}: The first step in the test sequence must be to start up the tv-app."
)
current_index += 1
tv_app_abs_path = os.path.abspath(tv_app_rel_path)
# Run the Linux tv-app subprocess.
with ProcessOutputCapture(
cmd_execute_list(tv_app_abs_path), linux_tv_app_log_path
) as tv_app_process:
# Verify that the tv-app is up and running.
parse_output_msg_in_subprocess(
RunningProcesses(tv_app=tv_app_process),
test_sequence_name,
test_sequence_steps[current_index],
)
current_index += 1
if test_sequence_steps[current_index].input_cmd != START_APP:
raise ValueError(
f"{test_sequence_name}: The third step in the test sequence must contain `START_APP` as `input_cmd` to indicate starting the tv-casting-app."
)
elif test_sequence_steps[current_index].app != App.TV_CASTING_APP:
raise ValueError(
f"{test_sequence_name}: The third step in the test sequence must be to start up the tv-casting-app."
)
current_index += 1
tv_casting_app_abs_path = os.path.abspath(tv_casting_app_rel_path)
# Run the Linux tv-casting-app subprocess.
with ProcessOutputCapture(
cmd_execute_list(tv_casting_app_abs_path), linux_tv_casting_app_log_path
) as tv_casting_app_process:
processes = RunningProcesses(
tv_casting=tv_casting_app_process, tv_app=tv_app_process
)
# Verify that the server initialization is completed in the tv-casting-app output.
parse_output_msg_in_subprocess(
processes,
test_sequence_name,
test_sequence_steps[current_index],
)
current_index += 1
run_test_sequence_steps(
current_index,
test_sequence_name,
test_sequence_steps,
processes,
)
if __name__ == "__main__":
# Start with a clean slate by removing any previously cached entries.
try:
cached_file_pattern = "/tmp/chip_*"
remove_cached_files(cached_file_pattern)
except OSError:
logging.error(
f"Error while removing cached files with file pattern: {cached_file_pattern}"
)
sys.exit(1)
# Test casting (discovery and commissioning) between the Linux tv-casting-app and the tv-app.
test_casting_fn()