feat(web): render bash tool output as a terminal card
The bash tool already declares the `card: 'terminal'` render intent for both its call and its result, and host/connection/runtime already deliver it to the browser as callView/resultView. The Web client ignored it: rows derived from raw args, and the details panel flattened every tool's content into one soft-wrapping `<pre>`. Column-aligned output folded into a paragraph and a long listing stretched the panel without bound. `TerminalBlock` (ui-primitives) renders a command as a terminal surface: a shortened-cwd prompt line, output at `white-space: pre` in a horizontally scrolling box, a head/tail height cap with an expand control, an exit-code/signal status pill, and a copy control for the raw output. ANSI SGR runs are parsed with `anser` and resolved onto `--dsw-*` theme tokens, with literal rgb kept for values the design system has no token for. Geometry and fonts mirror CodeBlock; the clipboard write both need moved into a package-internal `clipboard.ts`. Both Web render sites for a bash call consume the intent through one derivation (`terminal-card-model.ts`), so they cannot disagree about a command, its cwd, or its exit status: the keyed BashRow carries the card resident below its summary row, and the render-site fallback row keeps it behind its existing expand control. Rows cap at 8 lines against the panel's 16. Inline output in the chat row reverses this package's stated no-inline-output convention, on the owner's explicit decision; the Agent Note records the reversal and its bound. Tests: TerminalBlock/ansi/clipboard unit specs, ui-conversation wiring specs at every render site, a built-client-graph snapshot covering both chat-row shapes, and a real-browser e2e asserting the no-wrap layout and the page's own Clipboard API.
This commit is contained in:
188
packages/client/ui-primitives/tests/ansi.spec.ts
Normal file
188
packages/client/ui-primitives/tests/ansi.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// parseAnsiLines, the ANSI model behind TerminalBlock: anser's SGR runs
|
||||
// resolved into inline styles and folded into per-line span arrays, with every
|
||||
// escape and control character that carries no color removed first. The DOM
|
||||
// side of the same model (which runs get a span wrapper) is in
|
||||
// terminal-block.spec.tsx.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseAnsiLines } from '../src/ansi.ts'
|
||||
|
||||
const ESC = '\u001b'
|
||||
|
||||
/** Paint `text` with the SGR `codes`, then reset. */
|
||||
function sgr(codes: string, text: string): string {
|
||||
return `${ESC}[${codes}m${text}${ESC}[0m`
|
||||
}
|
||||
|
||||
/** The single span of a single-line, single-run parse. */
|
||||
function onlySpan(text: string) {
|
||||
const lines = parseAnsiLines(text)
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toHaveLength(1)
|
||||
return lines[0]![0]!
|
||||
}
|
||||
|
||||
describe('parseAnsiLines: text without SGR state', () => {
|
||||
it('leaves plain text as one unstyled span', () => {
|
||||
expect(parseAnsiLines('hello')).toEqual([[{ text: 'hello', style: undefined }]])
|
||||
})
|
||||
|
||||
it('returns exactly one empty line for empty input', () => {
|
||||
expect(parseAnsiLines('')).toEqual([[]])
|
||||
})
|
||||
|
||||
it('splits a multi-line run and drops the empty line between two blocks', () => {
|
||||
expect(parseAnsiLines('a\n\nb')).toEqual([
|
||||
[{ text: 'a', style: undefined }],
|
||||
[],
|
||||
[{ text: 'b', style: undefined }],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps tabs, which the terminal surface needs for column layout', () => {
|
||||
expect(onlySpan('a\tb')).toEqual({ text: 'a\tb', style: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: basic colors mapped onto theme tokens', () => {
|
||||
it.each<[string, string, string]>([
|
||||
['30', 'black', 'var(--dsw-alias-label-primary)'],
|
||||
['37', 'white', 'var(--dsw-alias-label-primary)'],
|
||||
['90', 'bright black', 'var(--dsw-alias-label-tertiary)'],
|
||||
['31', 'red', 'var(--dsw-alias-state-error-primary)'],
|
||||
['91', 'bright red', 'var(--dsw-alias-state-error-secondary)'],
|
||||
['32', 'green', 'var(--dsw-alias-state-success-primary)'],
|
||||
['92', 'bright green', 'var(--dsw-alias-state-success-secondary)'],
|
||||
['33', 'yellow', 'var(--dsw-alias-state-warn-primary)'],
|
||||
['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'],
|
||||
['34', 'blue', 'var(--dsw-alias-state-business-primary)'],
|
||||
['94', 'bright blue', 'var(--dsw-static-blue-400)'],
|
||||
])('SGR %s (%s) resolves to %s', (code, _name, token) => {
|
||||
expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: colors with no token equivalent', () => {
|
||||
it.each<[string, string, string]>([
|
||||
['35', 'magenta', 'rgb(187, 0, 187)'],
|
||||
['36', 'cyan', 'rgb(0, 187, 187)'],
|
||||
['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'],
|
||||
['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'],
|
||||
])('SGR %s (%s) falls through to %s', (code, _name, literal) => {
|
||||
expect(onlySpan(sgr(code, 'x')).style).toEqual({ color: literal })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: backgrounds', () => {
|
||||
it('sets backgroundColor for a background-only run', () => {
|
||||
expect(onlySpan(sgr('44', 'x')).style).toEqual({ backgroundColor: 'rgb(0, 0, 187)' })
|
||||
})
|
||||
|
||||
it('keeps the literal foreground when the run paints its own background', () => {
|
||||
expect(onlySpan(sgr('41;37', 'x')).style).toEqual({
|
||||
backgroundColor: 'rgb(187, 0, 0)',
|
||||
color: 'rgb(255,255,255)',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders reverse video as the swapped pair anser reports', () => {
|
||||
expect(onlySpan(sgr('31;7', 'x')).style).toEqual({
|
||||
backgroundColor: 'rgb(187, 0, 0)',
|
||||
color: 'rgb(0, 0, 0)',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: decorations', () => {
|
||||
it.each<[string, string, Record<string, unknown>]>([
|
||||
['1', 'bold', { fontWeight: 700 }],
|
||||
['2', 'dim', { opacity: 0.7 }],
|
||||
['3', 'italic', { fontStyle: 'italic' }],
|
||||
['4', 'underline', { textDecoration: 'underline' }],
|
||||
['9', 'strikethrough', { textDecoration: 'line-through' }],
|
||||
['8', 'hidden', { visibility: 'hidden' }],
|
||||
])('SGR %s (%s) resolves to %o', (code, _name, style) => {
|
||||
expect(onlySpan(sgr(code, 'x')).style).toEqual(style)
|
||||
})
|
||||
|
||||
it('lets the later textDecoration win when a run declares underline and strikethrough', () => {
|
||||
expect(onlySpan(sgr('4;9', 'x')).style).toEqual({ textDecoration: 'line-through' })
|
||||
expect(onlySpan(sgr('9;4', 'x')).style).toEqual({ textDecoration: 'underline' })
|
||||
})
|
||||
|
||||
it('combines a color with several decorations in one style', () => {
|
||||
expect(onlySpan(sgr('1;3;31', 'x')).style).toEqual({
|
||||
color: 'var(--dsw-alias-state-error-primary)',
|
||||
fontWeight: 700,
|
||||
fontStyle: 'italic',
|
||||
})
|
||||
})
|
||||
|
||||
it('reproduces no animation for blink, leaving the run unstyled', () => {
|
||||
expect(onlySpan(sgr('5', 'x'))).toEqual({ text: 'x', style: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: sequences that carry no color', () => {
|
||||
it('removes an OSC string with its BEL terminator', () => {
|
||||
expect(onlySpan(`a${ESC}]0;window title\u0007b`)).toEqual({ text: 'ab', style: undefined })
|
||||
})
|
||||
|
||||
it('removes an OSC string terminated by ST', () => {
|
||||
expect(onlySpan(`a${ESC}]8;;https://example.com${ESC}\\b`)).toEqual({ text: 'ab', style: undefined })
|
||||
})
|
||||
|
||||
it('removes non-CSI escapes such as charset selection and reset', () => {
|
||||
expect(onlySpan(`x${ESC}(By${ESC}cz`)).toEqual({ text: 'xyz', style: undefined })
|
||||
})
|
||||
|
||||
it('removes inert C0 controls', () => {
|
||||
expect(onlySpan('\u0000ab\u001fc\u007f')).toEqual({ text: 'abc', style: undefined })
|
||||
})
|
||||
|
||||
it('keeps CSI sequences that only move the cursor out of the text', () => {
|
||||
expect(onlySpan(`${ESC}[2K${ESC}[1Adone`)).toEqual({ text: 'done', style: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: carriage returns', () => {
|
||||
it('keeps only the last redraw of a line', () => {
|
||||
expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined })
|
||||
})
|
||||
|
||||
it('drops the SGR codes that preceded a discarded redraw', () => {
|
||||
expect(onlySpan(`${ESC}[31mgone\rkept`)).toEqual({ text: 'kept', style: undefined })
|
||||
})
|
||||
|
||||
it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => {
|
||||
expect(parseAnsiLines('a\r\r\nb\r\n')).toEqual([
|
||||
[{ text: 'a', style: undefined }],
|
||||
[{ text: 'b', style: undefined }],
|
||||
[],
|
||||
])
|
||||
})
|
||||
|
||||
it('applies the redraw per line, not across the whole text', () => {
|
||||
expect(parseAnsiLines('one\rtwo\nthree')).toEqual([
|
||||
[{ text: 'two', style: undefined }],
|
||||
[{ text: 'three', style: undefined }],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnsiLines: runs spanning lines', () => {
|
||||
it('carries one run\'s style onto every line it covers', () => {
|
||||
expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
|
||||
[{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
||||
[{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps several runs of one line in order', () => {
|
||||
expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[
|
||||
{ text: 'plain', style: undefined },
|
||||
{ text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
||||
{ text: 'tail', style: undefined },
|
||||
]])
|
||||
})
|
||||
})
|
||||
315
packages/client/ui-primitives/tests/terminal-block.spec.tsx
Normal file
315
packages/client/ui-primitives/tests/terminal-block.spec.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
// @vitest-environment jsdom
|
||||
// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled
|
||||
// arms, 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 ?? '')
|
||||
}
|
||||
|
||||
/** `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`} />)
|
||||
const span = view.container.querySelector('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 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user