mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 14:27:40 +00:00
* Add copyTerminal translation key * Add copy terminal button with select all functionality * Remove copy button from error view button group * Add hover-based copy button overlay to terminal * Fix clipboard copy implementation in BaseTerminal * Add 'Copy all' tooltip to terminal copy button * Fix copy button to be away from right hand side * Update copy button to respect existing selection - Copy only selected text if any exists - Copy all text and clear selection if nothing selected - Update tooltip to reflect new behavior * Add dynamic tooltip showing actual copy action - Show 'Copy selection' when text is selected - Show 'Copy all' when no text is selected * Remove redundant i18n * Fix aria-label to use dynamic tooltip text * Remove debug console.error statements from useTerminal Clean up debug logging added during development: - Remove selection change debug logging - Remove focus state debug logging - Remove keyboard event debug logging - Remove copy/paste debug logging * Remove redundant keyboard handling from useTerminal The rebase commit already fixed basic copy/paste. Removed only the complex keyboard event handling that duplicates the rebase fix. Kept the valuable UI features: - Hover copy button overlay - Right-click context menu * Use Tailwind transition classes instead of custom CSS Replace custom .animate-fade-in with standard Tailwind transition-opacity duration-200 classes * Use VueUse useElementHover for robust hover handling Replace manual mouseenter/mouseleave events with VueUse useElementHover composable which properly handles all edge cases including mouseout and interrupted events * Move tooltip to left of button Relieves squished tooltip * Simplify code * Fix listener lifecycle management Consolidate setup into single onMounted block instead of creating unnecessary duplicate lifecycle hooks * Replace any type with proper IDisposable type * Refactor copy logic for clarity * Use v-show for proper opacity transitions * Prefer optional chaining * Use useEventListener for context menu * Remove redundant opacity classes * Add BaseTerminal component tests * Use pointer-events for button interactivity * Update tests for pointer-events button behavior * Fix clipboard mock in tests * Fix test expectations for opacity classes * Simplify hover tests for button state * Remove low-value 'renders terminal container' test * Remove non-functional 'button responds to hover' test * Remove implementation detail test for dispose listener * Remove redundant 'tracks selection changes' test * Remove obvious comments from test file * Use cn() utility for conditional classes * Update tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.spec.ts Co-authored-by: Alexander Brown <drjkl@comfy.org> * [auto-fix] Apply ESLint and Prettier fixes * Remove 'any' types from wrapper and terminalMock variables Add assertion to verify onSelectionChange was called * Move mountBaseTerminal factory to module scope * Rename test file - Current consensus is .test.ts for component files * Update src/components/bottomPanel/tabs/terminal/BaseTerminal.vue * nit --------- Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: GitHub Action <action@github.com>
219 lines
5.8 KiB
TypeScript
219 lines
5.8 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { VueWrapper, mount } from '@vue/test-utils'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { nextTick } from 'vue'
|
|
import { createI18n } from 'vue-i18n'
|
|
|
|
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
|
|
|
|
// Mock xterm and related modules
|
|
vi.mock('@xterm/xterm', () => ({
|
|
Terminal: vi.fn().mockImplementation(() => ({
|
|
open: vi.fn(),
|
|
dispose: vi.fn(),
|
|
onSelectionChange: vi.fn(() => {
|
|
// Return a disposable
|
|
return {
|
|
dispose: vi.fn()
|
|
}
|
|
}),
|
|
hasSelection: vi.fn(() => false),
|
|
getSelection: vi.fn(() => ''),
|
|
selectAll: vi.fn(),
|
|
clearSelection: vi.fn(),
|
|
loadAddon: vi.fn()
|
|
})),
|
|
IDisposable: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@xterm/addon-fit', () => ({
|
|
FitAddon: vi.fn().mockImplementation(() => ({
|
|
fit: vi.fn(),
|
|
proposeDimensions: vi.fn(() => ({ rows: 24, cols: 80 }))
|
|
}))
|
|
}))
|
|
|
|
const mockTerminal = {
|
|
open: vi.fn(),
|
|
dispose: vi.fn(),
|
|
onSelectionChange: vi.fn(() => ({
|
|
dispose: vi.fn()
|
|
})),
|
|
hasSelection: vi.fn(() => false),
|
|
getSelection: vi.fn(() => ''),
|
|
selectAll: vi.fn(),
|
|
clearSelection: vi.fn()
|
|
}
|
|
|
|
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
|
|
useTerminal: vi.fn(() => ({
|
|
terminal: mockTerminal,
|
|
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/utils/envUtil', () => ({
|
|
isElectron: vi.fn(() => false),
|
|
electronAPI: vi.fn(() => null)
|
|
}))
|
|
|
|
// Mock clipboard API
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
value: {
|
|
writeText: vi.fn().mockResolvedValue(undefined)
|
|
},
|
|
configurable: true
|
|
})
|
|
|
|
const i18n = createI18n({
|
|
legacy: false,
|
|
locale: 'en',
|
|
messages: {
|
|
en: {
|
|
serverStart: {
|
|
copySelectionTooltip: 'Copy selection',
|
|
copyAllTooltip: 'Copy all'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const mountBaseTerminal = () => {
|
|
return mount(BaseTerminal, {
|
|
global: {
|
|
plugins: [
|
|
createTestingPinia({
|
|
createSpy: vi.fn
|
|
}),
|
|
i18n
|
|
],
|
|
stubs: {
|
|
Button: {
|
|
template: '<button v-bind="$attrs"><slot /></button>',
|
|
props: ['icon', 'severity', 'size']
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
describe('BaseTerminal', () => {
|
|
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
wrapper?.unmount()
|
|
})
|
|
|
|
it('emits created event on mount', () => {
|
|
wrapper = mountBaseTerminal()
|
|
|
|
expect(wrapper.emitted('created')).toBeTruthy()
|
|
expect(wrapper.emitted('created')![0]).toHaveLength(2)
|
|
})
|
|
|
|
it('emits unmounted event on unmount', () => {
|
|
wrapper = mountBaseTerminal()
|
|
wrapper.unmount()
|
|
|
|
expect(wrapper.emitted('unmounted')).toBeTruthy()
|
|
})
|
|
|
|
it('button exists and has correct initial state', async () => {
|
|
wrapper = mountBaseTerminal()
|
|
|
|
const button = wrapper.find('button[aria-label]')
|
|
expect(button.exists()).toBe(true)
|
|
|
|
expect(button.classes()).toContain('opacity-0')
|
|
expect(button.classes()).toContain('pointer-events-none')
|
|
})
|
|
|
|
it('shows correct tooltip when no selection', async () => {
|
|
mockTerminal.hasSelection.mockReturnValue(false)
|
|
wrapper = mountBaseTerminal()
|
|
|
|
await wrapper.trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
const button = wrapper.find('button[aria-label]')
|
|
expect(button.attributes('aria-label')).toBe('Copy all')
|
|
})
|
|
|
|
it('shows correct tooltip when selection exists', async () => {
|
|
mockTerminal.hasSelection.mockReturnValue(true)
|
|
wrapper = mountBaseTerminal()
|
|
|
|
// Trigger the selection change callback that was registered during mount
|
|
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
|
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
|
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
|
|
.calls[0][0]
|
|
selectionCallback()
|
|
await nextTick()
|
|
|
|
await wrapper.trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
const button = wrapper.find('button[aria-label]')
|
|
expect(button.attributes('aria-label')).toBe('Copy selection')
|
|
})
|
|
|
|
it('copies selected text when selection exists', async () => {
|
|
const selectedText = 'selected text'
|
|
mockTerminal.hasSelection.mockReturnValue(true)
|
|
mockTerminal.getSelection.mockReturnValue(selectedText)
|
|
|
|
wrapper = mountBaseTerminal()
|
|
|
|
await wrapper.trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
const button = wrapper.find('button[aria-label]')
|
|
await button.trigger('click')
|
|
|
|
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
|
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
|
|
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('copies all text when no selection exists', async () => {
|
|
const allText = 'all terminal content'
|
|
mockTerminal.hasSelection.mockReturnValue(false)
|
|
mockTerminal.getSelection
|
|
.mockReturnValueOnce('') // First call returns empty (no selection)
|
|
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
|
|
|
|
wrapper = mountBaseTerminal()
|
|
|
|
await wrapper.trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
const button = wrapper.find('button[aria-label]')
|
|
await button.trigger('click')
|
|
|
|
expect(mockTerminal.selectAll).toHaveBeenCalled()
|
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
|
|
expect(mockTerminal.clearSelection).toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not copy when no text available', async () => {
|
|
mockTerminal.hasSelection.mockReturnValue(false)
|
|
mockTerminal.getSelection.mockReturnValue('')
|
|
|
|
wrapper = mountBaseTerminal()
|
|
|
|
await wrapper.trigger('mouseenter')
|
|
await nextTick()
|
|
|
|
const button = wrapper.find('button[aria-label]')
|
|
await button.trigger('click')
|
|
|
|
expect(mockTerminal.selectAll).toHaveBeenCalled()
|
|
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
|
})
|
|
})
|