Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b987685d89 | ||
|
|
3f4b64374b | ||
|
|
cf5e397d53 | ||
|
|
8052c87c23 | ||
|
|
9f8a295a4f | ||
|
|
5a12663c92 | ||
|
|
384eca7589 | ||
|
|
d96277ebd9 | ||
|
|
cc68fe98b5 | ||
|
|
39a3ac74f4 | ||
|
|
cb7da3a538 | ||
|
|
519131c21f |
@@ -2,13 +2,31 @@ name: Firefox Extension CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
# edited: removing the WIP: prefix starts the real run.
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- 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:
|
||||
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
|
||||
steps:
|
||||
- name: Check out repository
|
||||
|
||||
+16
-10
@@ -2,28 +2,34 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version).
|
||||
|
||||
## Unreleased
|
||||
## [1.2.0] (2026-10-18)
|
||||
|
||||
The first update on addons.mozilla.org since 1.0. It also delivers everything
|
||||
in 1.1.0, which was never published there.
|
||||
|
||||
- Add an Ask ChatGPT toolbar button: use selected text or open a blank chat,
|
||||
honoring saved prompt and tab/window preferences. No new permissions.
|
||||
Requires Firefox 112 or newer, like the other new entry points.
|
||||
- Add Off / Simple / Advanced context-menu modes with migration from the old
|
||||
visibility preference. Advanced provides selection presets and link/page URL
|
||||
actions, with optional translation and a preferred language or language dialog.
|
||||
- Preserve dedicated action instructions when shortening selections; ask before
|
||||
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
|
||||
model/effort URL probe, and document URL sharing. No new permissions.
|
||||
- Show up front, in the options panel and README, that ChatGPT may require
|
||||
signing in and that an unsent prompt needs Enter or Send.
|
||||
|
||||
|
||||
* Options instructions now show the configured keyboard shortcut, update when
|
||||
it changes, and indicate when no shortcut is assigned. Added a button to
|
||||
open Firefox’s shortcut editor where supported.
|
||||
* Shortened the context menu label to “Ask ChatGPT”.
|
||||
* Added quick usage instructions and guidance for changing the search keyword
|
||||
- Limit the prompt template to 1,000 characters, so an oversized template can
|
||||
no longer stop every other setting from saving.
|
||||
- Show the configured keyboard shortcut in the options instructions, updating
|
||||
when it changes and indicating when none is assigned, with a button to open
|
||||
Firefox’s shortcut editor where supported.
|
||||
- Shorten the context menu label to “Ask ChatGPT”.
|
||||
- Add quick usage instructions and guidance for changing the search keyword
|
||||
and default engine in Firefox to the options panel.
|
||||
* Added a “Feedback or report a bug” link to the options panel and README.
|
||||
- Add a “Feedback or report a bug” link to the options panel and README.
|
||||
|
||||
## [1.1.0] (2026-07-30)
|
||||
|
||||
@@ -44,7 +50,7 @@ Adds an options panel and two new ways to reach ChatGPT.
|
||||
* Permissions added: storage, menus, activeTab. None of them adds a line to
|
||||
Firefox's install prompt.
|
||||
|
||||
## [1.0.0]) (2024-06-09)
|
||||
## [1.0.0] (2024-06-09)
|
||||
|
||||
First release of software.
|
||||
|
||||
|
||||
@@ -96,6 +96,21 @@ send. The hypothesis that address-bar navigation might still be favoured
|
||||
(`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in
|
||||
practice**.
|
||||
|
||||
**Correction, 2026-10-18: `q` auto-submits again.**
|
||||
- **Signed out:** in headless Firefox 157, `chatgpt.com/?q=` sent the prompt
|
||||
and ChatGPT answered with no keypress. This happened even when an unrelated
|
||||
page navigated to the URL (`Sec-Fetch-Site: cross-site`).
|
||||
- **Maintainer's Firefox:** the same day, the maintainer reported that prompts
|
||||
were sent without pressing Enter.
|
||||
- **A week earlier:** on 2026-10-12 the signed-out path showed a verification
|
||||
error and the signed-in path still needed Enter
|
||||
(`docs/toolbar-verification.md`).
|
||||
|
||||
So this changed within a week and may differ between accounts. Treat both
|
||||
behaviours as possible. The copy rule below still applies, and the README
|
||||
already says ChatGPT decides whether a prompt is sent. Appendix B covers what
|
||||
this means for selection smuggling.
|
||||
|
||||
No copy in the options panel, README, or AMO listing may say the query is
|
||||
*searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled
|
||||
in**. The two-keypress behaviour is an upstream ChatGPT change, not a defect in
|
||||
@@ -698,7 +713,7 @@ by running commands against copies under `/tmp`; the repo itself was unmodified.
|
||||
| Parameter | Rating | Basis |
|
||||
|---|---|---|
|
||||
| `q` prefills | **Solid** | Dated 2026 sources; maintained tooling; OpenAI's own extension uses it; a live probe returned HTTP 200 with all parameters preserved. |
|
||||
| `q` auto-submits | **Refuted, and confirmed by first-hand test** | Prefill-only in every dated report since 2025-11-11. Tenable TRA-2025-22 documents OpenAI gating auto-submit on `Sec-Fetch-Site`. Reproduced 2026-07-29 in the maintainer's logged-in Firefox via this extension's own search-provider path. |
|
||||
| `q` auto-submits | **Varies; auto-submitting again as of 2026-10-18** | Prefill-only in every dated report from 2025-11-11, when Tenable TRA-2025-22 documented OpenAI gating auto-submit on `Sec-Fetch-Site`, and reproduced 2026-07-29 in the maintainer's logged-in Firefox. On 2026-10-18 it auto-submitted signed out, including after a cross-site navigation, and the maintainer reported the same in their own Firefox. See the correction under the `q` section. |
|
||||
| `model` honoured alongside `q` | **Uncertain** | An unresolved 2024-12-28 report says `q` forces GPT-4o regardless; counter-evidence exists from 2024–2025; no 2026 source positively confirms. Maintained tooling grades it experimental. |
|
||||
| Valid `model` slugs | **Uncertain** | No authoritative list; sightings rot (`gpt-4`, `o1-preview`, `gpt-4o`, `gpt-5-thinking`, `auto`, `instant`, `thinking`). |
|
||||
| `temporary-chat=true` | **Uncertain** | Community-confirmed originally; multiple reports of it not applying or reverting after the first message; OpenAI reworking the feature. |
|
||||
@@ -790,6 +805,16 @@ composer until a human presses Enter. **Auto-submit deletes that checkpoint.**
|
||||
This is also why the context menu and shortcut remain safe *as long as they only
|
||||
prefill*.
|
||||
|
||||
> **2026-10-18:** ChatGPT itself now auto-submits `?q=` prompts, at least for
|
||||
> some accounts (see the correction under the `q` section). While that holds,
|
||||
> the composer checkpoint above is gone for the context menu, toolbar button and
|
||||
> shortcut alike: hidden text in a selection can reach an answered prompt
|
||||
> without review. The extension still adds no submission of its own, and this
|
||||
> appendix's rejection stands. 1.2.0 ships with the behaviour known. Possible
|
||||
> mitigations, such as framing the default Ask prompt as data the way the preset
|
||||
> actions already do, or an option to review page-sourced text first, are open
|
||||
> follow-ups rather than part of this design.
|
||||
|
||||
**`webNavigation.transitionType` is not a security boundary.** The signal looks
|
||||
right — a `gpt` search emits `transitionType: "generated"` with
|
||||
`transitionQualifiers: ["from_address_bar"]`, and content cannot forge those
|
||||
|
||||
@@ -13,7 +13,6 @@ const TITLES = {
|
||||
};
|
||||
|
||||
export function getContext(info) {
|
||||
if (info.inputType === 'password') return null;
|
||||
if (typeof info.selectionText === 'string' && 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;
|
||||
|
||||
// Fraction of the kept text within which a whitespace cut is preferred.
|
||||
const WHITESPACE_WINDOW = 0.9;
|
||||
|
||||
export function applyTemplate(template, query) {
|
||||
if (query === "") {
|
||||
return "";
|
||||
@@ -42,65 +39,9 @@ function addOptionalParams(url, settings) {
|
||||
return url;
|
||||
}
|
||||
|
||||
function lastWhitespaceIndex(text) {
|
||||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
||||
if (/\s/.test(text[i])) {
|
||||
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.
|
||||
// Encode a complete prompt without silently shortening it; prepareAction owns
|
||||
// the length budget and asks before shortening. q goes first, matching the
|
||||
// most-cited working examples.
|
||||
export function serializePrompt(settings, prompt) {
|
||||
const url = new URL(BASE);
|
||||
if (prompt) url.searchParams.set("q", prompt);
|
||||
|
||||
+12
-1
@@ -25,12 +25,23 @@ export function prepareAction(request) {
|
||||
}
|
||||
const resolved = { ...request, language };
|
||||
const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text));
|
||||
// Each code point adds at least one URL character, so none past the first
|
||||
// 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.');
|
||||
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.');
|
||||
const points = Array.from(source);
|
||||
let low = 0, high = points.length;
|
||||
while (low < high) {
|
||||
const mid = Math.ceil((low + high) / 2);
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "Search with ChatGPT powered by OpenAI!",
|
||||
"description": "Adds a ChatGPT search engine",
|
||||
"description": "Adds a ChatGPT search engine, plus a right-click menu, toolbar button and shortcut that open ChatGPT with your selected text.",
|
||||
"manifest_version": 2,
|
||||
"author": "Mathew Guest <mat@zavage.net>",
|
||||
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
|
||||
"version": "1.1",
|
||||
"version": "1.2",
|
||||
"icons": {
|
||||
"16": "icons/powered-by-openai-icon-16x16.png",
|
||||
"32": "icons/powered-by-openai-icon-32x32.png",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"devDependencies": {
|
||||
"web-ext": "10.5.0"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "search-with-chatgpt-powered-by-openai-extension",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Development tooling for the Search with ChatGPT Firefox extension.",
|
||||
|
||||
@@ -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.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({selectionText:'secret',inputType:'password'}), null);
|
||||
assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page');
|
||||
});
|
||||
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 { 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 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",
|
||||
);
|
||||
assert.equal(serializePrompt(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("an empty prompt omits q entirely", () => {
|
||||
assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/");
|
||||
for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) {
|
||||
assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy model values never add a model URL parameter", () => {
|
||||
assert.equal(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"),
|
||||
buildChatGptUrl(DEFAULT_SETTINGS, "hi"));
|
||||
assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"),
|
||||
serializePrompt(DEFAULT_SETTINGS, "hi"));
|
||||
});
|
||||
|
||||
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);
|
||||
assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]);
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
test("all three URL-affecting settings combine, q first", () => {
|
||||
const url = buildChatGptUrl(
|
||||
withSettings({
|
||||
temporaryChat: true,
|
||||
webSearch: true,
|
||||
promptTemplate: "Explain: {query}",
|
||||
}),
|
||||
"entropy",
|
||||
);
|
||||
test("all URL-affecting settings combine, q first", () => {
|
||||
const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" });
|
||||
const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy"));
|
||||
assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]);
|
||||
assert.equal(queryOf(url), "Explain: entropy");
|
||||
});
|
||||
|
||||
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"]) {
|
||||
assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base);
|
||||
assert.equal(serializePrompt(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);
|
||||
const url = serializePrompt(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");
|
||||
assert.equal(applyTemplate("{query} / {query}", "x"), "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");
|
||||
assert.equal(applyTemplate("Be concise:", "why"), "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");
|
||||
for (const template of ["", " "]) {
|
||||
assert.equal(applyTemplate(template, "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({ 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);
|
||||
assert.equal(applyTemplate(null, "why"), "why");
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) {
|
||||
test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => {
|
||||
assert.equal(applyTemplate("{query}", raw), raw);
|
||||
assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${raw}`);
|
||||
assert.equal(queryOf(serializePrompt(DEFAULT_SETTINGS, applyTemplate("{query}", raw))), raw);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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 req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides});
|
||||
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.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', () => {
|
||||
assert.equal(typeof prepareAction, 'function');
|
||||
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)}));
|
||||
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]'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user