blob: c20977668e04b94fb342a650b9b3bbd4f2724b94 [file]
# Copyright 2026 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.
"""Lacewing E2E test verifying logging functionality."""
import asyncio
import json
import logging
import re
import time
from typing import Any
import fuchsia_base_test
from honeydew.transports.ffx.types import MachineFormat
from honeydew.typing import custom_types
from mobly import asserts, test_runner
_LOGGER: logging.Logger = logging.getLogger(__name__)
class FfxLogVerificationTest(fuchsia_base_test.FuchsiaBaseTest):
"""Test class for verifying logging functionality."""
async def setup_test(self) -> None:
"""Called automatically before each test case."""
await super().setup_test()
async def _collect_streaming_logs(
self,
cmd_args: list[str],
machine_format: MachineFormat,
test_msg_prefix: str,
expected_count: int,
timeout: float = 60.0,
) -> dict[str, dict[str, Any]]:
"""Starts a background ffx/ssh command, reads stdout line-by-line, and collects matching entries."""
cmd = self.dut.ffx.generate_ffx_cmd(
cmd_args,
machine=machine_format,
)
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
captured_entries: dict[str, dict[str, Any]] = {}
log_pattern = re.compile(rf"{re.escape(test_msg_prefix)}-(\d+)(?!\d)")
async def read_stdout() -> None:
assert process.stdout is not None
while True:
line_bytes = await process.stdout.readline()
if not line_bytes:
break
line = line_bytes.decode("utf-8", errors="ignore").strip()
if not line:
continue
try:
log_line = json.loads(line)
target_log = log_line["data"]["TargetLog"]
msg = target_log["payload"]["root"]["message"]["value"]
match = log_pattern.search(msg)
if match:
idx = int(match.group(1))
if 0 <= idx < expected_count:
candidate = f"{test_msg_prefix}-{idx}"
if candidate not in captured_entries:
captured_entries[candidate] = target_log
if len(captured_entries) >= expected_count:
break
except (json.JSONDecodeError, KeyError, TypeError):
pass
try:
await asyncio.wait_for(read_stdout(), timeout=timeout)
except asyncio.TimeoutError:
_LOGGER.warning(
f"Timeout reached while streaming logs for {cmd_args}. Found {len(captured_entries)}/{expected_count} logs."
)
finally:
try:
process.kill()
await asyncio.wait_for(process.wait(), timeout=5.0)
except Exception:
# This may be expected if the process couldn't be killed because
# it already exited.
pass
return captured_entries
async def test_logging_batching_and_formats(self) -> None:
"""Emits a batch of logs and validates log_listener and ffx log."""
num_logs = 10
timestamp_sec = int(time.time())
test_msg_prefix = f"FfxLogE2EBatchLog-{timestamp_sec}"
# 1. Start the streaming readers as background tasks BEFORE we emit any logs.
fxt_task = asyncio.create_task(
self._collect_streaming_logs(
cmd_args=[
"target",
"ssh",
"log_listener --encoding fxt --json",
],
machine_format=MachineFormat.RAW,
test_msg_prefix=test_msg_prefix,
expected_count=num_logs,
timeout=60.0,
)
)
json_task = asyncio.create_task(
self._collect_streaming_logs(
cmd_args=[
"target",
"ssh",
"log_listener --encoding json --json",
],
machine_format=MachineFormat.RAW,
test_msg_prefix=test_msg_prefix,
expected_count=num_logs,
timeout=60.0,
)
)
ffx_task = asyncio.create_task(
self._collect_streaming_logs(
cmd_args=["log", "--symbolize", "off"],
machine_format=MachineFormat.JSON,
test_msg_prefix=test_msg_prefix,
expected_count=num_logs,
timeout=60.0,
)
)
# 2. Emit a batch of unique logs to validate batching.
for i in range(num_logs):
msg = f"{test_msg_prefix}-{i}"
await self.dut.log_message_to_device(
message=msg,
level=custom_types.LEVEL.INFO,
)
# 3. Await all streaming tasks to complete or timeout.
fxt_entries, json_entries, ffx_entries = await asyncio.gather(
fxt_task, json_task, ffx_task
)
# Verify that we correctly received the batch (at least some, but we expect all 10)
asserts.assert_equal(
len(fxt_entries),
num_logs,
f"Expected {num_logs} logs in FXT log_listener output, but found {len(fxt_entries)}",
)
asserts.assert_equal(
len(json_entries),
num_logs,
f"Expected {num_logs} logs in JSON log_listener output, but found {len(json_entries)}",
)
asserts.assert_equal(
len(ffx_entries),
num_logs,
f"Expected {num_logs} logs in FFX log output, but found {len(ffx_entries)}",
)
# 4. Ensure JSON, FXT, and FFX formatting is consistent and identical.
for i in range(num_logs):
msg = f"{test_msg_prefix}-{i}"
fxt_entry = fxt_entries[msg]
json_entry = json_entries[msg]
ffx_entry = ffx_entries[msg]
# Validate that the component moniker contains remote-control
# (or log_message_to_device sender context).
for name, entry in [
("FXT", fxt_entry),
("JSON", json_entry),
("FFX", ffx_entry),
]:
asserts.assert_true(
any(
expected in entry.get("moniker", "")
for expected in ["remote-control", "remote"]
),
f"Unexpected {name} moniker: {entry.get('moniker')}",
)
# Compare structure and key fields to ensure identical formatting between all outputs
for field in ["moniker", "version"]:
asserts.assert_equal(
fxt_entry.get(field),
json_entry.get(field),
f"Mismatch in '{field}' between FXT and JSON for log: {msg}",
)
asserts.assert_equal(
json_entry.get(field),
ffx_entry.get(field),
f"Mismatch in '{field}' between JSON and FFX for log: {msg}",
)
# Compare metadata fields
fxt_meta = fxt_entry.get("metadata", {})
json_meta = json_entry.get("metadata", {})
ffx_meta = ffx_entry.get("metadata", {})
for field in ["severity", "component_url"]:
asserts.assert_equal(
fxt_meta.get(field),
json_meta.get(field),
f"Mismatch in metadata '{field}' between FXT and JSON for log: {msg}",
)
asserts.assert_equal(
json_meta.get(field),
ffx_meta.get(field),
f"Mismatch in metadata '{field}' between JSON and FFX for log: {msg}",
)
if __name__ == "__main__":
test_runner.main()