275 lines
10 KiB
TypeScript
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" } },
|
|
};
|
|
}
|