fix(browser): expire abandoned extension menu snapshots (#35167)

* fix(browser): expire abandoned extension menu snapshots

* Keep menu snapshot expiry cases in a focused host suite

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 13:32:13 -07:00
committed by GitHub
co-authored by OrcaWin
parent 8e9a08b543
commit ade67ab1f2
2 changed files with 152 additions and 1 deletions
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { StoredContextMenuItem } from './browser-extension-profile-state-store'
const mocks = vi.hoisted(() => ({
items: Array<StoredContextMenuItem>(),
emit: vi.fn()
}))
const VAULT = 'a'.repeat(32)
vi.mock('./api/web-navigation', () => ({ chromeFrameId: () => 0 }))
vi.mock('./api/permissions', () => ({ noteBrowserExtensionUserGesture: vi.fn() }))
vi.mock('./browser-extension-api-host', () => ({
emitBrowserExtensionEvent: mocks.emit,
getBrowserExtensionProfileIdForSession: () => 'default'
}))
vi.mock('./browser-extension-active-tab', () => ({ grantBrowserExtensionActiveTab: vi.fn() }))
vi.mock('./browser-extension-document-ids', () => ({ getBrowserExtensionDocumentId: () => null }))
vi.mock('./browser-extension-install-store', () => ({
getBrowserExtensionInstallStore: () => ({ get: () => null })
}))
vi.mock('./browser-extension-namespace-registry', () => ({ extensionHasPermission: () => true }))
vi.mock('./browser-extension-profile-state-store', () => ({
getBrowserExtensionProfileStateStore: () => ({ read: () => ({ contextMenus: mocks.items }) })
}))
vi.mock('./browser-extension-session-loader', () => ({
getBrowserExtensionSessionForProfile: () => ({
extensions: { getExtension: (id: string) => ({ id, name: 'Vault' }) }
})
}))
vi.mock('./browser-extension-tab-model', () => ({
resolveBrowserExtensionTab: () => null,
describeBrowserExtensionTab: vi.fn()
}))
import { createContextMenuItem } from './browser-extension-context-menu-model'
import * as menuHost from './browser-extension-context-menu-host'
beforeEach(() => mocks.emit.mockClear())
describe('context-menu snapshot expiry', () => {
it('expires abandoned menu tokens without another menu being opened', async () => {
mocks.items = createContextMenuItem([], { id: 'expired', title: 'Expired', contexts: ['all'] })
vi.useFakeTimers()
try {
const tokens = Array.from(
{ length: 100 },
() => menuHost.buildBrowserExtensionActionContextMenu('default', VAULT, null)?.token ?? ''
)
const pendingTimers = vi.getTimerCount()
await vi.advanceTimersByTimeAsync(600_001)
const results = tokens.map((token) =>
menuHost.runBrowserExtensionContextMenuClick({
token,
extensionId: VAULT,
key: '"expired"'
})
)
expect(results).toEqual(Array(100).fill(false))
expect(mocks.emit.mock.calls).toEqual([])
expect(pendingTimers).toBe(1)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('keeps a later menu usable when an older menu expires and clears its last timer', async () => {
mocks.items = createContextMenuItem([], { id: 'fresh', title: 'Fresh', contexts: ['all'] })
vi.useFakeTimers()
try {
const oldToken =
menuHost.buildBrowserExtensionActionContextMenu('default', VAULT, null)?.token ?? ''
await vi.advanceTimersByTimeAsync(300_001)
const freshToken =
menuHost.buildBrowserExtensionActionContextMenu('default', VAULT, null)?.token ?? ''
await vi.advanceTimersByTimeAsync(300_000)
const oldResult = menuHost.runBrowserExtensionContextMenuClick({
token: oldToken,
extensionId: VAULT,
key: '"fresh"'
})
const freshResult = menuHost.runBrowserExtensionContextMenuClick({
token: freshToken,
extensionId: VAULT,
key: '"fresh"'
})
expect(oldResult).toBe(false)
expect(freshResult).toBe(true)
expect(
menuHost.runBrowserExtensionContextMenuClick({
token: freshToken,
extensionId: VAULT,
key: '"fresh"'
})
).toBe(false)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('refuses an expired token when the wall clock advances before its timer runs', async () => {
mocks.items = createContextMenuItem([], { id: 'jump', title: 'Jump', contexts: ['all'] })
vi.useFakeTimers()
try {
const token =
menuHost.buildBrowserExtensionActionContextMenu('default', VAULT, null)?.token ?? ''
vi.setSystemTime(Date.now() + 600_001)
expect(
menuHost.runBrowserExtensionContextMenuClick({ token, extensionId: VAULT, key: '"jump"' })
).toBe(false)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
})
@@ -50,6 +50,7 @@ type MenuSnapshot = {
const SNAPSHOT_TTL_MS = 10 * 60_000
const snapshots = new Map<string, MenuSnapshot>()
let expiryTimer: ReturnType<typeof setTimeout> | null = null
function pruneSnapshots(now: number): void {
for (const [token, snapshot] of snapshots) {
@@ -59,6 +60,31 @@ function pruneSnapshots(now: number): void {
}
}
function scheduleSnapshotExpiry(): void {
const oldest = snapshots.values().next().value
if (expiryTimer || !oldest) {
return
}
const delay = Math.min(
SNAPSHOT_TTL_MS + 1,
Math.max(1, SNAPSHOT_TTL_MS + 1 - (Date.now() - oldest.createdAt))
)
expiryTimer = setTimeout(() => {
expiryTimer = null
pruneSnapshots(Date.now())
scheduleSnapshotExpiry()
}, delay)
expiryTimer.unref()
}
function forgetSnapshot(token: string): void {
snapshots.delete(token)
if (snapshots.size === 0 && expiryTimer) {
clearTimeout(expiryTimer)
expiryTimer = null
}
}
function topDocumentId(tabId: number | null): string | null {
const contents = tabId === null ? null : resolveBrowserExtensionTab(tabId)?.contents
return contents ? getBrowserExtensionDocumentId(contents.mainFrame.frameTreeNodeId) : null
@@ -126,6 +152,7 @@ export function buildBrowserExtensionPageContextMenu(
mediaType: params.mediaType,
createdAt: now
})
scheduleSnapshotExpiry()
return { token, groups }
}
@@ -191,7 +218,10 @@ export function runBrowserExtensionContextMenuClick(
return false
}
// Why one-shot: a menu is clicked once; a replayed token must not fire again.
snapshots.delete(click.token)
forgetSnapshot(click.token)
if (Date.now() - snapshot.createdAt > SNAPSHOT_TTL_MS) {
return false
}
return runClick(snapshot, click.extensionId, click.key)
}
@@ -237,5 +267,6 @@ export function buildBrowserExtensionActionContextMenu(
editable: false,
createdAt: now
})
scheduleSnapshotExpiry()
return { token, groups }
}