637 lines
21 KiB
TypeScript
637 lines
21 KiB
TypeScript
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket';
|
|
import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits';
|
|
|
|
const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({
|
|
getSocketMock: vi.fn(),
|
|
destroySocketMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/socket', () => ({
|
|
getSocket: getSocketMock,
|
|
destroySocket: destroySocketMock,
|
|
}));
|
|
|
|
import { ChatPage } from './chat';
|
|
|
|
function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void {
|
|
const proto =
|
|
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
setter?.call(el, value);
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
function selectValue(el: HTMLSelectElement, value: string): void {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
|
setter?.call(el, value);
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
function findButton(container: HTMLElement, text: string): HTMLButtonElement {
|
|
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
|
candidate.textContent?.includes(text),
|
|
);
|
|
if (!button) throw new Error(`Button with text "${text}" not found`);
|
|
return button;
|
|
}
|
|
|
|
let fake: ReturnType<typeof createFakeChatSocket>;
|
|
let root: Root | null;
|
|
let container: HTMLElement;
|
|
|
|
beforeAll(() => {
|
|
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
configurable: true,
|
|
value: true,
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
fake = createFakeChatSocket();
|
|
getSocketMock.mockReset().mockReturnValue(fake.socket);
|
|
destroySocketMock.mockReset();
|
|
container = document.createElement('div');
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
await act(async () => {
|
|
root?.render(<ChatPage />);
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
root?.unmount();
|
|
});
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
describe('ChatPage', () => {
|
|
it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
});
|
|
await act(async () => {
|
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' });
|
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' });
|
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' });
|
|
fake.serverEmit('agent:tool:start', {
|
|
conversationId: 'c1',
|
|
toolCallId: 't1',
|
|
toolName: 'web_search',
|
|
});
|
|
});
|
|
|
|
expect(container.textContent).toContain('pondering…');
|
|
expect(container.textContent).toContain('Hello!');
|
|
expect(container.textContent).toContain('web_search');
|
|
expect(container.textContent).toMatch(/running/i);
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('agent:tool:end', {
|
|
conversationId: 'c1',
|
|
toolCallId: 't1',
|
|
toolName: 'web_search',
|
|
isError: false,
|
|
});
|
|
fake.serverEmit('agent:end', {
|
|
conversationId: 'c1',
|
|
usage: {
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'medium',
|
|
tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 },
|
|
cost: 0.02,
|
|
context: { percent: 3, window: 200000 },
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(container.textContent).toMatch(/success/i);
|
|
expect(container.textContent).toContain('Hello!');
|
|
expect(container.textContent).toMatch(/46/);
|
|
});
|
|
|
|
it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('commands:manifest', {
|
|
manifest: {
|
|
commands: [
|
|
{
|
|
name: 'model',
|
|
aliases: ['m'],
|
|
description: 'Change the active model',
|
|
scope: 'core',
|
|
execution: 'socket',
|
|
available: true,
|
|
},
|
|
],
|
|
skills: [],
|
|
version: 1,
|
|
},
|
|
});
|
|
fake.serverEmit('session:info', {
|
|
conversationId: 'c1',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'medium',
|
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
|
routingDecision: {
|
|
model: 'claude',
|
|
provider: 'anthropic',
|
|
ruleName: 'default',
|
|
reason: 'default routing',
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(container.textContent).toContain('model');
|
|
expect(container.textContent).toContain('Change the active model');
|
|
expect(container.textContent).toContain('anthropic');
|
|
expect(container.textContent).toContain('default routing');
|
|
|
|
const select = container.querySelector(
|
|
'select[aria-label="Thinking level"]',
|
|
) as HTMLSelectElement;
|
|
expect(select).toBeTruthy();
|
|
expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']);
|
|
|
|
await act(async () => {
|
|
selectValue(select, 'high');
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'set:thinking',
|
|
payload: { conversationId: 'c1', level: 'high' },
|
|
});
|
|
});
|
|
|
|
it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
const commandInput = container.querySelector(
|
|
'input[aria-label="Command name"]',
|
|
) as HTMLInputElement;
|
|
const argsInput = container.querySelector(
|
|
'input[aria-label="Command arguments"]',
|
|
) as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
setValue(commandInput, 'model');
|
|
setValue(argsInput, 'gpt-5');
|
|
});
|
|
await act(async () => {
|
|
findButton(container, 'Run command').click();
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:execute',
|
|
payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' },
|
|
});
|
|
|
|
await act(async () => {
|
|
setValue(commandInput, 'deploy');
|
|
setValue(argsInput, 'prod');
|
|
});
|
|
await act(async () => {
|
|
findButton(container, 'Request approval').click();
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:approve',
|
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
|
});
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('command:approval', {
|
|
conversationId: 'c1',
|
|
command: 'deploy',
|
|
success: true,
|
|
approvalId: 'ap1',
|
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
expect(container.textContent).toMatch(/approved/i);
|
|
|
|
await act(async () => {
|
|
findButton(container, 'Run approved command').click();
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:execute',
|
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
|
});
|
|
});
|
|
|
|
it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
|
fake.serverEmit('command:result', {
|
|
conversationId: 'c1',
|
|
command: 'model',
|
|
success: false,
|
|
message: 'Unknown model',
|
|
});
|
|
});
|
|
|
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
|
expect(alertText).toContain('The model is unavailable');
|
|
// The structured, contract-provided denial reason is visibly rendered.
|
|
expect(alertText).toContain('Unknown model');
|
|
});
|
|
|
|
it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmitRaw('command:result', {
|
|
conversationId: 'c1',
|
|
command: 'model',
|
|
success: false,
|
|
message: { bad: 'object' },
|
|
});
|
|
});
|
|
|
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
const alertText = alerts.map((node) => node.textContent).join(' ');
|
|
expect(alertText).toContain('Command failed.');
|
|
});
|
|
|
|
it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => {
|
|
const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`);
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('session:info', {
|
|
conversationId: 'c1',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'level-0',
|
|
availableThinkingLevels: hostileLevels,
|
|
});
|
|
});
|
|
|
|
const select = container.querySelector(
|
|
'select[aria-label="Thinking level"]',
|
|
) as HTMLSelectElement;
|
|
expect(select).toBeTruthy();
|
|
expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS);
|
|
});
|
|
|
|
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmitRaw('session:info', {
|
|
conversationId: 'c1',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'medium',
|
|
availableThinkingLevels: null,
|
|
});
|
|
});
|
|
|
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
const select = container.querySelector(
|
|
'select[aria-label="Thinking level"]',
|
|
) as HTMLSelectElement;
|
|
expect(select).toBeTruthy();
|
|
// A malformed level list still shows a visible, safe placeholder option
|
|
// rather than a silently empty select.
|
|
expect([...select.options]).toHaveLength(1);
|
|
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
|
|
|
await act(async () => {
|
|
selectValue(select, '');
|
|
});
|
|
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
|
});
|
|
|
|
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
fake.serverEmitRaw('agent:end', {
|
|
conversationId: 'c1',
|
|
usage: {
|
|
provider: { nested: 'object' },
|
|
modelId: undefined,
|
|
thinkingLevel: 'medium',
|
|
tokens: { total: 'not-a-number' },
|
|
cost: undefined,
|
|
context: { percent: null, window: 200000 },
|
|
},
|
|
});
|
|
});
|
|
|
|
const usage = container.querySelector('[aria-label="Usage"]');
|
|
expect(usage).toBeTruthy();
|
|
expect(usage?.textContent).toContain('tokens unavailable');
|
|
expect(usage?.textContent).toContain('cost unavailable');
|
|
expect(usage?.textContent).not.toContain('0 tokens');
|
|
expect(usage?.textContent).not.toContain('$0.0000');
|
|
expect(usage?.textContent).toContain('unknown/unknown');
|
|
});
|
|
|
|
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
|
await expect(
|
|
act(async () => {
|
|
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
|
}),
|
|
).resolves.not.toThrow();
|
|
|
|
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
|
node.textContent?.includes('Message accepted'),
|
|
);
|
|
expect(status).toBeTruthy();
|
|
// A malformed messageId gets a stable, visible fallback — never blank,
|
|
// never the raw object.
|
|
expect(status?.textContent).toContain('unknown');
|
|
});
|
|
|
|
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
|
await expect(
|
|
act(async () => {
|
|
fake.serverEmitRaw('system:reload', {
|
|
commands: [],
|
|
skills: [],
|
|
providers: [],
|
|
message: { bad: 'object' },
|
|
});
|
|
}),
|
|
).resolves.not.toThrow();
|
|
|
|
const status = container.querySelector('[role="status"]');
|
|
expect(status).toBeTruthy();
|
|
// A malformed reload message renders a stable, visible fallback rather
|
|
// than a silently empty status line.
|
|
expect(status?.textContent).toContain('Commands reloaded.');
|
|
});
|
|
|
|
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
|
await expect(
|
|
act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
|
}),
|
|
).resolves.not.toThrow();
|
|
|
|
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
|
});
|
|
|
|
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
const providerInput = container.querySelector(
|
|
'input[aria-label="Provider"]',
|
|
) as HTMLInputElement;
|
|
const modelInput = container.querySelector('input[aria-label="Model"]') as HTMLInputElement;
|
|
|
|
const stopButtonBefore = container.querySelector(
|
|
'button[aria-label="Stop"]',
|
|
) as HTMLButtonElement;
|
|
expect(stopButtonBefore.disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'hello there');
|
|
setValue(providerInput, 'anthropic');
|
|
setValue(modelInput, 'claude');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'message',
|
|
payload: {
|
|
conversationId: undefined,
|
|
content: 'hello there',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
},
|
|
});
|
|
expect(container.textContent).toContain('hello there');
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
});
|
|
|
|
const stopButtonDuring = container.querySelector(
|
|
'button[aria-label="Stop"]',
|
|
) as HTMLButtonElement;
|
|
expect(stopButtonDuring.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
stopButtonDuring.click();
|
|
});
|
|
|
|
expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } });
|
|
});
|
|
|
|
it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => {
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'hello');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
fake.serverEmit('session:info', {
|
|
conversationId: 'c1',
|
|
provider: 'anthropic',
|
|
modelId: 'claude',
|
|
thinkingLevel: 'medium',
|
|
availableThinkingLevels: ['low', 'medium', 'high'],
|
|
});
|
|
});
|
|
|
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
expect(container.textContent).toContain('anthropic');
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
|
expect(container.textContent).toContain('anthropic');
|
|
});
|
|
|
|
it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => {
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'hello');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
fake.serverEmit('error', {
|
|
conversationId: 'c1',
|
|
error: 'Failed to start agent session. Please try again.',
|
|
});
|
|
});
|
|
|
|
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
|
expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe(
|
|
true,
|
|
);
|
|
|
|
const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement;
|
|
expect(stopButton.disabled).toBe(true);
|
|
});
|
|
|
|
it('shows an accessible status once the message is acknowledged', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
const statuses = [...container.querySelectorAll('[role="status"]')];
|
|
expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true);
|
|
});
|
|
|
|
it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' });
|
|
fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' });
|
|
});
|
|
|
|
expect(container.textContent).toContain('reasoning about it');
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('agent:end', { conversationId: 'c1' });
|
|
});
|
|
|
|
expect(container.textContent).toContain('reasoning about it');
|
|
expect(container.textContent).toContain('Done.');
|
|
});
|
|
|
|
it('ignores a concurrent approval request and only executes the approved command once', async () => {
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
});
|
|
|
|
const commandInput = container.querySelector(
|
|
'input[aria-label="Command name"]',
|
|
) as HTMLInputElement;
|
|
const argsInput = container.querySelector(
|
|
'input[aria-label="Command arguments"]',
|
|
) as HTMLInputElement;
|
|
|
|
await act(async () => {
|
|
setValue(commandInput, 'deploy');
|
|
setValue(argsInput, 'prod');
|
|
});
|
|
await act(async () => {
|
|
findButton(container, 'Request approval').click();
|
|
});
|
|
await act(async () => {
|
|
setValue(argsInput, 'staging');
|
|
});
|
|
await act(async () => {
|
|
findButton(container, 'Request approval').click();
|
|
});
|
|
|
|
expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1);
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:approve',
|
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod' },
|
|
});
|
|
|
|
await act(async () => {
|
|
fake.serverEmit('command:approval', {
|
|
conversationId: 'c1',
|
|
command: 'deploy',
|
|
success: true,
|
|
approvalId: 'ap1',
|
|
expiresAt: '2026-01-01T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
await act(async () => {
|
|
findButton(container, 'Run approved command').click();
|
|
findButton(container, 'Run approved command').click();
|
|
});
|
|
|
|
expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1);
|
|
expect(fake.emitted).toContainEqual({
|
|
event: 'command:execute',
|
|
payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' },
|
|
});
|
|
});
|
|
|
|
it('disables sending a second message while a turn is streaming', async () => {
|
|
const textarea = container.querySelector(
|
|
'textarea[aria-label="Message"]',
|
|
) as HTMLTextAreaElement;
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'first');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
|
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
|
});
|
|
|
|
const sendButton = findButton(container, 'Send');
|
|
expect(sendButton.disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
setValue(textarea, 'second');
|
|
});
|
|
await act(async () => {
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
|
);
|
|
});
|
|
|
|
expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1);
|
|
});
|
|
|
|
it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => {
|
|
expect(fake.listeners.size).toBeGreaterThan(0);
|
|
|
|
await act(async () => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
|
|
for (const [, handlers] of fake.listeners) {
|
|
expect(handlers.size).toBe(0);
|
|
}
|
|
expect(destroySocketMock).toHaveBeenCalledOnce();
|
|
});
|
|
});
|