Merge remote-tracking branch 'origin/develop' into chore/release-1.2.0

This commit is contained in:
golem
2026-10-18 02:36:50 -06:00
13 changed files with 135 additions and 183 deletions
+18
View File
@@ -2,13 +2,31 @@ name: Firefox Extension CI
on: on:
pull_request: pull_request:
# edited: removing the WIP: prefix starts the real run.
types: [opened, synchronize, reopened, edited]
push: push:
branches: branches:
- develop - develop
- main - main
workflow_dispatch:
# The gate and concurrency use the github.* context, Gitea's alias for gitea.*:
# it is the form the pattern was canaried with and the one pr-flow detects.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# Cancel superseded PR runs, never a push run: main's push uploads the package.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
validate: validate:
# WIP gate: skip PRs titled WIP: so early PRs cost no run per push. Gate on
# the title and nothing else, since a skipped job reports success on the
# required check (see ~/.claude/docs/gitea-ci-pattern.md).
if: >-
github.event_name != 'pull_request' || (
!startsWith(github.event.pull_request.title, 'WIP:') &&
!startsWith(github.event.pull_request.title, '[WIP]')
)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository - name: Check out repository
+2
View File
@@ -15,6 +15,8 @@ in 1.1.0, which was never published there.
actions, with optional translation and a preferred language or language dialog. actions, with optional translation and a preferred language or language dialog.
- Preserve dedicated action instructions when shortening selections; ask before - Preserve dedicated action instructions when shortening selections; ask before
sending shortened text and reject URLs that cannot be sent intact. sending shortened text and reject URLs that cannot be sent intact.
- Stay responsive with very large selections from the shortcut or toolbar: only
the part that can fit in the ChatGPT link is processed.
- Add accessible action dialogs with cancellation and retry, remove the temporary - Add accessible action dialogs with cancellation and retry, remove the temporary
model/effort URL probe, and document URL sharing. No new permissions. model/effort URL probe, and document URL sharing. No new permissions.
- Show up front, in the options panel and README, that ChatGPT may require - Show up front, in the options panel and README, that ChatGPT may require
+1
View File
@@ -74,6 +74,7 @@ Open `about:addons`, select this extension, and choose the **Options** tab
guarantee. guarantee.
- **Open in** — a new tab, a new background tab, or a new window. - **Open in** — a new tab, a new background tab, or a new window.
- **Prompt template** — wraps your text, for example `Explain simply: {query}`. - **Prompt template** — wraps your text, for example `Explain simply: {query}`.
Up to 1,000 characters.
Restore defaults resets extension preferences to Simple mode with translation Restore defaults resets extension preferences to Simple mode with translation
hidden; it does not change Firefox's search engine or shortcut settings. hidden; it does not change Firefox's search engine or shortcut settings.
-1
View File
@@ -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
View File
@@ -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);
+6
View File
@@ -3,6 +3,12 @@
export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"];
// In UTF-16 units, as maxlength counts. Keeps the template far inside
// storage.sync's 8192-byte item quota even when every character is a pasted
// control character (six bytes once JSON-escaped), and far beyond any template
// the 8,000-character URL budget could send.
export const MAX_TEMPLATE_CHARS = 1000;
export const DEFAULT_SETTINGS = { export const DEFAULT_SETTINGS = {
contextMenuMode: "simple", contextMenuMode: "simple",
showTranslate: false, showTranslate: false,
+14 -3
View File
@@ -25,12 +25,23 @@ export function prepareAction(request) {
} }
const resolved = { ...request, language }; const resolved = { ...request, language };
const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text)); const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text));
const url = serialize(source); // Each code point adds at least one URL character, so none past the first
if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url }; // MAX_URL_CHARS can fit. Capping first keeps a select-all on a huge page from
// costing seconds of re-encoding. A capped source is never sent as ready: its
// kept prefix may trim down to something that fits and silently drop the rest.
const points = [];
let capped = false;
for (const point of source) {
if (points.length === MAX_URL_CHARS) { capped = true; break; }
points.push(point);
}
if (!capped) {
const url = serialize(source);
if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url };
}
if (isUrl) return error('This URL is too long to send intact. No part of it has been sent.'); if (isUrl) return error('This URL is too long to send intact. No part of it has been sent.');
const marker = '\n[Selected text shortened]'; const marker = '\n[Selected text shortened]';
if (serialize(marker).length >= MAX_URL_CHARS) return error('The prompt instructions are too long. Shorten your prompt template and try again.'); if (serialize(marker).length >= MAX_URL_CHARS) return error('The prompt instructions are too long. Shorten your prompt template and try again.');
const points = Array.from(source);
let low = 0, high = points.length; let low = 0, high = points.length;
while (low < high) { while (low < high) {
const mid = Math.ceil((low + high) / 2); const mid = Math.ceil((low + high) / 2);
+2 -2
View File
@@ -1,4 +1,4 @@
import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS, OPEN_IN_VALUES } from "./defaults.js";
export function normalizeLanguage(value) { export function normalizeLanguage(value) {
if (typeof value !== "string") return ""; if (typeof value !== "string") return "";
@@ -28,7 +28,7 @@ export function mergeSettings(stored) {
if (OPEN_IN_VALUES.includes(stored.openIn)) { if (OPEN_IN_VALUES.includes(stored.openIn)) {
merged.openIn = stored.openIn; merged.openIn = stored.openIn;
} }
if (typeof stored.promptTemplate === "string") { if (typeof stored.promptTemplate === "string" && stored.promptTemplate.length <= MAX_TEMPLATE_CHARS) {
merged.promptTemplate = stored.promptTemplate; merged.promptTemplate = stored.promptTemplate;
} }
return merged; return merged;
+2 -2
View File
@@ -80,8 +80,8 @@
<div class="field"> <div class="field">
<label for="promptTemplate">Prompt template</label> <label for="promptTemplate">Prompt template</label>
<input type="text" id="promptTemplate" spellcheck="false" /> <input type="text" id="promptTemplate" spellcheck="false" maxlength="1000" />
<p class="hint"><code>{query}</code> is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts.</p> <p class="hint"><code>{query}</code> is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts. Up to 1,000 characters.</p>
</div> </div>
<div class="actions"> <div class="actions">
-1
View File
@@ -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
View File
@@ -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}`);
});
+31
View File
@@ -1,6 +1,7 @@
import test from 'node:test'; 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 { MAX_URL_CHARS } from '../lib/build-url.js';
const { prepareAction } = await import('../lib/prepare-action.js').catch(()=>({})); const { prepareAction } = await import('../lib/prepare-action.js').catch(()=>({}));
const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides}); const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides});
test('ready actions preserve source and hints, translation asks only when needed', () => { test('ready actions preserve source and hints, translation asks only when needed', () => {
@@ -23,6 +24,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};
@@ -38,3 +50,22 @@ test('long selections preserve instructions, repetition and Unicode with explici
const translated=prepareAction(req({actionId:'translate',language:'Spanish',source:'x'.repeat(20000)})); const translated=prepareAction(req({actionId:'translate',language:'Spanish',source:'x'.repeat(20000)}));
assert.equal(translated.kind,'shortened');assert.match(new URL(translated.url).searchParams.get('q'), /Spanish/); assert.equal(translated.kind,'shortened');assert.match(new URL(translated.url).searchParams.get('q'), /Spanish/);
}); });
test('huge selections are capped before shortening, with identical output', () => {
assert.equal(typeof prepareAction, 'function');
// Select-all on a very large page; shortening this took over a second uncapped.
const source='😀 word '.repeat(625000);
const started=performance.now();
const out=prepareAction(req({source}));
const elapsed=performance.now()-started;
assert.equal(out.kind,'shortened');
assert.ok(elapsed<250,`took ${elapsed.toFixed(0)} ms`);
const capped=Array.from(source).slice(0,MAX_URL_CHARS).join('');
assert.deepEqual(prepareAction(req({source:capped})),out);
});
test('a selection too long to send whole always needs consent, even if its start would fit', () => {
assert.equal(typeof prepareAction, 'function');
// The first MAX_URL_CHARS code points trim down to "a"; sending just that would drop the rest unasked.
const out=prepareAction(req({source:'a'+' '.repeat(MAX_URL_CHARS)+'b'}));
assert.equal(out.kind,'shortened');
assert.ok(new URL(out.url).searchParams.get('q').includes('[Selected text shortened]'));
});
+27 -1
View File
@@ -1,7 +1,9 @@
import test from "node:test"; 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 { readFileSync } from "node:fs";
import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS } from "../lib/defaults.js";
import { mergeSettings } from "../lib/settings.js"; import { mergeSettings } from "../lib/settings.js";
test("empty object yields the defaults", () => { test("empty object yields the defaults", () => {
@@ -67,6 +69,30 @@ test("wrong types fall back to the default", () => {
assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate);
}); });
test("a prompt template over the length limit falls back to the default", () => {
assert.equal(typeof MAX_TEMPLATE_CHARS, "number");
const atLimit = "x".repeat(MAX_TEMPLATE_CHARS);
assert.equal(mergeSettings({ promptTemplate: atLimit }).promptTemplate, atLimit);
assert.equal(mergeSettings({ promptTemplate: `${atLimit}x` }).promptTemplate,
DEFAULT_SETTINGS.promptTemplate);
});
// storage.sync rejects any item over 8192 bytes (key plus JSON value), and the
// options page writes every field in one set(), so one oversized template would
// make every later save fail. The worst character is a pasted control
// character, which JSON escapes to six bytes.
test("a template at the limit fits storage.sync's per-item quota in the worst case", () => {
const worst = "\u0001".repeat(MAX_TEMPLATE_CHARS);
const bytes = new TextEncoder().encode("promptTemplate" + JSON.stringify(worst)).length;
assert.ok(bytes <= 8192, `${bytes} bytes`);
});
test("the options page enforces the same template limit", () => {
const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8");
const input = html.match(/<input[^>]*id="promptTemplate"[^>]*>/)?.[0] ?? "";
assert.equal(input.match(/maxlength="(\d+)"/)?.[1], String(MAX_TEMPLATE_CHARS));
});
test("an out-of-range openIn falls back to the default", () => { test("an out-of-range openIn falls back to the default", () => {
assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn);
assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn); assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn);