feat: add advanced context actions and optional translation

This commit is contained in:
golem
2026-10-05 15:38:16 -06:00
parent 33c4dad00f
commit 5308932bf1
34 changed files with 1579 additions and 159 deletions
+33
View File
@@ -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/);
});
+6 -12
View File
@@ -23,13 +23,9 @@ test("an empty or whitespace-only query omits q entirely", () => {
}
});
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("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", () => {
@@ -43,17 +39,16 @@ test("temporaryChat and webSearch each appear only when enabled", () => {
assert.equal(search.searchParams.get("hints"), "search");
});
test("all four URL-affecting settings combine, q first", () => {
test("all three 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.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]);
assert.equal(queryOf(url), "Explain: entropy");
});
@@ -100,10 +95,9 @@ test("an over-long prompt is truncated to fit the URL budget", () => {
});
test("truncation accounts for the other parameters", () => {
const settings = withSettings({ model: "some-long-model-slug", temporaryChat: true, webSearch: true });
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("model"), "some-long-model-slug");
assert.equal(new URL(url).searchParams.get("hints"), "search");
});
+56
View File
@@ -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');
});
+37
View File
@@ -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);
});
+50
View File
@@ -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);
});
+14
View File
@@ -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'));
});
+40
View File
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(q),false);
assert.equal(new URL(out.url).searchParams.get('hints'),'search');
assert.equal(prepareAction(req({settings:{...settings,promptTemplate:'x'.repeat(9000)}})).kind,'error');
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/);
});
+57 -18
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DEFAULT_SETTINGS, MAX_MODEL_CHARS } from "../lib/defaults.js";
import { DEFAULT_SETTINGS } from "../lib/defaults.js";
import { mergeSettings } from "../lib/settings.js";
test("empty object yields the defaults", () => {
@@ -21,14 +21,15 @@ test("a non-object yields the defaults", () => {
test("valid stored values are preserved", () => {
assert.deepEqual(
mergeSettings({
model: "auto",
showContextMenu: false,
temporaryChat: true,
webSearch: true,
openIn: "new-window",
promptTemplate: "Explain: {query}",
}),
{
model: "auto",
...DEFAULT_SETTINGS,
contextMenuMode: "off",
temporaryChat: true,
webSearch: true,
openIn: "new-window",
@@ -38,8 +39,9 @@ test("valid stored values are preserved", () => {
});
test("missing keys fall back individually", () => {
const merged = mergeSettings({ model: "auto" });
assert.equal(merged.model, "auto");
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);
});
@@ -52,12 +54,14 @@ test("unknown keys are dropped", () => {
test("wrong types fall back to the default", () => {
const merged = mergeSettings({
showContextMenu: "false",
model: 123,
temporaryChat: "yes",
webSearch: 1,
promptTemplate: [],
});
assert.equal(merged.model, DEFAULT_SETTINGS.model);
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);
@@ -75,20 +79,55 @@ test("each documented openIn value is accepted", () => {
});
test("mergeSettings does not mutate DEFAULT_SETTINGS", () => {
mergeSettings({ model: "mutated" });
assert.equal(DEFAULT_SETTINGS.model, "");
mergeSettings({ openIn: "new-window" });
assert.equal(DEFAULT_SETTINGS.openIn, "new-tab");
});
// An unbounded model string can, on its own, push the finished URL over
// MAX_URL_CHARS (lib/build-url.js), which silently drops `q` — the user's
// selection vanishes with no signal. mergeSettings() must bound `model` the
// same way it already bounds an out-of-range openIn: fall back to the default.
test("a model at exactly MAX_MODEL_CHARS is accepted", () => {
const model = "m".repeat(MAX_MODEL_CHARS);
assert.equal(mergeSettings({ model }).model, model);
test("previously saved model selections are ignored", () => {
assert.deepEqual(mergeSettings({ model: "gpt-5.6-sol" }), DEFAULT_SETTINGS);
});
test("a model one character over MAX_MODEL_CHARS falls back to the default", () => {
const model = "m".repeat(MAX_MODEL_CHARS + 1);
assert.equal(mergeSettings({ model }).model, DEFAULT_SETTINGS.model);
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; }
});
+30
View File
@@ -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;}
});
+82
View File
@@ -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/);
});