blob: 8997befe02d8fd7ff4902aed0bd09e1a77006a56 [file] [log] [blame]
#!/usr/bin/env fuchsia-vendored-python
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Simple FFX host tool E2E test."""
import inspect
import json
import logging
import os
import subprocess
import tempfile
import zipfile
from pathlib import Path
from typing import Any, List, Optional, Text, Tuple
import ffxtestcase
from honeydew.transports.ffx.errors import FfxCommandError
from mobly import asserts, test_runner
_LOGGER: logging.Logger = logging.getLogger(__name__)
def parse_json_messages(output: str) -> list[dict[str, Any]]:
decoder = json.JSONDecoder()
messages = []
position = 0
while position < len(output):
(message, read) = decoder.raw_decode(output[position:])
messages.append(message)
position = position + read
return messages
class FfxStrictTest(ffxtestcase.FfxTestCase):
"""FFX host tool E2E test For Strict."""
def setup_class(self) -> None:
# This just gets some things out of the way before we start turning
# the daemon off and on again.
super().setup_class()
self.dut_ssh_address = self.dut.ffx.get_target_ssh_address()
self.dut_name = self.dut.ffx.get_target_name()
self.ssh_private_key: Optional[str] = None
def setup_test(self) -> None:
"""Each test must run without the daemon."""
super().setup_test()
self.dut.ffx.run(["daemon", "stop"])
# Return list of ["key=val"]
def _get_configs(self, keys: List[str]) -> List[str]:
outputs = []
for key in keys:
output = json.loads(
self.run_ffx(["config", "get", "-s", "first", key])
)
asserts.assert_true(
isinstance(output, Text),
f"Value for {key} is not a string: {output}",
)
output = output.strip().replace('"', "")
outputs.append(f"{key}={output}")
return outputs
# Look up and store the user's private key
def _get_ssh_private_key(self) -> None:
if self.ssh_private_key:
return
ssh_priv_output = json.loads(
self.run_ffx(["config", "get", "-s", "first", "ssh.priv"])
)
ssh_priv = ""
if isinstance(ssh_priv_output, List):
ssh_priv = ssh_priv_output[0].strip().replace('"', "")
elif isinstance(ssh_priv_output, Text):
ssh_priv = ssh_priv_output.strip().replace('"', "")
self.ssh_private_key = ssh_priv
# Build the default configs passed to strict invocations of ffx
def _build_strict_config_args(self, extra_configs: List[str]) -> List[Text]:
environ = os.environ
configs = extra_configs
# Get output directory
out_dir = os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR")
caller_frame = inspect.currentframe()
if caller_frame:
caller_frame = caller_frame.f_back
if out_dir and caller_frame:
out_dir = os.path.join(
out_dir, f"{caller_frame.f_code.co_name}.log"
)
if not out_dir:
out_dir = "/dev/null"
_LOGGER.info(f"Setting ffx config log dir to {out_dir}")
# Get other required configs:
#
# ffx.subtool-search-paths: Subtool path, for externally-compiled subtools
# test.output_path: Output path for certain tests
# ssh.priv: path to private key for ssh connection to target
# fastboot.devices_file.path: path for discovering Pontis fastboot devices
# log.dir: directory to write logs
ffx_path = self.dut.ffx.config.binary_path
subtool_path = Path(ffx_path).resolve().parent
configs.append(f"ffx.subtool-search-paths={subtool_path}")
configs.append(f"test.output_path={out_dir}")
self._get_ssh_private_key()
configs.append(f"ssh.priv={self.ssh_private_key}")
configs.append(
f"fastboot.devices_file.path={environ['HOME']}/.fastboot/devices"
)
configs.append(f"log.dir={environ['FUCHSIA_TEST_OUTDIR']}/ffx_logs")
# Return as list of args: ["-c, "key1=val1", "-c", "key2=val2", ...]
retval = []
for c in configs:
retval.append("--config")
retval.append(c)
return retval
# Run ffx --strict <cmd> with the specified configs, and
# optionally with a target
def _run_strict_ffx_with_configs(
self, cmd: List[str], configs: List[str], target: Optional[str]
) -> Any:
all_args = [
"--strict",
"--machine",
"json",
"-o",
"/dev/null",
*configs,
]
if target is not None:
all_args += ["-t", target]
all_args += cmd
return json.loads(self.run_ffx(all_args))
def _run_strict_ffx_unchecked_with_format(
self, cmd: List[str], target: Optional[str], format: str
) -> Tuple[int, str, str]:
all_args = [
"--strict",
"--machine",
format,
"-o",
"/dev/null",
*self._build_strict_config_args([]),
]
if target is not None:
all_args += ["-t", target]
all_args += cmd
(code, stdout, stderr) = self.run_ffx_unchecked(all_args)
return (code, stdout.strip().replace("\n", ""), stderr)
def _run_strict_ffx_unchecked(
self, cmd: List[str], target: Optional[str] = None
) -> Tuple[int, str, str]:
return self._run_strict_ffx_unchecked_with_format(cmd, target, "json")
# Run ffx --strict <cmd> with the default configs, and
# optionally with a target
def _run_strict_ffx(
self, cmd: List[str], target: Optional[str] = None
) -> Any:
return self._run_strict_ffx_with_configs(
cmd, self._build_strict_config_args([]), target
)
def test_target_echo_no_start_daemon(self) -> None:
"""Test `ffx --strict target echo` does not affect daemon state."""
output = self._run_strict_ffx(
[
"target",
"echo",
"From a Test",
],
f"{self.dut_ssh_address}",
)
asserts.assert_equal(output, {"message": "From a Test"})
with asserts.assert_raises(FfxCommandError):
self.dut.ffx.run(["-c", "daemon.autostart=false", "daemon", "echo"])
def test_strict_errors_with_target_name(self) -> None:
"""Test `ffx --strict target echo` fails when attempt discovery."""
with asserts.assert_raises(subprocess.CalledProcessError):
self._run_strict_ffx(
[
"target",
"echo",
"From a Test",
],
self.dut_name,
)
def test_strict_can_check_for_no_target(self) -> None:
"""Test `ffx --strict target echo` requires a target."""
(code, stdout, stderr) = self._run_strict_ffx_unchecked(
["target", "echo"], None
)
message = json.loads(stdout)
asserts.assert_equal(stderr, "")
asserts.assert_equal(message["type"], "user")
asserts.assert_equal(message["code"], 1)
asserts.assert_equal(
message["message"],
"Command line flags unsatisfactory for strict mode:\n\tffx strict requires that the target be explicitly specified",
)
def test_strict_can_accept_no_target(self) -> None:
"""Test `ffx --strict product download` doesn't require a target."""
with asserts.assert_raises(subprocess.CalledProcessError):
try:
self._run_strict_ffx(
["product", "download", "http://0.0.0.0:12345", "foo"], None
)
except subprocess.CalledProcessError as e:
asserts.assert_false(
b"ffx strict requires that the target be explicitly specified"
in e.stderr,
"The command should not require a target",
)
raise
def test_strict_may_require_shared_data(self) -> None:
"""Test `ffx --strict` will error out if $SHARED_DATA is required but not supplied"""
(_code, stdout, _stderr) = self._run_strict_ffx_unchecked(
["repository", "server", "list"],
f"{self.dut_ssh_address}",
)
_LOGGER.info(f"code: {_code}")
_LOGGER.info(f"stdout: {stdout}")
_LOGGER.info(f"stderr: {_stderr}")
# We can't actually load the output into json, because `ffx repository server list`
# has technically not been ported to ffx-strict, and it can produce multiple error
# lines, which is not valid JSON. But it is the only built-in plugin that requires
# SHARED_DATA, so it is the tool we are using here.
asserts.assert_true(
"SHARED_DATA must be specified in strict mode" in stdout,
"Expected a message about SHARED_DATA in strict",
)
def test_strict_uses_shared_data(self) -> None:
"""Test `ffx --strict` will use the value passed in for $SHARED_DATA"""
(_code, stdout, _stderr) = self._run_strict_ffx_unchecked_with_format(
["-c", "shared_data=foo", "config", "get", "monitor.pid_file"],
None,
"raw",
)
asserts.assert_true(
"foo/monitor" in stdout,
"Expected SHARED_DATA to be correctly set in strict",
stdout,
)
def test_target_list_strict(self) -> None:
"""Test `ffx --strict target list` does not affect daemon state."""
emu_config = self._get_configs(["emu.instance_dir"])
configs = self._build_strict_config_args(emu_config)
output = self._run_strict_ffx_with_configs(
[
"target",
"list",
self.dut_name,
],
configs,
None,
)
asserts.assert_equal(output[0]["rcs_state"], "Y")
with asserts.assert_raises(FfxCommandError):
self.dut.ffx.run(["-c", "daemon.autostart=false", "daemon", "echo"])
def test_target_list_strict_fails(self) -> None:
"""Test `ffx --strict target list` correctly reports RCS=N."""
emu_config = self._get_configs(["emu.instance_dir"])
configs = self._build_strict_config_args(emu_config)
# Ensure that we cannot find the ssh.priv file
new_configs = []
for c in configs:
if c.startswith("ssh.priv="):
new_configs.append(c + "NONEXISTENT")
else:
new_configs.append(c)
output = self._run_strict_ffx_with_configs(
[
"target",
"list",
self.dut_name,
],
new_configs,
None,
)
asserts.assert_equal(output[0]["rcs_state"], "N")
def test_target_wait_strict(self) -> None:
"""Test `ffx --strict target wait`."""
output = self._run_strict_ffx(
[
"target",
"wait",
],
f"{self.dut_ssh_address}",
)
asserts.assert_equal(output, {"ok": {}})
def test_target_wait_down_strict(self) -> None:
"""Test `ffx --strict target wait --down`."""
(code, stdout, stderr) = self._run_strict_ffx_unchecked(
[
"target",
"wait",
"--timeout",
"1",
"--down",
],
f"{self.dut_ssh_address}",
)
# the raw json decoder doesnt like whitespace or newlines
asserts.assert_equal(stderr, "")
messages = parse_json_messages(stdout)
# We'll grab the last message to parse
message = messages[-1]
asserts.assert_equal(message["type"], "user")
asserts.assert_equal(message["code"], 1)
asserts.assert_equal(code, 1)
def test_target_snapshot_destination_annotations(self) -> None:
"""Test `ffx --strict target snapshot` with incorrect dir."""
(_code, stdout, _stderr) = self._run_strict_ffx_unchecked(
[
"target",
"snapshot",
"--dump-annotations",
],
f"{self.dut_ssh_address}",
)
messages = parse_json_messages(stdout)
message = messages[-1]
asserts.assert_true(
message.get("annotations") is not None,
f"JSON message doesn't contain 'annotations': {message}",
)
asserts.assert_true(
"board" in message["annotations"]["annotations"],
f"message check failed: {message}",
)
def test_target_snapshot_destination_not_directory(self) -> None:
"""Test `ffx --strict target snapshot` with incorrect dir."""
with tempfile.NamedTemporaryFile() as tmp:
tmp.write(b"blah blah blah")
(_code, stdout, _stderr) = self._run_strict_ffx_unchecked(
[
"target",
"snapshot",
"-d",
tmp.name,
],
f"{self.dut_ssh_address}",
)
messages = parse_json_messages(stdout)
message = messages[-1]
asserts.assert_true(
message.get("user_error") is not None,
f"JSON message doesn't contain 'user_error': {message}",
)
asserts.assert_true(
"not a directory" in message["user_error"]["message"],
f"message check failed: {message}",
)
asserts.assert_true(
tmp.name in message["user_error"]["message"],
f"message check failed: {message}",
)
def test_target_snapshot_e2e(self) -> None:
"""Test `ffx --strict target snapshot` with incorrect dir."""
with tempfile.TemporaryDirectory() as tmpdir:
(_code, stdout, _stderr) = self._run_strict_ffx_unchecked(
[
"target",
"snapshot",
"-d",
tmpdir,
],
f"{self.dut_ssh_address}",
)
messages = parse_json_messages(stdout)
message = messages[-1]
# Example expected output:
# {"snapshot":{"output_file":"/tmp/snapshots/20250218_150248/snapshot.zip"}}
asserts.assert_true(
message.get("snapshot") is not None,
f"JSON message doesn't contain 'snapshot': {message}",
)
asserts.assert_true(
"zip" in message["snapshot"]["output_file"],
f"message check failed: {message}",
)
output_file = message["snapshot"]["output_file"]
output_file_size = os.path.getsize(output_file)
asserts.assert_true(output_file_size > 0, "output file is empty")
# Example unzip of snapshot contents:
# Archive: snapshot.zip
# inflating: annotations.json
# inflating: build.kernel-boot-options.txt
# inflating: build.snapshot.xml
# inflating: inspect.json
# inflating: log.kernel.txt
# inflating: log.system.txt
# inflating: metadata.json
with zipfile.ZipFile(output_file, "r") as output_file_zip:
info = output_file_zip.infolist()
checks = [
"annotations.json",
"build.kernel-boot-options.txt",
"build.snapshot.xml",
"inspect.json",
"log.kernel.txt",
"log.system.txt",
"metadata.json",
]
for check in checks:
asserts.assert_true(
any(map(lambda x: x.filename == check, info)),
f"Expected `{check}` in snapshot: {info}",
)
def test_target_ssh_strict(self) -> None:
"""Test `ffx --strict target ssh`."""
(_code, stdout, _stderr) = self._run_strict_ffx_unchecked_with_format(
[
"target",
"ssh",
"echo",
"foo",
],
f"{self.dut_ssh_address}",
"raw",
)
asserts.assert_equal(stdout, "foo")
if __name__ == "__main__":
test_runner.main()
if __name__ == "__main__":
test_runner.main()