command-image-envelope.snapshot.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  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 notice and everything retained; a declaring command consumes
  7. // the images — serialized through the real draft-image chain into the
  8. // commands/execute payload — and clears the composer on success.
  9. import { fireEvent, screen, waitFor } from '@testing-library/react'
  10. import { expect, it } from 'vitest'
  11. import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
  12. installAssembledBootEnv()
  13. /** Open a fresh fixture session and return its composer textarea. */
  14. async function freshComposer(): Promise<HTMLTextAreaElement> {
  15. const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
  16. const start = tree.querySelector<HTMLButtonElement>('button[aria-label="New session in fixture"]')
  17. if (start === null) throw new Error('fixture Workspace new-session action missing')
  18. fireEvent.click(start)
  19. return await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) as HTMLTextAreaElement
  20. }
  21. /** Paste one tiny PNG into the composer and wait for its rail thumbnail. */
  22. async function pasteImage(textarea: HTMLTextAreaElement, name: string): Promise<void> {
  23. const image = new File([new Uint8Array([137, 80, 78, 71])], name, { type: 'image/png' })
  24. fireEvent.paste(textarea, {
  25. clipboardData: {
  26. items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }],
  27. getData: () => '',
  28. },
  29. })
  30. await waitFor(() => {
  31. const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
  32. if (rail === null) throw new Error('attachment rail missing')
  33. expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name)
  34. }, { timeout: 5_000 })
  35. }
  36. it('refuses an image-carrying submit to a non-declaring command and keeps draft and images', async () => {
  37. mountAssembledApp()
  38. const textarea = await freshComposer()
  39. await pasteImage(textarea, 'ref.png')
  40. // /echo is a leadingInput fixture command without `input.images`.
  41. fireEvent.change(textarea, { target: { value: '/echo hello' } })
  42. fireEvent.keyDown(textarea, { key: 'Enter' })
  43. // Several live-region elements exist (session activity among them); the
  44. // refusal is the status whose text is the localized notice.
  45. const notice = await waitFor(() => {
  46. const el = [...document.querySelectorAll('[role="status"]')]
  47. .find(candidate => candidate.textContent?.includes('image attachments') ?? false)
  48. if (el === undefined) throw new Error('composer refusal notice missing')
  49. return el
  50. }, { timeout: 5_000 })
  51. expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first')
  52. // The whole envelope is retained: draft text and the rail thumbnail.
  53. expect(textarea.value).toBe('/echo hello')
  54. const rail = document.querySelector('[role="group"][aria-label="Pending images"]')
  55. expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png'])
  56. })
  57. it('consumes images through a declaring command and clears the composer on success', async () => {
  58. mountAssembledApp()
  59. const textarea = await freshComposer()
  60. await pasteImage(textarea, 'goal-ref.png')
  61. // /goal declares `input.images` in the fixture catalog; the claim submit
  62. // serializes the pasted bytes and the fixture executor admits them.
  63. fireEvent.change(textarea, { target: { value: '/goal rebuild the cathedral' } })
  64. fireEvent.keyDown(textarea, { key: 'Enter' })
  65. await waitFor(() => {
  66. expect(textarea.value).toBe('')
  67. expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
  68. }, { timeout: 5_000 })
  69. })