first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AssistantMessage } from '../../apps/web/src/components/AssistantMessage';
|
||||
import type { AgentEvent, ChatMessage } from '../../apps/web/src/types';
|
||||
|
||||
function messageWithEvents(events: AgentEvent[]): ChatMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
events,
|
||||
startedAt: 1_000,
|
||||
endedAt: 3_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AssistantMessage unfinished todo state', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('keeps Done for a completed latest TodoWrite fixture', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={messageWithEvents([
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-1',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Ship layout', status: 'completed' }] },
|
||||
},
|
||||
])}
|
||||
streaming={false}
|
||||
projectId="project-1"
|
||||
isLast
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Done')).toBeTruthy();
|
||||
expect(screen.queryByText('Stopped with unfinished work')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows unfinished state and passes unfinished todos to the continue callback', () => {
|
||||
const onContinue = vi.fn();
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={messageWithEvents([
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-1',
|
||||
name: 'TodoWrite',
|
||||
input: {
|
||||
todos: [
|
||||
{ content: 'Draft layout', status: 'completed' },
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])}
|
||||
streaming={false}
|
||||
projectId="project-1"
|
||||
isLast
|
||||
onContinueRemainingTasks={onContinue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Stopped with unfinished work')).toBeTruthy();
|
||||
expect(screen.getByText('2 task(s) remain')).toBeTruthy();
|
||||
const remainingList = screen.getByText('2 task(s) remain').closest('.unfinished-todos');
|
||||
expect(remainingList).not.toBeNull();
|
||||
expect(within(remainingList as HTMLElement).getByText('Building components')).toBeTruthy();
|
||||
expect(within(remainingList as HTMLElement).getByText('Run QA')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue remaining tasks' }));
|
||||
|
||||
expect(onContinue).toHaveBeenCalledWith([
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides the continue button on older assistant turns', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={messageWithEvents([
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-1',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
|
||||
},
|
||||
])}
|
||||
streaming={false}
|
||||
projectId="project-1"
|
||||
isLast={false}
|
||||
onContinueRemainingTasks={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Stopped with unfinished work')).toBeTruthy();
|
||||
expect(screen.getByText('1 task(s) remain')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatPane } from '../../apps/web/src/components/ChatPane';
|
||||
import type { ChatMessage } from '../../apps/web/src/types';
|
||||
|
||||
function renderChatPane(messages: ChatMessage[]) {
|
||||
return render(
|
||||
<ChatPane
|
||||
messages={messages}
|
||||
streaming={false}
|
||||
error={null}
|
||||
projectId="project-1"
|
||||
projectFiles={[]}
|
||||
onEnsureProject={async () => 'project-1'}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
conversations={[]}
|
||||
activeConversationId={null}
|
||||
onSelectConversation={() => {}}
|
||||
onDeleteConversation={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('conversation timestamps', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows inline relative message times with exact hover text', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-15T14:00:00Z'));
|
||||
|
||||
renderChatPane([
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'Create a landing page',
|
||||
createdAt: Date.parse('2025-01-15T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done',
|
||||
createdAt: Date.parse('2025-01-15T12:01:00Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
const firstTime = screen.getByText('2h ago');
|
||||
expect(firstTime.tagName).toBe('TIME');
|
||||
expect(firstTime.getAttribute('title')).toContain('2025');
|
||||
expect(screen.getByText('1h ago').tagName).toBe('TIME');
|
||||
});
|
||||
|
||||
it('adds day separators when a conversation crosses days', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-16T14:00:00Z'));
|
||||
|
||||
renderChatPane([
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'First request',
|
||||
createdAt: Date.parse('2025-01-15T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
content: 'Follow-up',
|
||||
createdAt: Date.parse('2025-01-16T12:00:00Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PreviewModal } from '../../apps/web/src/components/PreviewModal';
|
||||
|
||||
// Regression coverage for nexu-io/open-design#141: pressing Esc in fullscreen
|
||||
// used to require two presses because the browser exits its native fullscreen
|
||||
// element on the first press without delivering a keydown to JS, leaving the
|
||||
// React `fullscreen` state stuck on. The fix listens to fullscreenchange and
|
||||
// mirrors the native state into React.
|
||||
|
||||
const baseProps = {
|
||||
title: 'Sample',
|
||||
views: [{ id: 'main', label: 'Main', html: '<p>hi</p>' }],
|
||||
exportTitleFor: (id: string) => id,
|
||||
};
|
||||
|
||||
function dispatchFullscreenChange() {
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
}
|
||||
|
||||
function setNativeFullscreenElement(el: Element | null) {
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => el,
|
||||
});
|
||||
}
|
||||
|
||||
describe('PreviewModal fullscreen exit', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setNativeFullscreenElement(null);
|
||||
});
|
||||
|
||||
it('drops the fullscreen overlay when the browser exits native fullscreen', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<PreviewModal {...baseProps} onClose={onClose} />,
|
||||
);
|
||||
|
||||
// Click the Fullscreen button. jsdom does not implement requestFullscreen
|
||||
// on plain elements, so PreviewModal's fallback path runs and just sets
|
||||
// the React state — exactly matching what happens after a successful
|
||||
// browser fullscreen request.
|
||||
const fsButton = container.querySelector(
|
||||
'button[title="Fullscreen"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(fsButton).toBeTruthy();
|
||||
fireEvent.click(fsButton);
|
||||
const stage = container.querySelector('.ds-modal') as HTMLElement;
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
|
||||
|
||||
// Simulate the user pressing Esc in browser fullscreen: the browser
|
||||
// exits its native fullscreen element and fires fullscreenchange, but
|
||||
// (in browsers like Firefox) does not deliver the keydown to JS.
|
||||
setNativeFullscreenElement(null);
|
||||
dispatchFullscreenChange();
|
||||
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the modal mounted on Esc while fullscreen, and closes only on a second Esc', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<PreviewModal {...baseProps} onClose={onClose} />,
|
||||
);
|
||||
const fsButton = container.querySelector(
|
||||
'button[title="Fullscreen"]',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(fsButton);
|
||||
const stage = container.querySelector('.ds-modal') as HTMLElement;
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
|
||||
|
||||
// First Esc — drops fullscreen, must not close the modal.
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Second Esc — closes the modal.
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores fullscreenchange when another element is still fullscreen', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<PreviewModal {...baseProps} onClose={onClose} />,
|
||||
);
|
||||
const fsButton = container.querySelector(
|
||||
'button[title="Fullscreen"]',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(fsButton);
|
||||
const stage = container.querySelector('.ds-modal') as HTMLElement;
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
|
||||
|
||||
// Some other element is the active fullscreen target — our overlay must
|
||||
// not collapse to non-fullscreen on transitions that leave a different
|
||||
// element fullscreen.
|
||||
const other = document.createElement('div');
|
||||
document.body.appendChild(other);
|
||||
setNativeFullscreenElement(other);
|
||||
dispatchFullscreenChange();
|
||||
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
|
||||
document.body.removeChild(other);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createClaudeStreamHandler } from '../../apps/daemon/src/claude-stream.js';
|
||||
import { createCopilotStreamHandler } from '../../apps/daemon/src/copilot-stream.js';
|
||||
import { mapPiRpcEvent } from '../../apps/daemon/src/pi-rpc.js';
|
||||
|
||||
describe('structured agent stream fixtures', () => {
|
||||
it('emits TodoWrite tool_use from Claude Code stream JSON', () => {
|
||||
const events: unknown[] = [];
|
||||
const handler = createClaudeStreamHandler((event: unknown) => events.push(event));
|
||||
handler.feed(`${JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
id: 'msg-1',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu-1',
|
||||
name: 'TodoWrite',
|
||||
input: {
|
||||
todos: [{ content: 'Run QA', status: 'pending' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})}\n`);
|
||||
handler.flush();
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'tool_use',
|
||||
id: 'toolu-1',
|
||||
name: 'TodoWrite',
|
||||
input: {
|
||||
todos: [{ content: 'Run QA', status: 'pending' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('emits TodoWrite tool_use from Pi RPC tool_execution events', () => {
|
||||
const events: unknown[] = [];
|
||||
const send = (_channel: string, payload: unknown) => { events.push(payload); };
|
||||
const ctx = { runStartedAt: Date.now(), sentFirstToken: { value: false } };
|
||||
|
||||
mapPiRpcEvent(
|
||||
{ type: 'tool_execution_start', toolCallId: 'pi-call-1', toolName: 'TodoWrite', args: { todos: [{ content: 'Run QA', status: 'pending' }] } },
|
||||
send,
|
||||
ctx,
|
||||
);
|
||||
mapPiRpcEvent(
|
||||
{ type: 'tool_execution_end', toolCallId: 'pi-call-1', toolName: 'TodoWrite', result: { content: [{ type: 'text', text: 'written' }] }, isError: false },
|
||||
send,
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'tool_use',
|
||||
id: 'pi-call-1',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
|
||||
});
|
||||
expect(events).toContainEqual({
|
||||
type: 'tool_result',
|
||||
toolUseId: 'pi-call-1',
|
||||
content: 'written',
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits TodoWrite tool_use from GitHub Copilot CLI JSON stream', () => {
|
||||
const events: unknown[] = [];
|
||||
const handler = createCopilotStreamHandler((event: unknown) => events.push(event));
|
||||
handler.feed(`${JSON.stringify({
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'TodoWrite',
|
||||
arguments: {
|
||||
todos: [{ content: 'Run QA', status: 'pending' }],
|
||||
},
|
||||
},
|
||||
})}\n`);
|
||||
handler.flush();
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: 'tool_use',
|
||||
id: 'call-1',
|
||||
name: 'TodoWrite',
|
||||
input: {
|
||||
todos: [{ content: 'Run QA', status: 'pending' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
latestTodosFromEvents,
|
||||
parseTodoWriteInput,
|
||||
unfinishedTodosFromEvents,
|
||||
} from '../../apps/web/src/runtime/todos';
|
||||
import type { AgentEvent } from '../../apps/web/src/types';
|
||||
|
||||
const firstTodoInput = {
|
||||
todos: [
|
||||
{ content: 'Draft layout', status: 'completed' },
|
||||
{ content: 'Build components', status: 'in_progress', activeForm: 'Building components' },
|
||||
{ content: 'Run QA', status: 'pending' },
|
||||
{ content: '', status: 'pending' },
|
||||
{ content: 'Unknown status defaults pending', status: 'blocked' },
|
||||
null,
|
||||
],
|
||||
};
|
||||
|
||||
describe('todo event helpers', () => {
|
||||
it('normalizes TodoWrite input and ignores malformed items', () => {
|
||||
expect(parseTodoWriteInput(firstTodoInput)).toEqual([
|
||||
{ content: 'Draft layout', status: 'completed', activeForm: undefined },
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
||||
{
|
||||
content: 'Unknown status defaults pending',
|
||||
status: 'pending',
|
||||
activeForm: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the latest TodoWrite event as the current todo truth', () => {
|
||||
const events: AgentEvent[] = [
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
{ kind: 'text', text: 'Working...' },
|
||||
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-2',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Final polish', status: 'pending' }] },
|
||||
},
|
||||
];
|
||||
|
||||
expect(latestTodosFromEvents(events)).toEqual([
|
||||
{ content: 'Final polish', status: 'pending', activeForm: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an empty latest TodoWrite event as authoritative', () => {
|
||||
const events: AgentEvent[] = [
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
{ kind: 'text', text: 'All done.' },
|
||||
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
|
||||
];
|
||||
|
||||
expect(latestTodosFromEvents(events)).toEqual([]);
|
||||
expect(unfinishedTodosFromEvents(events)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only pending and in-progress todos as unfinished', () => {
|
||||
expect(unfinishedTodosFromEvents([
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
])).toEqual([
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
||||
{
|
||||
content: 'Unknown status defaults pending',
|
||||
status: 'pending',
|
||||
activeForm: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user