import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { CloudChat } from '@/pages/CloudAgents/CloudChat'; import type { CloudHistory, CloudMessage, CloudRun, CloudThread } from '../../shared/cloud-agents'; const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), download: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; } let stream: Stream; let history: CloudHistory; const now = new Date(2026, 9, 13, 17, 30, 40); const sent = new Date(2026, 9, 13, 17, 24, 12).toISOString(); const replied = new Date(2026, 9, 13, 17, 25, 34).toISOString(); const run: CloudRun = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' }; const thread: CloudThread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' }; const saved = (fields: Partial = {}): CloudMessage => ({ id: 1, role: 'assistant', message_id: 'answer', run_id: 'run', request_id: 'request', created_at: replied, content: '已经保存的回答', ...fields, }); const articleFor = (text: string) => { const article = screen.getByText(text).closest('article'); expect(article).not.toBeNull(); return article!; }; async function emitAnswer(id = 'answer', content = '正在写的回答') { await act(async () => stream.dispatchEvent(new MessageEvent('messages', { lastEventId: '100-1', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { items: [{ stream_event: { type: 'message_delta', message_id: id, content } }], } }), }))); } async function finish(status = 'completed') { await act(async () => stream.dispatchEvent(new MessageEvent('end', { lastEventId: '100-1', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status } }), }))); } beforeEach(() => { vi.resetAllMocks(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(now); stream = new Stream(); history = { thread_id: 'thread', messages: [], run, next_offset: null }; api.recovery.mockResolvedValue({ recent: null, pending: [] }); api.remember.mockResolvedValue({}); api.events.mockResolvedValue(stream); api.call.mockImplementation(async (operation: string) => { if (operation === 'threads') return { threads: [thread], next_offset: null }; if (operation === 'history') return history; if (operation === 'run') return history.run; if (operation === 'files') return { files: [] }; if (operation === 'attachments') return { attachments: [] }; if (operation === 'viewed') return {}; throw new Error(operation); }); }); afterEach(() => { cleanup(); vi.useRealTimers(); }); it('displays cloud creation times for sent questions and replies with semantic time and full precision', async () => { history = { ...history, run: { ...run, status: 'completed' }, messages: [ saved({ id: 1, role: 'user', message_id: 'question', created_at: sent, content: '已经发送的问题' }), saved({ id: 2 }), ] }; const view = render(); await screen.findByText('已经保存的回答'); const questionTime = within(articleFor('已经发送的问题')).getByLabelText('发送时间:2026年10月13日 17:24:12'); expect(questionTime.tagName).toBe('TIME'); expect(questionTime).toHaveTextContent(/^17:24$/); expect(questionTime).toHaveAttribute('datetime', sent); expect(questionTime).toHaveAttribute('title', '2026年10月13日 17:24:12'); const replyTime = within(articleFor('已经保存的回答')).getByLabelText('回复时间:2026年10月13日 17:25:34'); expect(replyTime).toHaveTextContent(/^17:25$/); expect(replyTime).toHaveAttribute('datetime', replied); expect(view.container.querySelectorAll('time')).toHaveLength(2); expect(api.events).not.toHaveBeenCalled(); }); it('includes the date for older messages and the year when needed', async () => { const yesterday = new Date(2026, 9, 12, 23, 59, 58).toISOString(); const lastYear = new Date(2025, 9, 12, 9, 5, 6).toISOString(); history = { ...history, run: { ...run, status: 'completed' }, messages: [ saved({ id: 1, role: 'user', created_at: yesterday, content: '昨天的问题' }), saved({ id: 2, created_at: lastYear, content: '去年的回答' }), ] }; render(); await screen.findByText('去年的回答'); expect(within(articleFor('昨天的问题')).getByText('10月12日 23:59')).toHaveAttribute('datetime', yesterday); expect(within(articleFor('去年的回答')).getByText('2025年10月12日 09:05')).toHaveAttribute('title', '2025年10月12日 09:05:06'); }); it('does not fabricate timestamps for legacy or invalid cloud records', async () => { history = { ...history, run: { ...run, status: 'completed' }, messages: [ saved({ id: 1, role: 'user', created_at: '', content: '没有记录时间的问题' }), saved({ id: 2, created_at: 'invalid-date', content: '时间无效的回答' }), ] }; const view = render(); await screen.findByText('时间无效的回答'); expect(within(articleFor('没有记录时间的问题')).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown'); expect(within(articleFor('时间无效的回答')).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown'); expect(view.container.querySelector('time')).toBeNull(); }); it('preserves the original cloud timestamp when a conversation is read again on a later day', async () => { history = { ...history, run: { ...run, status: 'completed' }, messages: [saved()] }; const view = render(); await screen.findByText('已经保存的回答'); expect(within(articleFor('已经保存的回答')).getByText('17:25')).toHaveAttribute('datetime', replied); view.unmount(); vi.setSystemTime(new Date(2026, 9, 14, 10, 0, 0)); render(); await screen.findByText('已经保存的回答'); expect(within(articleFor('已经保存的回答')).getByText('10月13日 17:25')).toHaveAttribute('datetime', replied); expect(api.call.mock.calls.filter(([operation]) => operation === 'history')).toHaveLength(2); }); it('replaces the pending reply marker with its authoritative cloud timestamp once, after final history arrives', async () => { const view = render(); await waitFor(() => expect(api.events).toHaveBeenCalledWith('run')); await emitAnswer(); expect(within(articleFor('正在写的回答')).getByText('回复中…')).toHaveAttribute('data-message-time', 'pending'); expect(view.container.querySelector('time')).toBeNull(); history = { ...history, run: { ...run, status: 'completed' }, messages: [saved({ content: '完整的已保存回答' })] }; await finish(); await screen.findByText('完整的已保存回答'); expect(screen.queryByText('正在写的回答')).not.toBeInTheDocument(); expect(screen.queryByText('回复中…')).not.toBeInTheDocument(); expect(view.container.querySelectorAll('time')).toHaveLength(1); expect(within(articleFor('完整的已保存回答')).getByLabelText('回复时间:2026年10月13日 17:25:34')).toHaveAttribute('datetime', replied); }); it('keeps a known source timestamp when live content replaces its partial saved history', async () => { history = { ...history, messages: [saved({ content: '已保存的片段' })] }; const view = render(); await waitFor(() => expect(api.events).toHaveBeenCalled()); await emitAnswer('answer', '实时补全的回答'); expect(screen.queryByText('已保存的片段')).not.toBeInTheDocument(); expect(within(articleFor('实时补全的回答')).getByLabelText('回复时间:2026年10月13日 17:25:34')).toHaveAttribute('datetime', replied); expect(view.container.querySelectorAll('time')).toHaveLength(1); expect(screen.queryByText('回复中…')).not.toBeInTheDocument(); }); it('retains the original date if an older source message is replayed by a newer run', async () => { const oldTime = new Date(2026, 9, 11, 8, 15, 3).toISOString(); history = { ...history, messages: [saved({ run_id: 'old-run', request_id: 'old-request', created_at: oldTime, content: '旧回答的历史记录' })] }; render(); await waitFor(() => expect(api.events).toHaveBeenCalled()); await emitAnswer('answer', '旧回答再次推送的内容'); const replay = articleFor('旧回答再次推送的内容'); expect(within(replay).getByLabelText('回复时间:2026年10月11日 08:15:03')).toHaveTextContent('10月11日 08:15'); expect(replay.querySelector('time')).toHaveAttribute('datetime', oldTime); expect(replay.querySelector('time')).not.toHaveAttribute('datetime', now.toISOString()); }); it('does not borrow a question timestamp for a streamed reply with the same source id', async () => { history = { ...history, messages: [saved({ role: 'user', created_at: sent, content: '历史中的问题' })] }; render(); await waitFor(() => expect(api.events).toHaveBeenCalled()); await emitAnswer(); const reply = articleFor('正在写的回答'); expect(within(reply).getByText('回复中…')).toHaveAttribute('data-message-time', 'pending'); expect(reply.querySelector('time')).toBeNull(); }); it('marks a terminal unsaved reply as unrecorded if history refresh fails instead of using the local clock', async () => { render(); await waitFor(() => expect(api.events).toHaveBeenCalled()); await emitAnswer(); const implementation = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, ...args) => { if (operation === 'history') throw new Error('历史暂不可用'); return implementation(operation, ...args); }); await finish('failed'); await screen.findByText('历史暂不可用'); const reply = articleFor('正在写的回答'); expect(within(reply).getByText('时间未记录')).toHaveAttribute('data-message-time', 'unknown'); expect(reply.querySelector('time')).toBeNull(); expect(screen.queryByText('回复中…')).not.toBeInTheDocument(); });