qwen-code/packages/web-shell/client/components/MessageList.dom.test.tsx
ermin.zem 5c82857fea
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
Add harness infrastructure for web-shell package (#6517)
* test(web-shell): add browser and lint harness

* test(web-shell): harden browser smoke harness

* fix(web-shell): guard mock daemon model state

* test(web-shell): remove unused scenario harness

* fix(web-shell): remove stale lint disables

* test(web-shell): make matchMedia stub writable

* fix(web-shell): exclude tests from package typecheck

* test(web-shell): tighten mock daemon route contract

* Update packages/web-shell/client/e2e/utils/mockDaemon.ts

Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>

* test(web-shell): clear stale SSE connections

* ci(web-shell): gate smoke on full CI profile

---------

Co-authored-by: ermin.zem <ermin.zem@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: 易良 <1204183885@qq.com>
2026-07-09 08:11:58 +00:00

1102 lines
34 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, createRef, type RefObject } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Message } from '../adapters/types';
import { I18nProvider } from '../i18n';
import flashStyles from './MessageLocateFlash.module.css';
import styles from './MessageList.module.css';
// Mock the App context and the heavy row children so this test exercises only
// MessageList's own collapse + deferred-scroll logic, not the whole render tree.
vi.mock('../App', async () => {
const { createContext } = await import('react');
return { CompactModeContext: createContext(false) };
});
vi.mock('./MessageItem', async () => {
const React = await import('react');
return {
MessageItem: ({
message,
showAssistantActions,
isLocateFlashing,
}: {
message: Message;
showAssistantActions?: boolean;
isLocateFlashing?: boolean;
}) =>
React.createElement(
'div',
{
'data-testid': `msg-${message.id}`,
'data-assistant-actions': String(Boolean(showAssistantActions)),
'data-locate-flashing': isLocateFlashing ? 'true' : undefined,
},
message.role === 'thinking'
? React.createElement('button', {
'aria-expanded': 'false',
'data-testid': `disclosure-${message.id}`,
})
: null,
),
};
});
vi.mock('./messages/tools/ParallelAgentsGroup', async () => {
const React = await import('react');
return {
ParallelAgentsGroup: () =>
React.createElement('div', { 'data-testid': 'parallel-agents' }),
};
});
vi.mock('./messages/ToolApproval', () => ({ ToolApproval: () => null }));
vi.mock('./messages/AskUserQuestion', () => ({ AskUserQuestion: () => null }));
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({
count,
enabled,
getItemKey,
}: {
count: number;
enabled: boolean;
getItemKey: (index: number) => string | number;
}) => {
const virtualItems = enabled
? Array.from({ length: Math.min(count, 5) }, (_, index) => ({
key: getItemKey(index),
index,
start: index * 80,
}))
: [];
return {
getVirtualItems: () => virtualItems,
getTotalSize: () => (enabled ? count * 80 : 0),
measureElement: () => {},
scrollToIndex: () => {},
};
},
}));
const { MessageList } = await import('./MessageList');
type MessageListHandle = import('./MessageList').MessageListHandle;
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
// jsdom provides neither ResizeObserver (MessageList's resize guard) nor a real
// scrollIntoView (the non-virtual scroll path) — stub both.
const resizeObserverCallbacks: ResizeObserverCallback[] = [];
class ResizeObserverStub {
constructor(private readonly callback: ResizeObserverCallback) {
resizeObserverCallbacks.push(callback);
}
observe() {
this.callback([], this as unknown as ResizeObserver);
}
unobserve() {}
disconnect() {}
}
(globalThis as { ResizeObserver?: unknown }).ResizeObserver ??=
ResizeObserverStub;
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
function triggerResizeObservers() {
for (const callback of resizeObserverCallbacks) {
callback([], {} as ResizeObserver);
}
}
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
resizeObserverCallbacks.length = 0;
vi.useRealTimers();
});
type UserMessage = Extract<Message, { role: 'user' }>;
type ToolGroupMessage = Extract<Message, { role: 'tool_group' }>;
type AssistantMessage = Extract<Message, { role: 'assistant' }>;
type ThinkingMessage = Extract<Message, { role: 'thinking' }>;
type PlanMessage = Extract<Message, { role: 'plan' }>;
const userMsg = (id: string): UserMessage => ({
id,
role: 'user',
content: 'q',
});
const userShellMsg = (
id: string,
): Extract<Message, { role: 'user_shell' }> => ({
id,
role: 'user_shell',
command: 'npm test',
output: '',
});
const toolMsg = (id: string): ToolGroupMessage => ({
id,
role: 'tool_group',
tools: [{ callId: `call-${id}`, toolName: 'Read', status: 'completed' }],
});
const agentMsg = (id: string): ToolGroupMessage => ({
id,
role: 'tool_group',
tools: [
{
callId: `call-${id}`,
toolName: 'Task',
status: 'completed',
args: { subagent_type: 'explore' },
},
],
});
const asstMsg = (id: string): AssistantMessage => ({
id,
role: 'assistant',
content: 'answer',
});
const thinkingMsg = (id: string): ThinkingMessage => ({
id,
role: 'thinking',
content: 'thinking',
});
const planMsg = (id: string): PlanMessage => ({
id,
role: 'plan',
todos: [{ id: 'todo-1', content: 'step one', status: 'pending' }],
});
function mount(
messages: Message[],
ref?: RefObject<MessageListHandle | null>,
opts: {
hideSessionTimeline?: boolean;
loadingTranscript?: boolean;
catchingUp?: boolean;
isResponding?: boolean;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
} = {},
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<MessageList
ref={ref}
messages={messages}
pendingApproval={null}
hideSessionTimeline={opts.hideSessionTimeline}
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}
function renderInto(
root: Root,
messages: Message[],
ref?: RefObject<MessageListHandle | null>,
opts: {
loadingTranscript?: boolean;
catchingUp?: boolean;
isResponding?: boolean;
onCanScrollToBottomChange?: (canScrollToBottom: boolean) => void;
} = {},
) {
act(() => {
root.render(
<I18nProvider language="en">
<MessageList
ref={ref}
messages={messages}
pendingApproval={null}
loadingTranscript={opts.loadingTranscript}
catchingUp={opts.catchingUp}
isResponding={opts.isResponding}
onCanScrollToBottomChange={opts.onCanScrollToBottomChange}
/>
</I18nProvider>,
);
});
}
const has = (c: HTMLElement, id: string) =>
c.querySelector(`[data-testid="msg-${id}"]`) !== null;
const assistantActions = (c: HTMLElement, id: string) =>
c
.querySelector(`[data-testid="msg-${id}"]`)
?.getAttribute('data-assistant-actions');
const isCollapsed = (c: HTMLElement, id: string) =>
c
.querySelector(`[data-testid="msg-${id}"]`)
?.closest('[data-collapsed="true"]') !== null;
const queryToggle = (c: HTMLElement, turnId: string) =>
c.querySelector(`[data-testid="toggle-${turnId}"]`) as HTMLElement | null;
const toggle = (c: HTMLElement, turnId: string) =>
queryToggle(c, turnId) as HTMLElement;
const disclosure = (c: HTMLElement, id: string) =>
c.querySelector(`[data-testid="disclosure-${id}"]`) as HTMLElement;
const toggleRow = (c: HTMLElement, turnId: string) =>
toggle(c, turnId).closest('[role="button"]') as HTMLElement;
const click = (el: Element) =>
act(() => el.dispatchEvent(new MouseEvent('click', { bubbles: true })));
const nextFrame = () =>
act(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
const mockMessageListWidth = (width: number) =>
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width,
height: 600,
top: 0,
right: width,
bottom: 600,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
const simpleTurns = (count: number): Message[] =>
Array.from({ length: count }, (_, index) => {
const turn = index + 1;
return [userMsg(`u${turn}`), asstMsg(`a${turn}`)] as Message[];
}).flat();
describe('MessageList — turn collapse (DOM)', () => {
it('collapses a completed turn: hides the step, keeps prompt + answer, shows the toggle', () => {
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')]);
expect(has(c, 'u1')).toBe(true);
expect(has(c, 'a1')).toBe(true);
expect(isCollapsed(c, 'g1')).toBe(true);
expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('false');
});
it('renders collapse metrics in the standalone turn row', () => {
const c = mount([
{ ...userMsg('u1'), timestamp: 1_000 },
{ ...toolMsg('g1'), timestamp: 2_000 },
{
id: 't1',
role: 'thinking',
content: 'checking the tool result',
timestamp: 2_500,
},
{
...asstMsg('a1'),
timestamp: 13_400,
usage: { inputTokens: 3100, outputTokens: 5100, cachedTokens: 2800 },
},
]);
const text = c.textContent ?? '';
expect(text).toContain('Processed');
expect(text).toContain('13s');
expect(text).toContain('↑3.1k (2.8k cached, 90%) ↓5.1k');
expect(text).toContain('1 tool call');
expect(text).toContain('1 thought');
expect(text).not.toContain('1 step');
expect(text.indexOf('↓5.1k')).toBeLessThan(text.indexOf('1 tool call'));
});
it('renders step-less metrics without a toggle', () => {
const c = mount([
{ ...userMsg('u1'), timestamp: 1_000 },
{
...asstMsg('a1'),
timestamp: 1_900,
usage: { inputTokens: 1200, outputTokens: 45 },
},
]);
const text = c.textContent ?? '';
expect(queryToggle(c, 'u1')).toBeNull();
expect(text).toContain('Processed 1s');
expect(text).toContain('↑1.2k ↓45');
expect(text).not.toContain('step');
});
it('omits elapsed-only completed metrics when there is no toggle', () => {
const c = mount([
{ ...userMsg('u1'), timestamp: 1_000 },
{ ...asstMsg('a1'), timestamp: 13_400 },
]);
const text = c.textContent ?? '';
expect(queryToggle(c, 'u1')).toBeNull();
expect(text).not.toContain('Processed');
expect(text).not.toContain('13s');
});
it('shows live elapsed time for a running step-less turn', () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
const c = mount([{ ...userMsg('u1'), timestamp: 7_600 }], undefined, {
isResponding: true,
});
expect(queryToggle(c, 'u1')).toBeNull();
expect(c.textContent).toContain('Processing 3s');
});
it('toggle round-trip reveals then re-hides the step', () => {
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')]);
click(toggle(c, 'u1'));
expect(has(c, 'g1')).toBe(true);
expect(isCollapsed(c, 'g1')).toBe(false);
expect(toggleRow(c, 'u1').getAttribute('aria-expanded')).toBe('true');
click(toggle(c, 'u1'));
expect(isCollapsed(c, 'g1')).toBe(true);
});
it('renders virtual scroll rows with sizer and row width classes', () => {
const c = mount(simpleTurns(110));
expect(c.querySelector(`.${styles.virtualSizer}`)).not.toBeNull();
expect(c.querySelectorAll(`.${styles.virtualRow}`).length).toBeGreaterThan(
0,
);
});
it('renders the session timeline in the left gutter without expanding turns', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount([
userMsg('u1'),
thinkingMsg('think1'),
asstMsg('mid1'),
toolMsg('g1'),
planMsg('plan1'),
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
userMsg('u3'),
asstMsg('a3'),
userMsg('u4'),
asstMsg('a4'),
]);
await nextFrame();
const timeline = c.querySelector('[data-testid="session-timeline"]');
expect(timeline).not.toBeNull();
const entries = Array.from(
c.querySelectorAll('[data-testid="session-timeline-entry"]'),
);
expect(entries.map((entry) => entry.getAttribute('data-turn-id'))).toEqual([
'u1',
'u2',
'u3',
'u4',
]);
expect(entries[0]?.getAttribute('data-node-kinds')).toBe(
'thought,commentary,tool,plan',
);
const details = Array.from(
c.querySelectorAll('[data-testid="session-timeline-detail"]'),
);
expect(details).toHaveLength(4);
expect(details[0]?.getAttribute('data-detail')).toBe('answer');
const buttons = Array.from(
c.querySelectorAll<HTMLButtonElement>(
'[data-testid="session-timeline-entry"] button',
),
);
expect(buttons[0]?.getAttribute('aria-label')).toBe(
'Turn 1: q. Current turn',
);
expect(buttons[0]?.hasAttribute('title')).toBe(false);
expect(entries[0]?.getAttribute('data-in-current-range')).toBe('true');
expect(entries[1]?.getAttribute('data-in-current-range')).toBe('true');
expect(
c.querySelector('[data-testid="session-timeline-range"]'),
).toBeNull();
expect(isCollapsed(c, 'g1')).toBe(true);
expect(c.querySelector('[data-testid="turn-timeline-row"]')).toBeNull();
rectSpy.mockRestore();
});
it('renders scheduled task marker when source is present', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount([
// Source propagation is owned by the metadata adapter PR; this test covers
// the timeline rendering contract once that source is present.
{ ...userMsg('u1'), source: 'cron', content: 'scheduled tracking task' },
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
userMsg('u3'),
asstMsg('a3'),
userMsg('u4'),
asstMsg('a4'),
]);
await nextFrame();
const scheduledDetail = c.querySelector(
'[data-turn-id="u1"] [data-testid="session-timeline-detail"]',
);
expect(scheduledDetail?.getAttribute('data-scheduled-task')).toBe('true');
expect(
scheduledDetail?.querySelector(`.${styles.sessionTimelineDetailsIcon}`),
).not.toBeNull();
expect(scheduledDetail?.textContent).toContain('scheduled tracking task');
rectSpy.mockRestore();
});
it('hides the session timeline until there are at least four turns', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(3));
await nextFrame();
expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull();
rectSpy.mockRestore();
});
it('clicks a session timeline entry to jump to its turn', async () => {
const rectSpy = mockMessageListWidth(1200);
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {});
const c = mount(simpleTurns(4));
await nextFrame();
const secondEntryButton = c.querySelector<HTMLButtonElement>(
'[data-turn-id="u2"] button',
);
expect(secondEntryButton).not.toBeNull();
act(() => {
secondEntryButton?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
});
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' });
await nextFrame();
const targetMessage = c.querySelector('[data-testid="msg-u2"]');
expect(targetMessage?.getAttribute('data-locate-flashing')).toBe('true');
expect(targetMessage?.closest('[data-index]')?.className).not.toMatch(
/flash/i,
);
scrollIntoView.mockRestore();
rectSpy.mockRestore();
});
it('flashes grouped parallel agents inside the row when locating a tool', async () => {
const scrollIntoView = vi
.spyOn(Element.prototype, 'scrollIntoView')
.mockImplementation(() => {});
const ref = createRef<MessageListHandle>();
const c = mount(
[userMsg('u1'), agentMsg('g1'), agentMsg('g2'), asstMsg('a1')],
ref,
);
let found = false;
act(() => {
found = ref.current!.scrollToMessage('g1', 'call-g1');
});
await nextFrame();
expect(found).toBe(true);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' });
const parallelAgents = c.querySelector('[data-testid="parallel-agents"]');
expect(parallelAgents?.parentElement?.className).toContain(
flashStyles.flash,
);
expect(parallelAgents?.closest('[data-index]')?.className).not.toMatch(
/flash/i,
);
scrollIntoView.mockRestore();
});
it('hides the session timeline when the message list is narrow', async () => {
const rectSpy = mockMessageListWidth(1000);
const c = mount(simpleTurns(4));
await nextFrame();
expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull();
rectSpy.mockRestore();
});
it('hides the session timeline when the caller disables it', async () => {
const rectSpy = mockMessageListWidth(1200);
const c = mount(simpleTurns(4), undefined, {
hideSessionTimeline: true,
});
await nextFrame();
expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull();
rectSpy.mockRestore();
});
it('hides the session timeline when the message list has no width', async () => {
const rectSpy = mockMessageListWidth(0);
const c = mount(simpleTurns(4));
await nextFrame();
expect(c.querySelector('[data-testid="session-timeline"]')).toBeNull();
rectSpy.mockRestore();
});
it('scrollToMessage auto-expands the collapsed turn that holds the target', () => {
const ref = createRef<MessageListHandle>();
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')], ref);
expect(isCollapsed(c, 'g1')).toBe(true);
let found = false;
act(() => {
found = ref.current!.scrollToMessage('g1', 'call-g1');
});
expect(found).toBe(true);
expect(has(c, 'g1')).toBe(true);
expect(isCollapsed(c, 'g1')).toBe(false);
});
it('smooth-scrolls the page when a new chat prompt appears', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
renderInto(root, [userMsg('u1'), asstMsg('a1'), userMsg('u2')]);
await nextFrame();
expect(scrollTo).toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('does not smooth-scroll when initial history already contains a user prompt', () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
mount([userMsg('u1'), asstMsg('a1')]);
expect(scrollTo).not.toHaveBeenCalled();
});
it('shows a transcript skeleton while loading transcript', () => {
const c = mount([], undefined, { loadingTranscript: true });
expect(
c.querySelector('[data-testid="message-list-loading-skeleton"]'),
).not.toBeNull();
expect(c.querySelector('[role="status"]')?.textContent).toBe(
'Session is still loading. Try again in a moment.',
);
});
it('shows the transcript skeleton while loading transcript with existing messages', () => {
const c = mount([userMsg('u1')], undefined, {
loadingTranscript: true,
});
expect(
c.querySelector('[data-testid="message-list-loading-skeleton"]'),
).not.toBeNull();
});
it('does not show the transcript skeleton outside transcript loading', () => {
const idle = mount([]);
expect(
idle.querySelector('[data-testid="message-list-loading-skeleton"]'),
).toBeNull();
});
it('does not smooth-scroll when existing session history loads after an empty render', () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, []);
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalled();
});
it('smooth-scrolls the first new prompt after an empty render', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, []);
renderInto(root, [userMsg('u1')]);
await nextFrame();
expect(scrollTo).toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('does not smooth-scroll restored history that ends with a user prompt', async () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [], undefined, { loadingTranscript: true });
renderInto(root, [userMsg('u1')], undefined, {
loadingTranscript: false,
});
await nextFrame();
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('does not smooth-scroll when a user prompt is already followed by an assistant row', async () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, [userMsg('u1'), asstMsg('a1')]);
renderInto(root, [
userMsg('u1'),
asstMsg('a1'),
userMsg('u2'),
asstMsg('a2'),
]);
await nextFrame();
expect(scrollTo).not.toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('snaps to bottom without smooth scrolling when catch-up completes', () => {
const scrollTo = vi.fn();
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = value;
},
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
const messages = [userMsg('u1'), asstMsg('a1')];
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
renderInto(root, messages, undefined, { catchingUp: true });
expect(scrollTop).toBe(0);
renderInto(root, messages, undefined, { catchingUp: false });
expect(scrollTop).toBe(1200);
expect(scrollTo).not.toHaveBeenCalled();
});
it('does not treat a user_shell row as a new chat prompt', () => {
const scrollTo = vi.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
});
mount([userShellMsg('shell')]);
expect(scrollTo).not.toHaveBeenCalledWith({
top: 1200,
behavior: 'smooth',
});
});
it('shows assistant actions on the final answer of a user_shell turn', () => {
const c = mount([
userShellMsg('shell'),
asstMsg('mid'),
toolMsg('tool'),
asstMsg('a1'),
]);
expect(assistantActions(c, 'mid')).toBe('false');
expect(assistantActions(c, 'a1')).toBe('true');
});
it('reports when the user has scrolled away from the bottom', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 600,
writable: true,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
});
const onCanScrollToBottomChange = vi.fn();
const container = mount([asstMsg('a1')], undefined, {
onCanScrollToBottomChange,
});
await nextFrame();
const list = container.firstElementChild as HTMLElement;
list.scrollTop = 600;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
list.scrollTop = 500;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
list.scrollTop = 600;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports no scroll-to-bottom affordance when the list has no scrollbar', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
const onCanScrollToBottomChange = vi.fn();
mount([userMsg('u1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports no scroll-to-bottom affordance when already at the bottom', async () => {
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 600,
writable: true,
});
const onCanScrollToBottomChange = vi.fn();
mount([userMsg('u1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('keeps the scroll-to-bottom affordance hidden when followed content grows', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
mount([asstMsg('a1')], undefined, { onCanScrollToBottomChange });
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
scrollHeight = 1200;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports scroll-to-bottom affordance when a clicked disclosure grows during streaming', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([thinkingMsg('t1'), asstMsg('a1')], undefined, {
isResponding: true,
onCanScrollToBottomChange,
});
await nextFrame();
click(disclosure(c, 't1'));
scrollHeight = 1200;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
});
it('keeps the scroll-to-bottom affordance hidden when disclosure growth stays near bottom', async () => {
let scrollHeight = 600;
let scrollTop = 0;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, scrollHeight - 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([thinkingMsg('t1'), asstMsg('a1')], undefined, {
isResponding: true,
onCanScrollToBottomChange,
});
await nextFrame();
click(disclosure(c, 't1'));
scrollHeight = 620;
act(() => triggerResizeObservers());
await nextFrame();
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('clears the scroll-to-bottom affordance immediately after scrolling to bottom', async () => {
let scrollTop = 600;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
value: 1200,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(value, 600));
},
});
const onCanScrollToBottomChange = vi.fn();
const ref = createRef<MessageListHandle>();
const c = mount([asstMsg('a1')], ref, { onCanScrollToBottomChange });
await nextFrame();
await nextFrame();
const list = c.firstElementChild as HTMLElement;
scrollTop = 0;
act(() => list.dispatchEvent(new Event('scroll', { bubbles: true })));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
act(() => ref.current?.scrollToBottom('auto'));
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(false);
});
it('reports scroll-to-bottom affordance when expanding content creates overflow', async () => {
let scrollHeight = 600;
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get: () => scrollHeight,
});
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 600,
});
Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
configurable: true,
value: 0,
writable: true,
});
const onCanScrollToBottomChange = vi.fn();
const c = mount([userMsg('u1'), toolMsg('g1'), asstMsg('a1')], undefined, {
onCanScrollToBottomChange,
});
await nextFrame();
click(toggle(c, 'u1'));
scrollHeight = 1200;
await nextFrame();
await nextFrame();
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 230)));
await nextFrame();
expect(onCanScrollToBottomChange).toHaveBeenLastCalledWith(true);
});
});