Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pip install -r requirements-dev.txt

### GUI layer (`src/pytest_fly/gui/`)
- `gui_main.py` — `FlyAppMainWindow`: 8-tab Qt window with a periodic timer (default 3 s) that pulls updates from the runner and refreshes all tabs.
- Tabs: `run_tab/` (run/stop controls, status, system metrics, failed tests, live output), `graph_tab/` (time-based progress chart), `table_tab/` (per-test status grid), `coverage_tab/` (coverage-over-time chart), `history_tab/` (recent-run summaries — run times, pass/fail statistics, failed-test lists; run count set by the History Run Limit preference), `log_tab/` (live application event log — admission-gate, resource-guard, and stall-watchdog events, each line date/time-prefixed; default view shows tagged `EVENT_EXTRA` events + warnings, Verbose shows all INFO+), `configuration_tab/` (parallelism, thresholds, gates), `about_tab/`.
- Tabs: `run_tab/` (run/stop controls, status, system metrics, failed tests, live output), `graph_tab/` (time-based progress chart), `table_tab/` (per-test status grid), `coverage_tab/` (coverage-over-time chart), `history_tab/` (recent-run summaries — run times, pass/fail statistics, failed-test lists; multi-select rows copy to the clipboard; run count set by the History Run Limit preference), `log_tab/` (live application event log — admission-gate, resource-guard, and stall-watchdog events, each line date/time-prefixed; default view shows tagged `EVENT_EXTRA` events + warnings, Verbose shows all INFO+), `configuration_tab/` (parallelism, thresholds, gates), `about_tab/`.

