Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
79805e0
Fix flaky integration tests in test_init.py
m3nu Jan 23, 2026
e519e6e
Test reliability fixes
m3nu Jan 23, 2026
2d9c62e
Fix pythonpath
m3nu Jan 23, 2026
4c295c4
Skip D-Bus calls during tests to fix CI hangs
m3nu Jan 23, 2026
8dabc6b
Add debug logging to diagnose CI hang
m3nu Jan 23, 2026
42d6160
Add more debug logging to init_db fixture and MainWindow
m3nu Jan 23, 2026
01ce6fa
Add debug to test function and fixture yield
m3nu Jan 23, 2026
1badcbc
Add debug logging to init_db teardown
m3nu Jan 23, 2026
14f5f2e
Replace qtbot.waitUntil with simple polling in teardown
m3nu Jan 23, 2026
a9c231e
Remove processEvents from teardown to avoid D-Bus hang
m3nu Jan 23, 2026
25d1526
Add more debug to teardown signal disconnect
m3nu Jan 23, 2026
8566053
Add debug around mock_db.close()
m3nu Jan 23, 2026
b6d496c
Remove processEvents from load_window to avoid D-Bus hang
m3nu Jan 23, 2026
e42baff
Remove debug logging from CI hang investigation
m3nu Jan 23, 2026
1aab60e
Add debug timing to diagnose slow macOS CI tests
m3nu Jan 23, 2026
771a1f9
Skip CoreWLAN WiFi enumeration during tests to fix slow macOS CI
m3nu Jan 23, 2026
be9b024
Add granular debug timing to MainWindow.__init__ for CI investigation
m3nu Jan 23, 2026
3c81730
Skip psutil.process_iter() during tests to fix slow macOS CI
m3nu Jan 23, 2026
82ac76e
Add debug timing inside ArchiveTab.__init__ to find slow line
m3nu Jan 23, 2026
36d7bcc
Add more granular debug timing inside ArchiveTab methods
m3nu Jan 23, 2026
f6d947e
Add timing to final section of populate_from_profile
m3nu Jan 23, 2026
9639622
Fix slow macOS CI tests by mocking DNS lookups
m3nu Jan 23, 2026
4474a5f
Fix DNS mock to handle all getaddrinfo calls, not just AI_CANONNAME
m3nu Jan 23, 2026
1938a08
Re-add CoreWLAN skip during tests to prevent hang on CI
m3nu Jan 23, 2026
a1c6ddd
Fix CI hang: cache FQDN result instead of global DNS mock
m3nu Jan 23, 2026
2f1036d
Revert FQDN caching to isolate hang issue
m3nu Jan 23, 2026
ddab310
Patch _getfqdn specifically during tests to avoid slow DNS lookups
m3nu Jan 23, 2026
d9c87e1
Skip DNS lookup in _getfqdn during tests to fix CI slowness
m3nu Jan 23, 2026
b9c7a73
Add debug output to trace _getfqdn timing and flag state
m3nu Jan 23, 2026
e9ef30a
Add debug points to find actual hang location
m3nu Jan 23, 2026
938845a
Add debug to test_prune_intervals to verify test starts
m3nu Jan 23, 2026
a10f184
Add detailed debug to test_prune_intervals
m3nu Jan 23, 2026
1844e6f
Add debug to fixture teardown to find hang location
m3nu Jan 23, 2026
efe0b98
Add debug to archive_env fixture
m3nu Jan 23, 2026
e61e468
Add debug at very start of init_db fixture
m3nu Jan 23, 2026
65079e6
Disable pytest-qt exception capture to fix macOS hang
m3nu Jan 23, 2026
bcaac8e
Monkey-patch pytest-qt processEvents to prevent macOS hang
m3nu Jan 23, 2026
b5de80a
Remove debug print statements after fixing macOS test hang
m3nu Jan 23, 2026
86262b5
refactor: Replace sys._called_from_test with pytest mocks
m3nu Jan 23, 2026
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
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Vorta is a desktop backup client for macOS and Linux that provides a GUI for Bor

## Common Commands

This project uses `uv` for Python environment and dependency management. All Python commands should be run via `uv run`.

