test / fake-extension.ts
test / fake-extension.ts
import * as fs from "node:fs";
import * as path from "node:path";
import WebSocket from "ws";
import { ReplayGuard, seal, unseal } from "../src/protocol";
// Stands in for the browser extension: speaks the same signed protocol and serves
// fixture pages instead of loading real ones.
export type DdgMode = "results" | "redesigned" | "captcha" | "noresults" | "flaky";
const FIX = path.join(process.cwd(), "test", "fixtures");
const fixture = (name: string) => fs.readFileSync(path.join(FIX, name), "utf8");
const textOf = (html: string) => html.replace(/<script[\s\S]*?<\/script>/g, " ").replace(/<[^>]+>/g, " ").replace(/&/g, "&");
export const ARTICLE = Array.from({ length: 400 }, (_, i) => `Paragraph ${i + 1} of the long test article about open source licensing.`).join("\n\n");
const TOPICS = [
["Choosing a license", "Picking a license early saves trouble later. Teams should agree on goals for reuse before the first public release, and write the decision down in the repository."],
["Permissive licenses", "Permissive licenses such as MIT allow reuse with very few conditions. They ask for attribution and little else, which makes them popular for libraries."],
["Patent grants", "The Apache License 2.0 includes an explicit patent grant: every contributor grants users a license to any patents their contribution practices. The patent grant ends for anyone who sues over patents in the work."],
["Copyleft", "Copyleft licenses such as the GPL require that distributed changes stay under the same license. This keeps derivatives open but limits combination with proprietary code."],
["Contributor agreements", "Some projects ask contributors to sign an agreement that assigns or licenses their copyright, which lets the project relicense later."],
["Dual licensing", "A company may offer the same code under a copyleft license and a paid commercial license, letting customers choose."],
];
export const LONG_GUIDE =
"# A practical guide to open source licensing\n\n" +
Array.from({ length: 30 }, (_, i) => {
const [heading, body] = TOPICS[i % TOPICS.length];
return `## ${heading} (part ${Math.floor(i / TOPICS.length) + 1})\n\n${body} ${"Additional general discussion follows here. ".repeat(4)}`;
}).join("\n\n");
export class FakeExtension {
ws: WebSocket | null = null;
verified = false;
ddgMode: DdgMode = "results";
loads: string[] = [];
closed: number[] = [];
focused: number[] = [];
loadDelayMs = 0;
private nextTab = 100;
private guard = new ReplayGuard();
private stopped = false;
constructor(private port: number, private secret: string) {}
start(): void {
const attempt = () => {
if (this.stopped || this.ws) return;
const ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
this.ws = ws;
ws.on("open", () => ws.send(seal(this.secret, { type: "hello", version: "test" })));
ws.on("message", (data) => this.onMessage(data.toString()));
ws.on("close", () => {
if (this.ws === ws) {
this.ws = null;
this.verified = false;
}
});
ws.on("error", () => {});
};
attempt();
const timer = setInterval(() => (this.stopped ? clearInterval(timer) : attempt()), 200);
}
stop(): void {
this.stopped = true;
this.ws?.close();
}
waitVerified(ms = 5000): Promise<void> {
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
if (this.verified) resolve();
else if (Date.now() - started > ms) reject(new Error("fake extension never verified"));
else setTimeout(tick, 50);
};
tick();
});
}
private reply(reqId: string, ok: boolean, payload: unknown): void {
this.ws?.send(seal(this.secret, ok ? { type: "result", reqId, ok, data: payload } : { type: "result", reqId, ok, error: payload }));
}
private onMessage(raw: string): void {
const msg = unseal(this.secret, raw, this.guard);
if (!msg) return;
if (msg.type === "load" && this.loadDelayMs > 0) {
setTimeout(() => this.handle(msg), this.loadDelayMs);
return;
}
this.handle(msg);
}
private handle(msg: ReturnType<typeof unseal> & {}): void {
if (msg.type === "welcome") {
this.verified = true;
return;
}
if (msg.type === "close") {
this.closed.push(msg.tabId as number);
return this.reply(msg.id, true, {});
}
if (msg.type === "focus") {
this.focused.push(msg.tabId as number);
return this.reply(msg.id, true, {});
}
if (msg.type === "load") {
const url = String(msg.url);
this.loads.push(url);
const tabId = this.nextTab++;
const host = new URL(url).hostname;
if (msg.mode === "read" && url.includes("missing")) {
return this.reply(msg.id, true, {
tabId,
url,
title: "404 Not Found",
html: "",
text: "Not Found\n\nThe requested URL was not found on this server.",
readable: null,
});
}
if (msg.mode === "read" && url.includes("long-guide")) {
return this.reply(msg.id, true, {
tabId,
url,
title: "Licensing guide",
html: "",
text: LONG_GUIDE,
readable: { title: "Licensing guide", byline: "", text: LONG_GUIDE },
});
}
if (msg.mode === "read") {
return this.reply(msg.id, true, {
tabId,
url,
title: "Long article",
html: "",
text: ARTICLE,
readable: { title: "Long article", byline: "", text: ARTICLE },
});
}
let file: string;
if (host.endsWith("duckduckgo.com") && this.ddgMode === "flaky") {
file = "ddg-consent.html";
this.ddgMode = "results";
} else if (host.endsWith("duckduckgo.com")) file = `ddg-${this.ddgMode}.html`;
else if (host.endsWith("startpage.com")) file = "startpage-results.html";
else file = "mojeek-results.html";
const html = fixture(file);
const title = (/<title>([^<]*)<\/title>/.exec(html) ?? [])[1] ?? "";
const finalUrl = file === "ddg-consent.html" ? "https://duckduckgo.com/consent?continue=yes&q=private-query" : url;
return this.reply(msg.id, true, { tabId, url: finalUrl, title, html, text: textOf(html) });
}
this.reply(msg.id, false, `unknown command ${msg.type}`);
}
}
import * as fs from "node:fs";
import * as path from "node:path";
import WebSocket from "ws";
import { ReplayGuard, seal, unseal } from "../src/protocol";
// Stands in for the browser extension: speaks the same signed protocol and serves
// fixture pages instead of loading real ones.
export type DdgMode = "results" | "redesigned" | "captcha" | "noresults" | "flaky";
const FIX = path.join(process.cwd(), "test", "fixtures");
const fixture = (name: string) => fs.readFileSync(path.join(FIX, name), "utf8");
const textOf = (html: string) => html.replace(/<script[\s\S]*?<\/script>/g, " ").replace(/<[^>]+>/g, " ").replace(/&/g, "&");
export const ARTICLE = Array.from({ length: 400 }, (_, i) => `Paragraph ${i + 1} of the long test article about open source licensing.`).join("\n\n");
const TOPICS = [
["Choosing a license", "Picking a license early saves trouble later. Teams should agree on goals for reuse before the first public release, and write the decision down in the repository."],
["Permissive licenses", "Permissive licenses such as MIT allow reuse with very few conditions. They ask for attribution and little else, which makes them popular for libraries."],
["Patent grants", "The Apache License 2.0 includes an explicit patent grant: every contributor grants users a license to any patents their contribution practices. The patent grant ends for anyone who sues over patents in the work."],
["Copyleft", "Copyleft licenses such as the GPL require that distributed changes stay under the same license. This keeps derivatives open but limits combination with proprietary code."],
["Contributor agreements", "Some projects ask contributors to sign an agreement that assigns or licenses their copyright, which lets the project relicense later."],
["Dual licensing", "A company may offer the same code under a copyleft license and a paid commercial license, letting customers choose."],
];
export const LONG_GUIDE =
"# A practical guide to open source licensing\n\n" +
Array.from({ length: 30 }, (_, i) => {
const [heading, body] = TOPICS[i % TOPICS.length];
return `## ${heading} (part ${Math.floor(i / TOPICS.length) + 1})\n\n${body} ${"Additional general discussion follows here. ".repeat(4)}`;
}).join("\n\n");
export class FakeExtension {
ws: WebSocket | null = null;
verified = false;
ddgMode: DdgMode = "results";
loads: string[] = [];
closed: number[] = [];
focused: number[] = [];
loadDelayMs = 0;
private nextTab = 100;
private guard = new ReplayGuard();
private stopped = false;
constructor(private port: number, private secret: string) {}
start(): void {
const attempt = () => {
if (this.stopped || this.ws) return;
const ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
this.ws = ws;
ws.on("open", () => ws.send(seal(this.secret, { type: "hello", version: "test" })));
ws.on("message", (data) => this.onMessage(data.toString()));
ws.on("close", () => {
if (this.ws === ws) {
this.ws = null;
this.verified = false;
}
});
ws.on("error", () => {});
};
attempt();
const timer = setInterval(() => (this.stopped ? clearInterval(timer) : attempt()), 200);
}
stop(): void {
this.stopped = true;
this.ws?.close();
}
waitVerified(ms = 5000): Promise<void> {
const started = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
if (this.verified) resolve();
else if (Date.now() - started > ms) reject(new Error("fake extension never verified"));
else setTimeout(tick, 50);
};
tick();
});
}
private reply(reqId: string, ok: boolean, payload: unknown): void {
this.ws?.send(seal(this.secret, ok ? { type: "result", reqId, ok, data: payload } : { type: "result", reqId, ok, error: payload }));
}
private onMessage(raw: string): void {
const msg = unseal(this.secret, raw, this.guard);
if (!msg) return;
if (msg.type === "load" && this.loadDelayMs > 0) {
setTimeout(() => this.handle(msg), this.loadDelayMs);
return;
}
this.handle(msg);
}
private handle(msg: ReturnType<typeof unseal> & {}): void {
if (msg.type === "welcome") {
this.verified = true;
return;
}
if (msg.type === "close") {
this.closed.push(msg.tabId as number);
return this.reply(msg.id, true, {});
}
if (msg.type === "focus") {
this.focused.push(msg.tabId as number);
return this.reply(msg.id, true, {});
}
if (msg.type === "load") {
const url = String(msg.url);
this.loads.push(url);
const tabId = this.nextTab++;
const host = new URL(url).hostname;
if (msg.mode === "read" && url.includes("missing")) {
return this.reply(msg.id, true, {
tabId,
url,
title: "404 Not Found",
html: "",
text: "Not Found\n\nThe requested URL was not found on this server.",
readable: null,
});
}
if (msg.mode === "read" && url.includes("long-guide")) {
return this.reply(msg.id, true, {
tabId,
url,
title: "Licensing guide",
html: "",
text: LONG_GUIDE,
readable: { title: "Licensing guide", byline: "", text: LONG_GUIDE },
});
}
if (msg.mode === "read") {
return this.reply(msg.id, true, {
tabId,
url,
title: "Long article",
html: "",
text: ARTICLE,
readable: { title: "Long article", byline: "", text: ARTICLE },
});
}
let file: string;
if (host.endsWith("duckduckgo.com") && this.ddgMode === "flaky") {
file = "ddg-consent.html";
this.ddgMode = "results";
} else if (host.endsWith("duckduckgo.com")) file = `ddg-${this.ddgMode}.html`;
else if (host.endsWith("startpage.com")) file = "startpage-results.html";
else file = "mojeek-results.html";
const html = fixture(file);
const title = (/<title>([^<]*)<\/title>/.exec(html) ?? [])[1] ?? "";
const finalUrl = file === "ddg-consent.html" ? "https://duckduckgo.com/consent?continue=yes&q=private-query" : url;
return this.reply(msg.id, true, { tabId, url: finalUrl, title, html, text: textOf(html) });
}
this.reply(msg.id, false, `unknown command ${msg.type}`);
}
}