### Core runner (`src/pytest_fly/pytest_runner/`)
- `pytest_runner.py` — `PytestRunner` (thread): orchestrates worker threads, schedules tests, handles run modes.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ python -m pytest_fly
- **Table** — per-test status grid with elapsed time, peak CPU, memory usage, and individual coverage
- **Coverage** — line chart of combined code coverage over time with covered/total line counts
- **History** — summaries of recent runs, most recent first: start time, duration, completion
status, pass/fail statistics, and each run's failed tests as expandable rows. The number of
status, pass/fail statistics, and each run's failed tests as expandable rows. Rows can be
multi-selected and copied to the clipboard (Ctrl+C or right-click → Copy). The number of
runs shown is configurable
- **Log** — live application event log, each line date/time-prefixed. The default view shows
only notable run events (admission-gate deferrals, resource-guard and stall-watchdog
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "pytest-fly"
description = "pytest runner and observer"
version = "0.8.0"
version = "0.8.1"
readme = "README.md"
requires-python = ">=3.12"
authors = [
Expand Down
78 changes: 70 additions & 8 deletions src/pytest_fly/gui/history_tab/history_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@

Shows one row per run (start time, duration, completion status, pass/fail statistics, and
the program-under-test version), with the run's failed tests as expandable child rows.
Rows can be multi-selected and copied to the clipboard (Ctrl+C or right-click → Copy).
The number of runs shown is the Configuration tab's "History Run Limit" preference.
"""

from datetime import datetime

from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGroupBox, QSizePolicy, QTreeWidget, QTreeWidgetItem, QVBoxLayout
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QGuiApplication, QKeySequence, QShortcut
from PySide6.QtWidgets import QAbstractItemView, QGroupBox, QMenu, QSizePolicy, QTreeWidget, QTreeWidgetItem, QVBoxLayout

from ...colors import TABLE_COLORS
from ...db import PytestProcessInfoReader
Expand All @@ -21,7 +23,7 @@
_COLUMNS = ("Start", "Duration", "Status", "Pass", "Fail", "Other", "Total", "Version")
_START_COLUMN, _DURATION_COLUMN, _STATUS_COLUMN, _PASS_COLUMN, _FAIL_COLUMN, _OTHER_COLUMN, _TOTAL_COLUMN, _VERSION_COLUMN = range(len(_COLUMNS))

# Run GUID stored on each top-level item so expansion state survives rebuilds.
# Run GUID stored on each top-level item so expansion and selection state survive rebuilds.
_RUN_GUID_ROLE = Qt.ItemDataRole.UserRole


Expand All @@ -38,13 +40,23 @@ def __init__(self):

self._tree = QTreeWidget()
self._tree.setHeaderLabels(list(_COLUMNS))
self._tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self._tree.setToolTip(
"One row per recent test run; expand a run to see its failed tests.\n"
"Other = terminated, stopped, or still queued/running tests.\n"
"Select rows (Ctrl/Shift-click for several) and copy them with Ctrl+C or right-click → Copy.\n"
"The number of runs shown is set by the Configuration tab's History Run Limit."
)
self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self._tree.customContextMenuRequested.connect(self._show_context_menu)
layout.addWidget(self._tree)

# WidgetWithChildrenShortcut: the tree's viewport has focus during interaction, so a
# plain widget-context shortcut on the tree itself would never fire.
copy_shortcut = QShortcut(QKeySequence(QKeySequence.StandardKey.Copy), self._tree)
copy_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
copy_shortcut.activated.connect(self.copy_selection_to_clipboard)

# Change-detection state: rebuild only when the DB content or the run limit changed.
self._change_token: tuple[int, int] | None = None
self._run_limit: int | None = None
Expand All @@ -59,16 +71,61 @@ def update_tick(self, db: PytestProcessInfoReader) -> None:
self._run_limit = run_limit
self._rebuild(build_run_history(db.query_recent_runs(run_limit)))

def selection_as_text(self) -> str:
"""Return the highlighted rows as clipboard text, in visual (tree) order.

A run row becomes one tab-separated line of its column values; a failed-test row
becomes the test's node_id. Empty when nothing is selected.
"""
lines: list[str] = []
for index in range(self._tree.topLevelItemCount()):
run_item = self._tree.topLevelItem(index)
if run_item is None:
continue
if run_item.isSelected():
lines.append("\t".join(run_item.text(column) for column in range(len(_COLUMNS))))
for child_index in range(run_item.childCount()):
child = run_item.child(child_index)
if child is not None and child.isSelected():
lines.append(child.text(0))
return "\n".join(lines)

def copy_selection_to_clipboard(self) -> None:
"""Copy the highlighted rows to the system clipboard; a no-op when nothing is selected."""
text = self.selection_as_text()
if text:
QGuiApplication.clipboard().setText(text)

def _show_context_menu(self, position: QPoint) -> None:
"""Right-click menu: Copy the highlighted rows to the clipboard."""
menu = QMenu(self._tree)
copy_action = menu.addAction("Copy")
copy_action.setEnabled(bool(self._tree.selectedItems()))
selected_action = menu.exec_(self._tree.viewport().mapToGlobal(position))
if selected_action == copy_action:
self.copy_selection_to_clipboard()

def _rebuild(self, summaries: list[RunHistorySummary]) -> None:
"""Repopulate the tree, preserving each still-present run's expansion state."""
"""Repopulate the tree, preserving each still-present run's expansion and selection state."""
expansion_by_guid: dict[str, bool] = {}
selected_run_guids: set[str] = set()
selected_failed_tests: set[tuple[str, str]] = set() # (run_guid, test node_id)
for index in range(self._tree.topLevelItemCount()):
item = self._tree.topLevelItem(index)
if item is None:
continue
run_guid = item.data(0, _RUN_GUID_ROLE)
# Only a row with failed-test children has a meaningful expansion state to keep.
# Recording childless rows would freeze an in-progress run in its initial collapsed
# state and defeat the auto-expand when its first failure appears.
if item is not None and item.childCount() > 0:
expansion_by_guid[item.data(0, _RUN_GUID_ROLE)] = item.isExpanded()
if item.childCount() > 0:
expansion_by_guid[run_guid] = item.isExpanded()
if item.isSelected():
selected_run_guids.add(run_guid)
for child_index in range(item.childCount()):
child = item.child(child_index)
if child is not None and child.isSelected():
selected_failed_tests.add((run_guid, child.text(0)))

self._tree.clear()
fail_color = TABLE_COLORS[PytestRunnerState.FAIL]
Expand All @@ -94,12 +151,17 @@ def _rebuild(self, summaries: list[RunHistorySummary]) -> None:
failed_item.setForeground(0, fail_color)
run_item.addChild(failed_item)
self._tree.addTopLevelItem(run_item)
# Selection (like the column span below) only takes effect once the item is in the tree.
run_item.setSelected(summary.run_guid in selected_run_guids)
# Runs with failures start expanded so the failed tests are immediately visible;
# a run the user explicitly collapsed (or expanded) stays that way across rebuilds.
run_item.setExpanded(expansion_by_guid.get(summary.run_guid, summary.n_fail > 0))
# A failed-test child row is a single name, not tabular data — span it across all columns.
for child_index in range(run_item.childCount()):
run_item.child(child_index).setFirstColumnSpanned(True)
child = run_item.child(child_index)
if child is not None:
child.setSelected((summary.run_guid, child.text(0)) in selected_failed_tests)
# A failed-test child row is a single name, not tabular data — span it across all columns.
child.setFirstColumnSpanned(True)

for column in range(len(_COLUMNS)):
self._tree.resizeColumnToContents(column)
76 changes: 74 additions & 2 deletions tests/test_history_tab.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"""History tab — recent-run summaries rendered from the DB.

Covers the run rows (times, pass/fail statistics, status), the failed-test child rows,
the configurable run limit, and the change-token gating that skips rebuilds when the DB
has not changed.
the configurable run limit, the change-token gating that skips rebuilds when the DB
has not changed, and multi-select copy-to-clipboard.
"""

import time

import pytest
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication

from pytest_fly.db import PytestProcessInfoDB, PytestProcessInfoReader
from pytest_fly.gui.history_tab import HistoryTab
Expand Down Expand Up @@ -162,3 +163,74 @@ def test_history_tab_empty_db(qtbot, history_run_limit_pref):
qtbot.addWidget(tab)
_update_from_db(tab, data_dir)
assert tab._tree.topLevelItemCount() == 0


def test_history_tab_copy_selection(qtbot, history_run_limit_pref):
"""Selected run rows copy as tab-separated columns and failed-test rows as names, in visual order."""
data_dir = get_temp_dir("history_tab_copy")
now = time.time()
with PytestProcessInfoDB(data_dir) as db:
db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, now - 100))
db.write(_record("run-2", "tests/test_a.py", PyTestFlyExitCode.OK, now - 50))
db.write(_record("run-2", "tests/test_b.py", PyTestFlyExitCode.TESTS_FAILED, now - 40))

