skill-row.client.spec.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. // @vitest-environment jsdom
  2. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
  5. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  6. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  7. import { SkillRow } from '../src/client/SkillRow.tsx'
  8. import { zh } from '../src/client/locales.ts'
  9. type SkillRowProps = Parameters<typeof SkillRow>[0]
  10. const t: SkillRowProps['t'] = makeTranslate(zh, commonZh)
  11. afterEach(cleanup)
  12. function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
  13. return {
  14. kind: 'tool-result',
  15. seq: 3,
  16. time: 3_000,
  17. callId: 'call-skill',
  18. call: { name: 'skill', argsRaw: '{"name":"dsh-manage-issues"}' },
  19. callTime: 2_000,
  20. content: [{ type: 'text', text: 'Follow the issue workflow.\nKeep project fields in sync.' }],
  21. isError: false,
  22. subCalls: [],
  23. ...over,
  24. }
  25. }
  26. function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
  27. return {
  28. callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [],
  29. }
  30. }
  31. function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowProps {
  32. return {
  33. callId: block.callId,
  34. toolName: 'skill',
  35. block,
  36. openFile: vi.fn(),
  37. inspect,
  38. t,
  39. } as unknown as SkillRowProps
  40. }
  41. describe('SkillRow', () => {
  42. it('renders a compact Bash-shaped summary and discloses the exact instructions', () => {
  43. const inspect = vi.fn()
  44. const view = render(<SkillRow {...props(settled(), inspect)} />)
  45. const row = screen.getByRole('button', { name: 'Skilldsh-manage-issues' })
  46. expect(row.getAttribute('aria-expanded')).toBe('false')
  47. expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('ok')
  48. expect(view.container.querySelector('[data-tool="skill"] svg')?.getAttribute('width')).toBe('14')
  49. expect(screen.queryByLabelText('说明')).toBeNull()
  50. fireEvent.click(row)
  51. expect(row.getAttribute('aria-expanded')).toBe('true')
  52. const card = screen.getByLabelText('说明')
  53. expect(card.textContent).toBe('说明Follow the issue workflow.\nKeep project fields in sync.')
  54. expect(view.container.textContent).not.toContain('{"name":"dsh-manage-issues"}')
  55. fireEvent.click(screen.getByRole('button', { name: '查看' }))
  56. expect(inspect).toHaveBeenCalledTimes(1)
  57. fireEvent.click(row)
  58. expect(row.getAttribute('aria-expanded')).toBe('false')
  59. })
  60. it('supports Enter and Space while ignoring unrelated keys', () => {
  61. render(<SkillRow {...props(settled())} />)
  62. const row = screen.getByRole('button')
  63. fireEvent.keyDown(row, { key: 'Escape' })
  64. expect(row.getAttribute('aria-expanded')).toBe('false')
  65. fireEvent.keyDown(row, { key: 'Enter' })
  66. expect(row.getAttribute('aria-expanded')).toBe('true')
  67. fireEvent.keyDown(row, { key: ' ' })
  68. expect(row.getAttribute('aria-expanded')).toBe('false')
  69. })
  70. it('keeps a running call compact and announces its state', () => {
  71. const view = render(<SkillRow {...props(running())} />)
  72. const row = view.container.querySelector('[data-tool="skill"] > div')!
  73. expect(row.getAttribute('role')).toBeNull()
  74. expect(view.container.textContent).toContain('正在加载 skill')
  75. expect(view.container.textContent).toContain('dsh-manage-issues')
  76. expect(view.container.querySelector('svg [fill="currentColor"]')).not.toBeNull()
  77. })
  78. it('uses the first failure line in the summary and exposes the full error', () => {
  79. const view = render(<SkillRow {...props(settled({
  80. content: [{ type: 'text', text: 'SkillError: missing resource\nCheck SKILL.md.' }],
  81. isError: true,
  82. error: { name: 'SkillError', code: 'missing' },
  83. }))} />)
  84. const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' })
  85. expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error')
  86. expect(row.textContent).not.toContain('Check SKILL.md.')
  87. fireEvent.click(row)
  88. const output = view.container.querySelector('pre')!
  89. expect(output.textContent).toBe('SkillError: missing resource\nCheck SKILL.md.')
  90. expect(output.getAttribute('data-error')).toBe('true')
  91. })
  92. it('renders stopped, structured, and structured-error durable outcomes', () => {
  93. const stoppedView = render(<SkillRow {...props(settled({
  94. error: { name: 'InterruptedError', code: 'interrupted' },
  95. }))} />)
  96. expect(stoppedView.container.textContent).toContain('skill 加载已中止')
  97. expect(stoppedView.container.querySelector('[data-state="warning"]')).not.toBeNull()
  98. cleanup()
  99. const structuredView = render(<SkillRow {...props(settled({
  100. content: [{ type: 'reasoning', text: 'structured instruction note' }],
  101. }))} />)
  102. fireEvent.click(screen.getByRole('button'))
  103. expect(structuredView.container.textContent).toContain('"type": "reasoning"')
  104. cleanup()
  105. render(<SkillRow {...props(settled({
  106. content: [],
  107. isError: true,
  108. error: { name: 'SkillError', code: 'missing' },
  109. }))} />)
  110. const errorRow = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing' })
  111. fireEvent.click(errorRow)
  112. expect(screen.getAllByText('SkillError: missing')).toHaveLength(2)
  113. })
  114. it('falls back to durable args or call id when the skill name is unavailable', () => {
  115. const invalid = render(<SkillRow {...props(running('{"name":\n'))} />)
  116. expect(invalid.container.textContent).toContain('{"name":')
  117. cleanup()
  118. const scalar = render(<SkillRow {...props(running('"raw-name"'))} />)
  119. expect(scalar.container.textContent).toContain('"raw-name"')
  120. cleanup()
  121. const emptyName = render(<SkillRow {...props(running('{"name":""}'))} />)
  122. expect(emptyName.container.textContent).toContain('{"name":""}')
  123. cleanup()
  124. const blank = render(<SkillRow {...props(settled({ call: null, content: [] }))} />)
  125. expect(blank.container.textContent).toContain('call-skill')
  126. expect(blank.container.querySelector('[role="button"]')).toBeNull()
  127. expect(blank.container.textContent).not.toContain('正在加载 skill')
  128. })
  129. })