git-bridge.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import { startWith } from './helpers/config'
  2. import { ensureUserExists, login } from './helpers/login'
  3. import { createProject } from './helpers/project'
  4. import git from 'isomorphic-git'
  5. import http from 'isomorphic-git/http/web'
  6. import LightningFS from '@isomorphic-git/lightning-fs'
  7. import { throttledRecompile } from './helpers/compile'
  8. describe('git-bridge', function () {
  9. const ENABLED_VARS = {
  10. GIT_BRIDGE_ENABLED: 'true',
  11. GIT_BRIDGE_HOST: 'git-bridge',
  12. GIT_BRIDGE_PORT: '8000',
  13. V1_HISTORY_URL: 'http://sharelatex:3100/api',
  14. }
  15. describe('enabled in Server Pro', function () {
  16. startWith({
  17. pro: true,
  18. vars: ENABLED_VARS,
  19. })
  20. ensureUserExists({ email: 'user@example.com' })
  21. function clearAllTokens() {
  22. cy.get('button.linking-git-bridge-revoke-button').each(el => {
  23. cy.wrap(el).click()
  24. cy.findByText('Delete token').click()
  25. })
  26. }
  27. function maybeClearAllTokens() {
  28. cy.visit('/user/settings')
  29. cy.findByText('Git Integration')
  30. cy.get('button')
  31. .contains(/Generate token|Add another token/)
  32. .then(btn => {
  33. if (btn.text() === 'Add another token') {
  34. clearAllTokens()
  35. }
  36. })
  37. }
  38. beforeEach(function () {
  39. login('user@example.com')
  40. maybeClearAllTokens()
  41. })
  42. it('should render the git-bridge UI in the settings', () => {
  43. cy.visit('/user/settings')
  44. cy.findByText('Git Integration')
  45. cy.get('button').contains('Generate token').click()
  46. cy.get('code')
  47. .contains(/olp_[a-zA-Z0-9]{16}/)
  48. .as('newToken')
  49. cy.findAllByText('Close').last().click()
  50. cy.get('@newToken').then(token => {
  51. // There can be more than one token with the same prefix when retrying
  52. cy.findAllByText(
  53. `${token.text().slice(0, 'olp_1234'.length)}${'*'.repeat(12)}`
  54. ).should('have.length.at.least', 1)
  55. })
  56. cy.get('button').contains('Generate token').should('not.exist')
  57. cy.get('button').contains('Add another token').should('exist')
  58. clearAllTokens()
  59. cy.get('button').contains('Generate token').should('exist')
  60. cy.get('button').contains('Add another token').should('not.exist')
  61. })
  62. it('should render the git-bridge UI in the editor', function () {
  63. cy.visit('/project')
  64. createProject('git').as('projectId')
  65. cy.get('header').findByText('Menu').click()
  66. cy.findByText('Sync')
  67. cy.findByText('Git').click()
  68. cy.findByRole('dialog').within(() => {
  69. cy.get('@projectId').then(id => {
  70. cy.get('code').contains(`git clone http://git@sharelatex/git/${id}`)
  71. })
  72. cy.findByRole('button', {
  73. name: 'Generate token',
  74. }).click()
  75. cy.get('code').contains(/olp_[a-zA-Z0-9]{16}/)
  76. })
  77. // Re-open
  78. cy.url().then(url => cy.visit(url))
  79. cy.get('header').findByText('Menu').click()
  80. cy.findByText('Git').click()
  81. cy.findByRole('dialog').within(() => {
  82. cy.get('@projectId').then(id => {
  83. cy.get('code').contains(`git clone http://git@sharelatex/git/${id}`)
  84. })
  85. cy.findByText('Generate token').should('not.exist')
  86. cy.findByText(/generate a new one in Account Settings/)
  87. cy.findByText('Go to settings')
  88. .should('have.attr', 'target', '_blank')
  89. .and('have.attr', 'href', '/user/settings')
  90. })
  91. })
  92. it('should expose interface for git', () => {
  93. cy.visit('/project')
  94. createProject('git').as('projectId')
  95. const recompile = throttledRecompile()
  96. cy.get('header').findByText('Menu').click()
  97. cy.findByText('Sync')
  98. cy.findByText('Git').click()
  99. cy.get('@projectId').then(projectId => {
  100. cy.findByRole('dialog').within(() => {
  101. cy.get('code').contains(
  102. `git clone http://git@sharelatex/git/${projectId}`
  103. )
  104. })
  105. cy.findByRole('button', {
  106. name: 'Generate token',
  107. }).click()
  108. cy.get('code')
  109. .contains(/olp_[a-zA-Z0-9]{16}/)
  110. .then(async tokenEl => {
  111. const token = tokenEl.text()
  112. // close Git modal
  113. cy.findAllByText('Close').last().click()
  114. // close editor menu
  115. cy.get('#left-menu-modal').click()
  116. // check history
  117. cy.findAllByText('History').last().click()
  118. cy.findByText('(via Git)').should('not.exist')
  119. cy.findAllByText('Back to editor').last().click()
  120. const fs = new LightningFS('fs')
  121. const dir = `/${projectId}`
  122. async function readFile(path: string) {
  123. return new Promise((resolve, reject) => {
  124. fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
  125. if (err) return reject(err)
  126. resolve(blob)
  127. })
  128. })
  129. }
  130. async function writeFile(path: string, data: string) {
  131. return new Promise<void>((resolve, reject) => {
  132. fs.writeFile(path, data, undefined, err => {
  133. if (err) return reject(err)
  134. resolve()
  135. })
  136. })
  137. }
  138. const commonOptions = {
  139. dir,
  140. fs,
  141. }
  142. const httpOptions = {
  143. http,
  144. url: `http://sharelatex/git/${projectId}`,
  145. headers: {
  146. Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`,
  147. },
  148. }
  149. const authorOptions = {
  150. author: { name: 'user', email: 'user@example.com' },
  151. committer: { name: 'user', email: 'user@example.com' },
  152. }
  153. // Clone
  154. cy.then({ timeout: 10_000 }, async () => {
  155. await git.clone({
  156. ...commonOptions,
  157. ...httpOptions,
  158. })
  159. })
  160. const mainTex = `${dir}/main.tex`
  161. const text = `
  162. \\documentclass{article}
  163. \\begin{document}
  164. Hello world
  165. \\end{document}
  166. `
  167. // Make a change
  168. cy.then(async () => {
  169. await writeFile(mainTex, text)
  170. await git.add({
  171. ...commonOptions,
  172. filepath: 'main.tex',
  173. })
  174. await git.commit({
  175. ...commonOptions,
  176. ...authorOptions,
  177. message: 'Swap main.tex',
  178. })
  179. await git.push({
  180. ...commonOptions,
  181. ...httpOptions,
  182. })
  183. })
  184. // check push in editor
  185. cy.findByText(/\\documentclass/)
  186. .parent()
  187. .parent()
  188. .should('have.text', text.replaceAll('\n', ''))
  189. // Wait for history sync - trigger flush by toggling the UI
  190. cy.findAllByText('History').last().click()
  191. cy.findAllByText('Back to editor').last().click()
  192. // check push in history
  193. cy.findAllByText('History').last().click()
  194. cy.findByText(/Hello world/)
  195. cy.findByText('(via Git)').should('exist')
  196. // Back to the editor
  197. cy.findAllByText('Back to editor').last().click()
  198. cy.findByText(/\\documentclass/)
  199. .parent()
  200. .parent()
  201. .click()
  202. .type('% via editor{enter}')
  203. // Trigger flush via compile
  204. recompile()
  205. // Back into the history, check what we just added
  206. cy.findAllByText('History').last().click()
  207. cy.findByText(/% via editor/)
  208. // Pull the change
  209. cy.then(async () => {
  210. await git.pull({
  211. ...commonOptions,
  212. ...httpOptions,
  213. ...authorOptions,
  214. })
  215. expect(await readFile(mainTex)).to.equal(text + '% via editor\n')
  216. })
  217. })
  218. })
  219. })
  220. })
  221. function checkDisabled() {
  222. ensureUserExists({ email: 'user@example.com' })
  223. it('should not render the git-bridge UI in the settings', () => {
  224. login('user@example.com')
  225. cy.visit('/user/settings')
  226. cy.findByText('Git Integration').should('not.exist')
  227. })
  228. it('should not render the git-bridge UI in the editor', function () {
  229. login('user@example.com')
  230. cy.visit('/project')
  231. createProject('maybe git')
  232. cy.get('header').findByText('Menu').click()
  233. cy.findByText('Word Count') // wait for lazy loading
  234. cy.findByText('Sync').should('not.exist')
  235. cy.findByText('Git').should('not.exist')
  236. })
  237. }
  238. describe('disabled in Server Pro', () => {
  239. startWith({
  240. pro: true,
  241. })
  242. checkDisabled()
  243. })
  244. describe('unavailable in CE', () => {
  245. startWith({
  246. pro: false,
  247. vars: ENABLED_VARS,
  248. })
  249. checkDisabled()
  250. })
  251. })