import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react' import { WorkflowContext } from '@/app/components/workflow/context' import { createWorkflowStore } from '@/app/components/workflow/store' import { consoleQuery } from '@/service/client' import { START_TAB_ID } from '../constants' import { useSkillSaveManager } from './skill-save-context' import { SkillSaveProvider } from './use-skill-save-manager' const { mockMutateAsync, mockToastNotify } = vi.hoisted(() => ({ mockMutateAsync: vi.fn(), mockToastNotify: vi.fn(), })) vi.mock('@/service/use-app-asset', () => ({ useUpdateAppAssetFileContent: () => ({ mutateAsync: mockMutateAsync, }), })) vi.mock('@/app/components/base/toast', () => ({ default: { notify: mockToastNotify, }, })) const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) const createWrapper = (params: { appId: string, store: ReturnType, queryClient: QueryClient }) => { const { appId, store, queryClient } = params return ({ children }: { children: ReactNode }) => ( {children} ) } const setCachedContent = (queryClient: QueryClient, appId: string, fileId: string, content: string, extra: Record = {}) => { const queryKey = consoleQuery.appAsset.getFileContent.queryKey({ input: { params: { appId, nodeId: fileId } }, }) queryClient.setQueryData(queryKey, { ...extra, content }) return queryKey } const getCachedPayload = (queryClient: QueryClient, appId: string, fileId: string) => { const queryKey = consoleQuery.appAsset.getFileContent.queryKey({ input: { params: { appId, nodeId: fileId } }, }) const cached = queryClient.getQueryData<{ content?: string }>(queryKey) if (!cached?.content) return null return JSON.parse(cached.content) as Record } // Scenario: skill save manager coordinates store state, cache, and mutations. describe('useSkillSaveManager', () => { beforeEach(() => { vi.clearAllMocks() mockMutateAsync.mockResolvedValue(undefined) }) it('should throw when used outside provider', () => { expect(() => renderHook(() => useSkillSaveManager())).toThrow('Missing SkillSaveProvider in the tree') }) // Scenario: save guard clauses block invalid saves. describe('Guards', () => { it('should return unsaved when app id is missing', async () => { // Arrange const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId: '', store, queryClient }) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile('file-1') // Assert expect(response.saved).toBe(false) expect(mockMutateAsync).not.toHaveBeenCalled() }) it('should return unsaved when no dirty content or metadata exists', async () => { // Arrange const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId: 'app-1', store, queryClient }) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile('file-1') // Assert expect(response.saved).toBe(false) expect(mockMutateAsync).not.toHaveBeenCalled() }) it('should skip saves for the start tab', async () => { // Arrange const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId: 'app-1', store, queryClient }) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile(START_TAB_ID) // Assert expect(response.saved).toBe(false) expect(mockMutateAsync).not.toHaveBeenCalled() }) }) // Scenario: successful saves update cache and clear draft state. describe('Saving', () => { it('should save draft content, update cache, and clear draft content', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftContent(fileId, 'draft-content') store.getState().setFileMetadata(fileId, { author: 'test' }) const queryKey = setCachedContent(queryClient, appId, fileId, JSON.stringify({ content: 'old' }), { extra: 'keep' }) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile(fileId) // Assert expect(response.saved).toBe(true) expect(mockMutateAsync).toHaveBeenCalledWith({ appId, nodeId: fileId, payload: { content: 'draft-content', metadata: { author: 'test' } }, }) expect(store.getState().dirtyContents.has(fileId)).toBe(false) expect(queryClient.getQueryData<{ extra?: string }>(queryKey)?.extra).toBe('keep') expect(getCachedPayload(queryClient, appId, fileId)).toEqual({ content: 'draft-content', metadata: { author: 'test' }, }) }) it('should save metadata-only changes using cached json content', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftMetadata(fileId, { version: 2 }) setCachedContent(queryClient, appId, fileId, JSON.stringify({ content: 'cached-content' })) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile(fileId) // Assert expect(response.saved).toBe(true) expect(mockMutateAsync).toHaveBeenCalledWith({ appId, nodeId: fileId, payload: { content: 'cached-content', metadata: { version: 2 } }, }) expect(store.getState().dirtyMetadataIds.has(fileId)).toBe(false) }) it('should fall back to raw cached content when json parsing fails', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftMetadata(fileId, { version: 3 }) setCachedContent(queryClient, appId, fileId, 'raw-content') const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile(fileId) // Assert expect(response.saved).toBe(true) expect(mockMutateAsync).toHaveBeenCalledWith({ appId, nodeId: fileId, payload: { content: 'raw-content', metadata: { version: 3 } }, }) }) it('should clear dirty metadata when filtered tools match saved snapshot', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const toolId1 = '00000000-0000-0000-0000-000000000001' const toolId2 = '00000000-0000-0000-0000-000000000002' const content = `Hello §[tool].[provider].[tool-name].[${toolId1}]§` const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftMetadata(fileId, { tools: { [toolId1]: { type: 'builtin' }, [toolId2]: { type: 'builtin' }, }, }) setCachedContent(queryClient, appId, fileId, JSON.stringify({ content })) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile(fileId) // Assert expect(response.saved).toBe(true) expect(store.getState().dirtyMetadataIds.has(fileId)).toBe(false) }) it('should return unsaved when metadata is dirty but no content is available', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftMetadata(fileId, { version: 4 }) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile(fileId) // Assert expect(response.saved).toBe(false) expect(mockMutateAsync).not.toHaveBeenCalled() }) it('should keep drafts when mutation fails', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftContent(fileId, 'draft-content') mockMutateAsync.mockRejectedValueOnce(new Error('failed')) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const response = await result.current.saveFile(fileId) // Assert expect(response.saved).toBe(false) expect(response.error).toBeInstanceOf(Error) expect(store.getState().dirtyContents.has(fileId)).toBe(true) }) }) // Scenario: fallback registry supplies content/metadata when other sources are empty. describe('Fallback Registry', () => { it('should use registered fallback when cache and options are missing', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ fileMetadata: new Map>(), dirtyMetadataIds: new Set([fileId]), }) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act result.current.registerFallback(fileId, { content: 'fallback-content', metadata: { source: 'registry' } }) const response = await result.current.saveFile(fileId) // Assert expect(response.saved).toBe(true) expect(mockMutateAsync).toHaveBeenCalledWith({ appId, nodeId: fileId, payload: { content: 'fallback-content', metadata: { source: 'registry' } }, }) }) it('should return unsaved after fallback is unregistered', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ fileMetadata: new Map>(), dirtyMetadataIds: new Set([fileId]), }) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act result.current.registerFallback(fileId, { content: 'fallback-content', metadata: { source: 'registry' } }) result.current.unregisterFallback(fileId) const response = await result.current.saveFile(fileId) // Assert expect(response.saved).toBe(false) expect(mockMutateAsync).not.toHaveBeenCalled() }) }) // Scenario: multiple saves for the same file are queued. describe('Queueing', () => { it('should serialize save requests for the same file', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftContent(fileId, 'draft-1') let resolveFirst: (() => void) | undefined mockMutateAsync.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act const first = result.current.saveFile(fileId) await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledTimes(1) }) store.getState().setDraftContent(fileId, 'draft-2') const second = result.current.saveFile(fileId) // Assert resolveFirst?.() await first await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledTimes(2) }) await second }) }) // Scenario: saveAllDirty saves all relevant dirty files once. describe('saveAllDirty', () => { it('should save all dirty files except the start tab', async () => { // Arrange const appId = 'app-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftContent('file-1', 'draft-1') store.getState().setDraftMetadata('file-2', { tag: 'meta' }) store.getState().setDraftContent(START_TAB_ID, 'start-draft') setCachedContent(queryClient, appId, 'file-2', JSON.stringify({ content: 'meta-content' })) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act result.current.saveAllDirty() // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledTimes(2) }) const nodeIds = mockMutateAsync.mock.calls.map(call => call[0].nodeId) expect(nodeIds.sort()).toEqual(['file-1', 'file-2']) }) it('should ignore dirty start tab when no other files are dirty', async () => { // Arrange const appId = 'app-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.getState().setDraftContent(START_TAB_ID, 'start-draft') const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) // Act result.current.saveAllDirty() // Assert await waitFor(() => { expect(mockMutateAsync).not.toHaveBeenCalled() }) }) }) // Scenario: Ctrl/Cmd+S triggers save for the active tab. describe('Keyboard Shortcut', () => { const dispatchKeydown = (key: string, modifiers: { ctrlKey?: boolean, metaKey?: boolean } = {}) => { const event = new KeyboardEvent('keydown', { key, ctrlKey: modifiers.ctrlKey ?? false, metaKey: modifiers.metaKey ?? false, bubbles: true, cancelable: true, }) window.dispatchEvent(event) } it('should trigger save on Ctrl+S for active tab', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ activeTabId: fileId }) store.getState().setDraftContent(fileId, 'draft-content') renderHook(() => useSkillSaveManager(), { wrapper }) // Act dispatchKeydown('s', { ctrlKey: true }) // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith({ appId, nodeId: fileId, payload: { content: 'draft-content' }, }) }) }) it('should trigger save on Cmd+S for active tab', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ activeTabId: fileId }) store.getState().setDraftContent(fileId, 'draft-content') renderHook(() => useSkillSaveManager(), { wrapper }) // Act dispatchKeydown('s', { metaKey: true }) // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() }) }) it('should not trigger save when activeTabId is START_TAB_ID', async () => { // Arrange const appId = 'app-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ activeTabId: START_TAB_ID }) renderHook(() => useSkillSaveManager(), { wrapper }) // Act dispatchKeydown('s', { ctrlKey: true }) // Assert await waitFor(() => { expect(mockMutateAsync).not.toHaveBeenCalled() }) }) it('should not trigger save when activeTabId is null', async () => { // Arrange const appId = 'app-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ activeTabId: null }) renderHook(() => useSkillSaveManager(), { wrapper }) // Act dispatchKeydown('s', { ctrlKey: true }) // Assert await waitFor(() => { expect(mockMutateAsync).not.toHaveBeenCalled() }) }) it('should show success toast when save succeeds', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ activeTabId: fileId }) store.getState().setDraftContent(fileId, 'draft-content') renderHook(() => useSkillSaveManager(), { wrapper }) // Act dispatchKeydown('s', { ctrlKey: true }) // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.api.saved', }) }) }) it('should show error toast when save fails', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ activeTabId: fileId }) store.getState().setDraftContent(fileId, 'draft-content') mockMutateAsync.mockRejectedValueOnce(new Error('Network error')) renderHook(() => useSkillSaveManager(), { wrapper }) // Act dispatchKeydown('s', { ctrlKey: true }) // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', message: 'Network error', }) }) }) it('should use registered fallback content for keyboard save', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ activeTabId: fileId }) store.setState({ dirtyMetadataIds: new Set([fileId]) }) const { result } = renderHook(() => useSkillSaveManager(), { wrapper }) result.current.registerFallback(fileId, { content: 'fallback-content', metadata: { tag: 'v1' } }) // Act dispatchKeydown('s', { ctrlKey: true }) // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith({ appId, nodeId: fileId, payload: { content: 'fallback-content', metadata: { tag: 'v1' } }, }) }) }) it('should not trigger save for unrelated keys', async () => { // Arrange const appId = 'app-1' const fileId = 'file-1' const store = createWorkflowStore({}) const queryClient = createQueryClient() const wrapper = createWrapper({ appId, store, queryClient }) store.setState({ activeTabId: fileId }) store.getState().setDraftContent(fileId, 'draft-content') renderHook(() => useSkillSaveManager(), { wrapper }) // Act dispatchKeydown('x', { ctrlKey: true }) // Assert await waitFor(() => { expect(mockMutateAsync).not.toHaveBeenCalled() }) }) }) })