openpetswithchatandmcp/apps/desktop/tests/plugin-sdk-bridge.test.ts
2026-06-11 21:25:55 +02:00

275 lines
10 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PluginSdkBridge, type PluginHostCapabilities } from "../src/plugin-sdk-bridge.js";
import { pluginSdkQuotas } from "../src/plugin-sdk-quotas.js";
import { PluginStateStore, type PluginStateRecord } from "../src/plugin-state.js";
import type { OpenPetsJavascriptPluginManifest } from "../src/plugin-manifest.js";
import { sanitizePluginDiagnosticsFields } from "../src/plugin-diagnostics.js";
await scenario("storage.subscribe receives set value and delete as undefined", async ({ api }) => {
const values: unknown[] = [];
api.storage.subscribe("counter", (value: unknown) => values.push(value));
api.storage.set("counter", 3);
api.storage.delete("counter");
await Promise.resolve();
assert.equal(values.length, 2);
assert.equal(values[0], 3);
assert.equal(values[1], undefined);
});
await scenario("storage subscription quota is enforced", async ({ api }) => {
for (let i = 0; i < pluginSdkQuotas.storageSubscriptions; i += 1) {
api.storage.subscribe(`key-${i}`, () => undefined);
}
assert.throws(
() => api.storage.subscribe("one-too-many", () => undefined),
/Plugin storage subscription quota exceeded\./,
);
});
await scenario("config.onChange disposer removes listener", async ({ api, bridge, store }) => {
const seen: unknown[] = [];
const dispose = api.config.onChange((config: Record<string, unknown>) => seen.push(config.value));
store.replaceConfig("plug", { value: "first" });
bridge.notifyConfigChanged("plug");
await Promise.resolve();
dispose();
store.replaceConfig("plug", { value: "second" });
bridge.notifyConfigChanged("plug");
await Promise.resolve();
assert.deepEqual(seen, ["first"]);
});
await scenario("diagnostics sanitizer redacts paths tokens and URL queries", () => {
const safe = sanitizePluginDiagnosticsFields({ reason: "failed /Users/alvin/secrets/token.txt https://example.com/path?token=abc123 sk-1234567890123456", host: "example.com", ignored: "secret" });
assert.equal(safe.host, "example.com");
assert.equal("ignored" in safe, false);
const reason = String(safe.reason);
assert.equal(reason.includes("/Users/alvin"), false);
assert.equal(reason.includes("abc123"), false);
assert.equal(reason.includes("sk-1234567890123456"), false);
});
await scenario("events.on config:changed uses config listener path", async ({ api, bridge, store, capabilities }) => {
const seen: unknown[] = [];
const sub = api.events.on("config:changed", (config: Record<string, unknown>) => seen.push(config.value));
store.replaceConfig("plug", { value: "first" });
bridge.notifyConfigChanged("plug");
await Promise.resolve();
api.events.off(sub.subscriptionId);
store.replaceConfig("plug", { value: "second" });
bridge.notifyConfigChanged("plug");
await Promise.resolve();
assert.deepEqual(seen, ["first"]);
assert.deepEqual(capabilities.events.subscribed, []);
});
await scenario("commands accept declared icon asset refs and reject raw svg strings", ({ api, bridge }) => {
api.commands.register({ id: "focus", title: "Focus", icon: { kind: "icon", name: "focus" } }, () => undefined);
assert.deepEqual(bridge.getPublicState("plug").commands[0]?.icon, { kind: "icon", name: "focus" });
assert.throws(
() => api.commands.register({ id: "raw-svg", title: "Raw SVG", icon: "<svg></svg>" }, () => undefined),
/Invalid plugin command icon\./,
);
});
await scenario("pet.react validates silent reaction options", async ({ api }) => {
await api.pet.react("waving", { showMessage: false });
await assert.rejects(() => api.pet.react("waving", { showMessage: "no" }), /Invalid pet reaction showMessage option\./);
await assert.rejects(() => api.pet.react("waving", { showMessage: false, extra: true }), /Invalid pet reaction option\./);
});
await scenario("hud bubble spec validation is enforced", async ({ store, bridge }) => {
const record = store.getRecord("plug")!;
const updatedRecord = {
...record,
approvedPermissions: [...record.approvedPermissions, "pet:pin" as const],
};
store.upsertRecord(updatedRecord);
const approvedApi = bridge.createApi(updatedRecord, manifest());
// Should succeed with valid HUD
await approvedApi.ui.bubble({
pin: true,
hud: {
items: [
{ icon: "food", value: 80, tone: "amber", label: "Food" },
],
},
});
// Should reject if pin: true is missing
await assert.rejects(
() => approvedApi.ui.bubble({
hud: {
items: [
{ icon: "food", value: 80, tone: "amber", label: "Food" },
],
},
}),
/Bubble HUD descriptor is only allowed for pinned bubbles\./,
);
// Should reject if combined with text
await assert.rejects(
() => approvedApi.ui.bubble({
pin: true,
text: "hello",
hud: {
items: [
{ icon: "food", value: 80, tone: "amber", label: "Food" },
],
},
}),
/Plugin bubble HUD cannot be combined with text, markdown, body media, or indicator\./,
);
// Should reject if items contains more than 4 items
await assert.rejects(
() => approvedApi.ui.bubble({
pin: true,
hud: {
items: [
{ icon: "food", value: 80 },
{ icon: "zap", value: 80 },
{ icon: "play", value: 80 },
{ icon: "heart", value: 80 },
{ icon: "star", value: 80 },
],
},
}),
/Bubble HUD items must contain between 1 and 4 items\./,
);
// Should reject if item lacks icon
await assert.rejects(
() => approvedApi.ui.bubble({
pin: true,
hud: {
items: [
{ value: 80 },
],
},
}),
/Bubble HUD item must have an icon\./,
);
// Should reject if item value is outside 0..100
await assert.rejects(
() => approvedApi.ui.bubble({
pin: true,
hud: {
items: [
{ icon: "food", value: 150 },
],
},
}),
/Bubble HUD item value must be a number between 0 and 100\./,
);
});
type ScenarioContext = {
api: ReturnType<PluginSdkBridge["createApi"]>;
bridge: PluginSdkBridge;
store: PluginStateStore;
capabilities: TestCapabilities;
};
async function scenario(name: string, run: (context: ScenarioContext) => Promise<void> | void): Promise<void> {
const root = mkdtempSync(join(tmpdir(), "openpets-plugin-sdk-"));
try {
const store = new PluginStateStore({ statePath: join(root, "state.json") });
store.initialize();
const record: PluginStateRecord = {
id: "plug",
version: "1.0.0",
manifestPath: join(root, "openpets.plugin.json"),
installPath: root,
source: "local",
manifestVersion: 3,
runtime: "javascript",
sdkVersion: "3.0.0",
enabled: true,
approvedPermissions: ["commands", "events", "storage", "pet:reaction"],
config: {},
};
store.upsertRecord(record);
const capabilities = createTestCapabilities();
const bridge = new PluginSdkBridge({
stateStore: store,
petApi: { speak() {}, react() {}, moveBy() {}, wander() {}, moveToHome() {} },
scheduler: { setTimeout: () => ({ cancel() {} }) },
capabilities,
});
const api = bridge.createApi(record, manifest());
await run({ api, bridge, store, capabilities });
} finally {
rmSync(root, { recursive: true, force: true });
}
}
type TestCapabilities = PluginHostCapabilities & { events: PluginHostCapabilities["events"] & { subscribed: string[] } };
function createTestCapabilities(): TestCapabilities {
return {
bubbles: { show: async () => ({ id: "bubble", update: async () => undefined, dismiss: async () => undefined, pin: async () => undefined, unpin: async () => undefined }) },
audio: { play: async () => undefined, importUserSound: async (_pluginId, _fileId, opts) => ({ kind: "user-sound", id: "0".repeat(32), name: opts?.name }), forgetUserSound: async () => undefined, stop: async () => undefined },
events: { subscribed: [], subscribe(event) { this.subscribed.push(event); return () => undefined; } },
pets: {
list: () => [],
spawn: async () => "pet",
close: async () => undefined,
show: async () => undefined,
hide: async () => undefined,
react: async () => undefined,
setAnimation: async () => undefined,
setScale: async () => undefined,
setStatusReaction: async () => undefined,
moveBy: async () => undefined,
wander: async () => undefined,
moveToHome: async () => undefined,
moveTo: async () => undefined,
followCursor: async () => undefined,
physics: async () => undefined,
getState: async () => ({ position: { x: 0, y: 0 }, bounds: { x: 0, y: 0, width: 0, height: 0 }, currentAnimation: "idle", visible: true, dragging: false }),
onTick: () => () => undefined,
onChange: () => () => undefined,
},
toast: async () => undefined,
notify: async () => undefined,
panels: { open: async () => ({ id: "panel", show: async () => undefined, hide: async () => undefined, postMessage: async () => undefined, close: async () => undefined }) },
secrets: { get: async () => undefined, set: async () => undefined, delete: async () => undefined, has: async () => false },
ai: { available: async () => false, complete: async () => ({ text: "" }), stream: async () => ({ text: "" }) },
voice: { speak: async () => undefined, listen: async () => ({ text: "" }) },
auth: { oauth: async () => ({ accessToken: "" }), refresh: async () => ({ accessToken: "" }), signOut: async () => undefined },
files: { pick: async () => [], read: async () => "", save: async () => undefined },
system: { info: async () => ({ platform: "mac", locale: "en-US", timezone: "UTC", theme: "light", appVersion: "0.0.0", online: true }), metrics: async () => ({ cpuPercent: 0, memUsedPercent: 0 }), openExternal: async () => undefined, readClipboardText: async () => "", writeClipboardText: async () => undefined },
settings: { audioAllowed: () => true, dynamicSpeechAllowed: () => false, voiceAllowed: () => true, listenAllowed: () => false, inQuietHours: () => false },
};
}
function manifest(): OpenPetsJavascriptPluginManifest {
return {
manifestVersion: 3,
id: "plug",
name: "Plug",
version: "1.0.0",
runtime: "javascript",
sdkVersion: "3.0.0",
entry: "index.js",
permissions: ["commands", "events", "storage"],
assets: { icons: { focus: "assets/focus.svg" } },
};
}