Files
hermes-agent/apps/desktop/src/contrib/plugin.test.ts
T
Brooklyn Nicholson e8ccb4a2ea feat(desktop): ctx.os — the curated OS door for plugins
Fold ctx.notifyNative into a ctx.os namespace so every way a plugin
reaches outside the app window lives behind one attributed door instead
of accreting one top-level ctx method per capability:

- ctx.os.notify — the native-notification door from the previous commit,
  unchanged semantics (plugin kind pref, away-gating, per-plugin throttle).
- ctx.os.openExternal / ctx.os.revealPath / ctx.os.writeClipboard — the
  existing window.hermesDesktop bridge capabilities, now sanctioned and
  result-shaped: each resolves false (never throws) when the bridge or
  member is missing, so a plugin branches on the result instead of
  sniffing the preload surface or crashing on an older shell.

No new Electron surface: everything routes through bridge members the
app already ships; the notification path keeps every existing gate.
2026-08-04 11:33:25 -06:00

63 lines
2.4 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { dispatchPluginNativeNotification } from '@/store/native-notifications'
import { createPluginContext } from './plugin'
vi.mock('@/store/native-notifications', () => ({ dispatchPluginNativeNotification: vi.fn() }))
describe('createPluginContext.onDispose', () => {
it('collects arbitrary cleanups so the host runs them on deactivate', () => {
const disposers: Array<() => void> = []
const ctx = createPluginContext('demo', dispose => disposers.push(dispose))
let cleaned = false
ctx.onDispose(() => {
cleaned = true
})
// The cleanup is tracked alongside contribution/socket disposers, so the
// loader's deactivate (which runs every collected disposer) tears it down.
expect(disposers).toHaveLength(1)
disposers.forEach(dispose => dispose())
expect(cleaned).toBe(true)
})
})
describe('createPluginContext.os', () => {
it('dispatches a native notification attributed to the plugin', () => {
const ctx = createPluginContext('demo')
ctx.os.notify({ body: 'b', title: 't' })
expect(dispatchPluginNativeNotification).toHaveBeenCalledWith('demo', { body: 'b', title: 't' })
})
it('resolves false (never throws) when the desktop bridge is missing', async () => {
const ctx = createPluginContext('demo')
// jsdom has no window.hermesDesktop — the exact older-shell/browser case.
await expect(ctx.os.openExternal('https://example.com')).resolves.toBe(false)
await expect(ctx.os.revealPath('/tmp')).resolves.toBe(false)
await expect(ctx.os.writeClipboard('hi')).resolves.toBe(false)
})
it('routes through the bridge and turns a bridge throw into false', async () => {
const bridge = {
openExternal: vi.fn().mockResolvedValue(undefined),
revealPath: vi.fn().mockResolvedValue(true),
writeClipboard: vi.fn().mockRejectedValue(new Error('nope'))
}
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = bridge
try {
const ctx = createPluginContext('demo')
await expect(ctx.os.openExternal('https://example.com')).resolves.toBe(true)
expect(bridge.openExternal).toHaveBeenCalledWith('https://example.com')
await expect(ctx.os.revealPath('/tmp')).resolves.toBe(true)
await expect(ctx.os.writeClipboard('hi')).resolves.toBe(false)
} finally {
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
}
})
})