Files
Netcatty/electron/plugins/hostInteractionExample.test.cjs
T

118 lines
8.8 KiB
JavaScript

"use strict";
const assert = require("node:assert/strict");
const { readFile } = require("node:fs/promises");
const { EventEmitter } = require("node:events");
const { pathToFileURL } = require("node:url");
const test = require("node:test");
const { MessageChannel } = require("node:worker_threads");
const { PluginRpcRouter } = require("./rpcRouter.cjs");
const { PluginHostRpcRegistry } = require("./hostRpcRegistry.cjs");
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
const { PluginHostInteractionService } = require("./hostInteractionService.cjs");
const { PluginContributionService } = require("./contributionService.cjs");
for (const source of ["compose", "share"]) test(`normal browser example completes the ${source} text workflow through permission middleware`, async () => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const plugin = await import("../../examples/plugins/host-interactions/browser.js");
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
const audit = [], copied = [], requests = [];
let reads = 0, active = true;
const database = { listPermissionGrants: () => [], recordSecurityAudit(...args) { audit.push(args); } };
const permissionEngine = new PluginPermissionEngine({ database,
requestDecision: async (request) => { requests.push(request.permission); return { requestId: request.requestId, decision: "allow", scope: "once" }; },
});
let service;
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false,
send(_channel, message) {
if (message.closed) return;
queueMicrotask(() => {
const action = message.request.kind === "clipboard.read" && message.phase === "request" ? "preview" : "submit";
const value = message.request.kind === "quickPick" ? source : message.request.kind === "input" ? "synthetic output" : undefined;
void service.respond(sender, { requestId: message.requestId, actionToken: message.actionToken, action, ...(value === undefined ? {} : { value }) }).catch(() => {});
});
},
});
const window = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
service = new PluginHostInteractionService({ database, permissionEngine, getWindow: () => window, isLocked: () => false,
quotaManager: { chargeBytes() {} },
clipboard: { readText() { reads++; return "synthetic private text"; }, writeText(text) { copied.push(text); } },
});
service.setReady(sender, true);
const registry = new PluginHostRpcRegistry();
registry.use(permissionEngine.createMiddleware()); service.registerRpcCapabilities(registry);
const routes = registry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-synthetic", runtimeKind: "browser", manifest,
assertCurrent() { if (!active) throw new Error("stale runtime"); },
});
const { port1, port2 } = new MessageChannel();
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message), handlers: routes.requestHandlers });
port1.on("message", (message) => { void host.accept(message); });
const peer = await startPluginRuntime({ port: port2,
config: { pluginId: manifest.id, pluginVersion: manifest.version, netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: ["host.interactions"] },
loadPlugin: async () => plugin,
});
try {
await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: ["host.interactions"] });
await host.request("plugin.activate", {});
const result = await host.request("plugin.command.execute", { command: "com.netcatty.host-interactions.text", invocation: { source: "palette" } });
const output = source === "compose" ? "synthetic output" : "SYNTHETIC PRIVATE TEXT";
assert.deepEqual(result, { copied: true, characters: output.length });
assert.deepEqual(copied, [output]); assert.equal(reads, source === "share" ? 1 : 0);
assert.ok(requests.includes("ui.interact")); assert.ok(requests.includes("clipboard.write"));
assert.equal(requests.includes("clipboard.read"), source === "share");
assert.equal(JSON.stringify(audit).includes("synthetic private text"), false);
assert.equal(JSON.stringify(audit).includes("synthetic output"), false);
assert.equal(service.pending.size, 0);
} finally { active = false; await peer.dispose(); host.close(); port1.close(); service.shutdown(); permissionEngine.shutdown(); }
});
test("outer command deadline closes its real browser clipboard confirmation and rejects late commit", async () => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const plugin = await import("../../examples/plugins/host-interactions/browser.js");
const manifest = JSON.parse(await readFile(new URL("../../examples/plugins/host-interactions/netcatty.plugin.json", pathToFileURL(__filename)), "utf8"));
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "runtime-deadline", runtimeKind: "browser" };
const writes = [], messages = [];
const database = { listPermissionGrants: () => [], recordSecurityAudit() {},
listPlugins: () => [{ id: manifest.id, activeVersion: manifest.version, manifest, enabled: true, runtime: { status: "running" } }],
};
const engine = new PluginPermissionEngine({ database, requestDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" }) });
let service, confirm;
const copyPrompt = new Promise((resolve) => { confirm = resolve; });
const sender = Object.assign(new EventEmitter(), { id: 8, isDestroyed: () => false, send(_channel, message) {
messages.push(message); if (message.closed) return;
if (message.request.kind === "clipboard.write") { confirm(message); return; }
const value = message.request.kind === "quickPick" ? "compose" : "synthetic pending copy";
queueMicrotask(() => { void service.respond(sender, { requestId: message.requestId, actionToken: message.actionToken, action: "submit", value }).catch(() => {}); });
} });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
service = new PluginHostInteractionService({ database, permissionEngine: engine, getWindow: () => owner, isLocked: () => false,
quotaManager: { chargeBytes() {} }, clipboard: { writeText(text) { writes.push(text); } } });
service.setReady(sender, true);
const registry = new PluginHostRpcRegistry(); registry.use(engine.createMiddleware()); service.registerRpcCapabilities(registry);
const routes = registry.createRoutes({ ...identity, manifest, assertCurrent() {} });
const { port1, port2 } = new MessageChannel();
const host = new PluginRpcRouter({ pluginId: manifest.id, send: (message) => port1.postMessage(message), handlers: routes.requestHandlers });
port1.on("message", (message) => { void host.accept(message); });
const features = manifest.features.required;
const peer = await startPluginRuntime({ port: port2, config: { ...identity, netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: features }, loadPlugin: async () => plugin });
try {
await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: features });
await host.request("plugin.activate", {});
const contributions = new PluginContributionService({ database, onCommandFinished: (runtimeId) => service.cancelRuntimeRequests(runtimeId),
runtimeSupervisor: { async start() { return identity; }, request(_pluginId, method, params, options) {
assert.equal(options.timeoutMs, 180_000, "host chooses the fixed declared budget");
return host.request(method, params, { timeoutMs: 500 }); // bounded synthetic timer, no production timeout change
} },
});
const command = contributions.executeCommand("com.netcatty.host-interactions.text");
const prompt = await Promise.race([copyPrompt, command.then(() => { throw new Error("command finished before confirmation"); })]);
const attempts = service.rates.get(identity.runtimeId).count;
await assert.rejects(command, /timed out/);
assert.equal(service.pending.size, 0);
assert.equal(service.rates.get(identity.runtimeId).count, attempts);
assert.ok(messages.some((message) => message.closed && message.requestId === prompt.requestId));
assert.equal(await service.respond(sender, { requestId: prompt.requestId, actionToken: prompt.actionToken, action: "submit" }), false);
assert.deepEqual(writes, []);
assert.equal(contributions.activeCommandCalls.size, 0);
} finally { await peer.dispose(); host.close(); port1.close(); service.shutdown(); engine.shutdown(); }
});