fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
b2e005f2b4
commit
caebf9ef70
@@ -0,0 +1,199 @@
|
||||
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('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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user