mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-25 22:28:04 +00:00
943 lines
37 KiB
TypeScript
943 lines
37 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ApiClient } from '@/api/client'
|
|
import type { DecryptedMessage } from '@/types/api'
|
|
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
|
|
import {
|
|
computeCanCancel,
|
|
computeEditPendingSchedule,
|
|
getQueuedMessageEditText,
|
|
getQueuedMessagePreview,
|
|
QueuedMessagesBar,
|
|
sortQueuedMessages,
|
|
} from './QueuedMessagesBar'
|
|
import { formatScheduledTime } from '@/lib/scheduledTime'
|
|
import { clearQueuedEditRecovery, getQueuedEditRecovery } from '@/lib/queued-edit-recovery'
|
|
|
|
type DeferredCancelResult = { status: 'cancelled' | 'invoked' | 'busy'; localId?: string }
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
composerText: '',
|
|
composerSetText: vi.fn(),
|
|
addToast: vi.fn(),
|
|
mutateAsync: vi.fn(),
|
|
resolveCancel: null as ((result: DeferredCancelResult) => void) | null,
|
|
rejectCancel: null as ((reason?: unknown) => void) | null,
|
|
steerMessage: vi.fn(),
|
|
resolveSteer: null as ((result: unknown) => void) | null,
|
|
markMessagesConsumed: vi.fn(),
|
|
appendOptimisticMessage: vi.fn(),
|
|
saveDraft: vi.fn(),
|
|
messageWindowState: { messages: [] as unknown[] },
|
|
}))
|
|
|
|
vi.mock('@assistant-ui/react', () => ({
|
|
useAui: () => ({
|
|
composer: () => ({
|
|
getState: () => ({ text: mocks.composerText }),
|
|
setText: mocks.composerSetText,
|
|
}),
|
|
}),
|
|
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({
|
|
composer: { text: mocks.composerText },
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/message-window-store', () => ({
|
|
getMessageWindowState: () => mocks.messageWindowState,
|
|
subscribeMessageWindow: () => () => {},
|
|
markMessagesConsumed: mocks.markMessagesConsumed,
|
|
appendOptimisticMessage: mocks.appendOptimisticMessage,
|
|
}))
|
|
|
|
vi.mock('@/hooks/mutations/useCancelQueuedMessage', () => ({
|
|
useCancelQueuedMessage: () => ({
|
|
isPending: false,
|
|
variables: undefined,
|
|
mutateAsync: mocks.mutateAsync,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/composer-drafts', () => ({
|
|
saveDraft: mocks.saveDraft,
|
|
}))
|
|
|
|
vi.mock('@/lib/use-translation', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string) => ({
|
|
'queuedMessages.edit': 'Edit queued message',
|
|
'queuedMessages.cancel': 'Cancel queued message',
|
|
'queuedMessages.steerNow': 'Steer now',
|
|
})[key] ?? key,
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/toast-context', () => ({
|
|
useToast: () => ({ addToast: mocks.addToast }),
|
|
}))
|
|
|
|
function makeQueuedMessage(
|
|
scheduledAt: number | null = null,
|
|
id = 'server-message-id',
|
|
overrides: Partial<DecryptedMessage> = {},
|
|
): DecryptedMessage {
|
|
return {
|
|
id,
|
|
localId: `local-${id}`,
|
|
createdAt: 1000,
|
|
seq: 1,
|
|
scheduledAt,
|
|
invokedAt: null,
|
|
status: 'queued',
|
|
content: {
|
|
role: 'user',
|
|
content: { type: 'text', text: 'Queued request' },
|
|
},
|
|
...overrides,
|
|
} as unknown as DecryptedMessage
|
|
}
|
|
|
|
function renderQueuedMessage(
|
|
scheduledAt: number | null = null,
|
|
pendingSchedule: PendingSchedule | null = null,
|
|
pendingScheduleRevision = 0,
|
|
canSteer = false,
|
|
api: ApiClient | null = null,
|
|
) {
|
|
const onEdit = vi.fn()
|
|
let currentPendingScheduleRevision = pendingScheduleRevision
|
|
mocks.messageWindowState = { messages: [makeQueuedMessage(scheduledAt)] }
|
|
// The real useSteerQueuedMessage hook runs inside the bar, so every render
|
|
// needs a QueryClient (its mutations use the tanstack defaults).
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { mutations: { retry: false } },
|
|
})
|
|
const view = render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<QueuedMessagesBar
|
|
sessionId="session-1"
|
|
api={api}
|
|
pendingSchedule={pendingSchedule}
|
|
pendingScheduleRevision={currentPendingScheduleRevision}
|
|
onEdit={onEdit}
|
|
sessionMetadata={canSteer ? { flavor: 'pi' } : null}
|
|
steeringActive={canSteer}
|
|
canSteer={canSteer}
|
|
/>
|
|
</QueryClientProvider>
|
|
)
|
|
return {
|
|
onEdit,
|
|
unmount: view.unmount,
|
|
rerender: (nextPendingSchedule: PendingSchedule | null, nextPendingScheduleRevision = currentPendingScheduleRevision) => {
|
|
currentPendingScheduleRevision = nextPendingScheduleRevision
|
|
view.rerender(
|
|
<QueryClientProvider client={queryClient}>
|
|
<QueuedMessagesBar
|
|
sessionId="session-1"
|
|
api={api}
|
|
pendingSchedule={nextPendingSchedule}
|
|
pendingScheduleRevision={currentPendingScheduleRevision}
|
|
onEdit={onEdit}
|
|
sessionMetadata={canSteer ? { flavor: 'pi' } : null}
|
|
steeringActive={canSteer}
|
|
canSteer={canSteer}
|
|
/>
|
|
</QueryClientProvider>
|
|
)
|
|
},
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mocks.composerText = ''
|
|
mocks.composerSetText.mockReset()
|
|
mocks.addToast.mockReset()
|
|
mocks.mutateAsync.mockReset()
|
|
mocks.resolveCancel = null
|
|
mocks.rejectCancel = null
|
|
mocks.steerMessage.mockReset()
|
|
mocks.resolveSteer = null
|
|
mocks.markMessagesConsumed.mockReset()
|
|
mocks.appendOptimisticMessage.mockReset()
|
|
mocks.saveDraft.mockReset()
|
|
mocks.messageWindowState = { messages: [] }
|
|
clearQueuedEditRecovery('session-1')
|
|
mocks.mutateAsync.mockImplementation(() => new Promise<DeferredCancelResult>((resolve, reject) => {
|
|
mocks.resolveCancel = resolve
|
|
mocks.rejectCancel = reject
|
|
}))
|
|
})
|
|
|
|
async function resolveCancel(result: DeferredCancelResult): Promise<void> {
|
|
await act(async () => {
|
|
mocks.resolveCancel?.(result)
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
|
|
function installManualAnimationFrames() {
|
|
let nextHandle = 1
|
|
const pending = new Map<number, FrameRequestCallback>()
|
|
const history = new Map<number, FrameRequestCallback>()
|
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
const handle = nextHandle++
|
|
pending.set(handle, callback)
|
|
history.set(handle, callback)
|
|
return handle
|
|
})
|
|
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
|
|
pending.delete(handle)
|
|
})
|
|
|
|
return {
|
|
flushPending() {
|
|
while (pending.size > 0) {
|
|
const [handle, callback] = pending.entries().next().value as [number, FrameRequestCallback]
|
|
pending.delete(handle)
|
|
callback(Date.now())
|
|
}
|
|
},
|
|
flushHistory() {
|
|
for (const callback of history.values()) {
|
|
callback(Date.now())
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('QueuedMessagesBar layout', () => {
|
|
it('keeps the queue footer flush with the composer area', () => {
|
|
renderQueuedMessage()
|
|
|
|
const bar = screen.getByRole('status')
|
|
const content = bar.firstElementChild
|
|
|
|
expect(bar).not.toHaveClass('mb-1')
|
|
expect(content).toHaveClass('pt-2', 'pb-0')
|
|
expect(content).not.toHaveClass('py-2')
|
|
})
|
|
})
|
|
|
|
describe('QueuedMessagesBar steer', () => {
|
|
function renderSupportedSteer(options: { steeringActive?: boolean; isThinking?: boolean }) {
|
|
const steerQueuedMessage = vi.fn().mockResolvedValue({
|
|
status: 'steered',
|
|
localId: 'local-server-message-id',
|
|
})
|
|
const api = { steerQueuedMessage } as unknown as ApiClient
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { mutations: { retry: false } },
|
|
})
|
|
mocks.messageWindowState = { messages: [makeQueuedMessage()] }
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<QueuedMessagesBar
|
|
sessionId="session-1"
|
|
api={api}
|
|
sessionMetadata={{ flavor: 'codex' }}
|
|
steeringActive={options.steeringActive}
|
|
isThinking={options.isThinking}
|
|
pendingSchedule={null}
|
|
pendingScheduleRevision={0}
|
|
/>
|
|
</QueryClientProvider>
|
|
)
|
|
return steerQueuedMessage
|
|
}
|
|
|
|
it('keeps steer available while a supported remote session is thinking', async () => {
|
|
const steerQueuedMessage = renderSupportedSteer({ steeringActive: false, isThinking: true })
|
|
|
|
const steerButton = screen.getByRole('button', { name: 'Steer now' })
|
|
expect(steerButton).not.toBeDisabled()
|
|
fireEvent.click(steerButton)
|
|
|
|
await waitFor(() => expect(steerQueuedMessage).toHaveBeenCalledTimes(1))
|
|
await waitFor(() => expect(steerButton).not.toBeDisabled())
|
|
})
|
|
|
|
it('starts only one steer operation for synchronous double clicks', async () => {
|
|
const steerQueuedMessage = renderSupportedSteer({ steeringActive: true })
|
|
|
|
const steerButton = screen.getByRole('button', { name: 'Steer now' })
|
|
fireEvent.click(steerButton)
|
|
fireEvent.click(steerButton)
|
|
|
|
await waitFor(() => expect(steerQueuedMessage).toHaveBeenCalledTimes(1))
|
|
await waitFor(() => expect(steerButton).not.toBeDisabled())
|
|
})
|
|
})
|
|
|
|
describe('QueuedMessagesBar edit restore', () => {
|
|
it('keeps a newly typed draft and its schedule when the deferred cancel succeeds', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { onEdit } = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
expect(mocks.resolveCancel).not.toBeNull()
|
|
|
|
// The user starts a new draft while DELETE /messages/:id is still pending.
|
|
mocks.composerText = 'New draft typed while cancelling'
|
|
await resolveCancel({ status: 'cancelled' })
|
|
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(onEdit).not.toHaveBeenCalled()
|
|
expect(mocks.addToast).toHaveBeenCalledWith({
|
|
title: 'queuedMessages.editCurrentDraftKept',
|
|
body: '',
|
|
sessionId: 'session-1',
|
|
url: window.location.href,
|
|
})
|
|
})
|
|
|
|
it('keeps a newly selected schedule when the composer text is unchanged', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { onEdit, rerender } = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
// The user changes only the clock selection while DELETE /messages/:id is pending.
|
|
rerender({ type: 'preset', preset: '+30m' }, 1)
|
|
await resolveCancel({ status: 'cancelled' })
|
|
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(onEdit).not.toHaveBeenCalled()
|
|
expect(mocks.addToast).toHaveBeenCalledWith({
|
|
title: 'queuedMessages.editCurrentDraftKept',
|
|
body: '',
|
|
sessionId: 'session-1',
|
|
url: window.location.href,
|
|
})
|
|
})
|
|
|
|
it('keeps the current state after selecting and then clearing a schedule', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { onEdit, rerender } = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
rerender({ type: 'preset', preset: '+30m' }, 1)
|
|
rerender(null, 2)
|
|
await resolveCancel({ status: 'cancelled' })
|
|
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(onEdit).not.toHaveBeenCalled()
|
|
expect(mocks.addToast).toHaveBeenCalledWith({
|
|
title: 'queuedMessages.editCurrentDraftKept',
|
|
body: '',
|
|
sessionId: 'session-1',
|
|
url: window.location.href,
|
|
})
|
|
})
|
|
|
|
it('restores both text and schedule when the composer is unchanged', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { onEdit } = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
await resolveCancel({ status: 'cancelled' })
|
|
|
|
expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request')
|
|
expect(onEdit).toHaveBeenCalledWith({
|
|
text: 'Queued request',
|
|
pendingSchedule: { type: 'absolute', ms: scheduledAt },
|
|
})
|
|
expect(mocks.addToast).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('treats structurally equal schedule props as unchanged', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { onEdit, rerender } = renderQueuedMessage(scheduledAt, { type: 'preset', preset: '+5m' })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
rerender({ type: 'preset', preset: '+5m' })
|
|
await resolveCancel({ status: 'cancelled' })
|
|
|
|
expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request')
|
|
expect(onEdit).toHaveBeenCalledWith({
|
|
text: 'Queued request',
|
|
pendingSchedule: { type: 'absolute', ms: scheduledAt },
|
|
})
|
|
})
|
|
|
|
it('globally disables queued operations and keeps the first edit completion', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const first = makeQueuedMessage(null, 'server-message-a')
|
|
const second = makeQueuedMessage(scheduledAt, 'server-message-b')
|
|
mocks.messageWindowState = { messages: [first, second] }
|
|
const onEdit = vi.fn()
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { mutations: { retry: false } },
|
|
})
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<QueuedMessagesBar
|
|
sessionId="session-1"
|
|
api={null}
|
|
pendingSchedule={null}
|
|
pendingScheduleRevision={0}
|
|
onEdit={onEdit}
|
|
/>
|
|
</QueryClientProvider>
|
|
)
|
|
|
|
const editButtons = screen.getAllByRole('button', { name: 'Edit queued message' })
|
|
const cancelButtons = screen.getAllByRole('button', { name: 'Cancel queued message' })
|
|
// QueuedMessagesBar orders immediate rows first, so the scheduled edit is second.
|
|
fireEvent.click(editButtons[1]!)
|
|
|
|
await waitFor(() => {
|
|
for (const button of [...screen.getAllByRole('button', { name: 'Edit queued message' }), ...screen.getAllByRole('button', { name: 'Cancel queued message' })]) {
|
|
expect(button).toBeDisabled()
|
|
}
|
|
})
|
|
fireEvent.click(cancelButtons[0]!)
|
|
fireEvent.click(editButtons[0]!)
|
|
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
|
|
|
|
await resolveCancel({ status: 'cancelled' })
|
|
expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request')
|
|
expect(onEdit).toHaveBeenCalledWith({
|
|
text: 'Queued request',
|
|
pendingSchedule: { type: 'absolute', ms: scheduledAt },
|
|
})
|
|
})
|
|
|
|
it('persists an unmounted edit result and restores it after the same session remounts', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { unmount } = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
unmount()
|
|
await resolveCancel({ status: 'cancelled' })
|
|
|
|
expect(mocks.saveDraft).not.toHaveBeenCalled()
|
|
expect(getQueuedEditRecovery('session-1')).toEqual(expect.objectContaining({
|
|
text: 'Queued request',
|
|
pendingSchedule: { type: 'absolute', ms: scheduledAt },
|
|
composerTextAtEdit: '',
|
|
pendingScheduleAtEdit: null,
|
|
}))
|
|
|
|
const { onEdit } = renderQueuedMessage(scheduledAt)
|
|
await waitFor(() => expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request'))
|
|
expect(onEdit).toHaveBeenCalledWith({
|
|
text: 'Queued request',
|
|
pendingSchedule: { type: 'absolute', ms: scheduledAt },
|
|
})
|
|
expect(getQueuedEditRecovery('session-1')).toBeNull()
|
|
})
|
|
|
|
it('notifies a same-session remount that returns before the edit cancellation completes', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { unmount } = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
unmount()
|
|
const { onEdit } = renderQueuedMessage(scheduledAt)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: 'Edit queued message' })).toBeDisabled()
|
|
expect(screen.getByRole('button', { name: 'Cancel queued message' })).toBeDisabled()
|
|
})
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel queued message' }))
|
|
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
|
|
|
|
await resolveCancel({ status: 'cancelled' })
|
|
|
|
await waitFor(() => expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request'))
|
|
expect(onEdit).toHaveBeenCalledWith({
|
|
text: 'Queued request',
|
|
pendingSchedule: { type: 'absolute', ms: scheduledAt },
|
|
})
|
|
expect(getQueuedEditRecovery('session-1')).toBeNull()
|
|
})
|
|
|
|
it('keeps a remounted session busy until its queued-edit recovery is consumed', async () => {
|
|
const raf = installManualAnimationFrames()
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { unmount } = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
unmount()
|
|
const { onEdit } = renderQueuedMessage(scheduledAt)
|
|
await resolveCancel({ status: 'cancelled' })
|
|
|
|
// The mutation token has ended, but the unconsumed recovery keeps Q2 busy.
|
|
expect(screen.getByRole('button', { name: 'Edit queued message' })).toBeDisabled()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel queued message' }))
|
|
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
|
|
|
|
raf.flushPending()
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Edit queued message' })).not.toBeDisabled())
|
|
expect(onEdit).toHaveBeenCalledWith({
|
|
text: 'Queued request',
|
|
pendingSchedule: { type: 'absolute', ms: scheduledAt },
|
|
})
|
|
})
|
|
|
|
it('ignores overlapping and disposed recovery animation callbacks, then allows a fresh remount to consume', async () => {
|
|
const raf = installManualAnimationFrames()
|
|
const scheduledAt = Date.now() + 60_000
|
|
const first = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
first.unmount()
|
|
const second = renderQueuedMessage(scheduledAt)
|
|
await resolveCancel({ status: 'cancelled' })
|
|
second.unmount()
|
|
|
|
raf.flushHistory()
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(second.onEdit).not.toHaveBeenCalled()
|
|
expect(getQueuedEditRecovery('session-1')).not.toBeNull()
|
|
|
|
const third = renderQueuedMessage(scheduledAt)
|
|
raf.flushPending()
|
|
await waitFor(() => expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request'))
|
|
expect(third.onEdit).toHaveBeenCalledWith({
|
|
text: 'Queued request',
|
|
pendingSchedule: { type: 'absolute', ms: scheduledAt },
|
|
})
|
|
expect(getQueuedEditRecovery('session-1')).toBeNull()
|
|
})
|
|
|
|
it('clears a conflicting recovery once and never restores it after a later composer clear', async () => {
|
|
const scheduledAt = Date.now() + 60_000
|
|
const { unmount } = renderQueuedMessage(scheduledAt)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
unmount()
|
|
await resolveCancel({ status: 'cancelled' })
|
|
mocks.composerText = 'Current draft wins'
|
|
|
|
const { onEdit, rerender } = renderQueuedMessage(scheduledAt)
|
|
await waitFor(() => expect(mocks.addToast).toHaveBeenCalledWith({
|
|
title: 'queuedMessages.editCurrentDraftKept',
|
|
body: '',
|
|
sessionId: 'session-1',
|
|
url: window.location.href,
|
|
}))
|
|
expect(getQueuedEditRecovery('session-1')).toBeNull()
|
|
|
|
mocks.composerText = ''
|
|
rerender(null, 0)
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(onEdit).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the already-invoked toast behavior', async () => {
|
|
const { onEdit } = renderQueuedMessage()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
await resolveCancel({ status: 'invoked' })
|
|
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(onEdit).not.toHaveBeenCalled()
|
|
expect(mocks.addToast).toHaveBeenCalledWith({
|
|
title: 'queuedMessages.editAlreadyInvoked',
|
|
body: '',
|
|
sessionId: 'session-1',
|
|
url: window.location.href,
|
|
})
|
|
})
|
|
|
|
it('does not prefill when a normal cancel returns busy (steer still may deliver)', async () => {
|
|
const { onEdit } = renderQueuedMessage()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
await resolveCancel({ status: 'busy', localId: 'local-server-message-id' })
|
|
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(onEdit).not.toHaveBeenCalled()
|
|
expect(mocks.addToast).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not prefill when editing an indeterminate row whose cancel returns busy', async () => {
|
|
// Hub may still be mid-dispatch (serialized as indeterminate). Prefilling
|
|
// would invite a duplicate send if the original later lands.
|
|
mocks.messageWindowState = {
|
|
messages: [makeQueuedMessage(null, 'server-message-id', { deliveryState: 'indeterminate' })],
|
|
}
|
|
const onEdit = vi.fn()
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { mutations: { retry: false } },
|
|
})
|
|
render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<QueuedMessagesBar
|
|
sessionId="session-1"
|
|
api={null}
|
|
pendingSchedule={null}
|
|
pendingScheduleRevision={0}
|
|
onEdit={onEdit}
|
|
/>
|
|
</QueryClientProvider>
|
|
)
|
|
|
|
expect(screen.getByText('queuedMessages.steerOutcomeUnknown')).toBeTruthy()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
await resolveCancel({ status: 'busy', localId: 'local-server-message-id' })
|
|
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(onEdit).not.toHaveBeenCalled()
|
|
expect(mocks.addToast).toHaveBeenCalledWith({
|
|
title: 'queuedMessages.editBusyNotRestored',
|
|
body: '',
|
|
sessionId: 'session-1',
|
|
url: window.location.href,
|
|
})
|
|
})
|
|
|
|
it('does not restore an edit when cancel fails', async () => {
|
|
const { onEdit } = renderQueuedMessage(Date.now() + 60_000)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
|
|
await act(async () => {
|
|
mocks.rejectCancel?.(new Error('network failed'))
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(mocks.composerSetText).not.toHaveBeenCalled()
|
|
expect(onEdit).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Unit tests for computeCanCancel — the race guard that prevents sending
|
|
* DELETE before the hub has a row to delete (pre-server-echo scenario).
|
|
*
|
|
* Key invariant: useSendMessage.onMutate creates an optimistic message with
|
|
* { id: localId, localId }
|
|
* so id === localId until the server echo (message-received SSE) arrives and
|
|
* message-window-store replaces the row with the server-assigned UUID id.
|
|
* After that replace, id !== localId.
|
|
*
|
|
* canCancel = hasServerEcho && !isPending
|
|
*/
|
|
describe('computeCanCancel', () => {
|
|
describe('hasServerEcho detection', () => {
|
|
it('is false when id === localId (purely optimistic, no server echo)', () => {
|
|
// useSendMessage.onMutate sets id = localId before POST /messages completes.
|
|
const localId = 'local-abc-123'
|
|
expect(computeCanCancel({ id: localId, localId, isPending: false })).toBe(false)
|
|
})
|
|
|
|
it('is true when id !== localId (server echo replaced id with server UUID)', () => {
|
|
const localId = 'local-abc-123'
|
|
const serverId = 'server-uuid-456'
|
|
expect(computeCanCancel({ id: serverId, localId, isPending: false })).toBe(true)
|
|
})
|
|
|
|
it('is true when localId is undefined/null (server-only row, no local tracking)', () => {
|
|
// Rows from server-loaded history have no localId — treat as already echoed.
|
|
expect(computeCanCancel({ id: 'server-uuid-789', localId: undefined, isPending: false })).toBe(true)
|
|
expect(computeCanCancel({ id: 'server-uuid-789', localId: null, isPending: false })).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('isPending guard', () => {
|
|
it('is false when a cancel mutation is already in-flight, even with server echo', () => {
|
|
const localId = 'local-abc-123'
|
|
const serverId = 'server-uuid-456'
|
|
expect(computeCanCancel({ id: serverId, localId, isPending: true })).toBe(false)
|
|
})
|
|
|
|
it('is false when purely optimistic AND isPending', () => {
|
|
const localId = 'local-abc-123'
|
|
expect(computeCanCancel({ id: localId, localId, isPending: true })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('combined conditions', () => {
|
|
it('is true only when server echo received AND no in-flight cancel', () => {
|
|
const localId = 'local-abc-123'
|
|
const serverId = 'server-uuid-456'
|
|
// The normal case: user can click ✕ or ✎
|
|
expect(computeCanCancel({ id: serverId, localId, isPending: false })).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #4 computeEditPendingSchedule — edit restores scheduledAt as absolute pending
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('computeEditPendingSchedule', () => {
|
|
it('returns null for immediate-queued message (no scheduledAt)', () => {
|
|
const now = Date.now()
|
|
expect(computeEditPendingSchedule(null, now)).toBeNull()
|
|
expect(computeEditPendingSchedule(undefined, now)).toBeNull()
|
|
})
|
|
|
|
it('returns null for scheduledAt in the past (message matured)', () => {
|
|
const now = Date.now()
|
|
const past = now - 5000 // 5 seconds ago
|
|
expect(computeEditPendingSchedule(past, now)).toBeNull()
|
|
})
|
|
|
|
it('returns absolute PendingSchedule for future scheduledAt', () => {
|
|
const now = Date.now()
|
|
const future = now + 60_000 // 1 minute from now
|
|
const result = computeEditPendingSchedule(future, now)
|
|
expect(result).not.toBeNull()
|
|
expect(result?.type).toBe('absolute')
|
|
if (result?.type === 'absolute') {
|
|
expect(result.ms).toBe(future)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('sortQueuedMessages', () => {
|
|
const make = (id: string, createdAt: number, scheduledAt: number | null = null): DecryptedMessage => ({
|
|
id,
|
|
localId: id,
|
|
createdAt,
|
|
seq: createdAt,
|
|
scheduledAt,
|
|
invokedAt: null,
|
|
content: { role: 'user', content: { type: 'text', text: id } },
|
|
} as unknown as DecryptedMessage)
|
|
|
|
it('places immediate-queued messages before scheduled ones', () => {
|
|
const a = make('a-immediate', 1000)
|
|
const b = make('b-scheduled-soon', 500, Date.now() + 60_000)
|
|
const result = sortQueuedMessages([b, a])
|
|
expect(result.map((m) => m.id)).toEqual(['a-immediate', 'b-scheduled-soon'])
|
|
})
|
|
|
|
it('orders immediate-queued messages by createdAt ascending', () => {
|
|
const older = make('older', 1000)
|
|
const newer = make('newer', 2000)
|
|
const result = sortQueuedMessages([newer, older])
|
|
expect(result.map((m) => m.id)).toEqual(['older', 'newer'])
|
|
})
|
|
|
|
it('orders scheduled messages by scheduledAt ascending (soonest first)', () => {
|
|
const later = make('fires-later', 1000, 10_000)
|
|
const sooner = make('fires-sooner', 2000, 5_000)
|
|
const result = sortQueuedMessages([later, sooner])
|
|
expect(result.map((m) => m.id)).toEqual(['fires-sooner', 'fires-later'])
|
|
})
|
|
|
|
it('combined: immediate first, then scheduled in fire-time order', () => {
|
|
const im1 = make('im1', 1000)
|
|
const im2 = make('im2', 2000)
|
|
const sched1 = make('sched-near', 500, 5_000)
|
|
const sched2 = make('sched-far', 600, 10_000)
|
|
const result = sortQueuedMessages([sched2, im2, sched1, im1])
|
|
expect(result.map((m) => m.id)).toEqual(['im1', 'im2', 'sched-near', 'sched-far'])
|
|
})
|
|
})
|
|
|
|
describe('getQueuedMessagePreview', () => {
|
|
it('keeps attachment names with a text prompt', () => {
|
|
const message = {
|
|
id: 'queued-with-image',
|
|
localId: 'queued-with-image',
|
|
createdAt: 1000,
|
|
seq: null,
|
|
invokedAt: null,
|
|
status: 'queued',
|
|
content: {
|
|
role: 'user',
|
|
content: {
|
|
type: 'text',
|
|
text: 'Analyze this screenshot',
|
|
attachments: [{
|
|
id: 'att-1',
|
|
filename: 'image.png',
|
|
mimeType: 'image/png',
|
|
size: 1234,
|
|
path: '/tmp/image.png',
|
|
}],
|
|
},
|
|
},
|
|
} as unknown as DecryptedMessage
|
|
|
|
expect(getQueuedMessagePreview(message)).toEqual({
|
|
text: 'Analyze this screenshot',
|
|
attachmentNames: ['image.png'],
|
|
})
|
|
})
|
|
|
|
it('uses attachment names for attachment-only queued messages', () => {
|
|
const message = {
|
|
id: 'queued-image-only',
|
|
localId: 'queued-image-only',
|
|
createdAt: 1000,
|
|
seq: null,
|
|
invokedAt: null,
|
|
status: 'queued',
|
|
content: {
|
|
role: 'user',
|
|
content: {
|
|
type: 'text',
|
|
text: '',
|
|
attachments: [{
|
|
id: 'att-1',
|
|
filename: 'image.png',
|
|
mimeType: 'image/png',
|
|
size: 1234,
|
|
path: '/tmp/image.png',
|
|
}],
|
|
},
|
|
},
|
|
} as unknown as DecryptedMessage
|
|
|
|
expect(getQueuedMessagePreview(message)).toEqual({
|
|
text: '',
|
|
attachmentNames: ['image.png'],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('getQueuedMessageEditText', () => {
|
|
it('keeps the prompt text when queued message has both text and attachments', () => {
|
|
expect(getQueuedMessageEditText({
|
|
text: 'Analyze this screenshot',
|
|
attachmentNames: ['image.png'],
|
|
})).toBe('Analyze this screenshot')
|
|
})
|
|
|
|
it('falls back to attachment names for attachment-only queued messages', () => {
|
|
expect(getQueuedMessageEditText({
|
|
text: '',
|
|
attachmentNames: ['image.png', 'trace.log'],
|
|
})).toBe('image.png, trace.log')
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// formatScheduledTime — cross-year support (#8)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('formatScheduledTime', () => {
|
|
it('omits year for a date in the current year', () => {
|
|
const now = new Date()
|
|
// Use a date 1 month ahead in the same year, guarding against Dec edge case
|
|
const sameYearDate = new Date(now.getFullYear(), now.getMonth() + 1 < 12 ? now.getMonth() + 1 : 0, 15, 10, 30)
|
|
if (sameYearDate.getFullYear() !== now.getFullYear()) {
|
|
// Wrapped to next year — skip (edge case in late December)
|
|
return
|
|
}
|
|
const result = formatScheduledTime(sameYearDate.getTime())
|
|
// Year digits should not appear
|
|
expect(result).not.toContain(String(now.getFullYear()))
|
|
})
|
|
|
|
it('includes year for a date in a different year', () => {
|
|
const nextYear = new Date().getFullYear() + 1
|
|
const crossYearDate = new Date(nextYear, 0, 15, 10, 30) // Jan 15 next year
|
|
const result = formatScheduledTime(crossYearDate.getTime())
|
|
expect(result).toContain(String(nextYear))
|
|
})
|
|
})
|
|
|
|
describe('QueuedMessagesBar steer action', () => {
|
|
// The real useSteerQueuedMessage hook runs here (only the cancel hook is
|
|
// module-mocked); pass a fake API whose steer request resolves on demand.
|
|
function renderSteerable(canSteer = true) {
|
|
mocks.steerMessage.mockImplementation(() => new Promise((resolve) => {
|
|
mocks.resolveSteer = resolve
|
|
}))
|
|
const api = { steerQueuedMessage: mocks.steerMessage } as unknown as ApiClient
|
|
const view = renderQueuedMessage(null, null, 0, canSteer, api)
|
|
return { unmount: view.unmount }
|
|
}
|
|
|
|
it('shows the Steer button only when canSteer is set and the row is immediate', () => {
|
|
const immediate = renderSteerable(true)
|
|
expect(screen.getByRole('button', { name: 'Steer now' })).toBeTruthy()
|
|
immediate.unmount()
|
|
|
|
renderSteerable(false)
|
|
expect(screen.queryByRole('button', { name: 'Steer now' })).toBeNull()
|
|
})
|
|
|
|
it('disables the Steer button on future-scheduled rows', () => {
|
|
const api = { steerQueuedMessage: mocks.steerMessage } as unknown as ApiClient
|
|
renderQueuedMessage(Date.now() + 60_000, null, 0, true, api)
|
|
expect(screen.getByRole('button', { name: 'Steer now' })).toBeDisabled()
|
|
})
|
|
|
|
it('calls the steer api with the session and message id', async () => {
|
|
renderSteerable(true)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Steer now' }))
|
|
|
|
await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalledWith('session-1', 'server-message-id'))
|
|
// Settle the pending mutation so the queued-operation token releases;
|
|
// otherwise the next test would see the session as busy.
|
|
await act(async () => {
|
|
mocks.resolveSteer?.({ status: 'steered', localId: 'local-server-message-id' })
|
|
await Promise.resolve()
|
|
})
|
|
})
|
|
|
|
it('toasts when the steer fails and leaves the row queued', async () => {
|
|
renderSteerable(true)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Steer now' }))
|
|
await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled())
|
|
await act(async () => {
|
|
mocks.resolveSteer?.({ status: 'failed', error: 'Session is not streaming', localId: 'local-server-message-id' })
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(mocks.addToast).toHaveBeenCalledWith({
|
|
title: 'queuedMessages.steerFailed',
|
|
body: 'Session is not streaming',
|
|
sessionId: 'session-1',
|
|
url: window.location.href,
|
|
})
|
|
})
|
|
|
|
it('does not toast on a successful steer (the consumed event clears the row)', async () => {
|
|
renderSteerable(true)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Steer now' }))
|
|
await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled())
|
|
await act(async () => {
|
|
mocks.resolveSteer?.({ status: 'steered', localId: 'local-server-message-id' })
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(mocks.addToast).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reconciles a stale queued row when the steer returns invoked (missed consumption SSE)', async () => {
|
|
renderSteerable(true)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Steer now' }))
|
|
await waitFor(() => expect(mocks.steerMessage).toHaveBeenCalled())
|
|
await act(async () => {
|
|
mocks.resolveSteer?.({
|
|
status: 'invoked',
|
|
message: { localId: 'local-server-message-id', invokedAt: 5_000 },
|
|
})
|
|
await Promise.resolve()
|
|
})
|
|
|
|
expect(mocks.appendOptimisticMessage).toHaveBeenCalledWith(
|
|
'session-1',
|
|
expect.objectContaining({
|
|
localId: 'local-server-message-id',
|
|
invokedAt: 5_000,
|
|
status: 'sent',
|
|
})
|
|
)
|
|
expect(mocks.addToast).toHaveBeenCalledWith({
|
|
title: 'queuedMessages.steerAlreadyInvoked',
|
|
body: '',
|
|
sessionId: 'session-1',
|
|
url: window.location.href,
|
|
})
|
|
})
|
|
})
|