### Testing
```bash
# Run all tests (uses nox to test against multiple Borg versions)
Expand All @@ -20,13 +22,13 @@ make test-unit
make test-integration

# Run tests with a specific Borg version
BORG_VERSION=1.2.4 nox -- tests/unit/test_archives.py
BORG_VERSION=1.2.4 uv run nox -- tests/unit/test_archives.py

# Run a single test file
nox -- tests/unit/test_archives.py
uv run nox -- tests/unit/test_archives.py

# Run a single test
nox -- tests/unit/test_archives.py::test_archive_add
uv run nox -- tests/unit/test_archives.py::test_archive_add
```

### Linting
Expand All @@ -42,7 +44,7 @@ make dist/Vorta.dmg # Create notarized macOS DMG

### Development Install
```bash
pip install -e .
uv sync # Install dependencies and project in editable mode
```

## Architecture
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ quote-style = "preserve"
[tool.pytest.ini_options]
addopts = "-vs"
testpaths = ["tests"]
pythonpath = ["tests/unit"]
qt_default_raising = true
filterwarnings = ["ignore::DeprecationWarning"]

Expand Down
10 changes: 5 additions & 5 deletions src/vorta/views/repo_add_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,10 @@ def run(self):
params = BorgInfoRepoJob.prepare(self.values)
if params['ok']:
self.saveButton.setEnabled(False)
thread = BorgInfoRepoJob(params['cmd'], params)
thread.updated.connect(self._set_status)
thread.result.connect(self.run_result)
self.thread = thread # Needs to be connected to self for tests to work.
self.thread.run()
job = BorgInfoRepoJob(params['cmd'], params)
job.updated.connect(self._set_status)
job.result.connect(self.run_result)
self.thread = job # Keep reference for tests
QApplication.instance().jobs_manager.add_job(job)
else:
self._set_status(params['message'])
44 changes: 43 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import os
import socket
import sys
from unittest.mock import Mock

import pytest
from peewee import SqliteDatabase
Expand All @@ -10,10 +12,50 @@


def pytest_configure(config):
sys._called_from_test = True
pytest._wait_defaults = {'timeout': 20000}
os.environ['LANG'] = 'en' # Ensure we test an English UI

# Disable pytest-qt's processEvents() calls on macOS to prevent hangs.
# See: https://github.com/pytest-dev/pytest-qt/issues/223
if sys.platform == 'darwin':
try:
import pytestqt.plugin

pytestqt.plugin._process_events = lambda: None
except (ImportError, AttributeError):
pass

# Mock D-Bus system bus to prevent hangs in CI (scheduler.py, network_manager.py)
try:
from PyQt6 import QtDBus

_original_system_bus = QtDBus.QDBusConnection.systemBus

def _mock_system_bus():
mock_bus = Mock()
mock_bus.isConnected.return_value = False
return mock_bus

QtDBus.QDBusConnection.systemBus = staticmethod(_mock_system_bus)
except ImportError:
pass

# Mock DNS lookups to prevent timeouts in CI (utils._getfqdn)
_original_getaddrinfo = socket.getaddrinfo
socket.getaddrinfo = lambda *args, **kwargs: []

# Mock WiFi enumeration to prevent hangs on headless CI (utils.get_sorted_wifis)
import vorta.utils

_original_get_network_status_monitor = vorta.utils.get_network_status_monitor

def _mock_get_network_status_monitor():
mock_monitor = Mock()
mock_monitor.get_known_wifis.return_value = []
return mock_monitor

vorta.utils.get_network_status_monitor = _mock_get_network_status_monitor


@pytest.fixture(scope='session')
def qapp(tmpdir_factory):
Expand Down
48 changes: 46 additions & 2 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest
from packaging.version import Version
from peewee import SqliteDatabase
from PyQt6.QtCore import QCoreApplication

import vorta
import vorta.application
Expand All @@ -22,6 +23,34 @@
from vorta.utils import borg_compat
from vorta.views.main_window import ArchiveTab, MainWindow


def disconnect_all(signal):
"""
Disconnect ALL handlers from a Qt signal.
Unlike signal.disconnect() without arguments which only disconnects ONE handler,
this function disconnects all connected handlers by calling disconnect in a loop
until TypeError is raised (indicating no more handlers are connected).
"""
while True:
try:
signal.disconnect()
except TypeError:
# No more handlers connected
break


def all_workers_finished(jobs_manager):
"""
Check if all worker threads have actually exited.
This is more thorough than is_worker_running() which only checks current_job,
because threads may still be alive briefly after current_job is set to None.
"""
for worker in jobs_manager.workers.values():
if worker.is_alive():
return False
return True


