open-design/apps/web/tests/components/ChatComposer.infinite-render.test.tsx
chaoxiaoche fce444bcab
Consolidate chat comments preview on main (#2906)
* feat(web): queue chat sends

* feat(web): render code comment directives

* feat(web): add preview comments and manual edits

* fix(web): polish shared chrome controls

* fix(web): align queued send loading state

* feat(web): open primary project artifacts

* fix(web): keep queued sends and tests aligned

* fix(web): restore docked comment tools layout

* fix(web): align preview comment toolbar

* fix(web): place local cli beside handoff

* fix(web): move agent menu beside handoff

* fix(web): make project instructions a direct header action

* fix(web): compact handoff and toolbar labels

* fix(web): clarify handoff menu and annotation label

* fix(web): restore compact cursor handoff trigger

* fix(web): align agent menu trigger with handoff

* fix(web): add draw toolbar close action

* fix(web): move inspect editing into edit mode

* fix(web): avoid reserving comment sidebar in annotation mode

* fix(web): float preview comments panel

* fix(web): keep edit canvas full width

* fix(web): polish preview annotation tools

* fix(web): highlight active preview comments

* fix(web): open comments panel after annotation save

* fix(web): polish comment handoff controls

* fix(web): remove palette preview tool

* fix(web): simplify draw annotation toolbar

* fix(web): restore queued tasks into composer

* fix(web): restore queued send strip styling

* fix(web): hide internal comment target ids

* fix(web): align manual edit panel header

* test(web): cover visual interaction contracts

* fix(web): address PR feedback regressions

* fix(web): preserve artifact chrome state

* fix(daemon): restore project raw file routes

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-26 10:31:19 +00:00

147 lines
5 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatComposer } from '../../src/components/ChatComposer';
let fetchMock: ReturnType<typeof vi.fn>;
function renderComposer(overrides: Partial<ComponentProps<typeof ChatComposer>> = {}) {
return render(
<ChatComposer
projectId="project-1"
projectFiles={[]}
streaming={false}
onEnsureProject={async () => 'project-1'}
onSend={vi.fn()}
onStop={vi.fn()}
skills={[]}
{...overrides}
/>,
);
}
beforeEach(() => {
fetchMock = vi.fn(async (url: string) => {
if (url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/skills') {
return new Response(JSON.stringify({ skills: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
window.localStorage.clear();
cleanup();
});
describe('ChatComposer infinite re-render regression (#2097)', () => {
it('shows only stop while streaming with an empty composer', () => {
renderComposer({ streaming: true });
expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy();
expect(screen.queryByTestId('chat-send')).toBeNull();
});
it('keeps send available while streaming so the next prompt can queue', () => {
const onSend = vi.fn();
const onStop = vi.fn();
renderComposer({ streaming: true, onSend, onStop });
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
fireEvent.change(textarea, {
target: { value: 'change the font', selectionStart: 'change the font'.length },
});
expect(screen.queryByRole('button', { name: 'Stop' })).toBeNull();
fireEvent.click(screen.getByTestId('chat-send'));
expect(onStop).not.toHaveBeenCalled();
expect(onSend).toHaveBeenCalledWith('change the font', [], [], undefined);
});
it('restores a saved draft for the active conversation', () => {
window.localStorage.setItem('od:chat-composer:draft:project-1:conv-1', 'draft before refresh');
renderComposer({
draftStorageKey: 'od:chat-composer:draft:project-1:conv-1',
});
expect((screen.getByTestId('chat-composer-input') as HTMLTextAreaElement).value).toBe(
'draft before refresh',
);
});
it('clears the saved draft after submitting it', async () => {
const key = 'od:chat-composer:draft:project-1:conv-1';
const onSend = vi.fn();
renderComposer({
draftStorageKey: key,
onSend,
});
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
fireEvent.change(textarea, {
target: { value: 'send then clear', selectionStart: 15, selectionEnd: 15 },
});
await waitFor(() => expect(window.localStorage.getItem(key)).toBe('send then clear'));
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
await waitFor(() => expect(window.localStorage.getItem(key)).toBeNull());
});
it('does not re-sync the composer scroll offset on every plain-text keystroke', () => {
const scrollTopGetter = vi.fn(() => 0);
const original = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'scrollTop');
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollTop', {
configurable: true,
get: scrollTopGetter,
set() {},
});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
renderComposer();
const textarea = screen.getByTestId('chat-composer-input') as HTMLTextAreaElement;
const baseline = scrollTopGetter.mock.calls.length;
for (const value of ['h', 'he', 'hel', 'hell', 'hello']) {
fireEvent.change(textarea, { target: { value, selectionStart: value.length } });
}
const maxDepth = consoleError.mock.calls.find((args) =>
args.some((a) => typeof a === 'string' && a.includes('Maximum update depth exceeded')),
);
expect(maxDepth).toBeUndefined();
const perKeystroke = scrollTopGetter.mock.calls.length - baseline;
expect(perKeystroke).toBe(0);
} finally {
consoleError.mockRestore();
if (original) {
Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollTop', original);
} else {
delete (HTMLTextAreaElement.prototype as { scrollTop?: number }).scrollTop;
}
}
});
});