project.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. import { login } from './login'
  2. import { openEmail } from './email'
  3. import { v4 as uuid } from 'uuid'
  4. import { prepareWaitForNextCompileSlot } from './compile'
  5. export const NEW_PROJECT_BUTTON_MATCHER = /new project/i
  6. const NEW_EDITOR_QUERY_PARAMS = ''
  7. const OLD_EDITOR_QUERY_PARAMS = '?old-editor-override=true'
  8. export function redirectEditorUrlWithQueryParams(newEditor: boolean) {
  9. const queryString = newEditor
  10. ? NEW_EDITOR_QUERY_PARAMS
  11. : OLD_EDITOR_QUERY_PARAMS
  12. cy.intercept(
  13. { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
  14. req => {
  15. // Intercept redirect and add the query param to use the new editor
  16. req.redirect(`${req.url}${queryString}`)
  17. }
  18. )
  19. }
  20. export function createProject(
  21. name: string,
  22. {
  23. type = 'Blank project',
  24. newProjectButtonMatcher = NEW_PROJECT_BUTTON_MATCHER,
  25. open = true,
  26. newEditor = false,
  27. }: {
  28. type?: 'Blank project' | 'Example project'
  29. newProjectButtonMatcher?: RegExp
  30. open?: boolean
  31. newEditor?: boolean
  32. } = {}
  33. ): Cypress.Chainable<string> {
  34. cy.url().then(url => {
  35. if (!url.endsWith('/project')) {
  36. cy.visit('/project')
  37. }
  38. })
  39. const interceptId = uuid()
  40. let projectId = ''
  41. if (!open) {
  42. cy.then(() => {
  43. // Register intercept just before creating the project, otherwise we might
  44. // intercept a request from a prior createProject invocation.
  45. cy.intercept(
  46. { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
  47. req => {
  48. projectId = req.url.split('/').pop()!.split('?')[0]
  49. // Redirect back to the project dashboard, effectively reload the page.
  50. req.redirect('/project')
  51. }
  52. ).as(interceptId)
  53. })
  54. } else {
  55. redirectEditorUrlWithQueryParams(newEditor)
  56. }
  57. cy.findAllByRole('button').contains(newProjectButtonMatcher).click()
  58. // FIXME: This should only look in the left menu
  59. // The upgrading tests create projects in older versions of Server Pro which used different casing of the project type. Use case-insensitive match.
  60. cy.findAllByText(type, { exact: false }).first().click()
  61. cy.findByRole('dialog').within(() => {
  62. cy.get('input').type(name)
  63. cy.findByRole('button', { name: 'Create' }).click()
  64. })
  65. if (open) {
  66. cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
  67. waitForMainDocToLoad()
  68. return cy
  69. .url()
  70. .should('match', /\/project\/[a-fA-F0-9]{24}/)
  71. .then(url => url.split('/').pop()?.split('?')[0])
  72. } else {
  73. const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
  74. cy.wait(alias)
  75. return cy.then(() => projectId)
  76. }
  77. }
  78. // TODO ide-redesign-cleanup: Remove this and just use createProject directly
  79. export function createProjectAndOpenInNewEditor(
  80. projectName: string,
  81. options: {
  82. type?: 'Blank project' | 'Example project'
  83. newProjectButtonMatcher?: RegExp
  84. } = {}
  85. ) {
  86. return createProject(projectName, { ...options, open: false }).then(
  87. projectId => {
  88. // Open the new project in the new editor
  89. openProjectById(projectId, true)
  90. return cy.then(() => projectId)
  91. }
  92. )
  93. }
  94. export function openProjectByName(projectName: string, newEditor = false) {
  95. cy.visit('/project')
  96. redirectEditorUrlWithQueryParams(newEditor)
  97. cy.findByText(projectName).click()
  98. waitForMainDocToLoad()
  99. if (newEditor) {
  100. // Close the beta intro modal if it appears
  101. // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
  102. cy.get('body').type('{esc}')
  103. }
  104. }
  105. export function openProjectById(projectId: string, newEditor = false) {
  106. const url = newEditor
  107. ? `/project/${projectId}${NEW_EDITOR_QUERY_PARAMS}`
  108. : `/project/${projectId}${OLD_EDITOR_QUERY_PARAMS}`
  109. cy.visit(url)
  110. waitForMainDocToLoad()
  111. if (newEditor) {
  112. // Close the beta intro modal if it appears
  113. // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
  114. cy.get('body').type('{esc}')
  115. }
  116. }
  117. export function openProjectViaLinkSharingAsAnon(
  118. url: string,
  119. newEditor = false
  120. ) {
  121. redirectEditorUrlWithQueryParams(newEditor)
  122. cy.visit(url)
  123. waitForMainDocToLoad()
  124. if (newEditor) {
  125. // Close the beta intro modal if it appears
  126. // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
  127. cy.get('body').type('{esc}')
  128. }
  129. }
  130. export function openProjectViaLinkSharingAsUser(
  131. url: string,
  132. projectName: string,
  133. email: string,
  134. newEditor: boolean = false
  135. ) {
  136. cy.visit(url)
  137. cy.findByText(projectName) // wait for lazy loading
  138. cy.contains(`as ${email}`)
  139. redirectEditorUrlWithQueryParams(newEditor)
  140. cy.findByText('OK, join project').click()
  141. waitForMainDocToLoad()
  142. if (newEditor) {
  143. // Close the beta intro modal if it appears
  144. // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
  145. cy.get('body').type('{esc}')
  146. }
  147. }
  148. export function openProjectViaInviteNotification(
  149. projectName: string,
  150. newEditor: boolean = false
  151. ) {
  152. cy.visit('/project')
  153. cy.findByText(projectName)
  154. .parent()
  155. .parent()
  156. .within(() => {
  157. cy.findByText('Join Project').click()
  158. })
  159. cy.intercept(
  160. // NOTE: Struggling to work out why but this only seems to work with times: 2.
  161. // This is temporary code so leaving it for now.
  162. { method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 2 },
  163. req => {
  164. const queryString = newEditor
  165. ? NEW_EDITOR_QUERY_PARAMS
  166. : OLD_EDITOR_QUERY_PARAMS
  167. // Intercept redirect and add the query param to use the new editor
  168. req.redirect(`${req.url}${queryString}`)
  169. }
  170. )
  171. const { waitForCompile } = prepareWaitForNextCompileSlot()
  172. waitForCompile(() => {
  173. cy.findByText('Open Project').click()
  174. cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
  175. waitForMainDocToLoad()
  176. if (newEditor) {
  177. // Close the beta intro modal if it appears
  178. // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
  179. cy.get('body').type('{esc}')
  180. }
  181. })
  182. }
  183. function shareProjectByEmail(
  184. projectName: string,
  185. email: string,
  186. level: 'Viewer' | 'Editor' | 'Reviewer',
  187. newEditor: boolean = false
  188. ) {
  189. openProjectByName(projectName, newEditor)
  190. cy.findByRole('button', { name: 'Share' }).click()
  191. cy.findByRole('dialog').within(() => {
  192. cy.findByLabelText('Add email address', { selector: 'input' }).type(
  193. `${email},`
  194. )
  195. cy.findByLabelText('Add email address', { selector: 'input' })
  196. .parents('form')
  197. .within(() => {
  198. cy.findByTestId('add-collaborator-select').as('select').click()
  199. cy.get('@select').then(() => {
  200. cy.findByRole('option', { name: level }).click()
  201. })
  202. })
  203. cy.findByRole('button', { name: 'Invite' }).click()
  204. cy.findByText('Invite not yet accepted.')
  205. })
  206. }
  207. export function shareProjectByEmailAndAcceptInviteViaDash(
  208. projectName: string,
  209. email: string,
  210. level: 'Viewer' | 'Editor' | 'Reviewer',
  211. newEditor: boolean = false
  212. ) {
  213. shareProjectByEmail(projectName, email, level, newEditor)
  214. login(email)
  215. openProjectViaInviteNotification(projectName, newEditor)
  216. }
  217. export function getSpamSafeProjectName() {
  218. while (true) {
  219. // Move from hex/16 to base64/64 possible characters per char in string
  220. const name = Buffer.from(uuid().replaceAll('-', ''), 'hex')
  221. .toString('base64')
  222. .replaceAll('/', 'x')
  223. .replaceAll('+', 'y')
  224. .slice(0, 10)
  225. const nDigits = (name.match(/\d/g) || []).length
  226. if (nDigits < 6) return name
  227. }
  228. }
  229. export function shareProjectByEmailAndAcceptInviteViaEmail(
  230. projectName: string,
  231. email: string,
  232. level: 'Viewer' | 'Editor',
  233. newEditor: boolean = false
  234. ) {
  235. shareProjectByEmail(projectName, email, level, newEditor)
  236. login(email)
  237. openEmail(projectName, frame => {
  238. frame.contains('View project').then(a => {
  239. cy.log(
  240. 'bypass target=_blank and navigate current browser tab/cypress-iframe to project invite'
  241. )
  242. cy.visit(a.attr('href')!)
  243. })
  244. })
  245. cy.url().should('match', /\/project\/[a-f0-9]+\/invite\/token\/[a-f0-9]+/)
  246. cy.findByText(/user would like you to join/)
  247. cy.contains(new RegExp(`You are accepting this invite as ${email}`))
  248. redirectEditorUrlWithQueryParams(newEditor)
  249. cy.findByText('Join Project').click()
  250. waitForMainDocToLoad()
  251. if (newEditor) {
  252. // Close the beta intro modal if it appears
  253. // TODO ide-redesign-cleanup: Remove this when the intro modal is removed
  254. cy.get('body').type('{esc}')
  255. }
  256. }
  257. export function enableLinkSharing() {
  258. let linkSharingReadOnly: string
  259. let linkSharingReadAndWrite: string
  260. const origin = new URL(Cypress.config().baseUrl!).origin
  261. waitForMainDocToLoad()
  262. cy.findByText('Share').click()
  263. cy.findByText('Turn on link sharing').click()
  264. cy.findByText('Anyone with this link can view this project')
  265. .next()
  266. .should('contain.text', origin + '/read')
  267. .then(el => {
  268. linkSharingReadOnly = el.text()
  269. })
  270. cy.findByText('Anyone with this link can edit this project')
  271. .next()
  272. .should('contain.text', origin + '/')
  273. .then(el => {
  274. linkSharingReadAndWrite = el.text()
  275. })
  276. return cy.then(() => {
  277. return { linkSharingReadOnly, linkSharingReadAndWrite }
  278. })
  279. }
  280. export function waitForMainDocToLoad() {
  281. cy.log('Wait for main doc to load; it will steal the focus after loading')
  282. cy.get('.cm-content').should('contain.text', 'Introduction')
  283. }
  284. export function openFile(fileName: string, waitFor: string) {
  285. // force: The file-tree pane is too narrow to display the full name.
  286. cy.findByRole('navigation', { name: 'Project files and outline' })
  287. .findByRole('treeitem', { name: fileName })
  288. .click({ force: true })
  289. // wait until we've switched to the selected file
  290. cy.findByRole('status').should('not.exist')
  291. cy.findByText(waitFor)
  292. }
  293. export function createNewFile() {
  294. const fileName = `${uuid()}.tex`
  295. cy.log('create new project file')
  296. cy.get('button').contains('New file').click({ force: true })
  297. cy.findByRole('dialog').within(() => {
  298. cy.get('input').clear()
  299. cy.get('input').type(fileName)
  300. cy.findByText('Create').click()
  301. })
  302. // force: The file-tree pane is too narrow to display the full name.
  303. cy.findByTestId('file-tree')
  304. .findByRole('treeitem', { name: fileName })
  305. .click({ force: true })
  306. // wait until we've switched to the newly created empty file
  307. cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
  308. cy.findByRole('status').should('not.exist')
  309. cy.get('.cm-line').should('have.length', 1)
  310. })
  311. cy.get('.cm-line').should('have.length', 1)
  312. return fileName
  313. }
  314. export function expectFileExists(
  315. name: string,
  316. binary: boolean,
  317. content: string
  318. ) {
  319. cy.findByRole('treeitem', { name }).click()
  320. if (binary) {
  321. cy.findByText(content).should('not.have.class', 'cm-line')
  322. } else {
  323. cy.findByText(content).should('have.class', 'cm-line')
  324. }
  325. }
  326. export function prepareFileUploadTest(binary = false) {
  327. const name = `${uuid()}.txt`
  328. const content = `Test File Content ${name}${binary ? ' \x00' : ''}`
  329. cy.get('button').contains('Upload').click({ force: true })
  330. cy.get('input[type=file]')
  331. .first()
  332. .selectFile(
  333. {
  334. contents: Cypress.Buffer.from(content),
  335. fileName: name,
  336. lastModified: Date.now(),
  337. },
  338. { force: true }
  339. )
  340. // wait for the upload to finish
  341. cy.findByRole('treeitem', { name })
  342. return () => expectFileExists(name, binary, content)
  343. }
  344. export function testNewFileUpload() {
  345. it('can upload text file', function () {
  346. const check = prepareFileUploadTest(false)
  347. check()
  348. })
  349. it('can upload binary file', function () {
  350. const check = prepareFileUploadTest(true)
  351. check()
  352. })
  353. }