tab = HistoryTab()
qtbot.addWidget(tab)
_update_from_db(tab, data_dir)
tree = tab._tree

# Select (in reverse click order, to prove visual ordering wins): the older run row,
# then the newest run's failed-test child, then the newest run row.
tree.topLevelItem(1).setSelected(True)
tree.topLevelItem(0).child(0).setSelected(True)
tree.topLevelItem(0).setSelected(True)

tab.copy_selection_to_clipboard()
lines = QGuiApplication.clipboard().text().splitlines()
assert len(lines) == 3
newest_run_line, failed_test_line, oldest_run_line = lines
newest_columns = newest_run_line.split("\t")
assert newest_columns[2:8] == ["Complete", "1", "1", "0", "2", "put 1.0"] # Status through Version
assert failed_test_line == "tests/test_b.py"
assert oldest_run_line.split("\t")[3] == "1" # older run's pass count


def test_history_tab_copy_empty_selection_is_noop(qtbot, history_run_limit_pref):
"""Copy with nothing selected leaves the clipboard untouched."""
data_dir = get_temp_dir("history_tab_copy_empty")
now = time.time()
with PytestProcessInfoDB(data_dir) as db:
db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.OK, now))

tab = HistoryTab()
qtbot.addWidget(tab)
_update_from_db(tab, data_dir)

QGuiApplication.clipboard().setText("sentinel")
assert tab.selection_as_text() == ""
tab.copy_selection_to_clipboard()
assert QGuiApplication.clipboard().text() == "sentinel"


def test_history_tab_selection_survives_rebuild(qtbot, history_run_limit_pref):
"""Run-row and failed-test selections persist when new records force a rebuild."""
data_dir = get_temp_dir("history_tab_selection_rebuild")
now = time.time()
with PytestProcessInfoDB(data_dir) as db:
db.write(_record("run-1", "tests/test_a.py", PyTestFlyExitCode.TESTS_FAILED, now - 100))
db.write(_record("run-2", "tests/test_a.py", PyTestFlyExitCode.OK, now - 50))

tab = HistoryTab()
qtbot.addWidget(tab)
_update_from_db(tab, data_dir)
tree = tab._tree
tree.topLevelItem(0).setSelected(True) # run-2's row
tree.topLevelItem(1).child(0).setSelected(True) # run-1's failed test

with PytestProcessInfoDB(data_dir) as db:
db.write(_record("run-2", "tests/test_b.py", PyTestFlyExitCode.OK, now))
_update_from_db(tab, data_dir)

assert tree.topLevelItem(0).isSelected() is True
assert tree.topLevelItem(1).isSelected() is False
assert tree.topLevelItem(1).child(0).isSelected() is True
Loading