Przeglądaj źródła

Merge pull request #25435 from overleaf/mj-review-panel-tests

[web] Add review panel cypress tests

GitOrigin-RevId: e953519fc8fed089df59970ee3c745b06d78ddfb
Mathias Jakobsen 1 rok temu
rodzic
commit
3c3414a7d3

+ 6 - 2
services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx

@@ -295,7 +295,11 @@ const ReviewPanelCurrentFile: FC = () => {
   }
 
   return (
-    <>
+    <div
+      role="tabpanel"
+      id="review-panel-current-file"
+      aria-labelledby="review-panel-tab-button-current-file"
+    >
       {showEmptyState && <ReviewPanelEmptyState />}
       {onMoreCommentsAboveClick && (
         <ReviewPanelMoreCommentsButton
@@ -356,7 +360,7 @@ const ReviewPanelCurrentFile: FC = () => {
           direction="downward"
         />
       )}
-    </>
+    </div>
   )
 }
 

+ 1 - 2
services/web/frontend/js/features/review-panel-new/components/review-panel-header.tsx

@@ -7,9 +7,8 @@ import getMeta from '@/utils/meta'
 import { PanelHeading } from '@/shared/components/panel-heading'
 import useReviewPanelLayout from '../hooks/use-review-panel-layout'
 
