Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
187 changes: 187 additions & 0 deletions qubesappmenus/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import os
import tempfile
import sys
import types

import unittest
import unittest.mock
Expand All @@ -35,6 +36,28 @@
import qubesappmenus
import qubesappmenus.receive

try:
import qubesappmenusext
except ModuleNotFoundError as e:
if e.name.split('.')[0] != 'qubes':
raise
qubes_module = types.ModuleType('qubes')
qubes_ext_module = types.ModuleType('qubes.ext')
qubes_utils_module = types.ModuleType('qubes.utils')

def handler(*args, **kwargs):
# Only decorator registration is needed for these unit tests.
return lambda func: func

qubes_ext_module.handler = handler
qubes_ext_module.Extension = object
qubes_utils_module.sanitize_stderr_for_log = lambda stderr: stderr
qubes_module.ext = qubes_ext_module
sys.modules['qubes'] = qubes_module
sys.modules['qubes.ext'] = qubes_ext_module
sys.modules['qubes.utils'] = qubes_utils_module
import qubesappmenusext

class Label(object):
def __init__(self, index, color, name):
self.index = index
Expand All @@ -48,6 +71,12 @@ class TestApp(object):
def __init__(self):
self.domains = {}

class TestVMM(object):
offline_mode = False

class TestAppmenusExtApp(object):
vmm = TestVMM()

class TestFeatures(dict):

def __init__(self, vm, **kwargs) -> None:
Expand Down Expand Up @@ -95,6 +124,18 @@ def icon(self):
return 'servicevm-' + raw_icon_name
return 'appvm-' + raw_icon_name

class TestAppmenusExtVM(object):
# pylint: disable=too-few-public-methods
app = TestAppmenusExtApp()

def __init__(self, template_for_dispvms=False,
appmenus_dispvm=False):
self.name = 'test-ext-vm'
self.template_for_dispvms = template_for_dispvms
self.features = TestFeatures(self)
if appmenus_dispvm:
self.features['appmenus-dispvm'] = '1'

VMPREFIX = 'test-'

class TC_00_Appmenus(unittest.TestCase):
Expand Down Expand Up @@ -146,6 +187,59 @@ def assertPathNotExists(self, path):
if os.path.exists(path):
self.fail("Path {} exists while it should not".format(path))

def assertUpdateScheduled(self, callback, should_schedule):
ext = qubesappmenusext.AppmenusExtension()
ext.collect_done_tasks = unittest.mock.Mock()
ext.update_appmenus = unittest.mock.Mock(return_value='update-task')

@marmarek marmarek Jun 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found it - it's here. The AppmenusExtension() module (and other core-admin extensions) have a trap: they are singletons. The object you create (and modify) here, will survive across all tests. If you need to mockup some of its methods, use mock.patch.object, to undo the change after the test.

with unittest.mock.patch('asyncio.ensure_future') as ensure_future:
callback(ext)
if should_schedule:
ext.collect_done_tasks.assert_called_once()
ext.update_appmenus.assert_called_once()
ensure_future.assert_called_once_with('update-task')
else:
ext.collect_done_tasks.assert_not_called()
ext.update_appmenus.assert_not_called()
ensure_future.assert_not_called()

def test_000_appmenus_ext_template_for_dispvms_needs_feature(self):
vm = TestAppmenusExtVM(
template_for_dispvms=True,
appmenus_dispvm=False)

self.assertUpdateScheduled(
lambda ext: ext.template_for_dispvms_setter(vm, None),
False)

vm.features['appmenus-dispvm'] = '1'
self.assertUpdateScheduled(
lambda ext: ext.template_for_dispvms_setter(vm, None),
True)

def test_000_appmenus_ext_dispvm_feature_needs_property(self):
vm = TestAppmenusExtVM(
template_for_dispvms=False,
appmenus_dispvm=True)

self.assertUpdateScheduled(
lambda ext: ext.on_feature_set_appmenus_dispvm(
vm, None, 'appmenus-dispvm', '1'),
False)
self.assertUpdateScheduled(
lambda ext: ext.on_feature_del_appmenus_dispvm(
vm, None, 'appmenus-dispvm'),
False)

vm.template_for_dispvms = True
self.assertUpdateScheduled(
lambda ext: ext.on_feature_set_appmenus_dispvm(
vm, None, 'appmenus-dispvm', '1'),
True)
self.assertUpdateScheduled(
lambda ext: ext.on_feature_del_appmenus_dispvm(
vm, None, 'appmenus-dispvm'),
True)


