diff --git a/AGENTS.md b/AGENTS.md index 9a2c2f9..7309bc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,19 @@ ## Project Structure & Module Organization -This is a manifest-only Firefox WebExtension with npm-based development tooling. +This is a Firefox WebExtension (Manifest V2) with npm-based development tooling. -- `manifest.json` defines the extension metadata and ChatGPT search provider. +- `manifest.json` defines the extension metadata, the ChatGPT search provider, + the background script, the options panel, and the keyboard command. +- `lib/` holds pure logic with no browser dependencies: `defaults.js`, + `build-url.js`, and `settings.js`. This is the unit-tested core. +- `background/background.js` is an ES-module background script that registers + the context menu and the keyboard command. +- `options/` is the options panel rendered inline in `about:addons`. +- `test/` holds `node:test` unit tests. Excluded from the packaged ZIP. - `icons/` contains size-specific PNG artwork named `powered-by-openai-icon-x.png`. - `web-ext-config.mjs` controls linting, local execution, and ZIP packaging. +- `docs/superpowers/` holds specs and plans. Excluded from the packaged ZIP. - `.gitea/workflows/firefox-extension.yml` validates pull requests and protected branches. - `README.md`, `CHANGELOG.md`, and `LICENSE.txt` cover usage, releases, and licensing. @@ -19,8 +27,9 @@ Firefox Desktop is the only current implementation and release target. Do not ad - `npm ci` installs the exact dependencies from `package-lock.json`; use Node.js 22 or newer. - `npm run dev` launches a temporary Firefox profile with automatic extension reload. - `npm run lint` runs strict Mozilla extension validation. +- `npm run test:unit` runs the `node:test` unit tests over `test/`. - `npm run build` creates the unsigned ZIP under `web-ext-artifacts/`. -- `npm test` runs lint and build checks together; run it before every pull request. +- `npm test` runs lint, unit tests, and build together; run it before every pull request. ## Coding Style & Naming Conventions @@ -28,9 +37,48 @@ Use two-space indentation in JSON, YAML, and JavaScript configuration. Preserve ## Testing Guidelines -Automated checks cover manifest validation and packaging, not browser behavior. After `npm test`, use `npm run dev` and confirm `gpt ` opens ChatGPT, the provider stays non-default, and Firefox search settings can configure it. Inspect ZIP contents whenever packaging rules or assets change. +Unit tests cover the pure logic in `lib/` via `node:test`; automated checks +otherwise cover manifest validation and packaging, not browser behaviour. Note +`node --test` exits 0 when it discovers no test files, so confirm a non-zero +test count rather than trusting the exit code alone. -Gitea Actions runs checks for every pull request and pushes to `develop` and `main`. Only successful `main` pushes upload an unsigned build artifact; CI does not publish to AMO. +After `npm test`, use `npm run dev` and confirm: + +- `gpt ` opens ChatGPT, the provider stays non-default, and Firefox + search settings can configure it. +- The Options/Preferences tab renders the settings plus Restore defaults, + in both light and dark themes and at a narrow window width, and values + persist across a restart. +- **Context menu → Simple** shows the direct **Ask ChatGPT** item only for + selected text. **Advanced** shows the contextual submenu; selection takes + precedence over links. **Off** hides the menu. Switch modes without restarting + and confirm Alt+Shift+G still works in every mode. Restore defaults returns + to Simple with Translate hidden, and preferences persist across a restart. +- Enable Translate and check Ask each time, preferred language, Enter, Escape, + and Cancel. Confirm long selections require consent before shortening and + link URLs are never shortened. Check concurrent dialogs retain separate data. +- `Alt+Shift+G` acts on the selection and opens a blank chat when nothing is + selected or on a restricted page such as `about:config`. Press the key to test + this — `commands.getAll()` reports a shortcut as registered even when Firefox + has silently overridden it. The injected snippet is gated on + `document.hasFocus()`, so cover all four frame cases: + - Select in the top frame → the top-frame text arrives. + - Select inside an iframe → the iframe text arrives. + - Select in the top frame, *then* select inside an iframe → the iframe text + arrives. `hasFocus()` is true for a focused frame and all its ancestors, so + if the stale top-frame text arrives instead, exclude ancestors by also + checking that `document.activeElement` is not the frame element. + - Press `Ctrl+F`, find a term, then press the shortcut without clicking back + into the page → a blank chat is expected. Firefox leaves focus in the + findbar, so no content frame reports focus. This is a deliberate trade: a + stale cross-origin frame silently seeding the prompt was the worse failure. + +Inspect ZIP contents whenever packaging rules or assets change; `test/` and +`docs/` must never appear, not even as empty directory entries. + +Gitea Actions runs checks for every pull request and pushes to `develop` and +`main`. Only successful `main` pushes upload an unsigned build artifact; CI does +not publish to AMO. ## Commit & Pull Request Guidelines @@ -41,3 +89,11 @@ Pull requests should explain the user-visible effect, list manual Firefox checks ## Security & Privacy Do not commit credentials or API keys. New permissions, telemetry, intermediary servers, or data collection require explicit justification and matching privacy documentation. + +Permissions are deliberately limited to `storage`, `menus`, and `activeTab`, +which add no line to Firefox's install prompt. Do not add `tabs`, +`notifications`, `webNavigation`, or any host permission without re-reading +`docs/superpowers/specs/2026-07-29-extension-options-design.md` — each was +considered and rejected there, and Appendix B records a feature rejected on +security grounds that must not be revived without clearing the bar documented +with it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 20bf935..415c9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,47 @@ 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 + +- 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. +- Add accessible action dialogs with cancellation and retry, remove the temporary + model/effort URL probe, and document URL sharing. No new permissions. + + + * 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 + and default engine in Firefox to the options panel. + * Added a “Feedback or report a bug” link to the options panel and README. + +## [1.1.0] (2026-07-30) + +Adds an options panel and two new ways to reach ChatGPT. + + * Options panel in `about:addons` with five settings: context menu visibility, + temporary chat, web-search hint, where results open, and a prompt template. + * Right-click selected text and choose "Ask ChatGPT" to open ChatGPT with that + text filled in. + * Keyboard shortcut `Alt+Shift+G` does the same for the current selection, and + opens an empty chat when nothing is selected. Rebindable via Manage Extension + Shortcuts. + * Documented that ChatGPT no longer submits prompts passed in a URL, so a + second Enter is needed. This is an upstream change to chatgpt.com. + * `gpt ` address-bar search is unchanged. + * The options panel, right-click item, and keyboard shortcut require Firefox + 112 or newer. Older versions keep the `gpt ` search unchanged. + * Permissions added: storage, menus, activeTab. None of them adds a line to + Firefox's install prompt. + ## [1.0.0]) (2024-06-09) First release of software. * favicon - Wanted to use the OpenAI ChatGPT icon but worried about copyright. Without granted permission, have to roll with this icon. (Powered by OpenAI!) * Adds ChatGPT as non-default search engine. Queryable by "gpt " in user's URL bar. Can configure through browser settings. - \ No newline at end of file diff --git a/README.md b/README.md index ec0b98a..7200e8d 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,104 @@ It uses no API key, custom server, or intermediary handler. This is an unofficial extension provided by Zavage Software Inc. - [Firefox Add-ons listing](https://addons.mozilla.org/en-US/firefox/addon/search-with-chatgpt/) +- [Project website](https://zavage-software.com/portfolio/search-with-ChatGPT) - [Project repository](https://git-repos.zavage.net/zavage-software/search-with-chatgpt-powered-by-openai-extension) +- [Feedback or report a bug](https://zavage-software.com/contact-us) - [Zavage Software](https://zavage-software.com) See `LICENSE.txt` for licensing terms. +## Ask about selected text + +Highlight text on a webpage, right-click it, and choose **Ask ChatGPT**. +ChatGPT opens with your selection. If the prompt is not sent automatically, +press Enter in ChatGPT to send it. Try selecting a paragraph you want explained or +translated. + +You can also press **Alt+Shift+G** to use the current selection. With nothing +selected, the shortcut opens a blank chat. Change the shortcut under +**Manage Extension Shortcuts** in the `about:addons` gear menu. + +The options panel shows your currently configured shortcut and refreshes when +it changes or you return to the panel. If you remove it, the panel shows +“No keyboard shortcut assigned”. Its **Change keyboard shortcut** button opens +Firefox’s shortcut editor when supported; otherwise follow the menu path above. +**Restore defaults** does not change your keyboard shortcut. + +To customize the prompt, set **Prompt template** to something like +`Explain simply: {query}` or `Translate into Spanish: {query}`. The Simple menu action, Ask normally, and the shortcut use your template. +Other advanced actions use their own dedicated prompts. + +## Search keyword and default engine + +Open Firefox’s **Settings → Search**, or paste `about:preferences#search` into +the address bar. Under **Search Shortcuts**, edit ChatGPT’s keyword to replace +`gpt` with your preferred prefix. Under **Default Search Engine**, choose +ChatGPT to search without a prefix, or choose your previous engine to switch +back. See [Firefox’s search settings guide](https://support.mozilla.org/en-US/kb/change-your-default-search-settings-firefox) +for illustrated instructions. + +These are Firefox settings; the extension’s **Restore defaults** button does +not change them. + +## Options + +Open `about:addons`, select this extension, and choose the **Options** tab +(**Preferences** on macOS and Linux). Available settings: + +- **Context menu** — **Simple** (default) keeps the direct selection action; + **Advanced** offers contextual presets; **Off** hides the menu. The keyboard + shortcut works in every mode. Existing enabled/disabled preferences migrate + to Simple/Off. Changes apply without restarting Firefox. +- **Show Translate in advanced menu** — off by default. When enabled, choose + **Ask each time** for a language dialog, or enter a **Preferred language** + to translate directly. An empty or invalid preferred language falls back to + asking. Enter continues; Escape or Cancel opens nothing. +- **Request a temporary chat** — asks ChatGPT not to save the conversation. + ChatGPT may not honour this; check its own indicator before relying on it. +- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a + guarantee. +- **Open in** — a new tab, a new background tab, or a new window. +- **Prompt template** — wraps your text, for example `Explain simply: {query}`. + +Restore defaults resets extension preferences to Simple mode with translation +hidden; it does not change Firefox's search engine or shortcut settings. + +In Advanced mode, selected text offers **Ask normally**, **Explain**, +**Summarize**, and **Debug code**. Translate appears only when enabled. The +extension does not try to detect code. A selected link uses selection actions; +without a selection, a link offers **Ask about this link** and **Review link +safety**. Page background offers **Ask about this page** and **Summarize this +page**. Link/page actions send only the URL. ChatGPT may be unable to access +its content, and a link review is not a guarantee of safety. + +Long selections require confirmation before sending shortened text. Link URLs +are never shortened: unsupported schemes, embedded login credentials, and URLs +that cannot fit are rejected. The extension does not simulate submission; ChatGPT controls whether a URL +prompt is sent automatically or waits in the composer. + +These settings apply to selecting text and choosing +**Ask ChatGPT** from the right-click menu, or pressing +Alt+Shift+G. Rebind the shortcut under +**Manage Extension Shortcuts** in the Add-ons gear menu. + +The `gpt ` address-bar search is unchanged and does not read these +settings. + +The options panel, the right-click item, and the keyboard shortcut require +Firefox 112 or newer. On older versions the extension still installs and the +`gpt ` address-bar search still works; only these newer features are +inactive. + +## Sending your prompt + +The extension opens ChatGPT with the prompt in the URL. ChatGPT controls what +happens next: it may send the prompt automatically or leave it in the composer. +**If the prompt is not sent automatically, press Enter in ChatGPT.** + +The extension does not click Send, simulate Enter, or call ChatGPT's internal +conversation API. This behavior can vary with changes to ChatGPT itself. + ## Browser Support Firefox Desktop is the current development and release target. Chrome, Brave, @@ -31,9 +124,11 @@ Available commands: - `npm run dev` launches the extension in a temporary Firefox profile and reloads it when files change. - `npm run lint` validates the extension and treats warnings as errors. +- `npm run test:unit` runs the `node:test` unit tests over `test/`. - `npm run build` creates `web-ext-artifacts/search-with-chatgpt-firefox-.zip`. -- `npm test` runs the same lint and build checks used by Gitea Actions. +- `npm test` runs the same lint, unit test, and build checks used by Gitea + Actions. The generated ZIP is unsigned. Use `npm run dev` for local testing; signing and submission to Mozilla Add-ons remain a separate release step. @@ -44,4 +139,32 @@ configured in Firefox search settings. ## Privacy Policy -This extension collects zero user data. +This extension has no server, no API key, and no intermediary. Nothing is +reported to Zavage Software. + +- Your settings are stored in your own browser profile. If you use Firefox Sync + with **Add-ons** enabled, they sync end-to-end encrypted through your own + Mozilla account. +- Selection actions and the shortcut place your selected text in the ChatGPT + URL. Advanced link/page actions instead place the chosen link or current + page URL in the prompt. URLs may include private query parameters or fragments; + use those actions only for URLs you want to share with ChatGPT. +- The extension reads the selected text or relevant URL only when you invoke + the action. It does not extract full page text, read page titles, or open a + link itself for a safety check. +- Language and confirmation dialogs hold pending content in memory only, not + in settings sync or persistent storage. Canceling sends no prompt to ChatGPT. + Closing or restarting Firefox discards pending actions. + +## Model selection + +Model selection is not currently supported. We tested model URL hints while +signed in and signed out, but could not make them change the selected model. +Signed-out testing used Luna; signed-in testing stayed on GPT-5.6 Sol with +medium effort regardless of the requested model. We removed the model setting +rather than offer a control that did not work. Choose your model and thinking +effort in ChatGPT itself where available. Previously saved extension model +values are ignored. + +Temporary chat did work in manual testing. Check ChatGPT's temporary-chat +indicator before sending; web-search hints remain unverified. diff --git a/background/background.js b/background/background.js new file mode 100644 index 0000000..414cf26 --- /dev/null +++ b/background/background.js @@ -0,0 +1,92 @@ +import { load } from "../lib/settings.js"; +import { prepareAction } from "../lib/prepare-action.js"; +import { openChat } from "./open-chat.js"; +import { showActionDialog } from "./dialogs.js"; +import { initializeMenus } from "./menus.js"; + +const COMMAND_ID = "ask-chatgpt"; + +// Reads the user's selection for the keyboard-shortcut path. The context menu +// does not need this — it receives info.selectionText directly. +// +// A commands invocation confers activeTab (ext-commands.js grants it before +// firing onCommand), so executeScript works with no host permission. +// +// Every frame keeps its own independent Selection, and a selection made in a +// non-focused frame is not cleared when the user selects elsewhere. Without a +// filter, a stale selection in an unfocused (possibly cross-origin) iframe +// could win over — or be pulled alongside — the selection the user actually +// made, with the winner among multiple non-empty results decided by an +// executeScript ordering that isn't specified. The injected snippet is +// therefore gated on document.hasFocus(), which is true for the focused +// document and all of its ancestors: the top frame wins when the user +// selected there, and an iframe's selection is only picked up when it is +// itself the focused frame. Exclude ancestors whose active element is a frame. +// +// Failure has three measured shapes, all handled here: a thrown +// "Missing host permission for the tab", a thrown variant naming frames, and a +// SILENT resolution to [null] on parent-process about: pages. Never index into +// a result without a null check. +async function readSelection() { + let tabs; + try { + tabs = await browser.tabs.query({ active: true, currentWindow: true }); + } catch { + return ""; + } + + // Only tab.id may be read. Reading tab.url or tab.title would require the + // "tabs" permission, which does add an install-prompt warning line. + const tabId = tabs?.[0]?.id; + if (typeof tabId !== "number") { + return ""; + } + + let results; + try { + results = await browser.tabs.executeScript(tabId, { + code: "document.hasFocus() && !['IFRAME', 'FRAME'].includes(document.activeElement?.tagName) ? window.getSelection().toString() : ''", + allFrames: true, + matchAboutBlank: true, + }); + } catch { + return ""; + } + + if (!Array.isArray(results)) { + return ""; + } + for (const result of results) { + if (typeof result === "string" && result.trim() !== "") { + return result; + } + } + return ""; +} + +async function runAction(request) { + let preparation = prepareAction(request); + if (preparation.kind === "ready") { + try { + await openChat(browser, preparation.url, request.settings.openIn); + return; + } catch { + // Keep the ready payload so the dialog's Continue button retries it. + } + } + try { + await showActionDialog(browser, { request, preparation }); + } catch { + console.error("Could not open the action dialog. Please try the action again."); + } +} + +void initializeMenus({ browser, load, onAction: runAction }).catch(console.error); +if (browser.commands) { + browser.commands.onCommand.addListener(async command => { + if (command !== COMMAND_ID) return; + // Read the selection before another asynchronous API loses activeTab context. + const source = await readSelection(); + await runAction({ actionId: "ask", source, settings: await load() }); + }); +} diff --git a/background/dialogs.js b/background/dialogs.js new file mode 100644 index 0000000..2ce68da --- /dev/null +++ b/background/dialogs.js @@ -0,0 +1,61 @@ +// Pending source content exists only during the initial handshake. The dialog +// owns a structured clone after receipt, independent of the event page's life. +export async function showActionDialog(browser, payload) { + const id = crypto.randomUUID(); + const url = browser.runtime.getURL('dialog/action.html') + '?request=' + id; + let tabId; + let port; + let finished = false; + let resolveOpened; + const opened = new Promise(resolve => { resolveOpened = resolve; }); + let timer; + let resolveDone, rejectDone; + const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; }); + // Attach rejection handling immediately, including when window creation stalls. + void done.catch(() => {}); + function finish(error) { + if (finished) return; + finished = true; + clearTimeout(timer); + payload = null; + browser.runtime.onConnect.removeListener(onConnect); + browser.tabs.onRemoved.removeListener(onRemoved); + if (port) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + port.disconnect(); + } + if (error) rejectDone(error); else resolveDone(); + } + function onMessage(message) { + if (message?.type === 'received') finish(); + } + function onDisconnect() { finish(); } + function onRemoved(removedId) { if (removedId === tabId) finish(); } + async function onConnect(candidate) { + if (candidate.name !== 'action-dialog:' + id) return; + await opened; + if (finished || port || candidate.sender?.id !== browser.runtime.id || + candidate.sender?.url !== url || candidate.sender?.tab?.id !== tabId) return; + port = candidate; + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + try { port.postMessage({ type: 'initialize', payload }); } + catch { finish(new Error('Could not initialize action dialog')); } + } + browser.runtime.onConnect.addListener(onConnect); + browser.tabs.onRemoved.addListener(onRemoved); + timer = setTimeout(() => finish(new Error('Action dialog initialization timed out')), 15000); + try { + try { + const win = await browser.windows.create({ url, type: 'popup', width: 480, height: 360 }); + tabId = win.tabs?.[0]?.id; + if (typeof tabId !== 'number') throw new Error('Missing dialog tab'); + } catch { + const tab = await browser.tabs.create({ url }); + tabId = tab.id; + } + } catch (error) { finish(error); } + finally { resolveOpened(); } + return done; +} diff --git a/background/menus.js b/background/menus.js new file mode 100644 index 0000000..4b30409 --- /dev/null +++ b/background/menus.js @@ -0,0 +1,89 @@ +import { getActions, getContext } from '../lib/actions.js'; + +const ROOT = 'ask-chatgpt'; +const SIMPLE = 'ask-chatgpt-selection'; +const PREFIX = 'ask-chatgpt:'; +const CONTEXTS = ['selection', 'link', 'page']; +const MENU_KEYS = ['contextMenuMode', 'showContextMenu', 'showTranslate', 'translationMode', 'translationLanguage']; + +export function initializeMenus({ browser, load, onAction }) { + let queue = Promise.resolve(); + let mode = 'off'; + let children = []; + let menuInstance = 0; + const enqueue = task => { + queue = queue.catch(() => {}).then(task); + return queue; + }; + const create = properties => new Promise((resolve, reject) => { + browser.menus.create(properties, () => { + const error = browser.runtime.lastError; + if (error) reject(new Error(error.message)); else resolve(); + }); + }); + function rebuild() { + return enqueue(async () => { + const settings = await load(); + await browser.menus.removeAll(); + children = []; + mode = settings.contextMenuMode; + if (mode === 'off') return; + if (mode === 'simple') { + await create({ id: SIMPLE, title: 'Ask ChatGPT', contexts: ['selection'] }); + return; + } + await create({ id: ROOT, title: 'Ask ChatGPT', contexts: CONTEXTS }); + for (const kind of ['selection', 'link', 'page']) { + for (const action of getActions({ kind }, settings)) { + const id = PREFIX + action.id; + await create({ id, parentId: ROOT, title: action.title, contexts: CONTEXTS, visible: false }); + children.push({ id, kind }); + if (action.id === 'ask') { + const separator = PREFIX + 'separator'; + await create({ id: separator, parentId: ROOT, type: 'separator', contexts: CONTEXTS, visible: false }); + children.push({ id: separator, kind }); + } + } + } + }); + } + browser.storage.onChanged.addListener((changes, area) => { + if (area === 'sync' && MENU_KEYS.some(key => Object.hasOwn(changes, key))) return rebuild().catch(console.error); + }); + browser.menus.onHidden.addListener(() => { menuInstance++; }); + browser.menus.onShown.addListener(info => { + const instance = ++menuInstance; + // Context labels are available without host access; selectionText/pageUrl + // are deliberately NOT required until the user invokes an item. + const contexts = info.contexts ?? []; + const kind = contexts.includes('selection') ? 'selection' + : contexts.includes('link') ? 'link' + : contexts.includes('page') && !info.editable && !contexts.some(c => ['image', 'video', 'audio'].includes(c)) ? 'page' : null; + return enqueue(async () => { + if (instance !== menuInstance || mode !== 'advanced') return; + await Promise.all([ + browser.menus.update(ROOT, { visible: kind !== null }), + ...children.map(child => browser.menus.update(child.id, { visible: child.kind === kind })), + ]); + if (instance === menuInstance) await browser.menus.refresh(); + }).catch(console.error); + }); + browser.menus.onClicked.addListener(async info => { + try { + const settings = await load(); + const context = getContext(info); + if (!context || settings.contextMenuMode === 'off') return; + let actionId; + if (settings.contextMenuMode === 'simple') { + if (info.menuItemId !== SIMPLE || context.kind !== 'selection') return; + actionId = 'ask'; + } else { + const allowed = getActions(context, settings); + actionId = allowed.find(action => PREFIX + action.id === info.menuItemId)?.id; + if (!actionId) return; + } + await onAction({ actionId, source: context.source, settings }); + } catch { console.error('Could not run context menu action.'); } + }); + return rebuild(); +} diff --git a/background/open-chat.js b/background/open-chat.js new file mode 100644 index 0000000..e816597 --- /dev/null +++ b/background/open-chat.js @@ -0,0 +1,9 @@ +export async function openChat(browser, url, openIn) { + if (openIn === 'new-window') { + try { + await browser.windows.create({ url }); + return; + } catch { /* Fall back to a tab. */ } + } + await browser.tabs.create({ url, active: openIn !== 'background-tab' }); +} diff --git a/dialog/action.css b/dialog/action.css new file mode 100644 index 0000000..a0048e2 --- /dev/null +++ b/dialog/action.css @@ -0,0 +1,5 @@ +body { margin: 0; padding: 1.25rem; box-sizing: border-box; } +h1 { font-size: 1.25rem; margin-top: 0; } +[hidden] { display: none !important; } +button:disabled { opacity: 0.6; } +#message { overflow-wrap: anywhere; } diff --git a/dialog/action.html b/dialog/action.html new file mode 100644 index 0000000..a589d50 --- /dev/null +++ b/dialog/action.html @@ -0,0 +1,22 @@ + + + + +Ask ChatGPT + + +

