diff --git a/.gitea/workflows/firefox-extension.yml b/.gitea/workflows/firefox-extension.yml index 2fac9ef..4a5c567 100644 --- a/.gitea/workflows/firefox-extension.yml +++ b/.gitea/workflows/firefox-extension.yml @@ -2,13 +2,31 @@ name: Firefox Extension CI on: pull_request: + # edited: removing the WIP: prefix starts the real run. + types: [opened, synchronize, reopened, edited] push: branches: - develop - main + workflow_dispatch: + +# The gate and concurrency use the github.* context, Gitea's alias for gitea.*: +# it is the form the pattern was canaried with and the one pr-flow detects. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # Cancel superseded PR runs, never a push run: main's push uploads the package. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: validate: + # WIP gate: skip PRs titled WIP: so early PRs cost no run per push. Gate on + # the title and nothing else, since a skipped job reports success on the + # required check (see ~/.claude/docs/gitea-ci-pattern.md). + if: >- + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') + ) runs-on: ubuntu-latest steps: - name: Check out repository diff --git a/CHANGELOG.md b/CHANGELOG.md index c2587e8..47ace30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ in 1.1.0, which was never published there. actions, with optional translation and a preferred language or language dialog. - Preserve dedicated action instructions when shortening selections; ask before sending shortened text and reject URLs that cannot be sent intact. +- Stay responsive with very large selections from the shortcut or toolbar: only + the part that can fit in the ChatGPT link is processed. - Add accessible action dialogs with cancellation and retry, remove the temporary model/effort URL probe, and document URL sharing. No new permissions. - Show up front, in the options panel and README, that ChatGPT may require diff --git a/README.md b/README.md index e2dba84..48e80c4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Open `about:addons`, select this extension, and choose the **Options** tab guarantee. - **Open in** — a new tab, a new background tab, or a new window. - **Prompt template** — wraps your text, for example `Explain simply: {query}`. + Up to 1,000 characters. Restore defaults resets extension preferences to Simple mode with translation hidden; it does not change Firefox's search engine or shortcut settings. diff --git a/lib/actions.js b/lib/actions.js index 48737ee..442c0ad 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -13,7 +13,6 @@ const TITLES = { }; export function getContext(info) { - if (info.inputType === 'password') return null; if (typeof info.selectionText === 'string' && info.selectionText.trim()) { return { kind: 'selection', source: info.selectionText.trim() }; } diff --git a/lib/build-url.js b/lib/build-url.js index 5e836e8..a9f11b8 100644 --- a/lib/build-url.js +++ b/lib/build-url.js @@ -12,9 +12,6 @@ const BASE = "https://chatgpt.com/"; export const MAX_URL_CHARS = 8000; -// Fraction of the kept text within which a whitespace cut is preferred. -const WHITESPACE_WINDOW = 0.9; - export function applyTemplate(template, query) { if (query === "") { return ""; @@ -42,65 +39,9 @@ function addOptionalParams(url, settings) { return url; } -function lastWhitespaceIndex(text) { - for (let i = text.length - 1; i >= 0; i -= 1) { - if (/\s/.test(text[i])) { - return i; - } - } - return -1; -} - -// Returns the longest prefix of `prompt` whose finished URL fits the budget. -// Slicing happens by CODE POINT on the decoded string, so a surrogate pair can -// never be split and a percent-escape can never be severed (encoding happens -// afterwards, via URLSearchParams). -function fitToBudget(prompt, settings) { - const codePoints = Array.from(prompt); - - const urlLengthFor = (count) => { - const url = new URL(BASE); - url.searchParams.set("q", codePoints.slice(0, count).join("")); - addOptionalParams(url, settings); - return url.href.length; - }; - - if (urlLengthFor(codePoints.length) <= MAX_URL_CHARS) { - return prompt; - } - - let low = 0; - let high = codePoints.length; - while (low < high) { - const mid = Math.floor((low + high + 1) / 2); - if (urlLengthFor(mid) <= MAX_URL_CHARS) { - low = mid; - } else { - high = mid - 1; - } - } - - const kept = codePoints.slice(0, low).join(""); - const boundary = Math.floor(kept.length * WHITESPACE_WINDOW); - const cut = lastWhitespaceIndex(kept); - return cut >= boundary && cut > 0 ? kept.slice(0, cut) : kept; -} - -export function buildChatGptUrl(settings, rawQuery) { - const query = String(rawQuery ?? "").trim(); - const prompt = fitToBudget(applyTemplate(settings.promptTemplate, query), settings); - - // q first, matching the most-cited working examples. Total serialized length - // is order-independent, so measuring without q above is sound. - const url = new URL(BASE); - if (prompt !== "") { - url.searchParams.set("q", prompt); - } - addOptionalParams(url, settings); - return url.href; -} - -// Encode a complete prompt without silently shortening it. +// Encode a complete prompt without silently shortening it; prepareAction owns +// the length budget and asks before shortening. q goes first, matching the +// most-cited working examples. export function serializePrompt(settings, prompt) { const url = new URL(BASE); if (prompt) url.searchParams.set("q", prompt); diff --git a/lib/defaults.js b/lib/defaults.js index fcf5e57..fc8e59c 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -3,6 +3,12 @@ export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; +// In UTF-16 units, as maxlength counts. Keeps the template far inside +// storage.sync's 8192-byte item quota even when every character is a pasted +// control character (six bytes once JSON-escaped), and far beyond any template +// the 8,000-character URL budget could send. +export const MAX_TEMPLATE_CHARS = 1000; + export const DEFAULT_SETTINGS = { contextMenuMode: "simple", showTranslate: false, diff --git a/lib/prepare-action.js b/lib/prepare-action.js index 8726a2c..53ee9f7 100644 --- a/lib/prepare-action.js +++ b/lib/prepare-action.js @@ -25,12 +25,23 @@ export function prepareAction(request) { } const resolved = { ...request, language }; const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text)); - const url = serialize(source); - if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url }; + // Each code point adds at least one URL character, so none past the first + // MAX_URL_CHARS can fit. Capping first keeps a select-all on a huge page from + // costing seconds of re-encoding. A capped source is never sent as ready: its + // kept prefix may trim down to something that fits and silently drop the rest. + const points = []; + let capped = false; + for (const point of source) { + if (points.length === MAX_URL_CHARS) { capped = true; break; } + points.push(point); + } + if (!capped) { + const url = serialize(source); + if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url }; + } if (isUrl) return error('This URL is too long to send intact. No part of it has been sent.'); const marker = '\n[Selected text shortened]'; if (serialize(marker).length >= MAX_URL_CHARS) return error('The prompt instructions are too long. Shorten your prompt template and try again.'); - const points = Array.from(source); let low = 0, high = points.length; while (low < high) { const mid = Math.ceil((low + high) / 2); diff --git a/lib/settings.js b/lib/settings.js index 978b926..068a1ba 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -1,4 +1,4 @@ -import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; +import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS, OPEN_IN_VALUES } from "./defaults.js"; export function normalizeLanguage(value) { if (typeof value !== "string") return ""; @@ -28,7 +28,7 @@ export function mergeSettings(stored) { if (OPEN_IN_VALUES.includes(stored.openIn)) { merged.openIn = stored.openIn; } - if (typeof stored.promptTemplate === "string") { + if (typeof stored.promptTemplate === "string" && stored.promptTemplate.length <= MAX_TEMPLATE_CHARS) { merged.promptTemplate = stored.promptTemplate; } return merged; diff --git a/options/options.html b/options/options.html index 127b569..0b852e5 100644 --- a/options/options.html +++ b/options/options.html @@ -80,8 +80,8 @@
- -