def test_000_templates_dirs(self):
self.assertEqual(
Expand Down Expand Up @@ -392,6 +486,99 @@ def test_007_created_dispvm(self):
self.assertIn(b'X-Qubes-NonDispvmExec=', content)
self.assertNotIn(b'X-Qubes-DispvmExec=', content)

def test_008_appmenus_update_when_template_for_dispvms_enabled(self):
"""Dispvm menu entries appear after template_for_dispvms is set True.

Regression test for QubesOS/qubes-issues#9194: setting
template_for_dispvms on a VM must regenerate menus so the
"Disposable:" submenu shows up.
"""
tpl = TestVM('test-inst-tpl',
klass='TemplateVM',
virt_mode='pvh',
updateable=True,
provides_network=False,
label=self.app.labels[1])
self.ext.appmenus_init(tpl)
appvm = TestVM('test-inst-dvm',
klass='AppVM',
template=tpl,
virt_mode='pvh',
updateable=False,
provides_network=False,
template_for_dispvms=False,
label=self.app.labels[1])
self.ext.appmenus_init(appvm)
with open(os.path.join(self.ext.templates_dirs(tpl)[0],
'evince.desktop'), 'wb') as f:
f.write(importlib.resources.files(
__package__).joinpath(
'test-data/evince.desktop.template').read_bytes())

# First create without dispvm — no "Disposable:" directory entry
self.ext.appmenus_create(appvm, refresh_cache=False)
appmenus_dir = self.ext.appmenus_dir(appvm)
dispvm_dir = os.path.join(appmenus_dir,
'qubes-dispvm-directory_test_dinst_ddvm.directory')
self.assertPathNotExists(dispvm_dir)

# Now simulate setting template_for_dispvms=True + appmenus-dispvm
appvm.template_for_dispvms = True
appvm.features['appmenus-dispvm'] = '1'
self.ext.appmenus_create(appvm, refresh_cache=False)

self.assertPathExists(dispvm_dir)
dispvm_evince = os.path.join(appmenus_dir,
'org.qubes-os.dispvm._test_dinst_ddvm.evince.desktop')
self.assertPathExists(dispvm_evince)

def test_009_appmenus_update_when_template_for_dispvms_disabled(self):
"""Dispvm menu entries are removed after template_for_dispvms is cleared.

Regression test for QubesOS/qubes-issues#9194: clearing
template_for_dispvms must regenerate menus so stale
"Disposable:" entries are removed.
"""
tpl = TestVM('test-inst-tpl2',
klass='TemplateVM',
virt_mode='pvh',
updateable=True,
provides_network=False,
label=self.app.labels[1])
self.ext.appmenus_init(tpl)
appvm = TestVM('test-inst-dvm2',
klass='AppVM',
template=tpl,
virt_mode='pvh',
updateable=False,
provides_network=False,
template_for_dispvms=True,
label=self.app.labels[1])
appvm.features['appmenus-dispvm'] = '1'
self.ext.appmenus_init(appvm)
with open(os.path.join(self.ext.templates_dirs(tpl)[0],
'evince.desktop'), 'wb') as f:
f.write(importlib.resources.files(
__package__).joinpath(
'test-data/evince.desktop.template').read_bytes())

# First create as dispvm template — "Disposable:" entries should exist
self.ext.appmenus_create(appvm, refresh_cache=False)
appmenus_dir = self.ext.appmenus_dir(appvm)
dispvm_dir = os.path.join(appmenus_dir,
'qubes-dispvm-directory_test_dinst_ddvm2.directory')
self.assertPathExists(dispvm_dir)

# Simulate clearing template_for_dispvms
appvm.template_for_dispvms = False
del appvm.features['appmenus-dispvm']
self.ext.appmenus_create(appvm, refresh_cache=False)

self.assertPathNotExists(dispvm_dir)
dispvm_evince = os.path.join(appmenus_dir,
'org.qubes-os.dispvm._test_dinst_ddvm2.evince.desktop')
self.assertPathNotExists(dispvm_evince)

def test_100_get_appmenus(self):
self.maxDiff = None
def _run(service, **kwargs):
Expand Down
16 changes: 15 additions & 1 deletion qubesappmenusext/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@ def provides_network_setter(self, vm, event, **kwargs):
self.vm_tasks[vm.name].append(
asyncio.ensure_future(self.update_appmenus(vm)))

@qubes.ext.handler('property-set:template_for_dispvms')
def template_for_dispvms_setter(self, vm, event, **kwargs):

@marmarek marmarek Jun 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disposable-related menu entries are supposed to be created only if appmenus-dispvm feature is also set. There is already handling for setting this feature, so if one first sets template_for_dispvms property and then appmenus-dispvm feature, it will regenerate correctly. But if one first sets the feature, and only then the property, indeed regenerating is missing.

But, with the current version of this PR, when the user sets both the feature and the property (as qubes-vm-settings application does), regenerating will happen twice, in parallel, making QubesOS/qubes-issues#10908 worse. This should ensure regenerating is done only when expected entries change. This means:

  • here - only if appmenus-dispvm feature is already set
  • in the appmenus-dispvm feature handlers (both of them) - if template_for_dispvms property is True

if vm.app.vmm.offline_mode:
return
if not vm.features.get('appmenus-dispvm', False):
return
self.collect_done_tasks(vm)
self.vm_tasks[vm.name].append(
asyncio.ensure_future(self.update_appmenus(vm)))

@qubes.ext.handler('property-set:guivm')
def provides_network_setter(self, vm, event, name, newvalue, oldvalue=None):
if vm.app.vmm.offline_mode:
Expand All @@ -186,6 +196,8 @@ def provides_network_setter(self, vm, event, name, newvalue, oldvalue=None):
def on_feature_del_appmenus_dispvm(self, vm, event, feature):
if vm.app.vmm.offline_mode:
return
if not getattr(vm, 'template_for_dispvms', False):
return
self.collect_done_tasks(vm)
self.vm_tasks[vm.name].append(
asyncio.ensure_future(self.update_appmenus(vm)))
Expand All @@ -195,12 +207,14 @@ def on_feature_set_appmenus_dispvm(self, vm, event, feature,
value, oldvalue=None):
if vm.app.vmm.offline_mode:
return
if not getattr(vm, 'template_for_dispvms', False):
return
self.collect_done_tasks(vm)
self.vm_tasks[vm.name].append(
asyncio.ensure_future(self.update_appmenus(vm)))

@qubes.ext.handler('domain-feature-set:menu-items')
def on_feature_set_appmenus_dispvm(self, vm, event, feature,
def on_feature_set_menu_items(self, vm, event, feature,
value, oldvalue=None):
if vm.app.vmm.offline_mode:
return
Expand Down