Compare commits

..
Author SHA1 Message Date
zavage b987685d89 Merge pull request 'chore: release 1.2.0' (#14) from develop into main
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/14
2026-10-18 02:55:38 -06:00
zavage 3f4b64374b Merge pull request 'chore: prepare the 1.2.0 release' (#13) from chore/release-1.2.0 into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/13
2026-10-18 02:52:32 -06:00
golem cf5e397d53 Merge remote-tracking branch 'origin/develop' into chore/release-1.2.0 2026-10-18 02:36:50 -06:00
golemandClaude Opus 5.5 8052c87c23 chore: prepare the 1.2.0 release
Version 1.2.0 (manifest "1.2"): the release adds features without
breaking changes, and 1.1.0 already exists in the changelog. The
lockfile's root version, still 1.0.0, moves with it.

Turn Unreleased into the 1.2.0 section, fold in the unheaded bullets
beneath it, add the template-limit entry, note that 1.1.0 never reached
addons.mozilla.org, and fix the stray ")" in the 1.0.0 heading. The
manifest description now covers the menu, toolbar button and shortcut.

Record in the design spec that ChatGPT auto-submits ?q= prompts again as
of 2026-10-18, and what that means for Appendix B's selection-smuggling
argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:27:54 -06:00
zavage 9f8a295a4f Merge pull request 'fix: limit the prompt template to 1,000 characters' (#12) from fix/template-length-limit into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/12
2026-10-18 02:26:43 -06:00
zavage 5a12663c92 Merge pull request 'refactor: remove the unused URL shortener' (#11) from refactor/remove-unused-shortener into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/11
2026-10-18 02:26:37 -06:00
zavage 384eca7589 Merge pull request 'ci: add a WIP-title gate to extension CI' (#10) from ci/8-wip-gate into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/10
2026-10-18 02:25:53 -06:00
zavage d96277ebd9 Merge pull request 'fix: cap huge selections before shortening' (#9) from fix/long-selection-cap into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/9
2026-10-18 02:25:44 -06:00
golemandClaude Opus 5.5 269e721fe8 fix: limit the prompt template to 1,000 characters
storage.sync rejects any item over 8192 bytes, and the options page
writes every field in one set(). An oversized template therefore made
every later save fail, including changes to unrelated settings, with
only a brief "Could not save settings".

Cap the template at 1,000 UTF-16 units: maxlength on the input, and
mergeSettings falls back to the default for a longer stored value, as it
does for other out-of-range settings. Even a template of pasted control
characters (six bytes each once JSON-escaped) stays under the quota; a
test pins that arithmetic and another ties the HTML maxlength to the
constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:23:03 -06:00
golemandClaude Opus 5.5 39a3ac74f4 ci: use the github context for the WIP gate
Gitea treats github.* as an alias for gitea.*. The pattern was canaried
in this form, and pr-flow only detects a gate written this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:09:04 -06:00
golemandClaude Opus 5.5 cb7da3a538 ci: add a WIP-title gate to extension CI
Every push to an open PR ran the full validate job, and pull_request
lacked the edited type, so removing WIP: would not start a run. pr-flow
therefore refused to open PRs early.

Skip validate on PRs titled WIP: or [WIP], add edited so un-WIP starts
the run, add workflow_dispatch for on-demand runs, and cancel superseded
PR runs (never push runs, since main's push uploads the package). The
workflow name and job id are unchanged, so the required check context
stays "Firefox Extension CI / validate (pull_request)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:08:12 -06:00
golemandClaude Opus 5.5 519131c21f fix: cap huge selections before shortening
The shortcut and toolbar read window.getSelection() without a limit, so a
select-all on a very large page handed prepareAction millions of
characters. Every binary-search probe re-encoded the whole candidate
prefix: about 1.1 s for 5M characters, paid again by the dialog.

Each code point adds at least one character to the URL, so nothing past
the first MAX_URL_CHARS can fit. Cap there before any encoding. A capped
source never takes the ready path: renderPrompt trims, so a prefix of
mostly whitespace could fit and silently drop the rest without consent.

Output is unchanged: 2,000 random cases matched develop exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:32:22 -06:00
13 changed files with 138 additions and 25 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
+16 -10
View File
@@ -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). 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, - Add an Ask ChatGPT toolbar button: use selected text or open a blank chat,
honoring saved prompt and tab/window preferences. No new permissions. 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 - Add Off / Simple / Advanced context-menu modes with migration from the old
visibility preference. Advanced provides selection presets and link/page URL visibility preference. Advanced provides selection presets and link/page URL
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
signing in and that an unsent prompt needs Enter or Send. signing in and that an unsent prompt needs Enter or Send.
- Limit the prompt template to 1,000 characters, so an oversized template can
no longer stop every other setting from saving.
* Options instructions now show the configured keyboard shortcut, update when - Show the configured keyboard shortcut in the options instructions, updating
it changes, and indicate when no shortcut is assigned. Added a button to when it changes and indicating when none is assigned, with a button to open
open Firefox’s shortcut editor where supported. Firefox’s shortcut editor where supported.
* Shortened the context menu label to “Ask ChatGPT”. - Shorten the context menu label to “Ask ChatGPT”.
* Added quick usage instructions and guidance for changing the search keyword - Add quick usage instructions and guidance for changing the search keyword
and default engine in Firefox to the options panel. 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) ## [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 * Permissions added: storage, menus, activeTab. None of them adds a line to
Firefox's install prompt. Firefox's install prompt.
## [1.0.0]) (2024-06-09) ## [1.0.0] (2024-06-09)
First release of software. First release of software.
+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.
@@ -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 (`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in
practice**. 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 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 *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 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 | | 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` 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. | | `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`). | | 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. | | `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 This is also why the context menu and shortcut remain safe *as long as they only
prefill*. 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 **`webNavigation.transitionType` is not a security boundary.** The signal looks
right — a `gpt` search emits `transitionType: "generated"` with right — a `gpt` search emits `transitionType: "generated"` with
`transitionQualifiers: ["from_address_bar"]`, and content cannot forge those `transitionQualifiers: ["from_address_bar"]`, and content cannot forge those
+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,
+12 -1
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));
// 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); const url = serialize(source);
if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url }; 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
@@ -1,10 +1,10 @@
{ {
"name": "Search with ChatGPT powered by OpenAI!", "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, "manifest_version": 2,
"author": "Mathew Guest <mat@zavage.net>", "author": "Mathew Guest <mat@zavage.net>",
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT", "homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
"version": "1.1", "version": "1.2",
"icons": { "icons": {
"16": "icons/powered-by-openai-icon-16x16.png", "16": "icons/powered-by-openai-icon-16x16.png",
"32": "icons/powered-by-openai-icon-32x32.png", "32": "icons/powered-by-openai-icon-32x32.png",
+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">
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.0.0", "version": "1.2.0",
"devDependencies": { "devDependencies": {
"web-ext": "10.5.0" "web-ext": "10.5.0"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.1.0", "version": "1.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Development tooling for the Search with ChatGPT Firefox extension.", "description": "Development tooling for the Search with ChatGPT Firefox extension.",
+20
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', () => {
@@ -49,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);