* Add scoped extension Configure hook * docs: fail-closed optional chaining in Configure consumer example + old-Core regression The documented consumer used ext?.settings.registerConfigure(...), which throws TypeError on an older E0 Core where settings exists but registerConfigure does not. Use full optional chaining (ext?.settings?.registerConfigure?.()) and add a Node regression proving the consumer survives a Core lacking the capability. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> --------- Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: n <a@n> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
7 files changed
+695
-23
No files matched your search
@@ -509,6 +509,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **Extensions can register a native "Configure" entry point under Settings → Extensions → Installed.** For complex editors that don't fit scalar `settings_schema` fields, a boot-trusted extension can call `ext.settings.registerConfigure(handler)` on the E0 handle to add one native Configure button for its current effective-enabled Installed row, while keeping the editor UI, validation, state, and persistence fully extension-owned. Registration is trust-gated and quarantine-aware, the first handler per extension wins (duplicates return `null`), and it returns an idempotent unregister; the button never appears in Diagnostics and is removed immediately on disable/uninstall. Thanks @franksong2702. (#7111)
|
||||
|
||||
- **Each settled assistant turn's footer now shows the model that actually served it.** In transparent-stream mode the turn footer gains a compact model chip (e.g. `8s · claude-opus-4-8 · TTFT 640ms · …`), read *after* the turn completes so a mid-turn fallback shows the real model that answered rather than the one originally requested. The label renders exactly once per turn — a gateway/failover turn keeps its existing routing chip and the additive chip is suppressed — and the footer wraps gracefully at narrow widths and on mobile instead of stretching. The used-model is persisted with the session so it survives a reload. Thanks @franksong2702. (#6113, #6068)
|
||||
|
||||
### Performance
|
||||
|
||||
@@ -300,6 +300,48 @@ sandbox, isolation mechanism, capability or permission system, or security
|
||||
boundary. Extensions still execute with the full WebUI session authority
|
||||
described above.
|
||||
|
||||
### Custom Configure editors
|
||||
|
||||
Extensions with structured configuration that does not fit scalar
|
||||
`settings_schema` fields can register one custom editor entry point on their
|
||||
scoped E0 settings handle:
|
||||
|
||||
```js
|
||||
const ext = window.hermesExt?.register?.("dictionary-manager");
|
||||
const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => {
|
||||
openDictionaryManager({ opener, restoreFocus });
|
||||
});
|
||||
```
|
||||
|
||||
`registerConfigure(handler)` is available only through a valid boot-trusted E0
|
||||
handle. It does not require `settings_schema` or extension-owned storage. The
|
||||
first handler registered for an extension wins; duplicates return `null` and do
|
||||
not replace it. A successful registration returns an idempotent unregister
|
||||
function.
|
||||
|
||||
Core shows one **Configure** button only for the extension's current
|
||||
effective-enabled row under **Settings → Extensions → Installed**. Diagnostics
|
||||
never shows the button. Late registration updates an already-mounted Installed
|
||||
row. Disable or uninstall status removes the entry point immediately; uninstall
|
||||
followed by a same-ID reinstall cannot revive the old page-local handler until a
|
||||
full WebUI reload injects and registers the new extension script.
|
||||
|
||||
Core enters a pending state before invoking the handler and passes the activated
|
||||
button as `opener` plus a one-shot `restoreFocus()` callback. The extension owns
|
||||
its dialog, validation, persistence, and close lifecycle. It must either call
|
||||
`restoreFocus()` when its UI closes or return a thenable whose settlement means
|
||||
the Configure UI is closed. Both paths end pending and converge on Core's
|
||||
one-shot focus restoration. A synchronous non-thenable handler remains disabled
|
||||
while unsettled. If it never calls `restoreFocus()`, it remains disabled until reload.
|
||||
Core does not guess dialog lifetime with a timeout.
|
||||
|
||||
Synchronous throws, thenable-access failures, and asynchronous rejections are
|
||||
isolated, logged with the extension ID, and surfaced as a generic failure. Focus
|
||||
returns to the connected opener when possible, otherwise to the current visible
|
||||
Configure button or the Installed tab without forcing navigation. The hook does
|
||||
not let an extension return DOM for Core to render, add a backend settings route,
|
||||
or change the trusted same-origin extension model.
|
||||
|
||||
### Turn lifecycle events
|
||||
|
||||
A registered extension can react when a session turn observed by the current page
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
const registrations=new Map();
|
||||
const turnLifecycleListeners=new Map();
|
||||
const turnLifecycleStates=new Map();
|
||||
const configureRegistrations=new Map();
|
||||
const configureQuarantinedIds=new Set();
|
||||
const configureChangeListeners=new Set();
|
||||
const currentExtensionStatus=new Map();
|
||||
let trustedSeeded=false;
|
||||
let extensionStatusSeeded=false;
|
||||
|
||||
function extensionId(value){
|
||||
return String(value||'').trim();
|
||||
@@ -116,6 +121,7 @@
|
||||
name:text(entry&&entry.name,id),
|
||||
storage_owned:storageOwned,
|
||||
settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[],
|
||||
effective_enabled:!(entry&&entry.effective_enabled===false),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
@@ -135,6 +141,20 @@
|
||||
}
|
||||
trustedSeeded=true;
|
||||
}
|
||||
const previousStatus=new Map(currentExtensionStatus);
|
||||
const nextIds=new Set(entries.map(entry=>entry.id));
|
||||
if(extensionStatusSeeded){
|
||||
for(const id of previousStatus.keys()){
|
||||
if(nextIds.has(id)) continue;
|
||||
configureQuarantinedIds.add(id);
|
||||
notifyConfigureChange(id,'quarantine');
|
||||
}
|
||||
}
|
||||
currentExtensionStatus.clear();
|
||||
for(const entry of entries){
|
||||
currentExtensionStatus.set(entry.id,{effective_enabled:entry.effective_enabled===true});
|
||||
}
|
||||
extensionStatusSeeded=true;
|
||||
schemas.clear();
|
||||
for(const entry of entries){
|
||||
const trusted=trustedExtensions.get(entry.id);
|
||||
@@ -146,6 +166,14 @@
|
||||
settings_schema:trusted.storage_owned===true?trusted.settings_schema:[],
|
||||
});
|
||||
}
|
||||
for(const [id,record] of configureRegistrations){
|
||||
if(!record||!record.active) continue;
|
||||
const before=previousStatus.get(id);
|
||||
const after=currentExtensionStatus.get(id);
|
||||
if(!before||!after||before.effective_enabled!==after.effective_enabled){
|
||||
notifyConfigureChange(id,'status');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadState(key){
|
||||
@@ -233,7 +261,7 @@
|
||||
return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length);
|
||||
}
|
||||
|
||||
function settingsAccessor(clean,meta,isTrusted){
|
||||
function settingsAccessor(clean,meta,isTrusted,allowConfigureRegistration){
|
||||
const schema=supportsSettings(meta)?meta.settings_schema:[];
|
||||
const key=settingsKey(clean);
|
||||
function current(){
|
||||
@@ -249,7 +277,7 @@
|
||||
const saved=safeWrite(key,overridesFromValues(schema,checked.values));
|
||||
return {ok:saved,values:checked.values,errors:saved?{}:{storage:'unavailable'}};
|
||||
}
|
||||
return {
|
||||
const accessor={
|
||||
extensionId:clean,
|
||||
trusted:isTrusted,
|
||||
storageOwned:!!meta.storage_owned,
|
||||
@@ -278,6 +306,10 @@
|
||||
return true;
|
||||
},
|
||||
};
|
||||
if(allowConfigureRegistration===true){
|
||||
accessor.registerConfigure=handler=>registerConfigureHandler(clean,handler);
|
||||
}
|
||||
return accessor;
|
||||
}
|
||||
|
||||
function settingsForExtension(id){
|
||||
@@ -350,6 +382,165 @@
|
||||
});
|
||||
}
|
||||
|
||||
function notifyConfigureChange(id,reason){
|
||||
const change=Object.freeze({id,reason});
|
||||
for(const listener of [...configureChangeListeners]){
|
||||
try{
|
||||
listener(change);
|
||||
}catch(error){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{console.error('[Hermes extensions] Configure change listener failed:',error);}catch(_loggingError){}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onConfigureChange(listener){
|
||||
if(typeof listener!=='function') return null;
|
||||
configureChangeListeners.add(listener);
|
||||
let active=true;
|
||||
return function unsubscribe(){
|
||||
if(!active) return false;
|
||||
active=false;
|
||||
configureChangeListeners.delete(listener);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function registerConfigureHandler(clean,handler){
|
||||
if(typeof handler!=='function'||!trustedExtensions.has(clean)||configureQuarantinedIds.has(clean)) return null;
|
||||
if(configureRegistrations.has(clean)) return null;
|
||||
const record={handler,pending:false,active:true};
|
||||
configureRegistrations.set(clean,record);
|
||||
notifyConfigureChange(clean,'registration');
|
||||
let active=true;
|
||||
return function unregister(){
|
||||
if(!active) return false;
|
||||
active=false;
|
||||
record.active=false;
|
||||
if(configureRegistrations.get(clean)===record) configureRegistrations.delete(clean);
|
||||
notifyConfigureChange(clean,'registration');
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function configureStateForExtension(id){
|
||||
const clean=extensionId(id);
|
||||
const record=configureRegistrations.get(clean);
|
||||
const status=currentExtensionStatus.get(clean);
|
||||
const available=!!(
|
||||
clean&&record&&record.active&&!configureQuarantinedIds.has(clean)
|
||||
&&status&&status.effective_enabled===true
|
||||
);
|
||||
return {available,pending:available&&record.pending===true};
|
||||
}
|
||||
|
||||
function focusableConfigureTarget(node){
|
||||
if(!node||typeof node.focus!=='function'||node.isConnected===false||node.hidden===true||node.disabled===true) return false;
|
||||
if(typeof node.getAttribute==='function'&&node.getAttribute('aria-disabled')==='true') return false;
|
||||
if(typeof node.closest==='function'&&node.closest('[hidden]')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusConfigureTarget(clean,opener){
|
||||
const candidates=[];
|
||||
if(focusableConfigureTarget(opener)) candidates.push(opener);
|
||||
if(typeof document!=='undefined'&&document){
|
||||
if(typeof document.querySelectorAll==='function'){
|
||||
const buttons=document.querySelectorAll('[data-extension-configure-id]');
|
||||
for(const button of buttons){
|
||||
if(button&&button.dataset&&button.dataset.extensionConfigureId===clean&&focusableConfigureTarget(button)){
|
||||
candidates.push(button);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(typeof document.querySelector==='function'){
|
||||
const installedTab=document.querySelector('[data-extensions-tab="installed"]');
|
||||
if(focusableConfigureTarget(installedTab)) candidates.push(installedTab);
|
||||
}
|
||||
}
|
||||
for(const candidate of candidates){
|
||||
try{
|
||||
candidate.focus({preventScroll:true});
|
||||
return true;
|
||||
}catch(_focusOptionsError){
|
||||
try{
|
||||
candidate.focus();
|
||||
return true;
|
||||
}catch(_focusError){}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function reportConfigureFailure(clean,error,onError){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{console.error(`[Hermes extensions] ${clean} Configure handler failed:`,error);}catch(_loggingError){}
|
||||
}
|
||||
if(typeof onError==='function'){
|
||||
try{onError(error);}catch(callbackError){
|
||||
if(typeof console!=='undefined'&&typeof console.error==='function'){
|
||||
try{console.error(`[Hermes extensions] ${clean} Configure failure reporter failed:`,callbackError);}catch(_loggingError){}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function invokeConfigure(id,options){
|
||||
const clean=extensionId(id);
|
||||
const state=configureStateForExtension(clean);
|
||||
const record=configureRegistrations.get(clean);
|
||||
if(!state.available||state.pending||!record) return false;
|
||||
const opener=options&&options.opener;
|
||||
const onError=options&&options.onError;
|
||||
let settled=false;
|
||||
let failureReported=false;
|
||||
record.pending=true;
|
||||
notifyConfigureChange(clean,'pending');
|
||||
|
||||
function settle(){
|
||||
if(settled) return false;
|
||||
settled=true;
|
||||
record.pending=false;
|
||||
notifyConfigureChange(clean,'pending');
|
||||
focusConfigureTarget(clean,opener);
|
||||
return true;
|
||||
}
|
||||
|
||||
function fail(error){
|
||||
if(!failureReported){
|
||||
failureReported=true;
|
||||
reportConfigureFailure(clean,error,onError);
|
||||
}
|
||||
settle();
|
||||
}
|
||||
|
||||
let result;
|
||||
try{
|
||||
result=record.handler(Object.freeze({opener,restoreFocus:settle}));
|
||||
}catch(error){
|
||||
fail(error);
|
||||
return true;
|
||||
}
|
||||
|
||||
let then;
|
||||
try{
|
||||
then=result!==null&&(typeof result==='object'||typeof result==='function')?result.then:null;
|
||||
}catch(error){
|
||||
fail(error);
|
||||
return true;
|
||||
}
|
||||
if(typeof then==='function'){
|
||||
try{
|
||||
then.call(result,settle,fail);
|
||||
}catch(error){
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function turnLifecycleKey(sessionId,streamId){
|
||||
return `${sessionId}\u0000${streamId}`;
|
||||
}
|
||||
@@ -416,7 +607,7 @@
|
||||
if(!trusted) return null;
|
||||
const handle=Object.freeze({
|
||||
id:clean,
|
||||
settings:settingsAccessor(clean,trusted,true),
|
||||
settings:settingsAccessor(clean,trusted,true,true),
|
||||
storage:storageAccessor(clean,trusted),
|
||||
events:eventAccessor(clean),
|
||||
});
|
||||
@@ -431,6 +622,9 @@
|
||||
settingsForExtension,
|
||||
storageForExtension,
|
||||
_dispatchTurnLifecycle:dispatchTurnLifecycle,
|
||||
_configureStateForExtension:configureStateForExtension,
|
||||
_invokeConfigure:invokeConfigure,
|
||||
_onConfigureChange:onConfigureChange,
|
||||
resetSettingsForExtension(id){return settingsForExtension(id).reset();},
|
||||
clearStorageForExtension(id){return storageForExtension(id).clear();},
|
||||
};
|
||||
|
||||
+80
-14
@@ -9881,7 +9881,18 @@ function _extensionSettingsControls(entry){
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _extensionInstalledList(extensions,extensionDirConfigured){
|
||||
function _extensionConfigureButton(entry,surface){
|
||||
if(surface!=='installed'||!(entry&&entry.effective_enabled)) return '';
|
||||
const id=(entry&&entry.id)||'';
|
||||
const runtime=window.HermesExtensionSettings;
|
||||
if(!id||!runtime||typeof runtime._configureStateForExtension!=='function') return '';
|
||||
const state=runtime._configureStateForExtension(id);
|
||||
if(!state||!state.available) return '';
|
||||
const pending=state.pending===true;
|
||||
return `<button class="sm-btn extension-configure-btn" type="button" data-extension-configure-id="${esc(id)}" aria-busy="${pending?'true':'false'}"${pending?' disabled':''}>${pending?'Opening…':'Configure'}</button>`;
|
||||
}
|
||||
|
||||
function _extensionInstalledList(extensions,extensionDirConfigured,surface){
|
||||
const list=Array.isArray(extensions)?extensions:[];
|
||||
if(!list.length){
|
||||
if(!extensionDirConfigured) return '<div class="extension-url-empty">No extension directory is configured.</div>';
|
||||
@@ -9898,6 +9909,7 @@ function _extensionInstalledList(extensions,extensionDirConfigured){
|
||||
const note=canToggle
|
||||
? 'Toggles the WebUI-managed override for the next app load.'
|
||||
: 'Manifest-disabled entries cannot be enabled from WebUI.';
|
||||
const configureButton=_extensionConfigureButton(entry,surface);
|
||||
return `<div class="extension-installed-row" data-extension-id="${esc(id)}">
|
||||
<div class="extension-installed-main">
|
||||
<div class="extension-installed-title-row">
|
||||
@@ -9907,7 +9919,10 @@ function _extensionInstalledList(extensions,extensionDirConfigured){
|
||||
<div class="extension-installed-meta"><code>${esc(id)}</code><span>${esc(note)}</span></div>
|
||||
${_extensionSettingsControls(entry)}
|
||||
</div>
|
||||
<button class="sm-btn extension-toggle-btn" type="button" data-extension-toggle-id="${esc(id)}" data-extension-next-enabled="${nextEnabled}"${disabledAttr}>${esc(buttonText)}</button>
|
||||
<div class="extension-installed-actions">
|
||||
${configureButton}
|
||||
<button class="sm-btn extension-toggle-btn" type="button" data-extension-toggle-id="${esc(id)}" data-extension-next-enabled="${nextEnabled}"${disabledAttr}>${esc(buttonText)}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
@@ -10115,6 +10130,7 @@ function _renderExtensionsPanel(data,seq){
|
||||
const copyBtn=$('extensionsCopyDiagnosticsBtn');
|
||||
if(!target) return;
|
||||
_extensionsStatusData=data||null;
|
||||
if(_extensionsGalleryData) _extensionsGalleryData.statusData=data||null;
|
||||
_configureExtensionSettingsFromStatus(data);
|
||||
if(copyBtn) copyBtn.disabled=!data;
|
||||
const manifest=(data&&data.manifest)||{};
|
||||
@@ -10165,7 +10181,7 @@ function _renderExtensionsPanel(data,seq){
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card-body extension-card-body">
|
||||
${_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured))}
|
||||
${_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card extension-assets-card">
|
||||
@@ -10201,6 +10217,37 @@ function _renderExtensionsPanel(data,seq){
|
||||
_monitorExtensionSidecars(sidecars,seq);
|
||||
}
|
||||
|
||||
function _bindExtensionConfigureButtons(root){
|
||||
if(!root) return;
|
||||
root.querySelectorAll('[data-extension-configure-id]').forEach(btn=>{
|
||||
btn.addEventListener('click',()=>handleExtensionConfigure(btn));
|
||||
});
|
||||
}
|
||||
|
||||
function _syncExtensionConfigureButtonState(id){
|
||||
const runtime=window.HermesExtensionSettings;
|
||||
if(!runtime||typeof runtime._configureStateForExtension!=='function') return;
|
||||
const state=runtime._configureStateForExtension(id);
|
||||
document.querySelectorAll('[data-extension-configure-id]').forEach(btn=>{
|
||||
if(!btn.dataset||btn.dataset.extensionConfigureId!==id) return;
|
||||
const pending=!!(state&&state.available&&state.pending);
|
||||
btn.disabled=pending;
|
||||
btn.setAttribute('aria-busy',pending?'true':'false');
|
||||
btn.textContent=pending?'Opening…':'Configure';
|
||||
});
|
||||
}
|
||||
|
||||
function handleExtensionConfigure(btn){
|
||||
if(!btn||btn.disabled) return;
|
||||
const id=btn.dataset.extensionConfigureId||'';
|
||||
const runtime=window.HermesExtensionSettings;
|
||||
if(!id||!runtime||typeof runtime._invokeConfigure!=='function') return;
|
||||
runtime._invokeConfigure(id,{
|
||||
opener:btn,
|
||||
onError:()=>showToast('Extension configuration failed.',4200,'error'),
|
||||
});
|
||||
}
|
||||
|
||||
function _bindExtensionToggleButtons(root){
|
||||
if(!root) return;
|
||||
root.querySelectorAll('[data-extension-toggle-id]').forEach(btn=>{
|
||||
@@ -10362,6 +10409,21 @@ function switchExtensionsTab(tab){
|
||||
if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
|
||||
}
|
||||
|
||||
function _handleExtensionConfigureChange(change){
|
||||
if(!change||!change.id) return;
|
||||
if(change.reason==='pending'){
|
||||
_syncExtensionConfigureButtonState(change.id);
|
||||
return;
|
||||
}
|
||||
if(_extensionsGalleryData&&_extensionsGalleryData.statusData){
|
||||
_renderInstalledExtensionsSurface(_extensionsGalleryData.statusData);
|
||||
}
|
||||
}
|
||||
|
||||
if(window.HermesExtensionSettings&&typeof window.HermesExtensionSettings._onConfigureChange==='function'){
|
||||
window.HermesExtensionSettings._onConfigureChange(_handleExtensionConfigureChange);
|
||||
}
|
||||
|
||||
function _extensionSafeHttpUrl(value){
|
||||
if(!value) return '';
|
||||
const raw=String(value).trim();
|
||||
@@ -10517,6 +10579,19 @@ function _extensionPostInstallNote(entry,isInstalled){
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderInstalledExtensionsSurface(statusData){
|
||||
const installedEl=$('extensionsInstalled');
|
||||
if(!installedEl) return;
|
||||
installedEl.innerHTML=_extensionInstalledList(
|
||||
statusData&&statusData.extensions,
|
||||
!!(statusData&&statusData.extension_dir_configured),
|
||||
'installed'
|
||||
);
|
||||
_bindExtensionToggleButtons(installedEl);
|
||||
_bindExtensionSettingsButtons(installedEl);
|
||||
_bindExtensionConfigureButtons(installedEl);
|
||||
}
|
||||
|
||||
async function loadExtensionsGallery(){
|
||||
_extensionsGalleryLoaded=true;
|
||||
const galleryEl=$('extensionsGallery');
|
||||
@@ -10540,7 +10615,6 @@ async function loadExtensionsGallery(){
|
||||
|
||||
function _renderExtensionsGallery(entries,statusData){
|
||||
const galleryEl=$('extensionsGallery');
|
||||
const installedEl=$('extensionsInstalled');
|
||||
_configureExtensionSettingsFromStatus(statusData);
|
||||
const installedIds=new Set();
|
||||
if(statusData&&statusData.gallery_installed){
|
||||
@@ -10551,11 +10625,7 @@ function _renderExtensionsGallery(entries,statusData){
|
||||
}
|
||||
if(!Array.isArray(entries)||entries.length===0){
|
||||
if(galleryEl) galleryEl.innerHTML='<div class="extensions-empty">No extensions found in the registry.</div>';
|
||||
if(installedEl){
|
||||
installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured));
|
||||
_bindExtensionToggleButtons(installedEl);
|
||||
_bindExtensionSettingsButtons(installedEl);
|
||||
}
|
||||
_renderInstalledExtensionsSurface(statusData);
|
||||
return;
|
||||
}
|
||||
const galleryCards=[];
|
||||
@@ -10599,11 +10669,7 @@ function _renderExtensionsGallery(entries,statusData){
|
||||
galleryCards.push(card);
|
||||
}
|
||||
if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'<div class="extensions-empty">No extensions found.</div>';
|
||||
if(installedEl){
|
||||
installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured));
|
||||
_bindExtensionToggleButtons(installedEl);
|
||||
_bindExtensionSettingsButtons(installedEl);
|
||||
}
|
||||
_renderInstalledExtensionsSurface(statusData);
|
||||
_bindExtensionGalleryButtons(entries);
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -5491,8 +5491,9 @@ main.main > #mainPlugin{display:none;}
|
||||
.extension-installed-title{font-size:13px;font-weight:600;color:var(--text);overflow-wrap:anywhere;min-width:0;}
|
||||
.extension-installed-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--muted);}
|
||||
.extension-installed-meta code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;}
|
||||
.extension-toggle-btn{white-space:nowrap;flex:0 0 auto;}
|
||||
.extension-toggle-btn:disabled{opacity:.55;cursor:not-allowed;}
|
||||
.extension-installed-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex:0 0 auto;flex-wrap:wrap;}
|
||||
.extension-toggle-btn,.extension-configure-btn{white-space:nowrap;flex:0 0 auto;}
|
||||
.extension-toggle-btn:disabled,.extension-configure-btn:disabled{opacity:.55;cursor:not-allowed;}
|
||||
.extension-settings-box{margin-top:8px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card-bg,var(--sidebar-bg));}
|
||||
.extension-settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:8px;}
|
||||
.extension-settings-title{font-size:12px;font-weight:700;color:var(--text);}
|
||||
@@ -5565,7 +5566,8 @@ main.main > #mainPlugin{display:none;}
|
||||
.extensions-copy-diagnostics-btn{width:100%;}
|
||||
.extension-summary-grid{grid-template-columns:1fr;}
|
||||
.extension-installed-row{align-items:flex-start;flex-direction:column;}
|
||||
.extension-toggle-btn{width:100%;}
|
||||
.extension-installed-actions{width:100%;}
|
||||
.extension-installed-actions .extension-toggle-btn,.extension-installed-actions .extension-configure-btn{width:auto;flex:1 1 140px;}
|
||||
.extension-sidecar-row-head{align-items:flex-start;flex-direction:column;}
|
||||
.extension-sidecar-fields>div{flex-direction:column;gap:2px;}
|
||||
.extension-sidecar-fields span{flex:0 0 auto;}
|
||||
|
||||
@@ -275,3 +275,255 @@ def test_hermes_ext_register_runtime_identity_and_reload_lifecycle():
|
||||
"""
|
||||
)
|
||||
_run_node(script)
|
||||
|
||||
|
||||
def test_hermes_ext_configure_registration_invocation_and_quarantine():
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
const fs = require('fs');
|
||||
const assert = require('assert');
|
||||
const store = new Map();
|
||||
const diagnostics = [];
|
||||
const changes = [];
|
||||
const replacementButton = {{
|
||||
dataset: {{extensionConfigureId: 'alpha.ext'}},
|
||||
isConnected: true,
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
focusCount: 0,
|
||||
getAttribute(name) {{ return name === 'aria-disabled' ? 'false' : null; }},
|
||||
closest() {{ return null; }},
|
||||
focus() {{ this.focusCount += 1; }},
|
||||
}};
|
||||
const installedTab = {{
|
||||
isConnected: true,
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
focusCount: 0,
|
||||
getAttribute() {{ return null; }},
|
||||
closest() {{ return null; }},
|
||||
focus() {{ this.focusCount += 1; }},
|
||||
}};
|
||||
global.document = {{
|
||||
querySelectorAll(selector) {{
|
||||
return selector === '[data-extension-configure-id]' ? [replacementButton] : [];
|
||||
}},
|
||||
querySelector(selector) {{
|
||||
return selector === '[data-extensions-tab="installed"]' ? installedTab : null;
|
||||
}},
|
||||
}};
|
||||
global.window = {{
|
||||
__HERMES_EXTENSION_CONFIG__: {{
|
||||
extensions: [{{id: 'alpha.ext', name: 'Alpha'}}, {{id: 'beta.ext', name: 'Beta'}}]
|
||||
}},
|
||||
localStorage: {{
|
||||
getItem(key) {{ return store.has(key) ? store.get(key) : null; }},
|
||||
setItem(key, value) {{ store.set(key, String(value)); }},
|
||||
removeItem(key) {{ store.delete(key); }}
|
||||
}}
|
||||
}};
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args) => diagnostics.push(args);
|
||||
|
||||
(async () => {{
|
||||
eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8'));
|
||||
const runtime = window.HermesExtensionSettings;
|
||||
const alpha = window.hermesExt.register('alpha.ext');
|
||||
const beta = window.hermesExt.register('beta.ext');
|
||||
assert.ok(alpha);
|
||||
assert.ok(beta);
|
||||
assert.strictEqual(typeof alpha.settings.registerConfigure, 'function');
|
||||
assert.strictEqual(
|
||||
window.HermesExtensionSettings.settingsForExtension('alpha.ext').registerConfigure,
|
||||
undefined,
|
||||
'legacy global accessors must not gain registration authority'
|
||||
);
|
||||
assert.strictEqual(window.hermesExt.register('unknown.ext'), null);
|
||||
|
||||
const stopChanges = runtime._onConfigureChange(change => changes.push(change));
|
||||
let alphaCalls = 0;
|
||||
let pendingInsideHandler = false;
|
||||
const unregisterAlpha = alpha.settings.registerConfigure(({{opener, restoreFocus}}) => {{
|
||||
alphaCalls += 1;
|
||||
pendingInsideHandler = runtime._configureStateForExtension('alpha.ext').pending;
|
||||
assert.strictEqual(opener.extensionId, 'alpha.ext');
|
||||
restoreFocus();
|
||||
restoreFocus();
|
||||
}});
|
||||
assert.strictEqual(typeof unregisterAlpha, 'function');
|
||||
assert.strictEqual(alpha.settings.registerConfigure(() => {{}}), null);
|
||||
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: true, pending: false}});
|
||||
assert.ok(changes.some(change => change.id === 'alpha.ext' && change.reason === 'registration'));
|
||||
|
||||
const connectedOpener = {{
|
||||
extensionId: 'alpha.ext',
|
||||
isConnected: true,
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
focusCount: 0,
|
||||
getAttribute() {{ return null; }},
|
||||
closest() {{ return null; }},
|
||||
focus() {{ this.focusCount += 1; }},
|
||||
}};
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true);
|
||||
assert.strictEqual(alphaCalls, 1);
|
||||
assert.strictEqual(pendingInsideHandler, true);
|
||||
assert.strictEqual(connectedOpener.focusCount, 1);
|
||||
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: true, pending: false}});
|
||||
|
||||
assert.strictEqual(unregisterAlpha(), true);
|
||||
assert.strictEqual(unregisterAlpha(), false);
|
||||
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}});
|
||||
|
||||
let releasePending;
|
||||
const unregisterPending = alpha.settings.registerConfigure(() => new Promise(resolve => {{ releasePending = resolve; }}));
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true);
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
|
||||
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true);
|
||||
releasePending();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
|
||||
assert.strictEqual(connectedOpener.focusCount, 2);
|
||||
assert.strictEqual(unregisterPending(), true);
|
||||
|
||||
let restoreExplicit;
|
||||
const unregisterExplicit = alpha.settings.registerConfigure(({{restoreFocus}}) => {{
|
||||
restoreExplicit = restoreFocus;
|
||||
}});
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true);
|
||||
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true);
|
||||
assert.strictEqual(restoreExplicit(), true);
|
||||
assert.strictEqual(restoreExplicit(), false);
|
||||
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
|
||||
assert.strictEqual(connectedOpener.focusCount, 3);
|
||||
assert.strictEqual(unregisterExplicit(), true);
|
||||
|
||||
const unregisterMissing = alpha.settings.registerConfigure(() => undefined);
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true);
|
||||
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true);
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
|
||||
assert.strictEqual(connectedOpener.focusCount, 3);
|
||||
assert.strictEqual(unregisterMissing(), true);
|
||||
|
||||
const reported = [];
|
||||
const unregisterThrowing = alpha.settings.registerConfigure(() => {{ throw new Error('sync boom'); }});
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => reported.push(error.message)}}), true);
|
||||
assert.deepStrictEqual(reported, ['sync boom']);
|
||||
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
|
||||
assert.ok(diagnostics.some(args => String(args[0]).includes('alpha.ext') && String(args[0]).includes('Configure handler failed')));
|
||||
assert.strictEqual(unregisterThrowing(), true);
|
||||
|
||||
const rejected = [];
|
||||
const unregisterRejected = alpha.settings.registerConfigure(() => Promise.reject(new Error('async boom')));
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => rejected.push(error.message)}}), true);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.deepStrictEqual(rejected, ['async boom']);
|
||||
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
|
||||
assert.strictEqual(unregisterRejected(), true);
|
||||
|
||||
const assimilated = [];
|
||||
const unregisterBadThenable = alpha.settings.registerConfigure(() => Object.defineProperty({{}}, 'then', {{
|
||||
get() {{ throw new Error('then getter boom'); }}
|
||||
}}));
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => assimilated.push(error.message)}}), true);
|
||||
assert.deepStrictEqual(assimilated, ['then getter boom']);
|
||||
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
|
||||
assert.strictEqual(unregisterBadThenable(), true);
|
||||
|
||||
const detachedOpener = {{
|
||||
extensionId: 'alpha.ext',
|
||||
isConnected: false,
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
focusCount: 0,
|
||||
getAttribute() {{ return null; }},
|
||||
closest() {{ return null; }},
|
||||
focus() {{ this.focusCount += 1; }},
|
||||
}};
|
||||
const unregisterDetached = alpha.settings.registerConfigure(({{restoreFocus}}) => restoreFocus());
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: detachedOpener}}), true);
|
||||
assert.strictEqual(detachedOpener.focusCount, 0);
|
||||
assert.strictEqual(replacementButton.focusCount, 1);
|
||||
assert.strictEqual(installedTab.focusCount, 0);
|
||||
|
||||
let betaCalls = 0;
|
||||
const unregisterBeta = beta.settings.registerConfigure(({{restoreFocus}}) => {{ betaCalls += 1; restoreFocus(); }});
|
||||
runtime.primeFromStatus({{extensions: [
|
||||
{{id: 'alpha.ext', name: 'Alpha', effective_enabled: false}},
|
||||
{{id: 'beta.ext', name: 'Beta', effective_enabled: true}}
|
||||
]}});
|
||||
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}});
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
|
||||
assert.strictEqual(runtime._invokeConfigure('beta.ext', {{opener: connectedOpener}}), true);
|
||||
assert.strictEqual(betaCalls, 1, 'another extension remains usable after alpha failures');
|
||||
|
||||
runtime.primeFromStatus({{extensions: [{{id: 'beta.ext', name: 'Beta', effective_enabled: true}}]}});
|
||||
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}});
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
|
||||
assert.strictEqual(unregisterDetached(), true);
|
||||
assert.strictEqual(alpha.settings.registerConfigure(() => {{}}), null, 'uninstalled IDs stay quarantined');
|
||||
|
||||
runtime.primeFromStatus({{extensions: [
|
||||
{{id: 'alpha.ext', name: 'Alpha reinstalled', effective_enabled: true}},
|
||||
{{id: 'beta.ext', name: 'Beta', effective_enabled: true}}
|
||||
]}});
|
||||
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}});
|
||||
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
|
||||
assert.strictEqual(unregisterBeta(), true);
|
||||
assert.strictEqual(stopChanges(), true);
|
||||
assert.strictEqual(stopChanges(), false);
|
||||
}})().then(
|
||||
() => {{ console.error = originalConsoleError; }},
|
||||
error => {{ console.error = originalConsoleError; throw error; }}
|
||||
);
|
||||
"""
|
||||
)
|
||||
_run_node(script)
|
||||
|
||||
|
||||
def test_documented_configure_consumer_is_safe_on_older_core_without_register_configure():
|
||||
"""The EXTENSIONS.md consumer pattern must not throw on an OLDER E0 Core
|
||||
build where ``register()`` and ``settings`` exist but ``registerConfigure``
|
||||
does not — the optional-chained form ``ext?.settings?.registerConfigure?.()``
|
||||
degrades to ``undefined`` instead of a TypeError, so extension init survives.
|
||||
Regression for the doc example at docs/EXTENSIONS.md (#7111 gate)."""
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
const assert = require('assert');
|
||||
|
||||
// Simulate an OLDER boot-trusted E0 handle: register() returns an ext
|
||||
// whose settings object has NO registerConfigure (the capability the
|
||||
// current Core adds). This is exactly what a pre-#7111 Core exposes.
|
||||
global.window = {
|
||||
hermesExt: {
|
||||
register(id) {
|
||||
return { id, settings: { get() { return null; }, set() { return false; } } };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let initReached = false;
|
||||
// The documented consumer pattern, verbatim from docs/EXTENSIONS.md.
|
||||
const ext = window.hermesExt?.register?.("dictionary-manager");
|
||||
const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => {
|
||||
// never called on old Core
|
||||
return true;
|
||||
});
|
||||
initReached = true; // must be reached — no throw above
|
||||
|
||||
assert.strictEqual(initReached, true, 'extension init must survive missing registerConfigure');
|
||||
assert.strictEqual(unregister, undefined, 'registerConfigure absent => optional call yields undefined');
|
||||
|
||||
// Sanity: the UNGUARDED form WOULD throw, proving the guard is load-bearing.
|
||||
let threw = false;
|
||||
try {
|
||||
ext.settings.registerConfigure(() => {});
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
}
|
||||
assert.strictEqual(threw, true, 'unguarded call is expected to throw on old Core (guard is required)');
|
||||
"""
|
||||
)
|
||||
_run_node(script)
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Regression tests for the Settings → Extensions diagnostics and toggles."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
@@ -50,6 +54,21 @@ def _contains_post_method(block: str) -> bool:
|
||||
return bool(re.search(r"\bmethod\s*:\s*([\"'`])POST\1", block))
|
||||
|
||||
|
||||
def _run_node(script: str):
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node is required for extension settings panel runtime tests")
|
||||
result = subprocess.run(
|
||||
[node, "-e", script],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
|
||||
def test_settings_sidebar_has_extensions_section_and_pane():
|
||||
assert 'data-settings-section="extensions"' in INDEX_HTML
|
||||
assert "switchSettingsSection('extensions',{fromSidebarItem:true})" in INDEX_HTML
|
||||
@@ -138,7 +157,7 @@ def test_extensions_panel_renders_sanitized_status_payload():
|
||||
assert "data&&data.extensions" in render_block
|
||||
assert "counts,'manifest_extensions'" in render_block
|
||||
assert "counts,'user_disabled'" in render_block
|
||||
assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured))" in render_block
|
||||
assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')" in render_block
|
||||
assert "_extensionSidecarCard(sidecars)" in render_block
|
||||
assert "data&&data.warnings" in render_block
|
||||
assert "esc(url)" in asset_block
|
||||
@@ -251,6 +270,7 @@ def test_extensions_installed_settings_route_through_shared_accessor():
|
||||
settings_block = _between("function _configureExtensionSettingsFromStatus", "function _extensionInstalledList")
|
||||
bind_block = _between("function _bindExtensionSettingsButtons", "async function loadExtensionsPanel")
|
||||
gallery_block = _between("function _renderExtensionsGallery", "function _bindExtensionGalleryButtons")
|
||||
installed_surface_block = _between("function _renderInstalledExtensionsSurface", "async function loadExtensionsGallery")
|
||||
|
||||
assert "entry&&entry.storage_owned" in settings_block
|
||||
assert "window.HermesExtensionSettings.settingsForExtension(id)" in settings_block
|
||||
@@ -268,8 +288,81 @@ def test_extensions_installed_settings_route_through_shared_accessor():
|
||||
assert "api('/api/extensions/status')" not in bind_block
|
||||
assert "api('/api/settings'" not in bind_block
|
||||
assert "localStorage" not in bind_block
|
||||
assert "_extensionInstalledList(statusData&&statusData.extensions" in gallery_block
|
||||
assert "_bindExtensionSettingsButtons(installedEl)" in gallery_block
|
||||
assert "_renderInstalledExtensionsSurface(statusData)" in gallery_block
|
||||
assert "_bindExtensionSettingsButtons(installedEl)" in installed_surface_block
|
||||
|
||||
|
||||
def test_extension_configure_hook_is_scoped_to_installed_rows_and_current_status():
|
||||
installed_block = _between("function _extensionInstalledList", "function _extensionSidecarHealthBadge")
|
||||
configure_block = _between("function _extensionConfigureButton", "function _extensionInstalledList")
|
||||
bind_block = _between("function _bindExtensionConfigureButtons", "async function handleExtensionToggle")
|
||||
diagnostics_block = _between("function _renderExtensionsPanel", "function _bindExtensionToggleButtons")
|
||||
gallery_block = _between("function _renderInstalledExtensionsSurface", "function _bindExtensionGalleryButtons")
|
||||
|
||||
assert "surface!=='installed'" in configure_block
|
||||
assert "entry&&entry.effective_enabled" in configure_block
|
||||
assert "_configureStateForExtension(id)" in configure_block
|
||||
assert "state.available" in configure_block
|
||||
assert "data-extension-configure-id" in configure_block
|
||||
assert "aria-busy" in configure_block
|
||||
assert "_extensionConfigureButton(entry,surface)" in installed_block
|
||||
assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')" in diagnostics_block
|
||||
assert "statusData&&statusData.extensions" in gallery_block
|
||||
assert "'installed'" in gallery_block
|
||||
assert "_bindExtensionConfigureButtons(installedEl)" in gallery_block
|
||||
assert "_invokeConfigure(id,{" in bind_block
|
||||
assert "opener:btn" in bind_block
|
||||
assert "Extension configuration failed." in bind_block
|
||||
assert "showToast" in bind_block
|
||||
assert "data-extension-configure-id" in bind_block
|
||||
|
||||
|
||||
def test_extension_configure_rendered_surface_runtime():
|
||||
configure_block = _between("function _extensionConfigureButton", "function _extensionInstalledList")
|
||||
installed_block = _between("function _extensionInstalledList", "function _extensionSidecarHealthBadge")
|
||||
script = "\n".join(
|
||||
[
|
||||
"const assert = require('assert');",
|
||||
"const states = new Map([['alpha.ext', {available:true,pending:false}], ['beta.ext', {available:true,pending:false}], ['gamma.ext', {available:false,pending:false}]]);",
|
||||
"const window = {HermesExtensionSettings:{_configureStateForExtension(id){return states.get(id)||{available:false,pending:false};}}};",
|
||||
"function esc(value){return String(value??'');}",
|
||||
"function _extensionEntryBadge(){return '';}",
|
||||
"function _extensionSettingsControls(){return '';}",
|
||||
configure_block,
|
||||
installed_block,
|
||||
"const entries=[",
|
||||
" {id:'alpha.ext',name:'Alpha',effective_enabled:true,can_toggle:true,user_enabled:true},",
|
||||
" {id:'beta.ext',name:'Beta',effective_enabled:false,can_toggle:true,user_enabled:false},",
|
||||
" {id:'gamma.ext',name:'Gamma',effective_enabled:true,can_toggle:true,user_enabled:true},",
|
||||
"];",
|
||||
"const installed=_extensionInstalledList(entries,true,'installed');",
|
||||
"const diagnostics=_extensionInstalledList(entries,true,'diagnostics');",
|
||||
"assert.strictEqual((installed.match(/data-extension-configure-id=/g)||[]).length,1);",
|
||||
"assert.ok(installed.includes('data-extension-configure-id=\"alpha.ext\"'));",
|
||||
"assert.strictEqual((diagnostics.match(/data-extension-configure-id=/g)||[]).length,0);",
|
||||
"states.set('alpha.ext',{available:true,pending:true});",
|
||||
"const pending=_extensionInstalledList(entries,true,'installed');",
|
||||
"assert.ok(pending.includes('aria-busy=\"true\" disabled'));",
|
||||
"assert.ok(pending.includes('Opening…'));",
|
||||
"states.set('alpha.ext',{available:false,pending:false});",
|
||||
"assert.strictEqual((_extensionInstalledList(entries,true,'installed').match(/data-extension-configure-id=/g)||[]).length,0);",
|
||||
"states.set('alpha.ext',{available:true,pending:false});",
|
||||
"assert.strictEqual((_extensionInstalledList(entries,true,'installed').match(/data-extension-configure-id=/g)||[]).length,1);",
|
||||
]
|
||||
)
|
||||
_run_node(script)
|
||||
|
||||
|
||||
def test_extension_configure_late_registration_rerenders_only_installed_surface():
|
||||
listener_block = _between("function _handleExtensionConfigureChange", "function _extensionSafeHttpUrl")
|
||||
diagnostics_block = _between("function _renderExtensionsPanel", "function _bindExtensionToggleButtons")
|
||||
|
||||
assert "_onConfigureChange(_handleExtensionConfigureChange)" in listener_block
|
||||
assert "change.reason==='pending'" in listener_block
|
||||
assert "_syncExtensionConfigureButtonState(change.id)" in listener_block
|
||||
assert "_renderInstalledExtensionsSurface" in listener_block
|
||||
assert "extensionsDiagnostics" not in listener_block
|
||||
assert "_extensionsGalleryData.statusData=data||null" in diagnostics_block
|
||||
|
||||
|
||||
def test_extensions_gallery_renders_post_install_guidance():
|
||||
@@ -353,6 +446,8 @@ def test_extensions_styles_are_scoped_to_extensions_panel():
|
||||
assert ".extension-toggle-btn" in STYLE_CSS
|
||||
assert ".extension-settings-box" in STYLE_CSS
|
||||
assert ".extension-settings-actions" in STYLE_CSS
|
||||
assert ".extension-installed-actions" in STYLE_CSS
|
||||
assert ".extension-configure-btn" in STYLE_CSS
|
||||
assert ".extension-sidecar-list" in STYLE_CSS
|
||||
assert ".extension-sidecar-runtime" in STYLE_CSS
|
||||
assert ".extension-sidecar-status-badge" in STYLE_CSS
|
||||
@@ -453,3 +548,22 @@ def test_extensions_docs_mentions_settings_panel_without_install_or_proxy_claims
|
||||
assert "do **not**" in diagnostics_section
|
||||
assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section
|
||||
assert "override state-file path" in diagnostics_section
|
||||
|
||||
|
||||
def test_extensions_docs_define_scoped_configure_editor_contract():
|
||||
configure_section = DOCS_EXTENSIONS[
|
||||
DOCS_EXTENSIONS.index("### Custom Configure editors"):
|
||||
DOCS_EXTENSIONS.index("### Turn lifecycle events")
|
||||
]
|
||||
|
||||
assert "ext?.settings?.registerConfigure?." in configure_section
|
||||
assert "does not require `settings_schema` or extension-owned storage" in configure_section
|
||||
assert "Settings → Extensions → Installed" in configure_section
|
||||
assert "Diagnostics" in configure_section
|
||||
assert "Late registration" in configure_section
|
||||
assert "same-ID reinstall" in configure_section
|
||||
assert "one-shot `restoreFocus()`" in configure_section
|
||||
assert "return a thenable" in configure_section
|
||||
assert "remains disabled until reload" in configure_section
|
||||
assert "Synchronous throws" in configure_section
|
||||
assert "generic failure" in configure_section
|
||||
Reference in new issue
Block a user