Ask ChatGPT

+

Preparing your action…

+
+ +

+
+ + +
+
+ + diff --git a/dialog/action.js b/dialog/action.js new file mode 100644 index 0000000..5b9fb06 --- /dev/null +++ b/dialog/action.js @@ -0,0 +1,51 @@ +import { createDialogController } from './controller.js'; +import { openChat } from '../background/open-chat.js'; + +const field = id => document.getElementById(id); +let controller; +let initialized = false; +let previousKind; +const close = () => window.close(); +function render(state) { + const language = state.kind === 'language'; + field('title').textContent = language ? 'Translate selected text' : 'Ask ChatGPT'; + field('description').textContent = language ? 'Which language would you like to translate into?' + : state.kind === 'shortened' ? 'The selection is too long to send in full. Continue with a shortened selection, or cancel and select less text.' + : state.kind === 'error' ? state.message : 'Ready to open ChatGPT with your prompt.'; + field('language-field').hidden = !language; + field('language').disabled = state.busy; + field('continue').hidden = state.kind === 'error'; + field('continue').disabled = state.busy; + field('continue').textContent = state.kind === 'shortened' ? 'Continue with shortened text' : 'Continue'; + field('cancel').disabled = state.busy; + field('message').textContent = state.kind === 'error' ? '' : state.message ?? ''; + if (previousKind !== state.kind) { + (language ? field('language') : state.kind === 'error' ? field('cancel') : field('continue')).focus(); + previousKind = state.kind; + } +} +function failed() { + if (initialized) return; + render({ kind: 'error', message: 'Could not prepare this action. Close this window and try the menu action again.' }); +} +const id = new URL(location.href).searchParams.get('request'); +const timer = setTimeout(failed, 15000); +const port = browser.runtime.connect({ name: 'action-dialog:' + id }); +port.onMessage.addListener(message => { + if (initialized || message?.type !== 'initialize' || !message.payload?.request) return; + initialized = true; + clearTimeout(timer); + controller = createDialogController({ payload: message.payload, render, close, + open: (url, mode) => openChat(browser, url, mode) }); + port.postMessage({ type: 'received' }); +}); +port.onDisconnect.addListener(() => { clearTimeout(timer); failed(); }); +field('action-form').addEventListener('submit', event => { + event.preventDefault(); + void controller?.submit(field('language').value); +}); +function cancel() { if (controller) controller.cancel(); else close(); } +field('cancel').addEventListener('click', cancel); +document.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); cancel(); } +}); diff --git a/dialog/controller.js b/dialog/controller.js new file mode 100644 index 0000000..c4d7b98 --- /dev/null +++ b/dialog/controller.js @@ -0,0 +1,46 @@ +import { normalizeLanguage } from '../lib/settings.js'; +import { prepareAction } from '../lib/prepare-action.js'; + +export function createDialogController({ payload, open, render, close }) { + let request = structuredClone(payload.request); + let state = { ...payload.preparation, busy: false }; + let cancelled = false; + const show = () => render({ ...state }); + show(); + return { + cancel() { + if (state.busy || cancelled) return; + cancelled = true; + request = null; + close(); + }, + async submit(language) { + if (cancelled || state.busy || state.kind === 'error') return; + if (state.kind === 'language') { + const normalized = normalizeLanguage(language); + if (!normalized) { + state.message = 'Enter a language using up to 100 characters.'; + show(); return; + } + request.language = normalized; + state = { ...prepareAction(request), busy: false }; + show(); + if (state.kind !== 'ready') return; + } + if (!['ready', 'shortened'].includes(state.kind)) return; + state.busy = true; + state.message = ''; + show(); + try { + await open(state.url, request.settings.openIn); + cancelled = true; + request = null; + close(); + } catch { + state.busy = false; + state.message = 'Could not open ChatGPT. Please try again or cancel.'; + show(); + } + }, + }; +} diff --git a/docs/advanced-context-menu-verification.md b/docs/advanced-context-menu-verification.md new file mode 100644 index 0000000..6a67758 --- /dev/null +++ b/docs/advanced-context-menu-verification.md @@ -0,0 +1,65 @@ +# Advanced context menu verification + +Implemented against the approved 2026-10-05 spec and plan. Implementation was verified in the feature checkout. PR preparation commits +the extension changes separately from unrelated local notes. No release or +permission change is included. + +## Automated checks + +- `npm test`: strict lint with zero errors, notices, or warnings; 10 discovered + test files passed; unsigned ZIP built successfully. +- Direct execution of each test file: **60 individual tests passed**. +- ZIP inspection: dialog assets present; no docs, tests, or execution scratch. +- `git diff --check`: clean. +- Regression checks cover legacy preference migration, partial-save preservation, + context priority, dedicated prompts, literal substitutions, URL rejection, + Unicode budgets, truncation consent, dialogs, menu updates, and frame focus. + +## Actual Firefox 157 checks + +Temporary Firefox launched through `npm run dev`; interactions automated using +Marionette in a temporary profile. No user's browsing profile was modified. + +- Native selection, link, linked-selection, and page-background menus displayed + the intended action groups. +- Extension dialog handoff acknowledged; language input received initial focus; + Spanish translation opened the correctly constructed ChatGPT URL. +- Concurrent dialogs held separate selections. Cancel and actual Escape key + dispatch opened no ChatGPT tab. Empty language submission was blocked. +- Long selection displayed consent; Continue opened an 8,000-character URL. +- Settings reflected storage updates. Narrow light/dark settings screenshots + were inspected and showed no horizontal overflow. +- Extension reload preserved configured preferences. + +Not verified: physical shortcut presses in the user's desktop session (headless +WebDriver key attempts did not trigger the command), full browser restart, +forced background suspension/wakeup, all editable-field cases, dialog-window +creation failure on the native desktop, and the complete AGENTS.md manual matrix. +The shortcut's frame-selection logic is covered by an executable regression test, +including stale parent selection and restricted-page failure. + +## Independent review + +A fresh reviewer found no blocking defect. Its partial-save contract finding +was reclassified as important and fixed with a failing-then-passing regression. +One minor remains: if a direct launch fails but a retry dialog opens, the dialog +says it is ready rather than explaining the original failure. Continue retries. + +## Execution decisions + +- Worked in the existing feature checkout with a backup at + `/tmp/chatgpt-advanced-baseline`; this preserves prior uncommitted work but + means new work shares that checkout. +- Deferred implementation commits to avoid sweeping unrelated changes into them; + review uses the saved baseline instead of task commit ranges. +- Target languages must be single-line text of at most 100 Unicode code points; + unusual control-character labels are rejected. +- Menu-open visibility uses permission-free Firefox context labels. Source data + is read only at invocation. Whitespace-only selections may show a group but + cannot trigger a nonempty-source action. +- Partial settings updates retain their original contract so unrelated settings + cannot be reset; the extra regression test protects that contract. +- Prior shortcut-editor behavior and other unrelated dirty changes were kept; + their preexisting limitations are outside this change. +- ChatGPT honoring hints or retrieving a URL remains external behavior, described + as best effort rather than guaranteed. diff --git a/docs/future-issue-proposals.md b/docs/future-issue-proposals.md new file mode 100644 index 0000000..bd2d4a8 --- /dev/null +++ b/docs/future-issue-proposals.md @@ -0,0 +1,149 @@ +# Future issue proposals + +Drafts for consideration, not filed issues or committed release milestones. +Firefox Desktop remains the only target. Suggested order starts with named +prompt actions; research tasks can run independently. + +## 1. Add named prompt actions + +**Priority:** High. **Type:** Enhancement. + +The extension currently supports one global prompt template. Let users save +several named actions such as Explain, Summarize, Translate, and Rewrite. + +Acceptance criteria: +- Create, edit, reorder, and delete actions in settings. +- Choose an action from a compact selection context submenu. +- Preserve the existing template and shortcut behavior during migration. +- Treat templates as text, never executable code; validate limits and handle + storage sync quota failures visibly. +- Test placeholder substitution with arbitrary selected text. + +## 2. Add link and page context actions + +**Priority:** Medium. **Type:** Enhancement. + +Selection context already exists. Add explicit actions for a clicked link and +the current page, initially passing the URL rather than extracting full text. +Sending a URL does not guarantee ChatGPT can access or summarize its contents. + +Acceptance criteria: +- Clearly identify whether the action shares selected text, a link, or a page URL. +- Do not include page metadata automatically in existing selection actions. +- Handle restricted pages and unsupported URL schemes gracefully. +- Review URLs containing private query data and update privacy documentation + for the new data flow before release. +- Assess permissions against the existing design spec; full-page extraction + is a separate proposal requiring explicit scope and permission review. + +## 3. Add a toolbar quick-launch popup + +**Priority:** Medium. **Type:** Enhancement. + +Provide a prompt field, access to named actions, and a link to settings from +the toolbar. Reuse the existing URL builder and open-location preferences. + +Acceptance criteria: +- Keyboard-accessible prompt entry and launch action. +- Empty input opens a blank chat. +- Named actions appear when available; the popup can ship independently. +- Works in light/dark themes and a narrow popup. +- No automatic page capture; any selection action is explicit. + +## 4. Investigate advanced address-bar commands + +**Priority:** Medium. **Type:** Research, followed by enhancement if viable. + +Investigate an omnibox entry point that reads saved settings and recognizes +commands such as `!search` and `!temp`. The current search provider uses a +static URL and does not read extension preferences. + +Acceptance criteria: +- Verify interaction with the installed engine and its user-customized keyword. +- Document any difference between Firefox's search keyword and the extension's + manifest-declared omnibox keyword; do not promise runtime keyword editing. +- Specify escaping so users can search for literal command text. +- Preserve ordinary address-bar searches and open-location behavior. +- Keep search/temporary options labeled as hints where not guaranteed. +- Defer `!think` until model/effort research establishes a working mechanism; + adding “think harder” to a prompt is not an effort-setting implementation. +- Do not reintroduce auto-submit; review Appendix B of the existing design spec. + +Reference: [Mozilla omnibox API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox). + +## 5. Add settings export and import + +**Priority:** Medium. **Type:** Enhancement. + +Settings already use `browser.storage.sync`. Add portable JSON backup and +restore, including named actions if that feature ships. + +Acceptance criteria: +- Export a versioned settings document without account or browsing data. +- Validate file size, schema, field types, and supported versions before writing. +- Preview replacement and require an explicit import action. +- Reject invalid input without changing current settings. +- Handle old versions, unknown fields, and storage failures predictably. +- Do not imply that Firefox-owned engine keywords or shortcuts are backed up. + +## 6. Evaluate Firefox Manifest V3 migration + +**Priority:** Low until a concrete benefit or requirement is identified. +**Type:** Research/maintenance. + +Evaluate MV3 separately from user features. The existing options framework, +normal launch, temporary-chat hint, and web-search hint do not need to be +reimplemented as a bundled milestone. MV3 does not establish model control. + +Acceptance criteria: +- Document Firefox-specific benefits, costs, and minimum supported version. +- Audit background lifecycle, script injection, permissions, search provider, + options, and packaging against Mozilla's migration guide. +- If proceeding, preserve settings and verify all existing Firefox workflows. +- No Chromium packaging or sibling-repository changes. + +Reference: [Firefox MV3 migration guide](https://extensionworkshop.com/documentation/develop/manifest-v3-migration-guide/). + +## 7. Investigate optional Firefox Containers integration + +**Priority:** Low. **Type:** Research. + +Explore opening ChatGPT in a user-selected existing container for account +separation. Keep integration opt-in and justify any additional permissions. + +Acceptance criteria: +- Verify the permission requirements for listing containers and opening tabs + in them; the contextualIdentities API documents contextualIdentities and + cookies permissions. +- Review the existing permission design before implementation. +- Handle missing/deleted containers, denied permissions, disabled containers, + and private browsing without silently switching accounts. +- Determine which entry points can honor the preference; do not promise that + the static Firefox search provider can use it. +- Explain that containers separate sessions; they do not make chats temporary. + +Reference: [Mozilla contextualIdentities API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities). + +## 8. Research reliable ChatGPT model and thinking-effort selection + +**Priority:** High interest; blocked on evidence of a reliable mechanism. +**Type:** Research, not a promised feature. + +User testing did not achieve the requested behavior using the temporary URL +probe with `prompt`, `model=gpt-5-6-thinking`, `hints=search`, and +`thinking_effort=extended`. This does not independently establish which fields +were ignored. Earlier model URL tests also failed to switch the selected model. + +Acceptance criteria: +- Record a reproducible browser test and observable evidence of the actual + selected model/effort; a URL parameter or model self-report is insufficient. +- Verify identifiers and distinguish URL prefill from conversation POST data. +- Prefer a documented integration; document maintenance and permission costs + before considering page automation or internal endpoints. +- Do not introduce controls claiming success without verified behavior. +- Treat a public-API implementation as a separate product decision, including + authentication, billing, privacy, and its different user experience. +- Remove the temporary URL test UI before release once investigation is done. + +Shared release constraint: preserve user submission in ChatGPT and the current +minimal-permission approach unless a separately reviewed change justifies more. diff --git a/docs/superpowers/plans/2026-07-30-extension-options-panel.md b/docs/superpowers/plans/2026-07-30-extension-options-panel.md new file mode 100644 index 0000000..e871ef9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-extension-options-panel.md @@ -0,0 +1,1396 @@ +# Extension Options Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an options panel to this Firefox extension, reachable from `about:addons`, configuring two new entry points — a context-menu item on selected text and an `Alt+Shift+G` shortcut that acts on the selection. + +**Architecture:** The extension is currently manifest-only with no JavaScript. This adds an MV2 ES-module background script (`background.type: "module"`, supported from Firefox 112) that registers a context menu and a keyboard command, three pure-logic modules under `lib/`, and an options page rendered inline in `about:addons`. All URL-building logic is pure and unit-tested with `node:test`; the browser-facing code is a thin wiring layer around it. + +**Tech Stack:** Firefox WebExtension Manifest V2, vanilla ES modules (no bundler, no framework), `node:test` for unit tests, `web-ext` 10.5.0 for lint and packaging. + +**Spec:** `docs/superpowers/specs/2026-07-29-extension-options-design.md` (committed at `3d5ce6a`). Read it before starting. **Appendix B is a rejection record, not a backlog** — do not implement auto-submit. + +**Branch:** `feat/extension-options-panel`, already cut from `develop` @ `5edb288`. + +## Global Constraints + +Every task's requirements implicitly include this section. Values are copied verbatim from the spec. + +- **Firefox Desktop only.** Do not add Chrome/Chromium packaging or touch any sibling repository. +- **Manifest stays V2.** Do not migrate to MV3. Keep the code MV3-portable: ES-module background, `persistent: false`, never `browser_style: true`. +- **Permissions are exactly `["storage", "menus", "activeTab"]`.** Do not add host permissions, `optional_permissions`, `tabs`, `webNavigation`, `notifications`, or `contentScripts`. All three chosen permissions add no install-prompt line; `tabs` and `notifications` do. +- **Never read `tab.url`, `tab.title`, or `tab.favIconUrl`.** Reading them requires the `tabs` permission. Only `tab.id` may be read. +- **`strict_min_version` is omitted.** Corrected during execution: the plan + originally pinned `"112.0"` (the ES-module background floor), but the existing + `data_collection_permissions` key requires Firefox 140 / Android 142, so + `addons-linter` warns on any lower value and `warningsAsErrors: true` fails the + build. Measured: `112.0` → 2 warnings, `140.0` → 1, `142.0` → clean, omitted → + clean. `142.0` was rejected because it locks out ESR 140. Below Firefox 112 the + `gpt` keyword still works and only the two new entry points are inert. +- **No new runtime or dev dependencies.** `node:test` is built in. +- **`lint.warningsAsErrors: true`** — a single addons-linter warning fails the build. `npx web-ext lint` must stay at 0 errors / 0 warnings / 0 notices. +- **Two-space indentation** in JSON, YAML, and JavaScript. Lowercase, hyphenated filenames. +- **Base URL is the root path `https://chatgpt.com/`.** Never `chatgpt.com/search`, which returns 404. +- **Send the `q` parameter only.** Never `prompt`, never both. +- **`MAX_URL_CHARS = 8000`.** +- **`hints` is hard-coded to the value `search`.** No mode dropdown. +- **Copy rule:** no user-facing text may say the query is *searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled in**. ChatGPT stopped auto-submitting URL-passed prompts; this is verified, not speculative. +- **Copy rule:** the words *private*, *incognito*, and *secure* must never appear near the temporary-chat setting. +- **No user-facing error notifications.** Every failure path degrades silently to something useful. +- **Commit format:** `: `, lowercase type (`feat:`, `test:`, `doc:`, `build:`). + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `lib/defaults.js` | **Create.** `DEFAULT_SETTINGS` and `OPEN_IN_VALUES` — the single source of truth for shape and defaults. No imports. | +| `lib/settings.js` | **Create.** Pure `mergeSettings()` (validation + defaulting) plus `load()`/`save()` wrapping `browser.storage.sync`. Only the pure part is unit-tested. | +| `lib/build-url.js` | **Create.** Pure `buildChatGptUrl(settings, rawQuery)`. Zero browser APIs. The tested core. | +| `background/background.js` | **Create.** Registers the context menu and command; reads the selection; opens the tab/window. Thin wiring only — no URL logic. | +| `options/options.html` | **Create.** The five controls plus Restore defaults. | +| `options/options.css` | **Create.** Hand-styled, responsive to ~400 px, light and dark via `prefers-color-scheme`. | +| `options/options.js` | **Create.** Renders settings, debounced auto-save, live re-sync on `storage.onChanged`. | +| `test/build-url.test.js` | **Create.** Unit tests for the URL builder. | +| `test/settings.test.js` | **Create.** Unit tests for `mergeSettings`. | +| `manifest.json` | **Modify.** Add `permissions`, `background`, `options_ui`, `commands`; bump version. | +| `package.json` | **Modify.** Add `"type": "module"`, a `test:unit` script; bump version. | +| `README.md` | **Modify.** Options section, the double-Enter limitation, revised privacy wording. | +| `AGENTS.md` | **Modify.** Project structure, commands, manual test checklist. | +| `CHANGELOG.md` | **Modify.** 1.1.0 entry. | + +`web-ext-config.mjs` already excludes `docs/` and `test/` (done alongside the spec commit) — no change needed. + +--- + +### Task 1: Tooling foundation, defaults, and settings validation + +Establishes the test runner and the settings contract every later task depends on. `"type": "module"` is **required**: without it `node --test` relies on Node's module-syntax detection, which emits a permanent `MODULE_TYPELESS_PACKAGE_JSON` warning and **fails outright on Node 22.0–22.6**, and CI pins the bare major `"22"`. + +**Files:** +- Modify: `package.json` +- Create: `lib/defaults.js` +- Create: `lib/settings.js` +- Test: `test/settings.test.js` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `DEFAULT_SETTINGS: { model: string, temporaryChat: boolean, webSearch: boolean, openIn: string, promptTemplate: string }` from `lib/defaults.js` + - `OPEN_IN_VALUES: string[]` from `lib/defaults.js` + - `mergeSettings(stored: unknown) => Settings` from `lib/settings.js` + - `load() => Promise` from `lib/settings.js` + - `save(partial: Partial) => Promise` from `lib/settings.js` + +- [ ] **Step 1: Add `"type": "module"` and the unit-test script to `package.json`** + +Replace the `"private"`, `"version"`, and `"scripts"` region so the file reads: + +```json +{ + "name": "search-with-chatgpt-powered-by-openai-extension", + "version": "1.1.0", + "private": true, + "type": "module", + "description": "Development tooling for the Search with ChatGPT Firefox extension.", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "web-ext run", + "lint": "web-ext lint", + "test:unit": "node --test", + "build": "web-ext build", + "test": "npm run lint && npm run test:unit && npm run build" + }, + "devDependencies": { + "web-ext": "10.5.0" + } +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `test/settings.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { mergeSettings } from "../lib/settings.js"; + +test("empty object yields the defaults", () => { + assert.deepEqual(mergeSettings({}), DEFAULT_SETTINGS); +}); + +test("null and undefined yield the defaults", () => { + assert.deepEqual(mergeSettings(null), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(undefined), DEFAULT_SETTINGS); +}); + +test("a non-object yields the defaults", () => { + assert.deepEqual(mergeSettings("nonsense"), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(42), DEFAULT_SETTINGS); +}); + +test("valid stored values are preserved", () => { + assert.deepEqual( + mergeSettings({ + model: "auto", + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }), + { + model: "auto", + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }, + ); +}); + +test("missing keys fall back individually", () => { + const merged = mergeSettings({ model: "auto" }); + assert.equal(merged.model, "auto"); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("unknown keys are dropped", () => { + const merged = mergeSettings({ model: "auto", nope: "gone", openInn: "typo" }); + assert.deepEqual(Object.keys(merged).sort(), Object.keys(DEFAULT_SETTINGS).sort()); + assert.equal("nope" in merged, false); +}); + +test("wrong types fall back to the default", () => { + const merged = mergeSettings({ + model: 123, + temporaryChat: "yes", + webSearch: 1, + promptTemplate: [], + }); + assert.equal(merged.model, DEFAULT_SETTINGS.model); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.webSearch, DEFAULT_SETTINGS.webSearch); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("an out-of-range openIn falls back to the default", () => { + assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); + assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn); +}); + +test("each documented openIn value is accepted", () => { + for (const value of ["new-tab", "background-tab", "new-window"]) { + assert.equal(mergeSettings({ openIn: value }).openIn, value); + } +}); + +test("mergeSettings does not mutate DEFAULT_SETTINGS", () => { + mergeSettings({ model: "mutated" }); + assert.equal(DEFAULT_SETTINGS.model, ""); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `npm run test:unit` + +Expected: FAIL — `Cannot find module .../lib/defaults.js`. Confirm the summary reports a non-zero `fail` count. + +- [ ] **Step 4: Create `lib/defaults.js`** + +```js +// Single source of truth for the settings shape and their defaults. +// Defaults reproduce the extension's pre-1.1 behaviour exactly: an empty model +// and both toggles off mean the only URL parameter sent is `q`. + +export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; + +export const DEFAULT_SETTINGS = { + model: "", + temporaryChat: false, + webSearch: false, + openIn: "new-tab", + promptTemplate: "{query}", +}; +``` + +- [ ] **Step 5: Create `lib/settings.js`** + +```js +import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; + +// Pure. Layers stored values over the defaults, dropping unknown keys and +// falling back on wrong types or out-of-range values. A fresh or signed-out +// profile returns {} from storage, which yields the defaults. +export function mergeSettings(stored) { + const merged = { ...DEFAULT_SETTINGS }; + if (stored === null || typeof stored !== "object") { + return merged; + } + if (typeof stored.model === "string") { + merged.model = stored.model; + } + if (typeof stored.temporaryChat === "boolean") { + merged.temporaryChat = stored.temporaryChat; + } + if (typeof stored.webSearch === "boolean") { + merged.webSearch = stored.webSearch; + } + if (OPEN_IN_VALUES.includes(stored.openIn)) { + merged.openIn = stored.openIn; + } + if (typeof stored.promptTemplate === "string") { + merged.promptTemplate = stored.promptTemplate; + } + return merged; +} + +// storage.sync works with no Mozilla account (it behaves as local storage and +// syncs later if the user signs in), so no capability probe is needed. Any +// rejection falls back to defaults rather than blocking the user's action. +// Firefox sanitises all non-quota backend errors to a single generic string, so +// there is nothing useful to branch on. +export async function load() { + try { + const stored = await browser.storage.sync.get(Object.keys(DEFAULT_SETTINGS)); + return mergeSettings(stored); + } catch { + return mergeSettings(null); + } +} + +export async function save(partial) { + await browser.storage.sync.set(partial); +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `npm run test:unit` + +Expected: PASS, all 10 tests. Confirm the output shows `# fail 0` **and a non-zero `# tests` count** — `node --test` exits 0 when it discovers zero test files, so a zero count means the runner is misconfigured, not that everything passed. + +- [ ] **Step 7: Confirm the full pipeline is green and no stderr warning appeared** + +Run: `npm test 2>&1 | grep -i 'MODULE_TYPELESS\|warning' ; npm test` + +Expected: no `MODULE_TYPELESS_PACKAGE_JSON` line; lint reports `0 errors / 0 warnings / 0 notices`; unit tests pass; build writes the ZIP; exit code 0. + +- [ ] **Step 8: Verify `test/` is excluded from the package** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.0.zip | grep -c 'test\|docs' || echo "0 matches - correct"` + +Expected: `0 matches - correct`. (The ZIP is still named `1.0` until Task 3 bumps the manifest version.) `lib/` legitimately appears once the background script imports it in Task 3. + +- [ ] **Step 9: Commit** + +```bash +git add package.json lib/defaults.js lib/settings.js test/settings.test.js +git commit -m "feat: add settings defaults and validation with unit tests" +``` + +--- + +### Task 2: The pure URL builder + +The core logic, isolated so it is fully testable without a browser. Every ChatGPT parameter here is undocumented and unreliable — the builder's job is to emit them cleanly and never depend on them being honoured. + +**Files:** +- Create: `lib/build-url.js` +- Test: `test/build-url.test.js` + +**Interfaces:** +- Consumes: `DEFAULT_SETTINGS` from `lib/defaults.js` (tests only). +- Produces: + - `buildChatGptUrl(settings: Settings, rawQuery: unknown) => string` from `lib/build-url.js` + - `MAX_URL_CHARS: number` (8000) from `lib/build-url.js` + +- [ ] **Step 1: Write the failing test** + +Create `test/build-url.test.js`: + +```js +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js"; + +const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); +const queryOf = (url) => new URL(url).searchParams.get("q"); +const paramsOf = (url) => [...new URL(url).searchParams.keys()]; + +test("defaults produce only the q parameter on the root path", () => { + assert.equal( + buildChatGptUrl(DEFAULT_SETTINGS, "hello"), + "https://chatgpt.com/?q=hello", + ); +}); + +test("an empty or whitespace-only query omits q entirely", () => { + for (const raw of ["", " ", "\n\t", null, undefined]) { + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`); + assert.equal(url, "https://chatgpt.com/"); + } +}); + +test("model is sent only when non-empty after trimming", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ model: "auto" }), "hi")), "hi"); + assert.equal(new URL(buildChatGptUrl(withSettings({ model: "auto" }), "hi")).searchParams.get("model"), "auto"); + assert.equal(new URL(buildChatGptUrl(withSettings({ model: " auto " }), "hi")).searchParams.get("model"), "auto"); + for (const model of ["", " "]) { + assert.equal(paramsOf(buildChatGptUrl(withSettings({ model }), "hi")).includes("model"), false); + } +}); + +test("temporaryChat and webSearch each appear only when enabled", () => { + assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false); + assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false); + + const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi")); + assert.equal(temp.searchParams.get("temporary-chat"), "true"); + + const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi")); + assert.equal(search.searchParams.get("hints"), "search"); +}); + +test("all four URL-affecting settings combine, q first", () => { + const url = buildChatGptUrl( + withSettings({ + model: "auto", + temporaryChat: true, + webSearch: true, + promptTemplate: "Explain: {query}", + }), + "entropy", + ); + assert.deepEqual(paramsOf(url), ["q", "model", "temporary-chat", "hints"]); + assert.equal(queryOf(url), "Explain: entropy"); +}); + +test("openIn never influences the URL", () => { + const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi"); + for (const openIn of ["new-tab", "background-tab", "new-window"]) { + assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base); + } +}); + +test("special characters survive encoding round-trip", () => { + const raw = 'a & b # c ? d = e + f "g" 100% 😀 日本語\nnewline'; + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); + assert.equal(url.includes(" "), false); + assert.equal(url.includes("#"), false); +}); + +test("the template replaces every {query} occurrence", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x"); + assert.equal(queryOf(url), "x / x"); +}); + +test("a template without {query} is treated as a prefix, never dropping the query", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why"); + assert.equal(queryOf(url), "Be concise: why"); +}); + +test("an empty or whitespace-only template yields the bare query", () => { + for (const promptTemplate of ["", " "]) { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why"); + } +}); + +test("a non-string template degrades to the bare query", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why"); +}); + +test("an over-long prompt is truncated to fit the URL budget", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.ok(queryOf(url).length > 0); + assert.ok(queryOf(url).length < 20000); +}); + +test("truncation accounts for the other parameters", () => { + const settings = withSettings({ model: "some-long-model-slug", temporaryChat: true, webSearch: true }); + const url = buildChatGptUrl(settings, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.equal(new URL(url).searchParams.get("model"), "some-long-model-slug"); + assert.equal(new URL(url).searchParams.get("hints"), "search"); +}); + +test("truncation never splits a surrogate pair", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀".repeat(9000)); + assert.ok(url.length <= MAX_URL_CHARS); + const lonelySurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀 ".repeat(9000)); + assert.equal(/%[0-9A-Fa-f]?$/.test(url), false); + assert.doesNotThrow(() => decodeURIComponent(new URL(url).search.slice(3))); +}); + +test("truncation prefers a whitespace boundary, so no word is cut in half", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "lorem ipsum ".repeat(2000)); + const tokens = queryOf(url).trim().split(/\s+/); + assert.ok(tokens.length > 1); + for (const token of tokens) { + assert.ok(token === "lorem" || token === "ipsum", `truncated mid-word: ${token}`); + } +}); + +test("a query needing no truncation is left byte-identical", () => { + const raw = "a modest question about thermodynamics"; + assert.equal(queryOf(buildChatGptUrl(DEFAULT_SETTINGS, raw)), raw); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --test test/build-url.test.js` + +Expected: FAIL — `Cannot find module .../lib/build-url.js`. + +- [ ] **Step 3: Create `lib/build-url.js`** + +```js +// Pure URL construction. No browser APIs, so node:test imports this directly. +// +// Every ChatGPT parameter below is undocumented by OpenAI and has broken before. +// This module's contract is only that it emits a well-formed URL; whether +// ChatGPT honours model / temporary-chat / hints is out of our control, which is +// why each is independently omittable and every label is hint-strength. +// +// The root path is mandatory: chatgpt.com/search began returning 404 in +// mid-2025, and the root form is what OpenAI's own extension uses. + +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; + +function applyTemplate(template, query) { + if (query === "") { + return ""; + } + const tpl = typeof template === "string" ? template : ""; + if (tpl.includes("{query}")) { + // Corrected during execution: the replacer MUST be a function. In the + // string form, $&, $`, $' and $$ are substitution patterns in the + // replacement — and the replacement is arbitrary page-selected text. + // Measured on the default {query} template: "awk print $& here" produced + // "awk print {query} here". + return tpl.replaceAll("{query}", () => query); + } + const trimmed = tpl.trim(); + return trimmed === "" ? query : `${trimmed} ${query}`; +} + +function addOptionalParams(url, settings) { + const model = typeof settings.model === "string" ? settings.model.trim() : ""; + if (model !== "") { + url.searchParams.set("model", model); + } + if (settings.temporaryChat) { + url.searchParams.set("temporary-chat", "true"); + } + if (settings.webSearch) { + url.searchParams.set("hints", "search"); + } + 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; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node --test test/build-url.test.js` + +Expected: PASS, all 17 tests, `# fail 0`. + +- [ ] **Step 5: Run the full suite** + +Run: `npm test` + +Expected: lint `0 errors / 0 warnings / 0 notices`; 27 unit tests passing across both files; build succeeds; exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add lib/build-url.js test/build-url.test.js +git commit -m "feat: add pure ChatGPT URL builder with unit tests" +``` + +--- + +### Task 3: Manifest wiring and the background script + +Turns the tested logic into a working extension. Manifest and background must land together — the manifest without the script registers nothing, and the script without the manifest is never loaded. + +**Files:** +- Modify: `manifest.json` +- Create: `background/background.js` + +**Interfaces:** +- Consumes: `load()` from `lib/settings.js`; `buildChatGptUrl()` from `lib/build-url.js`. +- Produces: the `ask-chatgpt` command name (referenced by `manifest.json` and by the `commands.onCommand` handler) and the `ask-chatgpt-selection` menu id. + +- [ ] **Step 1: Rewrite `manifest.json`** + +Replace the whole file. `browser_style: false` is set **explicitly** — omitting it defaults to `true` in MV2, and `true` is unsupported in MV3, so being explicit keeps the panel looking identical if the extension ever migrates. `is_default` stays `false`: setting it `true` triggers a second, separate consent doorhanger. + +```json +{ + "name": "Search with ChatGPT powered by OpenAI!", + "description": "Adds a ChatGPT search engine", + "manifest_version": 2, + "author": "Mathew Guest ", + "homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT", + "version": "1.1", + "icons": { + "16": "icons/powered-by-openai-icon-16x16.png", + "32": "icons/powered-by-openai-icon-32x32.png", + "48": "icons/powered-by-openai-icon-48x48.png", + "64": "icons/powered-by-openai-icon-64x64.png", + "128": "icons/powered-by-openai-icon-128x128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "{24644f0c-bfcb-4ebd-a16b-bc8a7d154288}", + "data_collection_permissions": { + "required": [ + "none" + ] + } + } + }, + "permissions": [ + "storage", + "menus", + "activeTab" + ], + "background": { + "scripts": [ + "background/background.js" + ], + "type": "module", + "persistent": false + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": false, + "browser_style": false + }, + "commands": { + "ask-chatgpt": { + "suggested_key": { + "default": "Alt+Shift+G" + }, + "description": "Ask ChatGPT about the selected text" + } + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "ChatGPT", + "search_url": "https://chatgpt.com?q={searchTerms}", + "keyword": "gpt", + "favicon_url": "https://zavage-software.com/static/ChatGPT-favicon.png", + "is_default": false, + "encoding": "UTF-8" + } + } +} +``` + +**Do not change `search_provider`.** Leaving Firefox as the transmitter of `gpt ` is a deliberate decision to minimise AMO data-collection exposure (spec Risk 6). + +- [ ] **Step 2: Create `background/background.js`** + +```js +import { load } from "../lib/settings.js"; +import { buildChatGptUrl } from "../lib/build-url.js"; + +const MENU_ID = "ask-chatgpt-selection"; +const COMMAND_ID = "ask-chatgpt"; + +// Reads the user's selection for the keyboard-shortcut path. The context menu +// does not need this — it receives info.selectionText directly. +// +// A commands invocation confers activeTab (ext-commands.js grants it before +// firing onCommand), so executeScript works with no host permission. +// +// Failure has three measured shapes, all handled here: a thrown +// "Missing host permission for the tab", a thrown variant naming frames, and a +// SILENT resolution to [null] on parent-process about: pages. Never index into +// a result without a null check. +async function readSelection() { + let tabs; + try { + tabs = await browser.tabs.query({ active: true, currentWindow: true }); + } catch { + return ""; + } + + // Only tab.id may be read. Reading tab.url or tab.title would require the + // "tabs" permission, which does add an install-prompt warning line. + const tabId = tabs?.[0]?.id; + if (typeof tabId !== "number") { + return ""; + } + + let results; + try { + results = await browser.tabs.executeScript(tabId, { + // Corrected during execution: gated on document.hasFocus(). Each frame + // owns an independent Selection that is not cleared when the user + // selects elsewhere, so an unfocused (possibly cross-origin) frame's + // stale selection could otherwise win on unspecified result ordering. + code: "document.hasFocus() ? window.getSelection().toString() : ''", + allFrames: true, + matchAboutBlank: true, + }); + } catch { + return ""; + } + + if (!Array.isArray(results)) { + return ""; + } + for (const result of results) { + if (typeof result === "string" && result.trim() !== "") { + return result; + } + } + return ""; +} + +// Corrected during execution: the whole body is wrapped, because both call +// sites invoke this without awaiting. Without the outer catch a rejection from +// the final tabs.create surfaces as an unhandled promise rejection, which the +// "degrade silently" constraint above forbids. The inner catch stays nested so +// a windows.create failure still falls through to a tab. +async function openChatGpt(query) { + try { + const settings = await load(); + const url = buildChatGptUrl(settings, query); + + if (settings.openIn === "new-window") { + try { + await browser.windows.create({ url }); + return; + } catch { + // Fall through to a tab rather than doing nothing. + } + } + + await browser.tabs.create({ url, active: settings.openIn !== "background-tab" }); + } catch { + // Nothing useful to say and nowhere useful to say it: the user expects a + // tab or nothing at all. + } +} + +// removeAll() first, so a background event page waking up and re-running this +// module cannot throw on a duplicate menu id. +async function registerMenu() { + await browser.menus.removeAll(); + browser.menus.create({ + id: MENU_ID, + title: 'Ask ChatGPT about "%s"', + contexts: ["selection"], + }); +} + +browser.menus.onClicked.addListener((info) => { + if (info.menuItemId !== MENU_ID) { + return; + } + openChatGpt(info.selectionText ?? ""); +}); + +// browser.commands does not exist on Firefox for Android. This extension is +// desktop-only, but guard rather than throwing at startup. +if (browser.commands) { + browser.commands.onCommand.addListener(async (command) => { + if (command !== COMMAND_ID) { + return; + } + openChatGpt(await readSelection()); + }); +} + +// Corrected during execution: menus.removeAll() is promise-based in Firefox, so +// an unguarded call here can log an unhandled rejection. (menus.create() is +// callback-based and cannot reject.) +registerMenu().catch(() => { + // A failed re-registration cannot be retried usefully from here. +}); +``` + +- [ ] **Step 3: Verify lint and packaging still pass** + +Run: `npm test` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; the ZIP is now named `search-with-chatgpt-firefox-1.1.zip`. + +- [ ] **Step 4: Verify the package contents** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip` + +Expected: `manifest.json`, `LICENSE.txt`, `icons/` + 5 PNGs, `background/background.js`, `lib/` + 3 modules. **No `test/`, no `docs/`, no `options/` yet** (Task 4 adds it), and no empty `test/`/`docs/` directory entries. + +- [ ] **Step 5: Manual smoke test in Firefox** + +Run: `npm run dev` + +In the temporary profile: +1. Select text on any ordinary web page, right-click → confirm an **"Ask ChatGPT about …"** item appears, showing the selected text. +2. Click it → ChatGPT opens in a new tab with the selection in the composer. +3. Right-click with **nothing** selected → confirm the item is **absent**. +4. Press `Alt+Shift+G` with text selected → same behaviour as the menu. +5. Press `Alt+Shift+G` with nothing selected → a blank ChatGPT opens (no `?q=`). +6. Navigate to `about:config`, press `Alt+Shift+G` → a blank ChatGPT opens. It must **not** fail silently with nothing happening, and must not log an unhandled rejection. +7. Select text inside an iframe (any page with an embedded frame) and press `Alt+Shift+G` → the iframe selection is used. +8. Confirm `gpt ` in the address bar still works and the provider is still non-default in `about:preferences#search`. + +**The shortcut must be verified by actually pressing the key.** `commands.getAll()` reports a `suggested_key` as registered even when Firefox has silently overridden it — `Ctrl+Shift+Y` was measured showing as registered and never firing. A green `getAll()` proves nothing. + +If `Alt+Shift+G` does not fire, check `about:addons` → gear → Manage Extension Shortcuts for a conflict before changing code. + +- [ ] **Step 6: Commit** + +```bash +git add manifest.json background/background.js +git commit -m "feat: add context menu and keyboard shortcut entry points" +``` + +--- + +### Task 4: The options panel + +The panel renders **inline inside `about:addons`**, which is a narrow, constrained surface that inherits that page's layout and follows the system theme. Because `browser_style` is off, it is hand-styled and must handle dark mode itself. + +**Files:** +- Create: `options/options.html` +- Create: `options/options.css` +- Create: `options/options.js` + +**Interfaces:** +- Consumes: `DEFAULT_SETTINGS` from `lib/defaults.js`; `load()` and `save()` from `lib/settings.js`. +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Create `options/options.html`** + +The element `id`s must match the keys in `DEFAULT_SETTINGS` exactly — `options.js` relies on that. + +```html + + + + +Search with ChatGPT — Options + + +
+
+ + +

