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
+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);
});