Merge remote-tracking branch 'origin/master' into worktree-session-hover-menu-fix

# Conflicts:
#	apps/web/tests/workspace-management.e2e.ts
This commit is contained in:
creatixchu
2026-07-31 15:14:05 +08:00
1191 changed files with 45284 additions and 5704 deletions

View File

@@ -386,11 +386,17 @@ describe('Modal', () => {
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
expect(screen.queryByRole('dialog')).toBeNull()
rerender(
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
<input aria-label="name" />
</Modal>)
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
const dialog = screen.getByRole('dialog', { name: 'Create new workspace' })
expect(dialog).toBeDefined()
// The full-page layer escapes caller stacking contexts but remains in
// this document/current WebUI window.
expect(dialog.parentElement?.parentElement).toBe(document.body)
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
expect(screen.getByText('Name it.')).toBeDefined()
expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
fireEvent.keyDown(document, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(document, { key: 'Escape' })

View File

@@ -0,0 +1,182 @@
// @vitest-environment jsdom
// DiffBlock: the per-file hunk rows (path header, removed block, added block),
// the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and
// its singular/plural, the head/tail height cap and its expand control, the
// empty-diffs null render, and the copy control writing the prefixed diff text
// on both the accepted and the refused clipboard paths. writeClipboard's own
// return contract is pinned in terminal-block.spec.tsx (the shared seam), so
// only its DOM consequence is asserted here.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** The rendered body rows, one string per visible line (CSS-module class prefix). */
function bodyRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '')
}
/** Only the changed rows (add/del), excluding the path header and gap chrome. */
function changeRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '')
}
/** `count` numbered added lines as one hunk's newText. */
function added(count: number): string {
return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n')
}
describe('DiffBlock structure', () => {
it('renders a create as a path header and an added block (no removed side)', () => {
const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('notes/new.txt')).toBeTruthy()
// No removed rows: both change lines are added.
expect(changeRows(container)).toEqual(['hello', 'world'])
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2)
})
it('renders an edit as a removed block above an added block', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1)
expect(changeRows(container)).toEqual(['old', 'new'])
})
it('opens a same-file second hunk with a gap instead of repeating the path', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'x', newText: 'y' },
{ path: 'a.ts', oldText: 'p', newText: 'q' },
]
const { container } = render(<DiffBlock diffs={diffs} />)
// One path header, one gap row.
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1)
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1)
})
it('opens a new file with its own path header', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'x', newText: 'y' },
{ path: 'b.ts', oldText: 'p', newText: 'q' },
]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2)
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0)
})
it('renders nothing for empty diffs', () => {
const { container } = render(<DiffBlock diffs={[]} />)
expect(container.firstChild).toBeNull()
})
it('treats a trailing newline as a terminator, not an extra blank line', () => {
// A create whose newText ends in a newline is one added line, not two, and
// the footer counts one — the phantom `+ ` empty line the naive split drew.
const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
expect(changeRows(container)).toEqual(['hello'])
expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('renders a full deletion as removed-only with no phantom added line', () => {
// newText '' is zero added lines: an empty string must contribute nothing.
const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
})
it('keeps a genuine interior blank line', () => {
const { container } = render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
})
})
describe('DiffBlock footer', () => {
it('counts added and removed lines and one file', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
})
it('pluralizes the distinct-file count', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: null, newText: 'x' },
{ path: 'b.ts', oldText: null, newText: 'y' },
]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
})
})
describe('DiffBlock height cap', () => {
it('shows head and tail with an expand control past the cap, then all lines expanded', () => {
// One added line over the default cap forces the collapse.
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }]
// The path header counts as a row, so a body of maxLines added lines plus
// the header is one over the cap.
const { container } = render(<DiffBlock diffs={diffs} />)
const toggle = screen.getByRole('button', { name: /展开其余/ })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
// Collapsed shows fewer rows than the full body.
const collapsedCount = bodyRows(container).length
expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1)
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true')
expect(bodyRows(container).length).toBeGreaterThan(collapsedCount)
})
it('shows no expand control at or under the cap', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }]
render(<DiffBlock diffs={diffs} maxLines={16} />)
expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull()
})
})
describe('DiffBlock copy', () => {
it('copies the prefixed diff text and flips the label on success', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'old', newText: 'new' },
{ path: 'a.ts', oldText: 'p', newText: 'q' },
]
render(<DiffBlock diffs={diffs} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
// Path header, del/add prefixes, and the same-file gap all reach the clipboard.
expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q')
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('keeps the label on a refused clipboard write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('ignores a second click while the copied label is showing', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) })
expect(writeText).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,211 @@
// @vitest-environment jsdom
// WebBlock: both kinds of the web card. The search card's answer, its citation
// list with the title-or-hostname label fallback and optional snippet/date, the
// source-list height cap and its expand control, and the truncated indicator;
// the fetch card's linked URL, status, and truncation. Safe-link attributes on
// both kinds: an http(s) URL becomes an external anchor (target/rel), any other
// URL renders as plain text with no href.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
import type { WebSourceView } from '../src/index.ts'
afterEach(cleanup)
/** `count` sources with sequential hostnames, so the cap slices read distinctly. */
function sources(count: number): WebSourceView[] {
return Array.from({ length: count }, (_value, index) => ({
url: `https://site-${index}.example.com/page`,
title: `Source ${index}`,
}))
}
describe('WebBlock search card', () => {
it('renders the answer above the citation list', () => {
const view = render(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
expect(view.getByText('Answer')).toBeTruthy()
expect(view.getByText('Source 0')).toBeTruthy()
expect(view.getByText('Source 1')).toBeTruthy()
})
it('omits the answer block when there is no answer', () => {
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull()
})
it('shows the empty-state note when a search returns no answer and no sources', () => {
const view = render(<WebBlock kind="search" sources={[]} truncated={false} />)
expect(view.getByText('未找到结果')).toBeTruthy()
// The empty note replaces the source list, not an empty <ol>.
expect(view.container.querySelector('ol')).toBeNull()
})
it('shows the source list, not the empty note, when a source is present', () => {
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(view.container.querySelector('ol')).toBeTruthy()
expect(view.queryByText('未找到结果')).toBeNull()
})
it('shows the source list when an empty source list still carries an answer', () => {
const view = render(<WebBlock kind="search" answer="Just an answer" sources={[]} truncated={false} />)
expect(view.getByText('Just an answer')).toBeTruthy()
expect(view.queryByText('未找到结果')).toBeNull()
})
it('labels a source by its title, and by hostname when the title is absent', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://example.com/a', title: 'Titled' },
{ url: 'https://plain.example.org/b' },
{ url: 'https://empty.example.net/c', title: '' },
]} />)
expect(view.getByText('Titled')).toBeTruthy()
// No title / empty title: the hostname labels the link.
expect(view.getByText('plain.example.org')).toBeTruthy()
expect(view.getByText('empty.example.net')).toBeTruthy()
})
it('labels a source by the raw url when it parses to an empty hostname', () => {
// file:/data:/javascript: URLs parse but have no hostname; the label must
// fall back to the raw URL so it is never blank (and the link stays plain
// text since the protocol is not http(s)).
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'file:///etc/passwd' },
]} />)
expect(view.getByText('file:///etc/passwd')).toBeTruthy()
})
it('renders a source as a safe external anchor for an http(s) url', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://example.com/a', title: 'Titled' },
]} />)
const anchor = view.getByText('Titled') as HTMLAnchorElement
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('https://example.com/a')
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
})
it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'javascript:alert(1)', title: 'Dangerous' },
{ url: 'not a url' },
]} />)
const unsafe = view.getByText('Dangerous')
expect(unsafe.tagName).toBe('SPAN')
expect(unsafe.getAttribute('href')).toBeNull()
// An unparseable url is not a link and cannot yield a hostname, so its raw
// text is the label.
const raw = view.getByText('not a url')
expect(raw.tagName).toBe('SPAN')
})
it('shows a source snippet and publication date when present, and omits them when absent or empty', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
{ url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
{ url: 'https://c.example.com', title: 'C' },
]} />)
expect(view.getByText('excerpt')).toBeTruthy()
expect(view.getByText('2026-07-01')).toBeTruthy()
// The empty-string and absent arms both draw nothing beyond the link.
expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1)
expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1)
})
it('shows the truncated indicator only when the list was capped by the tool', () => {
const on = render(<WebBlock kind="search" sources={sources(1)} truncated />)
expect(on.getByText('来源列表已截断')).toBeTruthy()
cleanup()
const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(off.queryByText('来源列表已截断')).toBeNull()
})
it('renders every source and no expand control under the cap', () => {
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 条来源')
fireEvent.click(toggle)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起来源' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
})
it('numbers a collapsed tail by each source original position, not its visible slot', () => {
// maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read
// as citations 9 and 10 (via <li value>), not renumbered 3 and 4.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10'])
})
it('keeps the expander out of the ordered-list numbering', () => {
// The expander is a marker-less <li>, so it is valid inside <ol> and does not
// consume a citation number between the head and tail sources.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const ol = view.container.querySelector('ol')!
// Every direct child is an <li> (no bare <button> child — invalid HTML).
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
})
it('caps at the documented default when maxSources is absent', () => {
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
})
})
describe('WebBlock fetch card', () => {
it('renders the fetched url as a safe external anchor and its HTTP status', () => {
const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('https://example.com/page')
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
it('renders a non-http fetch url as plain text with no href', () => {
const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
const label = view.getByText('file:///etc/passwd')
expect(label.tagName).toBe('SPAN')
expect(label.getAttribute('href')).toBeNull()
})
it('shows the truncated indicator only when the content was cut', () => {
const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
expect(on.getByText('内容已截断')).toBeTruthy()
cleanup()
const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
expect(off.queryByText('内容已截断')).toBeNull()
})
it('carries a non-200 status verbatim', () => {
const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
expect(view.getByText('HTTP 404')).toBeTruthy()
})
})