// @vitest-environment jsdom
/**
* The shared comment box (COMM-2): what its page says it owns — the keys,
* sending held or shown at once, a refusal putting the words back, the edit
* mode, drafts, files and the read-only line.
*/
import { act, Suspense, use } from 'react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { Extension, type Editor } from '@tiptap/core'
import { Fragment, Slice } from '@tiptap/pm/model'
import type { JSONContent } from '@tiptap/react'
import { CommentBox, commentBoxRows, keepCommentBoxWarm, type BoxUnder, type CommentBoxDraft, type CommentBoxEdit, type CommentBoxProps, type SendAnswer, type UploadAnswer } from './CommentBox'
import type { MentionPerson } from './ComposerTriggers'
const PEOPLE: MentionPerson[] = [{ id: 'one', name: 'Person one', pubkey: 'b'.repeat(64) }]
const INSERT = [commentBoxRows.file, commentBoxRows.mention, commentBoxRows.urgent, commentBoxRows.reference]
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.getClientRects = () => [] as unknown as DOMRectList
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
Range.prototype.getBoundingClientRect = () => new DOMRect()
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
// Only the clock: a list closing gives Enter 100ms of grace (suggestionPopup), which must not reach the next test.
vi.useFakeTimers({ toFake: ['Date'] })
})
afterEach(() => {
cleanup()
vi.setSystemTime(Date.now() + 1000)
})
/** A promise the test answers. */
function later() {
let answer!: (value: T) => void
const promise = new Promise((resolve) => {
answer = resolve
})
return { promise, answer }
}
const editorOf = (): Editor => (document.querySelector('.ProseMirror') as unknown as { editor: Editor }).editor
const textOf = (body: JSONContent): string => (body.text ?? '') + (body.content ?? []).map(textOf).join('')
async function type(text: string) {
await act(async () => {
editorOf().chain().focus('end').insertContent(text).run()
})
}
async function press(key: string, init: KeyboardEventInit = {}) {
await act(async () => {
const editor = editorOf()
editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, new KeyboardEvent('keydown', { key, ...init })))
})
}
/** Lets an answer reach the box: it passes through a few promises on the way. */
async function settle() {
await act(async () => {
for (let i = 0; i < 5; i++) await Promise.resolve()
})
}
function Box(props: Partial) {
return ({ accepted: true })} {...props} />
}
describe('sending, held until the relay answers', () => {
it('Enter sends the words and the box holds them, shut, until it is accepted; then it is empty', async () => {
const answer = later()
const send = vi.fn(() => answer.promise)
render()
await type('Hello there')
await press('Enter')
expect(send).toHaveBeenCalledTimes(1)
expect(textOf(send.mock.calls[0][0])).toBe('Hello there')
expect(screen.getByText('Sending…')).toBeTruthy()
expect(editorOf().isEditable).toBe(false)
expect(editorOf().getText()).toBe('Hello there')
answer.answer({ accepted: true })
await settle()
expect(editorOf().getText()).toBe('')
expect(screen.queryByText('Sending…')).toBeNull()
})
it('a refusal keeps the words in the box and says why under it', async () => {
render( ({ accepted: false, reason: 'rate-limited' })} />)
await type('Hello there')
await press('Enter')
await settle()
expect(editorOf().getText()).toBe('Hello there')
expect(screen.getByText('Not sent: rate-limited. Your text is back in the box.')).toBeTruthy()
expect(editorOf().isEditable).toBe(true)
})
it('Ctrl/Cmd+Enter sends as Enter does; Shift+Enter does not send', async () => {
const send = vi.fn(async () => ({ accepted: true }))
render()
await type('One')
await press('Enter', { shiftKey: true })
expect(send).not.toHaveBeenCalled()
await press('Enter', { ctrlKey: true })
expect(send).toHaveBeenCalledTimes(1)
})
it('an empty box sends nothing', async () => {
const send = vi.fn(async () => ({ accepted: true }))
render()
await type(' ')
await press('Enter')
expect(send).not.toHaveBeenCalled()
})
})
describe('sending, shown at once', () => {
it('the box empties at once; a refusal puts the words back, says why, and tells the app', async () => {
const answer = later()
const onSent = vi.fn()
const onRefused = vi.fn()
render( answer.promise} onSent={onSent} onRefused={onRefused} />)
await type('Hello there')
await press('Enter')
expect(editorOf().getText()).toBe('')
expect(onSent).toHaveBeenCalledTimes(1)
answer.answer({ accepted: false, reason: 'rate-limited' })
await settle()
expect(editorOf().getText()).toBe('Hello there')
expect(onRefused).toHaveBeenCalledTimes(1)
expect(screen.getByText('Not sent: rate-limited. Your text is back in the box.')).toBeTruthy()
})
})
describe('editing', () => {
const body: JSONContent = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Said before' }] }] }
it('opens with what it says; Enter saves it, and Escape cancels', async () => {
const onSave = vi.fn(async () => ({ accepted: true }))
const onCancel = vi.fn()
render()
expect(editorOf().getText()).toBe('Said before')
expect(screen.getByText(/to cancel/).textContent).toBe('Esc to cancel · Enter to save')
await type(', and now')
await press('Enter')
expect(textOf(onSave.mock.calls[0][0])).toBe('Said before, and now')
await press('Escape')
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('!@ opens no urgent list in an edit, and @ still does', async () => {
render( ({ accepted: true }), onCancel: () => {} }} />)
await type(' !@')
expect(screen.queryByRole('listbox', { name: 'Urgent mention' })).toBeNull()
await type(' @')
expect(screen.getByRole('listbox', { name: 'People' })).toBeTruthy()
await press('Escape')
})
it('Escape right after closing a list cancels the edit', async () => {
const onCancel = vi.fn()
render( ({ accepted: true }), onCancel }} />)
await type(' @')
expect(screen.getByRole('listbox')).toBeTruthy()
await press('Escape')
expect(onCancel).not.toHaveBeenCalled()
await press('Escape')
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('with words selected, the first Escape closes the selection strip and keeps the edit; the next cancels', async () => {
const onCancel = vi.fn()
render( ({ accepted: true }), onCancel }} />)
// Selected and pressed in one act, so the box's opening focus (a frame later) cannot collapse the selection in between.
await act(async () => {
const editor = editorOf()
editor.commands.setTextSelection({ from: 1, to: 5 })
editor.view.someProp('handleKeyDown', (handle) => handle(editor.view, new KeyboardEvent('keydown', { key: 'Escape' })))
})
expect(onCancel).not.toHaveBeenCalled()
expect(editorOf().state.selection.empty).toBe(true)
await press('Escape')
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('a refused save keeps the words and says it was not saved', async () => {
render( ({ accepted: false, reason: 'gone' }), onCancel: () => {} }} />)
await press('Enter')
await settle()
expect(screen.getByText('Not saved: gone. Your changes are still in the box.')).toBeTruthy()
expect(editorOf().getText()).toBe('Said before')
})
it('/ offers no Upload and no Urgent in an edit', async () => {
render( ({ accepted: true, value: 1 })} edit={{ body, onSave: async () => ({ accepted: true }), onCancel: () => {} }} />)
await type(' /')
const rows = within(screen.getByRole('listbox')).getAllByRole('option').map((o) => o.textContent ?? '')
expect(rows.some((r) => r.startsWith('Mention'))).toBe(true)
expect(rows.some((r) => r.startsWith('Upload') || r.startsWith('Urgent'))).toBe(false)
// Closed here: the editor is destroyed on a timer, and a list it closes then would hold the next test's Enter.
await press('Escape')
})
})
describe('drafts', () => {
it('keeps what is written, opens with it again, and forgets it once sent', async () => {
let kept: JSONContent | undefined
const draft: CommentBoxDraft = { read: () => kept, write: (b) => (kept = b), clear: () => (kept = undefined) }
const first = render()
await type('Half written')
first.unmount()
expect(textOf(kept!)).toBe('Half written')
const send = vi.fn(async () => ({ accepted: true }))
render()
expect(editorOf().getText()).toBe('Half written')
await press('Enter')
await settle()
expect(send).toHaveBeenCalledTimes(1)
expect(kept).toBeUndefined()
})
})
describe('files', () => {
it('a picked file uploads, Send waits for it, and it goes out with the words', async () => {
const uploaded = later()
const send = vi.fn(async () => ({ accepted: true }))
render( uploaded.promise} />)
const input = document.querySelector('input[type=file]') as HTMLInputElement
await act(async () => {
fireEvent.change(input, { target: { files: [new File(['x'], 'plan.pdf', { type: 'application/pdf' })] } })
})
expect(screen.getByText('Uploading… Send waits for the file.')).toBeTruthy()
await type('See this')
await press('Enter')
expect(send).not.toHaveBeenCalled()
uploaded.answer({ accepted: true, value: 'the-record' })
await settle()
await press('Enter')
expect(send).toHaveBeenCalledTimes(1)
expect(send.mock.calls[0][1]).toMatchObject([{ name: 'plan.pdf', state: 'ready', value: 'the-record' }])
})
it('a failed upload says why and holds Send and Enter until its ✕ takes it off', async () => {
const send = vi.fn(async () => ({ accepted: true }))
render( ({ accepted: false, reason: 'too large' })} />)
const input = document.querySelector('input[type=file]') as HTMLInputElement
await act(async () => {
fireEvent.change(input, { target: { files: [new File(['x'], 'big.mov', { type: 'video/quicktime' })] } })
})
await settle()
expect(screen.getByText('too large')).toBeTruthy()
expect(screen.queryByText('Uploading… Send waits for the file.')).toBeNull()
await type('See this')
expect(screen.getByText('Remove the file that did not upload to send.')).toBeTruthy()
expect((screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement).disabled).toBe(true)
await press('Enter')
expect(send).not.toHaveBeenCalled()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Remove big.mov' }))
})
expect(screen.queryByText('Remove the file that did not upload to send.')).toBeNull()
await press('Enter')
expect(send).toHaveBeenCalledTimes(1)
expect(textOf(send.mock.calls[0][0])).toBe('See this')
expect(send.mock.calls[0][1]).toEqual([])
})
it('an upload that arrives after the box opened offers Upload in /', async () => {
const { rerender } = render()
rerender( ({ accepted: true, value: 1 })} />)
await type('/')
const rows = within(screen.getByRole('listbox')).getAllByRole('option').map((o) => o.textContent ?? '')
expect(rows.some((r) => r.startsWith('Upload'))).toBe(true)
expect(rows.some((r) => r.startsWith('File'))).toBe(false)
await press('Escape')
})
it('no upload: no paperclip and no Upload row', async () => {
render()
expect(screen.queryByRole('button', { name: 'Attach a file' })).toBeNull()
await type('/')
const rows = within(screen.getByRole('listbox')).getAllByRole('option').map((o) => o.textContent ?? '')
expect(rows.some((r) => r.startsWith('Upload'))).toBe(false)
await press('Escape')
})
})
describe('read-only', () => {
it('a line saying why takes the box’s place', () => {
render()
expect(screen.getByText('Archived. It takes no new comments.')).toBeTruthy()
expect(document.querySelector('.ProseMirror')).toBeNull()
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull()
})
})
describe('under the box: the one card, with ✕, and the live parts', () => {
const ISSUE = 'https://app.example/item/one'
const BLOCK = 'https://app.example/page/two#block'
/** The app's read, by what the text names: one item gets the card; a block link is a part and leaves no card. */
const reader = (known = () => true): BoxUnder => ({
read: (body) => {
const text = textOf(body)
const item = known() && text.includes(ISSUE)
const part = text.includes(BLOCK)
return {
card: item && !part ? { value: 'item-one', view: Card of item one } : undefined,
parts: part ? [{ key: 'page-two', view: Live part of page two }] : [],
names: [...(item ? ['item-one'] : []), ...(part ? ['page-two'] : [])],
}
},
})
const removeCard = () => act(async () => fireEvent.click(screen.getByRole('button', { name: 'Remove card' })))
it('a draft naming one item shows its card under the box, and it is sent with the words', async () => {
const send = vi.fn(async () => ({ accepted: true }))
render()
expect(screen.queryByText('Card of item one')).toBeNull()
await type(`see ${ISSUE}`)
expect(screen.getByText('Card of item one')).toBeTruthy()
await press('Enter')
await settle()
expect(send.mock.calls[0][2]).toEqual({ card: 'item-one' })
expect(screen.queryByText('Card of item one')).toBeNull()
})
it('✕ takes the card off, and the message is sent with none', async () => {
const send = vi.fn(async () => ({ accepted: true }))
render()
await type(`see ${ISSUE}`)
await removeCard()
expect(screen.queryByText('Card of item one')).toBeNull()
await press('Enter')
await settle()
expect(send.mock.calls[0][2]).toEqual({})
})
it('a link taken out and put back gets its card again', async () => {
render()
await type(`see ${ISSUE}`)
await removeCard()
await act(async () => {
editorOf().commands.setContent('see')
})
await type(` ${ISSUE}`)
expect(screen.getByText('Card of item one')).toBeTruthy()
})
it('a card the app learns of later shows when the app says its answers changed', async () => {
let known = false
let listener = () => {}
const under: BoxUnder = {
...reader(() => known),
changed: (l) => {
listener = l
return () => {}
},
}
render()
await type(`see ${ISSUE}`)
expect(screen.queryByText('Card of item one')).toBeNull()
known = true
await act(async () => listener())
expect(screen.getByText('Card of item one')).toBeTruthy()
})
it('a refused send shown at once puts the words back without the card ✕ took off', async () => {
const answer = later()
const send = vi.fn(() => answer.promise)
render()
await type(`see ${ISSUE}`)
await removeCard()
await press('Enter')
expect(send.mock.calls[0][2]).toEqual({})
await act(async () => answer.answer({ accepted: false, reason: 'the relay is busy' }))
await settle()
expect(editorOf().getText()).toBe(`see ${ISSUE}`)
expect(screen.queryByText('Card of item one')).toBeNull()
})
it('a block link shows its live part under the box and no card', async () => {
render()
await type(`see ${ISSUE} and ${BLOCK}`)
expect(screen.getByText('Live part of page two')).toBeTruthy()
expect(screen.queryByText('Card of item one')).toBeNull()
})
it('the edit box shows the card it would add and no part, and Save carries the card', async () => {
const onSave = vi.fn(async () => ({ accepted: true }))
const under: BoxUnder = {
read: (body) => ({
card: { value: 'item-one', view: Card of item one },
parts: [{ key: 'page-two', view: Live part of page two }],
names: textOf(body) ? ['item-one', 'page-two'] : [],
}),
}
const body: JSONContent = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: `see ${ISSUE}` }] }] }
render( {} }} />)
expect(screen.getByText('Card of item one')).toBeTruthy()
expect(screen.queryByText('Live part of page two')).toBeNull()
await press('Enter')
expect(onSave.mock.calls[0][2]).toEqual({ card: 'item-one' })
})
})
describe('a URL pasted over words', () => {
const URL_ = 'https://app.example/item/one'
const doc = (...content: JSONContent[]): JSONContent => ({ type: 'doc', content: [{ type: 'paragraph', content }] })
const editing = (body: JSONContent) => ({ body, onSave: async (): Promise => ({ accepted: true }), onCancel: () => {} })
/** Selects `from`–`to` (1 is the paragraph's start; 'all' selects everything) and pastes `text`, in one act so the box's
* opening focus (a frame later) cannot collapse the selection in between. */
async function pasteOver(from: number | 'all', to: number, text: string) {
await act(async () => {
const editor = editorOf()
if (from === 'all') editor.commands.selectAll()
else editor.commands.setTextSelection({ from, to })
const event = { clipboardData: { getData: (type: string) => (type === 'text/plain' ? text : '') }, preventDefault: () => {} } as unknown as ClipboardEvent
const slice = new Slice(Fragment.from(editor.state.schema.text(text)), 0, 0)
editor.view.someProp('handlePaste', (handle) => handle(editor.view, event, slice))
})
}
const runs = () =>
((editorOf().getJSON() as JSONContent).content?.[0].content ?? []).map((n) => {
const link = n.marks?.find((m) => m.type === 'link')
return link ? `link:${n.text}>${link.attrs?.href}` : `${n.type}:${n.text ?? ''}`
})
it('links the words, and what is typed after them is not linked', async () => {
render()
await pasteOver(5, 13, URL_)
expect(runs()).toEqual(['text:see ', `link:the plan>${URL_}`, 'text: now'])
await act(async () => {
editorOf().chain().setTextSelection(13).insertContent('!').run()
})
expect(runs()).toEqual(['text:see ', `link:the plan>${URL_}`, 'text:! now'])
})
it('leaves the caret after the linked words, so the selection strip stays shut and one Enter sends (COMM-8)', async () => {
const send = vi.fn(async () => ({ accepted: true }))
render()
await type('see the plan')
await pasteOver(5, 13, URL_)
expect(editorOf().state.selection.empty).toBe(true)
expect(editorOf().state.selection.from).toBe(13)
expect(screen.queryByRole('textbox', { name: /link/i })).toBeNull()
await press('Enter')
expect(send).toHaveBeenCalledTimes(1)
})
it('select-all over one line links all its words, without the full stop copied after the URL', async () => {
render()
await pasteOver('all', 0, `${URL_}.`)
expect(runs()).toEqual([`link:the plan>${URL_}`])
})
it('words holding a bracket keep their words, and the URL goes in after them', async () => {
render()
await pasteOver(5, 15, URL_)
expect(runs()).toEqual([`text:see [the] plan ${URL_}`])
})
it('words across a line break keep their words, and the URL goes in after them', async () => {
render()
await pasteOver(1, 8, URL_)
expect(runs()).toEqual(['text:one', 'hardBreak:', `text:two ${URL_}`])
})
it('a URL a message cannot carry on words, with a parenthesis, goes in after the words', async () => {
render()
await pasteOver(1, 9, 'https://wiki.example/Nostr_(protocol)')
expect(runs()).toEqual(['text:the spec https://wiki.example/Nostr_(protocol)'])
})
})
describe('what an app adds around the box', () => {
const bar = () => document.querySelector('[data-composer-editor]')?.hasAttribute('data-marked')
const URGENT = { type: 'urgentMention', attrs: { id: 'one', label: 'Person one' } }
it('draws the bar down the text while an urgent mention is in the box, and drops it when the mention goes', async () => {
render()
expect(bar()).toBe(false)
await act(async () => {
editorOf().chain().focus('end').insertContent([URGENT, { type: 'text', text: ' look' }]).run()
})
expect(bar()).toBe(true)
await act(async () => {
editorOf().commands.clearContent(true)
})
expect(bar()).toBe(false)
})
it("draws the bar while the app's own mark holds, in the edit box too", async () => {
const marked = (editor: Editor) => editor.getText().includes('!!')
const body: JSONContent = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'plain' }] }] }
render( ({ accepted: true }), onCancel: () => {} }} />)
expect(bar()).toBe(false)
await type(' !!')
expect(bar()).toBe(true)
})
it("hands the box's editor out, so a launcher inserts into it and the words are sent; and null once it goes", async () => {
const send = vi.fn(async () => ({ accepted: true }))
let handed: Editor | null = null
const { unmount } = render( (handed = editor)} />)
await act(async () => {
handed!.chain().focus('end').insertContent('from the launcher').run()
})
await press('Enter')
expect(textOf(send.mock.calls[0][0])).toBe('from the launcher')
unmount()
expect(handed).toBeNull()
})
})
describe('a box built ahead', () => {
/** Runs what waits for an idle moment at once, as a page with nothing to do would. */
function idleNow() {
const queued: IdleRequestCallback[] = []
window.requestIdleCallback = ((callback: IdleRequestCallback) => queued.push(callback)) as typeof window.requestIdleCallback
return () => {
while (queued.length) queued.shift()!({ didTimeout: false, timeRemaining: () => 50 })
}
}
/** An app's own extension that counts the editors built with it. */
function counted() {
const built: Editor[] = []
const extensions = [Extension.create({ name: 'appOwn', onBeforeCreate: ({ editor }) => void built.push(editor as Editor) })]
return { built, extensions }
}
it("takes the editor built while the page was idle, labelled as this box, and it sends with this box's send", async () => {
const flush = idleNow()
const { built, extensions } = counted()
keepCommentBoxWarm({ extensions })
flush()
expect(built).toHaveLength(1)
let handed: Editor | null = null
const send = vi.fn(async () => ({ accepted: true }))
render( (handed = editor)} />)
expect(handed).toBe(built[0])
expect(screen.getByRole('textbox', { name: 'Write' })).toBeTruthy()
await type('ahead')
await press('Enter')
expect(textOf(send.mock.calls[0][0])).toBe('ahead')
// …and the next spare is built for the next box.
flush()
expect(built).toHaveLength(2)
})
it('a box opening with a draft, or with other extensions, builds its own and leaves the spare', async () => {
const flush = idleNow()
const { built, extensions } = counted()
keepCommentBoxWarm({ extensions })
flush()
let handed: Editor | null = null
const kept: CommentBoxDraft = { read: () => ({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'kept' }] }] }), write: () => {}, clear: () => {} }
render( (handed = editor)} />)
expect(built).toHaveLength(2)
expect(handed).not.toBe(built[0])
expect(editorOf().getText()).toBe('kept')
cleanup()
render( (handed = editor)} />)
expect(handed).not.toBe(built[0])
})
it('works again, with what was typed, after a loading page hides the box and shows it again', async () => {
// The box lets its editor go a moment after it is hidden: that moment is this clock's.
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
try {
let kept: JSONContent | undefined
const draft: CommentBoxDraft = { read: () => kept, write: (body) => void (kept = body), clear: () => void (kept = undefined) }
const send = vi.fn(async () => ({ accepted: true }))
const loading = later()
function Loading() {
use(loading.promise)
return null
}
function Page({ more }: { more: boolean }) {
return (
Loading
}>
{more && }
)
}
const { rerender } = render()
await type('typed before')
await act(async () => rerender())
expect(screen.getByText('Loading')).toBeTruthy()
act(() => vi.advanceTimersByTime(50))
await act(async () => {
loading.answer()
await loading.promise
})
expect(screen.queryByText('Loading')).toBeNull()
expect(screen.queryByRole('textbox', { name: 'Write' })).not.toBeNull()
expect(editorOf().getText()).toBe('typed before')
await press('Enter')
expect(textOf(send.mock.calls[0][0])).toBe('typed before')
} finally {
vi.useFakeTimers({ toFake: ['Date'] })
}
})
})
describe('a long draft', () => {
const said: JSONContent = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Said before' }] }] }
it('scrolls in the package’s ScrollArea, capped at half the window, in the send box and the edit box — not with the browser’s bar', () => {
for (const props of [{}, { edit: { body: said, onSave: async () => ({ accepted: true as const }), onCancel: () => {} } }]) {
const { unmount } = render()
const text = document.querySelector('.ProseMirror') as HTMLElement
// The text itself never scrolls: that is the browser's bar (Katerina, 21 Oct).
expect(text.className).not.toMatch(/(^|\s)overflow(-[xy])?-(auto|scroll)(\s|$)/)
expect(text.className).not.toContain('max-h-')
// ScrollArea's viewport (its overscroll rule is its mark) holds the text, with the cap.
const viewport = text.parentElement?.closest('[class*="overscroll-y-contain"]') as HTMLElement | null
expect(viewport).not.toBeNull()
expect(viewport!.className.split(' ')).toContain('max-h-[50vh]')
// The region reaches into the box's right padding and its content gives it back: the bar at the box's edge, the text where it was.
expect(viewport!.parentElement!.className.split(' ')).toContain('-mr-3')
expect((viewport!.firstElementChild as HTMLElement).className.split(' ')).toContain('pr-3')
expect(viewport!.closest('[data-composer-editor]')).not.toBeNull()
unmount()
}
})
})