mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
Contributors and coding agents need one local command that both reports and enforces the quality contract. This change turns the PR gate into the shared verification entrypoint, adds path-selected local lanes, tightens coverage accounting around changed lines, and documents the repair loop in contributor and agent-facing guidance. Constraint: Ordinary PR verification must stay non-live and runnable without provider credentials Constraint: Coverage policy updates in this commit require maintainer approval before push/merge Rejected: Keep quality guidance only in docs | agents need executable scripts and AGENTS.md instructions to follow the loop consistently Confidence: high Scope-risk: broad Directive: Do not bypass `bun run verify` for production changes; fix failed lanes and coverage reports instead of lowering thresholds Tested: bun run check:policy Tested: ALLOW_CLI_CORE_CHANGE=1 ALLOW_COVERAGE_BASELINE_CHANGE=1 bun run verify Not-tested: live provider baseline; no provider credentials were required for this non-live PR gate
1237 lines
36 KiB
TypeScript
1237 lines
36 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
// @ts-expect-error jsdom is installed in this workspace without local type declarations
|
|
import { JSDOM } from 'jsdom'
|
|
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
if (typeof document === 'undefined') {
|
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
|
url: 'http://localhost/',
|
|
})
|
|
const { window } = dom
|
|
|
|
Object.assign(globalThis, {
|
|
window,
|
|
document: window.document,
|
|
navigator: window.navigator,
|
|
localStorage: window.localStorage,
|
|
HTMLElement: window.HTMLElement,
|
|
HTMLButtonElement: window.HTMLButtonElement,
|
|
MutationObserver: window.MutationObserver,
|
|
Node: window.Node,
|
|
Event: window.Event,
|
|
MouseEvent: window.MouseEvent,
|
|
KeyboardEvent: window.KeyboardEvent,
|
|
getComputedStyle: window.getComputedStyle.bind(window),
|
|
IS_REACT_ACT_ENVIRONMENT: true,
|
|
})
|
|
}
|
|
|
|
type WorkspaceApiMocks = {
|
|
getWorkspaceStatusMock: ReturnType<typeof vi.fn>
|
|
getWorkspaceTreeMock: ReturnType<typeof vi.fn>
|
|
getWorkspaceFileMock: ReturnType<typeof vi.fn>
|
|
getWorkspaceDiffMock: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
var mocks: WorkspaceApiMocks | undefined
|
|
|
|
function getMocks() {
|
|
if (!mocks) {
|
|
throw new Error('Workspace API mocks were not initialized')
|
|
}
|
|
return mocks
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason?: unknown) => void
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
async function setWorkspaceState(
|
|
updater:
|
|
| ReturnType<typeof useWorkspacePanelStore.getInitialState>
|
|
| Parameters<typeof useWorkspacePanelStore.setState>[0],
|
|
) {
|
|
await act(() => {
|
|
useWorkspacePanelStore.setState(updater as Parameters<typeof useWorkspacePanelStore.setState>[0], true)
|
|
})
|
|
}
|
|
|
|
async function setSettingsState(
|
|
updater:
|
|
| ReturnType<typeof useSettingsStore.getInitialState>
|
|
| Parameters<typeof useSettingsStore.setState>[0],
|
|
) {
|
|
await act(() => {
|
|
useSettingsStore.setState(updater as Parameters<typeof useSettingsStore.setState>[0], true)
|
|
})
|
|
}
|
|
|
|
async function flushReactWork() {
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
|
|
async function renderPanel(sessionId: string) {
|
|
let view!: ReturnType<typeof render>
|
|
await act(async () => {
|
|
view = render(<WorkspacePanel sessionId={sessionId} />)
|
|
await Promise.resolve()
|
|
})
|
|
return view
|
|
}
|
|
|
|
async function clickElement(element: Element) {
|
|
await act(async () => {
|
|
fireEvent.click(element)
|
|
await Promise.resolve()
|
|
})
|
|
await flushReactWork()
|
|
}
|
|
|
|
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) {
|
|
mocks = {
|
|
getWorkspaceStatusMock: vi.fn(),
|
|
getWorkspaceTreeMock: vi.fn(),
|
|
getWorkspaceFileMock: vi.fn(),
|
|
getWorkspaceDiffMock: vi.fn(),
|
|
}
|
|
}
|
|
|
|
return {
|
|
getWorkspaceStatus: mocks.getWorkspaceStatusMock,
|
|
getWorkspaceTree: mocks.getWorkspaceTreeMock,
|
|
getWorkspaceFile: mocks.getWorkspaceFileMock,
|
|
getWorkspaceDiff: mocks.getWorkspaceDiffMock,
|
|
}
|
|
})(),
|
|
}))
|
|
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
|
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
|
import { WorkspacePanel } from './WorkspacePanel'
|
|
|
|
describe('WorkspacePanel', () => {
|
|
const workspaceInitialState = useWorkspacePanelStore.getInitialState()
|
|
const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState()
|
|
const settingsInitialState = useSettingsStore.getInitialState()
|
|
const chatInitialState = useChatStore.getInitialState()
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks()
|
|
await setWorkspaceState(workspaceInitialState)
|
|
useChatStore.setState(chatInitialState, true)
|
|
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
|
|
await setSettingsState({ ...settingsInitialState, locale: 'en' })
|
|
|
|
getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) =>
|
|
useWorkspacePanelStore.getState().statusBySession[sessionId] ?? {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
)
|
|
getMocks().getWorkspaceTreeMock.mockImplementation(async (sessionId: string, path = '') =>
|
|
useWorkspacePanelStore.getState().treeBySessionPath[sessionId]?.[path] ?? {
|
|
state: 'ok',
|
|
path,
|
|
entries: [],
|
|
},
|
|
)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
cleanup()
|
|
await setWorkspaceState(workspaceInitialState)
|
|
useChatStore.setState(chatInitialState, true)
|
|
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
|
|
await setSettingsState(settingsInitialState)
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('stays hidden when the panel is closed', async () => {
|
|
const view = await renderPanel('session-hidden')
|
|
|
|
expect(view.queryByTestId('workspace-panel')).toBeNull()
|
|
})
|
|
|
|
it('loads changed status on open and opens a diff preview from the changed view', async () => {
|
|
const statusRequest = deferred<{
|
|
state: 'ok'
|
|
workDir: string
|
|
repoName: string
|
|
branch: string
|
|
isGitRepo: true
|
|
changedFiles: Array<{
|
|
path: string
|
|
status: 'modified'
|
|
additions: number
|
|
deletions: number
|
|
}>
|
|
}>()
|
|
const diffRequest = deferred<{
|
|
state: 'ok'
|
|
path: string
|
|
diff: string
|
|
}>()
|
|
|
|
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
|
|
getMocks().getWorkspaceDiffMock.mockReturnValue(diffRequest.promise)
|
|
|
|
await act(() => {
|
|
useWorkspacePanelStore.getState().openPanel('session-changed')
|
|
})
|
|
|
|
const view = await renderPanel('session-changed')
|
|
|
|
const compactPanel = view.getByTestId('workspace-panel')
|
|
expect(compactPanel.style.width).toBe('520px')
|
|
expect(compactPanel.style.maxWidth).toBe('36%')
|
|
expect(compactPanel.style.minWidth).toBe('min(340px, 40%)')
|
|
|
|
await waitFor(() => {
|
|
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed')
|
|
})
|
|
|
|
await act(async () => {
|
|
statusRequest.resolve({
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [
|
|
{
|
|
path: 'src/app.ts',
|
|
status: 'modified',
|
|
additions: 4,
|
|
deletions: 1,
|
|
},
|
|
],
|
|
})
|
|
await statusRequest.promise
|
|
})
|
|
|
|
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
|
|
|
|
await clickElement(await view.findByText('src/app.ts'))
|
|
|
|
await waitFor(() => {
|
|
expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-changed', 'src/app.ts')
|
|
})
|
|
|
|
await act(async () => {
|
|
diffRequest.resolve({
|
|
state: 'ok',
|
|
path: 'src/app.ts',
|
|
diff: '@@ -1 +1 @@\n-console.log("old")\n+console.log("new")',
|
|
})
|
|
await diffRequest.promise
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
|
|
})
|
|
const expandedPanel = view.getByTestId('workspace-panel')
|
|
expect(expandedPanel.style.width).toBe('860px')
|
|
expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))')
|
|
expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)')
|
|
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('refreshes status on open and switches back to changed files when new changes exist', async () => {
|
|
getMocks().getWorkspaceStatusMock.mockResolvedValue({
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [
|
|
{
|
|
path: 'src/Fresh.ts',
|
|
status: 'modified',
|
|
additions: 4,
|
|
deletions: 1,
|
|
},
|
|
],
|
|
})
|
|
getMocks().getWorkspaceTreeMock.mockResolvedValue({
|
|
state: 'ok',
|
|
path: '',
|
|
entries: [{ name: 'src', path: 'src', isDirectory: true }],
|
|
})
|
|
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-stale-all': {
|
|
isOpen: true,
|
|
activeView: 'all',
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-stale-all': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-stale-all')
|
|
|
|
await waitFor(() => {
|
|
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-stale-all')
|
|
})
|
|
await waitFor(() => {
|
|
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
|
|
})
|
|
expect(view.getByText('src/Fresh.ts')).toBeTruthy()
|
|
})
|
|
|
|
it('loads workspace status when opened while the chat is running', async () => {
|
|
getMocks().getWorkspaceStatusMock.mockResolvedValue({
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [
|
|
{
|
|
path: 'src/running.ts',
|
|
status: 'modified',
|
|
additions: 1,
|
|
deletions: 0,
|
|
},
|
|
],
|
|
})
|
|
|
|
useChatStore.setState({
|
|
sessions: {
|
|
'session-running-open': {
|
|
chatState: 'thinking',
|
|
} as never,
|
|
},
|
|
})
|
|
|
|
await act(() => {
|
|
useWorkspacePanelStore.getState().openPanel('session-running-open')
|
|
})
|
|
|
|
const view = await renderPanel('session-running-open')
|
|
|
|
await waitFor(() => {
|
|
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-running-open')
|
|
})
|
|
await waitFor(() => {
|
|
expect(view.getByText('src/running.ts')).toBeTruthy()
|
|
})
|
|
expect(view.queryByText('Loading...')).toBeNull()
|
|
})
|
|
|
|
it('renders transcript-derived changed files for non-git sessions', async () => {
|
|
getMocks().getWorkspaceStatusMock.mockResolvedValue({
|
|
state: 'ok',
|
|
workDir: '/tmp/non-git-session',
|
|
repoName: 'non-git-session',
|
|
branch: null,
|
|
isGitRepo: false,
|
|
changedFiles: [
|
|
{
|
|
path: 'src/app.ts',
|
|
status: 'modified',
|
|
additions: 1,
|
|
deletions: 1,
|
|
},
|
|
],
|
|
})
|
|
getMocks().getWorkspaceDiffMock.mockResolvedValue({
|
|
state: 'ok',
|
|
path: 'src/app.ts',
|
|
diff: 'diff --session a/src/app.ts b/src/app.ts\n-export const answer = 1\n+export const answer = 2',
|
|
})
|
|
|
|
await act(() => {
|
|
useWorkspacePanelStore.getState().openPanel('session-non-git')
|
|
})
|
|
|
|
const view = await renderPanel('session-non-git')
|
|
|
|
await waitFor(() => {
|
|
expect(view.getByText('src/app.ts')).toBeTruthy()
|
|
})
|
|
expect(view.queryByText('No matching files')).toBeNull()
|
|
|
|
await clickElement(view.getByText('src/app.ts'))
|
|
|
|
await waitFor(() => {
|
|
expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-non-git', 'src/app.ts')
|
|
})
|
|
await waitFor(() => {
|
|
expect(view.getByTestId('workspace-code').textContent).toContain('export const answer = 2')
|
|
})
|
|
})
|
|
|
|
it('opens to all files when the current turn has no changed files', async () => {
|
|
const statusRequest = deferred<{
|
|
state: 'ok'
|
|
workDir: string
|
|
repoName: string
|
|
branch: string
|
|
isGitRepo: true
|
|
changedFiles: []
|
|
}>()
|
|
const rootTreeRequest = deferred<{
|
|
state: 'ok'
|
|
path: ''
|
|
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
|
}>()
|
|
|
|
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
|
|
getMocks().getWorkspaceTreeMock.mockReturnValue(rootTreeRequest.promise)
|
|
|
|
await act(() => {
|
|
useWorkspacePanelStore.getState().openPanel('session-empty-tree')
|
|
})
|
|
|
|
const view = await renderPanel('session-empty-tree')
|
|
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
|
|
|
|
await act(async () => {
|
|
statusRequest.resolve({
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
})
|
|
await statusRequest.promise
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-tree')).toBe('all')
|
|
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-empty-tree', '')
|
|
})
|
|
|
|
await act(async () => {
|
|
rootTreeRequest.resolve({
|
|
state: 'ok',
|
|
path: '',
|
|
entries: [
|
|
{ name: 'src', path: 'src', isDirectory: true },
|
|
{ name: 'README.md', path: 'README.md', isDirectory: false },
|
|
],
|
|
})
|
|
await rootTreeRequest.promise
|
|
})
|
|
|
|
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
|
|
expect(await view.findByText('src')).toBeTruthy()
|
|
expect(await view.findByText('README.md')).toBeTruthy()
|
|
expect(view.queryByText('No changes')).toBeNull()
|
|
})
|
|
|
|
it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => {
|
|
const statusRequest = deferred<{
|
|
state: 'ok'
|
|
workDir: string
|
|
repoName: string
|
|
branch: string
|
|
isGitRepo: true
|
|
changedFiles: []
|
|
}>()
|
|
const rootTreeRequest = deferred<{
|
|
state: 'ok'
|
|
path: ''
|
|
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
|
}>()
|
|
const childTreeRequest = deferred<{
|
|
state: 'ok'
|
|
path: 'src'
|
|
entries: Array<{ name: string; path: string; isDirectory: boolean }>
|
|
}>()
|
|
const fileRequest = deferred<{
|
|
state: 'ok'
|
|
path: string
|
|
content: string
|
|
language: string
|
|
size: number
|
|
}>()
|
|
|
|
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
|
|
getMocks().getWorkspaceTreeMock
|
|
.mockReturnValueOnce(rootTreeRequest.promise)
|
|
.mockReturnValueOnce(childTreeRequest.promise)
|
|
getMocks().getWorkspaceFileMock.mockReturnValue(fileRequest.promise)
|
|
|
|
await act(() => {
|
|
useWorkspacePanelStore.getState().openPanel('session-tree')
|
|
})
|
|
|
|
const view = await renderPanel('session-tree')
|
|
|
|
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
|
|
|
|
await clickElement(view.getByRole('button', { name: 'Changed files' }))
|
|
await clickElement(view.getByRole('menuitem', { name: 'All files' }))
|
|
|
|
await waitFor(() => {
|
|
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '')
|
|
})
|
|
|
|
await act(async () => {
|
|
statusRequest.resolve({
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
})
|
|
rootTreeRequest.resolve({
|
|
state: 'ok',
|
|
path: '',
|
|
entries: [
|
|
{ name: 'src', path: 'src', isDirectory: true },
|
|
{ name: 'README.md', path: 'README.md', isDirectory: false },
|
|
],
|
|
})
|
|
await Promise.all([statusRequest.promise, rootTreeRequest.promise])
|
|
})
|
|
|
|
const folderLabel = await view.findByText('src')
|
|
const folderButton = folderLabel.closest('button')
|
|
if (!folderButton) {
|
|
throw new Error('Expected src label to be rendered inside a folder button')
|
|
}
|
|
expect(folderButton.getAttribute('aria-expanded')).toBe('false')
|
|
|
|
await clickElement(folderButton)
|
|
|
|
await waitFor(() => {
|
|
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', 'src')
|
|
})
|
|
await act(async () => {
|
|
childTreeRequest.resolve({
|
|
state: 'ok',
|
|
path: 'src',
|
|
entries: [{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }],
|
|
})
|
|
await childTreeRequest.promise
|
|
})
|
|
await waitFor(() => {
|
|
expect(folderButton.getAttribute('aria-expanded')).toBe('true')
|
|
})
|
|
|
|
await clickElement(await view.findByText('index.ts'))
|
|
|
|
await waitFor(() => {
|
|
expect(getMocks().getWorkspaceFileMock).toHaveBeenCalledWith('session-tree', 'src/index.ts')
|
|
})
|
|
await act(async () => {
|
|
fileRequest.resolve({
|
|
state: 'ok',
|
|
path: 'src/index.ts',
|
|
content: 'export const ready = true',
|
|
language: 'typescript',
|
|
size: 25,
|
|
})
|
|
await fileRequest.promise
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true')
|
|
})
|
|
expect(view.getAllByText('File').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('renders multiple preview tabs and closes only the exact requested tab', async () => {
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-tabs': {
|
|
isOpen: true,
|
|
activeView: 'changed',
|
|
hasUserSelectedView: true,
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-tabs': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
previewTabsBySession: {
|
|
...state.previewTabsBySession,
|
|
'session-tabs': [
|
|
{
|
|
id: 'file:src/a.ts',
|
|
path: 'src/a.ts',
|
|
kind: 'file',
|
|
title: 'a.ts',
|
|
language: 'typescript',
|
|
content: 'export const a = 1',
|
|
state: 'ok',
|
|
size: 18,
|
|
},
|
|
{
|
|
id: 'diff:src/a.ts',
|
|
path: 'src/a.ts',
|
|
kind: 'diff',
|
|
title: 'a.ts',
|
|
diff: '@@ -1 +1 @@',
|
|
state: 'ok',
|
|
},
|
|
{
|
|
id: 'file:src/b.ts',
|
|
path: 'src/b.ts',
|
|
kind: 'file',
|
|
title: 'b.ts',
|
|
language: 'typescript',
|
|
content: 'export const b = 1',
|
|
state: 'ok',
|
|
size: 18,
|
|
},
|
|
],
|
|
},
|
|
activePreviewTabIdBySession: {
|
|
...state.activePreviewTabIdBySession,
|
|
'session-tabs': 'diff:src/a.ts',
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-tabs')
|
|
|
|
expect(view.getByRole('tablist', { name: 'Preview tabs' })).toBeTruthy()
|
|
expect(view.getAllByRole('tab', { name: /a\.ts/ })).toHaveLength(2)
|
|
expect(view.getAllByText('a.ts').length).toBeGreaterThanOrEqual(2)
|
|
expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1)
|
|
|
|
await clickElement(view.getByLabelText('Close tab a.ts Diff'))
|
|
|
|
expect(view.queryByLabelText('Close tab a.ts Diff')).toBeNull()
|
|
expect(view.getByLabelText('Close tab a.ts File')).toBeTruthy()
|
|
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',
|
|
hasUserSelectedView: true,
|
|
},
|
|
},
|
|
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('can expand long diff previews beyond the default rendered line cap', async () => {
|
|
const longDiff = Array.from({ length: 2300 }, (_, index) => `+line ${index + 1}`).join('\n')
|
|
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-large-preview': {
|
|
isOpen: true,
|
|
activeView: 'changed',
|
|
hasUserSelectedView: true,
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-large-preview': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
previewTabsBySession: {
|
|
...state.previewTabsBySession,
|
|
'session-large-preview': [{
|
|
id: 'diff:large.ts',
|
|
path: 'large.ts',
|
|
kind: 'diff',
|
|
title: 'large.ts',
|
|
diff: longDiff,
|
|
state: 'ok',
|
|
}],
|
|
},
|
|
activePreviewTabIdBySession: {
|
|
...state.activePreviewTabIdBySession,
|
|
'session-large-preview': 'diff:large.ts',
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-large-preview')
|
|
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
|
|
|
|
expect(highlightedCode).toContain('+line 1')
|
|
expect(highlightedCode).toContain('+line 2000')
|
|
expect(highlightedCode).not.toContain('+line 2001')
|
|
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
|
|
|
|
await waitFor(() => {
|
|
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300')
|
|
})
|
|
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
|
|
})
|
|
|
|
it('can expand long file previews beyond the default rendered line cap', async () => {
|
|
const longFile = Array.from({ length: 2300 }, (_, index) => `const line${index + 1} = ${index + 1}`).join('\n')
|
|
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-large-file-preview': {
|
|
isOpen: true,
|
|
activeView: 'all',
|
|
},
|
|
},
|
|
previewTabsBySession: {
|
|
...state.previewTabsBySession,
|
|
'session-large-file-preview': [{
|
|
id: 'file:large-file.ts',
|
|
path: 'large-file.ts',
|
|
kind: 'file',
|
|
title: 'large-file.ts',
|
|
content: longFile,
|
|
language: 'typescript',
|
|
previewType: 'text',
|
|
state: 'ok',
|
|
}],
|
|
},
|
|
activePreviewTabIdBySession: {
|
|
...state.activePreviewTabIdBySession,
|
|
'session-large-file-preview': 'file:large-file.ts',
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-large-file-preview')
|
|
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
|
|
|
|
expect(highlightedCode).toContain('const line1 = 1')
|
|
expect(highlightedCode).toContain('const line2000 = 2000')
|
|
expect(highlightedCode).not.toContain('const line2001 = 2001')
|
|
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
|
|
|
|
await waitFor(() => {
|
|
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
|
|
})
|
|
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
|
|
})
|
|
|
|
it('renders image previews from workspace files', async () => {
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-image-preview': {
|
|
isOpen: true,
|
|
activeView: 'all',
|
|
},
|
|
},
|
|
treeBySessionPath: {
|
|
...state.treeBySessionPath,
|
|
'session-image-preview': {
|
|
'': {
|
|
state: 'ok',
|
|
path: '',
|
|
entries: [{ name: 'logo.png', path: 'logo.png', isDirectory: false }],
|
|
},
|
|
},
|
|
},
|
|
}))
|
|
|
|
getMocks().getWorkspaceFileMock.mockResolvedValue({
|
|
state: 'ok',
|
|
path: 'logo.png',
|
|
previewType: 'image',
|
|
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
|
|
mimeType: 'image/png',
|
|
language: 'image',
|
|
size: 8,
|
|
})
|
|
|
|
const view = await renderPanel('session-image-preview')
|
|
|
|
await clickElement(await view.findByText('logo.png'))
|
|
|
|
const image = await view.findByRole('img', { name: 'logo.png' })
|
|
expect(image.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgo=')
|
|
})
|
|
|
|
it('renders markdown file previews as formatted documents', async () => {
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-markdown-preview': {
|
|
isOpen: true,
|
|
activeView: 'all',
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-markdown-preview': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
previewTabsBySession: {
|
|
...state.previewTabsBySession,
|
|
'session-markdown-preview': [{
|
|
id: 'file:README.md',
|
|
path: 'README.md',
|
|
kind: 'file',
|
|
title: 'README.md',
|
|
language: 'markdown',
|
|
content: '# Project Notes\n\n- **Done** item\n\n```ts\nexport const ok = true\n```',
|
|
state: 'ok',
|
|
size: 70,
|
|
}],
|
|
},
|
|
activePreviewTabIdBySession: {
|
|
...state.activePreviewTabIdBySession,
|
|
'session-markdown-preview': 'file:README.md',
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-markdown-preview')
|
|
|
|
expect(view.getByRole('heading', { name: 'Project Notes', level: 1 })).toBeTruthy()
|
|
expect(view.getByText('Done')).toBeTruthy()
|
|
expect(view.container.textContent).toContain('export const ok = true')
|
|
expect(view.queryByTestId('workspace-code')).toBeNull()
|
|
})
|
|
|
|
it('opens a context menu for preview tabs and closes tabs to the right', async () => {
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-preview-menu': {
|
|
isOpen: true,
|
|
activeView: 'changed',
|
|
hasUserSelectedView: true,
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-preview-menu': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
previewTabsBySession: {
|
|
...state.previewTabsBySession,
|
|
'session-preview-menu': [
|
|
{
|
|
id: 'file:src/App.jsx',
|
|
path: 'src/App.jsx',
|
|
kind: 'file',
|
|
title: 'App.jsx',
|
|
language: 'jsx',
|
|
content: 'app',
|
|
state: 'ok',
|
|
size: 3,
|
|
},
|
|
{
|
|
id: 'diff:vite.config.js',
|
|
path: 'vite.config.js',
|
|
kind: 'diff',
|
|
title: 'vite.config.js',
|
|
diff: '@@ -1 +1 @@',
|
|
state: 'ok',
|
|
size: 12,
|
|
},
|
|
{
|
|
id: 'file:src/index.css',
|
|
path: 'src/index.css',
|
|
kind: 'file',
|
|
title: 'index.css',
|
|
language: 'css',
|
|
content: 'body{}',
|
|
state: 'ok',
|
|
size: 6,
|
|
},
|
|
],
|
|
},
|
|
activePreviewTabIdBySession: {
|
|
...state.activePreviewTabIdBySession,
|
|
'session-preview-menu': 'diff:vite.config.js',
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-preview-menu')
|
|
|
|
await act(() => {
|
|
fireEvent.contextMenu(view.getByRole('tab', { name: /vite\.config\.js/i }), {
|
|
clientX: 320,
|
|
clientY: 42,
|
|
})
|
|
})
|
|
|
|
await clickElement(view.getByRole('menuitem', { name: 'Close to the Right' }))
|
|
|
|
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-menu']).toMatchObject([
|
|
{ id: 'file:src/App.jsx' },
|
|
{ id: 'diff:vite.config.js' },
|
|
])
|
|
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-menu']).toBe('diff:vite.config.js')
|
|
expect(view.queryByRole('tab', { name: /index\.css/i })).toBeNull()
|
|
})
|
|
|
|
it('adds a workspace file to the chat context from the file tree menu', async () => {
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-add-file': {
|
|
isOpen: true,
|
|
activeView: 'all',
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-add-file': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
treeBySessionPath: {
|
|
...state.treeBySessionPath,
|
|
'session-add-file': {
|
|
'': {
|
|
state: 'ok',
|
|
path: '',
|
|
entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }],
|
|
},
|
|
},
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-add-file')
|
|
|
|
await act(() => {
|
|
fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), {
|
|
clientX: 260,
|
|
clientY: 80,
|
|
})
|
|
})
|
|
|
|
await clickElement(view.getByRole('menuitem', { name: 'Add to chat' }))
|
|
|
|
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-add-file']).toMatchObject([
|
|
{
|
|
kind: 'file',
|
|
path: 'src/App.tsx',
|
|
absolutePath: '/repo/src/App.tsx',
|
|
name: 'App.tsx',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('adds a line comment from a code preview to the chat context', async () => {
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-line-comment': {
|
|
isOpen: true,
|
|
activeView: 'all',
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-line-comment': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
previewTabsBySession: {
|
|
...state.previewTabsBySession,
|
|
'session-line-comment': [{
|
|
id: 'file:src/App.tsx',
|
|
path: 'src/App.tsx',
|
|
kind: 'file',
|
|
title: 'App.tsx',
|
|
language: 'tsx',
|
|
content: 'const title = "Todo"\nexport default title',
|
|
state: 'ok',
|
|
size: 42,
|
|
}],
|
|
},
|
|
activePreviewTabIdBySession: {
|
|
...state.activePreviewTabIdBySession,
|
|
'session-line-comment': 'file:src/App.tsx',
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-line-comment')
|
|
|
|
await clickElement(view.getByRole('button', { name: 'Comment line 1' }))
|
|
const textarea = view.getByPlaceholderText('Describe what should change here...')
|
|
await act(() => {
|
|
fireEvent.change(textarea, { target: { value: 'Rename this title' } })
|
|
})
|
|
await clickElement(view.getByRole('button', { name: 'Add comment' }))
|
|
|
|
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-line-comment']).toMatchObject([
|
|
{
|
|
kind: 'code-comment',
|
|
path: 'src/App.tsx',
|
|
absolutePath: '/repo/src/App.tsx',
|
|
name: 'App.tsx',
|
|
lineStart: 1,
|
|
lineEnd: 1,
|
|
note: 'Rename this title',
|
|
quote: 'const title = "Todo"',
|
|
},
|
|
])
|
|
})
|
|
|
|
it('uses the localized view menu label', async () => {
|
|
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-zh': {
|
|
isOpen: true,
|
|
activeView: 'changed',
|
|
hasUserSelectedView: true,
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-zh': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-zh')
|
|
|
|
expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy()
|
|
})
|
|
|
|
it('keeps the workspace header controls compact', async () => {
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-compact-header': {
|
|
isOpen: true,
|
|
activeView: 'changed',
|
|
hasUserSelectedView: true,
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-compact-header': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-compact-header')
|
|
const viewMenuButton = view.getByRole('button', { name: 'Changed files' })
|
|
const refreshButton = view.getByRole('button', { name: 'Refresh workspace' })
|
|
const closeButton = view.getByRole('button', { name: 'Close workspace panel' })
|
|
|
|
expect(viewMenuButton.className).toContain('text-[14px]')
|
|
expect(viewMenuButton.className).not.toContain('text-[18px]')
|
|
expect(viewMenuButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[15px]')
|
|
expect(refreshButton.className).toContain('h-7 w-7')
|
|
expect(closeButton.className).toContain('h-7 w-7')
|
|
expect(refreshButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]')
|
|
expect(closeButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]')
|
|
})
|
|
|
|
it('shows explicit empty and error states in the changed view', async () => {
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-empty': {
|
|
isOpen: true,
|
|
activeView: 'changed',
|
|
hasUserSelectedView: true,
|
|
},
|
|
},
|
|
statusBySession: {
|
|
...state.statusBySession,
|
|
'session-empty': {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
},
|
|
},
|
|
}))
|
|
|
|
const view = await renderPanel('session-empty')
|
|
|
|
expect(view.getByText('No changes')).toBeTruthy()
|
|
|
|
getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => {
|
|
if (sessionId === 'session-error') {
|
|
throw new Error('status failed')
|
|
}
|
|
return useWorkspacePanelStore.getState().statusBySession[sessionId] ?? {
|
|
state: 'ok',
|
|
workDir: '/repo',
|
|
repoName: 'repo',
|
|
branch: 'main',
|
|
isGitRepo: true,
|
|
changedFiles: [],
|
|
}
|
|
})
|
|
|
|
await setWorkspaceState((state) => ({
|
|
...state,
|
|
panelBySession: {
|
|
...state.panelBySession,
|
|
'session-error': {
|
|
isOpen: true,
|
|
activeView: 'changed',
|
|
hasUserSelectedView: true,
|
|
},
|
|
},
|
|
errors: {
|
|
...state.errors,
|
|
statusBySession: {
|
|
...state.errors.statusBySession,
|
|
'session-error': 'status failed',
|
|
},
|
|
},
|
|
}))
|
|
|
|
await act(async () => {
|
|
view.rerender(<WorkspacePanel sessionId="session-error" />)
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(view.getByText('status failed')).toBeTruthy()
|
|
})
|
|
})
|
|
})
|