mirror of
https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension.git
synced 2026-08-10 21:50:29 -06:00
176 lines
7.3 KiB
JavaScript
176 lines
7.3 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
|
import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js";
|
|
|
|
const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides });
|
|
const queryOf = (url) => new URL(url).searchParams.get("q");
|
|
const paramsOf = (url) => [...new URL(url).searchParams.keys()];
|
|
|
|
test("defaults produce only the q parameter on the root path", () => {
|
|
assert.equal(
|
|
buildChatGptUrl(DEFAULT_SETTINGS, "hello"),
|
|
"https://chatgpt.com/?q=hello",
|
|
);
|
|
});
|
|
|
|
test("an empty or whitespace-only query omits q entirely", () => {
|
|
for (const raw of ["", " ", "\n\t", null, undefined]) {
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
|
assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`);
|
|
assert.equal(url, "https://chatgpt.com/");
|
|
}
|
|
});
|
|
|
|
test("model is sent only when non-empty after trimming", () => {
|
|
assert.equal(queryOf(buildChatGptUrl(withSettings({ model: "auto" }), "hi")), "hi");
|
|
assert.equal(new URL(buildChatGptUrl(withSettings({ model: "auto" }), "hi")).searchParams.get("model"), "auto");
|
|
assert.equal(new URL(buildChatGptUrl(withSettings({ model: " auto " }), "hi")).searchParams.get("model"), "auto");
|
|
for (const model of ["", " "]) {
|
|
assert.equal(paramsOf(buildChatGptUrl(withSettings({ model }), "hi")).includes("model"), false);
|
|
}
|
|
});
|
|
|
|
test("temporaryChat and webSearch each appear only when enabled", () => {
|
|
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false);
|
|
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false);
|
|
|
|
const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi"));
|
|
assert.equal(temp.searchParams.get("temporary-chat"), "true");
|
|
|
|
const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi"));
|
|
assert.equal(search.searchParams.get("hints"), "search");
|
|
});
|
|
|
|
test("all four URL-affecting settings combine, q first", () => {
|
|
const url = buildChatGptUrl(
|
|
withSettings({
|
|
model: "auto",
|
|
temporaryChat: true,
|
|
webSearch: true,
|
|
promptTemplate: "Explain: {query}",
|
|
}),
|
|
"entropy",
|
|
);
|
|
assert.deepEqual(paramsOf(url), ["q", "model", "temporary-chat", "hints"]);
|
|
assert.equal(queryOf(url), "Explain: entropy");
|
|
});
|
|
|
|
test("openIn never influences the URL", () => {
|
|
const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi");
|
|
for (const openIn of ["new-tab", "background-tab", "new-window"]) {
|
|
assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base);
|
|
}
|
|
});
|
|
|
|
test("special characters survive encoding round-trip", () => {
|
|
const raw = 'a & b # c ? d = e + f "g" <h> 100% 😀 日本語\nnewline';
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
|
assert.equal(queryOf(url), raw);
|
|
assert.equal(url.includes(" "), false);
|
|
assert.equal(url.includes("#"), false);
|
|
});
|
|
|
|
test("the template replaces every {query} occurrence", () => {
|
|
const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x");
|
|
assert.equal(queryOf(url), "x / x");
|
|
});
|
|
|
|
test("a template without {query} is treated as a prefix, never dropping the query", () => {
|
|
const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why");
|
|
assert.equal(queryOf(url), "Be concise: why");
|
|
});
|
|
|
|
test("an empty or whitespace-only template yields the bare query", () => {
|
|
for (const promptTemplate of ["", " "]) {
|
|
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why");
|
|
}
|
|
});
|
|
|
|
test("a non-string template degrades to the bare query", () => {
|
|
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why");
|
|
});
|
|
|
|
test("an over-long prompt is truncated to fit the URL budget", () => {
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, "x".repeat(20000));
|
|
assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`);
|
|
assert.ok(queryOf(url).length > 0);
|
|
assert.ok(queryOf(url).length < 20000);
|
|
});
|
|
|
|
test("truncation accounts for the other parameters", () => {
|
|
const settings = withSettings({ model: "some-long-model-slug", temporaryChat: true, webSearch: true });
|
|
const url = buildChatGptUrl(settings, "x".repeat(20000));
|
|
assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`);
|
|
assert.equal(new URL(url).searchParams.get("model"), "some-long-model-slug");
|
|
assert.equal(new URL(url).searchParams.get("hints"), "search");
|
|
});
|
|
|
|
test("truncation never splits a surrogate pair", () => {
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀".repeat(9000));
|
|
assert.ok(url.length <= MAX_URL_CHARS);
|
|
const lonelySurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
assert.equal(lonelySurrogate.test(queryOf(url)), false);
|
|
});
|
|
|
|
test("truncation never leaves a severed percent-escape", () => {
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀 ".repeat(9000));
|
|
assert.equal(/%[0-9A-Fa-f]?$/.test(url), false);
|
|
assert.doesNotThrow(() => decodeURIComponent(new URL(url).search.slice(3)));
|
|
});
|
|
|
|
test("truncation prefers a whitespace boundary, so no word is cut in half", () => {
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, "lorem ipsum ".repeat(2000));
|
|
const tokens = queryOf(url).trim().split(/\s+/);
|
|
assert.ok(tokens.length > 1);
|
|
for (const token of tokens) {
|
|
assert.ok(token === "lorem" || token === "ipsum", `truncated mid-word: ${token}`);
|
|
}
|
|
});
|
|
|
|
test("a query needing no truncation is left byte-identical", () => {
|
|
const raw = "a modest question about thermodynamics";
|
|
assert.equal(queryOf(buildChatGptUrl(DEFAULT_SETTINGS, raw)), raw);
|
|
});
|
|
|
|
// String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the
|
|
// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly
|
|
// that argument. These selections are ordinary shell one-liners, not
|
|
// adversarial input, and must round-trip byte-identically into `q`.
|
|
test("a selection containing $& survives the default {query} template byte-identically", () => {
|
|
const raw = "awk print $& here";
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
|
assert.equal(queryOf(url), raw);
|
|
});
|
|
|
|
test("a selection containing $& survives a prefix template byte-identically", () => {
|
|
const raw = "awk print $& here";
|
|
const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw);
|
|
assert.equal(queryOf(url), `Explain: ${raw}`);
|
|
});
|
|
|
|
test("a selection containing $` and $' survives the default {query} template byte-identically", () => {
|
|
const raw = "shell $` and $' here";
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
|
assert.equal(queryOf(url), raw);
|
|
});
|
|
|
|
test("a selection containing $` and $' survives a prefix template byte-identically", () => {
|
|
const raw = "shell $` and $' here";
|
|
const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw);
|
|
assert.equal(queryOf(url), `Explain: ${raw}`);
|
|
});
|
|
|
|
test("a selection containing $$ survives the default {query} template byte-identically", () => {
|
|
const raw = "PID is $$ in bash";
|
|
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
|
assert.equal(queryOf(url), raw);
|
|
});
|
|
|
|
test("a selection containing $$ survives a prefix template byte-identically", () => {
|
|
const raw = "PID is $$ in bash";
|
|
const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw);
|
|
assert.equal(queryOf(url), `Explain: ${raw}`);
|
|
});
|