storage.sync rejects any item over 8192 bytes, and the options page writes every field in one set(). An oversized template therefore made every later save fail, including changes to unrelated settings, with only a brief "Could not save settings". Cap the template at 1,000 UTF-16 units: maxlength on the input, and mergeSettings falls back to the default for a longer stored value, as it does for other out-of-range settings. Even a template of pasted control characters (six bytes each once JSON-escaped) stays under the quota; a test pins that arithmetic and another ties the HTML maxlength to the constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
6.6 KiB
JavaScript
160 lines
6.6 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
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", () => {
|
|
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("a prompt template over the length limit falls back to the default", () => {
|
|
assert.equal(typeof MAX_TEMPLATE_CHARS, "number");
|
|
const atLimit = "x".repeat(MAX_TEMPLATE_CHARS);
|
|
assert.equal(mergeSettings({ promptTemplate: atLimit }).promptTemplate, atLimit);
|
|
assert.equal(mergeSettings({ promptTemplate: `${atLimit}x` }).promptTemplate,
|
|
DEFAULT_SETTINGS.promptTemplate);
|
|
});
|
|
|
|
// storage.sync rejects any item over 8192 bytes (key plus JSON value), and the
|
|
// options page writes every field in one set(), so one oversized template would
|
|
// make every later save fail. The worst character is a pasted control
|
|
// character, which JSON escapes to six bytes.
|
|
test("a template at the limit fits storage.sync's per-item quota in the worst case", () => {
|
|
const worst = "\u0001".repeat(MAX_TEMPLATE_CHARS);
|
|
const bytes = new TextEncoder().encode("promptTemplate" + JSON.stringify(worst)).length;
|
|
assert.ok(bytes <= 8192, `${bytes} bytes`);
|
|
});
|
|
|
|
test("the options page enforces the same template limit", () => {
|
|
const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8");
|
|
const input = html.match(/<input[^>]*id="promptTemplate"[^>]*>/)?.[0] ?? "";
|
|
assert.equal(input.match(/maxlength="(\d+)"/)?.[1], String(MAX_TEMPLATE_CHARS));
|
|
});
|
|
|
|
test("an out-of-range openIn falls back to the default", () => {
|
|
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; }
|
|
});
|