#!/usr/bin/python3 -cimport os, sys; os.execv(os.path.dirname(sys.argv[1]) + "/../common/pywrap", sys.argv)## Copyright (C) 2017 Red Hat, Inc.# SPDX-License-Identifier: LGPL-2.1-or-laterimport jsonimport timeimport packagelibimport testlib@testlib.skipWsContainer("Not supported")@testlib.skipImage("TODO: fix for OpenSUSE", "opensuse-tumbleweed")@testlib.nondestructiveclass TestApps(packagelib.PackageCase):def setUp(self) -> None:super().setUp()self.appstream_collection: 'set[str]' = set()self.machine.upload(["verify/files/test.png"], "/var/tmp/")def createAppStreamPackage(self, name: str, version: str, revision: str, install: bool = False) -> None:self.createPackage(name, version, revision, content={f"/usr/share/metainfo/org.cockpit_project.{name}.metainfo.xml": f"""<component type="addon"><extends>org.cockpit_project.cockpit</extends><id>org.cockpit-project.{name}</id><icon type="stock">{name}</icon><name>{name}</name><summary>{name} application for testing</summary><launchable type="cockpit-manifest">{name}</launchable></component>""",f"/usr/share/icons/hicolor/64x64/apps/{name}.png": {"path": "/var/tmp/test.png"}}, install=install)self.appstream_collection.add(name)def createAppStreamRepoPackage(self, subdir: str | None = None) -> None:if subdir is None:subdir = "swcatalog/xml"body = ""for p in self.appstream_collection:body += f"""<component type="addon"><extends>org.cockpit_project.cockpit</extends><id>org.cockpit-project.{p}</id><icon type="cached">test.png</icon><name>{p}</name><summary>An application for testing</summary><launchable type="cockpit-manifest">{p}</launchable><description><p>DESCRIPTION:none</p></description><url type="homepage">https://{p}.com</url><pkgname>{p}</pkgname></component>"""self.machine.execute(f"mkdir -p /usr/share/{subdir}")self.createPackage("appstream-data-test", "1.0", "1", content={f"/usr/share/{subdir}/test.xml": f"""<components origin="test">{body}</components>""",f"/usr/share/{subdir.split('/')[0]}/icons/test/64x64/test.png": {"path": "/var/tmp/test.png"}})self.enableRepo(refresh_repo=True)# ignore the corresponding journal entryself.allow_journal_messages("org.freedesktop.PackageKit.*org.freedesktop.DBus.Error.NoReply.*")def testBasic(self, urlroot: str = ""):b = self.browserm = self.machineself.allow_journal_messages("can't remove watch: Invalid argument")self.restore_dir("/usr/share/metainfo", reboot_safe=True)self.restore_dir("/usr/share/swcatalog", reboot_safe=True)self.restore_dir("/usr/share/app-info", reboot_safe=True)self.restore_dir("/var/cache/app-info", reboot_safe=True)# Make sure none of the appstream directories exist. They# will be created later and we need to cope with that.m.execute("rm -rf /usr/share/metainfo /usr/share/swcatalog /usr/share/app-info /var/cache/app-info")# instead of the actual distro packages, use our own fake repo data packageconfig = {"config": {"appstream_data_packages": ["appstream-data-test"]}}# Debian/Ubuntu have an appstream configuration package which makes apt update fetch appstream dataif m.image.startswith("ubuntu") or m.image.startswith("debian"):self.createPackage("appstream-config", "1", "1")config["config"]["appstream_config_packages"] = ["appstream-config"]self.write_file("/etc/cockpit/apps.override.json", json.dumps(config))if urlroot != "":m.write("/etc/cockpit/cockpit.conf", f"[WebService]\nUrlRoot={urlroot}")self.login_and_go("/apps", urlroot=urlroot)b.wait_in_text(".pf-v6-c-empty-state", "No applications installed or available")b.wait_in_text(".pf-v6-c-empty-state", "Application information is missing")# still no metadata, but already installed applicationself.createAppStreamPackage("already", "1.0", "1", install=True)b.wait_not_present(".pf-v6-c-empty-state")b.wait_visible(".app-list .pf-v6-c-data-list__item-action[rowid='already'] button:contains('Remove')")b.wait_in_text(".pf-v6-c-alert", "Application information is missing")self.createAppStreamPackage("app-1", "1.0", "1")self.createAppStreamRepoPackage()# Install package metadatab.click(".pf-v6-c-alert button")with b.wait_timeout(60):b.wait_visible(".app-list #app-1")b.wait_not_present(".pf-v6-c-alert")b.click(".app-list #app-1")b.wait_visible('a[href="https://app-1.com"]')b.wait_visible(f'#app-page img[src^="{urlroot}/cockpit/channel/"]')b.click(".pf-v6-c-breadcrumb a:contains('Applications')")b.wait_visible("#list-page")b.wait_not_present("#app-page")if urlroot == "":b.assert_pixels("#list-page", "list-page")b.click(".app-list .pf-v6-c-data-list__item-action[rowid='app-1'] button:contains('Install')")b.wait_text(".app-list .pf-v6-c-data-list__item-action[rowid='app-1'] button", "Remove")b.wait_visible(f".app-list .pf-v6-c-data-list__item-row img[alt='icon of app-1'][src^='{urlroot}/cockpit/channel/']")m.execute("test -f /stamp-app-1-1.0-1")b.click(".app-list .pf-v6-c-data-list__item-action[rowid='app-1'] button:contains('Remove')")b.wait_text(".app-list .pf-v6-c-data-list__item-action[rowid='app-1'] button", "Install")b.wait_visible(f".app-list .pf-v6-c-data-list__item-row img[alt='icon of app-1'][src^='{urlroot}/cockpit/channel/']")m.execute("! test -f /stamp-app-1-1.0-1")def testWithUrlRoot(self):self.testBasic(urlroot="/webcon")def testOsMap(self):b = self.browserm = self.machineself.allow_journal_messages("can't remove watch: Invalid argument")self.restore_dir("/usr/share/metainfo", reboot_safe=True)self.restore_dir("/usr/share/swcatalog", reboot_safe=True)self.restore_dir("/usr/share/app-info", reboot_safe=True)self.restore_dir("/var/cache/app-info", reboot_safe=True)# Make sure none of the appstream directories exist. They# will be created later and we need to cope with that.m.execute("rm -rf /usr/share/metainfo /usr/share/swcatalog /usr/share/app-info /var/cache/app-info")# use a fake distro mapself.write_file("/etc/cockpit/apps.override.json",'{ "config": { "appstream_data_packages":'' {"testy": ["appstream-data-test"], "otheros": ["nosuchpackage"]},'' "appstream_config_packages": []'' }}')self.createAppStreamPackage("app-1", "1.0", "1")# old subdir until Fedora 39self.createAppStreamRepoPackage(subdir="app-info/xmls")self.login_and_go("/apps")b.wait_visible(".pf-v6-c-empty-state")# os-release is a symlink target, don't clobber thatself.restore_file("/etc/os-release")m.execute("rm /etc/os-release")# get along with absent os-releaseb.click("#refresh")# the progress bar is too fast to reliably catch ittime.sleep(1)b.wait_not_present("#refresh-progress")b.wait_visible(".pf-v6-c-empty-state")# wait until check for installed metadata package finishedb.wait_attr("#list-page", "data-packages-checked", "true")# no appstream metadata available, don't advertise itb.wait_in_text(".pf-v6-c-empty-state", "No applications installed or available")self.assertNotIn("Install application information", b.text(".pf-v6-c-empty-state"))b.wait_not_present(".pf-v6-c-empty-state button")# unknown OS: nothing gets installedm.write("/etc/os-release", 'ID="unmapped"\nID_LIKE="mysterious"\nVERSION_ID="1"\n')b.click("#refresh")# the progress bar is too fast to reliably catch ittime.sleep(1)b.wait_not_present("#refresh-progress")b.wait_visible(".pf-v6-c-empty-state")# known OS: appstream-data-test gets installed from the mapm.write("/etc/os-release", 'ID="derivative"\nID_LIKE="spicy testy"\nVERSION_ID="1"\n')b.click("#refresh")m.execute("until test -e /usr/share/app-info/xmls/test.xml; do sleep 1; done")b.wait_visible(".app-list #app-1")def testL10N(self):b = self.browserm = self.machine# Switching to a language might produce these messages, which seem to be harmless.self.allow_journal_messages("invalid or unusable locale.*","Error .* data: Connection reset by peer")# Reset everythingm.execute("for d in /usr/share/metainfo /usr/share/app-info /var/cache/app-info; do mkdir -p $d; mount -t tmpfs tmpfs $d; done")self.addCleanup(m.execute, "for d in /usr/share/metainfo /usr/share/app-info /var/cache/app-info; do umount $d; done")self.login_and_go("/apps")b.wait_visible(".pf-v6-c-empty-state")m.write("/usr/share/app-info/xmls/test.xml", """<components origin="test"><component type="addon"><extends>org.cockpit_project.cockpit</extends><id>org.cockpit-project.foo</id><name>NAME:none</name><name xml:lang="de">NAME:de</name><summary>SUMMARY:none</summary><summary xml:lang="de">SUMMARY:de</summary><description><p>DESCRIPTION:none</p><p xml:lang="de">DESCRIPTION:de</p></description><launchable type="cockpit-manifest">foo</launchable><pkgname>foo</pkgname></component></components>""")b.wait_text(".app-list .pf-v6-c-data-list__item-action[rowid='NAME:none'] button", "Install")b.click(".app-list .pf-v6-c-data-list__item-row button:contains('NAME:none')")b.wait_text(".app-description", "DESCRIPTION:none")def set_lang(lang: str) -> None:b.switch_to_top()b.open_session_menu()b.click("button.display-language-menu")b.wait_visible('#display-language-modal')b.click(f'#display-language-modal li[data-value={lang}] button')b.click("#display-language-modal footer button.pf-m-primary")b.wait_not_present('#display-language-modal')b.wait_language(lang)b.enter_page("/apps")set_lang("de-de")b.wait_text(".app-description", "DESCRIPTION:de")set_lang("ja-jp")b.wait_text(".app-description", "DESCRIPTION:none")# like in the general whitelist, but translatedself.allow_journal_messages("xargs: basename: .*Signal 13.*")def testBrokenXML(self):b = self.browserm = self.machine# Reset everythingm.execute("for d in /usr/share/metainfo /usr/share/app-info /var/cache/app-info /usr/share/swcatalog; do mkdir -p $d; mount -t tmpfs tmpfs $d; done")self.addCleanup(m.execute, "for d in /usr/share/metainfo /usr/share/app-info /var/cache/app-info /usr/share/swcatalog; do umount $d; done")appstream_folder = "/usr/share/app-info/xmls"self.login_and_go("/apps")b.wait_visible(".pf-v6-c-empty-state")self.allow_journal_messages(".*/usr/share/app-info/xmls/test.xml.*",".*xml.etree.ElementTree.ParseError.*")def reset():m.write(f"{appstream_folder}/test.xml", """<components origin="test"><component type="addon"><extends>org.cockpit_project.cockpit</extends><id>org.cockpit-project.test</id><name>Name</name><summary>Summary</summary><description><p>Description</p></description><launchable type="cockpit-manifest">foo</launchable><pkgname>foo</pkgname></component></components>""")b.wait_not_present(".pf-v6-c-empty-state")b.wait_visible(".app-list .pf-v6-c-data-list__item-row:contains('Summary')")# First lay down some good XML so that we can later detect the reaction to broken XML.reset()# Unparsablem.write(f"{appstream_folder}/test.xml", """This <is <not XML.""")b.wait_visible(".pf-v6-c-empty-state")reset()# Not really AppStreamm.write(f"{appstream_folder}/test.xml", """<foo></foo>""")b.wait_visible(".pf-v6-c-empty-state")reset()# No originm.write(f"{appstream_folder}/test.xml", """<components><component type="addon"><extends>org.cockpit_project.cockpit</extends><id>org.cockpit-project.test</id><name>Name</name><summary>Summary</summary><description><p>Description</p></description><launchable type="cockpit-manifest">foo</launchable><pkgname>foo</pkgname></component></components>""")b.wait_visible(".pf-v6-c-empty-state")reset()# No packagem.write(f"{appstream_folder}/test.xml", """<components origin="test"><component type="addon"><extends>org.cockpit_project.cockpit</extends><id>org.cockpit-project.test</id><name>Name</name><summary>Summary</summary><description><p>Description</p></description><launchable type="cockpit-manifest">foo</launchable></component></components>""")b.wait_visible(".pf-v6-c-empty-state")reset()# No idm.write(f"{appstream_folder}/test.xml", """<components origin="test"><component type="addon"><extends>org.cockpit_project.cockpit</extends><name>Name</name><summary>No description</summary><launchable type="cockpit-manifest">foo</launchable><pkgname>foo</pkgname></component></components>""")b.wait_visible(".pf-v6-c-empty-state")reset()# Error (launchable without type) in earlier entry, shouldn't affect the later entrym.write(f"{appstream_folder}/test.xml", """<components origin="test"><component type="addon"><extends>org.cockpit_project.cockpit</extends><id>org.cockpit-project.test2</id><name>Name</name><summary>Summary</summary><description><p>Description 2</p></description><launchable>foo</launchable><pkgname>foo</pkgname></component><component type="addon"><extends>org.cockpit_project.cockpit</extends><id>org.cockpit-project.test</id><name>Name</name><summary>Summary 2</summary><description><p>Description</p></description><launchable type="cockpit-manifest">foo</launchable><pkgname>foo</pkgname></component></components>""")b.wait_not_present(".pf-v6-c-empty-state")b.wait_visible(".app-list .pf-v6-c-data-list__item-row:contains('Summary 2')")def testNoPackageManager(self):b = self.browserm = self.machinedef disable_service(service_name: str) -> None:m.execute(f"systemctl stop {service_name}")system_service = m.execute(f"systemctl show -p FragmentPath --value {service_name}.service").strip()m.execute(f"mv {system_service} {system_service}.disabled")self.addCleanup(m.execute, f"mv {system_service}.disabled {system_service}")if m.image.startswith('fedora-'):disable_service("dnf5daemon-server")disable_service("packagekit")else:disable_service("packagekit")self.assertNotIn("\napps", m.execute("cockpit-bridge --packages"))self.login_and_go(None)b.wait_in_text("#host-apps", "Terminal")self.assertNotIn("Applications", b.text("#host-apps"))if __name__ == '__main__':testlib.test_main()