blob: 9f1c8f36a9fa132b82f39dfe7132e33be63f3f7b [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 app-launch diagnostic analysis plugin."""
import unittest
from typing import Any
from unittest.mock import MagicMock, patch
from app_launch import AppLaunchPlugin, _build_launch_cte
from plugins import PluginArgumentError
class AppLaunchPluginTest(unittest.TestCase):
"""Tests for the app-launch analysis plugin using mock trace processor queries."""
def setUp(self) -> None:
self.all_tables = {
"slice",
"thread_track",
"thread",
"process",
"thread_state",
}
def test_analyze_success_flow(self) -> None:
"""Tests that all 5 diagnostic sections are executed in order with expected outputs."""
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
def run_query_mock(sql: str) -> list[dict[str, Any]]:
if "pre_fork_ms" in sql:
return [
{
"startup_id": 1,
"package": "com.android.performanceLaunch",
"process_name": "rformanceLaunch",
"startup_type": "cold",
"total_launch_ms": 420.5,
"pre_fork_ms": 18.2,
"process_fork_ms": 24.3,
"attach_app_ms": 31.0,
"bind_app_ms": 115.0,
"activity_lifecycle_ms": 82.0,
"first_frame_ui_ms": 65.0,
"first_frame_render_ms": 85.0,
"display_present_tail_ms": 12.4,
}
]
elif "substages_with_sum" in sql:
return [
{
"startup_id": 1,
"package": "com.android.performanceLaunch",
"total_launch_ms": 420.5,
"init": 135.2,
"draw": 85.0,
"render": 6.6,
"traversal": 58.4,
"animation": 0.0,
"scenic": 3.4,
}
]
elif "open_dex_ms" in sql:
return [
{
"startup_id": 1,
"package": "com.android.performanceLaunch",
"open_dex_ms": 48.5,
"dlopen_ms": 12.1,
"verify_class_ms": 14.0,
"class_init_ms": 9.3,
"get_resources_ms": 18.2,
"inflate_ms": 34.6,
"relayout_window_ms": 11.4,
"dequeue_buffer_ms": 22.0,
"shader_compile_ms": 19.5,
"queue_present_ms": 38.2,
}
]
elif "running_cpu_ms" in sql:
return [
{
"startup_id": 1,
"package": "com.android.performanceLaunch",
"segment": "S3_BindApplication",
"thread_name": "rformanceLaunch",
"segment_dur_ms": 115.0,
"running_cpu_ms": 70.0,
"runnable_starved_ms": 15.0,
"sleeping_blocked_ms": 30.0,
"running_pct": 60.9,
}
]
elif "fxfs_page_in_ms" in sql:
return [
{
"startup_id": 1,
"package": "com.android.performanceLaunch",
"main_binder_calls": 14,
"main_binder_ms": 26.4,
"monitor_contention_ms": 4.2,
"native_lock_contention_ms": 8.1,
"fxfs_page_in_ms": 19.8,
"sysmem_busy_ms": 16.5,
"selinux_hooks_ms": 1.3,
}
]
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze(
["--package", "performanceLaunch", "--limit", "10"],
"dummy_trace",
)
self.assertEqual(len(results), 5)
# 1. Critical Path Phase Breakdown (Sequential Segments)
self.assertEqual(
results[0].name,
"Critical Path Phase Breakdown (Sequential Segments)",
)
res0 = results[0].results
assert res0 is not None
self.assertEqual(len(res0), 1)
self.assertEqual(res0[0]["pre_fork_ms"], 18.2)
self.assertEqual(res0[0]["process_fork_ms"], 24.3)
self.assertEqual(res0[0]["bind_app_ms"], 115.0)
self.assertEqual(res0[0]["first_frame_render_ms"], 85.0)
# 2. CUJ Sub-Stage Breakdown
self.assertEqual(results[1].name, "CUJ Sub-Stage Breakdown")
res1 = results[1].results
assert res1 is not None
self.assertEqual(len(res1), 1)
self.assertEqual(res1[0]["init"], 135.2)
self.assertEqual(res1[0]["traversal"], 58.4)
self.assertEqual(res1[0]["draw"], 85.0)
self.assertEqual(res1[0]["scenic"], 3.4)
# 3. Intra-Segment Sub-Operation Breakdown
self.assertEqual(
results[2].name, "Intra-Segment Sub-Operation Breakdown"
)
res2 = results[2].results
assert res2 is not None
self.assertEqual(len(res2), 1)
self.assertEqual(res2[0]["open_dex_ms"], 48.5)
self.assertEqual(res2[0]["inflate_ms"], 34.6)
# 4. Critical Path Thread State Breakdown
self.assertEqual(
results[3].name, "Critical Path Thread State Breakdown"
)
res3 = results[3].results
assert res3 is not None
self.assertEqual(len(res3), 1)
self.assertEqual(res3[0]["segment"], "S3_BindApplication")
self.assertEqual(res3[0]["running_cpu_ms"], 70.0)
# 5. Cross-Layer Contention & Fuchsia Subsystem Attribution
self.assertEqual(
results[4].name,
"Cross-Layer Contention & Fuchsia Subsystem Attribution",
)
res4 = results[4].results
assert res4 is not None
self.assertEqual(len(res4), 1)
self.assertEqual(res4[0]["fxfs_page_in_ms"], 19.8)
self.assertEqual(res4[0]["sysmem_busy_ms"], 16.5)
def test_analyze_empty_results(self) -> None:
"""Tests that empty query results format as empty lists across all 5 sections."""
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
mock_tp.run_query.return_value = []
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_trace")
self.assertEqual(len(results), 5)
for section in results:
self.assertTrue(section.name)
self.assertEqual(section.results, [])
def test_analyze_missing_schema(self) -> None:
"""Tests that missing schema tables are handled gracefully with section error messages."""
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = set()
mock_tp.run_query.return_value = []
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_trace")
self.assertEqual(len(results), 5)
for section in results:
assert section.error is not None
self.assertIn("Required schema tables/views missing", section.error)
def test_analyze_query_failure(self) -> None:
"""Tests that runtime SQL query failures return formatted error dicts without crashing."""
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
mock_tp.run_query.side_effect = RuntimeError(
"database disk image is malformed"
)
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_trace")
self.assertEqual(len(results), 5)
for section in results:
assert section.error is not None
self.assertIn(
"Query execution failed: database disk image is malformed",
section.error,
)
def test_analyze_invalid_limit(self) -> None:
"""Tests that invalid, non-positive, or non-integer limits raise PluginArgumentError."""
plugin = AppLaunchPlugin()
for invalid_limit in ["0", "-5"]:
with self.subTest(limit=invalid_limit):
with self.assertRaisesRegex(
PluginArgumentError, "Limit must be a positive integer"
):
plugin.analyze(["--limit", invalid_limit], "dummy_trace")
with self.assertRaisesRegex(PluginArgumentError, "invalid int value"):
plugin.analyze(["--limit", "abc"], "dummy_trace")
def test_analyze_scaling_option(self) -> None:
"""Tests that --scaling proportional injects proportional SQL expressions and validates choices."""
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
captured_queries: list[str] = []
def run_query_mock(sql: str) -> list[dict[str, Any]]:
captured_queries.append(sql)
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze(
["--scaling", "proportional"], "dummy_trace"
)
self.assertEqual(len(results), 5)
self.assertIn("stage_sum > 0 THEN", captured_queries[1])
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
plugin.analyze(["--scaling", "raw"], "dummy_trace")
# Scaling applies only to the CUJ sub-stages: the critical path segments already
# sum to the launch duration.
self.assertEqual(captured_queries[0], captured_queries[5])
self.assertNotIn("stage_sum > 0 THEN", captured_queries[6])
with self.assertRaisesRegex(PluginArgumentError, "invalid choice"):
plugin.analyze(["--scaling", "invalid"], "dummy_trace")
def test_critical_path_segments_cover_warm_launches(self) -> None:
"""Tests that warm launches get a pre-activity segment and gaps are reported."""
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
captured_queries: list[str] = []
def run_query_mock(sql: str) -> list[dict[str, Any]]:
captured_queries.append(sql)
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
plugin.analyze([], "dummy_trace")
query = captured_queries[0]
# Without bindApplication, the launch start to the first activityStart or
# activityResume is its own segment instead of being left out.
self.assertIn(
"THEN t3b_activity_start_ts - t0b_activity_start_request_ts",
query,
)
self.assertIn("AS pre_activity_ms", query)
self.assertIn("COALESCE(pre_activity_dur, 0)", query)
# Time no segment covers is reported, so the columns sum to total_launch_ms.
self.assertIn(
"ROUND((total_launch_dur - seg_sum) / 1e6, 2) AS unattributed_ms",
query,
)
def _capture_queries(self, args: list[str]) -> list[str]:
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
captured_queries: list[str] = []
def run_query_mock(sql: str) -> list[dict[str, Any]]:
captured_queries.append(sql)
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
plugin.analyze(args, "dummy_trace")
return captured_queries
def test_critical_path_reports_time_from_activity_start(self) -> None:
"""Tests that launches are also measured from ActivityTaskManager's START."""
query = self._capture_queries([])[0]
# START is the last resolveIntent before shouldAbortBackgroundActivityStart.
self.assertIn("sab.name = 'shouldAbortBackgroundActivityStart'", query)
self.assertIn("SELECT MAX(ri.ts)", query)
self.assertIn(
"(t6_launch_end - t0b_activity_start_request_ts) AS displayed_dur",
query,
)
self.assertIn(
"(t0b_activity_start_request_ts - t0_launch_start) AS pre_start_dur",
query,
)
# pre_start is its own segment, so the segments still sum to the launch.
self.assertIn("COALESCE(pre_start_dur, 0)", query)
self.assertIn(
"(t1_start_proc_ts - t0b_activity_start_request_ts) AS pre_fork_dur",
query,
)
# Without a START marker, the launch start stands in for it, so displayed_ms is
# always a number.
self.assertRegex(
query,
r"LIMIT 1\s*\),\s*m\.t0_launch_start\s*\) AS t0b_activity_start_request_ts",
)
def test_hot_launches_resolve_process_and_first_frame(self) -> None:
"""Tests that hot launches find a long truncated process name and the right frame."""
query = self._capture_queries([])[0]
# Starnix can truncate long process names to a prefix longer than 15 characters,
# e.g. 'com.google.android.apps.nexusla'.
self.assertEqual(
query.count(
"(LENGTH(p.name) >= 15 AND r.package GLOB p.name || '*')"
),
2,
)
# Frames drawn before the app starts handling the launch (e.g. a hot launcher's
# animations) are not the launch's first frame.
self.assertIn(
"a.name IN ('bindApplication', 'activityStart', 'activityResume')",
query,
)
def test_contention_columns_cover_only_ui_threads(self) -> None:
"""Tests that binder and lock columns count outermost slices on the app's threads."""
query = self._capture_queries([])[4]
# Starnix emits BinderIoctl rather than the Linux binder transaction events.
self.assertIn("s.name = 'BinderIoctl'", query)
self.assertIn("AS main_binder_calls", query)
self.assertIn("tt.utid IN (l.main_utid)\n", query)
self.assertIn("tt.utid IN (l.main_utid, l.render_utid)\n", query)
self.assertNotIn("t.upid = l.upid OR p.name = 'system_server'", query)
# Nested matches are skipped and slices are clipped to the launch.
self.assertIn("NOT EXISTS (", query)
self.assertIn("FROM ancestor_slice(s.id) anc", query)
self.assertIn(
"SUM(MIN(l.t6_launch_end, s.ts + s.dur) - MAX(l.t0_launch_start, s.ts))",
query,
)
# sysmem is reported as its own busy time, not summed across processes.
self.assertIn("AS sysmem_busy_ms", query)
self.assertNotIn("BufferCollection", query)
def test_cuj_substages_count_self_time_of_nested_slices(self) -> None:
"""Tests that nested sub-stage slices are not counted twice."""
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
captured_queries: list[str] = []
def run_query_mock(sql: str) -> list[dict[str, Any]]:
captured_queries.append(sql)
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
plugin.analyze([], "dummy_trace")
query = captured_queries[1]
# Each sub-stage slice finds its nearest sub-stage ancestor, which subtracts it.
self.assertIn("FROM ancestor_slice(a.slice_id) anc", query)
self.assertIn("c.stage_parent_id = p.slice_id", query)
for stage in ["init", "traversal", "render", "draw", "animation"]:
with self.subTest(stage=stage):
self.assertIn(
f"SUM(CASE WHEN a.stage = '{stage}' THEN a.self_dur END)",
query,
)
# Slices are clipped to the launch end.
self.assertIn(
"MIN(s.ts + s.dur, m.t6_launch_end) - s.ts AS clipped_dur", query
)
def test_build_launch_cte_escapes_package_filter(self) -> None:
"""Tests that package filter quotes are escaped in SQL CTE construction."""
cte = _build_launch_cte("foo'bar")
self.assertIn("*foo''bar*", cte)
def test_analyze_multiple_launches_in_single_trace(self) -> None:
"""Tests that multiple launches (e.g. cold + warm or multiple apps) in one trace are preserved."""
plugin = AppLaunchPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
captured_queries: list[str] = []
def run_query_mock(sql: str) -> list[dict[str, Any]]:
captured_queries.append(sql)
if "pre_fork_ms" in sql:
return [
{
"startup_id": 14,
"package": "com.google.android.calendar",
"process_name": "com.google.android.calendar",
"startup_type": "cold",
"total_launch_ms": 1571.6,
},
{
"startup_id": 21,
"package": "com.google.android.calendar",
"process_name": "com.google.android.calendar",
"startup_type": "warm",
"total_launch_ms": 209.95,
},
]
elif "substages_with_sum" in sql:
return [
{
"startup_id": 14,
"package": "com.google.android.calendar",
"total_launch_ms": 1571.6,
"init": 389.49,
"draw": 57.53,
"render": 6.66,
"traversal": 86.26,
"animation": 0.0,
"scenic": 2.38,
},
{
"startup_id": 21,
"package": "com.google.android.calendar",
"total_launch_ms": 209.95,
"init": 73.45,
"draw": 51.27,
"render": 2.55,
"traversal": 6.53,
"animation": 0.0,
"scenic": 0.77,
},
]
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("app_launch.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_multi_launch_trace")
res0 = results[0].results
assert res0 is not None
self.assertEqual(len(res0), 2)
self.assertEqual(res0[0]["startup_type"], "cold")
self.assertEqual(res0[1]["startup_type"], "warm")
res1 = results[1].results
assert res1 is not None
self.assertEqual(len(res1), 2)
self.assertIn(
"USING (startup_id, t0_launch_start)", captured_queries[1]
)
self.assertIn(
"USING (startup_id, t0_launch_start)", captured_queries[2]
)
if __name__ == "__main__":
unittest.main()