test / extension.test.ts
test / extension.test.ts
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as vm from "node:vm";
import { JSDOM } from "jsdom";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
// Runs the real extension background script against the real server. Only the browser
// itself is simulated: tabs "load" fixture pages, and the extension's injected
// extraction code runs inside jsdom with the real Readability library.
const ROOT = process.cwd();
const EXT = path.join(ROOT, "..", "extension");
const FIX = path.join(ROOT, "test", "fixtures");
const SECRET = "5ecre7".repeat(10) + "beef";
const READABILITY = fs.readFileSync(path.join(EXT, "lib", "Readability.js"), "utf8");
function pageHtml(url: string): string {
const host = new URL(url).hostname;
if (host.endsWith("duckduckgo.com")) return fs.readFileSync(path.join(FIX, captcha ? "ddg-captcha.html" : "ddg-results.html"), "utf8");
if (host.endsWith("startpage.com")) return fs.readFileSync(path.join(FIX, "startpage-results.html"), "utf8");
if (host.endsWith("mojeek.com")) return fs.readFileSync(path.join(FIX, "mojeek-results.html"), "utf8");
return fs.readFileSync(path.join(FIX, "article.html"), "utf8");
}
function runInPage(url: string, injected: string[]): unknown {
const dom = new JSDOM(pageHtml(url), { url, runScripts: "outside-only" });
const w = dom.window as unknown as { eval: (code: string) => unknown; HTMLElement: { prototype: object } };
// jsdom has no layout, so innerText is approximated by textContent.
Object.defineProperty(w.HTMLElement.prototype, "innerText", {
get(this: { textContent: string }) {
return this.textContent;
},
});
let result: unknown;
for (const code of injected) result = w.eval(code);
return result;
}
let captcha = false;
const removed: number[] = [];
const focused: number[] = [];
let sandbox: vm.Context;
let client: Client;
let port: number;
function makeBrowser(storage: Record<string, unknown>) {
const tabs = new Map<number, { id: number; url: string; windowId: number; status: string; scripts: string[] }>();
const onUpdated: Array<(id: number, info: { status?: string }) => void> = [];
const messageListeners: Array<(m: unknown) => unknown> = [];
let nextId = 1;
const api = {
storage: {
local: {
get: async () => ({ ...storage }),
set: async (o: Record<string, unknown>) => void Object.assign(storage, o),
},
onChanged: { addListener: () => {} },
},
runtime: {
getManifest: () => JSON.parse(fs.readFileSync(path.join(EXT, "manifest.json"), "utf8")),
onMessage: { addListener: (f: (m: unknown) => unknown) => messageListeners.push(f) },
onInstalled: { addListener: () => {} },
openOptionsPage: async () => {},
},
tabs: {
create: async ({ url }: { url: string }) => {
const tab = { id: nextId++, url, windowId: 1, status: "loading", scripts: [] as string[] };
tabs.set(tab.id, tab);
setTimeout(() => {
tab.status = "complete";
for (const f of [...onUpdated]) f(tab.id, { status: "complete" });
}, 30);
return { id: tab.id, url, windowId: 1, status: "loading" };
},
get: async (id: number) => {
const t = tabs.get(id);
if (!t) throw new Error("no tab");
return { id: t.id, url: t.url, windowId: t.windowId, status: t.status, title: "" };
},
update: async (id: number) => {
focused.push(id);
return { id, windowId: 1 };
},
remove: async (id: number) => {
tabs.delete(id);
removed.push(id);
},
executeScript: async (id: number, details: { file?: string; code?: string }) => {
const t = tabs.get(id);
if (!t) throw new Error("no tab");
if (details.file === "/lib/Readability.js") {
t.scripts.push(READABILITY);
return [undefined];
}
// Extraction scripts are files too: run them in the page after any earlier injections.
if (details.file) {
const code = fs.readFileSync(path.join(EXT, details.file.replace(/^\//, "")), "utf8");
return [runInPage(t.url, [...t.scripts, code])];
}
throw new Error("the extension should inject files, not code strings");
},
onUpdated: {
addListener: (f: (id: number, info: { status?: string }) => void) => onUpdated.push(f),
removeListener: (f: (id: number, info: { status?: string }) => void) => {
const i = onUpdated.indexOf(f);
if (i >= 0) onUpdated.splice(i, 1);
},
},
onRemoved: { addListener: () => {} },
},
windows: {
create: async () => {
throw new Error("not used");
},
get: async (id: number) => ({ id, state: "minimized" }),
update: async () => ({}),
},
};
return { api, messageListeners };
}
let messageListeners: Array<(m: unknown) => unknown>;
before(async () => {
port = 20000 + Math.floor(Math.random() * 20000);
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ss-ext-"));
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(ROOT, "dist", "simplesearch.js")],
env: {
PATH: process.env.PATH ?? "",
HOME: dataDir,
SIMPLESEARCH_SECRET: SECRET,
SIMPLESEARCH_PORT: String(port),
SIMPLESEARCH_DATA: dataDir,
SIMPLESEARCH_BROWSER: path.join(dataDir, "none"),
},
stderr: "pipe",
});
client = new Client({ name: "test", version: "1" });
await client.connect(transport);
const b = makeBrowser({ secret: SECRET, port });
messageListeners = b.messageListeners;
sandbox = vm.createContext({
browser: b.api,
crypto: globalThis.crypto,
WebSocket: globalThis.WebSocket,
TextEncoder,
URL,
setTimeout,
setInterval,
clearTimeout,
clearInterval,
console,
});
vm.runInContext(fs.readFileSync(path.join(EXT, "background.js"), "utf8"), sandbox, { filename: "background.js" });
const started = Date.now();
for (;;) {
const status = (await messageListeners[0]({ type: "status" })) as { connected: boolean };
if (status.connected) break;
if (Date.now() - started > 8000) throw new Error("the extension did not connect to the server");
await new Promise((r) => setTimeout(r, 100));
}
});
after(async () => {
await client?.close();
// The background script keeps timers running; end the process once tests are done.
setTimeout(() => process.exit(0), 100).unref();
});
const call = async (name: string, args: Record<string, unknown>) =>
((await client.callTool({ name, arguments: args })) as { content: Array<{ text: string }> }).content[0].text;
test("real extension code: signed handshake and a full search", async () => {
const out = await call("web_search", { query: "open source software" });
assert.match(out, /^status: ok/);
assert.match(out, /https:\/\/opensource\.org\/osd/);
assert.ok(removed.length >= 1, "the search tab was closed by the extension");
});
test("real extension code: readable article text with structure", async () => {
const out = await call("read_webpage", { url: "https://blog.example.org/licenses" });
assert.match(out, /status: ok/);
assert.match(out, /# Choosing an Open Source License|Choosing an Open Source License/);
assert.match(out, /## Permissive licenses/);
assert.match(out, /- MIT: short and simple/);
assert.doesNotMatch(out, /Subscribe to the newsletter/, "navigation is stripped by Readability");
});
test("real extension code: captcha focuses the tab and restores a minimized window", async () => {
captcha = true;
const out = await call("web_search", { query: "anything", engine: "duckduckgo" });
captcha = false;
assert.match(out, /status: captcha/);
assert.equal(focused.length, 1);
});
test("options page status reports the connection", async () => {
const status = (await messageListeners[0]({ type: "status" })) as { connected: boolean; port: number; secret: string };
assert.equal(status.connected, true);
assert.equal(status.port, port);
assert.equal(status.secret, SECRET);
});
import { after, before, test } from "node:test";
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as vm from "node:vm";
import { JSDOM } from "jsdom";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
// Runs the real extension background script against the real server. Only the browser
// itself is simulated: tabs "load" fixture pages, and the extension's injected
// extraction code runs inside jsdom with the real Readability library.
const ROOT = process.cwd();
const EXT = path.join(ROOT, "..", "extension");
const FIX = path.join(ROOT, "test", "fixtures");
const SECRET = "5ecre7".repeat(10) + "beef";
const READABILITY = fs.readFileSync(path.join(EXT, "lib", "Readability.js"), "utf8");
function pageHtml(url: string): string {
const host = new URL(url).hostname;
if (host.endsWith("duckduckgo.com")) return fs.readFileSync(path.join(FIX, captcha ? "ddg-captcha.html" : "ddg-results.html"), "utf8");
if (host.endsWith("startpage.com")) return fs.readFileSync(path.join(FIX, "startpage-results.html"), "utf8");
if (host.endsWith("mojeek.com")) return fs.readFileSync(path.join(FIX, "mojeek-results.html"), "utf8");
return fs.readFileSync(path.join(FIX, "article.html"), "utf8");
}
function runInPage(url: string, injected: string[]): unknown {
const dom = new JSDOM(pageHtml(url), { url, runScripts: "outside-only" });
const w = dom.window as unknown as { eval: (code: string) => unknown; HTMLElement: { prototype: object } };
// jsdom has no layout, so innerText is approximated by textContent.
Object.defineProperty(w.HTMLElement.prototype, "innerText", {
get(this: { textContent: string }) {
return this.textContent;
},
});
let result: unknown;
for (const code of injected) result = w.eval(code);
return result;
}
let captcha = false;
const removed: number[] = [];
const focused: number[] = [];
let sandbox: vm.Context;
let client: Client;
let port: number;
function makeBrowser(storage: Record<string, unknown>) {
const tabs = new Map<number, { id: number; url: string; windowId: number; status: string; scripts: string[] }>();
const onUpdated: Array<(id: number, info: { status?: string }) => void> = [];
const messageListeners: Array<(m: unknown) => unknown> = [];
let nextId = 1;
const api = {
storage: {
local: {
get: async () => ({ ...storage }),
set: async (o: Record<string, unknown>) => void Object.assign(storage, o),
},
onChanged: { addListener: () => {} },
},
runtime: {
getManifest: () => JSON.parse(fs.readFileSync(path.join(EXT, "manifest.json"), "utf8")),
onMessage: { addListener: (f: (m: unknown) => unknown) => messageListeners.push(f) },
onInstalled: { addListener: () => {} },
openOptionsPage: async () => {},
},
tabs: {
create: async ({ url }: { url: string }) => {
const tab = { id: nextId++, url, windowId: 1, status: "loading", scripts: [] as string[] };
tabs.set(tab.id, tab);
setTimeout(() => {
tab.status = "complete";
for (const f of [...onUpdated]) f(tab.id, { status: "complete" });
}, 30);
return { id: tab.id, url, windowId: 1, status: "loading" };
},
get: async (id: number) => {
const t = tabs.get(id);
if (!t) throw new Error("no tab");
return { id: t.id, url: t.url, windowId: t.windowId, status: t.status, title: "" };
},
update: async (id: number) => {
focused.push(id);
return { id, windowId: 1 };
},
remove: async (id: number) => {
tabs.delete(id);
removed.push(id);
},
executeScript: async (id: number, details: { file?: string; code?: string }) => {
const t = tabs.get(id);
if (!t) throw new Error("no tab");
if (details.file === "/lib/Readability.js") {
t.scripts.push(READABILITY);
return [undefined];
}
// Extraction scripts are files too: run them in the page after any earlier injections.
if (details.file) {
const code = fs.readFileSync(path.join(EXT, details.file.replace(/^\//, "")), "utf8");
return [runInPage(t.url, [...t.scripts, code])];
}
throw new Error("the extension should inject files, not code strings");
},
onUpdated: {
addListener: (f: (id: number, info: { status?: string }) => void) => onUpdated.push(f),
removeListener: (f: (id: number, info: { status?: string }) => void) => {
const i = onUpdated.indexOf(f);
if (i >= 0) onUpdated.splice(i, 1);
},
},
onRemoved: { addListener: () => {} },
},
windows: {
create: async () => {
throw new Error("not used");
},
get: async (id: number) => ({ id, state: "minimized" }),
update: async () => ({}),
},
};
return { api, messageListeners };
}
let messageListeners: Array<(m: unknown) => unknown>;
before(async () => {
port = 20000 + Math.floor(Math.random() * 20000);
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ss-ext-"));
const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(ROOT, "dist", "simplesearch.js")],
env: {
PATH: process.env.PATH ?? "",
HOME: dataDir,
SIMPLESEARCH_SECRET: SECRET,
SIMPLESEARCH_PORT: String(port),
SIMPLESEARCH_DATA: dataDir,
SIMPLESEARCH_BROWSER: path.join(dataDir, "none"),
},
stderr: "pipe",
});
client = new Client({ name: "test", version: "1" });
await client.connect(transport);
const b = makeBrowser({ secret: SECRET, port });
messageListeners = b.messageListeners;
sandbox = vm.createContext({
browser: b.api,
crypto: globalThis.crypto,
WebSocket: globalThis.WebSocket,
TextEncoder,
URL,
setTimeout,
setInterval,
clearTimeout,
clearInterval,
console,
});
vm.runInContext(fs.readFileSync(path.join(EXT, "background.js"), "utf8"), sandbox, { filename: "background.js" });
const started = Date.now();
for (;;) {
const status = (await messageListeners[0]({ type: "status" })) as { connected: boolean };
if (status.connected) break;
if (Date.now() - started > 8000) throw new Error("the extension did not connect to the server");
await new Promise((r) => setTimeout(r, 100));
}
});
after(async () => {
await client?.close();
// The background script keeps timers running; end the process once tests are done.
setTimeout(() => process.exit(0), 100).unref();
});
const call = async (name: string, args: Record<string, unknown>) =>
((await client.callTool({ name, arguments: args })) as { content: Array<{ text: string }> }).content[0].text;
test("real extension code: signed handshake and a full search", async () => {
const out = await call("web_search", { query: "open source software" });
assert.match(out, /^status: ok/);
assert.match(out, /https:\/\/opensource\.org\/osd/);
assert.ok(removed.length >= 1, "the search tab was closed by the extension");
});
test("real extension code: readable article text with structure", async () => {
const out = await call("read_webpage", { url: "https://blog.example.org/licenses" });
assert.match(out, /status: ok/);
assert.match(out, /# Choosing an Open Source License|Choosing an Open Source License/);
assert.match(out, /## Permissive licenses/);
assert.match(out, /- MIT: short and simple/);
assert.doesNotMatch(out, /Subscribe to the newsletter/, "navigation is stripped by Readability");
});
test("real extension code: captcha focuses the tab and restores a minimized window", async () => {
captcha = true;
const out = await call("web_search", { query: "anything", engine: "duckduckgo" });
captcha = false;
assert.match(out, /status: captcha/);
assert.equal(focused.length, 1);
});
test("options page status reports the connection", async () => {
const status = (await messageListeners[0]({ type: "status" })) as { connected: boolean; port: number; secret: string };
assert.equal(status.connected, true);
assert.equal(status.port, port);
assert.equal(status.secret, SECRET);
});