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

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. // @vitest-environment jsdom
  2. // The command image-attachment envelope over the BUILT client graph (real
  3. // bundles via AppWebEntry, keyless FixtureApiClient transport): an enter
  4. // submission carrying composer images resolves only through a command whose
  5. // descriptor declares `input.images`. 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 textarea. */
  15. async function freshComposer(): Promise<HTMLTextAreaElement> {
  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 screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) as HTMLTextAreaElement
  21. }
  22. /** Paste one tiny PNG into the composer and wait for its rail thumbnail. */
  23. async function pasteImage(textarea: HTMLTextAreaElement, name: string): Promise<void> {
  24. const image = new File([new Uint8Array([137, 80, 78, 71])], name, { type: 'image/png' })
  25. fireEvent.paste(textarea, {
  26. clipboardData: {
  27. items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
  28. getData: () => '',
  29. },
  30. })
  31. await waitFor(() => {
  32. const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
  33. if (rail === null) throw new Error('attachment rail missing')
  34. expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name)
  35. }, { timeout: 5_000 })
  36. }
  37. it('refuses an image-carrying submit to a non-declaring command and keeps draft and images', async () => {
  38. mountAssembledApp()
  39. const textarea = await freshComposer()
  40. await pasteImage(textarea, 'ref.png')
  41. // /echo is a leadingInput fixture command without `input.images`.
  42. fireEvent.change(textarea, { target: { value: '/echo hello' } })
  43. fireEvent.keyDown(textarea, { key: 'Enter' })
  44. // The refusal rides the same transient error banner as other composer
  45. // failures; session activity remains on its separate status live region.
  46. const notice = await waitFor(() => {
  47. const el = [...document.querySelectorAll('[role="alert"]')]
  48. .find(candidate => candidate.textContent?.includes('image attachments') ?? false)
  49. if (el === undefined) throw new Error('composer refusal banner missing')
  50. return el
  51. }, { timeout: 5_000 })
  52. expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first')
  53. expect([...document.querySelectorAll('[role="status"]')]
  54. .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false)
  55. // The whole envelope is retained: draft text and the rail thumbnail.
  56. expect(textarea.value).toBe('/echo hello')
  57. const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
  58. expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png'])
  59. })
  60. it('consumes images through a declaring command and clears the composer on success', async () => {
  61. mountAssembledApp()
  62. const textarea = await freshComposer()
  63. await pasteImage(textarea, 'goal-ref.png')
  64. // /goal declares `input.images` in the fixture catalog; the claim submit
  65. // serializes the pasted bytes and the fixture executor admits them.
  66. fireEvent.change(textarea, { target: { value: '/goal rebuild the cathedral' } })
  67. fireEvent.keyDown(textarea, { key: 'Enter' })
  68. await waitFor(() => {
  69. expect(textarea.value).toBe('')
  70. expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
  71. }, { timeout: 5_000 })
  72. })
  73. it('submits a bare /plan with an image as an image-only plan request', async () => {
  74. mountAssembledApp()
  75. const textarea = await freshComposer()
  76. await pasteImage(textarea, 'plan-task.png')
  77. fireEvent.change(textarea, { target: { value: '/plan' } })
  78. fireEvent.keyDown(textarea, { key: 'Enter' })
  79. await waitFor(() => {
  80. expect(textarea.value).toBe('')
  81. expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
  82. }, { timeout: 5_000 })
  83. expect([...document.querySelectorAll('[role="alert"]')]
  84. .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false)
  85. })