Files
Netcatty/scripts/plugin-assistant-panel.test.cjs
T
陈大猫 d5a03606b7 feat: rebuild plugin UI with host rendering and standard settings (#4053)
* feat: rebuild plugin pages with host rendering and native settings

* fix(plugins): contain host-view errors, isolate editing, and migrate fixtures

* fix(plugins): migrate remaining permission fixture hosts
2026-10-28 23:08:31 -07:00

194 lines
11 KiB
JavaScript

"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { JSDOM } = require("jsdom");
async function fixture(t, options = {}) {
const root = path.resolve(__dirname, "../examples/plugins/assistant");
const dom = new JSDOM(fs.readFileSync(path.join(root, "view.html"), "utf8"), { runScripts: "outside-only" });
t.after(() => dom.window.close());
const stylesheet = dom.window.document.createElement("style");
stylesheet.textContent = fs.readFileSync(path.join(root, "view.css"), "utf8");
dom.window.document.head.append(stylesheet);
let now = 0; let opens = 0; let modelCalls = 0; let changed = false;
const calls = [];
let messageListeners = 0;
const element = (id) => dom.window.document.getElementById(id);
dom.window.Date.now = options.clock ?? (() => now);
const commands = {
executeCommand: async (id, params) => {
const operation = id.replace("com.netcatty.assistant.", ""); calls.push({ operation, params });
if (options.commands) return options.commands(operation, structuredClone(params));
if (operation === "open") {
const count = ++opens;
if (count > 1 && options.opening) await options.opening;
return { chatHandle: `chat-${count}`, model: `Synthetic model ${count}`, expiresAt: now + 300_000 };
}
if (operation === "contexts") return [];
if (operation === "close" || operation === "stop") return { ok: true };
if (operation === "send") {
if (params.chatHandle === "chat-1" && (changed || now >= 300_000)) throw Error("Session unavailable");
modelCalls++;
if (options.uncertain) throw Error("Reply lost after invocation");
return { turnHandle: "synthetic-turn" };
}
if (operation === "read") return { kind: "end", sequence: 0 };
throw Error(`Unexpected operation ${operation}`);
},
};
const waitFor = async (condition) => {
const deadline = Date.now() + 2_000;
while (!condition()) {
if (Date.now() >= deadline) throw Error(`Panel state timed out: ${element("status").textContent}`);
await new Promise(setImmediate);
}
};
const mount = dom.window.eval('(' + fs.readFileSync(path.join(root, "panel.js"), "utf8").replace('export function mount', 'function mount') + ')');
const unmount = mount(dom.window.document.body, { commands, views: { onDidReceiveMessage() { messageListeners++; return { dispose() { messageListeners--; } }; } } }, 'com.netcatty.assistant.panel');
let disposed = false;
const dispose = () => { if (!disposed) { disposed = true; unmount(); } };
t.after(dispose);
await waitFor(() => element("status").textContent === "Ready");
element("prompt").value = "Synthetic draft must survive session recovery";
return { element, calls, waitFor, window: dom.window, opens: () => opens, modelCalls: () => modelCalls,
dispose, messageListeners: () => messageListeners,
timeTo: (value) => { now = value; }, changeModel: () => { changed = true; },
send: () => element("compose").dispatchEvent(new dom.window.Event("submit", { cancelable: true })) };
}
test("assistant mount cleanup unsubscribes messages and closes its own chat", async (t) => {
const f = await fixture(t);
assert.equal(f.messageListeners(), 1);
f.dispose();
assert.equal(f.messageListeners(), 0);
assert.equal(f.calls.filter(call => call.operation === 'close' && call.params.chatHandle === 'chat-1').length, 1);
});
test("expired sample chat reopens before one explicit send and preserves the draft", async (t) => {
const f = await fixture(t); f.timeTo(300_001); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.opens(), 2);
assert.deepEqual(f.calls.filter((call) => call.operation === "send").map((call) => call.params.chatHandle), ["chat-2"]);
assert.equal(f.modelCalls(), 1);
assert.equal(f.element("status").textContent, "Complete");
assert.match(f.element("prompt").value, /must survive/);
});
test("changed-model rejection offers explicit reopen without automatically resending", async (t) => {
const f = await fixture(t); f.changeModel(); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.opens(), 1); assert.equal(f.modelCalls(), 0);
assert.ok(f.element("refresh"), "A changed model needs an explicit session refresh control");
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 0);
assert.match(f.element("model").textContent, /model 2/);
assert.match(f.element("prompt").value, /must survive/);
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(f.modelCalls(), 1);
});
test("uncertain inference outcome is never automatically retried by session refresh", async (t) => {
const f = await fixture(t, { uncertain: true }); f.send();
await f.waitFor(() => !f.element("prompt").disabled);
assert.equal(f.modelCalls(), 1); assert.equal(f.opens(), 1);
assert.ok(f.element("refresh"), "Recovery must reopen only, never retry inference");
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(f.opens(), 2); assert.equal(f.modelCalls(), 1);
assert.match(f.element("prompt").value, /must survive/);
});
test("Stop during expired-session reopening discards the late session without sending", async (t) => {
let resume; const opening = new Promise((resolve) => { resume = resolve; });
t.after(() => resume());
const f = await fixture(t, { opening }); f.timeTo(300_001); f.send();
await f.waitFor(() => f.opens() === 2);
f.element("stop").click(); resume();
await f.waitFor(() => f.calls.some((call) => call.operation === "close" && call.params.chatHandle === "chat-2"));
assert.equal(f.modelCalls(), 0); assert.equal(f.element("status").textContent, "Stopped");
assert.match(f.element("prompt").value, /must survive/);
});
test("expired real selected Note requires explicit re-selection and fresh content permission without duplicate review", async (t) => {
const { PluginAIContextService } = require("../electron/plugins/ai/aiContextService.cjs");
let now = 0; let selections = 0; let reads = 0; let modelCalls = 0;
let contentAllowed = false;
const grants = []; const reviews = [];
const caller = { pluginId: "com.netcatty.assistant", runtimeId: "selected-note-panel", pluginVersion: "0.1.0", assertActive: async () => {} };
const source = { id: "synthetic-source", kind: "third-party", origin: "https://synthetic.example" };
const service = new PluginAIContextService({
database: {
getActivePlugin: () => ({ source, enabled: true, activeVersion: caller.pluginVersion, runtime: { status: "running" } }),
getRetainedPluginSource: () => source, privateOwner: () => "synthetic-private-owner", publicPluginId: () => caller.pluginId,
},
getRuntimeIdentity: () => ({ ...caller, runtimeKind: "browser" }),
permissionEngine: { onDidRevoke: () => ({ dispose() {} }), authorize: async (_caller, grant) => {
grants.push(grant); if (!contentAllowed) throw Error("Synthetic content grant denied");
} },
hostAdapter: {
assertActive: async () => {},
selectModel: async () => ({ label: "Synthetic model", isCurrent: () => true, invoke: () => {
modelCalls++; return (async function* () { yield { kind: "text", text: "Synthetic summary" }; })();
} }),
listSelected: async () => {
selections++;
return [{ key: "synthetic-note", scope: "note", title: "Synthetic selected Note", version: "v1", isCurrent: () => true,
readText: () => { reads++; return "Synthetic selected Note body"; } }];
},
reviewText: async (text) => { reviews.push(text); throw Error("Unexpected duplicate content review"); },
},
});
service.selected.clock = () => now; service.broker.clock = () => now;
t.after(() => service.shutdown());
const commands = {
open: () => service.open({}, caller), contexts: async () => (await service.list({}, caller)).items,
send: (params) => service.start(params, caller), read: (params) => service.read(params, caller),
stop: (params) => service.stop(params, caller), close: (params) => service.close(params, caller),
};
const f = await fixture(t, { clock: () => now, commands: (operation, params) => commands[operation](params) });
const selector = f.element("context"); const oldHandle = selector.options[1].value;
selector.value = oldHandle;
now = 300_001;
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.equal(f.calls.filter((call) => call.operation === "send").length, 0);
assert.equal(selections, 1); assert.equal(modelCalls, 0); assert.equal(reads, 0);
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
f.element("refresh").click(); await f.waitFor(() => f.element("status").textContent === "Ready");
assert.equal(selections, 1); // Refresh model must not silently share the Note again.
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.ok(f.element("choose-reference"));
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
assert.equal(selections, 2); assert.equal(selector.value, "");
const newHandle = selector.options[1].value;
assert.notEqual(newHandle, oldHandle);
const [fresh] = await service.selected.summaries(caller);
assert.equal(fresh.handle, newHandle); assert.equal(fresh.expiresAt, 600_001);
await assert.rejects(service.selected.assertCurrent({ handle: oldHandle, version: "v1" }, caller));
assert.equal(reads, 0); assert.equal(grants.length, 0); assert.equal(modelCalls, 0);
selector.value = newHandle;
f.send(); await f.waitFor(() => f.element("status").textContent.startsWith("Request ended"));
assert.equal(grants.length, 1); assert.equal(reads, 0); assert.equal(modelCalls, 0);
contentAllowed = true;
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(modelCalls, 1); assert.equal(reviews.length, 0);
assert.ok(grants.every((grant) => grant.permission === "context.selected.read" && grant.allowedScopes[0] === "once"
&& grant.resources[0] === `selected:${newHandle}:v1`));
assert.match(f.element("prompt").value, /must survive/);
// Repeat with both lifetimes expired, without manually refreshing the model.
now = 600_002;
f.send(); await f.waitFor(() => !f.element("prompt").disabled);
assert.match(f.element("status").textContent, /reference expired/i);
assert.equal(modelCalls, 1); assert.equal(selections, 2);
f.element("choose-reference").click(); await f.waitFor(() => f.element("status").textContent.startsWith("References updated"));
const repeatedHandle = selector.options[1].value;
assert.notEqual(repeatedHandle, newHandle); assert.equal(selector.value, "");
selector.value = repeatedHandle;
f.send(); await f.waitFor(() => f.element("status").textContent === "Complete");
assert.equal(modelCalls, 2); assert.equal(grants.at(-1).resources[0], `selected:${repeatedHandle}:v1`);
await assert.rejects(service.selected.assertCurrent({ handle: newHandle, version: "v1" }, caller));
assert.match(f.element("prompt").value, /must survive/);
});