+ ChatGPT may ignore this and use your account default. Leave blank to + always use your account default. +

+
+ +
+ +

+ ChatGPT may not honour this. Check ChatGPT's own indicator before sending + anything sensitive. +

+
+ +
+ +

A hint to ChatGPT's interface, not a guarantee.

+
+ +
+ + +
+ +
+ + +

{query} is replaced with your selected text.

+
+ +
+ + +
+
+ +

+ Selecting text and choosing Ask ChatGPT — or pressing + Alt+Shift+G — opens ChatGPT with your text + filled in. ChatGPT no longer submits prompts that arrive from a link, so press + Enter there to send. Change the shortcut under + Manage Extension Shortcuts in Add-ons. +

+ + +``` + +- [ ] **Step 2: Create `options/options.css`** + +```css +:root { + --fg: #15141a; + --fg-dim: #5b5b66; + --bg: transparent; + --border: #8f8f9d; + --field-bg: #ffffff; + --accent: #0060df; +} + +@media (prefers-color-scheme: dark) { + :root { + --fg: #fbfbfe; + --fg-dim: #b1b1bd; + --border: #8f8f9d; + --field-bg: #2b2a33; + --accent: #0df; + } +} + +body { + margin: 0; + padding: 0.5rem 0; + color: var(--fg); + background: var(--bg); + font: message-box; + font-size: 1rem; + line-height: 1.4; + max-width: 42rem; +} + +.field { + margin-bottom: 1.15rem; +} + +label { + display: block; + font-weight: 600; + margin-bottom: 0.3rem; +} + +.checkbox label { + font-weight: 400; + display: flex; + align-items: baseline; + gap: 0.45rem; +} + +input[type="text"], +select { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 0.4rem 0.5rem; + color: var(--fg); + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; +} + +input[type="text"]:focus-visible, +select:focus-visible, +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.hint { + margin: 0.3rem 0 0; + color: var(--fg-dim); + font-size: 0.85rem; +} + +.tag { + font-weight: 400; + font-size: 0.75rem; + color: var(--fg-dim); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0 0.3rem; + vertical-align: 1px; +} + +.actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 1.5rem; +} + +button { + padding: 0.35rem 0.8rem; + color: var(--fg); + background: var(--field-bg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; + cursor: pointer; +} + +#status { + color: var(--fg-dim); + font-size: 0.85rem; +} + +.note { + margin-top: 1.75rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + color: var(--fg-dim); + font-size: 0.85rem; +} + +code, +kbd { + font-family: monospace; + font-size: 0.9em; +} + +/* The inline about:addons surface can be very narrow. */ +@media (max-width: 26rem) { + .actions { + align-items: flex-start; + flex-direction: column; + } +} +``` + +- [ ] **Step 3: Create `options/options.js`** + +```js +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { load, save } from "../lib/settings.js"; + +const SAVE_DEBOUNCE_MS = 400; +const STATUS_CLEAR_MS = 1500; + +const field = (id) => document.getElementById(id); + +let saveTimer = null; +let statusTimer = null; +// Suppresses the change/input handlers while we are programmatically writing +// values into the form, so a remote sync cannot trigger a save loop. +let applying = false; + +// Corrected during execution: the activeElement guard is required because +// storage.onChanged fires for this page's OWN writes, so every debounced +// autosave re-entered the listener below and could overwrite whatever the user +// had typed since the save started. +function render(settings) { + applying = true; + for (const [id, value] of Object.entries(settings)) { + const element = field(id); + // Never overwrite the control the user is actively editing — this page's + // own saves echo back through storage.onChanged (see below). + if (element === document.activeElement) { + continue; + } + if (typeof value === "boolean") { + element.checked = value; + } else { + element.value = value; + } + } + applying = false; +} + +function collect() { + return { + model: field("model").value, + temporaryChat: field("temporaryChat").checked, + webSearch: field("webSearch").checked, + openIn: field("openIn").value, + promptTemplate: field("promptTemplate").value, + }; +} + +function showStatus(message) { + field("status").textContent = message; + clearTimeout(statusTimer); + statusTimer = setTimeout(() => { + field("status").textContent = ""; + }, STATUS_CLEAR_MS); +} + +async function persist(settings) { + try { + await save(settings); + showStatus("Saved"); + } catch { + // storage.sync sanitises its errors, so there is nothing specific to report. + showStatus("Could not save settings"); + } +} + +// Debounced so typing in a text field cannot approach any write-rate quota. +function scheduleSave() { + if (applying) { + return; + } + clearTimeout(saveTimer); + saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS); +} + +// Module scripts are deferred, so the DOM is already parsed here. +render(await load()); + +for (const id of Object.keys(DEFAULT_SETTINGS)) { + field(id).addEventListener("input", scheduleSave); + field(id).addEventListener("change", scheduleSave); +} + +field("restore").addEventListener("click", () => { + clearTimeout(saveTimer); + // Restoring defaults is an explicit user action, so it should reset every + // field even if one currently has focus. Blur first so render()'s + // activeElement guard (see below) has nothing to skip. + document.activeElement?.blur(); + render(DEFAULT_SETTINGS); + persist({ ...DEFAULT_SETTINGS }); +}); + +// browser.storage.onChanged fires for every write to the "sync" area, not just +// a remote sync landing on another device — this page's own debounced +// autosaves and its Restore-defaults save re-enter this listener too, which +// render()'s activeElement guard makes safe to react to unconditionally. +browser.storage.onChanged.addListener(async (changes, area) => { + if (area !== "sync") { + return; + } + render(await load()); +}); +``` + +- [ ] **Step 4: Verify lint and packaging** + +Run: `npm test` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; build succeeds. A top-level `await` in `options.js` is valid in an ES module; if addons-linter objects, wrap the body in an `async function init() { … } init();` instead. + +- [ ] **Step 5: Confirm `options/` is packaged** + +Run: `unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip | grep options` + +Expected: `options/options.html`, `options/options.css`, `options/options.js`. + +- [ ] **Step 6: Manual test the panel** + +Run: `npm run dev` + +1. Open `about:addons` → Extensions → this extension → the **Options** tab (Windows) / **Preferences** tab (macOS, Linux). Firefox's label is platform-dependent; do not expect "Preferences" everywhere. +2. Confirm all five controls plus **Restore defaults** render and are readable. +3. Change each setting; confirm "Saved" appears briefly. +4. Restart Firefox (or reload the extension) and confirm every value persisted. +5. Click **Restore defaults**; confirm the fields reset and persist. +6. Narrow the Firefox window as far as it goes; confirm nothing overflows horizontally and the actions row wraps. +7. Toggle the OS/Firefox theme to dark (`about:preferences` → Language and Appearance, or set `ui.systemUsesDarkTheme`); confirm text stays legible and inputs remain visible in **both** themes. +8. Set **Prompt template** to `Explain simply: {query}`, then use the context menu; confirm the ChatGPT composer contains `Explain simply: `. +9. Set **Open in** to each of the three values and confirm each behaves correctly, including that the background-tab option does not steal focus. + +- [ ] **Step 7: Commit** + +```bash +git add options/options.html options/options.css options/options.js +git commit -m "feat: add options panel embedded in about:addons" +``` + +--- + +### Task 5: Documentation, changelog, and release metadata + +**Files:** +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. + +- [ ] **Step 1: Add an Options section and the ChatGPT limitation to `README.md`** + +Insert after the existing intro paragraphs, before `## Browser Support`: + +```markdown +## Options + +Open `about:addons`, select this extension, and choose the **Options** tab +(**Preferences** on macOS and Linux). Available settings: + +- **Model** *(experimental)* — a model slug such as `auto`. ChatGPT may ignore + it and use your account default; leave blank to always use the account + default. +- **Request a temporary chat** — asks ChatGPT not to save the conversation. + ChatGPT may not honour this; check its own indicator before relying on it. +- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a + guarantee. +- **Open in** — a new tab, a new background tab, or a new window. +- **Prompt template** — wraps your text, for example `Explain simply: {query}`. + +These settings apply to the new entry points: selecting text and choosing +**Ask ChatGPT** from the right-click menu, or pressing +Alt+Shift+G. Rebind the shortcut under +**Manage Extension Shortcuts** in the Add-ons gear menu. + +The `gpt ` address-bar search is unchanged and does not read these +settings. + +## Why you press Enter twice + +ChatGPT no longer submits prompts that are passed to it in a URL — it fills them +into the composer and waits. So `gpt ` opens ChatGPT with your query +filled in, and you press Enter there to send it. + +This is a change OpenAI made to chatgpt.com, not a fault in this extension, and +it is outside our control. It was a deliberate security measure: automatically +running a prompt that arrived in a link let malicious links execute instructions +in a signed-in ChatGPT session. The extra keypress is the checkpoint that +prevents that. No API key, server, or intermediary is involved either way. +``` + +- [ ] **Step 2: Revise the `README.md` privacy section** + +Replace the existing `## Privacy Policy` section: + +```markdown +## Privacy Policy + +This extension has no server, no API key, and no intermediary. Nothing is +reported to Zavage Software. + +- Your settings are stored in your own browser profile. If you use Firefox Sync + with **Add-ons** enabled, they sync end-to-end encrypted through your own + Mozilla account. +- When you choose **Ask ChatGPT** or press the shortcut, the text you selected + is placed in the chatgpt.com URL the extension opens for you. It goes nowhere + else. +- The extension reads a page's selected text only at the moment you invoke it, + and never reads page addresses or titles. +``` + +Do **not** write "never leaves your device" unconditionally — with Sync enabled an encrypted blob does reach Mozilla's servers. Do not restore the bare claim "This extension collects zero user data": it is true in plain English, but AMO's definition of transmission is broader, and specific wording is safer. See spec Risk 6. + +- [ ] **Step 3: Update `AGENTS.md`** + +In **Project Structure & Module Organization**, replace the opening line and add entries: + +```markdown +This is a Firefox WebExtension (Manifest V2) with npm-based development tooling. + +- `manifest.json` defines the extension metadata, the ChatGPT search provider, + the background script, the options panel, and the keyboard command. +- `lib/` holds pure logic with no browser dependencies: `defaults.js`, + `build-url.js`, and `settings.js`. This is the unit-tested core. +- `background/background.js` is an ES-module background script that registers + the context menu and the keyboard command. +- `options/` is the options panel rendered inline in `about:addons`. +- `test/` holds `node:test` unit tests. Excluded from the packaged ZIP. +- `icons/` contains size-specific PNG artwork named `powered-by-openai-icon-x.png`. +- `web-ext-config.mjs` controls linting, local execution, and ZIP packaging. +- `docs/superpowers/` holds specs and plans. Excluded from the packaged ZIP. +- `.gitea/workflows/firefox-extension.yml` validates pull requests and protected branches. +- `README.md`, `CHANGELOG.md`, and `LICENSE.txt` cover usage, releases, and licensing. +``` + +In **Build, Test, and Development Commands**, add after the `npm run lint` line: + +```markdown +- `npm run test:unit` runs the `node:test` unit tests over `test/`. +``` + +and replace the `npm test` line: + +```markdown +- `npm test` runs lint, unit tests, and build together; run it before every pull request. +``` + +Replace the first paragraph of **Testing Guidelines**: + +```markdown +Unit tests cover the pure logic in `lib/` via `node:test`; automated checks +otherwise cover manifest validation and packaging, not browser behaviour. Note +`node --test` exits 0 when it discovers no test files, so confirm a non-zero +test count rather than trusting the exit code alone. + +After `npm test`, use `npm run dev` and confirm: + +- `gpt ` opens ChatGPT, the provider stays non-default, and Firefox + search settings can configure it. +- The Options/Preferences tab renders all five settings plus Restore defaults, + in both light and dark themes and at a narrow window width, and values + persist across a restart. +- The right-click **Ask ChatGPT** item appears only when text is selected. +- `Alt+Shift+G` acts on the selection, including a selection inside an iframe, + and opens a blank chat when nothing is selected or on a restricted page such + as `about:config`. Press the key to test this — `commands.getAll()` reports a + shortcut as registered even when Firefox has silently overridden it. + +Inspect ZIP contents whenever packaging rules or assets change; `test/` and +`docs/` must never appear, not even as empty directory entries. +``` + +Add a new subsection at the end of **Security & Privacy**: + +```markdown +Permissions are deliberately limited to `storage`, `menus`, and `activeTab`, +which add no line to Firefox's install prompt. Do not add `tabs`, +`notifications`, `webNavigation`, or any host permission without re-reading +`docs/superpowers/specs/2026-07-29-extension-options-design.md` — each was +considered and rejected there, and Appendix B records a feature rejected on +security grounds that must not be revived without clearing the bar documented +with it. +``` + +- [ ] **Step 4: Add the `CHANGELOG.md` entry** + +Insert directly below the `# Changelog` intro paragraph, above the `## [1.0.0]` heading: + +```markdown +## [1.1.0] (2026-07-30) + +Adds an options panel and two new ways to reach ChatGPT. + + * Options panel in `about:addons` with five settings: model (experimental), + temporary chat, web-search hint, where results open, and a prompt template. + * Right-click selected text and choose "Ask ChatGPT" to open ChatGPT with that + text filled in. + * Keyboard shortcut `Alt+Shift+G` does the same for the current selection, and + opens an empty chat when nothing is selected. Rebindable via Manage Extension + Shortcuts. + * Documented that ChatGPT no longer submits prompts passed in a URL, so a + second Enter is needed. This is an upstream change to chatgpt.com. + * `gpt ` address-bar search is unchanged. + * Permissions added: storage, menus, activeTab. None of them adds a line to + Firefox's install prompt. +``` + +- [ ] **Step 5: Verify the whole suite one last time** + +Run: `npm test && unzip -l web-ext-artifacts/search-with-chatgpt-firefox-1.1.zip` + +Expected: `0 errors / 0 warnings / 0 notices`; 27 tests pass; the ZIP contains exactly `manifest.json`, `LICENSE.txt`, `icons/` + 5 PNGs, `background/background.js`, `lib/` + 3 modules, `options/` + 3 files — and nothing else. + +- [ ] **Step 6: Commit** + +```bash +git add README.md AGENTS.md CHANGELOG.md +git commit -m "doc: document options panel, entry points, and the ChatGPT prefill limitation" +``` + +--- + +## Before opening the pull request + +- [ ] Re-read spec Risk 6. The PR description must flag the **open AMO + data-collection question** — whether `required: ["none"]` survives + extension code reading page text — and note it should be asked before + submission, not at submission. +- [ ] The PR description must list the manual Firefox checks actually performed, + call out the new permissions explicitly, and state that + `chrome_settings_overrides` is unchanged. +- [ ] Push the branch (`git push -u origin feat/extension-options-panel`) and + open the PR with `tea pr create`. Do not merge; hand the URL over for human + review. + +## Out of scope — do not implement + +Each was considered and rejected in the spec. Implementing any of them silently +would undo a deliberate decision: + +- Auto-submitting the prompt (spec Appendix B — rejected on security grounds). +- `browser.omnibox` (works, but increases AMO data-collection exposure). +- Any host permission, `optional_permissions`, or `webNavigation`. +- A toolbar button; multiple named prompt templates; MV3 migration; Chrome + packaging; user-facing error notifications. diff --git a/docs/superpowers/plans/2026-10-05-advanced-context-menu.md b/docs/superpowers/plans/2026-10-05-advanced-context-menu.md new file mode 100644 index 0000000..8ec98a7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-advanced-context-menu.md @@ -0,0 +1,166 @@ +# Advanced Context Menu Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add optional contextual prompt presets and opt-in translation while preserving the existing simple action. + +**Architecture:** Pure modules define settings, actions, and launch preparation. Firefox adapters register menus and open tabs/windows; a packaged dialog handles language entry, truncation consent, and errors. Direct and dialog launches share preparation and opening code. + +**Tech Stack:** Firefox Desktop MV2 WebExtensions, ES modules, vanilla HTML/CSS, node:test, web-ext; Node.js 22+. + +**Spec:** `docs/superpowers/specs/2026-10-05-advanced-context-menu-design.md` + +## Global Constraints + +- Firefox Desktop remains the only target. +- Preserve the current `storage`, `menus`, and `activeTab` permissions. +- No new host permissions, telemetry, intermediary service, runtime dependency, or API key is part of this design. +- Retain the current 8,000-character serialized URL budget. +- Users still review and submit in ChatGPT; no auto-submit or internal ChatGPT request modification. +- Options remain inline in `about:addons` and use existing visual styles. +- Preserve existing uncommitted changes; they are the working baseline, not disposable noise. Review the working tree before creating an isolated execution checkout; do not branch from HEAD alone and lose that baseline. +- Stage task-specific changes only. Many target files already contain prior work: inspect each staged diff and avoid committing unrelated hunks. + +## Review Focus + +- A selection inside a link must expose selection actions only (Task 2/5). +- A repeated `{query}` template must remain intact while budgeting repeated source text (Task 3). +- A language containing `&` or non-BMP characters must display literally and obey the 100-code-point limit (Task 1/5/6). +- Background suspension or two simultaneous translation dialogs must not mix or lose an acknowledged request (Task 4). +- Cancel, duplicate Enter, and a failed launch must never send twice or report success prematurely (Task 4). + +## File map and contracts + +- Modify `lib/defaults.js`, `lib/settings.js`: new defaults and legacy migration. +- Create `lib/actions.js`: context selection, action IDs, prompt rendering, language labels. +- Modify `lib/build-url.js`: retain URL encoding, expose untruncated prompt serialization. +- Create `lib/prepare-action.js`: pure validation and length decisions. +- Create `background/open-chat.js`: tab/window opening with fallback. +- Create `background/dialogs.js`: initial request handoff to packaged dialog. +- Create `background/menus.js`: menu registration and visibility updates. +- Modify `background/background.js`: compose modules and route entry points. +- Create `dialog/action.html`, `dialog/action.css`, `dialog/action.js`, `dialog/controller.js`: accessible dialog shell and controller. +- Modify `options/options.html`, `options/options.js`; create `options/menu-settings.js`: new controls, dependencies, validation. +- Tests mirror these responsibilities under `test/`; docs changes remain in README/CHANGELOG. + +Shared data types (plain JavaScript objects, documented with JSDoc): + +`ActionRequest = { actionId, source, settings, language? }`, where settings is a +sanitized snapshot, source is a string, and actionId is one of `ask`, `explain`, +`summarize`, `debug`, `translate`, `ask-link`, `review-link`, `ask-page`, +`summarize-page`. Only `ask` permits an empty source (blank-chat shortcut). + +`Preparation = {kind:'ready',url} | {kind:'language'} | +{kind:'shortened',url} | {kind:'error',message}`. + +## Task 1: Settings schema and migration + +**Files:** Modify `lib/defaults.js`, `lib/settings.js`, `test/settings.test.js`. + +**Interfaces:** Preserve `mergeSettings(stored)`, `load()`, `save(partial)`. +New keys: contextMenuMode (`off|simple|advanced`, default simple), showTranslate +(false), translationMode (`ask|preferred`, default ask), translationLanguage +(empty). Export `normalizeLanguage(value) -> string` from settings: trim, +return empty on invalid type or more than 100 code points. + +- [ ] Add failing assertions for `mergeSettings({showContextMenu:false}).contextMenuMode === 'off'`, absent legacy => simple, valid new mode wins over legacy, invalid new mode falls back through legacy, and unknown keys are removed. +- [ ] Add assertions that 100 emoji are accepted, 101 rejected, whitespace trims, and wrong language/mode/boolean types default. Update existing expected setting shapes without dropping their coverage. +- [ ] Run `node --test test/settings.test.js`; confirm new assertions fail for the intended missing behavior. +- [ ] Implement defaults and migration. `load()` requests both new keys and legacy `showContextMenu`; save writes normalized new keys, not a legacy boolean. Do not delete old data as an initialization side effect. +- [ ] Run `node --test test/settings.test.js`; require nonzero passing tests. Verify load against a fake storage containing only the legacy key. +- [ ] Review/stage only this task and commit `feat: migrate context menu and translation preferences`. + +## Task 2: Context and dedicated prompt definitions + +**Files:** Create `lib/actions.js`, `test/actions.test.js`. + +**Interfaces:** `getContext(info) -> {kind:'selection'|'link'|'page',source}|null`; +`getActions(context,settings) -> [{id,title}]`; `renderPrompt(request,sourceOverride=request.source) -> string`. +`getContext` accepts menu event data, prioritizes nonblank selection then link, +then page URL, and excludes password inputs and media-only contexts. Empty +editable fields get no page group. Link/page URL validation belongs to Task 3. + +- [ ] Add tests asserting linked selection chooses selection, empty selection chooses link, media-only and password contexts return null, and page background uses pageUrl. Selection order must be ask/explain/summarize/debug, with translate appended only when enabled. +- [ ] Add prompt assertions: default action applies the saved template; Explain/Debug ignore it; Translate includes the language; link-safety prompt asks for evidence/uncertainty and never claims completed checks. Preserve literal `$&`, `$$`, backticks, Unicode and braces in source text. Escape `&` in menu language labels. +- [ ] Run `node --test test/actions.test.js`; confirm intended failures. +- [ ] Implement fixed action definitions and dedicated prompts matching the spec table. Extract/reuse existing literal template replacement instead of implementing conflicting replacement semantics. Blank `ask` renders empty even with a nonempty template. +- [ ] Run `node --test test/actions.test.js test/build-url.test.js`; expect all passing. +- [ ] Commit reviewed task hunks as `feat: define contextual prompt actions`. + +## Task 3: Safe action preparation and length decisions + +**Files:** Modify `lib/build-url.js`; create `lib/prepare-action.js`, `test/prepare-action.test.js`; extend `test/build-url.test.js`. + +**Interfaces:** `serializePrompt(settings,prompt) -> string` encodes an already +rendered prompt without truncating. Keep legacy `buildChatGptUrl` compatibility +for existing tests until routing is replaced. `prepareAction(request) -> Preparation` +uses Task 2 rendering, Task 1 normalization, and `MAX_URL_CHARS`. + +- [ ] Add assertions for ready URLs <=8000, translation needing a language, invalid/credential-bearing/non-HTTP URLs producing error, and oversized URLs producing error rather than shortened. Accepted URL source must survive unchanged within decoded prompt. +- [ ] Add budget tests for repeated `{query}`, emoji, whitespace boundaries, hint overhead, oversized instructions, and the shortening marker. Every shortened result retains all fixed instructions and substitutes only a shortened source; a small source remains byte-identical after decoding. +- [ ] Run `node --test test/prepare-action.test.js`; confirm failures. +- [ ] Implement validation and binary search over source code points, measuring the fully rendered/encoded prompt each time. Reserve the marker `[Selected text shortened]` only when shortening. Prefer a late whitespace boundary as in existing builder. Reject unknown action IDs and missing required sources. +- [ ] Run `node --test test/prepare-action.test.js test/build-url.test.js test/actions.test.js`; require passing assertions and nonzero discovery. +- [ ] Commit reviewed changes as `feat: prepare actions with explicit truncation decisions`. + +## Task 4: Launch helper and reliable extension dialogs + +**Files:** Create `background/open-chat.js`, `background/dialogs.js`, `dialog/action.html`, `dialog/action.css`, `dialog/action.js`, `dialog/controller.js`, `test/open-chat.test.js`, `test/dialog.test.js`. + +**Interfaces:** `openChat(browser,url,openIn) -> Promise` rejects after +final failure. `showActionDialog(browser,payload) -> Promise` transfers +`payload={request,preparation}` to the dialog. `initializeDialog({browser,document,window})` +owns UI and launch after handoff, using Tasks 1–3 and openChat. + +- [ ] Write boundary tests: new-window fallback opens a tab, background-tab uses active:false, final failure rejects; language whitespace blocks submit, Cancel/Escape never launch, double submit launches once, rejected launch permits retry, shortening requires explicit consent. +- [ ] Write handoff tests with two opaque IDs and distinct sources: wrong sender/ID cannot claim data; after acknowledgment clearing background state does not affect dialog completion; closed/uninitialized dialogs release requests and show retry rather than processing missing data. +- [ ] Run `node --test test/open-chat.test.js test/dialog.test.js`; confirm failures. +- [ ] Implement tab/window helper and dialog shell. Use runtime extension-page messaging plus a random request ID, verify sender extension URL and associated tab ID, transfer a structured snapshot, acknowledge receipt, then release background payload. Keep launch handling active through handshake; register listeners before opening. Apply a finite 15-second initialization timeout, with clear retry copy; never time out user language entry after handoff. No selected content in URLs, logs, or storage. +- [ ] Implement dialog controller: validate language, rerun preparation, show shortening confirmation if needed, disable launch controls while pending, show errors/retry, close only after success. Use textContent for all source-derived UI. Window creation falls back to an extension tab; closing that surface cancels. Use the same shell for direct-launch error messages. +- [ ] Run boundary tests and manually probe actual Firefox event-page handoff/idle behavior before accepting this task. If lifecycle assumptions fail, correct the handoff design within the approved no-persistence/no-permission constraints before wiring menus. +- [ ] Commit task-specific files as `feat: add action dialogs and shared launch handling`. + +## Task 5: Register adaptive menus and route the shortcut + +**Files:** Create `background/menus.js`, `test/menus.test.js`; modify `background/background.js`. + +**Interfaces:** `initializeMenus({browser,load,onAction})` registers menus, +storage listener, onShown/onClicked, and serializes rebuilding. onAction receives +an ActionRequest. `runAction(request)` in background uses prepareAction then +openChat for ready or showActionDialog otherwise; errors use dialog feedback. + +- [ ] Add tests: Off has no root, Simple has one selection-only direct action, Advanced has one root with correct children; onShown reveals exactly one context group and refreshes; selection over link wins; live translation/mode changes rebuild without races. +- [ ] Add assertions that unexpected/stale item IDs or mismatched click contexts do not launch, preferred-language labels escape ampersands, and click creates one settings/source snapshot. Verify shortcut invokes `ask` independently of menu mode, including empty selection. +- [ ] Run `node --test test/menus.test.js`; confirm failures. +- [ ] Implement adapter using existing serialized removeAll/create pattern, namespaced stable IDs and visibility updates. Verify current Firefox menu APIs from Mozilla primary docs at execution time. Preserve the shortcut's focused-frame selection contract and existing fallback behavior; do not expand page-reading scope. +- [ ] Route both entry points through shared preparation, including truncation consent for Simple and shortcut. Remove obsolete menu listeners to prevent duplicates. +- [ ] Run all unit tests. Manually check AGENTS.md frame cases; if the existing ancestor-focus bug is reproduced, make the focused-frame guard correction with a regression check before claiming shortcut preservation. +- [ ] Commit reviewed changes as `feat: wire simple and advanced context menus`. + +## Task 6: Options UI, release cleanup, and complete verification + +**Files:** Modify `options/options.html`, `options/options.js`, `options/options.css` if needed; create `options/menu-settings.js`, `test/menu-settings.test.js`; modify `README.md`, `CHANGELOG.md`. + +**Interfaces:** `initializeMenuSettings({document,onChange}) -> {render(settings),collect()}` +owns context/translation controls. Existing options renderer delegates these +keys rather than assuming every preference maps to a checkbox/text input. + +- [ ] Write UI boundary tests for Off/Simple/Advanced dependent visibility, opt-in translation, preferred-language validation, retaining hidden preferences, migration display, and Restore defaults. Assert language length uses code points rather than HTML UTF-16 maxlength alone. +- [ ] Run `node --test test/menu-settings.test.js`; confirm failures. +- [ ] Add controls with approved exact labels, reuse existing styles and debounce/save behavior. Invalid preferred language displays inline guidance; launch behavior remains dialog fallback. Preserve active input against storage echoes. Update restore/shortcut copy that assumes five settings or a checkbox. +- [ ] Remove temporary URL-test HTML and handler. Update README/CHANGELOG and privacy section to describe URL sharing, menu modes, opt-in translation, length consent, and unchanged address-bar behavior. +- [ ] Run `npm test`; require lint with zero warnings, nonzero passing unit tests, and a successful ZIP build. Inspect ZIP entries for new dialog assets and absence of any `docs/` or `test/` entries; do not alter packaging exclusions unless inspection finds a defect. +- [ ] Run `npm run dev` and execute every manual check from the approved spec and AGENTS.md, including light/dark/narrow settings/dialogs, keyboard focus/cancellation, concurrent requests, idle/wakeup, long selection, URL rejection, menu precedence, restart persistence, and all shortcut frame cases. Record actual observed results; ask for user assistance if native interaction is unavailable rather than counting a headless launch as behavioral coverage. +- [ ] Run `git diff --check`, review permissions/search URL/package changes, and commit only reviewed task hunks as `feat: expose advanced menu preferences and usage guidance`. + +## Completion and review + +Execution must begin with a baseline snapshot/status review and the appropriate +isolation skill; unresolved existing work must not be overwritten. No release, +AMO upload, push, or merge is authorized by this plan. Finish with a review of +the complete change and a report of automated and actual Firefox checks, +including any checks still requiring user interaction. + +Plan self-review: spec sections map to Tasks 1–6; all five Review Focus cases +are assigned tests. Shared signatures use the ActionRequest/Preparation types +above. Lifecycle handoff is intentionally verified before entry-point wiring. diff --git a/docs/superpowers/specs/2026-07-29-extension-options-design.md b/docs/superpowers/specs/2026-07-29-extension-options-design.md new file mode 100644 index 0000000..aa7279b --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-extension-options-design.md @@ -0,0 +1,840 @@ +# Extension Options Panel — Design + +- **Date:** 2026-07-29 +- **Status:** Approved for planning +- **Branch:** `feat/extension-options-panel` (cut from `develop` @ `5edb288`) + +## Summary + +Add an options panel to the extension, reachable from `about:addons` → the +extension's detail card → the **Options** tab (Windows) / **Preferences** tab +(macOS, Linux). The panel configures two new entry points that this change also +introduces: a context-menu item on selected text, and a keyboard shortcut that +does the same for the current selection, falling back to a fresh empty chat when +nothing is selected. + +The existing `gpt ` address-bar search provider is **not** modified and +does not read the settings. See [Why the search provider is untouched](#why-the-search-provider-is-untouched). + +New permissions: `storage`, `menus`, and `activeTab`. All three are in Firefox's +no-prompt class, so the install dialog gains no new lines and +`data_collection_permissions: ["none"]` remains truthful. + +## Goals + +1. Ship a working options panel embedded in `about:addons`. +2. Ship two new entry points whose behaviour the panel controls. +3. Add no permission-warning lines to the install prompt. +4. Keep `data_collection_permissions: ["none"]` accurate. +5. Introduce unit testing for the pure logic, without new runtime dependencies. +6. Never promise behaviour that ChatGPT does not reliably deliver. + +## Non-goals + +- Making the `gpt ` search provider configurable. The declarative + `search_url` is fixed at install time, and the three routes around that were + each rejected: host permissions plus navigation interception (conflicts with + goals 3 and 4), a bundled extension page as `search_url` (Firefox refuses to + install), and `browser.omnibox` (works, but moves the transmitter of search + terms from Firefox to extension code — see Risk 6). `gpt ` keeps + behaving exactly as it does today. +- Auto-submitting the prompt on the user's behalf. Rejected on security grounds + after dedicated adversarial testing; see + [Appendix B](#appendix-b-why-auto-submit-was-rejected). +- Moving the `gpt` keyword to `browser.omnibox`. Deliberately declined to + minimise AMO data-collection exposure, not for lack of a working mechanism. +- A toolbar button. +- Named/multiple prompt templates with add-remove-reorder UI. One template only. +- Migrating to Manifest V3. +- Chrome or other-browser packaging (`AGENTS.md` browser scope). +- User-facing error notifications. The `notifications` permission *does* + generate an install warning, so every failure path degrades silently instead. + +## Background and constraints + +The extension is currently manifest-only: no JavaScript exists, and the +manifest declares no `background`, `content_scripts`, or +`web_accessible_resources` key. Its single feature is +`chrome_settings_overrides.search_provider` with +`search_url: "https://chatgpt.com?q={searchTerms}"`. + +### Why the search provider is untouched + +`chrome_settings_overrides.search_provider` is declarative and fixed at install +time. No WebExtension API can rewrite an installed engine's URL. Making +`gpt ` honour the settings would require intercepting the navigation it +starts, which needs host access to `chatgpt.com` plus `webRequest` or `tabs` — +all of which add install-prompt lines. Rejected in favour of goals 3 and 4. + +`is_default` stays `false`. Setting it to `true` triggers a second, separate +consent doorhanger (`webext-default-search-description`) outside any permission +list, plus an allowlist requirement. + +### ChatGPT URL parameters are undocumented + +None of `q`, `model`, `hints`, or `temporary-chat` is documented by OpenAI. +There is no contract and no support channel. The design is therefore resilient +by construction: the URL is built from a template with no runtime dependency on +any parameter being honoured, each parameter is independently removable so a +user can bisect when ChatGPT ignores one, and all user-facing copy is written at +hint strength. See [Appendix A](#appendix-a-verification-findings) for the +evidence behind each parameter's confidence rating. + +The one parameter with quasi-official standing is `q`, because OpenAI's own +"ChatGPT search" browser extension uses `https://chatgpt.com/?q=`. Build on it; +treat everything else as best-effort. + +**`q` prefills but does not submit — confirmed for this extension.** Auto-submit +was real historically but has been prefill-only since approximately November +2025, after OpenAI gated it on the `Sec-Fetch-Site` header as a prompt-injection +mitigation. + +Verified on 2026-07-29 in the maintainer's own logged-in Firefox profile, on +this extension's actual navigation path: the prompt lands in the composer and +waits. The user presses Enter twice — once to leave the address bar, once to +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**. + +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 +this extension, and is documented as such rather than worked around; see +[Rejected alternatives](#rejected-alternatives). + +## Architecture + +Manifest stays **V2**. Mozilla has not announced any MV2 deprecation and +commits to at least 12 months' notice. The repo is MV2 today and has no Chrome +target, so migration is an orthogonal change. The code is nonetheless kept +MV3-portable: ES-module background, `persistent: false`, no `browser_style: true`. + +ES modules work in an MV2 background script via +`"background": {"scripts": [...], "type": "module"}`, supported since Firefox +112 and verified empirically in Firefox 153.0.1. No background HTML shim is +needed. The options page is a normal extension page and can use +` diff --git a/options/options.js b/options/options.js new file mode 100644 index 0000000..a162a71 --- /dev/null +++ b/options/options.js @@ -0,0 +1,97 @@ +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { load, save } from "../lib/settings.js"; +import { initializeMenuSettings, MENU_SETTING_KEYS } from "./menu-settings.js"; +import { initializeShortcut } from "./shortcut.js"; + +const SAVE_DEBOUNCE_MS = 400; +const STATUS_CLEAR_MS = 1500; + +const field = (id) => document.getElementById(id); + +let saveTimer = null; +let statusTimer = null; + +const menuSettings = initializeMenuSettings({ document, onChange: scheduleSave }); + +function render(settings) { + menuSettings.render(settings); + for (const [id, value] of Object.entries(settings)) { + if (MENU_SETTING_KEYS.includes(id)) continue; + const element = field(id); + // Never overwrite the control the user is actively editing — this page's + // own saves echo back through storage.onChanged (see below). + if (element === document.activeElement) { + continue; + } + if (typeof value === "boolean") { + element.checked = value; + } else { + element.value = value; + } + } +} + +function collect() { + return { + ...menuSettings.collect(), + temporaryChat: field("temporaryChat").checked, + webSearch: field("webSearch").checked, + openIn: field("openIn").value, + promptTemplate: field("promptTemplate").value, + }; +} + +function showStatus(message) { + field("status").textContent = message; + clearTimeout(statusTimer); + statusTimer = setTimeout(() => { + field("status").textContent = ""; + }, STATUS_CLEAR_MS); +} + +async function persist(settings) { + try { + await save(settings); + showStatus("Saved"); + } catch { + // storage.sync sanitises its errors, so there is nothing specific to report. + showStatus("Could not save settings"); + } +} + +// Debounced so typing in a text field cannot approach any write-rate quota. +function scheduleSave() { + clearTimeout(saveTimer); + saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS); +} + +// Module scripts are deferred, so the DOM is already parsed here. +void initializeShortcut({ commands: browser.commands, document, window }); +render(await load()); + +for (const id of Object.keys(DEFAULT_SETTINGS)) { + if (MENU_SETTING_KEYS.includes(id)) continue; + field(id).addEventListener("input", scheduleSave); + field(id).addEventListener("change", scheduleSave); +} + +field("restore").addEventListener("click", () => { + clearTimeout(saveTimer); + // Restoring defaults is an explicit user action, so it should reset every + // field even if one currently has focus. Blur first so render()'s + // activeElement guard (see below) has nothing to skip. + document.activeElement?.blur(); + render(DEFAULT_SETTINGS); + persist({ ...DEFAULT_SETTINGS }); +}); + +// browser.storage.onChanged fires for every write to the "sync" area, not just +// a remote sync landing on another device — this page's own debounced +// autosaves and its Restore-defaults save re-enter this listener too, which +// render()'s activeElement guard makes safe to react to unconditionally. +browser.storage.onChanged.addListener(async (changes, area) => { + if (area !== "sync") { + return; + } + render(await load()); +}); diff --git a/options/shortcut.js b/options/shortcut.js new file mode 100644 index 0000000..08a0c36 --- /dev/null +++ b/options/shortcut.js @@ -0,0 +1,48 @@ +// Firefox owns shortcut preferences; never copy them into extension storage. +export async function initializeShortcut({ commands, document, window }) { + const field = (id) => document.getElementById(id); + let refreshId = 0; + + function render(shortcut) { + field("shortcut-intro").textContent = shortcut + ? ` Or press ${shortcut} to use your selection. With nothing selected, the shortcut opens a blank chat.` + : ""; + field("shortcut-hint").textContent = shortcut + ? `${shortcut} runs your default action even when the context menu is off.` + : "The keyboard shortcut runs your default action independently of the context menu."; + field("shortcut-current").textContent = shortcut === null + ? "Could not read the keyboard shortcut. Check Manage Extension Shortcuts in Add-ons." + : shortcut ? `Current shortcut: ${shortcut}` : "No keyboard shortcut assigned."; + } + + async function refresh() { + const requestId = ++refreshId; + try { + const all = await commands.getAll(); + const command = all.find(({ name }) => name === "ask-chatgpt"); + if (requestId === refreshId) render(command?.shortcut ?? ""); + } catch { + if (requestId === refreshId) render(null); + } + } + + const button = field("change-shortcut"); + button.hidden = typeof commands?.openShortcutSettings !== "function"; + button.addEventListener("click", async () => { + try { + await commands.openShortcutSettings(); + } catch { + field("shortcut-current").textContent = + "Open Add-ons, then the gear menu → Manage Extension Shortcuts to change your shortcut."; + } + }); + + commands?.onChanged?.addListener(({ name }) => { + if (name === "ask-chatgpt") void refresh(); + }); + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") void refresh(); + }); + await refresh(); +} diff --git a/package.json b/package.json index a9d9571..a865b96 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "search-with-chatgpt-powered-by-openai-extension", - "version": "1.0.0", + "version": "1.1.0", "private": true, + "type": "module", "description": "Development tooling for the Search with ChatGPT Firefox extension.", "engines": { "node": ">=22" @@ -9,8 +10,9 @@ "scripts": { "dev": "web-ext run", "lint": "web-ext lint", + "test:unit": "node --test", "build": "web-ext build", - "test": "npm run lint && npm run build" + "test": "npm run lint && npm run test:unit && npm run build" }, "devDependencies": { "web-ext": "10.5.0" diff --git a/test/actions.test.js b/test/actions.test.js new file mode 100644 index 0000000..193ac02 --- /dev/null +++ b/test/actions.test.js @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS as defaults } from '../lib/defaults.js'; +const actions = await import('../lib/actions.js').catch(() => ({})); + +test('context prioritizes selection over link and excludes unrelated targets', () => { + assert.equal(typeof actions.getContext, 'function'); + assert.deepEqual(actions.getContext({selectionText:'code',linkUrl:'https://example.com',contexts:['selection','link']}), {kind:'selection',source:'code'}); + 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', () => { + assert.equal(typeof actions.getActions, 'function'); + assert.deepEqual(actions.getActions({kind:'selection'}, defaults).map(a=>a.id), ['ask','explain','summarize','debug']); + const list = actions.getActions({kind:'selection'}, {...defaults,showTranslate:true,translationMode:'preferred',translationLanguage:'A & B'}); + assert.equal(list.at(-1).title,'Translate to A && B'); +}); +test('dedicated prompts ignore personal template and preserve literal source', () => { + assert.equal(typeof actions.renderPrompt, 'function'); + const request = {actionId:'ask',source:'$& $$ `code` 😀',settings:{...defaults,promptTemplate:'French: {query}'}}; + assert.equal(actions.renderPrompt(request), 'French: $& $$ `code` 😀'); + assert.equal(actions.renderPrompt({...request,source:''}), ''); + for (const actionId of ['explain','summarize','debug']) { + const prompt = actions.renderPrompt({...request,actionId}); + assert.ok(prompt.includes(request.source)); + assert.ok(!prompt.includes('French:')); + } + assert.match(actions.renderPrompt({...request,actionId:'translate',language:'Spanish'}), /Spanish/); + assert.match(actions.renderPrompt({...request,actionId:'review-link'}), /uncertainty/); +}); diff --git a/test/build-url.test.js b/test/build-url.test.js new file mode 100644 index 0000000..667ef4c --- /dev/null +++ b/test/build-url.test.js @@ -0,0 +1,169 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js"; + +const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); +const queryOf = (url) => new URL(url).searchParams.get("q"); +const paramsOf = (url) => [...new URL(url).searchParams.keys()]; + +test("defaults produce only the q parameter on the root path", () => { + assert.equal( + buildChatGptUrl(DEFAULT_SETTINGS, "hello"), + "https://chatgpt.com/?q=hello", + ); +}); + +test("an empty or whitespace-only query omits q entirely", () => { + for (const raw of ["", " ", "\n\t", null, undefined]) { + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`); + assert.equal(url, "https://chatgpt.com/"); + } +}); + +test("legacy model values never add a model URL parameter", () => { + assert.equal(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"), + buildChatGptUrl(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); + + const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi")); + assert.equal(temp.searchParams.get("temporary-chat"), "true"); + + const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi")); + assert.equal(search.searchParams.get("hints"), "search"); +}); + +test("all three URL-affecting settings combine, q first", () => { + const url = buildChatGptUrl( + withSettings({ + temporaryChat: true, + webSearch: true, + promptTemplate: "Explain: {query}", + }), + "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"); + for (const openIn of ["new-tab", "background-tab", "new-window"]) { + assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base); + } +}); + +test("special characters survive encoding round-trip", () => { + const raw = 'a & b # c ? d = e + f "g" 100% 😀 日本語\nnewline'; + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); + assert.equal(url.includes(" "), false); + assert.equal(url.includes("#"), false); +}); + +test("the template replaces every {query} occurrence", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x"); + assert.equal(queryOf(url), "x / x"); +}); + +test("a template without {query} is treated as a prefix, never dropping the query", () => { + const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why"); + assert.equal(queryOf(url), "Be concise: why"); +}); + +test("an empty or whitespace-only template yields the bare query", () => { + for (const promptTemplate of ["", " "]) { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why"); + } +}); + +test("a non-string template degrades to the bare query", () => { + assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why"); +}); + +test("an over-long prompt is truncated to fit the URL budget", () => { + const url = buildChatGptUrl(DEFAULT_SETTINGS, "x".repeat(20000)); + assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); + assert.ok(queryOf(url).length > 0); + assert.ok(queryOf(url).length < 20000); +}); + +test("truncation accounts for the other parameters", () => { + const settings = withSettings({ 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])|(? { + 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 +// 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}`); +}); diff --git a/test/dialog.test.js b/test/dialog.test.js new file mode 100644 index 0000000..6889b0b --- /dev/null +++ b/test/dialog.test.js @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +const { createDialogController } = await import('../dialog/controller.js').catch(()=>({})); +const { showActionDialog } = await import('../background/dialogs.js').catch(()=>({})); +const payload = source=>({request:{actionId:'translate',source,settings:{...DEFAULT_SETTINGS}},preparation:{kind:'language'}}); +function fixture(data=payload('hello')) { + const opened=[];let closed=0;let state; + const controller=createDialogController({payload:data,open:async(url)=>opened.push(url),render:s=>{state=s;},close:()=>closed++}); + return {controller,opened,get state(){return state;},get closed(){return closed;}}; +} +test('dialog validates language, cancel never launches, source snapshots are independent',async()=>{ + assert.equal(typeof createDialogController,'function'); + const a=fixture(),b=fixture(payload('second')); + await a.controller.submit(' ');assert.equal(a.opened.length,0);assert.match(a.state.message,/language/i); + a.controller.cancel();await a.controller.submit('Spanish');assert.equal(a.opened.length,0);assert.equal(a.closed,1); + await b.controller.submit('French');assert.equal(b.opened.length,1);assert.ok(new URL(b.opened[0]).searchParams.get('q').includes('second'));assert.equal(b.closed,1); +}); +test('long translation requires a separate explicit shortening confirmation',async()=>{ + assert.equal(typeof createDialogController,'function'); + const f=fixture(payload('x'.repeat(30000))); + await f.controller.submit('Spanish');assert.equal(f.opened.length,0);assert.equal(f.state.kind,'shortened'); + await f.controller.submit();assert.equal(f.opened.length,1);assert.ok(f.opened[0].length<=8000); +}); +test('pending launch blocks double submit and failure permits retry',async()=>{ + assert.equal(typeof createDialogController,'function'); + let count=0,release,state,closed=0; + const controller=createDialogController({payload:payload('hello'),render:s=>state=s,close:()=>closed++,open:()=>{count++;return new Promise((resolve,reject)=>release={resolve,reject});}}); + const pending=controller.submit('Spanish');await controller.submit('Spanish');assert.equal(count,1); + release.reject(Error('fail'));await pending;assert.equal(closed,0);assert.match(state.message,/try again/i); + const retry=controller.submit();release.resolve();await retry;assert.equal(count,2);assert.equal(closed,1); +}); +function event() { const listeners=new Set();return {addListener:f=>listeners.add(f),removeListener:f=>listeners.delete(f),emit:(...args)=>{for(const f of [...listeners])f(...args);},get size(){return listeners.size;}}; } +function browserFixture() { + let id=0;const tabs=[]; + return {tabs,runtime:{id:'extension',getURL:p=>'moz-extension://test/'+p,onConnect:event()},windows:{create:async({url})=>{const tab={id:++id};tabs.push({url,...tab});return {tabs:[tab]};}},tabsApi:{onRemoved:event()}}; +} +function connect(b,tab,index=0) { + const messages=[]; + const port={name:'action-dialog:'+new URL(tab.url).searchParams.get('request'),sender:{id:'extension',url:tab.url,tab:{id:tab.id+index}},onMessage:event(),onDisconnect:event(),postMessage:v=>messages.push(v),disconnect(){this.onDisconnect.emit();}}; + b.runtime.onConnect.emit(port);return {port,messages}; +} +test('handoff scopes data to matching dialog tab and releases background after acknowledgment',async()=>{ + assert.equal(typeof showActionDialog,'function'); + const b=browserFixture();b.tabs=b.tabsApi; + // Capture opened tabs outside the browser API object. + const opened=[];let id=0;b.windows.create=async({url})=>{const tab={id:++id,url};opened.push(tab);return {tabs:[tab]};}; + const first=showActionDialog(b,payload('first')),second=showActionDialog(b,payload('second')); + await new Promise(r=>setImmediate(r)); + const bad=connect(b,opened[0],100);await new Promise(r=>setImmediate(r));assert.equal(bad.messages.length,0); + const a=connect(b,opened[0]),c=connect(b,opened[1]);await new Promise(r=>setImmediate(r)); + assert.equal(a.messages[0].payload.request.source,'first');assert.equal(c.messages[0].payload.request.source,'second'); + a.port.onMessage.emit({type:'received'});c.port.onMessage.emit({type:'received'});await Promise.all([first,second]); + assert.equal(b.runtime.onConnect.size,0);assert.equal(b.tabs.onRemoved.size,0); + assert.equal(a.messages[0].payload.request.source,'first'); +}); diff --git a/test/menu-settings.test.js b/test/menu-settings.test.js new file mode 100644 index 0000000..1535452 --- /dev/null +++ b/test/menu-settings.test.js @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +const { initializeMenuSettings }=await import('../options/menu-settings.js').catch(()=>({})); +function fixture() { + const fields=new Map();const document={activeElement:null,getElementById:id=>{ + if(!fields.has(id))fields.set(id,{value:'',checked:false,hidden:false,textContent:'',addEventListener(type,fn){this[type]=fn;},setAttribute(name,value){this[name]=value;}}); + return fields.get(id); + }}; + let changed=0; + const controls=initializeMenuSettings({document,onChange:()=>changed++}); + return {controls,document,field:document.getElementById,get changed(){return changed;}}; +} +test('dependent controls preserve hidden preferences and restore defaults',()=>{ + assert.equal(typeof initializeMenuSettings,'function'); + const f=fixture();f.controls.render(DEFAULT_SETTINGS); + assert.equal(f.field('advanced-settings').hidden,true); + f.field('contextMenuMode').value='advanced';f.field('contextMenuMode').input(); + assert.equal(f.field('advanced-settings').hidden,false);assert.equal(f.field('translation-settings').hidden,true); + f.field('showTranslate').checked=true;f.field('showTranslate').input(); + assert.equal(f.field('translation-settings').hidden,false); + f.field('translationMode').value='preferred';f.field('translationMode').input(); + assert.equal(f.field('preferred-language-field').hidden,false); + f.field('translationLanguage').value=' Spanish ';f.field('translationLanguage').input(); + assert.equal(f.controls.collect().translationLanguage,'Spanish'); + f.field('contextMenuMode').value='off';f.field('contextMenuMode').input(); + assert.equal(f.controls.collect().translationLanguage,'Spanish'); + f.controls.render(DEFAULT_SETTINGS);assert.equal(f.field('showTranslate').checked,false);assert.equal(f.field('translationLanguage').value,''); +}); +test('invalid language gives guidance and active edits survive storage echoes',()=>{ + assert.equal(typeof initializeMenuSettings,'function'); + const f=fixture();f.controls.render({...DEFAULT_SETTINGS,contextMenuMode:'advanced',showTranslate:true,translationMode:'preferred'}); + assert.match(f.field('language-validation').textContent,/language/i); + f.field('translationLanguage').value='😀'.repeat(100);f.field('translationLanguage').input();assert.equal(f.field('language-validation').textContent,''); + f.field('translationLanguage').value='😀'.repeat(101);f.field('translationLanguage').input();assert.notEqual(f.field('language-validation').textContent,'');assert.equal(f.controls.collect().translationLanguage,''); + f.document.activeElement=f.field('translationLanguage');f.controls.render(DEFAULT_SETTINGS);assert.equal([...f.field('translationLanguage').value].length,101); +}); diff --git a/test/menus.test.js b/test/menus.test.js new file mode 100644 index 0000000..2ded7c1 --- /dev/null +++ b/test/menus.test.js @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +const { initializeMenus } = await import('../background/menus.js').catch(()=>({})); +function event(){const listeners=[];return {addListener:f=>listeners.push(f),emit:async(...args)=>{for(const f of listeners)await f(...args);}};} +function fixture(mode='advanced') { + let settings={...DEFAULT_SETTINGS,contextMenuMode:mode}; + const items=new Map(), launches=[];let refreshes=0; + const browser={runtime:{},storage:{onChanged:event()},menus:{onShown:event(),onHidden:event(),onClicked:event(), + removeAll:async()=>items.clear(),create:(item,callback)=>{items.set(item.id,{...item});callback();}, + update:async(id,patch)=>{assert.ok(items.has(id));Object.assign(items.get(id),patch);},refresh:async()=>{refreshes++;}}}; + const ready=initializeMenus({browser,load:async()=>({...settings}),onAction:async request=>launches.push(request)}); + return {browser,items,launches,ready,get refreshes(){return refreshes;},set settings(value){settings={...settings,...value};}}; +} +test('mode controls roots, live preferences rebuild, context-only onShown needs no host permission',async()=>{ + assert.equal(typeof initializeMenus,'function'); + const f=fixture('off');await f.ready;assert.equal(f.items.size,0); + f.settings={contextMenuMode:'simple'};await f.browser.storage.onChanged.emit({contextMenuMode:{}},'sync'); + assert.equal(f.items.size,1);assert.equal([...f.items.values()][0].parentId,undefined); + f.settings={contextMenuMode:'advanced'};await f.browser.storage.onChanged.emit({contextMenuMode:{}},'sync'); + assert.equal([...f.items.values()].filter(i=>!i.parentId).length,1); + await f.browser.menus.onShown.emit({contexts:['selection','link'],editable:false}); + assert.deepEqual([...f.items.values()].filter(i=>i.parentId&&i.visible&&i.type!=='separator').map(i=>i.title),['Ask normally','Explain','Summarize','Debug code']); + await f.browser.menus.onShown.emit({contexts:['link'],editable:false}); + assert.deepEqual([...f.items.values()].filter(i=>i.parentId&&i.visible).map(i=>i.title),['Ask about this link','Review link safety']); + assert.ok(f.refreshes>=2); +}); +test('click revalidates context and action, preserves source/settings snapshot',async()=>{ + assert.equal(typeof initializeMenus,'function'); + const f=fixture();await f.ready; + await f.browser.menus.onClicked.emit({menuItemId:'ask-chatgpt:debug',selectionText:'code',linkUrl:'https://example.com'}); + assert.equal(f.launches.length,1);assert.equal(f.launches[0].actionId,'debug');assert.equal(f.launches[0].source,'code'); + f.settings={promptTemplate:'changed'};assert.equal(f.launches[0].settings.promptTemplate,'{query}'); + await f.browser.menus.onClicked.emit({menuItemId:'ask-chatgpt:debug',linkUrl:'https://example.com'}); + await f.browser.menus.onClicked.emit({menuItemId:'ask-chatgpt:translate',selectionText:'code'}); + await f.browser.menus.onClicked.emit({menuItemId:'unknown',selectionText:'code'}); + assert.equal(f.launches.length,1); +}); +test('translation rebuilds with literal label and rapid preference updates settle',async()=>{ + assert.equal(typeof initializeMenus,'function'); + const f=fixture();await f.ready; + f.settings={showTranslate:true,translationMode:'preferred',translationLanguage:'A & B'}; + await f.browser.storage.onChanged.emit({showTranslate:{}},'sync'); + assert.equal(f.items.get('ask-chatgpt:translate').title,'Translate to A && B'); + f.settings={contextMenuMode:'off'}; + const a=f.browser.storage.onChanged.emit({contextMenuMode:{}},'sync'); + f.settings={contextMenuMode:'simple'}; + await Promise.all([a,f.browser.storage.onChanged.emit({contextMenuMode:{}},'sync')]); + assert.equal(f.items.size,1); +}); diff --git a/test/open-chat.test.js b/test/open-chat.test.js new file mode 100644 index 0000000..245d50e --- /dev/null +++ b/test/open-chat.test.js @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +const { openChat } = await import('../background/open-chat.js').catch(()=>({})); +test('window failure falls back to tab, background mode stays inactive, final failure rejects', async()=>{ + assert.equal(typeof openChat,'function'); + const opened=[]; + const browser={windows:{create:async()=>{throw Error('window failed');}},tabs:{create:async v=>opened.push(v)}}; + await openChat(browser,'https://chatgpt.com/','new-window'); + assert.deepEqual(opened,[{url:'https://chatgpt.com/',active:true}]); + await openChat(browser,'https://chatgpt.com/','background-tab'); + assert.equal(opened[1].active,false); + browser.tabs.create=async()=>{throw Error('tab failed');}; + await assert.rejects(openChat(browser,'https://chatgpt.com/','new-window')); +}); diff --git a/test/prepare-action.test.js b/test/prepare-action.test.js new file mode 100644 index 0000000..840b339 --- /dev/null +++ b/test/prepare-action.test.js @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DEFAULT_SETTINGS } from '../lib/defaults.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', () => { + assert.equal(typeof prepareAction, 'function'); + assert.equal(prepareAction(req()).url, 'https://chatgpt.com/?q=Hello'); + assert.equal(prepareAction(req({actionId:'translate'})).kind,'language'); + assert.match(new URL(prepareAction(req({actionId:'translate',language:'Spanish'})).url).searchParams.get('q'),/Spanish/); + assert.equal(prepareAction(req({actionId:'translate',settings:{...DEFAULT_SETTINGS,translationMode:'preferred',translationLanguage:'Spanish'}})).kind,'ready'); + assert.equal(prepareAction(req({source:''})).url,'https://chatgpt.com/'); + assert.equal(prepareAction(req({actionId:'debug',source:''})).kind,'error'); + assert.equal(prepareAction(req({actionId:'bogus'})).kind,'error'); +}); +test('URLs are validated and preserved whole, never shortened', () => { + assert.equal(typeof prepareAction, 'function'); + for (const source of ['javascript:alert(1)','about:config','ftp://example.com','https://user:pass@example.com','not a url','https://example.com/?q='+'x'.repeat(9000)]) { + assert.equal(prepareAction(req({actionId:'ask-link',source})).kind,'error',source.slice(0,40)); + } + const source='https://example.com/?q=a%20b&x=1#part'; + const out=prepareAction(req({actionId:'review-link',source})); + assert.equal(out.kind,'ready'); + assert.ok(new URL(out.url).searchParams.get('q').endsWith(source)); +}); +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}; + const out=prepareAction(req({source:'😀 word '.repeat(4000),settings})); + assert.equal(out.kind,'shortened'); + assert.ok(out.url.length<=8000); + const q=new URL(out.url).searchParams.get('q'); + assert.ok(q.startsWith('START '));assert.ok(q.includes(' END'));assert.ok(q.includes('[Selected text shortened]')); + assert.equal(q.match(/START (.*?) AGAIN/s)[1],q.match(/AGAIN (.*?) END/s)[1]); + assert.equal(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + assert.deepEqual(mergeSettings({}), DEFAULT_SETTINGS); +}); + +test("null and undefined yield the defaults", () => { + assert.deepEqual(mergeSettings(null), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(undefined), DEFAULT_SETTINGS); +}); + +test("a non-object yields the defaults", () => { + assert.deepEqual(mergeSettings("nonsense"), DEFAULT_SETTINGS); + assert.deepEqual(mergeSettings(42), DEFAULT_SETTINGS); +}); + +test("valid stored values are preserved", () => { + assert.deepEqual( + mergeSettings({ + showContextMenu: false, + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }), + { + ...DEFAULT_SETTINGS, + contextMenuMode: "off", + temporaryChat: true, + webSearch: true, + openIn: "new-window", + promptTemplate: "Explain: {query}", + }, + ); +}); + +test("missing keys fall back individually", () => { + const merged = mergeSettings({ openIn: "new-window" }); + assert.equal(merged.openIn, "new-window"); + assert.equal(merged.contextMenuMode, "simple"); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("unknown keys are dropped", () => { + const merged = mergeSettings({ model: "auto", nope: "gone", openInn: "typo" }); + assert.deepEqual(Object.keys(merged).sort(), Object.keys(DEFAULT_SETTINGS).sort()); + assert.equal("nope" in merged, false); +}); + +test("wrong types fall back to the default", () => { + const merged = mergeSettings({ + showContextMenu: "false", + model: 123, + temporaryChat: "yes", + webSearch: 1, + promptTemplate: [], + }); + assert.equal(merged.contextMenuMode, "simple"); + assert.equal("model" in merged, false); + assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat); + assert.equal(merged.webSearch, DEFAULT_SETTINGS.webSearch); + assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); +}); + +test("an out-of-range openIn falls back to the default", () => { + assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); + assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn); +}); + +test("each documented openIn value is accepted", () => { + for (const value of ["new-tab", "background-tab", "new-window"]) { + assert.equal(mergeSettings({ openIn: value }).openIn, value); + } +}); + +test("mergeSettings does not mutate DEFAULT_SETTINGS", () => { + mergeSettings({ openIn: "new-window" }); + assert.equal(DEFAULT_SETTINGS.openIn, "new-tab"); +}); + +test("previously saved model selections are ignored", () => { + assert.deepEqual(mergeSettings({ model: "gpt-5.6-sol" }), DEFAULT_SETTINGS); +}); + + +test("legacy mode migrates and a valid new mode wins", () => { + assert.equal(mergeSettings({ showContextMenu: false }).contextMenuMode, "off"); + assert.equal(mergeSettings({ showContextMenu: true }).contextMenuMode, "simple"); + assert.equal(mergeSettings({ showContextMenu: false, contextMenuMode: "advanced" }).contextMenuMode, "advanced"); + assert.equal(mergeSettings({ showContextMenu: false, contextMenuMode: "bad" }).contextMenuMode, "off"); +}); + +test("translation defaults and Unicode language limits are safe", () => { + assert.equal(mergeSettings({}).showTranslate, false); + assert.equal(mergeSettings({}).translationMode, "ask"); + assert.equal(mergeSettings({ translationLanguage: " Spanish " }).translationLanguage, "Spanish"); + assert.equal(mergeSettings({ translationLanguage: "😀".repeat(100) }).translationLanguage, "😀".repeat(100)); + assert.equal(mergeSettings({ translationLanguage: "😀".repeat(101) }).translationLanguage, ""); + assert.equal(mergeSettings({ translationLanguage: 42, translationMode: "bad", showTranslate: "yes" }).translationMode, "ask"); +}); + +test("load includes legacy-only preferences and save filters unknown keys", async () => { + const { load, save } = await import("../lib/settings.js"); + let saved; + globalThis.browser = { storage: { sync: { + get: async (keys) => Object.fromEntries(keys.filter(k => k === "showContextMenu").map(k => [k, false])), + set: async (value) => { saved = value; }, + } } }; + try { + assert.equal((await load()).contextMenuMode, "off"); + await save({ ...DEFAULT_SETTINGS, contextMenuMode: "advanced", unknown: true }); + assert.equal(saved.contextMenuMode, "advanced"); + assert.equal("unknown" in saved, false); + } finally { delete globalThis.browser; } +}); + +test("partial saves preserve unrelated stored preferences", async () => { + const { save } = await import("../lib/settings.js"); + const stored = { contextMenuMode: "advanced", translationLanguage: "Spanish", promptTemplate: "Explain {query}" }; + globalThis.browser = { storage: { sync: { set: async patch => Object.assign(stored, patch) } } }; + try { + await save({ webSearch: true }); + assert.equal(stored.contextMenuMode, "advanced"); + assert.equal(stored.translationLanguage, "Spanish"); + assert.equal(stored.promptTemplate, "Explain {query}"); + assert.equal(stored.webSearch, true); + } finally { delete globalThis.browser; } +}); diff --git a/test/shortcut-selection.test.js b/test/shortcut-selection.test.js new file mode 100644 index 0000000..58bc528 --- /dev/null +++ b/test/shortcut-selection.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; + +test('shortcut uses focused leaf frame, ignores stale ancestors and opens blank without content focus',async()=>{ + let command; + let frames=[]; + const opened=[]; + const noop={addListener(){},removeListener(){}}; + globalThis.browser={ + storage:{sync:{get:async()=>({contextMenuMode:'off'})},onChanged:noop}, + runtime:{onConnect:noop}, + menus:{removeAll:async()=>{},onShown:noop,onHidden:noop,onClicked:noop}, + commands:{onCommand:{addListener:f=>command=f}}, + tabs:{query:async()=>[{id:1}],executeScript:async(id,{code})=>frames.map(frame=>vm.runInNewContext(code,{document:{hasFocus:()=>frame.focus,activeElement:{tagName:frame.active??'BODY'}},window:{getSelection:()=>({toString:()=>frame.text})}})),create:async value=>opened.push(value)}, + }; + try { + await import('../background/background.js'); + for (const [value,expected] of [ + [[{focus:true,text:'top'}],'top'], + [[{focus:true,active:'IFRAME',text:'stale top'},{focus:true,text:'frame'}],'frame'], + [[{focus:false,text:'stale top'},{focus:false,text:'stale frame'}],null], + ]) { + frames=value;await command('ask-chatgpt'); + assert.equal(new URL(opened.at(-1).url).searchParams.get('q'),expected); + } + browser.tabs.executeScript=async()=>{throw Error('restricted');}; + await command('ask-chatgpt');assert.equal(opened.at(-1).url,'https://chatgpt.com/'); + } finally {delete globalThis.browser;} +}); diff --git a/test/shortcut.test.js b/test/shortcut.test.js new file mode 100644 index 0000000..9628543 --- /dev/null +++ b/test/shortcut.test.js @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { initializeShortcut } from "../options/shortcut.js"; + +function setup(commands) { + const elements = Object.fromEntries([ + "shortcut-intro", "shortcut-hint", "shortcut-current", "change-shortcut", + ].map((id) => [id, Object.assign(new EventTarget(), { textContent: "", hidden: true })])); + const document = Object.assign(new EventTarget(), { + visibilityState: "visible", + getElementById: (id) => elements[id], + }); + const window = new EventTarget(); + const ready = initializeShortcut({ commands, document, window }); + return { elements, document, window, ready }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); +const command = (shortcut) => ({ name: "ask-chatgpt", description: "Ask ChatGPT", shortcut }); + +test("shows the configured command rather than another command or the default", async () => { + const { elements, ready } = setup({ getAll: async () => [ + { name: "other", description: "Other", shortcut: "Alt+O" }, command("Ctrl+Shift+Y"), + ] }); + await ready; + for (const id of ["shortcut-intro", "shortcut-hint", "shortcut-current"]) { + assert.match(elements[id].textContent, /Ctrl\+Shift\+Y/); + assert.doesNotMatch(elements[id].textContent, /Alt\+Shift\+G|Alt\+O/); + } +}); + +test("updates an open page when the shortcut is removed", async () => { + let changed; + let shortcut = "Alt+Shift+G"; + const { elements, ready } = setup({ + getAll: async () => [command(shortcut)], + onChanged: { addListener: (listener) => { changed = listener; } }, + }); + await ready; + shortcut = ""; + changed({ name: "ask-chatgpt", newShortcut: "", oldShortcut: "Alt+Shift+G" }); + await flush(); + assert.match(elements["shortcut-current"].textContent, /No keyboard shortcut assigned/); + assert.doesNotMatch(elements["shortcut-intro"].textContent, /press|Alt\+Shift\+G/i); +}); + +test("refreshes on focus and visibility without the change event API", async () => { + let shortcut = "Alt+Shift+G"; + const { elements, window, document, ready } = setup({ getAll: async () => [command(shortcut)] }); + await ready; + shortcut = "Ctrl+Shift+Y"; + window.dispatchEvent(new Event("focus")); + await flush(); + assert.match(elements["shortcut-current"].textContent, /Ctrl\+Shift\+Y/); + shortcut = "Alt+J"; + document.dispatchEvent(new Event("visibilitychange")); + await flush(); + assert.match(elements["shortcut-current"].textContent, /Alt\+J/); +}); + +test("missing commands and failed reads never claim the default is assigned", async () => { + for (const commands of [undefined, { getAll: async () => [] }, { + getAll: async () => { throw new Error("Unavailable"); }, + }]) { + const { elements, ready } = setup(commands); + await ready; + assert.doesNotMatch(elements["shortcut-current"].textContent, /Alt\+Shift\+G/); + assert.equal(elements["change-shortcut"].hidden, true); + } +}); + +test("shortcut editor failure displays manual recovery instructions", async () => { + const { elements, ready } = setup({ + getAll: async () => [command("Alt+J")], + openShortcutSettings: async () => { throw new Error("Unavailable"); }, + }); + await ready; + assert.equal(elements["change-shortcut"].hidden, false); + elements["change-shortcut"].dispatchEvent(new Event("click")); + await flush(); + assert.match(elements["shortcut-current"].textContent, /Manage Extension Shortcuts/); +}); diff --git a/web-ext-config.mjs b/web-ext-config.mjs index b31b7ff..7be1694 100644 --- a/web-ext-config.mjs +++ b/web-ext-config.mjs @@ -9,6 +9,12 @@ export default { "package.json", "package-lock.json", "web-ext-config.mjs", + // Directories need both the bare name and the glob: the glob alone removes + // the files but leaves an empty directory entry in the ZIP. + "docs", + "docs/**", + "test", + "test/**", "web-ext-artifacts/**", ], build: {