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:
Poojan 2026-02-25 15:06:58 +05:30 committed by GitHub
parent 3c69bac2b1
commit 0ac09127c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 3811 additions and 30 deletions

View 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 })
})
})
})

View File

@ -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>

View 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--&gt;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()
})
})
})

View 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()
})
})

View 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()
})
})
})

View 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)
})
})

View File

@ -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')
})
})

View 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')
})
})

View 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)
})
})

View 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)
})
})

View 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=')
})
})

View 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()
})
})

View File

@ -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>
)

View File

@ -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')
})
})

View File

@ -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}

View File

@ -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())
})
})

View File

@ -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>

View File

@ -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()
})
})

View File

@ -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"
/>
)
}

View File

@ -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',
)
})
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})
})

View File

@ -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}}' })
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})
})

View File

@ -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,
)
})
})

View File

@ -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