Files
new-api/web/src/components/ai-elements/__tests__/response-fade-render.test.tsx
T
RedwindA 137d1171f2 feat(web): fade in streamed response words and harden playground editor (#6895)
* feat(web): fade in newly streamed response words

Animate only new word-level deltas while markdown is still streaming, and
cache markdown-it instances per parser id so concurrent Response trees do
not rebuild or reparse on every render.

* fix(web): keep CodeMirror editor alive across keystroke re-renders

Deliver onKeyDown through a ref instead of the extensions memo so a new
handler identity no longer tears down the EditorView, which reset the
cursor to the document start and made typing appear right-to-left.

* feat(web): add unsaved changes confirmation dialog in PlaygroundMessageEditor

Implement a confirmation dialog to warn users about unsaved changes when attempting to leave the editor. This includes handling the beforeunload event to prevent accidental navigation away from the editor. Additionally, add tests to verify the dialog's behavior under various scenarios.

* test(web): cover beforeunload guard and fade hydration suppression

Address review feedback: add regression tests for the unsaved-changes
beforeunload guard and the first-render fade suppression of hydrated
content, and annotate getCachedMarkdown's return type.
2026-08-18 18:20:53 +08:00

115 lines
3.8 KiB
TypeScript

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { Response } from '../response'
import {
FADE_DURATION_MS,
FADE_HYDRATION_THRESHOLD,
FADE_STAGGER_MAX_MS,
} from '../response-fade'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('Response streaming fade', () => {
test('wraps newly streamed words when final is false', () => {
const { rerender } = render(<Response final={false}>Hello</Response>)
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
0
)
expect(screen.getByText('Hello')).toBeTruthy()
rerender(<Response final={false}>Hello world</Response>)
const fades = [...document.querySelectorAll('[data-stream-fade]')]
expect(fades.some((node) => node.textContent === 'world')).toBe(true)
})
test('renders settled content with zero fade wrappers when final is true', () => {
render(<Response final>Hello world</Response>)
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
expect(screen.getByText(/Hello world/)).toBeTruthy()
})
test('does not fade inline code or fenced code blocks', () => {
render(
<Response final={false}>
{['Use `code` and:', '', '```', 'block', '```'].join('\n')}
</Response>
)
const fades = [...document.querySelectorAll('[data-stream-fade]')]
const fadedText = fades.map((node) => node.textContent ?? '').join('')
expect(fadedText.includes('block')).toBe(false)
expect(
fades.every((node) => {
const text = node.textContent ?? ''
return text.trim() !== 'code' && text.trim() !== 'block'
})
).toBe(true)
})
test('does not re-animate words after markdown restructuring around strong', () => {
vi.spyOn(performance, 'now').mockReturnValue(1000)
const { rerender } = render(<Response final={false}>**fin</Response>)
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
0
)
vi.spyOn(performance, 'now').mockReturnValue(
1000 + FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1
)
rerender(<Response final={false}>**final**</Response>)
const strong = document.querySelector('strong')
expect(strong?.textContent).toContain('final')
const fades = [...document.querySelectorAll('[data-stream-fade]')]
expect(
fades.every((node) => !(node.textContent ?? '').includes('final'))
).toBe(true)
})
test('suppresses fades on the first streaming render of hydrated content', () => {
const hydrated = 'word '.repeat(FADE_HYDRATION_THRESHOLD)
render(<Response final={false}>{hydrated}</Response>)
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
})
test('drops all fade wrappers once the stream settles', () => {
const { rerender } = render(
<Response final={false}>Streaming text</Response>
)
expect(document.querySelectorAll('[data-stream-fade]').length).toBeGreaterThan(
0
)
rerender(<Response final>Streaming text</Response>)
expect(document.querySelectorAll('[data-stream-fade]')).toHaveLength(0)
})
})