From c49f6415affba1c7376414def16e20e4872c8657 Mon Sep 17 00:00:00 2001 From: golem Date: Sat, 1 Aug 2026 21:59:47 -0600 Subject: [PATCH] feat: add settings defaults and validation with unit tests --- lib/defaults.js | 13 +++++++ lib/settings.js | 45 ++++++++++++++++++++++++ package.json | 6 ++-- test/settings.test.js | 80 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 lib/defaults.js create mode 100644 lib/settings.js create mode 100644 test/settings.test.js diff --git a/lib/defaults.js b/lib/defaults.js new file mode 100644 index 0000000..c7e67f7 --- /dev/null +++ b/lib/defaults.js @@ -0,0 +1,13 @@ +// 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}", +}; diff --git a/lib/settings.js b/lib/settings.js new file mode 100644 index 0000000..d3d4856 --- /dev/null +++ b/lib/settings.js @@ -0,0 +1,45 @@ +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); +} 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/settings.test.js b/test/settings.test.js new file mode 100644 index 0000000..f514e57 --- /dev/null +++ b/test/settings.test.js @@ -0,0 +1,80 @@ +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, ""); +});