f4c243c75f
The terminal card showed no run state: a running command and a settled command that produced no output rendered the same prompt line, so whether a command was still running had to be inferred from the absence of output. Lead the prompt line with a StateDot in three of its states — the spinning ring while running, red for the same exit status that renders the status pill, green for a clean settle. That is the same indicator a tool row's leading icon carries, so a row and its own card cannot disagree about one command; the row/card agreement is pinned in the ui-conversation spec. StateDot is aria-hidden, so a visually hidden text label rides beside it, which is what the refreshed aria goldens now record. The e2e adds what jsdom cannot compute: the dot's color resolves to the green success token through the real theme stylesheet, and the dot precedes the prompt label in document order.
366 lines
17 KiB
TypeScript
366 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled
|
|
// arms, the prompt line's run-state dot, the exit-status pill, the head/tail height cap and its expand control,
|
|
// and the copy control writing the raw output on both the accepted and the
|
|
// refused clipboard paths. writeClipboard's own return contract is pinned here
|
|
// too, since it is the seam both copy controls in this package share; the
|
|
// resolution of ANSI runs into styles is pinned in ansi.spec.ts, so only its
|
|
// DOM consequence (which runs get a span wrapper) is asserted here.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts'
|
|
import { writeClipboard } from '../src/clipboard.ts'
|
|
|
|
const ESC = '\u001b'
|
|
|
|
afterEach(cleanup)
|
|
|
|
beforeEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
/** The rendered output rows, one string per visible line (CSS-module class prefix). */
|
|
function outputLines(container: HTMLElement): string[] {
|
|
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
|
|
}
|
|
|
|
/** The prompt line's run-state dot: its StateDot state plus the hidden text label beside it. */
|
|
function runStateOf(container: HTMLElement): { state: string | null; label: string | undefined } {
|
|
const dot = container.querySelector('[class*="_runState_"][data-state]')
|
|
return {
|
|
state: dot?.getAttribute('data-state') ?? null,
|
|
label: container.querySelector('[class^="_runStateLabel_"]')?.textContent ?? undefined,
|
|
}
|
|
}
|
|
|
|
/** `count` numbered output lines, without the terminating newline. */
|
|
function body(count: number): string {
|
|
return Array.from({ length: count }, (_value, index) => `line ${index + 1}`).join('\n')
|
|
}
|
|
|
|
describe('TerminalBlock prompt label', () => {
|
|
it('collapses the home directory itself to ~', () => {
|
|
render(<TerminalBlock command="ls" cwd="/Users/me" home="/Users/me" />)
|
|
expect(screen.getByText('~')).toBeTruthy()
|
|
})
|
|
|
|
it('shows only the last segment below home', () => {
|
|
render(<TerminalBlock command="ls" cwd="/Users/me/Documents" home="/Users/me" />)
|
|
expect(screen.getByText('Documents')).toBeTruthy()
|
|
})
|
|
|
|
it('ignores trailing separators on both the cwd and home', () => {
|
|
const view = render(<TerminalBlock command="ls" cwd="/Users/me/" home="/Users/me" />)
|
|
expect(view.getByText('~')).toBeTruthy()
|
|
view.rerender(<TerminalBlock command="ls" cwd="/Users/me" home="/Users/me/" />)
|
|
expect(view.getByText('~')).toBeTruthy()
|
|
})
|
|
|
|
it('drops trailing separators before taking the last segment', () => {
|
|
render(<TerminalBlock command="ls" cwd="/Users/me/Documents///" home="/Users/me" />)
|
|
expect(screen.getByText('Documents')).toBeTruthy()
|
|
})
|
|
|
|
it('takes the last segment when no home is known', () => {
|
|
render(<TerminalBlock command="ls" cwd="C:\\Users\\me\\Projects" />)
|
|
expect(screen.getByText('Projects')).toBeTruthy()
|
|
})
|
|
|
|
it('collapses a backslash home path to ~', () => {
|
|
render(<TerminalBlock command="ls" cwd="C:\\Users\\me" home="C:\\Users\\me" />)
|
|
expect(screen.getByText('~')).toBeTruthy()
|
|
})
|
|
|
|
it('falls back to the raw path when it has no segment', () => {
|
|
render(<TerminalBlock command="ls" cwd="/" home="/Users/me" />)
|
|
expect(screen.getByText('/')).toBeTruthy()
|
|
})
|
|
|
|
it('renders a plain $ with no cwd', () => {
|
|
render(<TerminalBlock command="ls" />)
|
|
expect(screen.getByText('$')).toBeTruthy()
|
|
})
|
|
|
|
it('renders the command verbatim after the label', () => {
|
|
render(<TerminalBlock command="git log --oneline | head -3" cwd="/Users/me/app" />)
|
|
expect(screen.getByText('git log --oneline | head -3')).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('TerminalBlock states', () => {
|
|
it('running shows the command line only: no output, no placeholder, no copy', () => {
|
|
const view = render(<TerminalBlock command="sleep 5" running output="partial" />)
|
|
expect(view.getByText('sleep 5')).toBeTruthy()
|
|
expect(view.queryByText('partial')).toBeNull()
|
|
expect(view.queryByText('无输出')).toBeNull()
|
|
expect(view.queryByRole('button')).toBeNull()
|
|
expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('')
|
|
})
|
|
|
|
it('running still shows a settled-looking status pill when one is supplied', () => {
|
|
render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
|
|
expect(screen.getByText('信号 SIGINT')).toBeTruthy()
|
|
})
|
|
|
|
it('settled with whitespace-only output shows the dimmed placeholder', () => {
|
|
const view = render(<TerminalBlock command="true" output={' \n '} exitCode={0} />)
|
|
expect(view.getByText('无输出')).toBeTruthy()
|
|
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
|
})
|
|
|
|
it('settled with absent output shows the placeholder', () => {
|
|
render(<TerminalBlock command="true" exitCode={0} />)
|
|
expect(screen.getByText('无输出')).toBeTruthy()
|
|
})
|
|
|
|
it('settled with an empty string shows the placeholder', () => {
|
|
render(<TerminalBlock command="true" output="" exitCode={0} />)
|
|
expect(screen.getByText('无输出')).toBeTruthy()
|
|
})
|
|
|
|
it('merges className onto the wrapper', () => {
|
|
const view = render(<TerminalBlock command="ls" className="x" output="a" />)
|
|
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
|
|
expect(view.container.firstElementChild?.hasAttribute('data-running')).toBe(false)
|
|
})
|
|
|
|
it('drops the output text terminator instead of drawing a blank line', () => {
|
|
const view = render(<TerminalBlock command="ls" output={'a\nb\n'} />)
|
|
expect(outputLines(view.container)).toEqual(['a', 'b'])
|
|
})
|
|
|
|
it('keeps a genuinely blank final line when the output ends with two newlines', () => {
|
|
const view = render(<TerminalBlock command="ls" output={'a\nb\n\n'} />)
|
|
expect(outputLines(view.container)).toEqual(['a', 'b', ''])
|
|
})
|
|
|
|
it('renders ANSI runs as styled spans and plain text bare', () => {
|
|
const view = render(<TerminalBlock command="ls" output={`${ESC}[31mbad${ESC}[39m ok`} />)
|
|
// Scoped to a line: the prompt line's run-state dot is a styled span too.
|
|
const span = view.container.querySelector('[class^="_line_"] span[style]')
|
|
expect(span?.textContent).toBe('bad')
|
|
expect(span?.getAttribute('style')).toContain('--dsw-alias-state-error-primary')
|
|
expect(outputLines(view.container)).toEqual(['bad ok'])
|
|
})
|
|
|
|
it('renders uncolored output with no span wrappers at all', () => {
|
|
const view = render(<TerminalBlock command="ls" output={'plain one\nplain two\n'} />)
|
|
expect(view.container.querySelectorAll('[class^="_line_"] span')).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('TerminalBlock status pill', () => {
|
|
it('renders no pill for a clean exit', () => {
|
|
const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
|
|
expect(view.queryByText(/退出码|信号/u)).toBeNull()
|
|
})
|
|
|
|
it('renders no pill while the exit status is unknown', () => {
|
|
const view = render(<TerminalBlock command="ls" output="a" />)
|
|
expect(view.queryByText(/退出码|信号/u)).toBeNull()
|
|
})
|
|
|
|
it('renders the exit-code pill for a non-zero exit', () => {
|
|
render(<TerminalBlock command="false" output="a" exitCode={1} />)
|
|
expect(screen.getByText('退出码 1')).toBeTruthy()
|
|
})
|
|
|
|
it('renders the signal pill, which outranks the exit code', () => {
|
|
render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
|
|
expect(screen.getByText('信号 SIGKILL')).toBeTruthy()
|
|
expect(screen.queryByText(/退出码/u)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('TerminalBlock run-state dot', () => {
|
|
it('shows the spinning ring and its running label while the command runs', () => {
|
|
const view = render(<TerminalBlock command="sleep 5" running />)
|
|
expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
|
|
})
|
|
|
|
it('shows the done dot for a clean settled exit', () => {
|
|
const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
|
|
expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
|
|
})
|
|
|
|
it('counts a settled command with no exit status as a clean settle', () => {
|
|
const view = render(<TerminalBlock command="ls" output="a" />)
|
|
expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
|
|
})
|
|
|
|
it('shows the error dot for a non-zero exit', () => {
|
|
const view = render(<TerminalBlock command="false" output="a" exitCode={1} />)
|
|
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
|
|
})
|
|
|
|
it('shows the error dot for a signal, whatever the exit code says', () => {
|
|
const view = render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
|
|
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
|
|
})
|
|
|
|
// The dot precedes the prompt label, which is what makes it read as the
|
|
// state OF this command rather than of the card's chrome.
|
|
it('places the dot ahead of the prompt label and the command', () => {
|
|
const view = render(<TerminalBlock command="ls" cwd="/srv/app" output="a" />)
|
|
const prompt = view.container.querySelector('[class^="_prompt_"]')
|
|
expect([...prompt!.children].map(node => node.textContent)).toEqual(['', '已完成', 'app', 'ls'])
|
|
})
|
|
|
|
it('keeps the running dot even while a settled-looking status pill is supplied', () => {
|
|
const view = render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
|
|
expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
|
|
})
|
|
})
|
|
|
|
describe('TerminalBlock height cap', () => {
|
|
it('renders every line and no expand control under the cap', () => {
|
|
const view = render(<TerminalBlock command="ls" output={body(4)} maxLines={4} />)
|
|
expect(outputLines(view.container)).toHaveLength(4)
|
|
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
|
})
|
|
|
|
it('does not count the output terminator against the cap', () => {
|
|
const view = render(<TerminalBlock command="ls" output={`${body(4)}\n`} maxLines={4} />)
|
|
expect(outputLines(view.container)).toHaveLength(4)
|
|
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
|
})
|
|
|
|
it('slices head and tail over the cap and expands on click', () => {
|
|
const view = render(<TerminalBlock command="ls" output={body(10)} maxLines={4} />)
|
|
// maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
|
|
expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10'])
|
|
const toggle = view.getByRole('button', { name: '展开其余 6 行输出' })
|
|
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
|
expect(toggle.textContent).toBe('… 其余 6 行')
|
|
|
|
fireEvent.click(toggle)
|
|
expect(outputLines(view.container)).toHaveLength(10)
|
|
const collapse = view.getByRole('button', { name: '收起输出' })
|
|
expect(collapse.getAttribute('aria-expanded')).toBe('true')
|
|
expect(collapse.textContent).toBe('收起')
|
|
|
|
fireEvent.click(collapse)
|
|
expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10'])
|
|
})
|
|
|
|
it('renders the head slice alone when the cap leaves no tail', () => {
|
|
const view = render(<TerminalBlock command="ls" output={body(5)} maxLines={1} />)
|
|
expect(outputLines(view.container)).toEqual(['line 1'])
|
|
expect(view.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
|
|
})
|
|
|
|
it('caps at the documented default when maxLines is absent', () => {
|
|
const view = render(<TerminalBlock command="ls" output={body(DEFAULT_TERMINAL_MAX_LINES + 1)} />)
|
|
expect(outputLines(view.container)).toHaveLength(DEFAULT_TERMINAL_MAX_LINES)
|
|
expect(view.getByRole('button', { name: '展开其余 1 行输出' })).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('TerminalBlock copy', () => {
|
|
it('copies the raw output, never the prompt line or the pill', async () => {
|
|
vi.useFakeTimers()
|
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
|
const output = `${ESC}[31mbad${ESC}[39m\n`
|
|
render(<TerminalBlock command="make" cwd="/Users/me/app" output={output} exitCode={2} />)
|
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
|
// Escape codes, the newline terminator, and nothing of the chrome around them.
|
|
expect(writeText).toHaveBeenCalledWith(output)
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
})
|
|
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
|
// While the ok label is showing, further clicks are no-ops.
|
|
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
|
expect(writeText).toHaveBeenCalledTimes(1)
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
|
})
|
|
|
|
it('copies the whole output while the height cap hides its middle', async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
|
const output = `${body(10)}\n`
|
|
render(<TerminalBlock command="ls" output={output} maxLines={4} exitCode={0} />)
|
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
|
expect(writeText).toHaveBeenCalledWith(output)
|
|
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
|
|
})
|
|
|
|
it('does not claim success when the host refuses the write', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
|
})
|
|
render(<TerminalBlock command="ls" output="a" />)
|
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
})
|
|
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
|
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('writeClipboard', () => {
|
|
it('reports true after the async Clipboard API accepts the exact text', async () => {
|
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
|
await expect(writeClipboard('payload')).resolves.toBe(true)
|
|
expect(writeText).toHaveBeenCalledWith('payload')
|
|
})
|
|
|
|
it('reports false when the Clipboard API rejects', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
|
})
|
|
await expect(writeClipboard('payload')).resolves.toBe(false)
|
|
})
|
|
|
|
it('selects a detached textarea for the execCommand fallback and removes it after', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
|
|
let selected: string | undefined
|
|
const exec = vi.fn(() => {
|
|
selected = document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value
|
|
return true
|
|
})
|
|
Object.defineProperty(document, 'execCommand', { configurable: true, value: exec })
|
|
await expect(writeClipboard('payload')).resolves.toBe(true)
|
|
expect(exec).toHaveBeenCalledWith('copy')
|
|
expect(selected).toBe('payload')
|
|
expect(document.querySelector('textarea')).toBeNull()
|
|
})
|
|
|
|
it('reports execCommand\'s own refusal verbatim', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
|
|
Object.defineProperty(document, 'execCommand', { configurable: true, value: vi.fn(() => false) })
|
|
await expect(writeClipboard('payload')).resolves.toBe(false)
|
|
})
|
|
|
|
it('reports false and still removes the textarea when execCommand throws', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
|
|
Object.defineProperty(document, 'execCommand', {
|
|
configurable: true,
|
|
value: () => {
|
|
throw new Error('denied')
|
|
},
|
|
})
|
|
await expect(writeClipboard('payload')).resolves.toBe(false)
|
|
expect(document.querySelector('textarea')).toBeNull()
|
|
})
|
|
|
|
it('reports false on a host with neither clipboard path', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
|
|
Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
|
|
await expect(writeClipboard('payload')).resolves.toBe(false)
|
|
})
|
|
|
|
it('reports false when navigator.clipboard exists without writeText', async () => {
|
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {} })
|
|
Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
|
|
await expect(writeClipboard('payload')).resolves.toBe(false)
|
|
})
|
|
})
|