281 lines
8.0 KiB
TypeScript
281 lines
8.0 KiB
TypeScript
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { CommandsPanel } from './commands-panel';
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
configurable: true,
|
|
value: true,
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
});
|
|
|
|
let root: Root | null;
|
|
let container: HTMLElement | null;
|
|
|
|
async function render(node: Parameters<Root['render']>[0]): Promise<void> {
|
|
container = document.createElement('div');
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root?.render(node);
|
|
});
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
root?.unmount();
|
|
});
|
|
document.body.replaceChildren();
|
|
root = null;
|
|
container = null;
|
|
});
|
|
|
|
describe('CommandsPanel', () => {
|
|
it('shows the frozen local pendingApproval args in the confirmation area, regardless of misleading server message text', async () => {
|
|
await render(
|
|
<CommandsPanel
|
|
manifest={null}
|
|
results={[]}
|
|
approval={{
|
|
conversationId: 'c1',
|
|
command: 'deploy', // matches pendingApproval — this is a legitimately approved request
|
|
success: true,
|
|
approvalId: 'ap1',
|
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
// Free-text server message claims a different, less alarming target
|
|
// than what will actually be sent — the UI must not rely on this.
|
|
message: 'This will only affect the staging environment.',
|
|
}}
|
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
hasConversation
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
// The exact frozen combined action is visible...
|
|
expect(container?.textContent).toContain('/deploy');
|
|
expect(container?.textContent).toContain('prod');
|
|
// ...and the misleading server free-text is never shown next to it.
|
|
expect(container?.textContent).not.toContain('staging environment');
|
|
});
|
|
|
|
it('does not throw when a manifest commands entry is null', async () => {
|
|
const manifest = {
|
|
commands: [
|
|
null,
|
|
{
|
|
name: 'model',
|
|
aliases: [],
|
|
description: 'Change the active model',
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
],
|
|
skills: [null],
|
|
version: 1,
|
|
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
|
|
|
await expect(
|
|
render(
|
|
<CommandsPanel
|
|
manifest={manifest}
|
|
results={[]}
|
|
approval={null}
|
|
pendingApproval={null}
|
|
hasConversation={false}
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
),
|
|
).resolves.not.toThrow();
|
|
|
|
expect(container?.textContent).toContain('model');
|
|
});
|
|
|
|
it('shows an explicit no-args fallback when the frozen pendingApproval has no args', async () => {
|
|
await render(
|
|
<CommandsPanel
|
|
manifest={null}
|
|
results={[]}
|
|
approval={{
|
|
conversationId: 'c1',
|
|
command: 'deploy',
|
|
success: true,
|
|
approvalId: 'ap1',
|
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
}}
|
|
pendingApproval={{ command: 'deploy' }}
|
|
hasConversation
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(container?.textContent?.toLowerCase()).toContain('no args');
|
|
});
|
|
|
|
it('renders skills from a skills-only manifest', async () => {
|
|
await render(
|
|
<CommandsPanel
|
|
manifest={{
|
|
commands: [],
|
|
skills: [{ name: 'brave-search', description: 'Search the web', available: true }],
|
|
version: 1,
|
|
}}
|
|
results={[]}
|
|
approval={null}
|
|
pendingApproval={null}
|
|
hasConversation={false}
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(container?.textContent).toContain('brave-search');
|
|
expect(container?.textContent).toContain('Search the web');
|
|
});
|
|
|
|
it('does not show the Run affordance when approval.success/approvalId are objects, even though command matches pendingApproval', async () => {
|
|
const approval = {
|
|
conversationId: 'c1',
|
|
command: 'deploy',
|
|
success: { truthy: 'object' },
|
|
approvalId: { also: 'object' },
|
|
} as unknown as Parameters<typeof CommandsPanel>[0]['approval'];
|
|
|
|
await render(
|
|
<CommandsPanel
|
|
manifest={null}
|
|
results={[]}
|
|
approval={approval}
|
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
hasConversation
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(
|
|
[...(container?.querySelectorAll('button') ?? [])].some((button) =>
|
|
button.textContent?.includes('Run approved command'),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('shows the guarded server-provided denial reason for a denied approval', async () => {
|
|
await render(
|
|
<CommandsPanel
|
|
manifest={null}
|
|
results={[]}
|
|
approval={{
|
|
conversationId: 'c1',
|
|
command: 'deploy',
|
|
success: false,
|
|
message: 'Not authorized',
|
|
}}
|
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
hasConversation
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(container?.textContent).toContain('Not authorized');
|
|
});
|
|
|
|
it('falls back to a stable "Denied." copy when a denial has no usable message', async () => {
|
|
await render(
|
|
<CommandsPanel
|
|
manifest={null}
|
|
results={[]}
|
|
approval={{ conversationId: 'c1', command: 'deploy', success: false }}
|
|
pendingApproval={{ command: 'deploy', args: 'prod' }}
|
|
hasConversation
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(container?.textContent).toContain('Denied.');
|
|
});
|
|
|
|
it('shows the guarded contract-provided reason for a failed command result, falling back to a stable copy only when absent', async () => {
|
|
await render(
|
|
<CommandsPanel
|
|
manifest={null}
|
|
results={[
|
|
{ conversationId: 'c1', command: 'model', success: false, message: 'Unknown model' },
|
|
{ conversationId: 'c1', command: 'deploy', success: false },
|
|
]}
|
|
approval={null}
|
|
pendingApproval={null}
|
|
hasConversation={false}
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(container?.textContent).toContain('Unknown model');
|
|
expect(container?.textContent).toContain('Command failed.');
|
|
});
|
|
|
|
it('bounds an oversized command result message at the render site as defense-in-depth', async () => {
|
|
const hostileMessage = 'y'.repeat(50_000);
|
|
await render(
|
|
<CommandsPanel
|
|
manifest={null}
|
|
results={[
|
|
{ conversationId: 'c1', command: 'model', success: false, message: hostileMessage },
|
|
]}
|
|
approval={null}
|
|
pendingApproval={null}
|
|
hasConversation={false}
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const text = container?.textContent ?? '';
|
|
expect(text.length).toBeLessThan(hostileMessage.length);
|
|
});
|
|
|
|
it('does not throw when the manifest fields are malformed (non-array commands/skills)', async () => {
|
|
const manifest = {
|
|
commands: 'not-an-array',
|
|
skills: null,
|
|
version: 1,
|
|
} as unknown as Parameters<typeof CommandsPanel>[0]['manifest'];
|
|
|
|
await expect(
|
|
render(
|
|
<CommandsPanel
|
|
manifest={manifest}
|
|
results={[]}
|
|
approval={null}
|
|
pendingApproval={null}
|
|
hasConversation={false}
|
|
onExecute={vi.fn()}
|
|
onApprove={vi.fn()}
|
|
onRunApproved={vi.fn()}
|
|
/>,
|
|
),
|
|
).resolves.not.toThrow();
|
|
});
|
|
});
|