qwen-code/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx
ytahdn 694eea27e7
perf(web-shell): optimize long session rendering (#7408)
* perf(web-shell): optimize long session rendering

* test(web-shell): align long-session expectations

* fix(web-shell): address long-session review feedback

* test(web-shell): cover bounded refresh metadata and auto-pagination status suppression

* fix(web-shell): harden long-session refresh fallback and turn animation (#7408)

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
Co-authored-by: Qwen Autofix <autofix@qwen-code.dev>
2026-07-22 03:28:47 +00:00

463 lines
15 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
import { WebShellTranscript } from './WebShellTranscript';
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
function block(
value: Omit<
DaemonTranscriptBlock,
'clientReceivedAt' | 'createdAt' | 'updatedAt'
>,
timestamp = 1,
): DaemonTranscriptBlock {
return {
...value,
clientReceivedAt: timestamp,
createdAt: timestamp,
updatedAt: timestamp,
} as DaemonTranscriptBlock;
}
function render(node: ReactNode): {
container: HTMLElement;
unmount: () => void;
} {
const container = document.createElement('div');
container.style.height = '640px';
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
const entry = { root, container };
mounted.push(entry);
return {
container,
unmount() {
const index = mounted.indexOf(entry);
if (index >= 0) mounted.splice(index, 1);
act(() => root.unmount());
container.remove();
},
};
}
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.restoreAllMocks();
});
describe('WebShellTranscript DOM integration', () => {
it('renders representative transcript blocks without daemon providers', () => {
const blocks: DaemonTranscriptBlock[] = [
block({ id: 'u1', kind: 'user', text: 'Inspect the project' }, 1),
block({ id: 't1', kind: 'thought', text: 'Thinking through it' }, 2),
block(
{
id: 'tool1',
kind: 'tool',
toolCallId: 'call1',
title: 'Read package file',
toolName: 'read_file',
status: 'completed',
preview: { kind: 'file_read', path: 'package.json' },
rawOutput: 'package contents',
},
3,
),
block(
{
id: 'agent1',
kind: 'tool',
toolCallId: 'agent-call-1',
title: 'Explore the codebase',
toolName: 'Task',
status: 'completed',
preview: { kind: 'generic' },
rawInput: { subagent_type: 'Explore', prompt: 'Find the entrypoint' },
rawOutput: 'Found the entrypoint',
},
4,
),
block({ id: 'a1', kind: 'assistant', text: '**Finished** reading.' }, 5),
block(
{
id: 'p1',
kind: 'status',
text: `plan: ${JSON.stringify({
sessionUpdate: 'plan',
entries: [{ content: 'Verify the design', status: 'in_progress' }],
})}`,
},
6,
),
block({ id: 's1', kind: 'status', text: 'Historical status' }, 7),
block({ id: 'e1', kind: 'error', text: 'Historical error' }, 8),
block({ id: 'c1', kind: 'prompt_cancelled' }, 9),
];
const { container } = render(
<WebShellTranscript
blocks={blocks}
collapseCompletedTurns={false}
language="en"
/>,
);
expect(container.textContent).toContain('Inspect the project');
expect(container.textContent).not.toContain('Thinking through it');
const thinkingToggle = container.querySelector<HTMLButtonElement>(
'button[title="Expand thinking"]',
);
expect(thinkingToggle).not.toBeNull();
act(() => thinkingToggle?.click());
expect(container.textContent).toContain('Thinking through it');
expect(container.textContent).toContain('Read package file');
expect(container.textContent).toContain('Explore the codebase');
expect(container.textContent).toContain('Finished');
expect(container.textContent).toContain('Verify the design');
expect(container.textContent).toContain('Historical status');
expect(container.textContent).toContain('Historical error');
expect(container.textContent).toContain('You cancelled this request');
});
it('derives task detail from todo_write transcript snapshots', () => {
const todoSnapshot = (
id: string,
status: 'in_progress' | 'completed',
timestamp: number,
stats: {
promptTokens: number;
cachedTokens: number;
candidateTokens: number;
apiTimeMs: number;
},
) =>
block(
{
id,
kind: 'tool',
toolCallId: `${id}-call`,
title: 'Updated Plan',
toolName: 'todo_write',
toolKind: 'updated_plan',
status: 'completed',
preview: { kind: 'generic' },
rawOutput: {
entries: [
{
id: 'task-1',
content: 'Prepare release',
status,
},
],
stats,
},
},
timestamp,
);
const { container } = render(
<WebShellTranscript
blocks={[
todoSnapshot('todo-start', 'in_progress', 1000, {
promptTokens: 100,
cachedTokens: 10,
candidateTokens: 20,
apiTimeMs: 500,
}),
todoSnapshot('todo-done', 'completed', 5000, {
promptTokens: 300,
cachedTokens: 40,
candidateTokens: 80,
apiTimeMs: 1500,
}),
]}
collapseCompletedTurns={false}
language="en"
/>,
);
const summaries = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
).filter((button) => button.textContent?.includes('Updated task list'));
expect(summaries).toHaveLength(2);
act(() => {
summaries[1].click();
});
const completedSnapshot = summaries[1].parentElement;
const detailButton = completedSnapshot?.querySelector<HTMLButtonElement>(
'button[title="Show task detail"]',
);
expect(detailButton).not.toBeNull();
act(() => {
detailButton?.click();
});
const detailText = completedSnapshot?.textContent ?? '';
expect(detailText).toContain('Tokens');
expect(detailText).toContain('200');
expect(detailText).toContain('60');
expect(detailText).toContain('30');
expect(detailText).toContain('Time spent');
expect(detailText).toContain('1.0s');
expect(detailText).toContain('4.0s');
});
it('derives the transition introduced by each plan snapshot', () => {
const planSnapshot = (
id: string,
firstStatus: 'in_progress' | 'completed',
secondStatus: 'pending' | 'in_progress',
timestamp: number,
) =>
block(
{
id,
kind: 'status',
text: `plan: ${JSON.stringify({
sessionUpdate: 'plan',
entries: [
{
id: 'task-1',
content: 'Prepare release',
status: firstStatus,
},
{
id: 'task-2',
content: 'Run verification',
status: secondStatus,
},
],
})}`,
},
timestamp,
);
const { container } = render(
<WebShellTranscript
blocks={[
planSnapshot('plan-start', 'in_progress', 'pending', 1000),
planSnapshot('plan-next', 'completed', 'in_progress', 5000),
]}
collapseCompletedTurns={false}
language="en"
/>,
);
const planHeaders = Array.from(
container.querySelectorAll<HTMLButtonElement>('button'),
).filter((button) => button.textContent?.includes('Plan'));
expect(planHeaders).toHaveLength(2);
const secondSnapshotText = planHeaders[1].parentElement?.textContent ?? '';
expect(secondSnapshotText).toContain('Prepare release');
expect(secondSnapshotText).toContain('Run verification');
});
it('omits pending permissions and AskUserQuestion controls', () => {
const blocks: DaemonTranscriptBlock[] = [
block({ id: 'u1', kind: 'user', text: 'Before permission' }),
block({
id: 'permission',
kind: 'permission',
requestId: 'request-1',
title: 'Choose a deployment target',
options: [
{ optionId: 'staging', label: 'Staging', raw: {} },
{ optionId: 'production', label: 'Production', raw: {} },
],
toolCall: { toolCallId: 'ask-1', kind: 'think' },
preview: {
kind: 'ask_user_question',
questions: [
{
header: 'Target',
question: 'Where should this deploy?',
options: [
{ label: 'Staging', raw: {} },
{ label: 'Production', raw: {} },
],
raw: {},
},
],
},
}),
];
const { container } = render(<WebShellTranscript blocks={blocks} />);
expect(container.textContent).toContain('Before permission');
expect(container.textContent).not.toContain('Where should this deploy?');
expect(container.textContent).not.toContain('Production');
expect(container.querySelector('button[type="submit"]')).toBeNull();
});
it('renders the historical result when AskUserQuestion has a tool block', () => {
const blocks: DaemonTranscriptBlock[] = [
block({
id: 'permission',
kind: 'permission',
requestId: 'request-1',
title: 'Ask user 1 question',
options: [{ optionId: 'submit', label: 'Submit', raw: {} }],
toolCall: {
toolCallId: 'ask-1',
kind: 'think',
rawInput: {
questions: [
{
header: 'Target',
question: 'Where should this deploy?',
options: [{ label: 'Staging' }, { label: 'Production' }],
},
],
},
},
preview: { kind: 'generic' },
resolved: 'selected:submit',
}),
block(
{
id: 'ask-result',
kind: 'tool',
toolCallId: 'ask-1',
title: 'ask_user_question',
toolName: 'ask_user_question',
status: 'completed',
preview: { kind: 'generic' },
rawOutput: 'User answer: Staging',
},
2,
),
];
const { container } = render(
<WebShellTranscript blocks={blocks} collapseCompletedTurns={false} />,
);
expect(container.textContent).toContain('Ask user 1 question');
expect(container.textContent).toContain('User answer: Staging');
expect(container.querySelector('button[type="submit"]')).toBeNull();
});
it('keeps MessageList turn expansion as a local viewing interaction', () => {
const { container } = render(
<WebShellTranscript
blocks={[
block({ id: 'u1', kind: 'user', text: 'Collapsed turn' }, 1),
block({ id: 't1', kind: 'thought', text: 'Hidden reasoning' }, 2),
block({ id: 'a1', kind: 'assistant', text: 'Final answer' }, 3),
]}
/>,
);
const toggle = container.querySelector('[data-testid="toggle-u1"]');
const row = toggle?.closest('[role="button"]');
expect(row?.getAttribute('aria-expanded')).toBe('false');
expect(container.textContent).not.toContain('Hidden reasoning');
act(() => {
row?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true }),
);
});
expect(row?.getAttribute('aria-expanded')).toBe('true');
expect(container.textContent).not.toContain('Hidden reasoning');
const thinkingToggle = container.querySelector<HTMLButtonElement>(
'button[title="Expand thinking"]',
);
expect(thinkingToggle).not.toBeNull();
act(() => thinkingToggle?.click());
expect(container.textContent).toContain('Hidden reasoning');
});
it('suppresses session and goal events while preserving their text', () => {
const sessionEvents: unknown[] = [];
const goalEvents: unknown[] = [];
const onSession = (event: Event) =>
sessionEvents.push((event as CustomEvent).detail);
const onGoal = (event: Event) =>
goalEvents.push((event as CustomEvent).detail);
window.addEventListener('qwen:open-session', onSession);
window.addEventListener('web-shell-goal-status-active', onGoal);
const { container } = render(
<WebShellTranscript
blocks={[
block({
id: 'assistant',
kind: 'assistant',
text: '[Open child](qwen-session://child-session)',
}),
block(
{
id: 'goal',
kind: 'status',
text: '',
source: 'goal',
data: {
kind: 'set',
condition: 'All checks pass',
setAt: 1,
},
},
2,
),
]}
collapseCompletedTurns={false}
/>,
);
const sessionText = Array.from(container.querySelectorAll('span')).find(
(element) => element.textContent === 'Open child',
);
expect(sessionText).not.toBeUndefined();
expect(container.querySelector('a[role="button"]')).toBeNull();
expect(container.textContent).toContain('All checks pass');
expect(sessionEvents).toEqual([]);
expect(goalEvents).toEqual([]);
window.removeEventListener('qwen:open-session', onSession);
window.removeEventListener('web-shell-goal-status-active', onGoal);
});
it('mounts a themed scoped portal root and removes it on unmount', () => {
const { container, unmount } = render(
<WebShellTranscript blocks={[]} theme="light" language="zh-CN" />,
);
const root = container.querySelector<HTMLElement>('[data-web-shell-root]');
const portal = document.body.querySelector<HTMLElement>(
'[data-web-shell-portal-root]',
);
expect(root?.lang).toBe('zh-CN');
expect(root?.classList.contains('dark')).toBe(false);
expect(portal?.dataset.webShellShadcn).toBe('');
expect(portal?.lang).toBe('zh-CN');
expect(portal?.classList.contains('dark')).toBe(false);
unmount();
expect(
document.body.querySelector('[data-web-shell-portal-root]'),
).toBeNull();
});
it('falls back to the built-in Markdown renderer when customization throws', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(
<WebShellTranscript
blocks={[
block({
id: 'assistant',
kind: 'assistant',
text: 'Before\n\n```ts\nconst value = 1;\n```\n\nAfter',
}),
]}
markdown={{
renderCodeBlock() {
throw new Error('custom renderer failed');
},
}}
/>,
);
expect(container.textContent).toContain('Before');
expect(container.textContent).toContain('const value = 1;');
expect(container.textContent).toContain('After');
});
});