blob: b878e6321cf54787cf35a23ec64e15b847ca7783 [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.
"""Unit tests for the perf-analyze tool."""
import importlib.resources
import io
import json
import unittest
from contextlib import redirect_stderr, redirect_stdout
from typing import Any, Sequence
from perf_analyze import main
from plugins import AnalyzePlugin, PluginArgumentError
class MockAnalyzePlugin(AnalyzePlugin):
"""Mock implementation of AnalyzePlugin for unit testing success paths."""
name: str = "mock-analyze"
description: str = "Mock analyze plugin"
def analyze(
self,
remaining_args: Sequence[str],
trace_path: str,
cache: bool = True,
) -> list[dict[str, Any]]:
"""Mock analysis execution."""
if "--help" in remaining_args:
print("mock-analyze help")
return []
return [{"analyze_result": "ok"}]
class MockErrorPlugin(AnalyzePlugin):
"""Mock implementation of AnalyzePlugin for unit testing error paths."""
name: str = "mock-error"
description: str = "Mock error plugin"
def analyze(
self,
remaining_args: Sequence[str],
trace_path: str,
cache: bool = True,
) -> list[dict[str, Any]]:
"""Mock analysis execution raising errors."""
if "--invalid" in remaining_args:
raise PluginArgumentError("Invalid argument")
raise RuntimeError("Some other error")
class PerfAnalyzeTest(unittest.TestCase):
"""Tests for the perf-analyze command line tool."""
def test_query_sql_text(self) -> None:
"""Tests executing a simple SQL query in default text format."""
source = importlib.resources.files("test_data").joinpath(
"sample_fxt.fxt"
)
with importlib.resources.as_file(source) as trace_path:
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(
[
"query",
"--trace",
str(trace_path),
"--sql",
"select count(*) as cnt from slice",
]
)
self.assertEqual(exit_code, 0)
output = stdout.getvalue().strip()
self.assertEqual(output, "cnt\n520")
def test_query_sql_error(self) -> None:
"""Tests that query errors are formatted correctly."""
source = importlib.resources.files("test_data").joinpath(
"sample_fxt.fxt"
)
with importlib.resources.as_file(source) as trace_path:
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
exit_code = main(
[
"--format",
"text",
"query",
"--trace",
str(trace_path),
"--sql",
"select invalid-sql from slice",
]
)
self.assertNotEqual(exit_code, 0)
output = stdout.getvalue().strip()
self.assertTrue(
output.startswith("Error: Traceback"), f"Output was: {output!r}"
)
self.assertIn("no such column: invalid", output)
def test_batch_query(self) -> None:
"""Tests executing a batch of queries from a file."""
trace_source = importlib.resources.files("test_data").joinpath(
"sample_fxt.fxt"
)
queries_source = importlib.resources.files("test_data").joinpath(
"sample_queries.json"
)
with importlib.resources.as_file(
trace_source
) as trace_path, importlib.resources.as_file(
queries_source
) as queries_path:
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(
[
"--format",
"json",
"query",
"--trace",
str(trace_path),
"--batch",
f"@{queries_path}",
]
)
self.assertEqual(exit_code, 0)
results = json.loads(stdout.getvalue())
expected = [
{"name": "slice_count", "results": [{"cnt": 520}]},
{"name": "process_count", "results": [{"cnt": 2}]},
]
self.assertEqual(results, expected)
def test_query_help(self) -> None:
"""Tests that query --help displays query help."""
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(["query", "--help"])
self.assertEqual(exit_code, 0)
output = stdout.getvalue()
self.assertIn("--trace", output)
self.assertIn("--sql", output)
self.assertIn("--batch", output)
def test_query_empty_results_text(self) -> None:
"""Tests that queries with no results format as 'No results.'"""
source = importlib.resources.files("test_data").joinpath(
"sample_fxt.fxt"
)
with importlib.resources.as_file(source) as trace_path:
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(
[
"query",
"--trace",
str(trace_path),
"--sql",
"select * from slice where 1=0",
]
)
self.assertEqual(exit_code, 0)
output = stdout.getvalue().strip()
self.assertEqual(output, "No results.")
def test_list_plugins(self) -> None:
"""Tests that analyze --list-plugins lists registered plugins."""
plugins = [MockAnalyzePlugin()]
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(["analyze", "--list-plugins"], plugins=plugins)
self.assertEqual(exit_code, 0)
output = stdout.getvalue().strip()
self.assertIn("mock-analyze\tMock analyze plugin", output)
def test_analyze_plugin_routing(self) -> None:
"""Tests routing and execution of an analyze plugin."""
plugins = [MockAnalyzePlugin()]
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(
[
"--format",
"json",
"analyze",
"--trace",
"dummy_trace",
"--plugin",
"mock-analyze",
],
plugins=plugins,
)
self.assertEqual(exit_code, 0)
results = json.loads(stdout.getvalue())
self.assertEqual(results, [{"analyze_result": "ok"}])
def test_analyze_plugin_argument_error(self) -> None:
"""Tests that PluginArgumentError from plugin returns exit code 2."""
plugins = [MockErrorPlugin()]
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(
[
"analyze",
"--trace",
"dummy_trace",
"--plugin",
"mock-error",
"--invalid",
],
plugins=plugins,
)
self.assertEqual(exit_code, 2)
def test_analyze_plugin_generic_error(self) -> None:
"""Tests that generic Exception from plugin returns exit code 1."""
plugins = [MockErrorPlugin()]
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(
[
"analyze",
"--trace",
"dummy_trace",
"--plugin",
"mock-error",
],
plugins=plugins,
)
self.assertEqual(exit_code, 1)
def test_query_unrecognized_arguments(self) -> None:
"""Tests that query with unknown flags returns exit code 2."""
stderr = io.StringIO()
with redirect_stderr(stderr):
exit_code = main(
[
"query",
"--trace",
"dummy_trace",
"--sql",
"select 1",
"--unknown-flag",
]
)
self.assertEqual(exit_code, 2)
self.assertIn(
"unrecognized arguments: --unknown-flag", stderr.getvalue()
)
def test_analyze_missing_required_arguments(self) -> None:
"""Tests that analyze without --trace and --plugin returns exit code 2."""
stderr = io.StringIO()
with redirect_stderr(stderr):
exit_code = main(["analyze"])
self.assertEqual(exit_code, 2)
self.assertIn(
"the following arguments are required: --trace, --plugin",
stderr.getvalue(),
)
def test_analyze_plugin_help(self) -> None:
"""Tests that analyze --plugin <name> --help dispatches help to plugin."""
plugins = [MockAnalyzePlugin()]
stdout = io.StringIO()
with redirect_stdout(stdout):
exit_code = main(
["analyze", "--plugin", "mock-analyze", "--help"],
plugins=plugins,
)
self.assertEqual(exit_code, 0)
self.assertIn("mock-analyze help", stdout.getvalue())
def test_analyze_unknown_plugin(self) -> None:
"""Tests that analyze --plugin unknown returns exit code 2."""
plugins = [MockAnalyzePlugin()]
stderr = io.StringIO()
with redirect_stderr(stderr):
exit_code = main(
[
"analyze",
"--trace",
"dummy_trace",
"--plugin",
"non-existent-plugin",
],
plugins=plugins,
)
self.assertEqual(exit_code, 2)
self.assertIn(
"Plugin 'non-existent-plugin' not found", stderr.getvalue()
)
if __name__ == "__main__":
unittest.main()