refactor: remove the unused URL shortener
buildChatGptUrl and its fitToBudget helpers have had no production caller since prepareAction took over routing; the 2026-10-05 plan kept them only for existing tests until then. They also shortened silently, against the ask-before-shortening rule, so they were a trap to reuse. Port the tests that guard live code onto applyTemplate and serializePrompt, including the $&, $`, $' and $$ regressions (checked to fail against the string-replacement form), and cover prepareAction's whole-word cut, which nothing tested before. Also drop getContext's inputType password check. menus.OnClickData has no inputType, so it never fired; Firefox already withholds password fields from selectionText via mozIsTextField(true) in SelectionUtils (bug 565717). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,6 @@ const TITLES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getContext(info) {
|
export function getContext(info) {
|
||||||
if (info.inputType === 'password') return null;
|
|
||||||
if (typeof info.selectionText === 'string' && info.selectionText.trim()) {
|
if (typeof info.selectionText === 'string' && info.selectionText.trim()) {
|
||||||
return { kind: 'selection', source: info.selectionText.trim() };
|
return { kind: 'selection', source: info.selectionText.trim() };
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-62
@@ -12,9 +12,6 @@ const BASE = "https://chatgpt.com/";
|
|||||||
|
|
||||||
export const MAX_URL_CHARS = 8000;
|
export const MAX_URL_CHARS = 8000;
|
||||||
|
|
||||||
// Fraction of the kept text within which a whitespace cut is preferred.
|
|
||||||
const WHITESPACE_WINDOW = 0.9;
|
|
||||||
|
|
||||||
export function applyTemplate(template, query) {
|
export function applyTemplate(template, query) {
|
||||||
if (query === "") {
|
if (query === "") {
|
||||||
return "";
|
return "";
|
||||||
@@ -42,65 +39,9 @@ function addOptionalParams(url, settings) {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastWhitespaceIndex(text) {
|
// Encode a complete prompt without silently shortening it; prepareAction owns
|
||||||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
// the length budget and asks before shortening. q goes first, matching the
|
||||||
if (/\s/.test(text[i])) {
|
// most-cited working examples.
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the longest prefix of `prompt` whose finished URL fits the budget.
|
|
||||||
// Slicing happens by CODE POINT on the decoded string, so a surrogate pair can
|
|
||||||
// never be split and a percent-escape can never be severed (encoding happens
|
|
||||||
// afterwards, via URLSearchParams).
|
|
||||||
function fitToBudget(prompt, settings) {
|
|
||||||
const codePoints = Array.from(prompt);
|
|
||||||
|
|
||||||
const urlLengthFor = (count) => {
|
|
||||||
const url = new URL(BASE);
|
|
||||||
url.searchParams.set("q", codePoints.slice(0, count).join(""));
|
|
||||||
addOptionalParams(url, settings);
|
|
||||||
return url.href.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (urlLengthFor(codePoints.length) <= MAX_URL_CHARS) {
|
|
||||||
return prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
let low = 0;
|
|
||||||
let high = codePoints.length;
|
|
||||||
while (low < high) {
|
|
||||||
const mid = Math.floor((low + high + 1) / 2);
|
|
||||||
if (urlLengthFor(mid) <= MAX_URL_CHARS) {
|
|
||||||
low = mid;
|
|
||||||
} else {
|
|
||||||
high = mid - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const kept = codePoints.slice(0, low).join("");
|
|
||||||
const boundary = Math.floor(kept.length * WHITESPACE_WINDOW);
|
|
||||||
const cut = lastWhitespaceIndex(kept);
|
|
||||||
return cut >= boundary && cut > 0 ? kept.slice(0, cut) : kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildChatGptUrl(settings, rawQuery) {
|
|
||||||
const query = String(rawQuery ?? "").trim();
|
|
||||||
const prompt = fitToBudget(applyTemplate(settings.promptTemplate, query), settings);
|
|
||||||
|
|
||||||
// q first, matching the most-cited working examples. Total serialized length
|
|
||||||
// is order-independent, so measuring without q above is sound.
|
|
||||||
const url = new URL(BASE);
|
|
||||||
if (prompt !== "") {
|
|
||||||
url.searchParams.set("q", prompt);
|
|
||||||
}
|
|
||||||
addOptionalParams(url, settings);
|
|
||||||
return url.href;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode a complete prompt without silently shortening it.
|
|
||||||
export function serializePrompt(settings, prompt) {
|
export function serializePrompt(settings, prompt) {
|
||||||
const url = new URL(BASE);
|
const url = new URL(BASE);
|
||||||
if (prompt) url.searchParams.set("q", prompt);
|
if (prompt) url.searchParams.set("q", prompt);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ test('context prioritizes selection over link and excludes unrelated targets', (
|
|||||||
assert.deepEqual(actions.getContext({selectionText:' ',linkUrl:'https://example.com'}), {kind:'link',source:'https://example.com'});
|
assert.deepEqual(actions.getContext({selectionText:' ',linkUrl:'https://example.com'}), {kind:'link',source:'https://example.com'});
|
||||||
assert.equal(actions.getContext({mediaType:'image',pageUrl:'https://example.com'}), null);
|
assert.equal(actions.getContext({mediaType:'image',pageUrl:'https://example.com'}), null);
|
||||||
assert.equal(actions.getContext({editable:true,pageUrl:'https://example.com'}), null);
|
assert.equal(actions.getContext({editable:true,pageUrl:'https://example.com'}), null);
|
||||||
assert.equal(actions.getContext({selectionText:'secret',inputType:'password'}), null);
|
|
||||||
assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page');
|
assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page');
|
||||||
});
|
});
|
||||||
test('selection actions and optional translation reflect settings', () => {
|
test('selection actions and optional translation reflect settings', () => {
|
||||||
|
|||||||
+29
-111
@@ -2,168 +2,86 @@ import test from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
|
||||||
import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js";
|
import { applyTemplate, serializePrompt } from "../lib/build-url.js";
|
||||||
|
|
||||||
const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides });
|
const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides });
|
||||||
const queryOf = (url) => new URL(url).searchParams.get("q");
|
const queryOf = (url) => new URL(url).searchParams.get("q");
|
||||||
const paramsOf = (url) => [...new URL(url).searchParams.keys()];
|
const paramsOf = (url) => [...new URL(url).searchParams.keys()];
|
||||||
|
|
||||||
test("defaults produce only the q parameter on the root path", () => {
|
test("defaults produce only the q parameter on the root path", () => {
|
||||||
assert.equal(
|
assert.equal(serializePrompt(DEFAULT_SETTINGS, "hello"), "https://chatgpt.com/?q=hello");
|
||||||
buildChatGptUrl(DEFAULT_SETTINGS, "hello"),
|
|
||||||
"https://chatgpt.com/?q=hello",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an empty or whitespace-only query omits q entirely", () => {
|
test("an empty prompt omits q entirely", () => {
|
||||||
for (const raw of ["", " ", "\n\t", null, undefined]) {
|
assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/");
|
||||||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) {
|
||||||
assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`);
|
assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`);
|
||||||
assert.equal(url, "https://chatgpt.com/");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("legacy model values never add a model URL parameter", () => {
|
test("legacy model values never add a model URL parameter", () => {
|
||||||
assert.equal(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"),
|
assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"),
|
||||||
buildChatGptUrl(DEFAULT_SETTINGS, "hi"));
|
serializePrompt(DEFAULT_SETTINGS, "hi"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("temporaryChat and webSearch each appear only when enabled", () => {
|
test("temporaryChat and webSearch each appear only when enabled", () => {
|
||||||
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false);
|
assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]);
|
||||||
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false);
|
|
||||||
|
|
||||||
const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi"));
|
const temp = new URL(serializePrompt(withSettings({ temporaryChat: true }), "hi"));
|
||||||
assert.equal(temp.searchParams.get("temporary-chat"), "true");
|
assert.equal(temp.searchParams.get("temporary-chat"), "true");
|
||||||
|
|
||||||
const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi"));
|
const search = new URL(serializePrompt(withSettings({ webSearch: true }), "hi"));
|
||||||
assert.equal(search.searchParams.get("hints"), "search");
|
assert.equal(search.searchParams.get("hints"), "search");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all three URL-affecting settings combine, q first", () => {
|
test("all URL-affecting settings combine, q first", () => {
|
||||||
const url = buildChatGptUrl(
|
const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" });
|
||||||
withSettings({
|
const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy"));
|
||||||
temporaryChat: true,
|
|
||||||
webSearch: true,
|
|
||||||
promptTemplate: "Explain: {query}",
|
|
||||||
}),
|
|
||||||
"entropy",
|
|
||||||
);
|
|
||||||
assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]);
|
assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]);
|
||||||
assert.equal(queryOf(url), "Explain: entropy");
|
assert.equal(queryOf(url), "Explain: entropy");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("openIn never influences the URL", () => {
|
test("openIn never influences the URL", () => {
|
||||||
const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi");
|
const base = serializePrompt(DEFAULT_SETTINGS, "hi");
|
||||||
for (const openIn of ["new-tab", "background-tab", "new-window"]) {
|
for (const openIn of ["new-tab", "background-tab", "new-window"]) {
|
||||||
assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base);
|
assert.equal(serializePrompt(withSettings({ openIn }), "hi"), base);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("special characters survive encoding round-trip", () => {
|
test("special characters survive encoding round-trip", () => {
|
||||||
const raw = 'a & b # c ? d = e + f "g" <h> 100% 😀 日本語\nnewline';
|
const raw = 'a & b # c ? d = e + f "g" <h> 100% 😀 日本語\nnewline';
|
||||||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
const url = serializePrompt(DEFAULT_SETTINGS, raw);
|
||||||
assert.equal(queryOf(url), raw);
|
assert.equal(queryOf(url), raw);
|
||||||
assert.equal(url.includes(" "), false);
|
assert.equal(url.includes(" "), false);
|
||||||
assert.equal(url.includes("#"), false);
|
assert.equal(url.includes("#"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the template replaces every {query} occurrence", () => {
|
test("the template replaces every {query} occurrence", () => {
|
||||||
const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x");
|
assert.equal(applyTemplate("{query} / {query}", "x"), "x / x");
|
||||||
assert.equal(queryOf(url), "x / x");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a template without {query} is treated as a prefix, never dropping the query", () => {
|
test("a template without {query} is treated as a prefix, never dropping the query", () => {
|
||||||
const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why");
|
assert.equal(applyTemplate("Be concise:", "why"), "Be concise: why");
|
||||||
assert.equal(queryOf(url), "Be concise: why");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an empty or whitespace-only template yields the bare query", () => {
|
test("an empty or whitespace-only template yields the bare query", () => {
|
||||||
for (const promptTemplate of ["", " "]) {
|
for (const template of ["", " "]) {
|
||||||
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why");
|
assert.equal(applyTemplate(template, "why"), "why");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a non-string template degrades to the bare query", () => {
|
test("a non-string template degrades to the bare query", () => {
|
||||||
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why");
|
assert.equal(applyTemplate(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({ 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("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
|
// String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the
|
||||||
// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly
|
// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly
|
||||||
// that argument. These selections are ordinary shell one-liners, not
|
// that argument. These selections are ordinary shell one-liners, not
|
||||||
// adversarial input, and must round-trip byte-identically into `q`.
|
// adversarial input, and must round-trip byte-identically into `q`.
|
||||||
test("a selection containing $& survives the default {query} template byte-identically", () => {
|
for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) {
|
||||||
const raw = "awk print $& here";
|
test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => {
|
||||||
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
|
assert.equal(applyTemplate("{query}", raw), raw);
|
||||||
assert.equal(queryOf(url), raw);
|
assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${raw}`);
|
||||||
});
|
assert.equal(queryOf(serializePrompt(DEFAULT_SETTINGS, applyTemplate("{query}", raw))), 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}`);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ test('URLs are validated and preserved whole, never shortened', () => {
|
|||||||
assert.equal(out.kind,'ready');
|
assert.equal(out.kind,'ready');
|
||||||
assert.ok(new URL(out.url).searchParams.get('q').endsWith(source));
|
assert.ok(new URL(out.url).searchParams.get('q').endsWith(source));
|
||||||
});
|
});
|
||||||
|
test('shortened selections end on a whole word and decode cleanly', () => {
|
||||||
|
assert.equal(typeof prepareAction, 'function');
|
||||||
|
const out=prepareAction(req({source:'lorem ipsum '.repeat(2000)}));
|
||||||
|
assert.equal(out.kind,'shortened');
|
||||||
|
const words=new URL(out.url).searchParams.get('q').replace('[Selected text shortened]','').trim().split(/\s+/);
|
||||||
|
assert.ok(words.length>1);
|
||||||
|
for (const word of words) assert.ok(word==='lorem'||word==='ipsum',`cut mid-word: ${word}`);
|
||||||
|
const emoji=prepareAction(req({source:'😀 '.repeat(9000)}));
|
||||||
|
assert.equal(emoji.kind,'shortened');
|
||||||
|
assert.doesNotThrow(()=>decodeURIComponent(new URL(emoji.url).search.slice(1)));
|
||||||
|
});
|
||||||
test('long selections preserve instructions, repetition and Unicode with explicit consent', () => {
|
test('long selections preserve instructions, repetition and Unicode with explicit consent', () => {
|
||||||
assert.equal(typeof prepareAction, 'function');
|
assert.equal(typeof prepareAction, 'function');
|
||||||
const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true};
|
const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true};
|
||||||
|
|||||||
Reference in New Issue
Block a user