-const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
-
 const ReviewPanelHeader: FC = () => {
+  const isReviewerRoleEnabled = getMeta('ol-isReviewerRoleEnabled')
   const [trackChangesMenuExpanded, setTrackChangesMenuExpanded] =
     useState(false)
   const { closeReviewPanel } = useReviewPanelLayout()

+ 5 - 1
services/web/frontend/js/features/review-panel-new/components/review-panel-overview.tsx

@@ -48,7 +48,11 @@ export const ReviewPanelOverview: FC = () => {
   }, [rangesForDocs])
 
   return (
-    <div className="review-panel-overview">
+    <div
+      className="review-panel-overview"
+      id="review-panel-overview"
+      aria-labelledby="review-panel-tab-button-overview"
+    >
       {error && <div>{t('something_went_wrong')}</div>}
 
       {showEmptyState && <ReviewPanelEmptyState />}

+ 1 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-resolved-threads-button.tsx

@@ -27,6 +27,7 @@ export const ReviewPanelResolvedThreadsButton: FC = () => {
           }
           ref={buttonRef}
           onClick={() => setExpanded(true)}
+          aria-label={t('resolved_comments')}
         >
           <MaterialIcon type="inbox" />
         </button>

+ 8 - 0
services/web/frontend/js/features/review-panel-new/components/review-panel-tabs.tsx

@@ -16,6 +16,10 @@ const ReviewPanelTabs: FC = () => {
   return (
     <>
       <button
+        role="tab"
+        aria-selected={subView === 'cur_file'}
+        aria-controls="review-panel-current-file"
+        id="review-panel-tab-button-current-file"
         className={classnames('review-panel-tab', {
           'review-panel-tab-active': subView === 'cur_file',
         })}
@@ -25,6 +29,10 @@ const ReviewPanelTabs: FC = () => {
         {t('current_file')}
       </button>
       <button
+        role="tab"
+        aria-selected={subView === 'overview'}
+        aria-controls="review-panel-overview"
+        id="review-panel-tab-button-overview"
         className={classnames('review-panel-tab', {
           'review-panel-tab-active': subView === 'overview',
         })}

+ 6 - 2
services/web/frontend/js/features/review-panel-new/components/review-panel.tsx

@@ -25,14 +25,18 @@ const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => {
   })
 
   return (
-    <div className={className} style={style}>
+    <div className={className} style={style} data-testid="review-panel">
       <div id="review-panel-inner" className="review-panel-inner">
         {!newEditor && !mini && <ReviewPanelHeader />}
 
         {activeSubView === 'cur_file' && <ReviewPanelCurrentFile />}
         {activeSubView === 'overview' && <ReviewPanelOverview />}
 
-        <div className="review-panel-footer">
+        <div
+          className="review-panel-footer"
+          id="review-panel-tabs"
+          role="tablist"
+        >
           <ReviewPanelTabs />
         </div>
       </div>

+ 2 - 0
services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx

@@ -244,6 +244,7 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
             <button
               className="review-tooltip-menu-button"
               onClick={acceptChangesHandler}
+              aria-label={t('accept_selected_changes')}
             >
               <MaterialIcon type="check" />
             </button>
@@ -256,6 +257,7 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
             <button
               className="review-tooltip-menu-button"
               onClick={rejectChangesHandler}
+              aria-label={t('reject_selected_changes')}
             >
               <MaterialIcon type="clear" />
             </button>

+ 568 - 154
services/web/test/frontend/features/review-panel/review-panel.spec.tsx

@@ -1,21 +1,188 @@
 import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor'
-import { EditorProviders } from '../../helpers/editor-providers'
+import {
+  EditorProviders,
+  USER_EMAIL,
+  USER_ID,
+} from '../../helpers/editor-providers'
 import { mockScope } from '../source-editor/helpers/mock-scope'
 import { TestContainer } from '../source-editor/helpers/test-container'
+import { docId } from '../source-editor/helpers/mock-doc'
 
-// TODO: update tests and re-enable once reviewer role is active
-// eslint-disable-next-line mocha/no-skipped-tests
-describe.skip('<ReviewPanel />', function () {
+describe('<ReviewPanel />', function () {
   beforeEach(function () {
+    window.metaAttributesCache.set('ol-isReviewerRoleEnabled', true)
     window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
 
     cy.interceptEvents()
 
-    const scope = mockScope('')
-    scope.editor.showVisual = true
+    cy.intercept('GET', '/project/*/changes/users', [
+      {
+        id: USER_ID,
+        email: USER_EMAIL,
+        first_name: 'Test',
+        last_name: 'User',
+      },
+    ])
+
+    const userData = {
+      avatar_text: 'User',
+      email: USER_EMAIL,
+      hue: 180,
+      id: USER_ID,
+      isSelf: true,
+      first_name: 'Test',
+      last_name: 'User',
+    }
+
+    const resolvedThreadId = 'resolved-thread-id'
+    const unresolvedThreadId = 'unresolved-thread-id'
+
+    cy.intercept('GET', '/project/*/threads', {
+      // Resolved comment thread
+      [resolvedThreadId]: {
+        messages: [
+          {
+            content: 'comment text',
+            id: `${resolvedThreadId}-1`,
+            timestamp: new Date('2025-01-01T00:00:00.000Z'),
+            user: userData,
+            user_id: USER_ID,
+          },
+        ],
+        resolved: true,
+        resolved_at: new Date('2025-01-02T00:00:00.000Z').toISOString(),
+        resolved_by_user_id: USER_ID,
+        resolved_by_user: userData,
+      },
+      // Unresolved comment thread
+      [unresolvedThreadId]: {
+        messages: [
+          {
+            content: 'unresolved comment text',
+            id: `${unresolvedThreadId}-1`,
+            timestamp: new Date('2025-01-01T00:00:00.000Z'),
+            user: userData,
+            user_id: USER_ID,
+          },
+          {
+            content: 'reply to thread',
+            id: `${unresolvedThreadId}-2`,
+            timestamp: new Date('2025-01-01T01:00:00.000Z'),
+            user: userData,
+            user_id: USER_ID,
+          },
+        ],
+      },
+    })
+
+    const commentOps = [
+      {
+        id: 'resolved-op-id',
+        op: { p: 161, c: 'Your introduction', t: resolvedThreadId },
+      },
+      {
+        id: 'unresolved-op-id',
+        op: { p: 210, c: 'Your results', t: unresolvedThreadId },
+      },
+    ]
+
+    const changesOps = [
+      {
+        metadata: {
+          user_id: USER_ID,
+          ts: new Date('2025-01-01T00:00:00.000Z'),
+        },
+        id: 'inserted-op-id',
+        op: { p: 166, t: 'inserted-op-id', i: 'introduction' },
+      },
+      {
+        metadata: {
+          user_id: USER_ID,
+          ts: new Date('2025-01-01T01:00:00.000Z'),
+        },
+        id: 'deleted-op-id',
+        op: { p: 110, t: 'deleted-op-id', d: 'beautiful ' },
+      },
+    ]
+
+    cy.intercept('GET', '/project/*/ranges', [
+      {
+        id: docId,
+        ranges: {
+          changes: changesOps,
+          comments: commentOps,
+          docId,
+        },
+      },
+    ])
+
+    cy.intercept(
+      'POST',
+      `/project/*/doc/${docId}/thread/${resolvedThreadId}/reopen`,
+      {}
+    ).as('reopenThread')
+
+    cy.intercept(
+      'POST',
+      `/project/*/doc/${docId}/thread/${unresolvedThreadId}/resolve`,
+      {}
+    ).as('resolveThreadId')
+
+    cy.intercept(
+      'POST',
+      `/project/*/thread/${unresolvedThreadId}/messages/${unresolvedThreadId}-1/edit`,
+      {}
+    ).as('editComment')
+
+    cy.intercept(
+      'POST',
+      `/project/*/thread/${unresolvedThreadId}/messages`,
+      {}
+    ).as('addReply')
+
+    cy.intercept(
+      'POST',
+      /\/project\/.*\/thread\/[a-z0-9]{24}\/messages/,
+      {}
+    ).as('addNewComment')
+
+    cy.intercept(
+      'DELETE',
+      `/project/*/doc/${docId}/thread/${resolvedThreadId}`,
+      {}
+    ).as('deleteResolvedThread')
+
+    cy.intercept(
+      'DELETE',
+      `/project/*/thread/${unresolvedThreadId}/messages/${unresolvedThreadId}-2`,
+      {}
+    ).as('deleteComment')
+
+    cy.intercept(
+      'DELETE',
+      `/project/*/doc/${docId}/thread/${unresolvedThreadId}`,
+      {}
+    ).as('deleteThread')
+
+    cy.intercept('POST', `/project/*/doc/${docId}/changes/accept`, {}).as(
+      'acceptChange'
+    )
 
-    // The tests expect no documents, so remove them from the scope
-    scope.project.rootFolder = []
+    cy.intercept('POST', `/project/*/doc/${docId}/metadata`, {})
+
+    const getChanges = cy.stub().as('getChanges').returns([])
+    const removeChangeIds = cy.stub().as('removeChangeIds')
+
+    const scope = mockScope(undefined, {
+      docOptions: {
+        rangesOptions: {
+          comments: commentOps,
+          changes: changesOps,
+          getChanges,
+          removeChangeIds,
+        },
+      },
+    })
 
     cy.wrap(scope).as('scope')
 
@@ -27,107 +194,80 @@ describe.skip('<ReviewPanel />', function () {
       </TestContainer>
     )
 
+    // Open the review panel with keyboard shortcut
+    cy.findByText('contentLine 0').type('{command}j', { scrollBehavior: false })
+    cy.findByText('contentLine 1').type('{ctrl}j', { scrollBehavior: false })
+
     cy.findByTestId('review-panel').as('review-panel')
   })
 
   describe('toolbar', function () {
     describe('resolved comments dropdown', function () {
-      it('renders dropdown button', function () {
-        cy.findByRole('button', { name: /resolved comments/i })
-      })
-
-      // eslint-disable-next-line mocha/no-skipped-tests
-      it.skip('opens dropdown', function () {
-        cy.findByRole('button', { name: /resolved comments/i }).click()
-        // TODO dropdown opens/closes
-      })
-
-      // eslint-disable-next-line mocha/no-skipped-tests
-      it.skip('renders list of resolved comments', function () {})
-
-      // eslint-disable-next-line mocha/no-skipped-tests
-      it.skip('reopens resolved comment', function () {})
-
-      // eslint-disable-next-line mocha/no-skipped-tests
-      it.skip('deletes resolved comment', function () {})
-    })
-
-    describe('track changes toggle menu', function () {
-      it('renders track changes toolbar', function () {
-        cy.get('@review-panel').within(() => {
-          cy.findByRole('button', { name: /track changes is (on|off)$/i })
-        })
-      })
-
-      it('opens/closes toggle menu', function () {
-        cy.get('@review-panel').within(() => {
-          cy.findByTestId('review-panel-track-changes-menu').should('not.exist')
-          cy.findByRole('button', { name: /track changes is/i }).click()
-          // verify the menu is expanded
-          cy.findByTestId('review-panel-track-changes-menu')
-            .as('menu')
-            .then($el => {
-              const height = window
-                .getComputedStyle($el[0])
-                .getPropertyValue('height')
-              return parseFloat(height)
-            })
-            .should('be.gt', 1)
-          cy.findByRole('button', { name: /track changes is/i }).click()
-          cy.get('@menu').should('not.exist')
-        })
-      })
-
-      it('toggles the "everyone" track changes switch', function () {
-        cy.get('@review-panel').within(() => {
-          cy.findByRole('button', { name: /track changes is off/i }).click()
-          cy.findByLabelText(/track changes for everyone/i).click({
-            force: true,
+      it('renders a dropdown of resolved comments', function () {
+        // The dropdown button should be visible
+        cy.findByLabelText('Resolved comments').click()
+        // It should open the dropdown
+        cy.findByRole('tooltip')
+          .should('exist')
+          .within(() => {
+            // TODO: Fix selector
+            cy.get(
+              '.review-panel-resolved-comments-header .badge-content'
+            ).should('contain.text', '1')
+            // Should name the document with the comment
+            cy.findByText('test.tex').should('exist')
+            // Should show the comment text
+            cy.findByText('comment text').should('exist')
+            // Should show the author name
+            // TODO: Fix selector
+            cy.get('.review-panel-entry-user').should(
+              'contain.text',
+              'Test User'
+            )
           })
-          cy.findByLabelText(/track changes for everyone/i).should('be.checked')
-          // TODO: assert that track changes is on for everyone
-        })
       })
 
-      // eslint-disable-next-line mocha/no-skipped-tests
-      it.skip('renders track changes with "on" state', function () {
-        const scope = mockScope('')
-        scope.editor.showVisual = true
-        scope.editor.wantTrackChanges = true
-
-        cy.mount(
-          <TestContainer className="rp-size-expanded">
-            <EditorProviders scope={scope}>
-              <CodeMirrorEditor />
-            </EditorProviders>
-          </TestContainer>
-        )
-
-        cy.findByTestId('review-panel').within(() => {
-          cy.findByRole('button', { name: /track changes is on/i }).click()
+      it('reopens resolved comment', function () {
+        cy.findByLabelText('Resolved comments').click()
+        cy.findByRole('tooltip').within(() => {
+          // Find the re-open icon button using the hidden label
+          cy.findByText('Re-open').click({ force: true })
+          // verify the reopen thread API call
+          cy.wait('@reopenThread')
+
+          // TODO: Figure out a way to plumb the websocket response back through
+          // to the test so we can verify the comment is no longer resolved
+          // cy.get(
+          //   '.review-panel-resolved-comments-header .badge-content'
+          // ).should('contain.text', '0')
         })
       })
 
-      it('renders a disabled guests switch', function () {
-        cy.findByRole('button', { name: /track changes is off/i }).click()
-        cy.findByLabelText(/track changes for guests/i).should('be.disabled')
+      it('deletes resolved comment', function () {
+        cy.findByLabelText('Resolved comments').click()
+        cy.findByRole('tooltip').within(() => {
+          // Find the Delete icon button using the hidden label
+          cy.findByText('Delete').click({ force: true })
+          // verify the delete thread API call
+          cy.wait('@deleteResolvedThread')
+
+          // TODO: Figure out a way to plumb the websocket response back through
+          // to the test so we can verify the comment is no longer there
+          // cy.get(
+          //   '.review-panel-resolved-comments-header .badge-content'
+          // ).should('contain.text', '0')
+        })
       })
     })
   })
 
   describe('toggler', function () {
-    it('renders toggler button', function () {
+    it('should close panel when pressing close button', function () {
       cy.get('@review-panel').within(() => {
-        cy.findByRole('button', { name: /toggle review panel/i })
-      })
-    })
-
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('calls the toggler function on click', function () {
-      cy.get('@review-panel').within(() => {
-        cy.findByRole('button', { name: /toggle review panel/i }).click()
-        cy.get('@scope').its('toggleReviewPanel').should('be.calledOnce')
+        cy.findByLabelText('Close').click({ scrollBehavior: false })
       })
+      // We should collapse to the mini state
+      cy.get('.review-panel-mini').should('exist')
     })
   })
 
@@ -167,46 +307,177 @@ describe.skip('<ReviewPanel />', function () {
   })
 
   describe('comment entries', function () {
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('shows threads and comments', function () {})
+    it('shows threads and comments', function () {
+      cy.get('@review-panel').within(() => {
+        cy.findByText('unresolved comment text').should('exist')
+        cy.findByText('reply to thread').should('exist')
+      })
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('edits comment', function () {})
+    it('edits comment', function () {
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-comment-wrapper')
+          .first()
+          .within(() => {
+            // Find the options icon button using the hidden label
+            cy.findByText('More options')
+              .first()
+              .click({ force: true, scrollBehavior: false })
+            cy.findByRole('menu').within(() => {
+              cy.findByText('Edit').click({ scrollBehavior: false })
+            })
+            cy.findByRole('textbox').type(
+              '{selectAll}edited comment text{enter}',
+              { scrollBehavior: false }
+            )
+            cy.wait('@editComment')
+            // TODO: Figure out a way to plumb the websocket response back through
+            // to the test so we can verify the comment is resolved
+            // cy.findByText('edited comment text').should('exist')
+          })
+      })
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('deletes comment', function () {})
+    it('deletes thread', function () {
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-comment-wrapper')
+          .first()
+          .within(() => {
+            // Find the options icon button using the hidden label
+            cy.findByText('More options')
+              .first()
+              .click({ force: true, scrollBehavior: false })
+            cy.findByRole('menu').within(() => {
+              cy.findByText('Delete').click({ scrollBehavior: false })
+            })
+          })
+      })
+      cy.findByRole('dialog').within(() => {
+        cy.findByRole('button', { name: 'Delete' }).click()
+      })
+      cy.wait('@deleteThread')
+      // TODO: Figure out a way to plumb the websocket response back through
+      // to the test so we can verify the thread is deleted
+      // cy.findByText('unresolved comment text').should('not.exist')
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('cancels comment editing', function () {})
+    it('deletes reply', function () {
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-comment-wrapper')
+          .eq(1)
+          .within(() => {
+            // Find the options icon button using the hidden label
+            cy.findByText('More options')
+              .first()
+              .click({ force: true, scrollBehavior: false })
+            cy.findByRole('menu').within(() => {
+              cy.findByText('Delete').click({ scrollBehavior: false })
+            })
+          })
+      })
+      cy.findByRole('dialog').within(() => {
+        cy.findByRole('button', { name: 'Delete' }).click()
+      })
+      cy.wait('@deleteComment')
+      // TODO: Figure out a way to plumb the websocket response back through
+      // to the test so we can verify the reply is deleted
+      // cy.findByText('reply to thread').should('not.exist')
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('cancels comment deletion', function () {})
+    it('cancels comment deletion', function () {
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-comment-wrapper')
+          .eq(1)
+          .within(() => {
+            // Find the options icon button using the hidden label
+            cy.findByText('More options')
+              .first()
+              .click({ force: true, scrollBehavior: false })
+            cy.findByRole('menu').within(() => {
+              cy.findByText('Delete').click({ scrollBehavior: false })
+            })
+          })
+      })
+      cy.findByRole('dialog').within(() => {
+        cy.findByRole('button', { name: 'Cancel' }).click()
+      })
+      cy.findByText('unresolved comment text').should('exist')
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('adds new comment (replies) to a thread', function () {})
+    it('adds new comment (replies) to a thread', function () {
+      cy.get('@review-panel').within(() => {
+        cy.findByRole('textbox').type('a new reply{enter}', {
+          scrollBehavior: false,
+        })
+      })
+      cy.wait('@addReply')
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('resolves comment', function () {})
+    it('resolves comment', function () {
+      cy.get('@review-panel').within(() => {
+        // Find the resolve icon button using the hidden label
+        cy.findByText('Resolve comment').click({ force: true })
+        cy.wait('@resolveThreadId')
+        // TODO: Figure out a way to plumb the websocket response back through
+        // to the test so we can verify the comment is resolved
+        // cy.findByText('unresolved comment text').should('not.exist')
+      })
+    })
   })
 
   describe('change entries', function () {
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders inserted entries in current file mode', function () {})
-
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders deleted entries in current file mode', function () {})
+    it('renders inserted entries in current file mode', function () {
+      cy.get('@review-panel').within(() => {
+        cy.findByText('Added:').should('exist')
+        cy.findByText('introduction').should('exist')
+      })
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders inserted entries in overview mode', function () {})
+    it('renders deleted entries in current file mode', function () {
+      cy.get('@review-panel').within(() => {
+        cy.findByText('Deleted:').should('exist')
+        cy.findByText('beautiful').should('exist')
+      })
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders deleted entries in overview mode', function () {})
+    it('accepts change', function () {
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-entry-insert').within(() => {
+          // Find the accept icon button using the hidden label
+          cy.findByText('Accept change').click({ force: true })
+          cy.wait('@acceptChange')
+        })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('accepts change', function () {})
+        // TODO: Fix selector
+        cy.get('.review-panel-entry-delete').within(() => {
+          // Find the accept icon button using the hidden label
+          cy.findByText('Accept change').click({ force: true })
+          cy.wait('@acceptChange')
+        })
+      })
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('rejects change', function () {})
+    it('rejects change', function () {
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-entry-insert').within(() => {
+          // Find the reject icon button using the hidden label
+          cy.findByText('Reject change').click({ force: true })
+          cy.get('@getChanges').should('be.calledOnce')
+        })
+        // TODO: Fix selector
+        cy.get('.review-panel-entry-delete').within(() => {
+          // Find the reject icon button using the hidden label
+          cy.findByText('Reject change').click({ force: true })
+          cy.get('@getChanges').should('be.calledTwice')
+        })
+      })
+    })
   })
 
   describe('aggregate change entries', function () {
@@ -218,63 +489,206 @@ describe.skip('<ReviewPanel />', function () {
   })
 
   describe('add comment entry', function () {
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders `add comment button`', function () {})
+    beforeEach(function () {
+      cy.findByText('contentLine 12').type(
+        '{home}{shift}' + '{rightArrow}'.repeat(6),
+        { scrollBehavior: false }
+      )
+      // TODO: Fix selector
+      cy.get('.review-tooltip-add-comment-button').as('add-comment-button')
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('cancels adding comment', function () {})
+    it('renders floating `add comment button`', function () {
+      cy.get('@add-comment-button').should('exist')
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('adds comment', function () {})
+    it('can add comment', function () {
+      cy.get('@add-comment-button').click({ scrollBehavior: false })
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-add-comment-textarea').type(
+          'a new comment{enter}',
+          {
+            scrollBehavior: false,
+          }
+        )
+      })
+      cy.wait('@addNewComment')
+      // TODO : Figure out a way to plumb the websocket response back through
+      // to the test so we can verify the comment is added
+      // cy.findByText('a new comment').should('exist')
+    })
+
+    it('cancels adding comment', function () {
+      cy.get('@add-comment-button').click({ scrollBehavior: false })
+      cy.get('@review-panel').within(() => {
+        cy.findByRole('button', { name: 'Cancel' }).click({
+          scrollBehavior: false,
+        })
+      })
+    })
   })
 
   describe('bulk actions entry', function () {
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders the reject and accept all buttons`', function () {})
+    beforeEach(function () {
+      // Select a deletion and an insertion
+      cy.findByText('\\maketitle').type(
+        '{home}{shift}' + '{downArrow}'.repeat(10),
+        { scrollBehavior: false }
+      )
+      cy.findByLabelText('Accept selected changes').as(
+        'accept-selected-changes'
+      )
+      cy.findByLabelText('Reject selected changes').as(
+        'reject-selected-changes'
+      )
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('accepts all changes', function () {})
+    it('renders the reject and accept all buttons`', function () {
+      cy.get('@accept-selected-changes').should('exist')
+      cy.get('@reject-selected-changes').should('exist')
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('rejects all changes', function () {})
+    it('accepts all changes', function () {
+      cy.get('@accept-selected-changes').click({ scrollBehavior: false })
+      cy.findByRole('dialog').within(() => {
+        cy.findByText(
+          'Are you sure you want to accept the selected 2 changes?'
+        ).should('exist')
+        cy.findByRole('button', { name: 'OK' }).click({
+          scrollBehavior: false,
+        })
+        cy.wait('@acceptChange')
+        cy.get('@removeChangeIds').should('have.been.calledWith', [
+          'inserted-op-id',
+          'deleted-op-id',
+        ])
+      })
+    })
+
+    it('rejects all changes', function () {
+      cy.get('@reject-selected-changes').click({ scrollBehavior: false })
+      cy.findByRole('dialog').within(() => {
+        cy.findByText(
+          'Are you sure you want to reject the selected 2 changes?'
+        ).should('exist')
+        cy.findByRole('button', { name: 'OK' }).click({
+          scrollBehavior: false,
+        })
+        cy.get('@getChanges').should('have.been.calledWith', [
+          'inserted-op-id',
+          'deleted-op-id',
+        ])
+      })
+    })
   })
 
   describe('overview mode', function () {
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('shows list of files changed', function () {})
+    beforeEach(function () {
+      cy.findByRole('tab', { name: /overview/i }).click()
+    })
+    it('shows list of files changed', function () {
+      // TODO: Fix selector
+      cy.get('.collapsible-file-header').should('contain.text', 'test.tex')
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders comments', function () {})
+    it('renders comments', function () {
+      cy.get('@review-panel').within(() => {
+        cy.findByText('unresolved comment text').should('exist')
+        cy.findByText('reply to thread').should('exist')
+      })
+    })
+
+    it('renders changes', function () {
+      cy.get('@review-panel').within(() => {
+        cy.findByText('Added:').should('exist')
+        cy.findByText('introduction').should('exist')
+        cy.findByText('Deleted:').should('exist')
+        cy.findByText('beautiful').should('exist')
+      })
+    })
+
+    it('collapses the file entries when clicked', function () {
+      cy.findByText('test.tex').click()
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-entry').should('not.exist')
+      })
+      cy.findByText('test.tex').click()
+      cy.get('@review-panel').within(() => {
+        // TODO: Fix selector
+        cy.get('.review-panel-entry').should('exist')
+      })
+    })
   })
+})
 
-  describe('in editor widgets', function () {
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('toggle review panel', function () {})
+describe('<ReviewPanel /> for free users', function () {
+  function mountEditor(ownerId = USER_ID) {
+    const scope = mockScope(undefined, {
+      permissions: { write: true, trackedWrite: false, comment: true },
+      projectFeatures: { trackChanges: false },
+      projectOwner: {
+        _id: ownerId,
+      },
+    })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('accepts all changes', function () {})
+    cy.wrap(scope).as('scope')
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('rejects all changes', function () {})
+    cy.mount(
+      <TestContainer className="rp-size-expanded">
+        <EditorProviders scope={scope}>
+          <CodeMirrorEditor />
+        </EditorProviders>
+      </TestContainer>
+    )
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('add comment', function () {})
+    cy.findByLabelText('Editing').click()
+    cy.findByRole('menu').within(() => {
+      cy.findByText(/Reviewing/).click()
+    })
+  }
+
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-isReviewerRoleEnabled', true)
+    window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+    cy.interceptEvents()
+    cy.intercept('GET', '/project/*/changes/users', [])
+    cy.intercept('GET', '/project/*/threads', {})
   })
 
-  describe('upgrade track changes', function () {
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('renders modal', function () {})
+  it('renders modal', function () {
+    mountEditor()
+    cy.findByRole('dialog').within(() => {
+      cy.findByText('Upgrade to Review').should('exist')
+    })
+  })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('closes modal', function () {})
+  it('closes modal', function () {
+    mountEditor()
+    cy.findByRole('dialog').within(() => {
+      cy.findByText('Close').click()
+    })
+    cy.findByRole('dialog').should('not.exist')
+  })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('opens subscription page after clicking on `upgrade`', function () {})
+  it('opens subscription page after clicking on `upgrade`', function () {
+    mountEditor()
+    cy.findByRole('dialog').within(() => {
+      // Verify the button exists. Clicking it will open a new window
+      cy.findByText('Upgrade').should('exist')
+    })
+  })
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('opens subscription page after clicking on `try it for free`', function () {})
+  // eslint-disable-next-line mocha/no-skipped-tests
+  it.skip('opens subscription page after clicking on `try it for free`', function () {})
 
-    // eslint-disable-next-line mocha/no-skipped-tests
-    it.skip('shows `ask project owner to upgrade` message', function () {})
+  it('shows `ask project owner to upgrade` message', function () {
+    mountEditor('other-user-id')
+    cy.findByRole('dialog').within(() => {
+      cy.findByText(
+        'Please ask the project owner to upgrade to use track changes'
+      ).should('exist')
+    })
   })
 })

+ 7 - 1
services/web/test/frontend/features/source-editor/helpers/mock-doc.ts

@@ -53,7 +53,10 @@ class MockShareDoc extends EventEmitter {
   }
 }
 
-export const mockDoc = (content = defaultContent) => {
+export const mockDoc = (
+  content = defaultContent,
+  { rangesOptions = {} } = {}
+) => {
   const mockShareJSDoc: ShareDoc = new MockShareDoc(content)
 
   return {
@@ -92,7 +95,10 @@ export const mockDoc = (content = defaultContent) => {
         },
       }),
       resetDirtyState: () => {},
+      removeCommentId: () => {},
+      ...rangesOptions,
     },
+    submitOp: (op: any) => {},
     setTrackChangesIdSeeds: () => {},
     getTrackingChanges: () => true,
     setTrackingChanges: () => {},

+ 13 - 2
services/web/test/frontend/features/source-editor/helpers/mock-scope.ts

@@ -5,10 +5,18 @@ import { Folder } from '../../../../../types/folder'
 export const rootFolderId = '012345678901234567890123'
 export const figuresFolderId = '123456789012345678901234'
 export const figureId = '234567890123456789012345'
-export const mockScope = (content?: string) => {
+export const mockScope = (
+  content?: string,
+  {
+    docOptions = {},
+    projectFeatures = {},
+    permissions = {},
+    projectOwner = undefined,
+  }: any = {}
+) => {
   return {
     editor: {
-      sharejs_doc: mockDoc(content),
+      sharejs_doc: mockDoc(content, docOptions),
       open_doc_name: 'test.tex',
       open_doc_id: docId,
       showVisual: false,
@@ -61,14 +69,17 @@ export const mockScope = (content?: string) => {
       ] as Folder[],
       features: {
         trackChanges: true,
+        ...projectFeatures,
       },
       trackChangesState: {},
       members: [],
+      owner: projectOwner,
     },
     permissions: {
       comment: true,
       trackedWrite: true,
       write: true,
+      ...permissions,
     },
     ui: {
       reviewPanelOpen: false,