diff --git a/web/app/components/workflow/collaboration/skills/__tests__/use-skill-markdown-collaboration.spec.tsx b/web/app/components/workflow/collaboration/skills/__tests__/use-skill-markdown-collaboration.spec.tsx new file mode 100644 index 0000000000..96dc961627 --- /dev/null +++ b/web/app/components/workflow/collaboration/skills/__tests__/use-skill-markdown-collaboration.spec.tsx @@ -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() + }) +}) diff --git a/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts b/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts index db5e77c219..3db85b53ed 100644 --- a/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts +++ b/web/app/components/workflow/collaboration/skills/use-skill-code-collaboration.ts @@ -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) => { diff --git a/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts b/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts index 0ceb5fe920..64eef992f5 100644 --- a/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts +++ b/web/app/components/workflow/collaboration/skills/use-skill-markdown-collaboration.ts @@ -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) => { diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx index 17a1b10769..7819042617 100644 --- a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/__tests__/index.spec.tsx @@ -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() mocks.workflowState.fileMetadata = new Map>() mocks.workflowState.dirtyMetadataIds = new Set() @@ -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() + 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 = { diff --git a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-content-controller.ts b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-content-controller.ts index 54d72d21c2..aaa789514d 100644 --- a/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-content-controller.ts +++ b/web/app/components/workflow/skill/skill-body/panels/file-content-panel/use-file-content-controller.ts @@ -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>(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)