Ctrl K
ComunidadGuías…

tests/TestCleanerML.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later.  See the COPYING file in the top-level directory.

"""
Test cases for module CleanerML
"""

# standard imports
import os
import shutil
import sys
from unittest import mock

# first party imports
import bleachbit
from tests import common
from bleachbit import Cleaner
from bleachbit.CleanerML import (
    CleanerML,
    boolstr_to_bool,
    create_pot,
    default_vars,
    list_cleanerml_files,
    load_cleaners,
    pot_fragment)
from bleachbit.General import os_match as general_os_match
from bleachbit.Options import options
from bleachbit.Process import ProcessInfo, process_cache


class CleanerMLTestCase(common.BleachbitTestCase):
    """Test cases for CleanerML"""

    def run_all(self, xmlcleaner, really_delete):
        """Helper function to execute all options in a cleaner"""
        for (option_id, __name) in xmlcleaner.cleaner.get_options():
            for cmd in xmlcleaner.cleaner.get_commands(option_id):
                for result in cmd.execute(really_delete):
                    common.validate_result(self, result, really_delete)

    def _get_xmlcleaner(self):
        """Helper for CleanerML*()"""
        xmlcleaner = CleanerML("doc/example_cleaner.xml")
        self.assertIsInstance(xmlcleaner, CleanerML)
        self.assertIsInstance(xmlcleaner.cleaner, Cleaner.Cleaner)
        return xmlcleaner

    def test_CleanerML(self):
        """Unit test for class CleanerML"""
        xmlcleaner = self._get_xmlcleaner()
        # preview
        self.run_all(xmlcleaner, False)

    @common.skipUnlessDestructive
    def test_CleanerML_destructive(self):
        """Unit test the destructive parts of class CleanerML"""
        xmlcleaner = self._get_xmlcleaner()
        # really delete
        self.run_all(xmlcleaner, True)

    def test_android_studio_macos_vars(self):
        """On macOS, the macos and unix values must not repeat a path"""
        with mock.patch(
                'bleachbit.CleanerML.general_os_match',
                lambda os_str, _platform: general_os_match(os_str, 'darwin')):
            xmlc = CleanerML('cleaners/android_studio.xml')
        for var_name in ('gradle_home', 'android_home', 'as_cache', 'as_logs'):
            values = xmlc.vars.get(var_name, [])
            self.assertEqual(len(values), len(set(values)), var_name)

    def test_boolstr_to_bool(self):
        """Unit test for boolstr_to_bool()"""
        tests = [('True', True),
                 ('False', False)]

        for (arg, output) in tests:
            self.assertEqual(boolstr_to_bool(arg), output)
            self.assertEqual(boolstr_to_bool(arg.lower()), output)
            self.assertEqual(boolstr_to_bool(arg.upper()), output)

    def test_claude_session_keeps_memory(self):
        """Claude > Session keeps the per-project auto-memory"""
        project = os.path.join(self.tempdir, '.claude',
                               'projects', '-tmp-proj')
        transcript = os.path.join(project, 'abc.jsonl')
        memory = os.path.join(project, 'memory', 'MEMORY.md')
        common.touch_file(transcript)
        common.touch_file(memory)
        env = {'HOME': self.tempdir, 'USERPROFILE': self.tempdir}
        with mock.patch.dict(os.environ, env):
            cleaner = CleanerML('cleaners/claude.xml').get_cleaner()
        paths = [path for option_id, action in cleaner.actions
                 if option_id == 'session' for path in action.get_paths()]
        self.assertIn(transcript, paths)
        self.assertNotIn(memory, paths)
        self.assertNotIn(os.path.dirname(memory), paths)

    def test_claude_memory(self):
        """Claude > Auto memory deletes only the per-project auto-memory"""
        home = self.mkdtemp(prefix='bleachbit-claude-memory')
        project = os.path.join(home, '.claude', 'projects', '-tmp-proj')
        common.touch_file(os.path.join(project, 'abc.jsonl'))
        memory = os.path.join(project, 'memory', 'MEMORY.md')
        common.touch_file(memory)
        env = {'HOME': home, 'USERPROFILE': home}
        with mock.patch.dict(os.environ, env):
            cleaner = CleanerML('cleaners/claude.xml').get_cleaner()
        paths = [path for option_id, action in cleaner.actions
                 if option_id == 'memory' for path in action.get_paths()]
        self.assertEqual([memory, os.path.dirname(memory)], paths)

    def test_create_pot(self):
        """Unit test for create_pot()"""
        os.chdir('po')
        try:
            create_pot()
        finally:
            os.chdir('..')

    def test_default_vars_windows_system(self):
        """Unit test WindowsSystem in default_vars()"""
        env = {
            'WinDir': r'C:\Windows',
            'ProgramFiles': r'C:\Program Files (x86)',
            'ProgramW6432': r'C:\Program Files',
        }
        with mock.patch('bleachbit.CleanerML.IS_WINDOWS', True), \
                mock.patch.dict(os.environ, env, clear=True), \
                mock.patch('bleachbit.Windows.ARCH_BITS', 32):
            variables = default_vars()
        self.assertEqual(
            [r'C:\Windows\Sysnative', r'C:\Windows\SysWOW64'],
            variables['WindowsSystem'])

    @common.skipUnlessLinux
    def test_dnf_running(self):
        """The DNF and yum cleaners notice a running dnf or yum"""
        for path in ('cleaners/dnf.xml', 'cleaners/yum.xml'):
            cleaner = CleanerML(path).get_cleaner()
            for name in ('dnf', 'dnf4', 'dnf5', 'yum'):
                procs = (ProcessInfo(1234, name, False),)
                with mock.patch('bleachbit.Process.process_cache.get', return_value=procs):
                    self.assertTrue(cleaner.is_process_running(), (path, name))

    @common.skipUnlessLinux
    def test_geary_paths_listed_once(self):
        """Geary lists each path once under the default XDG directories"""
        env = {'HOME': self.tempdir,
               'XDG_CACHE_HOME': os.path.join(self.tempdir, '.cache'),
               'XDG_DATA_HOME': os.path.join(self.tempdir, '.local', 'share')}
        common.touch_file(os.path.join(
            env['XDG_CACHE_HOME'], 'geary', 'a.bin'))
        common.touch_file(os.path.join(
            env['XDG_DATA_HOME'], 'geary', 'acct', 'attachments', 'x.bin'))
        with mock.patch.dict(os.environ, env):
            cleaner = CleanerML('cleaners/geary.xml').get_cleaner()
        for option_id in ('cache', 'attachments'):
            with self.subTest(option_id=option_id):
                paths = [path for oid, action in cleaner.actions
                         if oid == option_id for path in action.get_paths()]
                self.assertTrue(paths)
                self.assertEqual(len(paths), len(set(paths)))

    def test_list_cleanerml_files(self):
        """Unit test for list_cleanerml_files()"""
        for pathname in list_cleanerml_files():
            self.assertExists(pathname)

    @common.skipIfWindows
    def test_list_cleanerml_files_vanished(self):
        """list_cleanerml_files() skips a cleaner deleted after listdir()

        The world-writable check is POSIX only, so this race does not exist
        on Windows.
        """
        dirname = self.mkdtemp(prefix='bleachbit-cleanerml-vanished')
        real_fn = os.path.join(dirname, 'real.xml')
        self.write_file(real_fn, contents=b'<cleaner id="test"/>')
        os.chmod(real_fn, 0o600)
        ghost_fn = os.path.join(dirname, 'ghost.xml')
        with mock.patch('bleachbit.CleanerML.listdir',
                        return_value=iter([ghost_fn, real_fn])):
            self.assertEqual(list(list_cleanerml_files()), [real_fn])

    def test_load_cleaners(self):
        """Unit test for load_cleaners()"""
        # normal
        list(load_cleaners())

        # should catch exception with invalid XML
        pcd = bleachbit.personal_cleaners_dir
        bleachbit.personal_cleaners_dir = self.mkdtemp(
            prefix='bleachbit-cleanerml-load')
        self.write_file(os.path.join(bleachbit.personal_cleaners_dir, 'invalid.xml'),
                        contents=b'<xml><broken>')
        list(load_cleaners())
        shutil.rmtree(bleachbit.personal_cleaners_dir)
        bleachbit.personal_cleaners_dir = pcd

    def test_load_cleaners_yields_by_time(self):
        """load_cleaners() yields on a deadline, not once per file"""
        with mock.patch.object(bleachbit.CleanerML, '_YIELD_SECONDS', 3600):
            self.assertEqual(list(load_cleaners()), [])
        with mock.patch.object(bleachbit.CleanerML, '_YIELD_SECONDS', 0):
            self.assertEqual(len(list(load_cleaners())),
                             len(list(list_cleanerml_files())))

    def test_load_cleaners_same_id(self):
        """A personal cleaner does not replace a bundled one with the same id

        Swap the directory names so the result cannot come from sorting paths.
        """
        xml_str = ('<cleaner id="test_same_id"><label>{label}</label>'
                   '<option id="o"><label>O</label><description>D</description>'
                   '<action command="delete" search="file" path="{base}/nonexistent"/>'
                   '</option></cleaner>')
        self.addCleanup(Cleaner.backends.pop, 'test_same_id', None)
        for personal_name, system_name in (('a', 'b'), ('b', 'a')):
            with self.subTest(personal=personal_name, system=system_name):
                base = self.mkdtemp(prefix='bleachbit-cleanerml-same-id')
                dirs = {'personal': os.path.join(base, personal_name),
                        'system': os.path.join(base, system_name)}
                for label, dirname in dirs.items():
                    os.mkdir(dirname, 0o700)
                    fn = os.path.join(dirname, 'same_id.xml')
                    self.write_file(fn, text=xml_str.format(
                        label=label, base=base))
                    os.chmod(fn, 0o600)
                with mock.patch.multiple(bleachbit,
                                         personal_cleaners_dir=dirs['personal'],
                                         system_cleaners_dir=dirs['system'],
                                         local_cleaners_dir=None), \
                        self.assertLogs('bleachbit.CleanerML', level='WARNING'):
                    list(load_cleaners())
                self.assertEqual(
                    'system', Cleaner.backends['test_same_id'].name)

    def test_load_cleaners_invalid_utf8(self):
        """Unit test for load_cleaners() with invalid UTF-8 encoding"""
        pcd = bleachbit.personal_cleaners_dir
        bleachbit.personal_cleaners_dir = self.mkdtemp(
            prefix='bleachbit-cleanerml-utf8')
        self.write_file(os.path.join(bleachbit.personal_cleaners_dir, 'broken_encoding.xml'),
                        contents=b'<cleaner id="poison">\n\xff\xfe\xfd Broken\n')
        list(load_cleaners())
        shutil.rmtree(bleachbit.personal_cleaners_dir)
        bleachbit.personal_cleaners_dir = pcd

    @common.skipUnlessLinux
    def test_load_cleaners_twice_localizations(self):
        """Reloading cleaners does not add the localization rules again"""
        from bleachbit import Unix
        tempdir = self.mkdtemp(prefix='bleachbit-cleanerml-loc')
        os.makedirs(os.path.join(tempdir, 'share', 'locale', 'fr'))
        cleaners_dir = os.path.join(tempdir, 'cleaners')
        os.mkdir(cleaners_dir)
        self.write_file(os.path.join(cleaners_dir, 'loc.xml'), text=(
            '<cleaner id="loctest" os="linux"><label>x</label><localizations>'
            f'<path location="{tempdir}/share"><path location="locale" filter="*"/></path>'
            '</localizations></cleaner>'))
        with mock.patch.object(bleachbit, 'personal_cleaners_dir', cleaners_dir), \
                mock.patch.object(bleachbit, 'local_cleaners_dir', None), \
                mock.patch.object(bleachbit, 'system_cleaners_dir', None), \
                mock.patch.object(Unix, 'locales', Unix.Locales()), \
                mock.patch.dict(Cleaner.backends):
            list(load_cleaners())
            list(load_cleaners())
            # pylint: disable-next=protected-access
            self.assertEqual(len(Unix.locales._paths.children), 1)
            paths = list(Unix.locales.localization_paths(['en']))
        self.assertEqual(
            paths, [os.path.join(tempdir, 'share', 'locale', 'fr')])

    def test_untrusted_process_action(self):
        """A process action is ignored for an untrusted cleaner"""
        xml_str = (
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<cleaner id="test_untrusted">\n'
            '  <label>Test</label>\n'
            '  <description>Test</description>\n'
            '  <option id="opt">\n'
            '    <label>Opt</label>\n'
            '    <description>Opt</description>\n'
            '    <action command="delete" search="file" path="test-does-not-exist"/>\n'
            '    <action command="process" cmd="calc.exe"/>\n'
            '    <action command="process.terminate" name="calc.exe"/>\n'
            '  </option>\n'
            '</cleaner>\n')
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-trust'),
                          'planted.xml')
        self.write_file(fn, text=xml_str)

        def action_classes(cleaner):
            return [a.__class__.__name__ for (_option_id, a) in cleaner.actions]

        trusted = action_classes(CleanerML(fn, trusted=True).cleaner)
        self.assertIn('Process', trusted)
        self.assertIn('ProcessTerminate', trusted)

        # The delete action stays; only the process actions are dropped
        untrusted = action_classes(CleanerML(fn, trusted=False).cleaner)
        self.assertNotIn('Process', untrusted)
        self.assertNotIn('ProcessTerminate', untrusted)
        self.assertIn('Delete', untrusted)

    def test_untrusted_winreg_action_allowed(self):
        """A winreg action is kept even for an untrusted cleaner"""
        xml_str = (
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<cleaner id="test_untrusted_winreg">\n'
            '  <label>Test</label>\n'
            '  <description>Test</description>\n'
            '  <option id="opt">\n'
            '    <label>Opt</label>\n'
            '    <description>Opt</description>\n'
            '    <action command="delete" search="file" path="test-does-not-exist"/>\n'
            '    <action command="winreg" path="HKCU\\Software\\BleachBitTest"/>\n'
            '  </option>\n'
            '</cleaner>\n')
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-trust'),
                          'planted_winreg.xml')
        self.write_file(fn, text=xml_str)

        def action_classes(cleaner):
            return [a.__class__.__name__ for (_option_id, a) in cleaner.actions]

        for trusted in (True, False):
            actions = action_classes(CleanerML(fn, trusted=trusted).cleaner)
            self.assertIn('Winreg', actions)
            self.assertIn('Delete', actions)

    def test_untrusted_actions_warn_once_per_file(self):
        """Ignored actions are summarized in one warning, not one per action"""
        actions = '\n'.join(
            f'    <action command="process" cmd="calc{i}.exe"/>'
            for i in range(10))
        xml_str = (
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<cleaner id="test_untrusted_many">\n'
            '  <label>Test</label>\n'
            '  <description>Test</description>\n'
            '  <option id="opt">\n'
            '    <label>Opt</label>\n'
            '    <description>Opt</description>\n'
            f'{actions}\n'
            '  </option>\n'
            '</cleaner>\n')
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-trust-log'),
                          'planted_many.xml')
        self.write_file(fn, text=xml_str)

        with self.assertLogs('bleachbit.CleanerML', level='WARNING') as cm:
            CleanerML(fn, trusted=False)
        self.assertEqual(len(cm.output), 1)
        self.assertIn("'process'", cm.output[0])

    def test_is_trusted_cleaner(self):
        """Files shipped next to the application are trusted"""
        from bleachbit.CleanerML import is_trusted_cleaner
        system_dir = bleachbit.system_cleaners_dir
        self.assertTrue(is_trusted_cleaner(
            os.path.join(system_dir, 'example.xml')))
        self.assertFalse(is_trusted_cleaner(
            os.path.join(bleachbit.personal_cleaners_dir, 'example.xml')))

    def test_is_trusted_cleaner_portable(self):
        """In portable mode the personal cleaners dir is the local one"""
        from bleachbit.CleanerML import is_trusted_cleaner
        app_dir = self.mkdtemp(prefix='bleachbit-portable')
        cleaners_dir = os.path.join(app_dir, 'cleaners')
        with mock.patch.multiple(
                'bleachbit',
                local_cleaners_dir=cleaners_dir,
                personal_cleaners_dir=cleaners_dir,
                system_cleaners_dir=os.path.join(app_dir, 'share', 'cleaners')):
            self.assertTrue(is_trusted_cleaner(
                os.path.join(cleaners_dir, 'winapp2.ini')))
            self.assertFalse(is_trusted_cleaner(
                os.path.join(app_dir, 'elsewhere', 'winapp2.ini')))

    def test_rejects_dtd(self):
        """A CleanerML file with a DTD is rejected (entity-expansion defense)"""
        xml_str = (
            '<?xml version="1.0"?>\n'
            '<!DOCTYPE cleaner [ <!ENTITY x "y"> ]>\n'
            '<cleaner id="dtd_test">\n'
            '  <label>Test</label>\n'
            '  <description>Test</description>\n'
            '</cleaner>\n')
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-dtd'),
                          'dtd.xml')
        self.write_file(fn, text=xml_str)
        xmlcleaner = CleanerML(fn)
        self.assertFalse(xmlcleaner.cleaner.is_usable())

    def test_nvalid_utf8(self):
        """Test CleanerML() with invalid UTF-8 encoding

        It should fail gracefully.
        """
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-utf8'),
                          'broken.xml')
        self.write_file(fn, contents=b'<cleaner id="poison">\n\xff\xfe\xfd\n')
        xmlcleaner = CleanerML(fn)
        self.assertIsInstance(xmlcleaner, CleanerML)
        self.assertFalse(xmlcleaner.cleaner.is_usable())

    def test_utf8_non_ascii(self):
        """Test CleanerML() with UTF-8 non-ASCII text

        It should load successfully.
        """
        xml_str = """<?xml version="1.0" encoding="UTF-8"?>
<cleaner id="test_utf8">
    <label>Test</label>
    <!-- 中文注释 -->
    <option id="opt">
        <label>测试标签</label>
        <description>测试描述</description>
        <action search="file" command="delete" path="C:\\中文路径\\file.txt"/>
    </option>
</cleaner>
"""
        fn = os.path.join(self.mkdtemp(prefix='bleachbit-cleanerml-utf8'),
                          'utf8.xml')
        self.write_file(fn, contents=xml_str.encode('utf-8'))
        xmlcleaner = CleanerML(fn)
        self.assertIsInstance(xmlcleaner, CleanerML)
        self.assertTrue(xmlcleaner.cleaner.is_usable())
        self.assertEqual('测试标签', xmlcleaner.cleaner.options['opt'][0])
        self.assertEqual('测试描述',
                         xmlcleaner.cleaner.options['opt'][1])
        commands = list(xmlcleaner.cleaner.get_commands('opt'))
        self.assertEqual(0, len(commands))

    def test_os_match(self):
        """Unit test for os_match"""
        xmlcleaner = CleanerML("doc/example_cleaner.xml")

        # blank always matches
        self.assertTrue(xmlcleaner.os_match(""))

        # as Linux
        self.assertFalse(xmlcleaner.os_match('windows', 'linux'))
        self.assertTrue(xmlcleaner.os_match('linux', 'linux'))
        self.assertTrue(xmlcleaner.os_match('unix', 'linux'))

        # as Windows
        self.assertFalse(xmlcleaner.os_match('linux', 'win32'))
        self.assertFalse(xmlcleaner.os_match('unix', 'win32'))
        self.assertTrue(xmlcleaner.os_match('windows', 'win32'))

        # as macOS (canonical name)
        self.assertTrue(xmlcleaner.os_match('macos', 'darwin'))
        self.assertTrue(xmlcleaner.os_match('bsd', 'darwin'))
        self.assertTrue(xmlcleaner.os_match('unix', 'darwin'))
        self.assertFalse(xmlcleaner.os_match('linux', 'darwin'))
        self.assertFalse(xmlcleaner.os_match('windows', 'darwin'))

        # as FreeBSD
        self.assertTrue(xmlcleaner.os_match('unix', 'freebsd'))
        self.assertTrue(xmlcleaner.os_match('bsd', 'freebsd'))
        self.assertTrue(xmlcleaner.os_match('freebsd', 'freebsd'))
        self.assertFalse(xmlcleaner.os_match('linux', 'freebsd'))
        self.assertFalse(xmlcleaner.os_match('windows', 'freebsd'))

        # "darwin" is accepted as a deprecated alias for "macos"
        with self.assertLogs('bleachbit.General', level='WARNING'):
            self.assertTrue(xmlcleaner.os_match('darwin', 'darwin'))

        # comma-separated list with negation (positive tokens OR'd, negative tokens excluded)
        cases = [
            ('unix,!macos', 'linux', True),
            ('unix,!macos', 'freebsd', True),
            ('unix,!macos', 'darwin', False),
            ('unix,!macos', 'win32', False),
            ('!macos', 'win32', True),
            ('linux , unix', 'linux', True),
            ('!macos', 'darwin', False),
            ('linux,freebsd', 'linux', True),
            ('linux,freebsd', 'freebsd', True),
            ('linux,freebsd', 'darwin', False),
            ('linux,freebsd', 'win32', False),
            ('!macos,!windows', 'linux', True),
            ('!macos,!windows', 'darwin', False),
            ('!macos,!windows', 'win32', False),
        ]
        for os_attr, platform, expected in cases:
            with self.subTest(os=os_attr, platform=platform):
                self.assertEqual(expected,
                                 xmlcleaner.os_match(os_attr, platform))

        # unrecognized tokens log a warning and fail closed: a positive
        # token is a non-match and a negated token disqualifies
        unknown_cases = [
            ('posix', 'linux', False),
            ('posix', 'win32', False),
            ('!posix', 'win32', False),
            ('!hal9000', 'linux', False),
            ('linux,hal9000', 'linux', True),
            ('windows,!hal9000', 'win32', False),
        ]
        for os_attr, platform, expected in unknown_cases:
            with self.subTest(os=os_attr, platform=platform):
                with self.assertLogs('bleachbit.General', level='WARNING'):
                    self.assertEqual(expected,
                                     xmlcleaner.os_match(os_attr, platform))

        # as unknown operating system
        with self.assertRaisesRegex(RuntimeError, 'Unknown operating system: hal9000'):
            xmlcleaner.os_match('linux', 'hal9000')
        with self.assertRaisesRegex(RuntimeError, 'Unknown operating system: hal9000'):
            xmlcleaner.os_match('!macos', 'hal9000')

    def test_option_os_filter(self):
        """Unit test for <option os="..."> filtering

        An option with an os attribute that does not match the current
        platform should not be registered on the cleaner.
        """
        xml_str = f"""<?xml version="1.0" encoding="UTF-8"?>
<cleaner id="test_option_os">
    <label>Test</label>
    <option id="always">
        <label>Always</label>
        <description>Delete the files</description>
        <action search="file" command="delete" path="{self.tempdir}/always.log"/>
    </option>
    <option id="windows_only" os="windows">
        <label>Windows only</label>
        <description>Delete the files</description>
        <action search="file" command="delete" path="{self.tempdir}/windows.log"/>
    </option>
    <option id="linux_only" os="linux">
        <label>Linux only</label>
        <description>Delete the files</description>
        <action search="file" command="delete" path="{self.tempdir}/linux.log"/>
    </option>
    <option id="mac_only" os="macos">
        <label>macOS only</label>
        <description>Delete the files</description>
        <action search="file" command="delete" path="{self.tempdir}/mac.log"/>
    </option>
</cleaner>
"""
        cml_path = os.path.join(self.tempdir, 'test_option_os.xml')
        self.write_file(cml_path, xml_str.encode(sys.getdefaultencoding()))

        xmlc = CleanerML(cml_path)
        # The unfiltered option is always present.
        self.assertIn('always', xmlc.cleaner.options)
        # The remaining options are conditionally available.
        if bleachbit.IS_LINUX:
            self.assertNotIn('mac_only', xmlc.cleaner.options)
            self.assertNotIn('windows_only', xmlc.cleaner.options)
            self.assertIn('linux_only', xmlc.cleaner.options)
        elif bleachbit.IS_WINDOWS:
            self.assertIn('windows_only', xmlc.cleaner.options)
            self.assertNotIn('linux_only', xmlc.cleaner.options)
            self.assertNotIn('mac_only', xmlc.cleaner.options)
        elif bleachbit.IS_MAC:
            self.assertNotIn('windows_only', xmlc.cleaner.options)
            self.assertNotIn('linux_only', xmlc.cleaner.options)
            self.assertIn('mac_only', xmlc.cleaner.options)

    def _load_real_cleaner_as(self, filename, platform):
        """Load a cleaner from the cleaners directory as if on another platform

        CleanerML.os_match() calls General.os_match() with the real
        platform, so the call is replaced to use the given platform.
        """
        def os_match_for_platform(os_str, _platform=None):
            return general_os_match(os_str, platform)

        with mock.patch('bleachbit.CleanerML.general_os_match',
                        os_match_for_platform):
            return CleanerML(os.path.join('cleaners', filename)).cleaner

    def test_teamviewer_options_by_platform(self):
        """The TeamViewer cleaner has the options of each platform

        macOS has the cache, the cookies and the site data. Windows keeps
        the logs and the most recently used list. Linux does not load the
        cleaner.
        """
        mac = self._load_real_cleaner_as('teamviewer.xml', 'darwin')
        self.assertEqual(sorted(mac.options), [
                         'cache', 'cookies', 'site_data'])
        # The framework adds an action of its own for the localizations.
        self.assertEqual(
            sum(1 for option_id, __action in mac.actions
                if option_id == 'cache'), 3)
        windows = self._load_real_cleaner_as('teamviewer.xml', 'win32')
        self.assertEqual(sorted(windows.options), ['logs', 'mru'])
        linux = self._load_real_cleaner_as('teamviewer.xml', 'linux')
        self.assertEqual(sorted(linux.options), [])
        self.assertEqual(linux.actions, [])

    def test_waterfox_options_by_platform(self):
        """The Waterfox cleaner has the macOS cache only on macOS"""
        mac_cache = os.path.normpath(
            os.path.expanduser('~/Library/Caches/Waterfox/Profiles/*/'))

        def cache_paths(platform):
            cleaner = self._load_real_cleaner_as('waterfox.xml', platform)
            return [os.path.normpath(path)
                    for option_id, action in cleaner.actions
                    if option_id == 'cache'
                    for path in action.paths]

        self.assertIn(mac_cache, cache_paths('darwin'))
        self.assertNotIn(mac_cache, cache_paths('win32'))
        self.assertNotIn(mac_cache, cache_paths('linux'))

    @common.skipIfWindows
    def test_waterfox_macos_profile_var(self):
        """On macOS the profile variable finds the Waterfox profile"""
        home = os.path.join(self.tempdir, 'waterfox-home')
        profile = os.path.join(
            home, 'Library', 'Application Support', 'Waterfox', 'Profiles',
            'abcd1234.default-release')
        os.makedirs(profile)
        with mock.patch.dict(os.environ, {'HOME': home}):
            cleaner = self._load_real_cleaner_as('waterfox.xml', 'darwin')
        paths = [os.path.normpath(path)
                 for option_id, action in cleaner.actions
                 if option_id == 'cookies'
                 for path in action.paths]
        self.assertIn(os.path.join(profile, 'cookies.sqlite'), paths)

    @common.skipUnlessLinux
    def test_running_process_names(self):
        """Cleaners notice their application by its Linux process name"""
        cases = (
            ('android_studio', 'studio'),
            ('apt', 'apt'),
            ('apt', 'aptitude'),
            ('rhythmbox', 'rhythmbox'),
            ('slack', 'slack'),
            ('transmission', 'transmission-daemon'),
            ('transmission', 'transmission-gtk'),
            ('transmission', 'transmission-qt'),
        )
        self.addCleanup(process_cache.invalidate)
        for cleaner_id, exe_name in cases:
            with self.subTest(cleaner_id=cleaner_id, exe_name=exe_name):
                cleaner = CleanerML(f'cleaners/{cleaner_id}.xml').get_cleaner()
                process_cache.invalidate()
                with mock.patch('bleachbit.Process.enumerate_processes',
                                return_value=[ProcessInfo(1234, exe_name, True)]):
                    self.assertTrue(cleaner.is_process_running())

    def test_pot_fragment(self):
        """Unit test for pot_fragment()"""
        self.assertIsString(pot_fragment("Foo", 'bar.xml'))

    def test_var(self):
        """Test the <var> element"""
        xml_str = r"""
<cleaner id="testvar">
    <label>cleaner label</label>
    <description>cleaner description</description>
    <var name="basepath">
        <value>%%LocalAppData%%\FooDoesNotExist</value>
        <value>~/.config/FooDoesNotExist</value>
        <value>{tempdir}/a</value>
        <value>{tempdir}/b</value>
    </var>
    <option id="option1">
        <label>option1 label</label>
        <description>option1 description</description>
        <action search="file" command="delete" path="$$basepath$$/test.log" />
    </option>
</cleaner>
""".format(**{'tempdir': self.tempdir})
        # write XML cleaner
        cml_path = os.path.join(self.tempdir, 'test.xml')
        self.write_file(cml_path, xml_str.encode(sys.getdefaultencoding()))

        # create two canaries
        test_log_path_a = os.path.join(self.tempdir, 'a', 'test.log')
        test_log_path_b = os.path.join(self.tempdir, 'b', 'test.log')
        common.touch_file(test_log_path_a)
        common.touch_file(test_log_path_b)
        self.assertExists(test_log_path_a)
        self.assertExists(test_log_path_b)

        # parse XML to XML cleaner instance
        xmlc = CleanerML(cml_path)
        self.assertIsInstance(xmlc, CleanerML)
        self.assertIsInstance(xmlc.cleaner, Cleaner.Cleaner)
        self.assertTrue(xmlc.cleaner.is_usable())

        # run preview
        self.run_all(xmlc, False)
        self.assertExists(test_log_path_a)
        self.assertExists(test_log_path_b)

        # really delete
        self.run_all(xmlc, True)
        self.assertNotExists(test_log_path_a)
        self.assertNotExists(test_log_path_b)

    def test_xlate_cb_source_text(self):
        """The .pot callback gets the source text, not its translation"""
        xml_str = f"""<cleaner id="xlate">
    <label translate="true">Label</label>
    <description>Description</description>
    <option id="option1">
        <label>Option label</label>
        <description>Option description</description>
        <warning>Option warning</warning>
        <action search="file" command="delete" path="{self.tempdir}/nonexistent"/>
    </option>
</cleaner>
"""
        cml_path = os.path.join(self.tempdir, 'xlate.xml')
        self.write_file(cml_path, xml_str.encode(sys.getdefaultencoding()))
        strings = []
        with mock.patch('bleachbit.CleanerML._', lambda s: 'translated ' + s):
            xmlc = CleanerML(cml_path,
                             lambda s, translators=None: strings.append(s))
        self.assertEqual(['Label', 'Description', 'Option label',
                          'Option description', 'Option warning'], strings)
        self.assertEqual('translated Label', xmlc.cleaner.name)

    @common.skipUnlessWindows
    def test_vuze_program_files(self):
        """Vuze actions under Program Files stay in the Vuze folder"""
        roots = (r'C:\Program Files (x86)', r'C:\Program Files')
        env = {'ProgramFiles': roots[0], 'ProgramW6432': roots[1]}
        with mock.patch.dict(os.environ, env):
            cleaner = CleanerML('cleaners/vuze.xml').get_cleaner()
        vuze_dirs = tuple(root + '\\Vuze\\' for root in roots)
        checked = 0
        for _option_id, action in cleaner.actions:
            for path in getattr(action, 'paths', ()):
                if path.startswith(roots):
                    self.assertTrue(path.startswith(vuze_dirs), path)
                    checked += 1
        self.assertGreater(checked, 0)

    def test_var_glob(self):
        """A glob <var> value does not leave '//' in action paths"""
        basedir = self.mkdtemp(prefix='bleachbit-cleanerml-glob')
        xml_str = f"""
<cleaner id="testvarglob">
    <label>cleaner label</label>
    <description>cleaner description</description>
    <var name="profile">
        <value search="glob">{basedir}/*</value>
    </var>
    <option id="option1">
        <label>option1 label</label>
        <description>option1 description</description>
        <action search="file" command="delete" path="$$profile$$/test.log" />
    </option>
</cleaner>
"""
        cml_path = os.path.join(self.mkdtemp(), 'test_var_glob.xml')
        self.write_file(cml_path, xml_str.encode(sys.getdefaultencoding()))
        profile = os.path.join(basedir, 'profile')
        test_log_path = os.path.join(profile, 'test.log')
        common.touch_file(test_log_path)
        # The glob matches directories only
        common.touch_file(os.path.join(basedir, 'profiles.ini'))

        xmlc = CleanerML(cml_path)
        self.assertEqual([profile], xmlc.vars['profile'])
        commands = list(xmlc.cleaner.get_commands('option1'))
        self.assertEqual([test_log_path], [cmd.path for cmd in commands])

        options.set_whitelist_paths([('file', test_log_path)])
        for cmd in commands:
            for result in cmd.execute(False):
                self.assertEqual(0, result['n_deleted'])
        self.assertExists(test_log_path)