git-bridge.spec.ts 9.1 KB

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