models = [
RepoModel,
RepoPassword,
Expand Down Expand Up @@ -175,9 +204,24 @@ def init_db(qapp, qtbot, tmpdir_factory, create_test_repo):

yield

# Teardown: cancel jobs and wait for workers to fully exit
qapp.jobs_manager.cancel_all_jobs()
qapp.backup_finished_event.disconnect()
qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults)

# Wait for all worker threads to actually exit (not just for current_job to be None).
# This is more thorough than is_worker_running() and prevents thread state leakage.
qtbot.waitUntil(lambda: all_workers_finished(qapp.jobs_manager), **pytest._wait_defaults)

# Process any pending events to ensure all queued signals are handled
QCoreApplication.processEvents()

# Disconnect signals after events are processed
disconnect_all(qapp.backup_finished_event)
disconnect_all(qapp.scheduler.schedule_changed)

# Clear the workers dict to prevent accumulation of dead thread references
qapp.jobs_manager.workers.clear()
qapp.jobs_manager.jobs.clear()

mock_db.close()


Expand Down
26 changes: 22 additions & 4 deletions tests/integration/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
from pathlib import PurePath

import pytest
from PyQt6.QtCore import Qt
from PyQt6.QtCore import QCoreApplication, Qt
from PyQt6.QtWidgets import QMessageBox

import vorta.borg
import vorta.utils
import vorta.views.repo_add_dialog
from tests.integration.conftest import all_workers_finished

LONG_PASSWORD = 'long-password-long'
TEST_REPO_NAME = 'TEST - REPONAME'
Expand Down Expand Up @@ -43,9 +44,17 @@ def test_create_repo(qapp, qtbot, monkeypatch, choose_file_dialog, tmpdir):
qtbot.keyClicks(add_repo_window.passwordInput.passwordLineEdit, LONG_PASSWORD)
qtbot.keyClicks(add_repo_window.passwordInput.confirmLineEdit, LONG_PASSWORD)

initial_count = main.repoTab.repoSelector.count()
add_repo_window.run()

qtbot.waitUntil(lambda: main.repoTab.repoSelector.count() == 2, **pytest._wait_defaults)
# Wait for all worker threads to fully exit (more thorough than is_worker_running)
qtbot.waitUntil(lambda: all_workers_finished(qapp.jobs_manager), **pytest._wait_defaults)

# Process pending Qt events to ensure signals are delivered and UI is updated
QCoreApplication.processEvents()

# Wait for the new repo to appear in the selector
qtbot.waitUntil(lambda: main.repoTab.repoSelector.count() == initial_count + 1, **pytest._wait_defaults)

# Check if repo was created in tmpdir
repo_url = (
Expand Down Expand Up @@ -96,7 +105,16 @@ def test_add_existing_repo(qapp, qtbot, monkeypatch, choose_file_dialog):

add_repo_window.run()

# check that repo was added
qtbot.waitUntil(lambda: tab.repoSelector.count() == 1, **pytest._wait_defaults)
# Wait for all worker threads to fully exit (more thorough than is_worker_running)
qtbot.waitUntil(lambda: all_workers_finished(qapp.jobs_manager), **pytest._wait_defaults)

# Process pending Qt events to ensure signals are delivered and UI is updated
QCoreApplication.processEvents()

# Wait for the repo to appear in the selector
# After unlink, count is 1 (only placeholder). After adding repo, count should be 2.
qtbot.waitUntil(lambda: tab.repoSelector.count() == 2, **pytest._wait_defaults)

# check that repo was added correctly
assert vorta.store.models.RepoModel.select().first().url == str(current_repo_path)
assert vorta.store.models.RepoModel.select().first().name == TEST_REPO_NAME
92 changes: 79 additions & 13 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import os
import time
from datetime import datetime as dt

import pytest
from peewee import SqliteDatabase
from test_constants import TEST_SOURCE_DIR, TEST_TEMP_DIR

import vorta
import vorta.application
Expand All @@ -20,6 +22,34 @@
)
from vorta.views.main_window import ArchiveTab, MainWindow


def disconnect_all(signal):
"""
Disconnect ALL handlers from a Qt signal.
Unlike signal.disconnect() without arguments which only disconnects ONE handler,
this function disconnects all connected handlers by calling disconnect in a loop
until TypeError is raised (indicating no more handlers are connected).
"""
while True:
try:
signal.disconnect()
except TypeError:
# No more handlers connected
break


