mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
test: cover multi-turn workspace rollback behavior
The session workspace and current-turn undo surfaces now have regression coverage for multi-turn rollback semantics, stale current-turn cards, and dark theme workspace token usage. Constraint: Workspace rollback must behave consistently for git and non-git session review flows. Rejected: Rely on manual browser-only checks | they do not guard future regressions. Confidence: high Scope-risk: narrow Directive: Keep rollback tests focused on target user message identity, not only visible index position. Tested: bun test src/server/__tests__/sessions.test.ts Tested: cd desktop && bun run test -- src/components/chat/MessageList.test.tsx src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Full live multi-turn assistant generation E2E across a fresh temporary project.
This commit is contained in:
parent
aaf25159af
commit
bcfe042fc8
@ -911,6 +911,180 @@ describe('MessageList nested tool calls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('undoes only the latest completed turn when earlier turns also changed files', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind')
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 7,
|
||||
deletions: 2,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
removedMessageIds: ['user-2', 'assistant-2'],
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: ['src/second.ts'],
|
||||
insertions: 7,
|
||||
deletions: 2,
|
||||
},
|
||||
})
|
||||
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
|
||||
state: 'ok',
|
||||
workDir: '/tmp/example-project',
|
||||
repoName: 'example-project',
|
||||
branch: null,
|
||||
isGitRepo: false,
|
||||
changedFiles: [],
|
||||
})
|
||||
const reloadHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const queueComposerPrefill = vi.fn()
|
||||
|
||||
useChatStore.setState({
|
||||
reloadHistory,
|
||||
queueComposerPrefill,
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '第一轮需求',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'first done',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二轮需求',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: 'second done',
|
||||
timestamp: 4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
expect(await screen.findByText('1 files changed')).toBeTruthy()
|
||||
expect(screen.getByText('src/second.ts')).toBeTruthy()
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二轮需求',
|
||||
dryRun: true,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo current turn changes' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Undo current turn?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Undo current turn' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenLastCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二轮需求',
|
||||
})
|
||||
})
|
||||
expect(reloadHistory).toHaveBeenCalledWith(ACTIVE_TAB)
|
||||
expect(queueComposerPrefill).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
text: '第二轮需求',
|
||||
attachments: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show a stale current-turn change card when the latest completed turn has no files', async () => {
|
||||
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
|
||||
target: {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
userMessageCount: 2,
|
||||
},
|
||||
conversation: {
|
||||
messagesRemoved: 2,
|
||||
},
|
||||
code: {
|
||||
available: true,
|
||||
filesChanged: [],
|
||||
insertions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[ACTIVE_TAB]: makeSessionState({
|
||||
messages: [
|
||||
{
|
||||
id: 'user-1',
|
||||
type: 'user_text',
|
||||
content: '第一轮改文件',
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
type: 'assistant_text',
|
||||
content: 'first done',
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
type: 'user_text',
|
||||
content: '第二轮只解释',
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
id: 'assistant-2',
|
||||
type: 'assistant_text',
|
||||
content: 'second done',
|
||||
timestamp: 4,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
render(<MessageList />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, {
|
||||
targetUserMessageId: 'user-2',
|
||||
userMessageIndex: 1,
|
||||
expectedContent: '第二轮只解释',
|
||||
dryRun: true,
|
||||
})
|
||||
})
|
||||
expect(screen.queryByLabelText('Current turn changed files')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows raw startup details under translated CLI startup errors', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
|
||||
@ -88,6 +88,17 @@ async function clickElement(element: Element) {
|
||||
})
|
||||
}
|
||||
|
||||
function classNameContains(element: Element | null, needle: string) {
|
||||
let current = element
|
||||
while (current) {
|
||||
if (typeof current.className === 'string' && current.className.includes(needle)) {
|
||||
return true
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
sessionsApi: (() => {
|
||||
if (!mocks) {
|
||||
@ -450,6 +461,60 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('uses theme tokens for the panel, preview tabs, and code surface in dark mode', async () => {
|
||||
await setSettingsState({ ...settingsInitialState, locale: 'en', theme: 'dark' })
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-dark-theme': {
|
||||
isOpen: true,
|
||||
activeView: 'changed',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-dark-theme': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-dark-theme': [{
|
||||
id: 'file:src/theme.ts',
|
||||
path: 'src/theme.ts',
|
||||
kind: 'file',
|
||||
title: 'theme.ts',
|
||||
language: 'typescript',
|
||||
content: 'export const theme = "dark"',
|
||||
state: 'ok',
|
||||
size: 27,
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-dark-theme': 'file:src/theme.ts',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-dark-theme')
|
||||
const panel = view.getByTestId('workspace-panel')
|
||||
const tabList = view.getByRole('tablist', { name: 'Preview tabs' })
|
||||
const codeSurface = view.getByTestId('workspace-code')
|
||||
|
||||
expect(panel.className).toContain('bg-[var(--color-surface)]')
|
||||
expect(panel.className).not.toContain('bg-white')
|
||||
expect(tabList.className).toContain('bg-[var(--color-surface-container-lowest)]')
|
||||
expect(tabList.className).not.toContain('bg-white')
|
||||
expect(classNameContains(codeSurface, 'bg-[var(--color-code-bg)]')).toBe(true)
|
||||
expect(classNameContains(codeSurface, 'bg-white')).toBe(false)
|
||||
})
|
||||
|
||||
it('caps rendered preview lines to keep large diffs responsive', async () => {
|
||||
const longDiff = Array.from({ length: 650 }, (_, index) => `+line ${index + 1}`).join('\n')
|
||||
|
||||
|
||||
@ -1726,6 +1726,86 @@ describe('Sessions API', () => {
|
||||
expect(remainingMessages[0]?.id).toBe(firstUserId)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should keep first-turn file state when undoing only the latest turn', async () => {
|
||||
const sessionId = 'dddddddd-bbbb-cccc-dddd-ffffffffffff'
|
||||
const workDir = path.join(tmpDir, 'two-turns-separate-files')
|
||||
const firstTurnFile = path.join(workDir, 'src', 'first.js')
|
||||
const secondTurnFile = path.join(workDir, 'src', 'second.js')
|
||||
const firstUserId = crypto.randomUUID()
|
||||
const secondUserId = crypto.randomUUID()
|
||||
const firstBaseBackup = 'separate-first@v1'
|
||||
const firstAfterTurnBackup = 'separate-first@v2'
|
||||
const secondBaseBackup = 'separate-second@v1'
|
||||
|
||||
await fs.mkdir(path.dirname(firstTurnFile), { recursive: true })
|
||||
await fs.writeFile(firstTurnFile, "export const FIRST = 'v1'\n", 'utf-8')
|
||||
await fs.writeFile(secondTurnFile, "export const SECOND = 'v2'\n", 'utf-8')
|
||||
await writeFileHistoryBackup(sessionId, firstBaseBackup, "export const FIRST = 'base'\n")
|
||||
await writeFileHistoryBackup(sessionId, firstAfterTurnBackup, "export const FIRST = 'v1'\n")
|
||||
await writeFileHistoryBackup(sessionId, secondBaseBackup, "export const SECOND = 'base'\n")
|
||||
|
||||
await writeSessionFile('-tmp-api-two-turns-separate-files', sessionId, [
|
||||
makeSessionMetaEntry(workDir),
|
||||
makeFileHistorySnapshotEntry(firstUserId, {
|
||||
'src/first.js': {
|
||||
backupFileName: firstBaseBackup,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make first file v1', firstUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE first', firstUserId),
|
||||
makeFileHistorySnapshotEntry(secondUserId, {
|
||||
'src/first.js': {
|
||||
backupFileName: firstAfterTurnBackup,
|
||||
version: 2,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
'src/second.js': {
|
||||
backupFileName: secondBaseBackup,
|
||||
version: 1,
|
||||
backupTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
...makeUserEntry('make second file v2', secondUserId),
|
||||
cwd: workDir,
|
||||
sessionId,
|
||||
},
|
||||
makeAssistantEntry('DONE second', secondUserId),
|
||||
])
|
||||
|
||||
const previewRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1, dryRun: true }),
|
||||
})
|
||||
expect(previewRes.status).toBe(200)
|
||||
const preview = await previewRes.json() as {
|
||||
code: { available: boolean; filesChanged: string[] }
|
||||
}
|
||||
expect(preview.code.available).toBe(true)
|
||||
expect(preview.code.filesChanged).toEqual([secondTurnFile])
|
||||
|
||||
const executeRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/rewind`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userMessageIndex: 1 }),
|
||||
})
|
||||
expect(executeRes.status).toBe(200)
|
||||
|
||||
expect(await fs.readFile(firstTurnFile, 'utf-8')).toBe("export const FIRST = 'v1'\n")
|
||||
expect(await fs.readFile(secondTurnFile, 'utf-8')).toBe("export const SECOND = 'base'\n")
|
||||
|
||||
const remainingMessages = await service.getSessionMessages(sessionId)
|
||||
expect(remainingMessages).toHaveLength(2)
|
||||
expect(remainingMessages[0]?.id).toBe(firstUserId)
|
||||
})
|
||||
|
||||
it('POST /api/sessions/:id/rewind should include files created after the first turn', async () => {
|
||||
const sessionId = 'eeeeeeee-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const workDir = path.join(tmpDir, 'created-on-second-turn')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user