#!/bin/bash # Every desktop shows the classroom's wallpaper, whatever it is numbered. # # Tiny Pebbles came through the 0.2.2 upgrade with its desktop as # containment 10 and no wallpaper configured on it, and showed Plasma's own # purple one. These run the real repair against that file, against a # machine that was already right, and against the shapes in between, and # then check that every place naming the wallpaper names the same one. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" python3 - "$ROOT" <<'PY' import importlib.util import json import os import re import sys import tempfile from importlib.machinery import SourceFileLoader from pathlib import Path root = Path(sys.argv[1]) loader = SourceFileLoader("wallpaper", str(root / "scripts/classroom-desktop-wallpaper")) spec = importlib.util.spec_from_loader("wallpaper", loader) module = importlib.util.module_from_spec(spec) loader.exec_module(module) passed = failed = 0 def check(label, condition, detail=""): global passed, failed if condition: print(f" ok {label}") passed += 1 else: print(f" FAIL {label}") if detail: print(f" {detail}") failed += 1 IMAGE = "Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png" def group_texts(text): """{group name: its lines}, for saying what did and did not change.""" lines = text.splitlines(keepends=True) return { name: "".join(lines[header:end]).rstrip("\n") for name, header, end in module.groups(lines) } def containment_ids(text): return sorted(re.findall(r"^\[Containments\]\[(\d+)\]$", text, re.M)) # A panel built with no screen present takes id 1 and its applets 2 to 9, # which is how Tiny Pebbles' desktop came to be 10. Its containment is as # it was read off the machine. PANEL_1 = """[ActionPlugins][0] MiddleButton;NoModifier=org.kde.paste RightButton;NoModifier=org.kde.contextmenu [ActionPlugins][1] RightButton;NoModifier=org.kde.contextmenu [Containments][1] activityId= formfactor=2 immutability=1 lastScreen=0 location=4 plugin=org.kde.panel wallpaperplugin=org.kde.image [Containments][1][Applets][2] immutability=1 plugin=org.kde.plasma.quicklaunch [Containments][1][Applets][2][Configuration][General] launcherUrls=file:///usr/share/applications/ekklesia-parent-call.desktop [Containments][1][Applets][3] immutability=1 plugin=org.kde.plasma.panelspacer [Containments][1][Applets][4] immutability=1 plugin=org.kde.plasma.icontasks [Containments][1][Applets][4][Configuration][General] launchers=applications:ekklesia-wonderink.desktop,applications:google-chrome.desktop,applications:ekklesia-dvd.desktop sortingStrategy=1 [Containments][1][Applets][5] immutability=1 plugin=org.kde.plasma.panelspacer [Containments][1][Applets][6] immutability=1 plugin=org.kde.plasma.networkmanagement [Containments][1][Applets][7] immutability=1 plugin=org.kde.plasma.volume [Containments][1][Applets][8] immutability=1 plugin=org.kde.plasma.notifications [Containments][1][Applets][9] immutability=1 plugin=org.kde.plasma.digitalclock [Containments][1][General] AppletOrder=2;3;4;5;6;7;8;9 """ TINY_PEBBLES = PANEL_1 + """ [Containments][10] activityId=62d3d55d-bd31-4fc8-9a7b-a5b9b2b99b60 formfactor=0 immutability=1 lastScreen=0 location=0 plugin=org.kde.plasma.folder wallpaperplugin=org.kde.image [Containments][10][General] positions={"1920x1080":[]} [ScreenMapping] itemsOnDisabledScreens= """ # The healthy machine, as the layout builds it with a screen present and # as the lab's own session wrote it: desktop 1, panel 2, applets 3 to 10. # The clock is applet 10 — the number that is Tiny Pebbles' desktop. RIVER_KIDS = """[ActionPlugins][0] MiddleButton;NoModifier=org.kde.paste RightButton;NoModifier=org.kde.contextmenu [Containments][1] ItemGeometries-1920x1080= ItemGeometriesHorizontal= activityId=a9db02f6-926f-4650-b05f-de3a9f0b73fe formfactor=0 immutability=1 lastScreen=0 location=0 plugin=org.kde.plasma.folder wallpaperplugin=org.kde.image [Containments][1][Wallpaper][org.kde.image][General] FillMode=2 Image=file:///usr/share/backgrounds/ekklesia/classroom-calm.png SlidePaths= [Containments][2] activityId= formfactor=2 immutability=1 lastScreen=0 location=4 plugin=org.kde.panel wallpaperplugin=org.kde.image [Containments][2][Applets][10] immutability=1 plugin=org.kde.plasma.digitalclock [Containments][2][Applets][10][Configuration][Appearance] showDate=true [Containments][2][Applets][3] immutability=1 plugin=org.kde.plasma.quicklaunch [Containments][2][Applets][5] immutability=1 plugin=org.kde.plasma.icontasks [Containments][2][General] AppletOrder=3;4;5;6;7;8;9;10 [ScreenMapping] itemsOnDisabledScreens= """ # What the lab produced when Plasma made a desktop mid-session: the good # one kept its picture, and the new one, 11, had none. LATE_DESKTOP = RIVER_KIDS.replace("[ScreenMapping]", """[Containments][11] activityId=f992593c-2347-4e8d-be82-f80d02e71158 formfactor=0 immutability=1 lastScreen=0 location=0 plugin=org.kde.plasma.folder wallpaperplugin=org.kde.image [Containments][11][General] positions={"1920x1080":[]} [ScreenMapping]""") print("1. Tiny Pebbles: a desktop numbered 10, with no wallpaper at all") check("the desktop is found by what it is, not its number", module.desktops(TINY_PEBBLES.splitlines(True)) == ["10"], str(module.desktops(TINY_PEBBLES.splitlines(True)))) after, changes = module.converge(TINY_PEBBLES) groups_before, groups_after = group_texts(TINY_PEBBLES), group_texts(after) wallpaper = groups_after.get("[Containments][10][Wallpaper][org.kde.image][General]", "") check("it gets the classroom's image", IMAGE in wallpaper, wallpaper) check("scaled and cropped, as the layout writes it", "FillMode=2" in wallpaper) check("and it says what it did", changes == ["desktop 10: had no wallpaper configured"], str(changes)) check("the wallpaper plugin is still the image one", "wallpaperplugin=org.kde.image" in groups_after["[Containments][10]"]) check("one group added and nothing else changed", set(groups_after) - set(groups_before) == {"[Containments][10][Wallpaper][org.kde.image][General]"} and all(groups_after[name] == text for name, text in groups_before.items())) check("every containment keeps its number", containment_ids(after) == ["1", "10"]) check("the panel is still containment 1, applets 2 to 9, in order", groups_after["[Containments][1][General]"].endswith("AppletOrder=2;3;4;5;6;7;8;9") and groups_after["[Containments][1]"] == groups_before["[Containments][1]"]) check("the new group sits with its own containment", after.index("[Containments][10][General]") < after.index("[Containments][10][Wallpaper]") < after.index("[ScreenMapping]")) again, changes_again = module.converge(after) check("a second run changes nothing", again == after and changes_again == [], str(changes_again)) print("2. River Kids: already right") after, changes = module.converge(RIVER_KIDS) check("the file comes back byte for byte", after == RIVER_KIDS) check("and nothing is reported as changed", changes == [], str(changes)) check("applet 10 on the panel is not mistaken for a desktop", module.desktops(RIVER_KIDS.splitlines(True)) == ["1"]) print("3. two desktops, one bare, as the lab made them") after, changes = module.converge(LATE_DESKTOP) groups_before, groups_after = group_texts(LATE_DESKTOP), group_texts(after) check("only the bare one is written", changes == ["desktop 11: had no wallpaper configured"], str(changes)) check("the good one is untouched", groups_after["[Containments][1][Wallpaper][org.kde.image][General]"] == groups_before["[Containments][1][Wallpaper][org.kde.image][General]"]) check("and the panel, numbered 2, is untouched", all(groups_after[name] == text for name, text in groups_before.items() if name and name.startswith("[Containments][2]"))) check("the new one has the image", IMAGE in groups_after.get("[Containments][11][Wallpaper][org.kde.image][General]", "")) print("4. a desktop some other profile left with another picture") OTHER = """[Containments][4] formfactor=0 location=0 plugin=org.kde.desktopcontainment wallpaperplugin=org.kde.color [Containments][4][Wallpaper][org.kde.image][General] Image[$e]=file:///usr/share/wallpapers/Next/contents/images/1920x1080.png SlidePaths=/usr/share/wallpapers/ [Containments][7] formfactor=2 location=4 plugin=org.kde.plasma.private.systemtray [Containments][9] formfactor=2 location=4 plugin=org.kde.panel """ after, changes = module.converge(OTHER) groups_after = group_texts(after) check("the plain Desktop containment counts as a desktop", module.desktops(OTHER.splitlines(True)) == ["4"]) check("its plugin becomes the image one", "wallpaperplugin=org.kde.image" in groups_after["[Containments][4]"]) wallpaper = groups_after["[Containments][4][Wallpaper][org.kde.image][General]"] check("its image becomes the classroom's, keeping the key's flags", "Image[$e]=file:///usr/share/backgrounds/ekklesia/classroom-calm.png" in wallpaper, wallpaper) check("a missing key is added to the existing group", "FillMode=2" in wallpaper) check("a key the classroom does not set is left alone", "SlidePaths=/usr/share/wallpapers/" in wallpaper) check("a system tray is not a desktop", groups_after["[Containments][7]"] == group_texts(OTHER)["[Containments][7]"]) check("nor is a panel", groups_after["[Containments][9]"] == group_texts(OTHER)["[Containments][9]"]) check("and it settles", module.converge(after) == (after, [])) print("5. the command, on files") work = Path(tempfile.mkdtemp()) def run(*args): return module.main([str(arg) for arg in args]) target = work / "tiny-pebbles" target.write_text(TINY_PEBBLES) os.chmod(target, 0o600) check("--check says it is wrong", run("--check", target) == 1) check("and changes nothing", target.read_text() == TINY_PEBBLES) check("the repair succeeds", run(target) == 0) check("the file is repaired", IMAGE in target.read_text()) check("the file keeps its permissions", (target.stat().st_mode & 0o777) == 0o600) check("no temporary file is left behind", sorted(p.name for p in work.iterdir()) == ["tiny-pebbles"]) stamp = (target.stat().st_ino, target.stat().st_mtime_ns) check("a second run succeeds", run(target) == 0) check("and does not write the file at all", (target.stat().st_ino, target.stat().st_mtime_ns) == stamp) check("--check then says it is right", run("--check", target) == 0) healthy = work / "river-kids" healthy.write_text(RIVER_KIDS) stamp = (healthy.stat().st_ino, healthy.stat().st_mtime_ns) check("a machine that is right is left alone", run(healthy) == 0 and (healthy.stat().st_ino, healthy.stat().st_mtime_ns) == stamp) check("no file at all is not an error", run(work / "absent") == 0) check("and none is created", not (work / "absent").exists()) empty = work / "no-desktop" empty.write_text(PANEL_1) check("a file with only a panel is not an error", run(empty) == 0) check("and is not changed", empty.read_text() == PANEL_1) print("6. never underneath a running shell") config_home = work / "config" config_home.mkdir() live = config_home / module.APPLETSRC live.write_text(TINY_PEBBLES) os.environ["XDG_CONFIG_HOME"] = str(config_home) check("the default file is the one in XDG_CONFIG_HOME", module.default_path() == str(live)) proc = work / "proc" for pid, name in (("1", "systemd"), ("812", "kwin_wayland"), ("self", "python3")): (proc / pid).mkdir(parents=True) (proc / pid / "comm").write_text(name + "\n") check("no plasmashell in the process table is no shell", module.shell_running(str(proc)) is False) (proc / "1044").mkdir() (proc / "1044" / "comm").write_text("plasmashell\n") check("this user's plasmashell is found", module.shell_running(str(proc)) is True) check("and the real process table can be read", module.shell_running() in (True, False)) module.shell_running = lambda: True check("with plasmashell running it refuses", run() == 1) check("and the shell's file is untouched", live.read_text() == TINY_PEBBLES) check("--check still answers while it runs", run("--check") == 1) module.shell_running = lambda: False check("with the shell stopped it repairs", run() == 0 and IMAGE in live.read_text()) print("7. one wallpaper, named the same everywhere") layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text() defaults = (root / "config/plasma/lookandfeel/contents/defaults").read_text() build = (root / "tools/build-release.sh").read_text() dropin = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text() package = root / "config/wallpapers/EkklesiaClassroom" metadata = json.loads((package / "metadata.json").read_text()) check("the layout and the repair name the same file", f'var WALLPAPER = "{module.WALLPAPER}";' in layout) check("and the same fill mode", 'desktop.writeConfig("FillMode", 2);' in layout and ("FillMode", "2") in module.WALLPAPER_KEYS) check("the packaged file is where they say", f'"$STAGE{module.WALLPAPER}"' in build) default = re.search(r"^\[Wallpaper\]\nImage=(.*)$", defaults, re.M) check("the look and feel's default is a package name, not a path", default is not None and "/" not in default.group(1), default.group(1) if default else "no [Wallpaper] Image") check("the package it names is the classroom's", default is not None and default.group(1) == metadata["KPlugin"]["Id"] == package.name) check("the build installs that package's metadata", f'"$ROOT/config/wallpapers/{package.name}/metadata.json"' in build and f'WALLPAPER_PACKAGE="$STAGE/usr/share/wallpapers/{package.name}"' in build) check("its image is a link to the one file, not a copy", f"ln -s {module.WALLPAPER}" in build) check("the repair is packaged", '"$STAGE/usr/local/bin/classroom-desktop-wallpaper"' in build) check("it runs before every shell start", "ExecStartPre=-/usr/local/bin/classroom-desktop-wallpaper" in dropin) check("and a failure in it cannot stop the shell", not re.search(r"^ExecStartPre=/", dropin, re.M)) check("the layout still writes the wallpaper for a fresh desktop", 'desktop.writeConfig("Image", "file://" + WALLPAPER);' in layout) print() print(f"{passed} passed, {failed} failed") sys.exit(1 if failed else 0) PY