From 2859ec06c7b6dea6c95bd5a4a48ea8bee4141902 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 25 May 2026 15:45:06 +0200 Subject: [PATCH] Add shell plugin registry contract test --- .../fixtures/plugin-registry/shell.qml | 165 ++++++++++++++++++ test/shell.d/plugin-registry-contract-test.sh | 66 +++++++ 2 files changed, 231 insertions(+) create mode 100644 test/shell.d/fixtures/plugin-registry/shell.qml create mode 100755 test/shell.d/plugin-registry-contract-test.sh diff --git a/test/shell.d/fixtures/plugin-registry/shell.qml b/test/shell.d/fixtures/plugin-registry/shell.qml new file mode 100644 index 00000000..38b4c163 --- /dev/null +++ b/test/shell.d/fixtures/plugin-registry/shell.qml @@ -0,0 +1,165 @@ +import QtQuick +import Quickshell +import "services" + +ShellRoot { + id: root + + readonly property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + property var failures: [] + property int changeCount: 0 + property var config: ({ + version: 1, + bar: { layout: { left: [], center: [], right: [] } }, + plugins: [] + }) + + function fail(message) { + failures.push(String(message)) + } + + function assertTrue(condition, message) { + if (!condition) fail(message) + } + + function assertEqual(actual, expected, message) { + if (actual !== expected) fail(message + " expected=" + expected + " actual=" + actual) + } + + function assertDeepEqual(actual, expected, message) { + var actualJson = JSON.stringify(actual) + var expectedJson = JSON.stringify(expected) + if (actualJson !== expectedJson) fail(message + " expected=" + expectedJson + " actual=" + actualJson) + } + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + function writeResult() { + var payload = JSON.stringify({ + ok: failures.length === 0, + failures: failures, + changeCount: changeCount, + config: config, + ids: Object.keys(registry.installedPlugins).sort() + }) + + if (resultPath) { + Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)]) + } + } + + function manifest(id, kinds, entryPoints) { + return { + schemaVersion: 1, + id: id, + name: id, + version: "1.0.0", + kinds: kinds, + entryPoints: entryPoints + } + } + + function block(kind, source, payload) { + return "===" + kind + "::" + source + "===\n" + + (typeof payload === "string" ? payload : JSON.stringify(payload)) + + "\n=== EOM ===\n" + } + + function has(id) { + return registry.installedPlugins[String(id)] !== undefined + } + + function pluginIds() { + return Object.keys(registry.installedPlugins).sort() + } + + function runChecks() { + var scan = "" + scan += block("firstparty", "/first/widgets/clock", manifest("omarchy.first-widget", ["bar-widget"], { barWidget: "Widget.qml" })) + scan += block("firstparty", "/first/bar", manifest("omarchy.first-bar", ["bar"], { bar: "Bar.qml" })) + scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" })) + scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" })) + scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" })) + scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" })) + scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" })) + scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" })) + scan += block("thirdparty", "/third/missing", { schemaVersion: 1, id: "third.missing", name: "missing", version: "1.0.0", kinds: ["panel"] }) + scan += block("thirdparty", "/third/schema", { schemaVersion: 2, id: "third.schema", name: "schema", version: "1.0.0", kinds: ["panel"], entryPoints: { panel: "Panel.qml" } }) + scan += block("thirdparty", "/third/bad-json", "{") + + registry.parseScanOutput(scan) + + root.assertDeepEqual(pluginIds(), [ + "omarchy.first-bar", + "omarchy.first-widget", + "omarchy.grouped-panel", + "third.panel", + "third.widget" + ], "registry merges valid first-party and third-party manifests") + + root.assertTrue(registry.installedPlugins["omarchy.first-widget"].__isFirstParty === true, "first-party manifests are stamped") + root.assertTrue(registry.installedPlugins["third.panel"].__isFirstParty === false, "third-party manifests are stamped") + root.assertEqual(registry.installedPlugins["omarchy.grouped-panel"].__sourceDir, "/first/panels/grouped", "grouped plugin source paths are preserved") + root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.panel"], "panel"), "file:///third/panel/Panel.qml", "entryPointUrl resolves plugin-relative paths") + root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.widget"], "barWidget"), "file:///third/widget/Widget.qml", "entryPointUrl resolves bar widget paths") + + root.assertTrue(!has("omarchy.reserved"), "third-party omarchy namespace ids are rejected") + root.assertTrue(!has("third.unsafe"), "unsafe entry points are rejected") + root.assertTrue(!has("third.missing"), "incomplete manifests are rejected") + root.assertTrue(!has("third.schema"), "unsupported schema versions are rejected") + + root.assertTrue(registry.isEnabled("omarchy.first-widget"), "first-party plugins are implicitly enabled") + root.assertTrue(registry.isEnabled("omarchy.first-bar"), "bar plugins are implicitly enabled") + root.assertTrue(!registry.isEnabled("third.panel"), "third-party plugins start disabled") + + registry.setEnabled("third.panel", true) + root.assertDeepEqual(root.config.plugins, [{ id: "third.panel" }], "enabling third-party panels writes plugins array") + root.assertTrue(registry.isEnabled("third.panel"), "enabled third-party panels are found") + registry.setEnabled("third.panel", false) + root.assertDeepEqual(root.config.plugins, [], "disabling third-party panels removes plugins array entry") + + registry.setEnabled("third.widget", true) + root.assertDeepEqual(root.config.bar.layout.right, [{ id: "third.widget" }], "enabling bar widgets appends to right layout") + root.assertTrue(registry.isEnabled("third.widget"), "enabled bar widgets are found") + registry.setEnabled("third.widget", false) + root.assertDeepEqual(root.config.bar.layout.right, [], "disabling bar widgets removes layout entry") + + root.config = { + version: 1, + bar: { layout: { left: [], center: [{ id: "third.widget", size: 4 }], right: [] } }, + plugins: [] + } + root.assertTrue(registry.isEnabled("third.widget"), "existing layout entries enable bar widgets") + registry.setEnabled("third.widget", false) + root.assertDeepEqual(root.config.bar.layout.center, [], "disabling existing bar widgets removes the original layout entry") + + root.config = { version: 1 } + registry.setEnabled("third.panel", true) + root.assertDeepEqual(root.config.plugins, [{ id: "third.panel" }], "setEnabled repairs missing plugin config shape") + + root.assertTrue(changeCount > 0, "registry emits change notifications") + writeResult() + } + + PluginRegistry { + id: registry + firstPartyDir: "" + pluginsDir: Quickshell.env("HOME") + "/.config/omarchy/plugins" + shellConfigProvider: function() { return root.config } + shellConfigMutator: function(mutator) { + var next = JSON.parse(JSON.stringify(root.config || {})) + mutator(next) + root.config = next + } + onPluginsChanged: root.changeCount++ + } + + Timer { + interval: 100 + running: true + repeat: false + onTriggered: root.runChecks() + } +} diff --git a/test/shell.d/plugin-registry-contract-test.sh b/test/shell.d/plugin-registry-contract-test.sh new file mode 100755 index 00000000..5d45ba1b --- /dev/null +++ b/test/shell.d/plugin-registry-contract-test.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping plugin registry contract test" + exit 0 +fi + +require_command jq + +TMPDIR=$(mktemp -d) +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +config_dir="$TMPDIR/plugin-registry" +mkdir -p "$config_dir" "$TMPDIR/home" +cp "$SHELL_TEST_DIR/fixtures/plugin-registry/shell.qml" "$config_dir/shell.qml" +ln -s "$ROOT/shell/services" "$config_dir/services" +ln -s "$ROOT/shell/Commons" "$config_dir/Commons" + +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +HOME="$TMPDIR/home" \ +QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ +QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ +PATH="$ROOT/bin:$PATH" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..50}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,180p' "$log" >&2 + fail "plugin registry quickshell exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,180p' "$log" >&2 + fail "plugin registry contract test timed out" +} + +if ! jq -e '.ok == true' "$result" >/dev/null; then + printf 'Plugin registry result:\n' >&2 + jq . "$result" >&2 + printf 'Plugin registry log:\n' >&2 + sed -n '1,180p' "$log" >&2 + fail "plugin registry contract checks pass" +fi + +pass "plugin registry contract checks pass"