Files
Panama/tests/quickshell/applications-settings-contract

319 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# The Applications page manages applications now, rather than only pointing file
# types at them. What is pinned here is the part that is easy to get subtly
# wrong and impossible to notice:
#
# * the role matcher, which decides which applications a role may be set to.
# It is extracted from the page and run against fixtures, because every bug
# it has ever had was silent -- a role that offered nothing but the
# application it already had, and nobody could tell whether that was the
# matcher or the machine;
# * removal being honest: Flatpak applications come off from here, system
# packages do not, and the row says the command instead of pretending;
# * the things a page can quietly lose in a rebuild -- the escape hatch for a
# single file type, the read-only compositor autostart, the launcher chord
# that is read rather than hardcoded.
#
# Reading only: nothing here runs the page or changes a setting.
set -euo pipefail
fail() {
printf 'applications settings contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings="$project_root/config/dot/quickshell/modules/settings"
page="$settings/ApplicationsPage.qml"
row="$settings/InstalledAppRow.qml"
picker="$settings/AutostartAppPicker.qml"
types="$settings/FileTypePicker.qml"
qmldir="$settings/qmldir"
for path in "$page" "$row" "$picker" "$types" "$qmldir"; do
[[ -f "$path" ]] || fail "missing $path"
done
assert_contains() {
rg -F --quiet "$1" "$page" || fail "page is missing: $1"
}
assert_row_contains() {
rg -F --quiet "$1" "$row" || fail "the installed-application row is missing: $1"
}
assert_contains 'SettingsPage {'
assert_contains 'objectName: "applications"'
assert_contains 'DesktopEntries.applications.values'
assert_contains 'AppLibrary'
assert_contains 'DefaultApps'
assert_contains 'SettingsCard {'
assert_contains 'SettingRow {'
assert_contains 'ActionRow {'
assert_contains 'TextRow {'
# ── The cards, and what each one is for ──────────────────────────────────────
for title in 'Installed applications' 'Browse the catalog' 'Default applications' \
'Autostart' 'Search'; do
rg -F --quiet "title: \"$title\"" "$page" || fail "the page has no \"$title\" card"
done
# Every role still has a row, and the role list is still ten long: a family that
# quietly disappears takes its file types with it.
for label in Browser Mail Files Terminal Images Music Video Documents Text Archives; do
assert_contains "label: \"$label\""
done
[[ "$(rg --count 'key: "' "$page")" == "10" ]] \
|| fail 'the ten default-application roles are no longer ten'
# The roles are pickers now, not an accordion of buttons.
assert_contains 'OptionPickerRow {'
assert_contains 'onPicked: value => DefaultApps.setDefault('
assert_contains 'DefaultApps.busy ? "Loading…"'
assert_contains 'currentEntry'
assert_contains 'choices.push(currentEntry)'
# The escape hatch for one type, and the service call behind it.
assert_contains 'label: "One file type"'
assert_contains 'FileTypePicker {'
assert_contains 'DefaultApps.searchTypes(query)'
assert_contains 'DefaultApps.setType(mime, desktopId)'
rg -Fq 'signal queried(string query)' "$types" \
|| fail 'the file-type picker cannot ask for a search'
rg -Fq 'signal chosen(string mime, string desktopId)' "$types" \
|| fail 'the file-type picker cannot report which application was chosen for a type'
# ── The installed list ───────────────────────────────────────────────────────
assert_contains 'InstalledAppRow {'
assert_contains 'AppLibrary.matches(app, installedSearch.text)'
assert_row_contains 'signal uninstallArmed'
assert_row_contains 'signal uninstallConfirmed'
assert_row_contains 'root.confirmingUninstall'
assert_row_contains 'label: "Permissions"'
assert_row_contains 'label: "Start with the session"'
assert_row_contains 'label: "Elsewhere in Settings"'
# Removing a system package is refused, by name, with the command that would do
# it. This is the whole reason the page can be trusted with an Uninstall button
# at all: it does one thing, and says plainly what it will not do.
assert_row_contains 'label: "Managed by dnf"'
assert_row_contains 'sudo dnf remove '
rg -v '^\s*//' "$page" | rg -q 'dnf remove' \
&& fail 'the page offers a package removal outside the honest refusal row'
python3 - "$page" "$row" <<'PY' || fail 'the page can run a package manager'
import re
import sys
for path in sys.argv[1:]:
text = open(path, encoding="utf-8").read()
# Every argument list handed to a process. A dnf or rpm in one of these is a
# settings page removing packages, whatever the button says.
for match in re.finditer(r"exec\w*\(\s*(\[[^\]]*\])", text):
argv = match.group(1)
if re.search(r'"(dnf|rpm|pkexec|sudo|yum|rpm-ostree)"', argv):
print(f"{path}: {argv.strip()[:120]}", file=sys.stderr)
raise SystemExit(1)
PY
# Flatseal is offered only when it is installed: a button that does nothing is
# worse than no button.
assert_contains 'flatsealAvailable'
assert_contains 'com.github.tchx84.Flatseal'
# The jump chips are shown where a rule exists, not everywhere.
assert_row_contains 'hasNotificationRule'
assert_row_contains 'hasSoundRule'
assert_contains 'Notifs.applications'
assert_contains 'AudioDevices.applications'
# ── The catalog ──────────────────────────────────────────────────────────────
#
# The id passed to install is the catalog line verbatim. Handing over the
# human-readable `ref` instead would be refused by the helper for every Flathub
# entry in the catalog -- the failure would look like "installing is broken".
assert_contains 'AppLibrary.install(root.activeCategory,'
rg -q 'AppLibrary\.install\([^)]*\.ref' "$page" \
&& fail 'the catalog installs by the display ref rather than by the catalog id'
assert_contains 'AppLibrary.entriesFor('
assert_contains 'system package, so installing asks for your password'
assert_contains 'value: catalogRow.installed ? "Installed" : ""'
# ── Autostart ────────────────────────────────────────────────────────────────
assert_contains 'AutostartAppPicker {'
assert_contains 'DefaultApps.addAutostart('
assert_contains 'label: "Add an application"'
assert_contains 'label: "Compositor autostart"'
assert_contains 'read-only'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0'
rg -Fq 'required property var existing' "$picker" \
|| fail 'autostart picker cannot exclude existing entries'
rg -Fq 'signal picked(string id)' "$picker" \
|| fail 'autostart picker does not emit a validated desktop id'
# The compositor's entries are described, never toggled: the file they live in
# is read once at launch, so a switch here would silently do nothing.
python3 - "$page" <<'PY' || fail 'a compositor autostart entry is offered as something to change'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
for match in re.finditer(r"model: DefaultApps\.luaAutostartEntries", text):
tail = text[match.start():match.start() + 1200]
if "SettingsToggle" in tail or "setAutostart" in tail:
print(tail[:200], file=sys.stderr)
raise SystemExit(1)
PY
# ── The launcher chord is read, not remembered ───────────────────────────────
#
# "Super+Space" was hardcoded here through two rebinds of the launcher and told
# the wrong story both times.
assert_contains 'Keybinds.binds'
assert_contains 'root.launcherChords'
python3 - "$page" <<'PY' || fail 'the launcher chord is stated rather than read from the keymap'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
text = "\n".join("" if line.strip().startswith("//") else line
for line in source.splitlines())
# The literal may survive as the fallback shown while the keymap is still being
# read, but not as the value itself.
for match in re.finditer(r'"Super ?\+ ?Space"', text):
line = text[:match.start()].rsplit("\n", 1)[-1] + text[match.start():].split("\n", 1)[0]
if "launcherChords" not in line:
print(line.strip(), file=sys.stderr)
raise SystemExit(1)
PY
# ── The role matcher, run rather than read ───────────────────────────────────
#
# Extracted from the page and executed against fixtures. The interesting cases
# are the near misses: a media centre is not a music player, a document scanner
# is not an image viewer, and the categories arrive as a QML list rather than a
# JavaScript array -- which is the exact shape that once made Archives match
# nothing at all.
PAGE_PATH="$page" bun -e '
const source = await Bun.file(process.env.PAGE_PATH).text();
const rolesSource = source.match(/readonly property var roles:\s*(\[[\s\S]*?\n \])/);
const matcherSource = source.match(/function matchesRole\(entry: var, role: var\): bool \{([\s\S]*?)\n \}/);
if (!rolesSource || !matcherSource) {
console.error("applications settings contract: role matcher could not be loaded");
process.exit(1);
}
const roles = Function(`return (${rolesSource[1]})`)();
const matchesRole = Function("entry", "role", matcherSource[1]);
const role = key => {
const found = roles.find(candidate => candidate.key === key);
if (!found) {
console.error(`applications settings contract: no "${key}" role`);
process.exit(1);
}
return found;
};
const fixtures = [
{
name: "AudioVideo does not imply music",
entry: { name: "Kodi", genericName: "Media Center", comment: "Entertainment hub", categories: "AudioVideo;Player;" },
role: "music",
expected: false
},
{
name: "Graphics does not imply image handler",
entry: { name: "Document Scanner", genericName: "Document Scanner", comment: "Scan documents", categories: ["Graphics"] },
role: "images",
expected: false
},
{
name: "Viewer does not imply image handler",
entry: { name: "Papers", genericName: "Document Viewer", comment: "Read documents", categories: "Office;Viewer;" },
role: "images",
expected: false
},
{
name: "comment does not nominate a default handler",
entry: { name: "Settings", genericName: "System Settings", comment: "Configure your video player", categories: ["System"] },
role: "video",
expected: false
},
{
name: "exact audio player categories match music",
entry: { name: "Rhythmbox", genericName: "Music Player", comment: "Play music", categories: "AudioVideo;Audio;Player;" },
role: "music",
expected: true
},
{
name: "exact video category matches video",
entry: { name: "Videos", genericName: "Video Player", comment: "Play movies", categories: ["AudioVideo", "Video", "Player"] },
role: "video",
expected: true
},
{
name: "descriptive metadata matches image handler",
entry: { name: "Loupe", genericName: "Image Viewer", comment: "Browse pictures", categories: "Graphics;Viewer;" },
role: "images",
expected: true
},
{
name: "an archive manager can be the archives handler",
entry: { name: "File Roller", genericName: "Archive Manager", comment: "Open archives", categories: "Utility;Archiving;" },
role: "archives",
expected: true
},
{
name: "categories that arrive as a list are read as categories",
entry: { name: "Ark", genericName: "Ark", comment: "", categories: ["Utility", "Archiving"] },
role: "archives",
expected: true
},
{
name: "a comma-separated category string is still a list of categories",
entry: { name: "Ark", genericName: "Ark", comment: "", categories: "Utility,Archiving" },
role: "archives",
expected: true
},
{
name: "a text editor is not a terminal",
entry: { name: "Neovim", genericName: "Text Editor", comment: "Edit text", categories: "Utility;TextEditor;" },
role: "terminal",
expected: false
}
];
for (const fixture of fixtures) {
const actual = matchesRole(fixture.entry, role(fixture.role));
if (actual !== fixture.expected) {
console.error(`applications settings contract: ${fixture.name}: expected ${fixture.expected}, got ${actual}`);
process.exit(1);
}
}
'
# ── House rules ──────────────────────────────────────────────────────────────
#
# The page may ask its services to load when it opens -- AppLibrary reads
# nothing until something wants it -- but it may not snapshot desktop entries or
# look one up by hand: both produce a list that stops tracking what is
# installed.
if rg --quiet 'DesktopEntries\.(byId|heuristicLookup)' "$page"; then
fail 'page performs a one-time desktop-entry lookup instead of tracking the live list'
fi
if rg -F --quiet 'label: "Could not apply the change"' "$page"; then
fail 'error heading incorrectly describes read failures as apply failures'
fi
for path in "$page" "$row" "$types"; do
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$path"; then
fail "$(basename "$path") introduces a color literal instead of the shared visual system"
fi
done
# Every component the page draws is registered, or the page does not load at
# all -- and a QML page that fails to load looks like an empty tab.
for component in AutostartAppPicker InstalledAppRow FileTypePicker SettingsChip OptionPickerRow; do
rg -q "^$component 1\.0 $component\.qml$" "$qmldir" \
|| fail "$component is not registered in the Settings module"
done
printf 'applications settings contract: PASS\n'