Files

236 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import test from "node:test";
const source = readFileSync(new URL("../Services/PluginService.qml", import.meta.url), "utf8");
function service({ enabled = true, type = "widget" } = {}) {
const compiled = new Map();
const files = new Map();
const requests = [];
const pending = [];
const failures = [];
const destroyed = [];
const manifest = { id: "fixture", name: "Fixture", type, component: "./Widget.qml", settings: "./Settings.qml" };
const context = vm.createContext({
Date, Object,
availablePlugins: {}, loadedPlugins: {}, knownManifests: {}, pathToPluginId: {},
pluginWidgetComponents: {}, pluginDesktopComponents: {}, pluginDaemonComponents: {},
pluginLauncherComponents: {}, pluginDashComponents: {}, pluginDashCardComponents: {},
pluginInstances: {}, pluginDaemonInstances: {}, installingPlugins: {}, _componentRevisions: {},
_daemonSpawnQueue: [], _daemonSpawnTimer: { restart() {} },
pluginSurfaceKeys: ["widget", "desktop", "daemon", "launcher", "dash", "dashCard"],
log: { error() {}, warn() {} },
Paths: { toFileUrl: path => path.startsWith("file://") ? path : "file://" + path },
SettingsData: { getPluginSetting: () => enabled },
DMSService: { update: (id, callback) => requests.push(callback), installedPlugins: [] },
ShellVersionService: { checkVersionRequirement: requirement => !requirement.startsWith(">=99"), getParsedShellVersion: () => ({}) },
Component: { Error: 3, PreferSynchronous: 0 },
I18n: { tr: text => ({ arg: value => text.replace("%1", value) }) },
ToastService: { showError() {} },
Qt: { createComponent(url) {
if (!compiled.has(url))
compiled.set(url, { value: files.get(url.split("?")[0]), createObject() { return this.value; }, destroy() { destroyed.push(url); } });
return compiled.get(url);
} },
pluginLoaded() {}, pluginUnloaded() {}, pluginListUpdated() {},
pluginLoadFailed: (id, error) => failures.push(error),
_clearLoadError() {}, _setLoadError() {}, _updateAvailablePluginsList() {},
_loadPluginTranslations() {}, _cleanupPluginStateWriter() {},
isPluginLoaded: id => !!context.loadedPlugins[id],
loadPluginManifestFile: (path, source, time) => pending.push(() => context._onManifestParsed(path, manifest, source, time))
});
context.root = context;
for (const match of source.matchAll(/^ function (\w+)\([^\n]*\)(?:: \w+)? \{\n[\s\S]*?^ \}/gm)) {
if (match[1] in context)
continue;
vm.runInContext(match[0], context);
}
files.set("file:///plugins/fixture/Widget.qml", 12);
context._onManifestParsed("/plugins/fixture/plugin.json", manifest, "user", 1);
return { context, files, requests, pending, failures, manifest, destroyed };
}
test("successful updates replace cached components and reread the manifest", () => {
const { context, files, requests, pending, manifest } = service();
assert.equal(context.pluginWidgetComponents.fixture.value, 12);
files.set("file:///plugins/fixture/Widget.qml", 16);
manifest.version = "2.0.0";
let response;
context.updatePlugin("fixture", value => response = value);
const result = { result: { success: true } };
requests.shift()(result);
assert.equal(response, result);
assert.equal(pending.length, 1);
pending.shift()();
assert.equal(context.pluginWidgetComponents.fixture.value, 16);
assert.equal(context.availablePlugins.fixture.version, "2.0.0");
});
test("an update whose registry entry needs a newer shell is refused before the download", () => {
const { context, requests, pending } = service();
context.DMSService.installedPlugins = [{ id: "fixture", requires_dms: ">=99.0.0" }];
let response;
context.updatePlugin("fixture", value => response = value);
assert.equal(requests.length, 0);
assert.equal(pending.length, 0);
assert.match(response.error, /99\.0\.0/);
context.DMSService.installedPlugins = [{ id: "fixture", requires_dms: ">=1.0.0" }];
context.updatePlugin("fixture");
assert.equal(requests.length, 1);
});
test("failed updates preserve the running plugin and forward the error", () => {
const { context, requests, pending } = service();
const component = context.pluginWidgetComponents.fixture;
let response;
context.updatePlugin("fixture", value => response = value);
requests.shift()({ error: "download failed" });
assert.equal(response.error, "download failed");
assert.equal(pending.length, 0);
assert.equal(context.pluginWidgetComponents.fixture, component);
});
test("updating a disabled plugin keeps it disabled and its next load is fresh", () => {
const { context, files, requests, pending } = service({ enabled: false });
context.loadPlugin("fixture");
context.unloadPlugin("fixture");
files.set("file:///plugins/fixture/Widget.qml", 18);
context.updatePlugin("fixture");
requests.shift()({});
pending.shift()();
assert.equal(context.isPluginLoaded("fixture"), false);
context.loadPlugin("fixture");
assert.equal(context.pluginWidgetComponents.fixture.value, 18);
});
test("reload and rescan retain fresh components across disable and enable", () => {
const { context, files, pending } = service();
for (let value = 13; value <= 16; value++) {
files.set("file:///plugins/fixture/Widget.qml", value);
assert.equal(context.reloadPlugin("fixture"), true);
assert.equal(context.pluginWidgetComponents.fixture.value, value);
context.unloadPlugin("fixture");
context.loadPlugin("fixture");
assert.equal(context.pluginWidgetComponents.fixture.value, value);
}
files.set("file:///plugins/fixture/Widget.qml", 20);
context.forceRescanPlugin("fixture");
pending.shift()();
assert.equal(context.pluginWidgetComponents.fixture.value, 20);
});
test("rescan picks up changed surfaces and preserves automatic desktop loading", () => {
const { context, files, pending, manifest } = service({ enabled: false, type: "desktop" });
assert.equal(context.pluginDesktopComponents.fixture.value, 12);
manifest.component = "./Desktop.qml";
files.set("file:///plugins/fixture/Desktop.qml", 24);
context.forceRescanPlugin("fixture");
pending.shift()();
assert.equal(context.pluginDesktopComponents.fixture.value, 24);
});
test("single component dash plugins load onto their dash surface", () => {
for (const [type, key] of [["dash", "pluginDashComponents"], ["dashCard", "pluginDashCardComponents"]]) {
const { context } = service({ type });
assert.deepEqual(context.availablePlugins.fixture.surfaces, [type]);
assert.equal(context[key].fixture.value, 12);
assert.equal(context.pluginWidgetComponents.fixture, undefined);
}
});
test("unloading destroys the plugin's components", () => {
const { context, destroyed } = service();
assert.equal(context.unloadPlugin("fixture"), true);
assert.deepEqual(destroyed, ["file:///plugins/fixture/Widget.qml"]);
assert.equal(context.pluginWidgetComponents.fixture, undefined);
});
test("settings and startup checks use the current plugin revision", () => {
const { context, files } = service();
const path = "/plugins/fixture/Settings.qml";
files.set("file://" + path, 12);
const before = context.pluginComponentUrl("fixture", path);
assert.equal(context.Qt.createComponent(before).value, 12);
files.set("file://" + path, 18);
context.reloadPlugin("fixture");
const after = context.pluginComponentUrl("fixture", path);
assert.equal(context.Qt.createComponent(after).value, 18);
assert.equal(context.pluginComponentUrl("fixture", "file://" + path), after);
assert.equal(context.pluginComponentUrl("fixture", null), "");
assert.equal(context._makeStartupCheckObject("fixture", { startupCheckPath: path }), 18);
});
test("unload failures abort reload and rescan without discarding the manifest", () => {
const { context, pending } = service();
const plugin = context.availablePlugins.fixture;
context.unloadPlugin = () => false;
assert.equal(context.reloadPlugin("fixture"), false);
context.forceRescanPlugin("fixture");
assert.equal(context.availablePlugins.fixture, plugin);
assert.equal(pending.length, 0);
});
test("updated startup checks still prevent activation on failure", () => {
const { context, files, pending, manifest, failures } = service();
manifest.startupCheck = "./Check.qml";
files.set("file:///plugins/fixture/Check.qml", {
check: () => "dependency unavailable",
destroy() {}
});
context.forceRescanPlugin("fixture");
pending.shift()();
assert.equal(context.isPluginLoaded("fixture"), false);
assert.equal(failures.at(-1), "dependency unavailable");
files.set("file:///plugins/fixture/Check.qml", {
check: () => null,
destroy() {}
});
context.forceRescanPlugin("fixture");
pending.shift()();
assert.equal(context.isPluginLoaded("fixture"), true);
files.set("file:///plugins/fixture/Check.qml", {
check: () => "dependency removed",
destroy() {}
});
context.reloadPlugin("fixture");
assert.equal(context.isPluginLoaded("fixture"), false);
assert.equal(failures.at(-1), "dependency removed");
});
test("startup checks that cannot run prevent activation", () => {
const { context, files, pending, manifest, failures } = service();
const createComponent = context.Qt.createComponent;
context.Qt.createComponent = url => url.startsWith("file:///plugins/fixture/Broken.qml") ? { status: 3, errorString: () => "module missing" } : createComponent(url);
const cases = [
["./Broken.qml", null, "module missing"],
["./Check.qml", { destroy() {} }, "startupCheck has no check function"],
["./Check.qml", { check() { throw new Error("sync throw"); }, destroy() {} }, "sync throw"],
["./Check.qml", { check(done) { throw new Error("async throw"); }, destroy() {} }, "async throw"]
];
for (const [path, probe, error] of cases) {
manifest.startupCheck = path;
files.set("file:///plugins/fixture/Check.qml", probe);
context.forceRescanPlugin("fixture");
pending.shift()();
assert.equal(context.isPluginLoaded("fixture"), false, error);
assert.equal(failures.at(-1), error);
}
});
test("composite updates refresh every surface without creating daemons synchronously", () => {
const { context, files, pending, manifest } = service();
delete manifest.component;
manifest.components = Object.fromEntries(context.pluginSurfaceKeys.map(surface => [surface, "./" + surface + ".qml"]));
for (const value of [12, 20]) {
for (const surface of context.pluginSurfaceKeys)
files.set("file:///plugins/fixture/" + surface + ".qml", value);
context.forceRescanPlugin("fixture");
pending.shift()();
for (const key of ["pluginWidgetComponents", "pluginDesktopComponents", "pluginDaemonComponents", "pluginLauncherComponents", "pluginDashComponents", "pluginDashCardComponents"])
assert.equal(context[key].fixture.value, value);
assert.equal(context.pluginDaemonInstances.fixture, undefined);
}
});