git-bridge.spec.ts 8.9 KB

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