fix(skill): guard file collaboration initialization

This commit is contained in:
yyh 2026-03-27 14:53:16 +08:00
parent de62fd15bd
commit e13100098a
No known key found for this signature in database
5 changed files with 126 additions and 3 deletions

View File

@ -0,0 +1,75 @@
import { renderHook, waitFor } from '@testing-library/react'
import { useSkillMarkdownCollaboration } from '../use-skill-markdown-collaboration'
const mocks = vi.hoisted(() => ({
openFile: vi.fn<(appId: string, fileId: string, initialContent: string) => void>(),
setActiveFile: vi.fn<(appId: string, fileId: string, isActive: boolean) => void>(),
subscribe: vi.fn<(fileId: string, callback: (text: string, source: 'remote') => void) => () => void>(() => vi.fn()),
onSyncRequest: vi.fn<(fileId: string, callback: () => void) => () => void>(() => vi.fn()),
updateText: vi.fn<(fileId: string, text: string) => void>(),
isLeader: vi.fn<(fileId: string) => boolean>(() => false),
getState: vi.fn(() => ({
clearDraftContent: vi.fn(),
setDraftContent: vi.fn(),
pinTab: vi.fn(),
})),
emit: vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: mocks.getState,
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mocks.emit,
},
}),
}))
vi.mock('../skill-collaboration-manager', () => ({
skillCollaborationManager: {
openFile: (appId: string, fileId: string, initialContent: string) => mocks.openFile(appId, fileId, initialContent),
setActiveFile: (appId: string, fileId: string, isActive: boolean) => mocks.setActiveFile(appId, fileId, isActive),
subscribe: (fileId: string, callback: (text: string, source: 'remote') => void) => mocks.subscribe(fileId, callback),
onSyncRequest: (fileId: string, callback: () => void) => mocks.onSyncRequest(fileId, callback),
updateText: (fileId: string, text: string) => mocks.updateText(fileId, text),
isLeader: (fileId: string) => mocks.isLeader(fileId),
},
}))
describe('useSkillMarkdownCollaboration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should degrade gracefully when collaboration initialization fails', async () => {
const error = new TypeError('Cannot read properties of undefined (reading "lorodoc_new")')
mocks.openFile.mockImplementation(() => {
throw error
})
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
renderHook(() => useSkillMarkdownCollaboration({
appId: 'app-1',
fileId: 'file-1',
enabled: true,
initialContent: 'hello',
baselineContent: 'hello',
onLocalChange: vi.fn(),
onLeaderSync: vi.fn(),
}))
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to initialize skill collaboration:', error)
})
expect(mocks.setActiveFile).not.toHaveBeenCalled()
expect(mocks.subscribe).not.toHaveBeenCalled()
expect(mocks.onSyncRequest).not.toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})

View File

@ -49,7 +49,14 @@ export const useSkillCodeCollaboration = ({
if (!enabled || !fileId)
return
skillCollaborationManager.openFile(appId, fileId, initialContent)
try {
skillCollaborationManager.openFile(appId, fileId, initialContent)
}
catch (error) {
console.error('Failed to initialize skill collaboration:', error)
return
}
skillCollaborationManager.setActiveFile(appId, fileId, true)
const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => {

View File

@ -52,7 +52,14 @@ export const useSkillMarkdownCollaboration = ({
if (!enabled || !fileId)
return
skillCollaborationManager.openFile(appId, fileId, initialContent)
try {
skillCollaborationManager.openFile(appId, fileId, initialContent)
}
catch (error) {
console.error('Failed to initialize skill collaboration:', error)
return
}
skillCollaborationManager.setActiveFile(appId, fileId, true)
const unsubscribe = skillCollaborationManager.subscribe(fileId, (nextText) => {

View File

@ -12,6 +12,12 @@ type AppStoreState = {
} | null
}
type GlobalPublicState = {
systemFeatures: {
enable_collaboration_mode: boolean
}
}
type WorkflowStoreState = {
activeTabId: string | null
editorAutoFocusFileId: string | null
@ -127,6 +133,11 @@ const mocks = vi.hoisted(() => ({
id: 'app-1',
},
} as AppStoreState,
globalPublicState: {
systemFeatures: {
enable_collaboration_mode: true,
},
} as GlobalPublicState,
workflowState: {
activeTabId: 'file-1',
editorAutoFocusFileId: null,
@ -228,6 +239,10 @@ vi.mock('@/app/components/workflow/store', () => ({
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: GlobalPublicState) => unknown) => selector(mocks.globalPublicState),
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mocks.appTheme }),
}))
@ -389,6 +404,7 @@ describe('FileContentPanel', () => {
mocks.appState.appDetail = { id: 'app-1' }
mocks.workflowState.activeTabId = 'file-1'
mocks.workflowState.editorAutoFocusFileId = null
mocks.globalPublicState.systemFeatures.enable_collaboration_mode = true
mocks.workflowState.dirtyContents = new Map<string, string>()
mocks.workflowState.fileMetadata = new Map<string, Record<string, unknown>>()
mocks.workflowState.dirtyMetadataIds = new Set<string>()
@ -564,6 +580,22 @@ describe('FileContentPanel', () => {
expect(mocks.saveFile).toHaveBeenCalledWith('file-1')
})
it('should disable skill collaboration when system collaboration feature is off', async () => {
// Arrange
mocks.globalPublicState.systemFeatures.enable_collaboration_mode = false
// Act
render(<FileContentPanel />)
await screen.findByTestId('code-editor')
// Assert
expect(mocks.useSkillCodeCollaboration).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
}),
)
})
it('should ignore editor content updates when file is not editable', async () => {
// Arrange
mocks.fileTypeInfo = {

View File

@ -6,6 +6,7 @@ import isDeepEqual from 'fast-deep-equal'
import { useCallback, useRef } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useSkillCodeCollaboration } from '../../../../collaboration/skills/use-skill-code-collaboration'
import { useSkillMarkdownCollaboration } from '../../../../collaboration/skills/use-skill-markdown-collaboration'
import { START_TAB_ID } from '../../../constants'
@ -21,6 +22,7 @@ import { extractFileReferenceIds } from './utils'
export const useFileContentController = (): FileContentControllerState => {
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id || ''
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
const activeTabId = useStore(s => s.activeTabId)
const editorAutoFocusFileId = useStore(s => s.editorAutoFocusFileId)
const storeApi = useWorkflowStore()
@ -67,7 +69,7 @@ export const useFileContentController = (): FileContentControllerState => {
const originalContent = fileContent?.content ?? ''
const currentContent = draftContent !== undefined ? draftContent : originalContent
const initialContentRegistryRef = useRef<Map<string, string>>(new Map())
const canInitCollaboration = Boolean(appId && fileTabId && isEditable && !isLoading && !error)
const canInitCollaboration = Boolean(isCollaborationEnabled && appId && fileTabId && isEditable && !isLoading && !error)
if (canInitCollaboration && fileTabId && !initialContentRegistryRef.current.has(fileTabId))
initialContentRegistryRef.current.set(fileTabId, currentContent)