blob: 9c7cfc67b1c05e3f9d5d58437dede5b23a082165 [file] [log] [blame]
# Copyright 2025 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.
"""Runs shell commands to enumerate tests or build a GN target.
Results: Returned from polling a command - tells its status and any outputs
destined for the UI.
TargetBuilder and TestEnumerator start running when the instance is created,
and finish running when poll() returns a status of Success or Failure.
poll() also returns any text output produced by the running command, sorted
into status updates, important messages, and errors.
TargetBuilder: Builds a GN target passed to the class initializer.
TestEnumerator: Enumerates all the tests in a given GN target.
Upon Success, member `tests` holds the enumerated tests.
"""
import select
import subprocess
import sys
import time
from dataclasses import dataclass
from typing import IO, Any
import data_structure
import util
from json_get import JsonGet
@dataclass
class Results:
"""Returned from CommandRunner.poll().
`status` will be one of Working, Success, Failing, or Failure.
`outputs` contains new text for the user to see.
"""
outputs: util.Outputs
status: util.Status
# TODO(https://fxbug.dev/412727988): unit-test the classes in this file
# Subclasses of CommandRunner are essentially helper classes, but there's enough state
# shared between CommandRunner and its helpers that it's easier to make them the same
# object with a few calling contracts, rather than multiple objects with calling
# contracts _and_ pointers into each other.
class CommandRunner:
"""Sets up and runs a command.
Must be subclassed. The subclass will prepare for _command() to be called,
then call __init__() which will start the command running. Then poll() can
be called by the holder of the instance.
"""
def __init__(self) -> None:
"""Sets up and runs the command."""
self.outputs = util.Outputs()
self.finalized_results: Results | None = None
self.status = util.Status.WORKING
self.exit_code: int | None = None
self.saw_error = False
self.command = _Command(self._command())
def poll(self) -> Results:
"""Returns the command's status and outputs."""
if self.finalized_results:
return self.finalized_results
if self.command.is_done():
return self._finalize()
out, err = self.command.get_new()
if out:
self._handle_outputs(out)
if err:
self._handle_errors(err)
results = Results(outputs=self.outputs, status=self.status)
self.outputs = util.Outputs()
return results
def _command(self) -> str:
"""Returns the command string from the subclass.
Must be overridden. Subclasses must ensure that any state required by
their _command() method implementation is initialized prior to calling
super().__init__().
"""
return ""
def _handle_outputs(self, lines: list[str]) -> None:
"""Override to handle stdout lines."""
for line in lines:
self.outputs.update(line)
def _handle_errors(self, lines: list[str]) -> None:
"""Override to handle stderr lines."""
if lines:
self.saw_error = True
for line in lines:
self.outputs.error(line)
def _almost_done(self) -> None:
"""Override to do something when the runner is about to finalize."""
def _should_fail(self) -> bool:
"""Override to compute a different failure condition."""
return self.saw_error or self.exit_code != 0
def _finalize(self) -> Results:
"""Wrap up a command and set self.finalized_results.
Gets and processes all remaining output from the command.
Only call when the shell command has finished, or it may hang or
return misleading results.
"""
self.exit_code = self.command.exit_code()
out, err = self.command.get_remaining()
if out:
self._handle_outputs(out)
if err:
self._handle_errors(err)
if self._should_fail():
self.status = util.Status.FAILING
if self.status == util.Status.FAILING:
self.status = util.Status.FAILURE
else:
self.status = util.Status.SUCCESS
self._almost_done()
self.finalized_results = Results(
outputs=util.Outputs(), status=self.status
)
r = Results(outputs=self.outputs, status=self.status)
# Anything written into this object will be ignored
self.outputs = util.Outputs()
return r
class TargetBuilder(CommandRunner):
"""Builds a GN target.
Init with a GN target containing tests, like "//sdk/ctf/release"
Call poll() (returns Results) until state is "success" or "failure".
"""
def __init__(self, target: str) -> None:
self.command_string = f".jiri_root/bin/fx build {target}"
super().__init__()
def _command(self) -> str:
return self.command_string
def _handle_outputs(self, lines: list[str]) -> None:
self.outputs.update(lines[-1])
class TestEnumerator(CommandRunner):
"""
Init with a GN target containing tests, like "//sdk/ctf/release"
Call poll() (returns Results) until state is "success" or "failure"
"""
def __init__(self, target: str) -> None:
self.command_string = (
f".jiri_root/bin/fx test {target} --list --no-build --logpath -"
)
super().__init__()
self.tests: data_structure.Tests = data_structure.Tests()
self.outputs.print(
"Please stand by - enumerating tests (may take 30 seconds)"
)
# `fx test` launches subcommands. Some of them produce output aimed at STDERR even though
# there's no error. We'll ignore that output. Each subcommand has a different "id".
self.ignore_errors: set[int] = set()
self.run_count = 0
self.total_runs = 0
def _command(self) -> str:
return self.command_string
def _almost_done(self) -> None:
if not self._should_fail():
self.outputs.update("Done enumerating tests")
def _handle_outputs(self, lines: list[str]) -> None:
"""Scan the JSON output for useful info."""
for line in lines:
if "tests could not be enumerated" in line:
self._handle_errors(
[
"Enumeration didn't work. You may need to start emu and/or serve."
]
)
info = JsonGet(line)
self._look_for_test_selections(info)
self._look_for_program_starts(info)
self._look_for_executing(info)
self._look_for_enumeration(info)
self._look_for_building(info)
self._look_for_error_output(info)
def _handle_errors(self, lines: list[str]) -> None:
"""Output useful information based on error line contents."""
if not lines:
return
self.saw_error = True
for e in lines:
self.outputs.error(e)
if (
"ERROR: Unknown GN label" in e
or "all_package_manifests.list" in e
):
self.outputs.error("You may need to do an `fx build`.")
def _look_for_test_selections(self, info: JsonGet) -> None:
info.match(
{"payload": {"test_selections": {"selected": Any}}},
self._selected_tests_callback,
)
def _selected_tests_callback(self, data_with_selected: Any) -> None:
self.total_runs = len(
data_with_selected.payload.test_selections.selected.keys()
)
def _look_for_program_starts(self, info: JsonGet) -> None:
# {"id": 3, "timestamp": 1280753.01070115, "parent": 2, "starting": true,
# "payload": {"program_execution": {"command": "fx",
# "flags": ["--dir", "/usr/local/google/home/cphoenix/fuchsia/out/emu", "dldist", "-v",
# "--needle", "//sdk/ctf/tests/pkg/fdio:fdio-spawn-tests-package", "--input",
# "/tmp/tmpp_1dukb1/temp-input.txt", "--match-contains"], "environment": {}}}}
info.match(
{
"id": Any,
"starting": True,
"payload": {
"program_execution": {"command": Any, "flags": Any}
},
},
self._program_start_callback,
)
def _program_start_callback(self, start: Any) -> None:
if "dldist" in start.payload.program_execution.flags:
self.ignore_errors.add(start.id)
def _look_for_executing(self, info: JsonGet) -> None:
# { "id": 110, "timestamp": 406305.148786878, "parent": 16, "starting": true,
# "payload":
# {"program_execution":
# {"command": "fx",
# "flags": ["--dir", "/usr/local/google/home/cphoenix/fuchsia/out/emu",
# "ffx", "test", "list-cases",
# "fuchsia-pkg://fuchsia.com/fuchsia-element-tests_ctf24?hash=0f997b3e79d591aa729b3fdc448869259a36c9083a82cbb0a5a0e3aa783f16bc#meta/test-root.cm"], "environment": {}}}}
info.match(
{
"starting": True,
"payload": {"program_execution": {"flags": Any}},
},
self._executing_callback,
)
def _executing_callback(self, data_with_flags: Any) -> None:
if not self.total_runs:
return
program_name = data_with_flags.payload.program_execution.flags[-1]
self.run_count += 1
self.outputs.update(
f"{int(self.run_count*100/self.total_runs)}%: {program_name.split('?')[0]}"
)
def _look_for_error_output(self, info: JsonGet) -> None:
# {"id": 11, "timestamp": 1268958.160280603, "payload": {"program_output":
# {"data": "ERROR: It looks like the package repository server is not running.\n",
# "stream": "STDERR", "print_verbatim": false}}}
info.match(
{
"id": Any,
"payload": {
"program_output": {"stream": "STDERR", "data": Any}
},
},
self._error_output_callback,
)
def _error_output_callback(self, info: Any) -> None:
if info.id not in self.ignore_errors:
self._handle_errors([info.payload.program_output.data])
def _look_for_building(self, info: JsonGet) -> None:
# {"id": 8, "timestamp": 1267997.052252219, "starting": true,
# "payload": {"build_targets": ["--default", "//sdk/ctf/tests/pkg/fdio:fdio-spawn-tests-package",
# "//sdk/ctf/tests/pkg/fdio:fdio-spawn-tests-package"]}}
info.match(
{"starting": True, "payload": {"build_targets": Any}},
self._building_callback,
)
def _building_callback(self, info: Any) -> None:
self.outputs.print(
f"Building (could take a while): {info.payload.build_targets[1:]}"
)
def _look_for_enumeration(self, info: JsonGet) -> None:
# {"id": 382, "timestamp": 406316.24595807, "payload": {
# "enumerate_test_cases": {
# "test_name": "fuchsia-pkg://fuchsia.com/media-button-test-suite_ctf26#meta/media-button-conformance-test.cm",
# "test_case_names": ["main"]}}}
info.match(
{
"payload": {
"enumerate_test_cases": {
"test_name": Any,
"test_case_names": Any,
}
}
},
self._enumeration_callback,
)
def _enumeration_callback(self, data_with_enumerate: Any) -> None:
suite_name = data_with_enumerate.payload.enumerate_test_cases.test_name
case_names = (
data_with_enumerate.payload.enumerate_test_cases.test_case_names
)
suite = data_structure.TestSuite(name=suite_name)
for cn in case_names:
suite.cases.append(data_structure.TestCase(cn))
self.tests.tests.append(suite)
class _Command:
"""Executes a shell command, and handles output while it's running.
Can be used to busy-wait-poll; if no new input, it does a brief delay.
"""
def __init__(self, command: str) -> None:
words = command.split()
try:
self.c = subprocess.Popen(
words,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=False,
)
except FileNotFoundError:
print(
"This script can only be run in the root of your source tree."
)
sys.exit()
def _read(self, stream: IO[bytes]) -> list[str]:
lines = []
while True:
read_list, _, _ = select.select(
[stream], [], [], 0.1
) # This makes the readline() non-blocking
if not read_list:
break
line = read_list[0].readline()
if line == b"":
break
else:
lines.append(str(line, encoding="utf-8"))
return lines
def get_new(self, delay: float = 0.005) -> tuple[list[str], list[str]]:
assert self.c.stdout, "keep MyPy happy - this should never be None"
new_stdout = self._read(self.c.stdout)
assert self.c.stderr, "keep MyPy happy - this should never be None"
new_stderr = self._read(self.c.stderr)
if not new_stdout and not new_stderr:
time.sleep(delay)
return new_stdout, new_stderr
# If command is still running, this will read until it naturally quits, so it may never return.
def get_remaining(self) -> tuple[list[str], list[str]]:
stdout_lines: list[str] = []
stderr_lines: list[str] = []
while self.c.poll() is None:
o, e = self.get_new()
stdout_lines.extend(o)
stderr_lines.extend(e)
while True:
o, e = self.get_new()
if not o and not e:
break
stdout_lines.extend(o)
stderr_lines.extend(e)
return stdout_lines, stderr_lines
def is_done(self) -> bool:
return self.c.poll() is not None
def exit_code(self) -> int | None:
return self.c.poll()