share-project-modal.test.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. import { expect } from 'chai'
  2. import sinon from 'sinon'
  3. import React from 'react'
  4. import {
  5. cleanup,
  6. render,
  7. screen,
  8. fireEvent,
  9. waitFor,
  10. waitForElementToBeRemoved
  11. } from '@testing-library/react'
  12. import fetchMock from 'fetch-mock'
  13. import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
  14. import * as locationModule from '../../../../../frontend/js/features/share-project-modal/utils/location'
  15. describe('<ShareProjectModal/>', function() {
  16. const project = {
  17. _id: 'test-project',
  18. name: 'Test Project',
  19. features: {
  20. collaborators: 10
  21. },
  22. owner: {
  23. email: 'project-owner@example.com'
  24. },
  25. members: [],
  26. invites: []
  27. }
  28. const contacts = [
  29. // user with edited name
  30. {
  31. type: 'user',
  32. email: 'test-user@example.com',
  33. first_name: 'Test',
  34. last_name: 'User',
  35. name: 'Test User'
  36. },
  37. // user with default name (email prefix)
  38. {
  39. type: 'user',
  40. email: 'test@example.com',
  41. first_name: 'test'
  42. },
  43. // no last name
  44. {
  45. type: 'user',
  46. first_name: 'Eratosthenes',
  47. email: 'eratosthenes@example.com'
  48. },
  49. // more users
  50. {
  51. type: 'user',
  52. first_name: 'Claudius',
  53. last_name: 'Ptolemy',
  54. email: 'ptolemy@example.com'
  55. },
  56. {
  57. type: 'user',
  58. first_name: 'Abd al-Rahman',
  59. last_name: 'Al-Sufi',
  60. email: 'al-sufi@example.com'
  61. },
  62. {
  63. type: 'user',
  64. first_name: 'Nicolaus',
  65. last_name: 'Copernicus',
  66. email: 'copernicus@example.com'
  67. }
  68. ]
  69. const ideWithProject = project => {
  70. return {
  71. $scope: {
  72. $watch: () => () => {},
  73. $applyAsync: () => {},
  74. project
  75. }
  76. }
  77. }
  78. const modalProps = {
  79. ide: ideWithProject(project),
  80. show: true,
  81. isAdmin: true,
  82. handleHide: sinon.stub()
  83. }
  84. const originalExposedSettings = window.ExposedSettings
  85. before(function() {
  86. window.ExposedSettings = { appName: 'Overleaf' }
  87. })
  88. after(function() {
  89. window.ExposedSettings = originalExposedSettings
  90. })
  91. beforeEach(function() {
  92. fetchMock.get('/user/contacts', { contacts })
  93. })
  94. afterEach(function() {
  95. fetchMock.restore()
  96. cleanup()
  97. })
  98. it('renders the modal', async function() {
  99. render(<ShareProjectModal {...modalProps} />)
  100. await screen.findByText('Share Project')
  101. })
  102. it('calls handleHide when a Close button is pressed', async function() {
  103. const handleHide = sinon.stub()
  104. render(<ShareProjectModal {...modalProps} handleHide={handleHide} />)
  105. const [
  106. headerCloseButton,
  107. footerCloseButton
  108. ] = await screen.findAllByRole('button', { name: 'Close' })
  109. fireEvent.click(headerCloseButton)
  110. fireEvent.click(footerCloseButton)
  111. expect(handleHide.callCount).to.equal(2)
  112. })
  113. it('handles access level "private"', async function() {
  114. render(
  115. <ShareProjectModal
  116. {...modalProps}
  117. ide={ideWithProject({ ...project, publicAccesLevel: 'private' })}
  118. />
  119. )
  120. await screen.findByText(
  121. 'Link sharing is off, only invited users can view this project.'
  122. )
  123. await screen.findByRole('button', { name: 'Turn on link sharing' })
  124. expect(screen.queryByText('Anyone with this link can view this project')).to
  125. .be.null
  126. expect(screen.queryByText('Anyone with this link can edit this project')).to
  127. .be.null
  128. })
  129. it('handles access level "tokenBased"', async function() {
  130. render(
  131. <ShareProjectModal
  132. {...modalProps}
  133. ide={ideWithProject({ ...project, publicAccesLevel: 'tokenBased' })}
  134. />
  135. )
  136. await screen.findByText('Link sharing is on')
  137. await screen.findByRole('button', { name: 'Turn off link sharing' })
  138. expect(screen.queryByText('Anyone with this link can view this project'))
  139. .not.to.be.null
  140. expect(screen.queryByText('Anyone with this link can edit this project'))
  141. .not.to.be.null
  142. })
  143. it('handles legacy access level "readAndWrite"', async function() {
  144. render(
  145. <ShareProjectModal
  146. {...modalProps}
  147. ide={ideWithProject({ ...project, publicAccesLevel: 'readAndWrite' })}
  148. />
  149. )
  150. await screen.findByText(
  151. 'This project is public and can be edited by anyone with the URL.'
  152. )
  153. await screen.findByRole('button', { name: 'Make Private' })
  154. })
  155. it('handles legacy access level "readOnly"', async function() {
  156. render(
  157. <ShareProjectModal
  158. {...modalProps}
  159. ide={ideWithProject({ ...project, publicAccesLevel: 'readOnly' })}
  160. />
  161. )
  162. await screen.findByText(
  163. 'This project is public and can be viewed but not edited by anyone with the URL'
  164. )
  165. await screen.findByRole('button', { name: 'Make Private' })
  166. })
  167. it('hides actions from non-admins', async function() {
  168. const invites = [
  169. {
  170. _id: 'invited-author',
  171. email: 'invited-author@example.com',
  172. privileges: 'readAndWrite'
  173. }
  174. ]
  175. // render as admin: actions should be present
  176. const { rerender } = render(
  177. <ShareProjectModal
  178. {...modalProps}
  179. ide={ideWithProject({
  180. ...project,
  181. invites,
  182. publicAccesLevel: 'tokenBased'
  183. })}
  184. isAdmin
  185. />
  186. )
  187. await screen.findByRole('button', { name: 'Turn off link sharing' })
  188. await screen.findByRole('button', { name: 'Resend' })
  189. // render as non-admin, link sharing on: actions should be missing and message should be present
  190. rerender(
  191. <ShareProjectModal
  192. {...modalProps}
  193. ide={ideWithProject({
  194. ...project,
  195. invites,
  196. publicAccesLevel: 'tokenBased'
  197. })}
  198. isAdmin={false}
  199. />
  200. )
  201. await screen.findByText(
  202. 'To change access permissions, please ask the project owner'
  203. )
  204. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  205. .be.null
  206. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  207. .null
  208. expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
  209. // render as non-admin (non-owner), link sharing off: actions should be missing and message should be present
  210. rerender(
  211. <ShareProjectModal
  212. {...modalProps}
  213. ide={ideWithProject({
  214. ...project,
  215. invites,
  216. publicAccesLevel: 'private'
  217. })}
  218. isAdmin={false}
  219. />
  220. )
  221. await screen.findByText(
  222. 'To change access permissions, please ask the project owner'
  223. )
  224. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  225. .be.null
  226. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  227. .null
  228. expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
  229. })
  230. it('only shows read-only token link to restricted token members', async function() {
  231. window.isRestrictedTokenMember = true
  232. render(
  233. <ShareProjectModal
  234. {...modalProps}
  235. ide={ideWithProject({ ...project, publicAccesLevel: 'tokenBased' })}
  236. />
  237. )
  238. window.isRestrictedTokenMember = false
  239. // no buttons
  240. expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
  241. .null
  242. expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
  243. .be.null
  244. // only read-only token link
  245. await screen.findByText('Anyone with this link can view this project')
  246. expect(screen.queryByText('Anyone with this link can edit this project')).to
  247. .be.null
  248. })
  249. it('displays project members and invites', async function() {
  250. const members = [
  251. {
  252. _id: 'member-author',
  253. email: 'member-author@example.com',
  254. privileges: 'readAndWrite'
  255. },
  256. {
  257. _id: 'member-viewer',
  258. email: 'member-viewer@example.com',
  259. privileges: 'readOnly'
  260. }
  261. ]
  262. const invites = [
  263. {
  264. _id: 'invited-author',
  265. email: 'invited-author@example.com',
  266. privileges: 'readAndWrite'
  267. },
  268. {
  269. _id: 'invited-viewer',
  270. email: 'invited-viewer@example.com',
  271. privileges: 'readOnly'
  272. }
  273. ]
  274. render(
  275. <ShareProjectModal
  276. {...modalProps}
  277. ide={ideWithProject({
  278. ...project,
  279. members,
  280. invites,
  281. publicAccesLevel: 'tokenBased'
  282. })}
  283. />
  284. )
  285. expect(screen.queryAllByText('project-owner@example.com')).to.have.length(1)
  286. expect(screen.queryAllByText('member-author@example.com')).to.have.length(1)
  287. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  288. expect(screen.queryAllByText('invited-author@example.com')).to.have.length(
  289. 1
  290. )
  291. expect(screen.queryAllByText('invited-viewer@example.com')).to.have.length(
  292. 1
  293. )
  294. expect(screen.queryAllByText('Invite not yet accepted.')).to.have.length(
  295. invites.length
  296. )
  297. expect(screen.queryAllByRole('button', { name: 'Resend' })).to.have.length(
  298. invites.length
  299. )
  300. })
  301. it('resends an invite', async function() {
  302. fetchMock.postOnce(
  303. 'express:/project/:projectId/invite/:inviteId/resend',
  304. 204
  305. )
  306. const invites = [
  307. {
  308. _id: 'invited-author',
  309. email: 'invited-author@example.com',
  310. privileges: 'readAndWrite'
  311. }
  312. ]
  313. render(
  314. <ShareProjectModal
  315. {...modalProps}
  316. ide={ideWithProject({
  317. ...project,
  318. invites,
  319. publicAccesLevel: 'tokenBased'
  320. })}
  321. />
  322. )
  323. const [, closeButton] = screen.getAllByRole('button', {
  324. name: 'Close'
  325. })
  326. const resendButton = screen.getByRole('button', { name: 'Resend' })
  327. fireEvent.click(resendButton)
  328. await waitFor(() => expect(closeButton.disabled).to.be.true)
  329. expect(fetchMock.done()).to.be.true
  330. expect(closeButton.disabled).to.be.false
  331. })
  332. it('revokes an invite', async function() {
  333. fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204)
  334. const invites = [
  335. {
  336. _id: 'invited-author',
  337. email: 'invited-author@example.com',
  338. privileges: 'readAndWrite'
  339. }
  340. ]
  341. render(
  342. <ShareProjectModal
  343. {...modalProps}
  344. ide={ideWithProject({
  345. ...project,
  346. invites,
  347. publicAccesLevel: 'tokenBased'
  348. })}
  349. />
  350. )
  351. const [, closeButton] = screen.getAllByRole('button', {
  352. name: 'Close'
  353. })
  354. const revokeButton = screen.getByRole('button', { name: 'Revoke' })
  355. fireEvent.click(revokeButton)
  356. await waitFor(() => expect(closeButton.disabled).to.be.true)
  357. expect(fetchMock.done()).to.be.true
  358. expect(closeButton.disabled).to.be.false
  359. })
  360. it('changes member privileges to read + write', async function() {
  361. fetchMock.putOnce('express:/project/:projectId/users/:userId', 204)
  362. const members = [
  363. {
  364. _id: 'member-viewer',
  365. email: 'member-viewer@example.com',
  366. privileges: 'readOnly'
  367. }
  368. ]
  369. render(
  370. <ShareProjectModal
  371. {...modalProps}
  372. ide={ideWithProject({
  373. ...project,
  374. members,
  375. publicAccesLevel: 'tokenBased'
  376. })}
  377. />
  378. )
  379. const [, closeButton] = await screen.getAllByRole('button', {
  380. name: 'Close'
  381. })
  382. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  383. const select = screen.getByDisplayValue('Read Only')
  384. await fireEvent.change(select, { target: { value: 'readAndWrite' } })
  385. const changeButton = screen.getByRole('button', { name: 'Change' })
  386. fireEvent.click(changeButton)
  387. await waitFor(() => expect(closeButton.disabled).to.be.true)
  388. const { body } = fetchMock.lastOptions()
  389. expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
  390. expect(fetchMock.done()).to.be.true
  391. expect(closeButton.disabled).to.be.false
  392. })
  393. it('removes a member from the project', async function() {
  394. fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204)
  395. const members = [
  396. {
  397. _id: 'member-viewer',
  398. email: 'member-viewer@example.com',
  399. privileges: 'readOnly'
  400. }
  401. ]
  402. render(
  403. <ShareProjectModal
  404. {...modalProps}
  405. ide={ideWithProject({
  406. ...project,
  407. members,
  408. publicAccesLevel: 'tokenBased'
  409. })}
  410. />
  411. )
  412. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  413. const removeButton = screen.getByRole('button', {
  414. name: 'Remove from project'
  415. })
  416. fireEvent.click(removeButton)
  417. const url = fetchMock.lastUrl()
  418. expect(url).to.equal('/project/test-project/users/member-viewer')
  419. expect(fetchMock.done()).to.be.true
  420. await waitForElementToBeRemoved(() =>
  421. screen.queryByText('member-viewer@example.com')
  422. )
  423. })
  424. it('changes member privileges to owner with confirmation', async function() {
  425. fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
  426. const members = [
  427. {
  428. _id: 'member-viewer',
  429. email: 'member-viewer@example.com',
  430. privileges: 'readOnly'
  431. }
  432. ]
  433. render(
  434. <ShareProjectModal
  435. {...modalProps}
  436. ide={ideWithProject({
  437. ...project,
  438. members,
  439. publicAccesLevel: 'tokenBased'
  440. })}
  441. />
  442. )
  443. expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
  444. const select = screen.getByDisplayValue('Read Only')
  445. fireEvent.change(select, { target: { value: 'owner' } })
  446. const changeButton = screen.getByRole('button', { name: 'Change' })
  447. await fireEvent.click(changeButton)
  448. screen.getByText((_, node) => {
  449. return (
  450. node.textContent ===
  451. 'Are you sure you want to make member-viewer@example.com the owner of Test Project?'
  452. )
  453. })
  454. const reloadStub = sinon.stub(locationModule, 'reload')
  455. const confirmButton = screen.getByRole('button', {
  456. name: 'Change owner'
  457. })
  458. fireEvent.click(confirmButton)
  459. await waitFor(() => expect(confirmButton.disabled).to.be.true)
  460. const { body } = fetchMock.lastOptions()
  461. expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
  462. expect(fetchMock.done()).to.be.true
  463. expect(reloadStub.calledOnce).to.be.true
  464. reloadStub.restore()
  465. })
  466. it('sends invites to input email addresses', async function() {
  467. render(
  468. <ShareProjectModal
  469. {...modalProps}
  470. ide={ideWithProject({
  471. ...project,
  472. publicAccesLevel: 'tokenBased'
  473. })}
  474. />
  475. )
  476. const [inputElement] = await screen.findAllByLabelText(
  477. 'Share with your collaborators'
  478. )
  479. // loading contacts
  480. await waitFor(() => {
  481. expect(fetchMock.called('express:/user/contacts')).to.be.true
  482. })
  483. // displaying a list of matching contacts
  484. inputElement.focus()
  485. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  486. await screen.findByText(/ptolemy@example.com/)
  487. // sending invitations
  488. fetchMock.post('express:/project/:projectId/invite', (url, req) => {
  489. const data = JSON.parse(req.body)
  490. if (data.email === 'a@b.c') {
  491. return {
  492. status: 400,
  493. body: { errorReason: 'invalid_email' }
  494. }
  495. }
  496. return {
  497. status: 200,
  498. body: {
  499. invite: {
  500. ...data,
  501. _id: data.email
  502. }
  503. }
  504. }
  505. })
  506. fireEvent.paste(inputElement, {
  507. clipboardData: {
  508. getData: () =>
  509. 'test@example.com, foo@example.com, bar@example.com, a@b.c'
  510. }
  511. })
  512. const privilegesElement = screen.getByDisplayValue('Can Edit')
  513. fireEvent.change(privilegesElement, { target: { value: 'readOnly' } })
  514. const submitButton = screen.getByRole('button', { name: 'Share' })
  515. submitButton.click()
  516. let calls
  517. await waitFor(
  518. () => {
  519. calls = fetchMock.calls('express:/project/:projectId/invite')
  520. expect(calls).to.have.length(4)
  521. },
  522. { timeout: 5000 } // allow time for delay between each request
  523. )
  524. expect(calls[0][1].body).to.equal(
  525. JSON.stringify({ email: 'test@example.com', privileges: 'readOnly' })
  526. )
  527. expect(calls[1][1].body).to.equal(
  528. JSON.stringify({ email: 'foo@example.com', privileges: 'readOnly' })
  529. )
  530. expect(calls[2][1].body).to.equal(
  531. JSON.stringify({ email: 'bar@example.com', privileges: 'readOnly' })
  532. )
  533. expect(calls[3][1].body).to.equal(
  534. JSON.stringify({ email: 'a@b.c', privileges: 'readOnly' })
  535. )
  536. // error from the last invite
  537. screen.getByText('An email address is invalid')
  538. })
  539. it('displays a message when the collaborator limit is reached', async function() {
  540. const originalUser = window.user
  541. window.user = { allowedFreeTrial: true }
  542. render(
  543. <ShareProjectModal
  544. {...modalProps}
  545. ide={ideWithProject({
  546. ...project,
  547. publicAccesLevel: 'tokenBased',
  548. features: {
  549. collaborators: 0
  550. }
  551. })}
  552. />
  553. )
  554. expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
  555. screen.getByText(
  556. /You need to upgrade your account to add more collaborators/
  557. )
  558. window.user = originalUser
  559. })
  560. it('handles server error responses', async function() {
  561. render(
  562. <ShareProjectModal
  563. {...modalProps}
  564. ide={ideWithProject({
  565. ...project,
  566. publicAccesLevel: 'tokenBased'
  567. })}
  568. />
  569. )
  570. // loading contacts
  571. await waitFor(() => {
  572. expect(fetchMock.called('express:/user/contacts')).to.be.true
  573. })
  574. const [inputElement] = await screen.findAllByLabelText(
  575. 'Share with your collaborators'
  576. )
  577. const submitButton = screen.getByRole('button', { name: 'Share' })
  578. const respondWithError = async function(errorReason) {
  579. inputElement.focus()
  580. fireEvent.change(inputElement, {
  581. target: { value: 'invited-author-1@example.com' }
  582. })
  583. inputElement.blur()
  584. fetchMock.postOnce(
  585. 'express:/project/:projectId/invite',
  586. {
  587. status: 400,
  588. body: { errorReason }
  589. },
  590. { overwriteRoutes: true }
  591. )
  592. expect(submitButton.disabled).to.be.false
  593. submitButton.click()
  594. await fetchMock.flush(true)
  595. expect(fetchMock.done()).to.be.true
  596. }
  597. await respondWithError('cannot_invite_non_user')
  598. await screen.findByText(
  599. `Can't send invite. Recipient must already have a Overleaf account`
  600. )
  601. await respondWithError('cannot_verify_user_not_robot')
  602. await screen.findByText(
  603. `Sorry, we could not verify that you are not a robot. Please check that Google reCAPTCHA is not being blocked by an ad blocker or firewall.`
  604. )
  605. await respondWithError('cannot_invite_self')
  606. await screen.findByText(`Can't send invite to yourself`)
  607. await respondWithError('invalid_email')
  608. await screen.findByText(`An email address is invalid`)
  609. await respondWithError('too_many_requests')
  610. await screen.findByText(
  611. `Too many requests were received in a short space of time. Please wait for a few moments and try again.`
  612. )
  613. })
  614. it('handles switching between access levels', async function() {
  615. fetchMock.post('express:/project/:projectId/settings/admin', 204)
  616. render(
  617. <ShareProjectModal
  618. {...modalProps}
  619. ide={ideWithProject({ ...project, publicAccesLevel: 'private' })}
  620. />
  621. )
  622. await screen.findByText(
  623. 'Link sharing is off, only invited users can view this project.'
  624. )
  625. const enableButton = await screen.findByRole('button', {
  626. name: 'Turn on link sharing'
  627. })
  628. fireEvent.click(enableButton)
  629. await waitFor(() => expect(enableButton.disabled).to.be.true)
  630. const { body: tokenBody } = fetchMock.lastOptions()
  631. expect(JSON.parse(tokenBody)).to.deep.equal({
  632. publicAccessLevel: 'tokenBased'
  633. })
  634. await screen.findByText('Link sharing is on')
  635. const disableButton = await screen.findByRole('button', {
  636. name: 'Turn off link sharing'
  637. })
  638. fireEvent.click(disableButton)
  639. await waitFor(() => expect(disableButton.disabled).to.be.true)
  640. const { body: privateBody } = fetchMock.lastOptions()
  641. expect(JSON.parse(privateBody)).to.deep.equal({
  642. publicAccessLevel: 'private'
  643. })
  644. await screen.findByText(
  645. 'Link sharing is off, only invited users can view this project.'
  646. )
  647. })
  648. it('avoids selecting unmatched contact', async function() {
  649. render(<ShareProjectModal {...modalProps} />)
  650. const [inputElement] = await screen.findAllByLabelText(
  651. 'Share with your collaborators'
  652. )
  653. // Wait for contacts to load
  654. await waitFor(() => {
  655. expect(fetchMock.called('express:/user/contacts')).to.be.true
  656. })
  657. // Enter a prefix that matches a contact
  658. inputElement.focus()
  659. fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
  660. // The matching contact should now be present and selected
  661. await screen.findByRole('option', {
  662. name: `Claudius Ptolemy <ptolemy@example.com>`,
  663. selected: true
  664. })
  665. // Keep entering text so the contact no longer matches
  666. fireEvent.change(inputElement, {
  667. target: { value: 'ptolemy.new@example.com' }
  668. })
  669. // The matching contact should no longer be present
  670. expect(
  671. screen.queryByRole('option', {
  672. name: `Claudius Ptolemy <ptolemy@example.com>`
  673. })
  674. ).to.be.null
  675. // No items should be added yet
  676. expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
  677. // Pressing Tab should add the entered item
  678. fireEvent.keyDown(inputElement, { key: 'Tab', code: 'Tab' })
  679. await waitFor(() => {
  680. expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
  681. 1
  682. )
  683. })
  684. // Blurring the input should not add another contact
  685. fireEvent.blur(inputElement)
  686. await waitFor(() => {
  687. expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
  688. 1
  689. )
  690. })
  691. })
  692. })