project.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { login } from './login'
  2. import { openEmail } from './email'
  3. import { v4 as uuid } from 'uuid'
  4. export const NEW_PROJECT_BUTTON_MATCHER = /new project/i
  5. export function createProject(
  6. name: string,
  7. {
  8. type = 'Blank project',
  9. newProjectButtonMatcher = NEW_PROJECT_BUTTON_MATCHER,
  10. open = true,
  11. }: {
  12. type?: 'Blank project' | 'Example project'
  13. newProjectButtonMatcher?: RegExp
  14. open?: boolean
  15. } = {}
  16. ): Cypress.Chainable<string> {
  17. cy.url().then(url => {
  18. if (!url.endsWith('/project')) {
  19. cy.visit('/project')
  20. }
  21. })
  22. const interceptId = uuid()
  23. let projectId = ''
  24. if (!open) {
  25. cy.then(() => {
  26. // Register intercept just before creating the project, otherwise we might
  27. // intercept a request from a prior createProject invocation.
  28. cy.intercept(
  29. { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
  30. req => {
  31. projectId = req.url.split('/').pop()!
  32. // Redirect back to the project dashboard, effectively reload the page.
  33. req.redirect('/project')
  34. }
  35. ).as(interceptId)
  36. })
  37. }
  38. cy.findAllByRole('button').contains(newProjectButtonMatcher).click()
  39. // FIXME: This should only look in the left menu
  40. // The upgrading tests create projects in older versions of Server Pro which used different casing of the project type. Use case-insensitive match.
  41. cy.findAllByText(type, { exact: false }).first().click()
  42. cy.findByRole('dialog').within(() => {
  43. cy.get('input').type(name)
  44. cy.findByRole('button', { name: 'Create' }).click()
  45. })
  46. if (open) {
  47. cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
  48. waitForMainDocToLoad()
  49. return cy
  50. .url()
  51. .should('match', /\/project\/[a-fA-F0-9]{24}/)
  52. .then(url => url.split('/').pop())
  53. } else {
  54. const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
  55. cy.wait(alias)
  56. return cy.then(() => projectId)
  57. }
  58. }
  59. export function openProjectByName(projectName: string) {
  60. cy.visit('/project')
  61. cy.findByText(projectName).click()
  62. waitForMainDocToLoad()
  63. }
  64. export function openProjectById(projectId: string) {
  65. cy.visit(`/project/${projectId}`)
  66. waitForMainDocToLoad()
  67. }
  68. export function openProjectViaLinkSharingAsAnon(url: string) {
  69. cy.visit(url)
  70. waitForMainDocToLoad()
  71. }
  72. export function openProjectViaLinkSharingAsUser(
  73. url: string,
  74. projectName: string,
  75. email: string
  76. ) {
  77. cy.visit(url)
  78. cy.findByText(projectName) // wait for lazy loading
  79. cy.contains(`as ${email}`)
  80. cy.findByText('OK, join project').click()
  81. waitForMainDocToLoad()
  82. }
  83. export function openProjectViaInviteNotification(projectName: string) {
  84. cy.visit('/project')
  85. cy.findByText(projectName)
  86. .parent()
  87. .parent()
  88. .within(() => {
  89. cy.findByText('Join Project').click()
  90. })
  91. cy.findByText('Open Project').click()
  92. cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
  93. waitForMainDocToLoad()
  94. }
  95. function shareProjectByEmail(
  96. projectName: string,
  97. email: string,
  98. level: 'Viewer' | 'Editor'
  99. ) {
  100. openProjectByName(projectName)
  101. cy.findByRole('button', { name: 'Share' }).click()
  102. cy.findByRole('dialog').within(() => {
  103. cy.findByLabelText('Add email address', { selector: 'input' }).type(
  104. `${email},`
  105. )
  106. cy.findByLabelText('Add email address', { selector: 'input' })
  107. .parents('form')
  108. .within(() => {
  109. cy.findByTestId('add-collaborator-select')
  110. .click()
  111. .then(() => {
  112. cy.findByRole('option', { name: level }).click()
  113. })
  114. })
  115. cy.findByRole('button', { name: 'Invite' }).click()
  116. cy.findByText('Invite not yet accepted.')
  117. })
  118. }
  119. export function shareProjectByEmailAndAcceptInviteViaDash(
  120. projectName: string,
  121. email: string,
  122. level: 'Viewer' | 'Editor'
  123. ) {
  124. shareProjectByEmail(projectName, email, level)
  125. login(email)
  126. openProjectViaInviteNotification(projectName)
  127. }
  128. export function getSpamSafeProjectName() {
  129. while (true) {
  130. // Move from hex/16 to base64/64 possible characters per char in string
  131. const name = Buffer.from(uuid().replaceAll('-', ''), 'hex')
  132. .toString('base64')
  133. .replace('/', '_')
  134. .slice(0, 10)
  135. const nDigits = (name.match(/\d/g) || []).length
  136. if (nDigits < 6) return name
  137. }
  138. }
  139. export function shareProjectByEmailAndAcceptInviteViaEmail(
  140. projectName: string,
  141. email: string,
  142. level: 'Viewer' | 'Editor'
  143. ) {
  144. shareProjectByEmail(projectName, email, level)
  145. login(email)
  146. openEmail(projectName, frame => {
  147. frame.contains('View project').then(a => {
  148. cy.log(
  149. 'bypass target=_blank and navigate current browser tab/cypress-iframe to project invite'
  150. )
  151. cy.visit(a.attr('href')!)
  152. })
  153. })
  154. cy.url().should('match', /\/project\/[a-f0-9]+\/invite\/token\/[a-f0-9]+/)
  155. cy.findByText(/user would like you to join/)
  156. cy.contains(new RegExp(`You are accepting this invite as ${email}`))
  157. cy.findByText('Join Project').click()
  158. waitForMainDocToLoad()
  159. }
  160. export function enableLinkSharing() {
  161. let linkSharingReadOnly: string
  162. let linkSharingReadAndWrite: string
  163. const origin = new URL(Cypress.config().baseUrl!).origin
  164. waitForMainDocToLoad()
  165. cy.findByText('Share').click()
  166. cy.findByText('Turn on link sharing').click()
  167. cy.findByText('Anyone with this link can view this project')
  168. .next()
  169. .should('contain.text', origin + '/read')
  170. .then(el => {
  171. linkSharingReadOnly = el.text()
  172. })
  173. cy.findByText('Anyone with this link can edit this project')
  174. .next()
  175. .should('contain.text', origin + '/')
  176. .then(el => {
  177. linkSharingReadAndWrite = el.text()
  178. })
  179. return cy.then(() => {
  180. return { linkSharingReadOnly, linkSharingReadAndWrite }
  181. })
  182. }
  183. export function waitForMainDocToLoad() {
  184. cy.log('Wait for main doc to load; it will steal the focus after loading')
  185. cy.get('.cm-content').should('contain.text', 'Introduction')
  186. }
  187. export function openFile(fileName: string, waitFor: string) {
  188. // force: The file-tree pane is too narrow to display the full name.
  189. cy.findByRole('navigation', { name: 'Project files and outline' })
  190. .findByRole('treeitem', { name: fileName })
  191. .click({ force: true })
  192. // wait until we've switched to the selected file
  193. cy.findByRole('status').should('not.exist')
  194. cy.findByText(waitFor)
  195. }
  196. export function createNewFile() {
  197. const fileName = `${uuid()}.tex`
  198. cy.log('create new project file')
  199. cy.get('button').contains('New file').click({ force: true })
  200. cy.findByRole('dialog').within(() => {
  201. cy.get('input').clear()
  202. cy.get('input').type(fileName)
  203. cy.findByText('Create').click()
  204. })
  205. // force: The file-tree pane is too narrow to display the full name.
  206. cy.findByTestId('file-tree')
  207. .findByRole('treeitem', { name: fileName })
  208. .click({ force: true })
  209. // wait until we've switched to the newly created empty file
  210. cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
  211. cy.findByRole('status').should('not.exist')
  212. cy.get('.cm-line').should('have.length', 1)
  213. })
  214. cy.get('.cm-line').should('have.length', 1)
  215. return fileName
  216. }
  217. export function expectFileExists(
  218. name: string,
  219. binary: boolean,
  220. content: string
  221. ) {
  222. cy.findByRole('treeitem', { name }).click()
  223. if (binary) {
  224. cy.findByText(content).should('not.have.class', 'cm-line')
  225. } else {
  226. cy.findByText(content).should('have.class', 'cm-line')
  227. }
  228. }
  229. export function prepareFileUploadTest(binary = false) {
  230. const name = `${uuid()}.txt`
  231. const content = `Test File Content ${name}${binary ? ' \x00' : ''}`
  232. cy.get('button').contains('Upload').click({ force: true })
  233. cy.get('input[type=file]')
  234. .first()
  235. .selectFile(
  236. {
  237. contents: Cypress.Buffer.from(content),
  238. fileName: name,
  239. lastModified: Date.now(),
  240. },
  241. { force: true }
  242. )
  243. // wait for the upload to finish
  244. cy.findByRole('treeitem', { name })
  245. return () => expectFileExists(name, binary, content)
  246. }
  247. export function testNewFileUpload() {
  248. it('can upload text file', () => {
  249. const check = prepareFileUploadTest(false)
  250. check()
  251. })
  252. it('can upload binary file', () => {
  253. const check = prepareFileUploadTest(true)
  254. check()
  255. })
  256. }