command-image-envelope.expected.e2e.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. // @vitest-environment jsdom
  2. // The command attachment envelope over the BUILT client graph (real
  3. // bundles via AppWebEntry, keyless fixture Connection RPC): an enter
  4. // submission carrying composer attachments resolves only through a command whose
  5. // descriptor declares `input.attachments`. A non-declaring command refuses with
  6. // one composer error banner and everything retained; a declaring command
  7. // consumes the images — serialized through the real draft-image chain into
  8. // the commands/execute payload — and clears the composer on success, including
  9. // when the image is the whole `/plan` task.
  10. import { fireEvent, screen, waitFor } from '@testing-library/react'
  11. import { expect, it } from 'vitest'
  12. import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
  13. installAssembledBootEnv()
  14. /** Open a fresh fixture session and return its composer surface. */
  15. async function freshComposer(): Promise<HTMLElement> {
  16. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
  17. const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
  18. if (start === null) throw new Error('fixture Workspace new-session action missing')
  19. fireEvent.click(start)
  20. return await waitFor(() => {
  21. const surface = document.querySelector<HTMLElement>(
  22. '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
  23. )
  24. if (surface === null) throw new Error('composer surface missing')
  25. return surface
  26. }, { timeout: 10_000 })
  27. }
  28. /** Type through the clipboard: jsdom carries no editable beforeinput; the
  29. * paste command inserts at the caret, committing a microtask later. */
  30. async function pasteText(surface: HTMLElement, text: string): Promise<void> {
  31. fireEvent.paste(surface, {
  32. clipboardData: { items: [], getData: () => text },
  33. })
  34. await waitFor(() => { expect(surface.textContent).toContain(text) })
  35. }
  36. /** Paste one tiny PNG into the composer and wait for its rail thumbnail. */
  37. async function pasteImage(textarea: HTMLElement, name: string): Promise<void> {
  38. const image = new File([new Uint8Array([137, 80, 78, 71])], name, { type: 'image/png' })
  39. fireEvent.paste(textarea, {
  40. clipboardData: {
  41. items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
  42. getData: () => '',
  43. },
  44. })
  45. await waitFor(() => {
  46. const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
  47. if (rail === null) throw new Error('attachment rail missing')
  48. expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name)
  49. }, { timeout: 5_000 })
  50. }
  51. it('refuses an image-carrying submit to a non-declaring command and keeps draft and images', async () => {
  52. mountAssembledApp()
  53. const textarea = await freshComposer()
  54. await pasteImage(textarea, 'ref.png')
  55. // /echo is a leadingInput fixture command without `input.attachments`.
  56. await pasteText(textarea, '/echo hello')
  57. fireEvent.keyDown(textarea, { key: 'Enter' })
  58. // The refusal rides the same transient error banner as other composer
  59. // failures; session activity remains on its separate status live region.
  60. const notice = await waitFor(() => {
  61. const el = [...document.querySelectorAll('[role="alert"]')]
  62. .find(candidate => candidate.textContent?.includes('attachments') ?? false)
  63. if (el === undefined) throw new Error('composer refusal banner missing')
  64. return el
  65. }, { timeout: 5_000 })
  66. expect(notice.textContent).toBe('/echo does not accept attachments; remove them first')
  67. expect([...document.querySelectorAll('[role="status"]')]
  68. .some(candidate => candidate.textContent?.includes('attachments') ?? false)).toBe(false)
  69. // The whole envelope is retained: draft text and the rail thumbnail.
  70. await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') })
  71. const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]')
  72. expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png'])
  73. })
  74. it('consumes images through a declaring command and clears the composer on success', async () => {
  75. mountAssembledApp()
  76. const textarea = await freshComposer()
  77. await pasteImage(textarea, 'goal-ref.png')
  78. // /goal declares `input.attachments` in the fixture catalog; the claim submit
  79. // serializes the pasted bytes and the fixture executor admits them.
  80. await pasteText(textarea, '/goal rebuild the cathedral')
  81. fireEvent.keyDown(textarea, { key: 'Enter' })
  82. await waitFor(() => {
  83. expect(textarea.textContent).toBe('')
  84. expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
  85. }, { timeout: 5_000 })
  86. })
  87. it('submits a bare /plan with an image as an image-only plan request', async () => {
  88. mountAssembledApp()
  89. const textarea = await freshComposer()
  90. await pasteImage(textarea, 'plan-task.png')
  91. // Trailing separator: a bare '/plan' leaves the caret on the token, where
  92. // the re-track opens the menu and Enter would pick instead of submit.
  93. await pasteText(textarea, '/plan ')
  94. fireEvent.keyDown(textarea, { key: 'Enter' })
  95. await waitFor(() => {
  96. expect(textarea.textContent).toBe('')
  97. expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
  98. }, { timeout: 5_000 })
  99. expect([...document.querySelectorAll('[role="alert"]')]
  100. .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false)
  101. })