{query} is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts.

+ +

{query} is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts. Up to 1,000 characters.

diff --git a/test/actions.test.js b/test/actions.test.js index 193ac02..fef2432 100644 --- a/test/actions.test.js +++ b/test/actions.test.js @@ -9,7 +9,6 @@ test('context prioritizes selection over link and excludes unrelated targets', ( assert.deepEqual(actions.getContext({selectionText:' ',linkUrl:'https://example.com'}), {kind:'link',source:'https://example.com'}); assert.equal(actions.getContext({mediaType:'image',pageUrl:'https://example.com'}), null); assert.equal(actions.getContext({editable:true,pageUrl:'https://example.com'}), null); - assert.equal(actions.getContext({selectionText:'secret',inputType:'password'}), null); assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page'); }); test('selection actions and optional translation reflect settings', () => { diff --git a/test/build-url.test.js b/test/build-url.test.js index 667ef4c..cf513ac 100644 --- a/test/build-url.test.js +++ b/test/build-url.test.js @@ -2,168 +2,86 @@ import test from "node:test"; import assert from "node:assert/strict"; import { DEFAULT_SETTINGS } from "../lib/defaults.js"; -import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js"; +import { applyTemplate, serializePrompt } from "../lib/build-url.js"; const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); const queryOf = (url) => new URL(url).searchParams.get("q"); const paramsOf = (url) => [...new URL(url).searchParams.keys()]; test("defaults produce only the q parameter on the root path", () => { - assert.equal( - buildChatGptUrl(DEFAULT_SETTINGS, "hello"), - "https://chatgpt.com/?q=hello", - ); + assert.equal(serializePrompt(DEFAULT_SETTINGS, "hello"), "https://chatgpt.com/?q=hello"); }); -test("an empty or whitespace-only query omits q entirely", () => { - for (const raw of ["", " ", "\n\t", null, undefined]) { - const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); - assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`); - assert.equal(url, "https://chatgpt.com/"); +test("an empty prompt omits q entirely", () => { + assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/"); + for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) { + assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`); } }); test("legacy model values never add a model URL parameter", () => { - assert.equal(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"), - buildChatGptUrl(DEFAULT_SETTINGS, "hi")); + assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"), + serializePrompt(DEFAULT_SETTINGS, "hi")); }); test("temporaryChat and webSearch each appear only when enabled", () => { - assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false); - assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false); + assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]); - const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi")); + const temp = new URL(serializePrompt(withSettings({ temporaryChat: true }), "hi")); assert.equal(temp.searchParams.get("temporary-chat"), "true"); - const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi")); + const search = new URL(serializePrompt(withSettings({ webSearch: true }), "hi")); assert.equal(search.searchParams.get("hints"), "search"); }); -test("all three URL-affecting settings combine, q first", () => { - const url = buildChatGptUrl( - withSettings({ - temporaryChat: true, - webSearch: true, - promptTemplate: "Explain: {query}", - }), - "entropy", - ); +test("all URL-affecting settings combine, q first", () => { + const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" }); + const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy")); assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]); assert.equal(queryOf(url), "Explain: entropy"); }); test("openIn never influences the URL", () => { - const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi"); + const base = serializePrompt(DEFAULT_SETTINGS, "hi"); for (const openIn of ["new-tab", "background-tab", "new-window"]) { - assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base); + assert.equal(serializePrompt(withSettings({ openIn }), "hi"), base); } }); test("special characters survive encoding round-trip", () => { const raw = 'a & b # c ? d = e + f "g" 100% 😀 日本語\nnewline'; - const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + const url = serializePrompt(DEFAULT_SETTINGS, raw); assert.equal(queryOf(url), raw); assert.equal(url.includes(" "), false); assert.equal(url.includes("#"), false); }); test("the template replaces every {query} occurrence", () => { - const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x"); - assert.equal(queryOf(url), "x / x"); + assert.equal(applyTemplate("{query} / {query}", "x"), "x / x"); }); test("a template without {query} is treated as a prefix, never dropping the query", () => { - const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why"); - assert.equal(queryOf(url), "Be concise: why"); + assert.equal(applyTemplate("Be concise:", "why"), "Be concise: why"); }); test("an empty or whitespace-only template yields the bare query", () => { - for (const promptTemplate of ["", " "]) { - assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why"); + for (const template of ["", " "]) { + assert.equal(applyTemplate(template, "why"), "why"); } }); test("a non-string template degrades to the bare query", () => { - assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why"); -}); - -test("an over-long prompt is truncated to fit the URL budget", () => { - const url = buildChatGptUrl(DEFAULT_SETTINGS, "x".repeat(20000)); - assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); - assert.ok(queryOf(url).length > 0); - assert.ok(queryOf(url).length < 20000); -}); - -test("truncation accounts for the other parameters", () => { - const settings = withSettings({ temporaryChat: true, webSearch: true }); - const url = buildChatGptUrl(settings, "x".repeat(20000)); - assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`); - assert.equal(new URL(url).searchParams.get("hints"), "search"); -}); - -test("truncation never splits a surrogate pair", () => { - const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀".repeat(9000)); - assert.ok(url.length <= MAX_URL_CHARS); - const lonelySurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { - const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀 ".repeat(9000)); - assert.equal(/%[0-9A-Fa-f]?$/.test(url), false); - assert.doesNotThrow(() => decodeURIComponent(new URL(url).search.slice(3))); -}); - -test("truncation prefers a whitespace boundary, so no word is cut in half", () => { - const url = buildChatGptUrl(DEFAULT_SETTINGS, "lorem ipsum ".repeat(2000)); - const tokens = queryOf(url).trim().split(/\s+/); - assert.ok(tokens.length > 1); - for (const token of tokens) { - assert.ok(token === "lorem" || token === "ipsum", `truncated mid-word: ${token}`); - } -}); - -test("a query needing no truncation is left byte-identical", () => { - const raw = "a modest question about thermodynamics"; - assert.equal(queryOf(buildChatGptUrl(DEFAULT_SETTINGS, raw)), raw); + assert.equal(applyTemplate(null, "why"), "why"); }); // String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the // REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly // that argument. These selections are ordinary shell one-liners, not // adversarial input, and must round-trip byte-identically into `q`. -test("a selection containing $& survives the default {query} template byte-identically", () => { - const raw = "awk print $& here"; - const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); - assert.equal(queryOf(url), raw); -}); - -test("a selection containing $& survives a prefix template byte-identically", () => { - const raw = "awk print $& here"; - const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw); - assert.equal(queryOf(url), `Explain: ${raw}`); -}); - -test("a selection containing $` and $' survives the default {query} template byte-identically", () => { - const raw = "shell $` and $' here"; - const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); - assert.equal(queryOf(url), raw); -}); - -test("a selection containing $` and $' survives a prefix template byte-identically", () => { - const raw = "shell $` and $' here"; - const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw); - assert.equal(queryOf(url), `Explain: ${raw}`); -}); - -test("a selection containing $$ survives the default {query} template byte-identically", () => { - const raw = "PID is $$ in bash"; - const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); - assert.equal(queryOf(url), raw); -}); - -test("a selection containing $$ survives a prefix template byte-identically", () => { - const raw = "PID is $$ in bash"; - const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw); - assert.equal(queryOf(url), `Explain: ${raw}`); -}); +for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) { + test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => { + assert.equal(applyTemplate("{query}", raw), raw); + assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${raw}`); + assert.equal(queryOf(serializePrompt(DEFAULT_SETTINGS, applyTemplate("{query}", raw))), raw); + }); +} diff --git a/test/prepare-action.test.js b/test/prepare-action.test.js index 840b339..bd496a9 100644 --- a/test/prepare-action.test.js +++ b/test/prepare-action.test.js @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +import { MAX_URL_CHARS } from '../lib/build-url.js'; const { prepareAction } = await import('../lib/prepare-action.js').catch(()=>({})); const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides}); test('ready actions preserve source and hints, translation asks only when needed', () => { @@ -23,6 +24,17 @@ test('URLs are validated and preserved whole, never shortened', () => { assert.equal(out.kind,'ready'); assert.ok(new URL(out.url).searchParams.get('q').endsWith(source)); }); +test('shortened selections end on a whole word and decode cleanly', () => { + assert.equal(typeof prepareAction, 'function'); + const out=prepareAction(req({source:'lorem ipsum '.repeat(2000)})); + assert.equal(out.kind,'shortened'); + const words=new URL(out.url).searchParams.get('q').replace('[Selected text shortened]','').trim().split(/\s+/); + assert.ok(words.length>1); + for (const word of words) assert.ok(word==='lorem'||word==='ipsum',`cut mid-word: ${word}`); + const emoji=prepareAction(req({source:'😀 '.repeat(9000)})); + assert.equal(emoji.kind,'shortened'); + assert.doesNotThrow(()=>decodeURIComponent(new URL(emoji.url).search.slice(1))); +}); test('long selections preserve instructions, repetition and Unicode with explicit consent', () => { assert.equal(typeof prepareAction, 'function'); const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true}; @@ -38,3 +50,22 @@ test('long selections preserve instructions, repetition and Unicode with explici const translated=prepareAction(req({actionId:'translate',language:'Spanish',source:'x'.repeat(20000)})); assert.equal(translated.kind,'shortened');assert.match(new URL(translated.url).searchParams.get('q'), /Spanish/); }); +test('huge selections are capped before shortening, with identical output', () => { + assert.equal(typeof prepareAction, 'function'); + // Select-all on a very large page; shortening this took over a second uncapped. + const source='😀 word '.repeat(625000); + const started=performance.now(); + const out=prepareAction(req({source})); + const elapsed=performance.now()-started; + assert.equal(out.kind,'shortened'); + assert.ok(elapsed<250,`took ${elapsed.toFixed(0)} ms`); + const capped=Array.from(source).slice(0,MAX_URL_CHARS).join(''); + assert.deepEqual(prepareAction(req({source:capped})),out); +}); +test('a selection too long to send whole always needs consent, even if its start would fit', () => { + assert.equal(typeof prepareAction, 'function'); + // The first MAX_URL_CHARS code points trim down to "a"; sending just that would drop the rest unasked. + const out=prepareAction(req({source:'a'+' '.repeat(MAX_URL_CHARS)+'b'})); + assert.equal(out.kind,'shortened'); + assert.ok(new URL(out.url).searchParams.get('q').includes('[Selected text shortened]')); +}); diff --git a/test/settings.test.js b/test/settings.test.js index 58b6e13..0f7fb34 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { readFileSync } from "node:fs"; + +import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS } from "../lib/defaults.js"; import { mergeSettings } from "../lib/settings.js"; test("empty object yields the defaults", () => { @@ -67,6 +69,30 @@ test("wrong types fall back to the default", () => { assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); }); +test("a prompt template over the length limit falls back to the default", () => { + assert.equal(typeof MAX_TEMPLATE_CHARS, "number"); + const atLimit = "x".repeat(MAX_TEMPLATE_CHARS); + assert.equal(mergeSettings({ promptTemplate: atLimit }).promptTemplate, atLimit); + assert.equal(mergeSettings({ promptTemplate: `${atLimit}x` }).promptTemplate, + DEFAULT_SETTINGS.promptTemplate); +}); + +// storage.sync rejects any item over 8192 bytes (key plus JSON value), and the +// options page writes every field in one set(), so one oversized template would +// make every later save fail. The worst character is a pasted control +// character, which JSON escapes to six bytes. +test("a template at the limit fits storage.sync's per-item quota in the worst case", () => { + const worst = "\u0001".repeat(MAX_TEMPLATE_CHARS); + const bytes = new TextEncoder().encode("promptTemplate" + JSON.stringify(worst)).length; + assert.ok(bytes <= 8192, `${bytes} bytes`); +}); + +test("the options page enforces the same template limit", () => { + const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8"); + const input = html.match(/]*id="promptTemplate"[^>]*>/)?.[0] ?? ""; + assert.equal(input.match(/maxlength="(\d+)"/)?.[1], String(MAX_TEMPLATE_CHARS)); +}); + test("an out-of-range openIn falls back to the default", () => { assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn);