mirror of
https://github.com/langgenius/dify.git
synced 2026-03-10 11:10:19 +08:00
test: add unit tests for base components-part-4 (#32452)
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
This commit is contained in:
parent
3c69bac2b1
commit
0ac09127c7
394
web/app/components/base/audio-gallery/AudioPlayer.spec.tsx
Normal file
394
web/app/components/base/audio-gallery/AudioPlayer.spec.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import useThemeMock from '@/hooks/use-theme'
|
||||
|
||||
import { Theme } from '@/types/app'
|
||||
import AudioPlayer from './AudioPlayer'
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(() => ({ theme: 'light' })),
|
||||
}))
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildAudioContext(channelLength = 512) {
|
||||
return class MockAudioContext {
|
||||
decodeAudioData(_ab: ArrayBuffer) {
|
||||
const arr = new Float32Array(channelLength)
|
||||
for (let i = 0; i < channelLength; i++)
|
||||
arr[i] = Math.sin((i / channelLength) * Math.PI * 2) * 0.5
|
||||
return Promise.resolve({ getChannelData: (_ch: number) => arr })
|
||||
}
|
||||
|
||||
close() { return Promise.resolve() }
|
||||
}
|
||||
}
|
||||
|
||||
function stubFetchOk(size = 256) {
|
||||
const ab = new ArrayBuffer(size)
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => ab,
|
||||
} as Response)
|
||||
}
|
||||
|
||||
function stubFetchFail() {
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false } as Response)
|
||||
}
|
||||
|
||||
async function advanceWaveformTimer() {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Setup / teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
; (useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.light })
|
||||
HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined)
|
||||
HTMLMediaElement.prototype.pause = vi.fn()
|
||||
HTMLMediaElement.prototype.load = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AudioPlayer — rendering', () => {
|
||||
it('should render the play button and audio element when given a src', () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
|
||||
expect(screen.getByTestId('play-pause-btn')).toBeInTheDocument()
|
||||
expect(document.querySelector('audio')).toBeInTheDocument()
|
||||
expect(document.querySelector('audio')?.getAttribute('src')).toBe('https://example.com/a.mp3')
|
||||
})
|
||||
|
||||
it('should render <source> elements when srcs array is provided', () => {
|
||||
render(<AudioPlayer srcs={['https://example.com/a.mp3', 'https://example.com/b.ogg']} />)
|
||||
|
||||
const sources = document.querySelectorAll('audio source')
|
||||
expect(sources).toHaveLength(2)
|
||||
expect((sources[0] as HTMLSourceElement).src).toBe('https://example.com/a.mp3')
|
||||
expect((sources[1] as HTMLSourceElement).src).toBe('https://example.com/b.ogg')
|
||||
})
|
||||
|
||||
it('should render without crashing when no props are supplied', () => {
|
||||
render(<AudioPlayer />)
|
||||
expect(screen.getByTestId('play-pause-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Play / Pause toggle ──────────────────────────────────────────────────────
|
||||
|
||||
describe('AudioPlayer — play/pause', () => {
|
||||
it('should call audio.play() on first button click', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const btn = screen.getByTestId('play-pause-btn')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(btn)
|
||||
})
|
||||
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call audio.pause() on second button click', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const btn = screen.getByTestId('play-pause-btn')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(btn)
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(btn)
|
||||
})
|
||||
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show the pause icon while playing and play icon while paused', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const btn = screen.getByTestId('play-pause-btn')
|
||||
|
||||
expect(btn.querySelector('.i-ri-play-large-fill')).toBeInTheDocument()
|
||||
expect(btn.querySelector('.i-ri-pause-circle-fill')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(btn)
|
||||
})
|
||||
|
||||
expect(btn.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument()
|
||||
expect(btn.querySelector('.i-ri-play-large-fill')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset to stopped state when the audio ends', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const btn = screen.getByTestId('play-pause-btn')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(btn)
|
||||
})
|
||||
expect(btn.querySelector('.i-ri-pause-circle-fill')).toBeInTheDocument()
|
||||
|
||||
const audio = document.querySelector('audio') as HTMLAudioElement
|
||||
await act(async () => {
|
||||
audio.dispatchEvent(new Event('ended'))
|
||||
})
|
||||
|
||||
expect(btn.querySelector('.i-ri-play-large-fill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable the play button when an audio error occurs', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const audio = document.querySelector('audio') as HTMLAudioElement
|
||||
|
||||
await act(async () => {
|
||||
audio.dispatchEvent(new Event('error'))
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('play-pause-btn')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Audio events ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AudioPlayer — audio events', () => {
|
||||
it('should update duration display when loadedmetadata fires', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const audio = document.querySelector('audio') as HTMLAudioElement
|
||||
Object.defineProperty(audio, 'duration', { value: 90, configurable: true })
|
||||
|
||||
await act(async () => {
|
||||
audio.dispatchEvent(new Event('loadedmetadata'))
|
||||
})
|
||||
|
||||
expect(screen.getByText('1:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update bufferedTime on progress event', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const audio = document.querySelector('audio') as HTMLAudioElement
|
||||
|
||||
const bufferedStub = { length: 1, start: () => 0, end: () => 60 }
|
||||
Object.defineProperty(audio, 'buffered', { value: bufferedStub, configurable: true })
|
||||
|
||||
await act(async () => {
|
||||
audio.dispatchEvent(new Event('progress'))
|
||||
})
|
||||
})
|
||||
|
||||
it('should do nothing on progress when buffered.length is 0', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const audio = document.querySelector('audio') as HTMLAudioElement
|
||||
|
||||
const bufferedStub = { length: 0, start: () => 0, end: () => 0 }
|
||||
Object.defineProperty(audio, 'buffered', { value: bufferedStub, configurable: true })
|
||||
|
||||
await act(async () => {
|
||||
audio.dispatchEvent(new Event('progress'))
|
||||
})
|
||||
})
|
||||
|
||||
it('should set isAudioAvailable to false when an audio error occurs', async () => {
|
||||
render(<AudioPlayer src="https://example.com/a.mp3" />)
|
||||
const audio = document.querySelector('audio') as HTMLAudioElement
|
||||
|
||||
await act(async () => {
|
||||
audio.dispatchEvent(new Event('error'))
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('play-pause-btn')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Waveform generation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('AudioPlayer — waveform generation', () => {
|
||||
it('should render the waveform canvas after fetch + decode succeed', async () => {
|
||||
vi.stubGlobal('AudioContext', buildAudioContext(700))
|
||||
stubFetchOk(512)
|
||||
|
||||
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
|
||||
await advanceWaveformTimer()
|
||||
|
||||
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use fallback random waveform when fetch returns not-ok', async () => {
|
||||
vi.stubGlobal('AudioContext', buildAudioContext(400))
|
||||
stubFetchFail()
|
||||
|
||||
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
|
||||
await advanceWaveformTimer()
|
||||
|
||||
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use fallback waveform when decodeAudioData rejects', async () => {
|
||||
class FailDecodeContext {
|
||||
decodeAudioData() { return Promise.reject(new Error('decode error')) }
|
||||
close() { return Promise.resolve() }
|
||||
}
|
||||
vi.stubGlobal('AudioContext', FailDecodeContext)
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(128),
|
||||
} as Response)
|
||||
|
||||
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
|
||||
await advanceWaveformTimer()
|
||||
|
||||
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show Toast when AudioContext is not available', async () => {
|
||||
vi.stubGlobal('AudioContext', undefined)
|
||||
|
||||
render(<AudioPlayer src="https://example.com/audio.mp3" />)
|
||||
await advanceWaveformTimer()
|
||||
|
||||
const toastFound = Array.from(document.body.querySelectorAll('div')).some(
|
||||
d => d.textContent?.includes('Web Audio API is not supported in this browser'),
|
||||
)
|
||||
expect(toastFound).toBe(true)
|
||||
})
|
||||
|
||||
it('should set audio unavailable when URL is not http/https', async () => {
|
||||
vi.stubGlobal('AudioContext', buildAudioContext())
|
||||
|
||||
render(<AudioPlayer srcs={['blob:something']} />)
|
||||
await advanceWaveformTimer()
|
||||
|
||||
expect(screen.getByTestId('play-pause-btn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not trigger waveform generation when no src or srcs provided', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
render(<AudioPlayer />)
|
||||
await advanceWaveformTimer()
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use srcs[0] as primary source for waveform', async () => {
|
||||
vi.stubGlobal('AudioContext', buildAudioContext(300))
|
||||
const fetchSpy = stubFetchOk(256)
|
||||
|
||||
render(<AudioPlayer srcs={['https://cdn.example/first.mp3', 'https://cdn.example/second.mp3']} />)
|
||||
await advanceWaveformTimer()
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith('https://cdn.example/first.mp3', { mode: 'cors' })
|
||||
})
|
||||
|
||||
it('should cover dark theme waveform draw branch', async () => {
|
||||
; (useThemeMock as ReturnType<typeof vi.fn>).mockReturnValue({ theme: Theme.dark })
|
||||
vi.stubGlobal('AudioContext', buildAudioContext(300))
|
||||
stubFetchOk(256)
|
||||
|
||||
render(<AudioPlayer src="https://cdn.example/audio.mp3" />)
|
||||
await advanceWaveformTimer()
|
||||
|
||||
expect(screen.getByTestId('waveform-canvas')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Canvas interactions ──────────────────────────────────────────────────────
|
||||
|
||||
describe('AudioPlayer — canvas seek interactions', () => {
|
||||
async function renderWithDuration(src = 'https://example.com/audio.mp3', durationVal = 120) {
|
||||
vi.stubGlobal('AudioContext', buildAudioContext(300))
|
||||
stubFetchOk(128)
|
||||
|
||||
render(<AudioPlayer src={src} />)
|
||||
|
||||
const audio = document.querySelector('audio') as HTMLAudioElement
|
||||
Object.defineProperty(audio, 'duration', { value: durationVal, configurable: true })
|
||||
Object.defineProperty(audio, 'buffered', {
|
||||
value: { length: 1, start: () => 0, end: () => durationVal },
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
audio.dispatchEvent(new Event('loadedmetadata'))
|
||||
})
|
||||
await advanceWaveformTimer()
|
||||
|
||||
const canvas = screen.getByTestId('waveform-canvas') as HTMLCanvasElement
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({ left: 0, width: 200, top: 0, height: 10, right: 200, bottom: 10 }) as DOMRect
|
||||
|
||||
return { audio, canvas }
|
||||
}
|
||||
|
||||
it('should seek to clicked position and start playback', async () => {
|
||||
const { audio, canvas } = await renderWithDuration()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(canvas, { clientX: 100 })
|
||||
})
|
||||
|
||||
expect(Math.abs((audio.currentTime || 0) - 60)).toBeLessThanOrEqual(2)
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should seek on mousedown', async () => {
|
||||
const { canvas } = await renderWithDuration()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(canvas, { clientX: 50 })
|
||||
})
|
||||
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call play again when already playing and canvas is clicked', async () => {
|
||||
const { canvas } = await renderWithDuration()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(canvas, { clientX: 50 })
|
||||
})
|
||||
const callsAfterFirst = (HTMLMediaElement.prototype.play as ReturnType<typeof vi.fn>).mock.calls.length
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(canvas, { clientX: 80 })
|
||||
})
|
||||
|
||||
expect((HTMLMediaElement.prototype.play as ReturnType<typeof vi.fn>).mock.calls.length).toBe(callsAfterFirst)
|
||||
})
|
||||
|
||||
it('should update hoverTime on mousemove within buffered range', async () => {
|
||||
const { audio, canvas } = await renderWithDuration()
|
||||
|
||||
Object.defineProperty(audio, 'buffered', {
|
||||
value: { length: 1, start: () => 0, end: () => 120 },
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseMove(canvas, { clientX: 100 })
|
||||
})
|
||||
})
|
||||
|
||||
it('should not update hoverTime when outside all buffered ranges', async () => {
|
||||
const { audio, canvas } = await renderWithDuration()
|
||||
|
||||
Object.defineProperty(audio, 'buffered', {
|
||||
value: { length: 0, start: () => 0, end: () => 0 },
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseMove(canvas, { clientX: 100 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,3 @@
|
||||
import {
|
||||
RiPauseCircleFill,
|
||||
RiPlayLargeFill,
|
||||
} from '@remixicon/react'
|
||||
import { t } from 'i18next'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@ -299,25 +295,26 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
|
||||
<source key={index} src={srcUrl} />
|
||||
))}
|
||||
</audio>
|
||||
<button type="button" className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}>
|
||||
<button type="button" data-testid="play-pause-btn" className="inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled" onClick={togglePlay} disabled={!isAudioAvailable}>
|
||||
{isPlaying
|
||||
? (
|
||||
<RiPauseCircleFill className="h-5 w-5" />
|
||||
<div className="i-ri-pause-circle-fill h-5 w-5" />
|
||||
)
|
||||
: (
|
||||
<RiPlayLargeFill className="h-5 w-5" />
|
||||
<div className="i-ri-play-large-fill h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
<div className={cn(isAudioAvailable && 'grow')} hidden={!isAudioAvailable}>
|
||||
<div className="flex h-8 items-center justify-center">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
data-testid="waveform-canvas"
|
||||
className="relative flex h-6 w-full grow cursor-pointer items-center justify-center"
|
||||
onClick={handleCanvasInteraction}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleCanvasInteraction}
|
||||
/>
|
||||
<div className="system-xs-medium inline-flex min-w-[50px] items-center justify-center text-text-accent-secondary">
|
||||
<div className="inline-flex min-w-[50px] items-center justify-center text-text-accent-secondary system-xs-medium">
|
||||
<span className="rounded-[10px] px-0.5 py-1">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
356
web/app/components/base/markdown-blocks/code-block.spec.tsx
Normal file
356
web/app/components/base/markdown-blocks/code-block.spec.tsx
Normal file
@ -0,0 +1,356 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Theme } from '@/types/app'
|
||||
|
||||
import CodeBlock from './code-block'
|
||||
|
||||
type UseThemeReturn = {
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
const mockUseTheme = vi.fn<() => UseThemeReturn>(() => ({ theme: Theme.light }))
|
||||
const require = createRequire(import.meta.url)
|
||||
const echartsCjs = require('echarts') as {
|
||||
getInstanceByDom: (dom: HTMLDivElement | null) => {
|
||||
resize: (opts?: { width?: string, height?: string }) => void
|
||||
} | null
|
||||
}
|
||||
|
||||
let clientWidthSpy: { mockRestore: () => void } | null = null
|
||||
let clientHeightSpy: { mockRestore: () => void } | null = null
|
||||
let offsetWidthSpy: { mockRestore: () => void } | null = null
|
||||
let offsetHeightSpy: { mockRestore: () => void } | null = null
|
||||
|
||||
type AudioContextCtor = new () => unknown
|
||||
type WindowWithLegacyAudio = Window & {
|
||||
AudioContext?: AudioContextCtor
|
||||
webkitAudioContext?: AudioContextCtor
|
||||
abcjsAudioContext?: unknown
|
||||
}
|
||||
|
||||
let originalAudioContext: AudioContextCtor | undefined
|
||||
let originalWebkitAudioContext: AudioContextCtor | undefined
|
||||
|
||||
class MockAudioContext {
|
||||
state = 'running'
|
||||
currentTime = 0
|
||||
destination = {}
|
||||
|
||||
resume = vi.fn(async () => undefined)
|
||||
|
||||
decodeAudioData = vi.fn(async (_data: ArrayBuffer, success?: (audioBuffer: unknown) => void) => {
|
||||
const mockAudioBuffer = {}
|
||||
success?.(mockAudioBuffer)
|
||||
return mockAudioBuffer
|
||||
})
|
||||
|
||||
createBufferSource = vi.fn(() => ({
|
||||
buffer: null as unknown,
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
onended: undefined as undefined | (() => void),
|
||||
}))
|
||||
}
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
__esModule: true,
|
||||
default: () => mockUseTheme(),
|
||||
}))
|
||||
|
||||
const findEchartsHost = async () => {
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.echarts-for-react')).toBeInTheDocument()
|
||||
})
|
||||
return document.querySelector('.echarts-for-react') as HTMLDivElement
|
||||
}
|
||||
|
||||
const findEchartsInstance = async () => {
|
||||
const host = await findEchartsHost()
|
||||
await waitFor(() => {
|
||||
expect(echartsCjs.getInstanceByDom(host)).toBeTruthy()
|
||||
})
|
||||
return echartsCjs.getInstanceByDom(host)!
|
||||
}
|
||||
|
||||
describe('CodeBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.light })
|
||||
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
|
||||
clientHeightSpy = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(400)
|
||||
offsetWidthSpy = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(900)
|
||||
offsetHeightSpy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(400)
|
||||
|
||||
const windowWithLegacyAudio = window as WindowWithLegacyAudio
|
||||
originalAudioContext = windowWithLegacyAudio.AudioContext
|
||||
originalWebkitAudioContext = windowWithLegacyAudio.webkitAudioContext
|
||||
windowWithLegacyAudio.AudioContext = MockAudioContext as unknown as AudioContextCtor
|
||||
windowWithLegacyAudio.webkitAudioContext = MockAudioContext as unknown as AudioContextCtor
|
||||
delete windowWithLegacyAudio.abcjsAudioContext
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
clientWidthSpy?.mockRestore()
|
||||
clientHeightSpy?.mockRestore()
|
||||
offsetWidthSpy?.mockRestore()
|
||||
offsetHeightSpy?.mockRestore()
|
||||
clientWidthSpy = null
|
||||
clientHeightSpy = null
|
||||
offsetWidthSpy = null
|
||||
offsetHeightSpy = null
|
||||
|
||||
const windowWithLegacyAudio = window as WindowWithLegacyAudio
|
||||
if (originalAudioContext)
|
||||
windowWithLegacyAudio.AudioContext = originalAudioContext
|
||||
else
|
||||
delete windowWithLegacyAudio.AudioContext
|
||||
|
||||
if (originalWebkitAudioContext)
|
||||
windowWithLegacyAudio.webkitAudioContext = originalWebkitAudioContext
|
||||
else
|
||||
delete windowWithLegacyAudio.webkitAudioContext
|
||||
|
||||
delete windowWithLegacyAudio.abcjsAudioContext
|
||||
originalAudioContext = undefined
|
||||
originalWebkitAudioContext = undefined
|
||||
})
|
||||
|
||||
// Base rendering behaviors for inline and language labels.
|
||||
describe('Rendering', () => {
|
||||
it('should render inline code element when inline prop is true', () => {
|
||||
const { container } = render(<CodeBlock inline className="language-javascript">const a=1;</CodeBlock>)
|
||||
|
||||
const code = container.querySelector('code')
|
||||
expect(code).toBeTruthy()
|
||||
expect(code?.textContent).toBe('const a=1;')
|
||||
})
|
||||
|
||||
it('should render code element when className does not include language prefix', () => {
|
||||
const { container } = render(<CodeBlock className="plain">abc</CodeBlock>)
|
||||
|
||||
expect(container.querySelector('code')?.textContent).toBe('abc')
|
||||
})
|
||||
|
||||
it('should render code element when className is not provided', () => {
|
||||
const { container } = render(<CodeBlock>plain text</CodeBlock>)
|
||||
|
||||
expect(container.querySelector('code')?.textContent).toBe('plain text')
|
||||
})
|
||||
|
||||
it('should render syntax-highlighted output when language is standard', () => {
|
||||
render(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
|
||||
|
||||
expect(screen.getByText('JavaScript')).toBeInTheDocument()
|
||||
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
|
||||
})
|
||||
|
||||
it('should format unknown language labels with capitalized fallback when language is not in map', () => {
|
||||
render(<CodeBlock className="language-ruby">puts "ok"</CodeBlock>)
|
||||
|
||||
expect(screen.getByText('Ruby')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render mermaid controls when language is mermaid', async () => {
|
||||
render(<CodeBlock className="language-mermaid">graph TB; A-->B;</CodeBlock>)
|
||||
|
||||
expect(await screen.findByText('app.mermaid.classic')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mermaid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render abc section header when language is abc', () => {
|
||||
render(<CodeBlock className="language-abc">X:1\nT:test</CodeBlock>)
|
||||
|
||||
expect(screen.getByText('ABC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide svg renderer when toggle is clicked for svg language', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<CodeBlock className="language-svg">{'<svg/>'}</CodeBlock>)
|
||||
|
||||
expect(await screen.findByText(/Error rendering SVG/i)).toBeInTheDocument()
|
||||
|
||||
const svgToggleButton = screen.getAllByRole('button')[0]
|
||||
await user.click(svgToggleButton)
|
||||
|
||||
expect(screen.queryByText(/Error rendering SVG/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render syntax-highlighted output when language is standard and app theme is dark', () => {
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.dark })
|
||||
|
||||
render(<CodeBlock className="language-javascript">const y = 2;</CodeBlock>)
|
||||
|
||||
expect(screen.getByText('JavaScript')).toBeInTheDocument()
|
||||
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
|
||||
})
|
||||
})
|
||||
|
||||
// ECharts behaviors for loading, parsing, and chart lifecycle updates.
|
||||
describe('ECharts', () => {
|
||||
it('should show loading indicator when echarts content is empty', () => {
|
||||
render(<CodeBlock className="language-echarts"></CodeBlock>)
|
||||
|
||||
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep loading when echarts content is whitespace only', () => {
|
||||
render(<CodeBlock className="language-echarts">{' '}</CodeBlock>)
|
||||
|
||||
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render echarts with parsed option when JSON is valid', async () => {
|
||||
const option = { title: [{ text: 'Hello' }] }
|
||||
render(<CodeBlock className="language-echarts">{JSON.stringify(option)}</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use error option when echarts content is invalid but structurally complete', async () => {
|
||||
render(<CodeBlock className="language-echarts">{'{a:1}'}</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use error option when echarts content is invalid non-structured text', async () => {
|
||||
render(<CodeBlock className="language-echarts">{'not a json {'}</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Chart loading.../i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep loading when option is valid JSON but not an object', async () => {
|
||||
render(<CodeBlock className="language-echarts">"text-value"</CodeBlock>)
|
||||
|
||||
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep loading when echarts content matches incomplete quote-pattern guard', async () => {
|
||||
render(<CodeBlock className="language-echarts">{'x{"a":1'}</CodeBlock>)
|
||||
|
||||
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep loading when echarts content has unmatched opening array bracket', async () => {
|
||||
render(<CodeBlock className="language-echarts">[[1,2]</CodeBlock>)
|
||||
|
||||
expect(await screen.findByText(/Chart loading.../i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep chart instance stable when window resize is triggered', async () => {
|
||||
render(<CodeBlock className="language-echarts">{'{}'}</CodeBlock>)
|
||||
|
||||
await findEchartsHost()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep rendering when echarts content updates repeatedly', async () => {
|
||||
const { rerender } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
|
||||
await findEchartsHost()
|
||||
|
||||
rerender(<CodeBlock className="language-echarts">{'{"a":2}'}</CodeBlock>)
|
||||
rerender(<CodeBlock className="language-echarts">{'{"a":3}'}</CodeBlock>)
|
||||
rerender(<CodeBlock className="language-echarts">{'{"a":4}'}</CodeBlock>)
|
||||
rerender(<CodeBlock className="language-echarts">{'{"a":5}'}</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stop processing extra finished events when chart finished callback fires repeatedly', async () => {
|
||||
render(<CodeBlock className="language-echarts">{'{"series":[]}'}</CodeBlock>)
|
||||
const chart = await findEchartsInstance()
|
||||
const chartWithTrigger = chart as unknown as { trigger?: (eventName: string, event?: unknown) => void }
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
chartWithTrigger.trigger?.('finished', {})
|
||||
chart.resize()
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
})
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch from loading to chart when streaming content becomes valid JSON', async () => {
|
||||
const { rerender } = render(<CodeBlock className="language-echarts">{'{ "a":'}</CodeBlock>)
|
||||
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
|
||||
|
||||
rerender(<CodeBlock className="language-echarts">{'{ "a": 1 }'}</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should parse array JSON after previously incomplete streaming content', async () => {
|
||||
const parseSpy = vi.spyOn(JSON, 'parse')
|
||||
parseSpy.mockImplementationOnce(() => ({ series: [] }) as unknown as object)
|
||||
const { rerender } = render(<CodeBlock className="language-echarts">[1, 2</CodeBlock>)
|
||||
expect(screen.getByText(/Chart loading.../i)).toBeInTheDocument()
|
||||
|
||||
rerender(<CodeBlock className="language-echarts">[1, 2]</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
parseSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should parse non-structured streaming content when JSON.parse fallback succeeds', async () => {
|
||||
const parseSpy = vi.spyOn(JSON, 'parse')
|
||||
parseSpy.mockImplementationOnce(() => ({ recovered: true }) as unknown as object)
|
||||
|
||||
render(<CodeBlock className="language-echarts">abcde</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
parseSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should render dark themed echarts path when app theme is dark', async () => {
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.dark })
|
||||
render(<CodeBlock className="language-echarts">{'{"series":[]}'}</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dark mode error option when app theme is dark and echarts content is invalid', async () => {
|
||||
mockUseTheme.mockReturnValue({ theme: Theme.dark })
|
||||
render(<CodeBlock className="language-echarts">{'{a:1}'}</CodeBlock>)
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should wire resize listener when echarts view re-enters with a ready chart instance', async () => {
|
||||
const { rerender, unmount } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
|
||||
await findEchartsHost()
|
||||
|
||||
rerender(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
|
||||
rerender(<CodeBlock className="language-echarts">{'{"a":2}'}</CodeBlock>)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
expect(await findEchartsHost()).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('should cleanup echarts resize listener without pending timer on unmount', async () => {
|
||||
const { unmount } = render(<CodeBlock className="language-echarts">{'{"a":1}'}</CodeBlock>)
|
||||
await findEchartsHost()
|
||||
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
164
web/app/components/base/markdown-blocks/link.spec.tsx
Normal file
164
web/app/components/base/markdown-blocks/link.spec.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Link from './link'
|
||||
|
||||
// ---- mocks ----
|
||||
const mockOnSend = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/context', () => ({
|
||||
useChatContext: () => ({
|
||||
onSend: mockOnSend,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockIsValidUrl = vi.fn()
|
||||
vi.mock('./utils', () => ({
|
||||
isValidUrl: (url: string) => mockIsValidUrl(url),
|
||||
}))
|
||||
|
||||
describe('Link component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------
|
||||
// ABBR LINK
|
||||
// --------------------------
|
||||
it('renders abbr link and calls onSend when clicked', () => {
|
||||
const node = {
|
||||
properties: {
|
||||
href: 'abbr:hello%20world',
|
||||
},
|
||||
children: [{ value: 'Tooltip text' }],
|
||||
}
|
||||
|
||||
render(<Link node={node} />)
|
||||
|
||||
const abbr = screen.getByText('Tooltip text')
|
||||
expect(abbr.tagName).toBe('ABBR')
|
||||
|
||||
fireEvent.click(abbr)
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledWith('hello world')
|
||||
})
|
||||
|
||||
// --------------------------
|
||||
// HASH SCROLL LINK
|
||||
// --------------------------
|
||||
it('scrolls to target element when hash link clicked', () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
Element.prototype.scrollIntoView = scrollIntoView
|
||||
|
||||
const node = {
|
||||
properties: {
|
||||
href: '#section1',
|
||||
},
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.className = 'chat-answer-container'
|
||||
|
||||
const target = document.createElement('div')
|
||||
target.id = 'section1'
|
||||
|
||||
container.appendChild(target)
|
||||
document.body.appendChild(container)
|
||||
|
||||
render(
|
||||
<div className="chat-answer-container">
|
||||
<div id="section1" />
|
||||
<Link node={node}>Go</Link>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const link = screen.getByText('Go')
|
||||
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// --------------------------
|
||||
// INVALID URL
|
||||
// --------------------------
|
||||
it('renders span when url is invalid', () => {
|
||||
mockIsValidUrl.mockReturnValue(false)
|
||||
|
||||
const node = {
|
||||
properties: {
|
||||
href: 'not-a-url',
|
||||
},
|
||||
}
|
||||
|
||||
render(<Link node={node}>Invalid</Link>)
|
||||
|
||||
const span = screen.getByText('Invalid')
|
||||
expect(span.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
// --------------------------
|
||||
// VALID EXTERNAL URL
|
||||
// --------------------------
|
||||
it('renders external link with target blank when url is valid', () => {
|
||||
mockIsValidUrl.mockReturnValue(true)
|
||||
|
||||
const node = {
|
||||
properties: {
|
||||
href: 'https://example.com',
|
||||
},
|
||||
}
|
||||
|
||||
render(<Link node={node}>Visit</Link>)
|
||||
|
||||
const link = screen.getByText('Visit')
|
||||
|
||||
expect(link.tagName).toBe('A')
|
||||
expect(link).toHaveAttribute('href', 'https://example.com')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
// --------------------------
|
||||
// NO HREF
|
||||
// --------------------------
|
||||
it('renders span when no href provided', () => {
|
||||
const node = {
|
||||
properties: {},
|
||||
}
|
||||
|
||||
render(<Link node={node}>NoHref</Link>)
|
||||
|
||||
const span = screen.getByText('NoHref')
|
||||
expect(span.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
// --------------------------
|
||||
// DEFAULT TEXT FALLBACK
|
||||
// --------------------------
|
||||
it('renders default text for external link if children not provided', () => {
|
||||
mockIsValidUrl.mockReturnValue(true)
|
||||
|
||||
const node = {
|
||||
properties: {
|
||||
href: 'https://example.com',
|
||||
},
|
||||
}
|
||||
|
||||
render(<Link node={node} />)
|
||||
|
||||
expect(screen.getByText('Download')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders default text for hash link if children not provided', () => {
|
||||
const node = {
|
||||
properties: {
|
||||
href: '#section1',
|
||||
},
|
||||
}
|
||||
|
||||
render(<Link node={node} />)
|
||||
|
||||
expect(screen.getByText('ScrollView')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
46
web/app/components/base/markdown-blocks/music.spec.tsx
Normal file
46
web/app/components/base/markdown-blocks/music.spec.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ErrorBoundary from '@/app/components/base/markdown/error-boundary'
|
||||
import MarkdownMusic from './music'
|
||||
|
||||
describe('MarkdownMusic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Base rendering behavior for the component shell.
|
||||
describe('Rendering', () => {
|
||||
it('should render wrapper and two internal container nodes', () => {
|
||||
const { container } = render(<MarkdownMusic><span>child</span></MarkdownMusic>)
|
||||
|
||||
const topLevel = container.firstElementChild as HTMLElement | null
|
||||
expect(topLevel).toBeTruthy()
|
||||
expect(topLevel?.children.length).toBe(2)
|
||||
expect(topLevel?.style.minWidth).toBe('100%')
|
||||
expect(topLevel?.style.overflow).toBe('auto')
|
||||
})
|
||||
})
|
||||
|
||||
// String input triggers abcjs execution in jsdom; verify error is safely catchable.
|
||||
describe('String Input', () => {
|
||||
it('should render fallback when abcjs audio initialization fails in test environment', async () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<MarkdownMusic>{'X:1\nT:Test\nK:C\nC D E F|'}</MarkdownMusic>
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText(/Oops! An error occurred./i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render fallback when children is not a string', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<MarkdownMusic><span>not a string</span></MarkdownMusic>
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/Oops! An error occurred./i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
96
web/app/components/base/markdown-blocks/plugin-img.spec.tsx
Normal file
96
web/app/components/base/markdown-blocks/plugin-img.spec.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PluginImg } from './plugin-img'
|
||||
|
||||
/* -------------------- Mocks -------------------- */
|
||||
|
||||
vi.mock('@/app/components/base/image-gallery', () => ({
|
||||
__esModule: true,
|
||||
default: ({ srcs }: { srcs: string[] }) => (
|
||||
<div data-testid="image-gallery">{srcs[0]}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockUsePluginReadmeAsset = vi.fn()
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
usePluginReadmeAsset: (args: unknown) => mockUsePluginReadmeAsset(args),
|
||||
}))
|
||||
|
||||
const mockGetMarkdownImageURL = vi.fn()
|
||||
vi.mock('./utils', () => ({
|
||||
getMarkdownImageURL: (src: string, pluginId?: string) =>
|
||||
mockGetMarkdownImageURL(src, pluginId),
|
||||
}))
|
||||
|
||||
/* -------------------- Tests -------------------- */
|
||||
|
||||
describe('PluginImg', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('uses blob URL when assetData exists', () => {
|
||||
const fakeBlob = new Blob(['test'])
|
||||
const fakeObjectUrl = 'blob:test-url'
|
||||
|
||||
mockUsePluginReadmeAsset.mockReturnValue({ data: fakeBlob })
|
||||
mockGetMarkdownImageURL.mockReturnValue('fallback-url')
|
||||
|
||||
const createSpy = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue(fakeObjectUrl)
|
||||
|
||||
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL')
|
||||
|
||||
const { unmount } = render(
|
||||
<PluginImg
|
||||
src="file.png"
|
||||
pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const gallery = screen.getByTestId('image-gallery')
|
||||
expect(gallery.textContent).toBe(fakeObjectUrl)
|
||||
|
||||
expect(createSpy).toHaveBeenCalledWith(fakeBlob)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(revokeSpy).toHaveBeenCalledWith(fakeObjectUrl)
|
||||
})
|
||||
|
||||
it('falls back to getMarkdownImageURL when no assetData', () => {
|
||||
mockUsePluginReadmeAsset.mockReturnValue({ data: undefined })
|
||||
mockGetMarkdownImageURL.mockReturnValue('computed-url')
|
||||
|
||||
render(
|
||||
<PluginImg
|
||||
src="file.png"
|
||||
pluginInfo={{ pluginUniqueIdentifier: 'abc', pluginId: '123' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const gallery = screen.getByTestId('image-gallery')
|
||||
expect(gallery.textContent).toBe('computed-url')
|
||||
|
||||
expect(mockGetMarkdownImageURL).toHaveBeenCalledWith('file.png', '123')
|
||||
})
|
||||
|
||||
it('works without pluginInfo', () => {
|
||||
mockUsePluginReadmeAsset.mockReturnValue({ data: undefined })
|
||||
mockGetMarkdownImageURL.mockReturnValue('default-url')
|
||||
|
||||
render(<PluginImg src="file.png" />)
|
||||
|
||||
const gallery = screen.getByTestId('image-gallery')
|
||||
expect(gallery.textContent).toBe('default-url')
|
||||
|
||||
expect(mockGetMarkdownImageURL).toHaveBeenCalledWith('file.png', undefined)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,69 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import ScriptBlock from './script-block'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
type ScriptNode = {
|
||||
children: Array<{ value?: string }>
|
||||
}
|
||||
|
||||
describe('ScriptBlock', () => {
|
||||
it('renders script tag string when child has value', () => {
|
||||
const node: ScriptNode = {
|
||||
children: [{ value: 'alert("hi")' }],
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ScriptBlock node={node} />,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe('<script>alert("hi")</script>')
|
||||
})
|
||||
|
||||
it('renders empty script tag when child value is undefined', () => {
|
||||
const node: ScriptNode = {
|
||||
children: [{}],
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ScriptBlock node={node} />,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe('<script></script>')
|
||||
})
|
||||
|
||||
it('renders empty script tag when children array is empty', () => {
|
||||
const node: ScriptNode = {
|
||||
children: [],
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ScriptBlock node={node} />,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe('<script></script>')
|
||||
})
|
||||
|
||||
it('preserves multiline script content', () => {
|
||||
const multi = `console.log("line1");
|
||||
console.log("line2");`
|
||||
|
||||
const node: ScriptNode = {
|
||||
children: [{ value: multi }],
|
||||
}
|
||||
|
||||
const { container } = render(
|
||||
<ScriptBlock node={node} />,
|
||||
)
|
||||
|
||||
expect(container.textContent).toBe(`<script>${multi}</script>`)
|
||||
})
|
||||
|
||||
it('has displayName set correctly', () => {
|
||||
expect(ScriptBlock.displayName).toBe('ScriptBlock')
|
||||
})
|
||||
})
|
||||
84
web/app/components/base/markdown-blocks/video-block.spec.tsx
Normal file
84
web/app/components/base/markdown-blocks/video-block.spec.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import VideoGallery from '../video-gallery'
|
||||
import VideoBlock from './video-block'
|
||||
|
||||
type ChildNode = {
|
||||
properties?: {
|
||||
src?: string
|
||||
}
|
||||
}
|
||||
|
||||
type BlockNode = {
|
||||
children: ChildNode[]
|
||||
properties?: {
|
||||
src?: string
|
||||
}
|
||||
}
|
||||
|
||||
describe('VideoBlock', () => {
|
||||
it('renders multiple video sources from node.children', () => {
|
||||
const node: BlockNode = {
|
||||
children: [
|
||||
{ properties: { src: 'a.mp4' } },
|
||||
{ properties: { src: 'b.mp4' } },
|
||||
],
|
||||
}
|
||||
|
||||
render(<VideoBlock node={node} />)
|
||||
|
||||
const video = document.querySelector('video')
|
||||
expect(video).toBeTruthy()
|
||||
|
||||
const sources = document.querySelectorAll('source')
|
||||
expect(sources).toHaveLength(2)
|
||||
expect(sources[0]).toHaveAttribute('src', 'a.mp4')
|
||||
expect(sources[1]).toHaveAttribute('src', 'b.mp4')
|
||||
})
|
||||
|
||||
it('renders single video from node.properties.src when no children srcs', () => {
|
||||
const node: BlockNode = {
|
||||
children: [],
|
||||
properties: { src: 'single.mp4' },
|
||||
}
|
||||
|
||||
render(<VideoBlock node={node} />)
|
||||
|
||||
const sources = document.querySelectorAll('source')
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(sources[0]).toHaveAttribute('src', 'single.mp4')
|
||||
})
|
||||
|
||||
it('returns null when no sources exist', () => {
|
||||
const node: BlockNode = {
|
||||
children: [],
|
||||
properties: {},
|
||||
}
|
||||
|
||||
const { container } = render(<VideoBlock node={node} />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('has displayName set', () => {
|
||||
expect(VideoBlock.displayName).toBe('VideoBlock')
|
||||
})
|
||||
})
|
||||
|
||||
describe('VideoGallery', () => {
|
||||
it('returns null when srcs are empty or invalid', () => {
|
||||
const { container } = render(<VideoGallery srcs={['', '']} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders video when valid srcs provided', () => {
|
||||
render(<VideoGallery srcs={['ok.mp4', 'also.mp4']} />)
|
||||
|
||||
const sources = document.querySelectorAll('source')
|
||||
expect(sources).toHaveLength(2)
|
||||
expect(sources[0]).toHaveAttribute('src', 'ok.mp4')
|
||||
expect(sources[1]).toHaveAttribute('src', 'also.mp4')
|
||||
})
|
||||
})
|
||||
54
web/app/components/base/markdown/error-boundary.spec.tsx
Normal file
54
web/app/components/base/markdown/error-boundary.spec.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ErrorBoundary from './error-boundary'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders children when there is no error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="child">Hello world</div>
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child')).toHaveTextContent('Hello world')
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('catches errors thrown in children, shows fallback UI and logs the error', () => {
|
||||
const testError = new Error('Test render error')
|
||||
|
||||
const Thrower: React.FC = () => {
|
||||
throw testError
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Thrower />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText(/Oops! An error occurred/i),
|
||||
).toBeInTheDocument()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
|
||||
const hasLoggedOurError = consoleErrorSpy.mock.calls.some((call: unknown[]) =>
|
||||
call.includes(testError),
|
||||
)
|
||||
|
||||
expect(hasLoggedOurError).toBe(true)
|
||||
})
|
||||
})
|
||||
123
web/app/components/base/markdown/index.spec.tsx
Normal file
123
web/app/components/base/markdown/index.spec.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import type { SimplePluginInfo } from './react-markdown-wrapper'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Markdown } from './index'
|
||||
|
||||
const { mockReactMarkdownWrapper } = vi.hoisted(() => ({
|
||||
mockReactMarkdownWrapper: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => (props: { latexContent: string }) => {
|
||||
mockReactMarkdownWrapper(props)
|
||||
return <div data-testid="react-markdown-wrapper">{props.latexContent}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
type CapturedProps = {
|
||||
latexContent: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
customComponents?: Record<string, unknown>
|
||||
customDisallowedElements?: string[]
|
||||
rehypePlugins?: unknown[]
|
||||
}
|
||||
|
||||
const getLastWrapperProps = (): CapturedProps => {
|
||||
const calls = mockReactMarkdownWrapper.mock.calls
|
||||
const lastCall = calls[calls.length - 1]
|
||||
return lastCall[0] as CapturedProps
|
||||
}
|
||||
|
||||
describe('Markdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render wrapper content', () => {
|
||||
render(<Markdown content="Hello World" />)
|
||||
expect(screen.getByTestId('react-markdown-wrapper')).toHaveTextContent('Hello World')
|
||||
})
|
||||
|
||||
it('should apply default classes', () => {
|
||||
const { container } = render(<Markdown content="Test" />)
|
||||
const markdownDiv = container.querySelector('.markdown-body')
|
||||
expect(markdownDiv).toHaveClass('markdown-body', '!text-text-primary')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
const { container } = render(<Markdown content="Test" className="custom another" />)
|
||||
const markdownDiv = container.querySelector('.markdown-body')
|
||||
expect(markdownDiv).toHaveClass('markdown-body', '!text-text-primary', 'custom', 'another')
|
||||
})
|
||||
|
||||
it('should not include undefined in className', () => {
|
||||
const { container } = render(<Markdown content="Test" className={undefined} />)
|
||||
const markdownDiv = container.querySelector('.markdown-body')
|
||||
expect(markdownDiv?.className).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it('should preprocess think tags', () => {
|
||||
render(<Markdown content="<think>Thought</think>" />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.latexContent).toContain('<details data-think=true>')
|
||||
expect(props.latexContent).toContain('Thought')
|
||||
expect(props.latexContent).toContain('[ENDTHINKFLAG]</details>')
|
||||
})
|
||||
|
||||
it('should preprocess latex block notation', () => {
|
||||
render(<Markdown content={'\\[x^2 + y^2 = z^2\\]'} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.latexContent).toContain('$$x^2 + y^2 = z^2$$')
|
||||
})
|
||||
|
||||
it('should preprocess latex parentheses notation', () => {
|
||||
render(<Markdown content={'Inline \\(a + b\\) equation'} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.latexContent).toContain('$$a + b$$')
|
||||
})
|
||||
|
||||
it('should preserve latex inside code blocks', () => {
|
||||
render(<Markdown content={'```\n$E = mc^2$\n```'} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.latexContent).toContain('$E = mc^2$')
|
||||
})
|
||||
|
||||
it('should pass pluginInfo through', () => {
|
||||
const pluginInfo = {
|
||||
pluginUniqueIdentifier: 'plugin-unique',
|
||||
pluginId: 'plugin-id',
|
||||
}
|
||||
render(<Markdown content="content" pluginInfo={pluginInfo} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.pluginInfo).toEqual(pluginInfo)
|
||||
})
|
||||
|
||||
it('should pass default empty customComponents when omitted', () => {
|
||||
render(<Markdown content="content" />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.customComponents).toEqual({})
|
||||
})
|
||||
|
||||
it('should pass customComponents through', () => {
|
||||
const customComponents = {
|
||||
h1: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
|
||||
}
|
||||
render(<Markdown content="# title" customComponents={customComponents} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.customComponents).toBe(customComponents)
|
||||
})
|
||||
|
||||
it('should pass customDisallowedElements through', () => {
|
||||
const customDisallowedElements = ['strong', 'em']
|
||||
render(<Markdown content="**bold**" customDisallowedElements={customDisallowedElements} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.customDisallowedElements).toBe(customDisallowedElements)
|
||||
})
|
||||
|
||||
it('should pass rehypePlugins through', () => {
|
||||
const plugin = () => (tree: unknown) => tree
|
||||
const rehypePlugins = [plugin]
|
||||
render(<Markdown content="content" rehypePlugins={rehypePlugins} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.rehypePlugins).toBe(rehypePlugins)
|
||||
})
|
||||
})
|
||||
157
web/app/components/base/markdown/markdown-utils.spec.ts
Normal file
157
web/app/components/base/markdown/markdown-utils.spec.ts
Normal file
@ -0,0 +1,157 @@
|
||||
// app/components/base/markdown/preprocess.spec.ts
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Helper to (re)load the module with a mocked config value.
|
||||
* We need to reset modules because the tested module imports
|
||||
* ALLOW_UNSAFE_DATA_SCHEME at top-level.
|
||||
*/
|
||||
const loadModuleWithConfig = async (allowDataScheme: boolean) => {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/config', () => ({ ALLOW_UNSAFE_DATA_SCHEME: allowDataScheme }))
|
||||
return await import('./markdown-utils')
|
||||
}
|
||||
|
||||
describe('preprocessLaTeX', () => {
|
||||
let mod: typeof import('./markdown-utils')
|
||||
|
||||
beforeEach(async () => {
|
||||
// config value doesn't matter for LaTeX preprocessing, mock it false
|
||||
mod = await loadModuleWithConfig(false)
|
||||
})
|
||||
|
||||
it('returns non-string input unchanged', () => {
|
||||
// call with a non-string (bypass TS type system)
|
||||
// @ts-expect-error test
|
||||
const out = mod.preprocessLaTeX(123)
|
||||
expect(out).toBe(123)
|
||||
})
|
||||
|
||||
it('converts \\[ ... \\] into $$ ... $$', () => {
|
||||
const input = 'This is math: \\[x^2 + 1\\]'
|
||||
const out = mod.preprocessLaTeX(input)
|
||||
expect(out).toContain('$$x^2 + 1$$')
|
||||
})
|
||||
|
||||
it('converts \\( ... \\) into $$ ... $$', () => {
|
||||
const input = 'Inline: \\(a+b\\)'
|
||||
const out = mod.preprocessLaTeX(input)
|
||||
expect(out).toContain('$$a+b$$')
|
||||
})
|
||||
|
||||
it('preserves code blocks (does not transform $ inside them)', () => {
|
||||
const input = [
|
||||
'Some text before',
|
||||
'```js',
|
||||
'const s = \'$insideCode$\'',
|
||||
'```',
|
||||
'And outside $math$',
|
||||
].join('\n')
|
||||
|
||||
const out = mod.preprocessLaTeX(input)
|
||||
|
||||
// code block should be preserved exactly (including $ inside)
|
||||
expect(out).toContain('```js\nconst s = \'$insideCode$\'\n```')
|
||||
// outside inline $math$ should remain intact (function keeps inline $...$)
|
||||
expect(out).toContain('$math$')
|
||||
})
|
||||
|
||||
it('does not treat escaped dollar \\$ as math delimiter', () => {
|
||||
const input = 'Price: \\$5 and math $x$'
|
||||
const out = mod.preprocessLaTeX(input)
|
||||
// escaped dollar should remain escaped
|
||||
expect(out).toContain('\\$5')
|
||||
// math should still be present
|
||||
expect(out).toContain('$x$')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preprocessThinkTag', () => {
|
||||
let mod: typeof import('./markdown-utils')
|
||||
|
||||
beforeEach(async () => {
|
||||
mod = await loadModuleWithConfig(false)
|
||||
})
|
||||
|
||||
it('transforms single <think>...</think> into details with data-think and ENDTHINKFLAG', () => {
|
||||
const input = '<think>this is a thought</think>'
|
||||
const out = mod.preprocessThinkTag(input)
|
||||
|
||||
expect(out).toContain('<details data-think=true>')
|
||||
expect(out).toContain('this is a thought')
|
||||
expect(out).toContain('[ENDTHINKFLAG]</details>')
|
||||
})
|
||||
|
||||
it('handles multiple <think> tags and inserts newline after closing </details>', () => {
|
||||
const input = '<think>one</think>\n<think>two</think>'
|
||||
const out = mod.preprocessThinkTag(input)
|
||||
|
||||
// both thoughts become details blocks
|
||||
const occurrences = (out.match(/<details data-think=true>/g) || []).length
|
||||
expect(occurrences).toBe(2)
|
||||
|
||||
// ensure ENDTHINKFLAG is present twice
|
||||
const endCount = (out.match(/\[ENDTHINKFLAG\]<\/details>/g) || []).length
|
||||
expect(endCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('customUrlTransform', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('allows fragments (#foo) and protocol-relative (//host) and relative paths', async () => {
|
||||
const mod = await loadModuleWithConfig(false)
|
||||
const t = mod.customUrlTransform
|
||||
|
||||
expect(t('#some-id')).toBe('#some-id')
|
||||
expect(t('//example.com/path')).toBe('//example.com/path')
|
||||
expect(t('relative/path/to/file')).toBe('relative/path/to/file')
|
||||
expect(t('/absolute/path')).toBe('/absolute/path')
|
||||
})
|
||||
|
||||
it('allows permitted schemes (http, https, mailto, xmpp, irc/ircs, abbr) case-insensitively', async () => {
|
||||
const mod = await loadModuleWithConfig(false)
|
||||
const t = mod.customUrlTransform
|
||||
|
||||
expect(t('http://example.com')).toBe('http://example.com')
|
||||
expect(t('HTTPS://example.com')).toBe('HTTPS://example.com')
|
||||
expect(t('mailto:user@example.com')).toBe('mailto:user@example.com')
|
||||
expect(t('xmpp:user@example.com')).toBe('xmpp:user@example.com')
|
||||
expect(t('irc:somewhere')).toBe('irc:somewhere')
|
||||
expect(t('ircs:secure')).toBe('ircs:secure')
|
||||
expect(t('abbr:some-ref')).toBe('abbr:some-ref')
|
||||
})
|
||||
|
||||
it('rejects unknown/unsafe schemes (javascript:, ftp:) and returns undefined', async () => {
|
||||
const mod = await loadModuleWithConfig(false)
|
||||
const t = mod.customUrlTransform
|
||||
|
||||
expect(t('javascript:alert(1)')).toBeUndefined()
|
||||
expect(t('ftp://example.com/file')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats colons inside path/query/fragment as NOT a scheme and returns the original URI', async () => {
|
||||
const mod = await loadModuleWithConfig(false)
|
||||
const t = mod.customUrlTransform
|
||||
|
||||
// colon after a slash -> part of path
|
||||
expect(t('folder/name:withcolon')).toBe('folder/name:withcolon')
|
||||
|
||||
// colon after question mark -> part of query
|
||||
expect(t('page?param:http')).toBe('page?param:http')
|
||||
|
||||
// colon after hash -> part of fragment
|
||||
expect(t('page#frag:with:colon')).toBe('page#frag:with:colon')
|
||||
})
|
||||
|
||||
it('respects ALLOW_UNSAFE_DATA_SCHEME: false blocks data:, true allows data:', async () => {
|
||||
const modFalse = await loadModuleWithConfig(false)
|
||||
expect(modFalse.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBeUndefined()
|
||||
|
||||
const modTrue = await loadModuleWithConfig(true)
|
||||
expect(modTrue.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBe('data:text/plain;base64,SGVsbG8=')
|
||||
})
|
||||
})
|
||||
271
web/app/components/base/notion-page-selector/base.spec.tsx
Normal file
271
web/app/components/base/notion-page-selector/base.spec.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
import type { DataSourceCredential } from '../../header/account-setting/data-source-page-new/types'
|
||||
import type { DataSourceNotionWorkspace } from '@/models/common'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/service/knowledge/use-import'
|
||||
import NotionPageSelector from './base'
|
||||
|
||||
vi.mock('@/service/knowledge/use-import', () => ({
|
||||
usePreImportNotionPages: vi.fn(),
|
||||
useInvalidPreImportNotionPages: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
const buildCredential = (
|
||||
id: string,
|
||||
name: string,
|
||||
workspaceName: string,
|
||||
): DataSourceCredential => ({
|
||||
id,
|
||||
name,
|
||||
type: CredentialTypeEnum.OAUTH2,
|
||||
is_default: false,
|
||||
avatar_url: '',
|
||||
credential: {
|
||||
workspace_icon: '',
|
||||
workspace_name: workspaceName,
|
||||
},
|
||||
})
|
||||
|
||||
const mockCredentialList: DataSourceCredential[] = [
|
||||
buildCredential('c1', 'Cred 1', 'Workspace 1'),
|
||||
buildCredential('c2', 'Cred 2', 'Workspace 2'),
|
||||
]
|
||||
|
||||
const mockNotionWorkspaces: DataSourceNotionWorkspace[] = [
|
||||
{
|
||||
workspace_id: 'w1',
|
||||
workspace_icon: '',
|
||||
workspace_name: 'Workspace 1',
|
||||
pages: [
|
||||
{ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false },
|
||||
{ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1', page_icon: null, type: 'page', is_bound: false },
|
||||
{ page_id: 'bound-1', page_name: 'Bound 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
workspace_id: 'w2',
|
||||
workspace_icon: '',
|
||||
workspace_name: 'Workspace 2',
|
||||
pages: [
|
||||
{ page_id: 'external-1', page_name: 'External 1', parent_id: 'root', page_icon: null, type: 'page', is_bound: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const createPreImportResult = ({
|
||||
notionInfo = mockNotionWorkspaces,
|
||||
isFetching = false,
|
||||
isError = false,
|
||||
}: {
|
||||
notionInfo?: DataSourceNotionWorkspace[]
|
||||
isFetching?: boolean
|
||||
isError?: boolean
|
||||
} = {}) =>
|
||||
({
|
||||
data: { notion_info: notionInfo },
|
||||
isFetching,
|
||||
isError,
|
||||
}) as ReturnType<typeof usePreImportNotionPages>
|
||||
|
||||
describe('NotionPageSelector Base', () => {
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockInvalidPreImportNotionPages = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useModalContextSelector).mockReturnValue(mockSetShowAccountSettingModal)
|
||||
vi.mocked(useInvalidPreImportNotionPages).mockReturnValue(mockInvalidPreImportNotionPages)
|
||||
})
|
||||
|
||||
it('should render loading state when pages are being fetched', () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isFetching: true }))
|
||||
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('notion-page-selector-loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render connector and open settings when fetch fails', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isError: true }))
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
|
||||
|
||||
const connectButton = screen.getByRole('button', { name: 'datasetCreation.stepOne.connect' })
|
||||
await user.click(connectButton)
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
|
||||
})
|
||||
|
||||
it('should render page selector and allow selecting a page tree', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={handleSelect} />)
|
||||
|
||||
expect(screen.getByTestId('notion-page-selector-base')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(handleSelect).toHaveBeenCalled()
|
||||
expect(handleSelect).toHaveBeenLastCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }),
|
||||
expect.objectContaining({ page_id: 'child-1', workspace_id: 'w1' }),
|
||||
expect.objectContaining({ page_id: 'bound-1', workspace_id: 'w1' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('should keep bound pages disabled and selected by default', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={handleSelect} />)
|
||||
|
||||
const boundCheckbox = screen.getByTestId('checkbox-notion-page-checkbox-bound-1')
|
||||
expect(screen.getByTestId('check-icon-notion-page-checkbox-bound-1')).toBeInTheDocument()
|
||||
await user.click(boundCheckbox)
|
||||
expect(handleSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter and clear search results from search input actions', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const user = userEvent.setup()
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
|
||||
|
||||
const searchInput = screen.getByTestId('notion-search-input')
|
||||
await user.type(searchInput, 'no-such-page')
|
||||
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('notion-search-input-clear'))
|
||||
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch credential and reset selection when choosing a different workspace', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const handleSelect = vi.fn()
|
||||
const onSelectCredential = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<NotionPageSelector
|
||||
credentialList={mockCredentialList}
|
||||
onSelect={handleSelect}
|
||||
onSelectCredential={onSelectCredential}
|
||||
datasetId="dataset-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
const selectorBtn = screen.getByTestId('notion-credential-selector-btn')
|
||||
await user.click(selectorBtn)
|
||||
const item2 = screen.getByTestId('notion-credential-item-c2')
|
||||
await user.click(item2)
|
||||
|
||||
expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-1', credentialId: 'c2' })
|
||||
expect(handleSelect).toHaveBeenCalledWith([])
|
||||
expect(onSelectCredential).toHaveBeenLastCalledWith('c2')
|
||||
})
|
||||
|
||||
it('should open settings when configuration action in header is clicked', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const user = userEvent.setup()
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Configure Notion' }))
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
|
||||
})
|
||||
|
||||
it('should preview a page and call onPreview when callback is provided', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const onPreview = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<NotionPageSelector
|
||||
credentialList={mockCredentialList}
|
||||
onSelect={vi.fn()}
|
||||
onPreview={onPreview}
|
||||
previewPageId="root-1"
|
||||
/>,
|
||||
)
|
||||
|
||||
const previewBtn = screen.getByTestId('notion-page-preview-root-1')
|
||||
await user.click(previewBtn)
|
||||
expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ page_id: 'root-1', workspace_id: 'w1' }))
|
||||
})
|
||||
|
||||
it('should handle preview click without onPreview callback', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const user = userEvent.setup()
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
|
||||
await user.click(screen.getByTestId('notion-page-preview-root-1'))
|
||||
expect(screen.getByTestId('notion-page-name-root-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelectCredential with current credential on initial render', () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const onSelectCredential = vi.fn()
|
||||
render(
|
||||
<NotionPageSelector
|
||||
credentialList={mockCredentialList}
|
||||
onSelect={vi.fn()}
|
||||
onSelectCredential={onSelectCredential}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(onSelectCredential).toHaveBeenCalledWith('c1')
|
||||
})
|
||||
|
||||
it('should fallback to first credential when current credential is removed in error mode', async () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult({ isError: true }))
|
||||
const onSelect = vi.fn()
|
||||
const onSelectCredential = vi.fn()
|
||||
const { rerender } = render(
|
||||
<NotionPageSelector
|
||||
credentialList={mockCredentialList}
|
||||
onSelect={onSelect}
|
||||
onSelectCredential={onSelectCredential}
|
||||
datasetId="dataset-fallback"
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<NotionPageSelector
|
||||
credentialList={[buildCredential('c3', 'Cred 3', 'Workspace 3')]}
|
||||
onSelect={onSelect}
|
||||
onSelectCredential={onSelectCredential}
|
||||
datasetId="dataset-fallback"
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidPreImportNotionPages).toHaveBeenCalledWith({ datasetId: 'dataset-fallback', credentialId: 'c3' })
|
||||
expect(onSelect).toHaveBeenCalledWith([])
|
||||
expect(onSelectCredential).toHaveBeenLastCalledWith('c3')
|
||||
})
|
||||
})
|
||||
|
||||
it('should update selected page state when controlled value changes', () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
const { rerender } = render(
|
||||
<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} value={['root-1']} />,
|
||||
)
|
||||
expect(screen.getByTestId('check-icon-notion-page-checkbox-root-1')).toBeInTheDocument()
|
||||
|
||||
rerender(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} value={[]} />)
|
||||
expect(screen.queryByTestId('check-icon-notion-page-checkbox-root-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide preview actions when canPreview is false', () => {
|
||||
vi.mocked(usePreImportNotionPages).mockReturnValue(createPreImportResult())
|
||||
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} canPreview={false} />)
|
||||
expect(screen.queryByTestId('notion-page-preview-root-1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -137,7 +137,7 @@ const NotionPageSelector = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex flex-col gap-y-2" data-testid="notion-page-selector-base">
|
||||
<Header
|
||||
onClickConfiguration={handleConfigureNotion}
|
||||
title="Choose notion pages"
|
||||
@ -162,7 +162,7 @@ const NotionPageSelector = ({
|
||||
<div className="overflow-hidden rounded-b-xl">
|
||||
{isFetchingNotionPages
|
||||
? (
|
||||
<div className="flex h-[296px] items-center justify-center">
|
||||
<div className="flex h-[296px] items-center justify-center" data-testid="notion-page-selector-loading">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import CredentialSelector from './index'
|
||||
|
||||
// Mock CredentialIcon since it's likely a complex component or uses next/image
|
||||
vi.mock('@/app/components/datasets/common/credential-icon', () => ({
|
||||
CredentialIcon: ({ name }: { name: string }) => <div data-testid="credential-icon">{name}</div>,
|
||||
}))
|
||||
|
||||
const mockItems = [
|
||||
{
|
||||
credentialId: '1',
|
||||
credentialName: 'Workspace 1',
|
||||
workspaceName: 'Notion Workspace 1',
|
||||
},
|
||||
{
|
||||
credentialId: '2',
|
||||
credentialName: 'Workspace 2',
|
||||
workspaceName: 'Notion Workspace 2',
|
||||
},
|
||||
]
|
||||
|
||||
describe('CredentialSelector', () => {
|
||||
it('should render current workspace name', () => {
|
||||
render(<CredentialSelector value="1" items={mockItems} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('notion-credential-selector-name')).toHaveTextContent('Notion Workspace 1')
|
||||
})
|
||||
|
||||
it('should show all workspaces when menu is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<CredentialSelector value="1" items={mockItems} onSelect={vi.fn()} />)
|
||||
|
||||
const btn = screen.getByTestId('notion-credential-selector-btn')
|
||||
await user.click(btn)
|
||||
|
||||
expect(screen.getByTestId('notion-credential-item-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('notion-credential-item-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect when a workspace is clicked', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<CredentialSelector value="1" items={mockItems} onSelect={handleSelect} />)
|
||||
|
||||
const btn = screen.getByTestId('notion-credential-selector-btn')
|
||||
await user.click(btn)
|
||||
|
||||
const item2 = screen.getByTestId('notion-credential-item-2')
|
||||
await user.click(item2)
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith('2')
|
||||
})
|
||||
|
||||
it('should use credentialName if workspaceName is missing', () => {
|
||||
const itemsWithoutWorkspaceName = [
|
||||
{
|
||||
credentialId: '1',
|
||||
credentialName: 'Credential Name 1',
|
||||
},
|
||||
]
|
||||
render(<CredentialSelector value="1" items={itemsWithoutWorkspaceName} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('notion-credential-selector-name')).toHaveTextContent('Credential Name 1')
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { CredentialIcon } from '@/app/components/datasets/common/credential-icon'
|
||||
@ -38,7 +37,10 @@ const CredentialSelector = ({
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`}>
|
||||
<MenuButton
|
||||
className={`flex h-7 items-center justify-center rounded-md p-1 pr-2 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} cursor-pointer`}
|
||||
data-testid="notion-credential-selector-btn"
|
||||
>
|
||||
<CredentialIcon
|
||||
className="mr-2"
|
||||
avatarUrl={currentCredential?.workspaceIcon}
|
||||
@ -48,10 +50,11 @@ const CredentialSelector = ({
|
||||
<div
|
||||
className="mr-1 w-[90px] truncate text-left text-sm font-medium text-text-secondary"
|
||||
title={currentDisplayName}
|
||||
data-testid="notion-credential-selector-name"
|
||||
>
|
||||
{currentDisplayName}
|
||||
</div>
|
||||
<RiArrowDownSLine className="h-4 w-4 text-text-secondary" />
|
||||
<div className="i-ri-arrow-down-s-line h-4 w-4 text-text-secondary" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
@ -76,6 +79,7 @@ const CredentialSelector = ({
|
||||
<div
|
||||
className="flex h-9 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover"
|
||||
onClick={() => onSelect(item.credentialId)}
|
||||
data-testid={`notion-credential-item-${item.credentialId}`}
|
||||
>
|
||||
<CredentialIcon
|
||||
className="mr-2 shrink-0"
|
||||
@ -84,7 +88,7 @@ const CredentialSelector = ({
|
||||
size={20}
|
||||
/>
|
||||
<div
|
||||
className="system-sm-medium mr-2 grow truncate text-text-secondary"
|
||||
className="mr-2 grow truncate text-text-secondary system-sm-medium"
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PageSelector from './index'
|
||||
|
||||
const buildPage = (overrides: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({
|
||||
page_id: 'page-id',
|
||||
page_name: 'Page name',
|
||||
parent_id: 'root',
|
||||
page_icon: null,
|
||||
type: 'page',
|
||||
is_bound: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockList: DataSourceNotionPage[] = [
|
||||
buildPage({ page_id: 'root-1', page_name: 'Root 1', parent_id: 'root' }),
|
||||
buildPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-1' }),
|
||||
buildPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }),
|
||||
]
|
||||
|
||||
const mockPagesMap: DataSourceNotionPageMap = {
|
||||
'root-1': { ...mockList[0], workspace_id: 'workspace-1' },
|
||||
'child-1': { ...mockList[1], workspace_id: 'workspace-1' },
|
||||
'grandchild-1': { ...mockList[2], workspace_id: 'workspace-1' },
|
||||
}
|
||||
|
||||
describe('PageSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render root level pages initially', () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Root 1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Child 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand child pages when toggle is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
const toggle = screen.getByTestId('notion-page-toggle-root-1')
|
||||
await user.click(toggle)
|
||||
|
||||
expect(screen.getByText('Child 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect with descendants when parent is selected', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith(new Set(['root-1', 'child-1', 'grandchild-1']))
|
||||
})
|
||||
|
||||
it('should call onSelect with empty set when parent is deselected', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set(['root-1', 'child-1', 'grandchild-1'])} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-root-1')
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith(new Set())
|
||||
})
|
||||
|
||||
it('should show breadcrumbs when searching', () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Grandchild" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Root 1 / Child 1 / Grandchild 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onPreview when preview button is clicked', async () => {
|
||||
const handlePreview = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} onPreview={handlePreview} />)
|
||||
|
||||
const previewBtn = screen.getByTestId('notion-page-preview-root-1')
|
||||
await user.click(previewBtn)
|
||||
|
||||
expect(handlePreview).toHaveBeenCalledWith('root-1')
|
||||
})
|
||||
|
||||
it('should show no result message when search returns nothing', () => {
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="nonexistent" pagesMap={mockPagesMap} list={[]} onSelect={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle selection when searchValue is present', async () => {
|
||||
const handleSelect = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="Child" pagesMap={mockPagesMap} list={mockList} onSelect={handleSelect} />)
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-notion-page-checkbox-child-1')
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith(new Set(['child-1']))
|
||||
})
|
||||
|
||||
it('should handle preview when onPreview is not provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
const previewBtn = screen.getByTestId('notion-page-preview-root-1')
|
||||
await user.click(previewBtn)
|
||||
// Should not crash
|
||||
})
|
||||
|
||||
it('should handle toggle when item is already expanded', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PageSelector value={new Set()} disabledValue={new Set()} searchValue="" pagesMap={mockPagesMap} list={mockList} onSelect={vi.fn()} />)
|
||||
|
||||
const toggleBtn = screen.getByTestId('notion-page-toggle-root-1')
|
||||
await user.click(toggleBtn) // Expand
|
||||
await waitFor(() => expect(screen.queryByText('Child 1')).toBeInTheDocument())
|
||||
|
||||
await user.click(toggleBtn) // Collapse
|
||||
await waitFor(() => expect(screen.queryByText('Child 1')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,5 @@
|
||||
import type { ListChildComponentProps } from 'react-window'
|
||||
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { areEqual, FixedSizeList as List } from 'react-window'
|
||||
@ -110,11 +109,12 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
|
||||
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
|
||||
style={{ marginLeft: current.depth * 8 }}
|
||||
onClick={() => handleToggle(index)}
|
||||
data-testid={`notion-page-toggle-${current.page_id}`}
|
||||
>
|
||||
{
|
||||
current.expand
|
||||
? <RiArrowDownSLine className="h-4 w-4 text-text-tertiary" />
|
||||
: <RiArrowRightSLine className="h-4 w-4 text-text-tertiary" />
|
||||
? <div className="i-ri-arrow-down-s-line h-4 w-4 text-text-tertiary" />
|
||||
: <div className="i-ri-arrow-right-s-line h-4 w-4 text-text-tertiary" />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
@ -141,6 +141,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
|
||||
onCheck={() => {
|
||||
handleCheck(index)
|
||||
}}
|
||||
id={`notion-page-checkbox-${current.page_id}`}
|
||||
/>
|
||||
{!searchValue && renderArrow()}
|
||||
<NotionIcon
|
||||
@ -151,6 +152,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
|
||||
<div
|
||||
className="grow truncate text-[13px] font-medium leading-4 text-text-secondary"
|
||||
title={current.page_name}
|
||||
data-testid={`notion-page-name-${current.page_id}`}
|
||||
>
|
||||
{current.page_name}
|
||||
</div>
|
||||
@ -161,6 +163,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
|
||||
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
|
||||
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex"
|
||||
onClick={() => handlePreview(index)}
|
||||
data-testid={`notion-page-preview-${current.page_id}`}
|
||||
>
|
||||
{t('dataSource.notion.selector.preview', { ns: 'common' })}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SearchInput from './index'
|
||||
|
||||
describe('SearchInput', () => {
|
||||
it('should render with placeholder', () => {
|
||||
render(<SearchInput value="" onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByPlaceholderText('common.dataSource.notion.selector.searchPages')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('notion-search-input-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when typing', async () => {
|
||||
const handleChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<SearchInput value="" onChange={handleChange} />)
|
||||
|
||||
const input = screen.getByTestId('notion-search-input')
|
||||
await user.type(input, 'test query')
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show clear button when value is not empty', () => {
|
||||
render(<SearchInput value="some value" onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('notion-search-input-clear')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange with empty string when clear button is clicked', async () => {
|
||||
const handleChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<SearchInput value="some value" onChange={handleChange} />)
|
||||
|
||||
const clearBtn = screen.getByTestId('notion-search-input-clear')
|
||||
await user.click(clearBtn)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('should not show clear button when value is empty', () => {
|
||||
render(<SearchInput value="" onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByTestId('notion-search-input-clear')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { RiCloseCircleFill, RiSearchLine } from '@remixicon/react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@ -19,19 +18,24 @@ const SearchInput = ({
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-8 w-[200px] items-center rounded-lg bg-components-input-bg-normal p-2')}>
|
||||
<RiSearchLine className="mr-0.5 h-4 w-4 shrink-0 text-components-input-text-placeholder" />
|
||||
<div
|
||||
className={cn('flex h-8 w-[200px] items-center rounded-lg bg-components-input-bg-normal p-2')}
|
||||
data-testid="notion-search-input-container"
|
||||
>
|
||||
<div className="i-ri-search-line mr-0.5 h-4 w-4 shrink-0 text-components-input-text-placeholder" />
|
||||
<input
|
||||
className="min-w-0 grow appearance-none border-0 bg-transparent px-1 text-[13px] leading-[16px] text-components-input-text-filled outline-0 placeholder:text-components-input-text-placeholder"
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
|
||||
placeholder={t('dataSource.notion.selector.searchPages', { ns: 'common' }) || ''}
|
||||
data-testid="notion-search-input"
|
||||
/>
|
||||
{
|
||||
value && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 shrink-0 cursor-pointer text-components-input-text-placeholder"
|
||||
<div
|
||||
className="i-ri-close-circle-fill h-4 w-4 shrink-0 cursor-pointer text-components-input-text-placeholder"
|
||||
onClick={handleClear}
|
||||
data-testid="notion-search-input-clear"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import ErrorMessageBlockComponent from './component'
|
||||
import { DELETE_ERROR_MESSAGE_COMMAND, ErrorMessageBlockNode } from './index'
|
||||
|
||||
vi.mock('../../hooks')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ErrorMessageBlockComponent', () => {
|
||||
const mockRef = { current: null as HTMLDivElement | null }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, false])
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render error_message text and base styles when unselected', () => {
|
||||
const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)
|
||||
|
||||
expect(screen.getByText('error_message')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('border-components-panel-border-subtle')
|
||||
})
|
||||
|
||||
it('should render selected styles when node is selected', () => {
|
||||
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, true])
|
||||
|
||||
const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('border-state-accent-solid')
|
||||
expect(container.firstChild).toHaveClass('bg-state-accent-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should stop propagation when wrapper is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onParentClick = vi.fn()
|
||||
|
||||
render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
<div onClick={onParentClick}>
|
||||
<ErrorMessageBlockComponent nodeKey="node-1" />
|
||||
</div>
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('error_message'))
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hooks', () => {
|
||||
it('should use selection hook and check node registration on mount', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-xyz" />)
|
||||
|
||||
expect(useSelectOrDelete).toHaveBeenCalledWith('node-xyz', DELETE_ERROR_MESSAGE_COMMAND)
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
|
||||
})
|
||||
|
||||
it('should throw when ErrorMessageBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)).toThrow(
|
||||
'WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,125 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { EntityMatch } from '@lexical/text'
|
||||
import type { LexicalEditor, LexicalNode } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { render } from '@testing-library/react'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import { ERROR_MESSAGE_PLACEHOLDER_TEXT } from '../../constants'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import ErrorMessageBlockReplacementBlock from './error-message-block-replacement-block'
|
||||
import { $createErrorMessageBlockNode, ErrorMessageBlockNode } from './node'
|
||||
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('lexical')
|
||||
vi.mock('../../utils')
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockRegisterNodeTransform = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
registerNodeTransform: mockRegisterNodeTransform,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ErrorMessageBlockReplacementBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterNodeTransform.mockReturnValue(vi.fn())
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => {
|
||||
return () => cleanups.forEach(cleanup => cleanup())
|
||||
})
|
||||
vi.mocked($createErrorMessageBlockNode).mockReturnValue({ type: 'node' } as unknown as ErrorMessageBlockNode)
|
||||
vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node)
|
||||
})
|
||||
|
||||
it('should register transform and cleanup on unmount', () => {
|
||||
const transformCleanup = vi.fn()
|
||||
mockRegisterNodeTransform.mockReturnValue(transformCleanup)
|
||||
|
||||
const { unmount, container } = renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
|
||||
expect(mockRegisterNodeTransform).toHaveBeenCalledWith(CustomTextNode, expect.any(Function))
|
||||
|
||||
unmount()
|
||||
expect(transformCleanup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw when ErrorMessageBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)).toThrow(
|
||||
'ErrorMessageBlockNodePlugin: ErrorMessageBlockNode not registered on editor',
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass matcher and creator to decoratorTransform and match placeholder text', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
const textNode = { id: 't-1' } as unknown as LexicalNode
|
||||
transformCallback(textNode)
|
||||
|
||||
expect(decoratorTransform).toHaveBeenCalledWith(
|
||||
textNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
)
|
||||
|
||||
const getMatch = vi.mocked(decoratorTransform).mock.calls[0][1] as (text: string) => EntityMatch | null
|
||||
const match = getMatch(`hello ${ERROR_MESSAGE_PLACEHOLDER_TEXT} world`)
|
||||
|
||||
expect(match).toEqual({
|
||||
start: 6,
|
||||
end: 6 + ERROR_MESSAGE_PLACEHOLDER_TEXT.length,
|
||||
})
|
||||
expect(getMatch('hello world')).toBeNull()
|
||||
})
|
||||
|
||||
it('should create replacement node and call onInsert when creator runs', () => {
|
||||
const onInsert = vi.fn()
|
||||
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock onInsert={onInsert} />)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 't-1' } as unknown as LexicalNode)
|
||||
|
||||
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as () => ErrorMessageBlockNode
|
||||
const created = createNode()
|
||||
|
||||
expect(onInsert).toHaveBeenCalledTimes(1)
|
||||
expect($createErrorMessageBlockNode).toHaveBeenCalledTimes(1)
|
||||
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'node' })
|
||||
expect(created).toEqual({ type: 'node' })
|
||||
})
|
||||
|
||||
it('should create replacement node without onInsert callback', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 't-1' } as unknown as LexicalNode)
|
||||
|
||||
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as () => ErrorMessageBlockNode
|
||||
|
||||
expect(() => createNode()).not.toThrow()
|
||||
expect($createErrorMessageBlockNode).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,143 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { render } from '@testing-library/react'
|
||||
import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import {
|
||||
DELETE_ERROR_MESSAGE_COMMAND,
|
||||
ErrorMessageBlock,
|
||||
ErrorMessageBlockNode,
|
||||
INSERT_ERROR_MESSAGE_BLOCK_COMMAND,
|
||||
} from './index'
|
||||
import { $createErrorMessageBlockNode } from './node'
|
||||
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('lexical', async () => {
|
||||
const actual = await vi.importActual('lexical')
|
||||
return {
|
||||
...actual,
|
||||
$insertNodes: vi.fn(),
|
||||
createCommand: vi.fn(name => name),
|
||||
COMMAND_PRIORITY_EDITOR: 1,
|
||||
}
|
||||
})
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockRegisterCommand = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
registerCommand: mockRegisterCommand,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ErrorMessageBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterCommand.mockReturnValue(vi.fn())
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => {
|
||||
return () => cleanups.forEach(cleanup => cleanup())
|
||||
})
|
||||
vi.mocked($createErrorMessageBlockNode).mockReturnValue({ id: 'node' } as unknown as ErrorMessageBlockNode)
|
||||
})
|
||||
|
||||
it('should render null and register insert and delete commands', () => {
|
||||
const { container } = renderWithLexicalContext(<ErrorMessageBlock />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(2)
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
INSERT_ERROR_MESSAGE_BLOCK_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
DELETE_ERROR_MESSAGE_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(ErrorMessageBlock.displayName).toBe('ErrorMessageBlock')
|
||||
})
|
||||
|
||||
it('should throw when ErrorMessageBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(<ErrorMessageBlock />)).toThrow(
|
||||
'ERROR_MESSAGEBlockPlugin: ERROR_MESSAGEBlock not registered on editor',
|
||||
)
|
||||
})
|
||||
|
||||
it('should insert created node and call onInsert when insert command handler runs', () => {
|
||||
const onInsert = vi.fn()
|
||||
renderWithLexicalContext(<ErrorMessageBlock onInsert={onInsert} />)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as () => boolean
|
||||
const result = insertHandler()
|
||||
|
||||
expect($createErrorMessageBlockNode).toHaveBeenCalledTimes(1)
|
||||
expect($insertNodes).toHaveBeenCalledWith([{ id: 'node' }])
|
||||
expect(onInsert).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on insert command without onInsert callback', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlock />)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as () => boolean
|
||||
|
||||
expect(insertHandler()).toBe(true)
|
||||
expect($insertNodes).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDelete and return true when delete command handler runs', () => {
|
||||
const onDelete = vi.fn()
|
||||
renderWithLexicalContext(<ErrorMessageBlock onDelete={onDelete} />)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
const result = deleteHandler()
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on delete command without onDelete callback', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlock />)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
|
||||
expect(deleteHandler()).toBe(true)
|
||||
})
|
||||
|
||||
it('should run merged cleanup on unmount', () => {
|
||||
const insertCleanup = vi.fn()
|
||||
const deleteCleanup = vi.fn()
|
||||
mockRegisterCommand
|
||||
.mockReturnValueOnce(insertCleanup)
|
||||
.mockReturnValueOnce(deleteCleanup)
|
||||
|
||||
const { unmount } = renderWithLexicalContext(<ErrorMessageBlock />)
|
||||
unmount()
|
||||
|
||||
expect(insertCleanup).toHaveBeenCalledTimes(1)
|
||||
expect(deleteCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,86 @@
|
||||
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
|
||||
import { createEditor } from 'lexical'
|
||||
import { $createErrorMessageBlockNode, $isErrorMessageBlockNode, ErrorMessageBlockNode } from './node'
|
||||
|
||||
describe('ErrorMessageBlockNode', () => {
|
||||
let editor: LexicalEditor
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
editor = createEditor({
|
||||
nodes: [ErrorMessageBlockNode as unknown as Klass<LexicalNode>],
|
||||
})
|
||||
})
|
||||
|
||||
const runInEditor = (callback: () => void) => {
|
||||
editor.update(callback, { discrete: true })
|
||||
}
|
||||
|
||||
it('should expose correct static type and clone behavior', () => {
|
||||
runInEditor(() => {
|
||||
const original = new ErrorMessageBlockNode('node-key')
|
||||
const cloned = ErrorMessageBlockNode.clone(original)
|
||||
|
||||
expect(ErrorMessageBlockNode.getType()).toBe('error-message-block')
|
||||
expect(cloned).toBeInstanceOf(ErrorMessageBlockNode)
|
||||
expect(cloned).not.toBe(original)
|
||||
expect(cloned.getKey()).toBe(original.getKey())
|
||||
})
|
||||
})
|
||||
|
||||
it('should be inline and provide expected text and json payload', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode()
|
||||
|
||||
expect(node.isInline()).toBe(true)
|
||||
expect(node.getTextContent()).toBe('{{#error_message#}}')
|
||||
expect(node.exportJSON()).toEqual({
|
||||
type: 'error-message-block',
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should create dom with expected classes and never update dom', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode()
|
||||
const dom = node.createDOM()
|
||||
|
||||
expect(dom.tagName).toBe('DIV')
|
||||
expect(dom).toHaveClass('inline-flex')
|
||||
expect(dom).toHaveClass('items-center')
|
||||
expect(dom).toHaveClass('align-middle')
|
||||
expect(node.updateDOM()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should decorate using ErrorMessageBlockComponent with node key', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode('decorator-key')
|
||||
const decorated = node.decorate()
|
||||
|
||||
expect(decorated.props.nodeKey).toBe('decorator-key')
|
||||
})
|
||||
})
|
||||
|
||||
it('should create and import node instances via helper APIs', () => {
|
||||
runInEditor(() => {
|
||||
const created = $createErrorMessageBlockNode()
|
||||
const imported = ErrorMessageBlockNode.importJSON()
|
||||
|
||||
expect(created).toBeInstanceOf(ErrorMessageBlockNode)
|
||||
expect(imported).toBeInstanceOf(ErrorMessageBlockNode)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return correct type guard values for lexical and non lexical inputs', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode()
|
||||
|
||||
expect($isErrorMessageBlockNode(node)).toBe(true)
|
||||
expect($isErrorMessageBlockNode(null)).toBe(false)
|
||||
expect($isErrorMessageBlockNode(undefined)).toBe(false)
|
||||
expect($isErrorMessageBlockNode({} as ErrorMessageBlockNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,87 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { EntityMatch } from '@lexical/text'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { render } from '@testing-library/react'
|
||||
import { useLexicalTextEntity } from '../../hooks'
|
||||
import VariableValueBlock from './index'
|
||||
import { $createVariableValueBlockNode, VariableValueBlockNode } from './node'
|
||||
|
||||
vi.mock('../../hooks')
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('VariableValueBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
vi.mocked($createVariableValueBlockNode).mockImplementation(
|
||||
text => ({ createdText: text } as unknown as VariableValueBlockNode),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render null and register lexical text entity when node is registered', () => {
|
||||
const { container } = renderWithLexicalContext(<VariableValueBlock />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([VariableValueBlockNode])
|
||||
expect(useLexicalTextEntity).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
VariableValueBlockNode,
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when VariableValueBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(<VariableValueBlock />)).toThrow(
|
||||
'VariableValueBlockPlugin: VariableValueNode not registered on editor',
|
||||
)
|
||||
})
|
||||
|
||||
it('should return match offsets when placeholder exists and null when not present', () => {
|
||||
renderWithLexicalContext(<VariableValueBlock />)
|
||||
|
||||
const getMatch = vi.mocked(useLexicalTextEntity).mock.calls[0][0] as (text: string) => EntityMatch | null
|
||||
|
||||
const match = getMatch('prefix {{foo_1}} suffix')
|
||||
expect(match).toEqual({ start: 7, end: 16 })
|
||||
|
||||
expect(getMatch('prefix without variable')).toBeNull()
|
||||
})
|
||||
|
||||
it('should create variable node from text node content in create callback', () => {
|
||||
renderWithLexicalContext(<VariableValueBlock />)
|
||||
|
||||
const createNode = vi.mocked(useLexicalTextEntity).mock.calls[0][2] as (
|
||||
textNode: { getTextContent: () => string },
|
||||
) => VariableValueBlockNode
|
||||
|
||||
const created = createNode({
|
||||
getTextContent: () => '{{account_id}}',
|
||||
})
|
||||
|
||||
expect($createVariableValueBlockNode).toHaveBeenCalledWith('{{account_id}}')
|
||||
expect(created).toEqual({ createdText: '{{account_id}}' })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,92 @@
|
||||
import type { EditorConfig, Klass, LexicalEditor, LexicalNode, SerializedTextNode } from 'lexical'
|
||||
import { createEditor } from 'lexical'
|
||||
import {
|
||||
$createVariableValueBlockNode,
|
||||
$isVariableValueNodeBlock,
|
||||
VariableValueBlockNode,
|
||||
} from './node'
|
||||
|
||||
describe('VariableValueBlockNode', () => {
|
||||
let editor: LexicalEditor
|
||||
let config: EditorConfig
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
editor = createEditor({
|
||||
nodes: [VariableValueBlockNode as unknown as Klass<LexicalNode>],
|
||||
})
|
||||
config = editor._config
|
||||
})
|
||||
|
||||
const runInEditor = (callback: () => void) => {
|
||||
editor.update(callback, { discrete: true })
|
||||
}
|
||||
|
||||
it('should expose static type and clone with same text/key', () => {
|
||||
runInEditor(() => {
|
||||
const original = new VariableValueBlockNode('value-text', 'node-key')
|
||||
const cloned = VariableValueBlockNode.clone(original)
|
||||
|
||||
expect(VariableValueBlockNode.getType()).toBe('variable-value-block')
|
||||
expect(cloned).toBeInstanceOf(VariableValueBlockNode)
|
||||
expect(cloned).not.toBe(original)
|
||||
expect(cloned.getKey()).toBe('node-key')
|
||||
})
|
||||
})
|
||||
|
||||
it('should add block classes in createDOM and disallow text insertion before', () => {
|
||||
runInEditor(() => {
|
||||
const node = new VariableValueBlockNode('hello')
|
||||
const dom = node.createDOM(config)
|
||||
|
||||
expect(dom).toHaveClass('inline-flex')
|
||||
expect(dom).toHaveClass('items-center')
|
||||
expect(dom).toHaveClass('px-0.5')
|
||||
expect(dom).toHaveClass('h-[22px]')
|
||||
expect(dom).toHaveClass('text-text-accent')
|
||||
expect(dom).toHaveClass('rounded-[5px]')
|
||||
expect(dom).toHaveClass('align-middle')
|
||||
expect(node.canInsertTextBefore()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should import serialized node and preserve text metadata in export', () => {
|
||||
runInEditor(() => {
|
||||
const serialized = {
|
||||
detail: 2,
|
||||
format: 1,
|
||||
mode: 'token',
|
||||
style: 'color:red;',
|
||||
text: '{{profile_name}}',
|
||||
type: 'text',
|
||||
version: 1,
|
||||
} as SerializedTextNode
|
||||
|
||||
const imported = VariableValueBlockNode.importJSON(serialized)
|
||||
const exported = imported.exportJSON()
|
||||
|
||||
expect(exported).toEqual({
|
||||
detail: 2,
|
||||
format: 1,
|
||||
mode: 'token',
|
||||
style: 'color:red;',
|
||||
text: '{{profile_name}}',
|
||||
type: 'variable-value-block',
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should create node with helper and support type guard checks', () => {
|
||||
runInEditor(() => {
|
||||
const node = $createVariableValueBlockNode('{{org_id}}')
|
||||
|
||||
expect(node).toBeInstanceOf(VariableValueBlockNode)
|
||||
expect(node.getTextContent()).toBe('{{org_id}}')
|
||||
expect($isVariableValueNodeBlock(node)).toBe(true)
|
||||
expect($isVariableValueNodeBlock(null)).toBe(false)
|
||||
expect($isVariableValueNodeBlock(undefined)).toBe(false)
|
||||
expect($isVariableValueNodeBlock({} as LexicalNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,507 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useReactFlow, useStoreApi } from 'reactflow'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import WorkflowVariableBlockComponent from './component'
|
||||
import { UPDATE_WORKFLOW_NODES_MAP } from './index'
|
||||
import { WorkflowVariableBlockNode } from './node'
|
||||
|
||||
const { mockVarLabel, mockIsExceptionVariable, mockForcedVariableKind } = vi.hoisted(() => ({
|
||||
mockVarLabel: vi.fn(),
|
||||
mockIsExceptionVariable: vi.fn<(variable: string, nodeType?: BlockEnum) => boolean>(() => false),
|
||||
mockForcedVariableKind: { value: '' as '' | 'env' | 'conversation' | 'rag' },
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext')
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('reactflow')
|
||||
vi.mock('../../hooks')
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
return {
|
||||
...actual,
|
||||
isExceptionVariable: mockIsExceptionVariable,
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/nodes/_base/components/variable/utils')>()
|
||||
return {
|
||||
...actual,
|
||||
isENV: (valueSelector: ValueSelector) => {
|
||||
if (mockForcedVariableKind.value === 'env')
|
||||
return true
|
||||
return actual.isENV(valueSelector)
|
||||
},
|
||||
isConversationVar: (valueSelector: ValueSelector) => {
|
||||
if (mockForcedVariableKind.value === 'conversation')
|
||||
return true
|
||||
return actual.isConversationVar(valueSelector)
|
||||
},
|
||||
isRagVariableVar: (valueSelector: ValueSelector) => {
|
||||
if (mockForcedVariableKind.value === 'rag')
|
||||
return true
|
||||
return actual.isRagVariableVar(valueSelector)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
|
||||
VariableLabelInEditor: (props: {
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
errorMsg?: string
|
||||
nodeTitle?: string
|
||||
nodeType?: BlockEnum
|
||||
notShowFullPath?: boolean
|
||||
}) => {
|
||||
mockVarLabel(props)
|
||||
return (
|
||||
<button type="button" onClick={props.onClick}>
|
||||
label
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel', () => ({
|
||||
default: (props: {
|
||||
nodeName: string
|
||||
path: string[]
|
||||
varType: Type
|
||||
nodeType?: BlockEnum
|
||||
}) => <div data-testid="var-full-path-panel">{props.nodeName}</div>,
|
||||
}))
|
||||
|
||||
const mockRegisterCommand = vi.fn()
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockSetViewport = vi.fn()
|
||||
const mockGetState = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
registerCommand: mockRegisterCommand,
|
||||
hasNodes: mockHasNodes,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
describe('WorkflowVariableBlockComponent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockForcedVariableKind.value = ''
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterCommand.mockReturnValue(vi.fn())
|
||||
mockGetState.mockReturnValue({ transform: [0, 0, 2] })
|
||||
|
||||
vi.mocked(useLexicalComposerContext).mockReturnValue([
|
||||
mockEditor,
|
||||
{},
|
||||
] as unknown as ReturnType<typeof useLexicalComposerContext>)
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
|
||||
vi.mocked(useSelectOrDelete).mockReturnValue([{ current: null }, false])
|
||||
vi.mocked(useReactFlow).mockReturnValue({
|
||||
setViewport: mockSetViewport,
|
||||
} as unknown as ReturnType<typeof useReactFlow>)
|
||||
vi.mocked(useStoreApi).mockReturnValue({
|
||||
getState: mockGetState,
|
||||
} as unknown as ReturnType<typeof useStoreApi>)
|
||||
})
|
||||
|
||||
it('should throw when WorkflowVariableBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'output']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
|
||||
})
|
||||
|
||||
it('should render variable label and register update command', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'output']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'label' })).toBeInTheDocument()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledWith(
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
expect.any(Function),
|
||||
expect.any(Number),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call setViewport when label is clicked and node exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const workflowContainer = document.createElement('div')
|
||||
workflowContainer.id = 'workflow-container'
|
||||
Object.defineProperty(workflowContainer, 'clientWidth', { value: 1000, configurable: true })
|
||||
Object.defineProperty(workflowContainer, 'clientHeight', { value: 800, configurable: true })
|
||||
document.body.appendChild(workflowContainer)
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'group', 'field']}
|
||||
workflowNodesMap={{
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 50, y: 80 },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'label' }))
|
||||
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({
|
||||
x: (1000 - 400 - 200 * 2) / 2 - 50 * 2,
|
||||
y: (800 - 100 * 2) / 2 - 80 * 2,
|
||||
zoom: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render safely when node exists and getVarType is not provided', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'group', 'field']}
|
||||
workflowNodesMap={{
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'label' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass computed varType when getVarType is provided', () => {
|
||||
const getVarType = vi.fn(() => Type.number)
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'group', 'field']}
|
||||
workflowNodesMap={{
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
}}
|
||||
getVarType={getVarType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getVarType).toHaveBeenCalledWith({
|
||||
nodeId: 'node-1',
|
||||
valueSelector: ['node-1', 'group', 'field'] as ValueSelector,
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark env variable invalid when not found in environmentVariables', () => {
|
||||
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['env', 'missing_key']}
|
||||
workflowNodesMap={{}}
|
||||
environmentVariables={environmentVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: expect.any(String),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep env variable valid when environmentVariables is omitted', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['env', 'missing_key']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should treat env variable as valid when it exists in environmentVariables', () => {
|
||||
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['env', 'valid_key']}
|
||||
workflowNodesMap={{}}
|
||||
environmentVariables={environmentVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle env selector with missing segment when environmentVariables are provided', () => {
|
||||
const environmentVariables: Var[] = [{ variable: 'env.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['env']}
|
||||
workflowNodesMap={{}}
|
||||
environmentVariables={environmentVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should evaluate env fallback selector tokens when classifier is forced', () => {
|
||||
mockForcedVariableKind.value = 'env'
|
||||
const environmentVariables: Var[] = [{ variable: '.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={[]}
|
||||
workflowNodesMap={{}}
|
||||
environmentVariables={environmentVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should treat conversation variable as valid when found in conversationVariables', () => {
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['conversation', 'topic']}
|
||||
workflowNodesMap={{}}
|
||||
conversationVariables={conversationVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep conversation variable valid when conversationVariables is omitted', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['conversation', 'topic']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should mark conversation variable invalid when not found in conversationVariables', () => {
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.other', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['conversation', 'topic']}
|
||||
workflowNodesMap={{}}
|
||||
conversationVariables={conversationVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: expect.any(String),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle conversation selector with missing segment when conversationVariables are provided', () => {
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['conversation']}
|
||||
workflowNodesMap={{}}
|
||||
conversationVariables={conversationVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should evaluate conversation fallback selector tokens when classifier is forced', () => {
|
||||
mockForcedVariableKind.value = 'conversation'
|
||||
const conversationVariables: Var[] = [{ variable: '.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={[]}
|
||||
workflowNodesMap={{}}
|
||||
conversationVariables={conversationVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should treat global variable as valid without node', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['sys', 'user_id']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should use rag variable validation path', () => {
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['rag', 'shared', 'answer']}
|
||||
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
|
||||
ragVariables={ragVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep rag variable valid when ragVariables is omitted', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['rag', 'shared', 'answer']}
|
||||
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should mark rag variable invalid when not found in ragVariables', () => {
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.other', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['rag', 'shared', 'answer']}
|
||||
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
|
||||
ragVariables={ragVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: expect.any(String),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle rag selector with missing segment when ragVariables are provided', () => {
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['rag', 'shared']}
|
||||
workflowNodesMap={{ shared: { title: 'Rag', type: BlockEnum.Tool } as never }}
|
||||
ragVariables={ragVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should evaluate rag fallback selector tokens when classifier is forced', () => {
|
||||
mockForcedVariableKind.value = 'rag'
|
||||
const ragVariables: Var[] = [{ variable: '..', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={[]}
|
||||
workflowNodesMap={{}}
|
||||
ragVariables={ragVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should apply workflow node map updates through command handler', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'field']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const updateHandler = mockRegisterCommand.mock.calls[0][1] as (map: Record<string, unknown>) => boolean
|
||||
let result = false
|
||||
act(() => {
|
||||
result = updateHandler({
|
||||
'node-1': {
|
||||
title: 'Updated',
|
||||
type: BlockEnum.LLM,
|
||||
width: 100,
|
||||
height: 50,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,204 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { WorkflowNodesMap } from './node'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { render } from '@testing-library/react'
|
||||
import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
CLEAR_HIDE_MENU_TIMEOUT,
|
||||
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
WorkflowVariableBlock,
|
||||
WorkflowVariableBlockNode,
|
||||
} from './index'
|
||||
import { $createWorkflowVariableBlockNode } from './node'
|
||||
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('lexical', async () => {
|
||||
const actual = await vi.importActual('lexical')
|
||||
return {
|
||||
...actual,
|
||||
$insertNodes: vi.fn(),
|
||||
createCommand: vi.fn(name => name),
|
||||
COMMAND_PRIORITY_EDITOR: 1,
|
||||
}
|
||||
})
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockRegisterCommand = vi.fn()
|
||||
const mockDispatchCommand = vi.fn()
|
||||
const mockUpdate = vi.fn((callback: () => void) => callback())
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
registerCommand: mockRegisterCommand,
|
||||
dispatchCommand: mockDispatchCommand,
|
||||
update: mockUpdate,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowVariableBlock', () => {
|
||||
const workflowNodesMap: WorkflowNodesMap = {
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 10, y: 20 },
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterCommand.mockReturnValue(vi.fn())
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
|
||||
vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ id: 'workflow-node' } as unknown as WorkflowVariableBlockNode)
|
||||
})
|
||||
|
||||
it('should render null and register insert/delete commands', () => {
|
||||
const { container } = renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(WorkflowVariableBlock.displayName).toBe('WorkflowVariableBlock')
|
||||
})
|
||||
|
||||
it('should dispatch workflow node map update on mount', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalled()
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
|
||||
})
|
||||
|
||||
it('should throw when WorkflowVariableBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
|
||||
})
|
||||
|
||||
it('should insert workflow variable block node and call onInsert', () => {
|
||||
const onInsert = vi.fn()
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
onInsert={onInsert}
|
||||
getVarType={getVarType}
|
||||
/>,
|
||||
)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
|
||||
const result = insertHandler(['node-1', 'answer'])
|
||||
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
|
||||
['node-1', 'answer'],
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
)
|
||||
expect($insertNodes).toHaveBeenCalledWith([{ id: 'workflow-node' }])
|
||||
expect(onInsert).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on insert when onInsert is omitted', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
|
||||
expect(insertHandler(['node-1', 'answer'])).toBe(true)
|
||||
})
|
||||
|
||||
it('should call onDelete and return true when delete handler runs', () => {
|
||||
const onDelete = vi.fn()
|
||||
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
const result = deleteHandler()
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on delete when onDelete is omitted', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
expect(deleteHandler()).toBe(true)
|
||||
})
|
||||
|
||||
it('should run merged cleanup on unmount', () => {
|
||||
const insertCleanup = vi.fn()
|
||||
const deleteCleanup = vi.fn()
|
||||
mockRegisterCommand
|
||||
.mockReturnValueOnce(insertCleanup)
|
||||
.mockReturnValueOnce(deleteCleanup)
|
||||
|
||||
const { unmount } = renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
unmount()
|
||||
|
||||
expect(insertCleanup).toHaveBeenCalledTimes(1)
|
||||
expect(deleteCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,166 @@
|
||||
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import { createEditor } from 'lexical'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
$createWorkflowVariableBlockNode,
|
||||
$isWorkflowVariableBlockNode,
|
||||
WorkflowVariableBlockNode,
|
||||
} from './node'
|
||||
|
||||
describe('WorkflowVariableBlockNode', () => {
|
||||
let editor: LexicalEditor
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
editor = createEditor({
|
||||
nodes: [WorkflowVariableBlockNode as unknown as Klass<LexicalNode>],
|
||||
})
|
||||
})
|
||||
|
||||
const runInEditor = (callback: () => void) => {
|
||||
editor.update(callback, { discrete: true })
|
||||
}
|
||||
|
||||
it('should expose type and clone with same payload', () => {
|
||||
runInEditor(() => {
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
const original = new WorkflowVariableBlockNode(
|
||||
['node-1', 'answer'],
|
||||
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
'node-key',
|
||||
)
|
||||
const cloned = WorkflowVariableBlockNode.clone(original)
|
||||
|
||||
expect(WorkflowVariableBlockNode.getType()).toBe('workflow-variable-block')
|
||||
expect(cloned).toBeInstanceOf(WorkflowVariableBlockNode)
|
||||
expect(cloned.getKey()).toBe(original.getKey())
|
||||
})
|
||||
})
|
||||
|
||||
it('should be inline and create expected dom classes', () => {
|
||||
runInEditor(() => {
|
||||
const node = new WorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
|
||||
const dom = node.createDOM()
|
||||
|
||||
expect(node.isInline()).toBe(true)
|
||||
expect(dom.tagName).toBe('DIV')
|
||||
expect(dom).toHaveClass('inline-flex')
|
||||
expect(dom).toHaveClass('items-center')
|
||||
expect(dom).toHaveClass('align-middle')
|
||||
expect(node.updateDOM()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should decorate with component props from node state', () => {
|
||||
runInEditor(() => {
|
||||
const getVarType = vi.fn(() => Type.number)
|
||||
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
|
||||
|
||||
const node = new WorkflowVariableBlockNode(
|
||||
['node-1', 'answer'],
|
||||
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
'decorator-key',
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
)
|
||||
|
||||
const decorated = node.decorate()
|
||||
expect(decorated.props.nodeKey).toBe('decorator-key')
|
||||
expect(decorated.props.variables).toEqual(['node-1', 'answer'])
|
||||
expect(decorated.props.workflowNodesMap).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
|
||||
expect(decorated.props.environmentVariables).toEqual(environmentVariables)
|
||||
expect(decorated.props.conversationVariables).toEqual(conversationVariables)
|
||||
expect(decorated.props.ragVariables).toEqual(ragVariables)
|
||||
})
|
||||
})
|
||||
|
||||
it('should export and import json with full payload', () => {
|
||||
runInEditor(() => {
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
|
||||
|
||||
const node = new WorkflowVariableBlockNode(
|
||||
['node-1', 'answer'],
|
||||
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
undefined,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
)
|
||||
|
||||
expect(node.exportJSON()).toEqual({
|
||||
type: 'workflow-variable-block',
|
||||
version: 1,
|
||||
variables: ['node-1', 'answer'],
|
||||
workflowNodesMap: { 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
})
|
||||
|
||||
const imported = WorkflowVariableBlockNode.importJSON({
|
||||
type: 'workflow-variable-block',
|
||||
version: 1,
|
||||
variables: ['node-2', 'result'],
|
||||
workflowNodesMap: { 'node-2': { title: 'B', type: BlockEnum.Tool } },
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
})
|
||||
|
||||
expect(imported).toBeInstanceOf(WorkflowVariableBlockNode)
|
||||
expect(imported.getVariables()).toEqual(['node-2', 'result'])
|
||||
expect(imported.getWorkflowNodesMap()).toEqual({ 'node-2': { title: 'B', type: BlockEnum.Tool } })
|
||||
})
|
||||
})
|
||||
|
||||
it('should return getters and text content in expected format', () => {
|
||||
runInEditor(() => {
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
|
||||
const node = new WorkflowVariableBlockNode(
|
||||
['node-1', 'answer'],
|
||||
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
undefined,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
)
|
||||
|
||||
expect(node.getVariables()).toEqual(['node-1', 'answer'])
|
||||
expect(node.getWorkflowNodesMap()).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
|
||||
expect(node.getVarType()).toBe(getVarType)
|
||||
expect(node.getEnvironmentVariables()).toEqual(environmentVariables)
|
||||
expect(node.getConversationVariables()).toEqual(conversationVariables)
|
||||
expect(node.getRagVariables()).toEqual(ragVariables)
|
||||
expect(node.getTextContent()).toBe('{{#node-1.answer#}}')
|
||||
})
|
||||
})
|
||||
|
||||
it('should create node helper and type guard checks', () => {
|
||||
runInEditor(() => {
|
||||
const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
|
||||
|
||||
expect(node).toBeInstanceOf(WorkflowVariableBlockNode)
|
||||
expect($isWorkflowVariableBlockNode(node)).toBe(true)
|
||||
expect($isWorkflowVariableBlockNode(null)).toBe(false)
|
||||
expect($isWorkflowVariableBlockNode(undefined)).toBe(false)
|
||||
expect($isWorkflowVariableBlockNode({} as LexicalNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,221 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { EntityMatch } from '@lexical/text'
|
||||
import type { LexicalEditor, LexicalNode } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { WorkflowNodesMap } from './node'
|
||||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { render } from '@testing-library/react'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import { WorkflowVariableBlockNode } from './index'
|
||||
import { $createWorkflowVariableBlockNode } from './node'
|
||||
import WorkflowVariableBlockReplacementBlock from './workflow-variable-block-replacement-block'
|
||||
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('lexical')
|
||||
vi.mock('../../utils')
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockRegisterNodeTransform = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
registerNodeTransform: mockRegisterNodeTransform,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowVariableBlockReplacementBlock', () => {
|
||||
const variables: NodeOutPutVar[] = [
|
||||
{
|
||||
nodeId: 'env',
|
||||
title: 'ENV',
|
||||
vars: [{ variable: 'env.key', type: VarType.string }],
|
||||
},
|
||||
{
|
||||
nodeId: 'conversation',
|
||||
title: 'Conversation',
|
||||
vars: [{ variable: 'conversation.topic', type: VarType.string }],
|
||||
},
|
||||
{
|
||||
nodeId: 'node-1',
|
||||
title: 'Node A',
|
||||
vars: [
|
||||
{ variable: 'output', type: VarType.string },
|
||||
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodeId: 'rag',
|
||||
title: 'RAG',
|
||||
vars: [{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true }],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowNodesMap: WorkflowNodesMap = {
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 20, y: 40 },
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterNodeTransform.mockReturnValue(vi.fn())
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
|
||||
vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ type: 'workflow-node' } as unknown as WorkflowVariableBlockNode)
|
||||
vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node)
|
||||
})
|
||||
|
||||
it('should register transform and cleanup on unmount', () => {
|
||||
const transformCleanup = vi.fn()
|
||||
mockRegisterNodeTransform.mockReturnValue(transformCleanup)
|
||||
|
||||
const { unmount, container } = renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
|
||||
expect(mockRegisterNodeTransform).toHaveBeenCalledWith(CustomTextNode, expect.any(Function))
|
||||
|
||||
unmount()
|
||||
expect(transformCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should throw when WorkflowVariableBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)).toThrow('WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor')
|
||||
})
|
||||
|
||||
it('should pass matcher and creator to decoratorTransform', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
const textNode = { id: 'text-node' } as unknown as LexicalNode
|
||||
transformCallback(textNode)
|
||||
|
||||
expect(decoratorTransform).toHaveBeenCalledWith(
|
||||
textNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should match variable placeholders and return null for non-placeholder text', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
|
||||
|
||||
const getMatch = vi.mocked(decoratorTransform).mock.calls[0][1] as (text: string) => EntityMatch | null
|
||||
const match = getMatch('prefix {{#node-1.output#}} suffix')
|
||||
|
||||
expect(match).toEqual({
|
||||
start: 7,
|
||||
end: 26,
|
||||
})
|
||||
expect(getMatch('plain text only')).toBeNull()
|
||||
})
|
||||
|
||||
it('should create replacement node with mapped env/conversation/rag vars and call onInsert', () => {
|
||||
const onInsert = vi.fn()
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
onInsert={onInsert}
|
||||
getVarType={getVarType}
|
||||
variables={variables}
|
||||
/>,
|
||||
)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
|
||||
|
||||
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as (
|
||||
textNode: { getTextContent: () => string },
|
||||
) => WorkflowVariableBlockNode
|
||||
|
||||
const created = createNode({
|
||||
getTextContent: () => '{{#node-1.output#}}',
|
||||
})
|
||||
|
||||
expect(onInsert).toHaveBeenCalledTimes(1)
|
||||
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
|
||||
['node-1', 'output'],
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
variables[0].vars,
|
||||
variables[1].vars,
|
||||
[
|
||||
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
|
||||
{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true },
|
||||
],
|
||||
)
|
||||
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'workflow-node' })
|
||||
expect(created).toEqual({ type: 'workflow-node' })
|
||||
})
|
||||
|
||||
it('should create replacement node without optional callbacks and variable groups', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
|
||||
|
||||
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as (
|
||||
textNode: { getTextContent: () => string },
|
||||
) => WorkflowVariableBlockNode
|
||||
|
||||
expect(() => createNode({ getTextContent: () => '{{#node-1.output#}}' })).not.toThrow()
|
||||
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
|
||||
['node-1', 'output'],
|
||||
workflowNodesMap,
|
||||
undefined,
|
||||
[],
|
||||
[],
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1295,9 +1295,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/base/audio-gallery/AudioPlayer.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@ -2254,11 +2251,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/base/notion-page-selector/credential-selector/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/notion-page-selector/page-selector/index.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user