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" 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])|(? { 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); });