263 lines
7.9 KiB
TypeScript
263 lines
7.9 KiB
TypeScript
import React, { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
|
|
|
|
const cliLogsRichViewState = vi.hoisted(() => ({
|
|
calls: [] as Record<string, unknown>[],
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/CliLogsRichView', () => ({
|
|
CliLogsRichView: (props: Record<string, unknown>) => {
|
|
cliLogsRichViewState.calls.push(props);
|
|
const cliLogsTail = typeof props.cliLogsTail === 'string' ? props.cliLogsTail : '';
|
|
return React.createElement('div', { 'data-testid': 'cli-logs-rich-view' }, cliLogsTail);
|
|
},
|
|
}));
|
|
|
|
vi.mock('@renderer/components/team/ClaudeLogsFilterPopover', () => ({
|
|
ClaudeLogsFilterPopover: () => React.createElement('div', { 'data-testid': 'logs-filter' }),
|
|
}));
|
|
|
|
vi.mock('@renderer/components/ui/button', () => ({
|
|
Button: ({
|
|
children,
|
|
onClick,
|
|
disabled,
|
|
}: {
|
|
children: React.ReactNode;
|
|
onClick?: () => void;
|
|
disabled?: boolean;
|
|
}) => React.createElement('button', { type: 'button', onClick, disabled }, children),
|
|
}));
|
|
|
|
import { ClaudeLogsPanel } from '@renderer/components/team/ClaudeLogsPanel';
|
|
|
|
function createController(overrides: Partial<ClaudeLogsController> = {}): ClaudeLogsController {
|
|
return {
|
|
data: { lines: [], total: 0, hasMore: false },
|
|
loading: false,
|
|
loadingMore: false,
|
|
error: null,
|
|
pendingNewCount: 0,
|
|
isAlive: false,
|
|
filteredText: '',
|
|
online: false,
|
|
badge: undefined,
|
|
showMoreVisible: false,
|
|
lastLogPreview: null,
|
|
searchQuery: '',
|
|
setSearchQuery: vi.fn(),
|
|
filter: { streams: new Set(), kinds: new Set() } as ClaudeLogsController['filter'],
|
|
setFilter: vi.fn(),
|
|
filterOpen: false,
|
|
setFilterOpen: vi.fn(),
|
|
viewerState: {} as ClaudeLogsController['viewerState'],
|
|
onViewerStateChange: vi.fn(),
|
|
applyPending: vi.fn(() => Promise.resolve()),
|
|
loadOlderLogs: vi.fn(() => Promise.resolve()),
|
|
containerRefCallback: vi.fn(),
|
|
handleScroll: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('ClaudeLogsPanel', () => {
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
cliLogsRichViewState.calls = [];
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('renders logs even when the team is offline if log lines are available', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const ctrl = createController({
|
|
isAlive: false,
|
|
data: {
|
|
lines: ['second line', 'first line'],
|
|
total: 2,
|
|
hasMore: false,
|
|
updatedAt: '2026-04-19T10:00:01.000Z',
|
|
},
|
|
filteredText: '[stdout]\nfirst line\nsecond line',
|
|
badge: '2 raw',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ClaudeLogsPanel, { ctrl }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('2 raw lines');
|
|
expect(host.textContent).toContain('first line');
|
|
expect(host.textContent).not.toContain('Team is not running.');
|
|
expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).not.toBeNull();
|
|
expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe(
|
|
'[stdout]\nfirst line\nsecond line'
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('renders leading toolbar controls before the search field', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const ctrl = createController({
|
|
isAlive: true,
|
|
data: {
|
|
lines: ['lead output'],
|
|
total: 1,
|
|
hasMore: false,
|
|
},
|
|
filteredText: 'lead output',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(ClaudeLogsPanel, {
|
|
ctrl,
|
|
toolbarControlsStart: React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'toolbar-source' },
|
|
'Lead'
|
|
),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const source = host.querySelector('[data-testid="toolbar-source"]');
|
|
const search = host.querySelector('input[placeholder="Search logs..."]');
|
|
expect(source).not.toBeNull();
|
|
expect(search).not.toBeNull();
|
|
expect(source?.compareDocumentPosition(search as Node)).toBe(
|
|
Node.DOCUMENT_POSITION_FOLLOWING
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('shows the offline empty state only when no logs exist', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const ctrl = createController({
|
|
isAlive: false,
|
|
data: { lines: [], total: 0, hasMore: false },
|
|
filteredText: '',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ClaudeLogsPanel, { ctrl }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('Team is not running.');
|
|
expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('explains raw-only logs instead of showing an empty displayable-log message', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const ctrl = createController({
|
|
isAlive: true,
|
|
data: {
|
|
lines: [
|
|
'[stdout] {"type":"system","subtype":"init"}',
|
|
'[stdout] {"type":"thread.started","thread_id":"thread-1"}',
|
|
],
|
|
total: 16,
|
|
hasMore: false,
|
|
},
|
|
filteredText: '[stdout]\n{"type":"thread.started","thread_id":"thread-1"}',
|
|
badge: '16 raw',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(React.createElement(ClaudeLogsPanel, { ctrl }));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(host.textContent).toContain('16 raw lines captured');
|
|
expect(cliLogsRichViewState.calls.at(-1)?.emptyMessageOverride).toBe(
|
|
'16 raw lines captured; none are assistant/tool output yet.'
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('renders toolbar accessory beside log search and filters', async () => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const ctrl = createController({
|
|
isAlive: true,
|
|
data: {
|
|
lines: ['[stdout] ready'],
|
|
total: 1,
|
|
hasMore: false,
|
|
},
|
|
filteredText: '[stdout]\nready',
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
React.createElement(ClaudeLogsPanel, {
|
|
ctrl,
|
|
compactMetaInTooltip: true,
|
|
toolbarAccessory: React.createElement(
|
|
'button',
|
|
{ type: 'button', 'data-testid': 'log-member-selector' },
|
|
'Lead'
|
|
),
|
|
})
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const search = host.querySelector('input[placeholder="Search logs..."]');
|
|
const accessory = host.querySelector('[data-testid="log-member-selector"]');
|
|
const filter = host.querySelector('[data-testid="logs-filter"]');
|
|
|
|
expect(search).not.toBeNull();
|
|
expect(accessory).not.toBeNull();
|
|
expect(filter).not.toBeNull();
|
|
expect(search?.parentElement?.className).toContain('flex-1');
|
|
expect(search?.compareDocumentPosition(accessory as Node) ?? 0).toBe(
|
|
Node.DOCUMENT_POSITION_FOLLOWING
|
|
);
|
|
expect(accessory?.compareDocumentPosition(filter as Node) ?? 0).toBe(
|
|
Node.DOCUMENT_POSITION_FOLLOWING
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
});
|