test / plugin.test.ts
test / plugin.test.ts
// The LM Studio plugin: settings, the tools it hands LM Studio, the bridge it keeps for the
// whole process, and the /simplesearch commands.
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import { FakeExtension } from "./fake-extension";
import { preprocess, readSettings, session, stopSession, toolsProvider } from "../src/plugin";
const SECRET = "5eed".repeat(16);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ss-plugin-"));
process.env.SIMPLESEARCH_DATA = path.join(home, "data");
function randomPort(): number {
return 20000 + Math.floor(Math.random() * 20000);
}
// Stands in for LM Studio's controller: only the global plugin settings are used.
function controller(values: Record<string, unknown>) {
return { getGlobalPluginConfig: () => ({ get: (k: string) => values[k] }) } as never;
}
function message(text: string) {
let replaced: string | null = null;
const msg = {
getText: () => text,
asMutableCopy: () => ({ replaceText: (t: string) => (replaced = t), get text() { return replaced; } }),
};
return msg as never;
}
const port = randomPort();
const settings = { secret: SECRET, port, browserPath: path.join(home, "no-such-browser"), engines: "duckduckgo, mojeek" };
let ext: FakeExtension;
before(async () => {
ext = new FakeExtension(port, SECRET);
ext.start();
});
after(async () => {
ext.stop();
await stopSession();
});
test("settings fall back to the defaults when LM Studio has none", () => {
const s = readSettings({ getGlobalPluginConfig: () => { throw new Error("no config"); } } as never);
assert.equal(s.port, 8790);
assert.equal(s.profile, "llm-browser");
assert.equal(s.autoRepair, false);
const partial = readSettings(controller({ secret: "abc", port: undefined }));
assert.equal(partial.secret, "abc");
assert.equal(partial.port, 8790);
});
test("the plugin offers the same five tools and they search through the extension", async () => {
const tools = (await toolsProvider(controller(settings))) as unknown as Array<{ name: string; implementation: (a: unknown, c: unknown) => Promise<string> }>;
assert.deepEqual(tools.map((t) => t.name), ["web_search", "read_webpage", "search_self_test", "inspect_search_layout", "propose_parser_rules"]);
await ext.waitVerified();
const search = tools.find((t) => t.name === "web_search")!;
const out = await search.implementation({ query: "open source licensing", goal: "Compare open source licenses" }, {});
assert.match(out, /^status: ok/);
assert.match(out, /task: Compare open source licenses/);
const self = await tools.find((t) => t.name === "search_self_test")!.implementation({}, {});
assert.match(self, /browser connection: ok/);
assert.doesNotMatch(self, /mcp\.json/);
assert.match(self, /Browser program field/);
});
test("one bridge serves every chat until the settings change", async () => {
const a = await session(readSettings(controller(settings)));
const b = await session(readSettings(controller(settings)));
assert.equal(a, b);
const otherPort = randomPort();
const c = await session(readSettings(controller({ ...settings, port: otherPort })));
assert.notEqual(c, a);
assert.equal(a.bridge.isListening(), false);
assert.equal(c.bridge.isListening(), true);
// Back to the first port: it was freed, so it opens again at once.
const d = await session(readSettings(controller(settings)));
assert.equal(d.bridge.isListening(), true);
await ext.waitVerified();
});
test("a new user message starts a new research task", async () => {
const ctx = await session(readSettings(controller(settings)));
ctx.research.resolve("Compare open source licenses");
assert.ok(ctx.research.current);
const msg = message("What about patents?");
const out = await preprocess(controller(settings), msg);
assert.equal(out, msg);
assert.equal(ctx.research.current, null);
});
test("/simplesearch commands are answered by the plugin, not the model", async () => {
const status = (await preprocess(controller(settings), message("/simplesearch status"))) as unknown as { text: string };
assert.match(status.text, /^\[simpleSearch handled a command from the user\./);
assert.match(status.text, /settings read from: LM Studio plugin settings/);
assert.match(status.text, /duckduckgo: default rules/);
const noEngine = (await preprocess(controller(settings), message("/simplesearch approve"))) as unknown as { text: string };
assert.match(noEngine.text, /Name an engine: duckduckgo, startpage, mojeek/);
const nothing = (await preprocess(controller(settings), message("/simplesearch reject mojeek"))) as unknown as { text: string };
assert.match(nothing.text, /No proposed rules are waiting for mojeek/);
const help = (await preprocess(controller(settings), message("/SimpleSearch"))) as unknown as { text: string };
assert.match(help.text, /\/simplesearch rollback <engine>/);
const ordinary = message("tell me about /simplesearch");
assert.equal(await preprocess(controller(settings), ordinary), ordinary);
});
test("a busy port is explained, and the plugin opens it once it is free", async () => {
const busyPort = randomPort();
const blocker = net.createServer();
await new Promise<void>((r) => blocker.listen(busyPort, "127.0.0.1", () => r()));
const s = readSettings(controller({ ...settings, port: busyPort }));
const ctx = await session(s);
assert.equal(ctx.bridge.isListening(), false);
assert.match(ctx.bridge.bindError ?? "", /remove the simplesearch entry from mcp\.json/);
await new Promise<void>((r) => blocker.close(() => r()));
// Within a few seconds the same failed bridge is reused rather than retried on every call.
assert.equal(await session(s), ctx);
await new Promise((r) => setTimeout(r, 5200));
const retried = await session(s);
assert.notEqual(retried, ctx);
assert.equal(retried.bridge.isListening(), true);
await session(readSettings(controller(settings)));
});
// The LM Studio plugin: settings, the tools it hands LM Studio, the bridge it keeps for the
// whole process, and the /simplesearch commands.
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import { FakeExtension } from "./fake-extension";
import { preprocess, readSettings, session, stopSession, toolsProvider } from "../src/plugin";
const SECRET = "5eed".repeat(16);
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ss-plugin-"));
process.env.SIMPLESEARCH_DATA = path.join(home, "data");
function randomPort(): number {
return 20000 + Math.floor(Math.random() * 20000);
}
// Stands in for LM Studio's controller: only the global plugin settings are used.
function controller(values: Record<string, unknown>) {
return { getGlobalPluginConfig: () => ({ get: (k: string) => values[k] }) } as never;
}
function message(text: string) {
let replaced: string | null = null;
const msg = {
getText: () => text,
asMutableCopy: () => ({ replaceText: (t: string) => (replaced = t), get text() { return replaced; } }),
};
return msg as never;
}
const port = randomPort();
const settings = { secret: SECRET, port, browserPath: path.join(home, "no-such-browser"), engines: "duckduckgo, mojeek" };
let ext: FakeExtension;
before(async () => {
ext = new FakeExtension(port, SECRET);
ext.start();
});
after(async () => {
ext.stop();
await stopSession();
});
test("settings fall back to the defaults when LM Studio has none", () => {
const s = readSettings({ getGlobalPluginConfig: () => { throw new Error("no config"); } } as never);
assert.equal(s.port, 8790);
assert.equal(s.profile, "llm-browser");
assert.equal(s.autoRepair, false);
const partial = readSettings(controller({ secret: "abc", port: undefined }));
assert.equal(partial.secret, "abc");
assert.equal(partial.port, 8790);
});
test("the plugin offers the same five tools and they search through the extension", async () => {
const tools = (await toolsProvider(controller(settings))) as unknown as Array<{ name: string; implementation: (a: unknown, c: unknown) => Promise<string> }>;
assert.deepEqual(tools.map((t) => t.name), ["web_search", "read_webpage", "search_self_test", "inspect_search_layout", "propose_parser_rules"]);
await ext.waitVerified();
const search = tools.find((t) => t.name === "web_search")!;
const out = await search.implementation({ query: "open source licensing", goal: "Compare open source licenses" }, {});
assert.match(out, /^status: ok/);
assert.match(out, /task: Compare open source licenses/);
const self = await tools.find((t) => t.name === "search_self_test")!.implementation({}, {});
assert.match(self, /browser connection: ok/);
assert.doesNotMatch(self, /mcp\.json/);
assert.match(self, /Browser program field/);
});
test("one bridge serves every chat until the settings change", async () => {
const a = await session(readSettings(controller(settings)));
const b = await session(readSettings(controller(settings)));
assert.equal(a, b);
const otherPort = randomPort();
const c = await session(readSettings(controller({ ...settings, port: otherPort })));
assert.notEqual(c, a);
assert.equal(a.bridge.isListening(), false);
assert.equal(c.bridge.isListening(), true);
// Back to the first port: it was freed, so it opens again at once.
const d = await session(readSettings(controller(settings)));
assert.equal(d.bridge.isListening(), true);
await ext.waitVerified();
});
test("a new user message starts a new research task", async () => {
const ctx = await session(readSettings(controller(settings)));
ctx.research.resolve("Compare open source licenses");
assert.ok(ctx.research.current);
const msg = message("What about patents?");
const out = await preprocess(controller(settings), msg);
assert.equal(out, msg);
assert.equal(ctx.research.current, null);
});
test("/simplesearch commands are answered by the plugin, not the model", async () => {
const status = (await preprocess(controller(settings), message("/simplesearch status"))) as unknown as { text: string };
assert.match(status.text, /^\[simpleSearch handled a command from the user\./);
assert.match(status.text, /settings read from: LM Studio plugin settings/);
assert.match(status.text, /duckduckgo: default rules/);
const noEngine = (await preprocess(controller(settings), message("/simplesearch approve"))) as unknown as { text: string };
assert.match(noEngine.text, /Name an engine: duckduckgo, startpage, mojeek/);
const nothing = (await preprocess(controller(settings), message("/simplesearch reject mojeek"))) as unknown as { text: string };
assert.match(nothing.text, /No proposed rules are waiting for mojeek/);
const help = (await preprocess(controller(settings), message("/SimpleSearch"))) as unknown as { text: string };
assert.match(help.text, /\/simplesearch rollback <engine>/);
const ordinary = message("tell me about /simplesearch");
assert.equal(await preprocess(controller(settings), ordinary), ordinary);
});
test("a busy port is explained, and the plugin opens it once it is free", async () => {
const busyPort = randomPort();
const blocker = net.createServer();
await new Promise<void>((r) => blocker.listen(busyPort, "127.0.0.1", () => r()));
const s = readSettings(controller({ ...settings, port: busyPort }));
const ctx = await session(s);
assert.equal(ctx.bridge.isListening(), false);
assert.match(ctx.bridge.bindError ?? "", /remove the simplesearch entry from mcp\.json/);
await new Promise<void>((r) => blocker.close(() => r()));
// Within a few seconds the same failed bridge is reused rather than retried on every call.
assert.equal(await session(s), ctx);
await new Promise((r) => setTimeout(r, 5200));
const retried = await session(s);
assert.notEqual(retried, ctx);
assert.equal(retried.bridge.isListening(), true);
await session(readSettings(controller(settings)));
});