def all_workers_finished(jobs_manager):
"""
Check if all worker threads have actually exited.
This is more thorough than is_worker_running() which only checks current_job,
because threads may still be alive briefly after current_job is set to None.
"""
for worker in jobs_manager.workers.values():
if worker.is_alive():
return False
return True


models = [
RepoModel,
RepoPassword,
Expand All @@ -35,10 +65,13 @@

def load_window(qapp: vorta.application.VortaApp):
"""
Reload the main window of the given application
Used to repopulate fields after loading mock data
Reload the main window of the given application.
Used to repopulate fields after loading mock data.
"""
qapp.main_window.deleteLater()
# Skip QCoreApplication.processEvents() - it can trigger D-Bus operations that hang in CI.
# Use a small sleep instead to allow deleteLater to be processed.
time.sleep(0.1)
del qapp.main_window
qapp.main_window = MainWindow(qapp)

Expand Down Expand Up @@ -89,14 +122,13 @@ def init_db(qapp, qtbot, tmpdir_factory, request):
test_archive1 = ArchiveModel(snapshot_id='99998', name='test-archive1', time=dt(2000, 1, 1, 0, 0), repo=1)
test_archive1.save()

source_dir = SourceFileModel(dir='/tmp/another', repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True)
source_dir = SourceFileModel(dir=TEST_SOURCE_DIR, repo=new_repo, dir_size=100, dir_files_count=18, path_isdir=True)
source_dir.save()

# Disconnect signals before destroying main_window to avoid "deleted object" errors
try:
qapp.scheduler.schedule_changed.disconnect()
except TypeError:
pass
# Disconnect ALL signal handlers before destroying main_window to avoid "deleted object" errors
# Using disconnect_all() instead of disconnect() to ensure ALL handlers are removed,
# not just one (which can leave stale connections from previous tests)
disconnect_all(qapp.scheduler.schedule_changed)

# Reload the window to apply the mock data
# If this test has the `window_load` fixture,
Expand All @@ -106,10 +138,28 @@ def init_db(qapp, qtbot, tmpdir_factory, request):

yield

# Teardown: cancel jobs and disconnect ALL signal handlers to prevent state leakage
qapp.jobs_manager.cancel_all_jobs()
qapp.backup_finished_event.disconnect()
qapp.scheduler.schedule_changed.disconnect()
qtbot.waitUntil(lambda: not qapp.jobs_manager.is_worker_running(), **pytest._wait_defaults)

# Wait for all worker threads to actually exit (not just for current_job to be None).
# Use simple polling instead of qtbot.waitUntil to avoid Qt event loop hangs in CI.
# qtbot.waitUntil processes Qt events while waiting, which can trigger D-Bus operations.
timeout = pytest._wait_defaults.get('timeout', 20000) / 1000 # Convert ms to seconds
start = time.time()
while not all_workers_finished(qapp.jobs_manager):
if time.time() - start > timeout:
break
time.sleep(0.1)

# Skip QCoreApplication.processEvents() - it can trigger D-Bus operations that hang in CI

# Disconnect signals
disconnect_all(qapp.backup_finished_event)
disconnect_all(qapp.scheduler.schedule_changed)

# Clear the workers dict to prevent accumulation of dead thread references
qapp.jobs_manager.workers.clear()
qapp.jobs_manager.jobs.clear()
mock_db.close()


Expand All @@ -123,19 +173,35 @@ def open(self, func):
func()

def selectedFiles(self):
return ['/tmp']
return [TEST_TEMP_DIR]

return MockFileDialog


@pytest.fixture
def borg_json_output():
"""
Returns a function to read borg JSON output files.
Opens real file handles (required for os.set_blocking and select.select),
but tracks them for cleanup when the fixture is torn down.
"""
open_files = []

def _read_json(subcommand):
stdout = open(f'tests/unit/borg_json_output/{subcommand}_stdout.json')
stderr = open(f'tests/unit/borg_json_output/{subcommand}_stderr.json')
open_files.append(stdout)
open_files.append(stderr)
return stdout, stderr

return _read_json
yield _read_json

# Clean up all opened files when the fixture is torn down
for f in open_files:
try:
f.close()
except Exception:
pass


@pytest.fixture
Expand Down
Loading
Loading