import CodeMirrorEditor from '../../../../frontend/js/features/source-editor/components/codemirror-editor' import { EditorProviders, makeProjectProvider, 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' import { mockProject } from '../source-editor/helpers/mock-project' 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' describe('', function () { beforeEach(function () { window.metaAttributesCache.set('ol-preventCompileOnLoad', true) cy.interceptEvents() cy.intercept('GET', '/project/*/changes/users', [ { id: USER_ID, email: USER_EMAIL, first_name: 'Test', last_name: 'User', }, ]) 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' ) 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, }, }, }) const project = mockProject({ projectOwner: { _id: USER_ID, }, projectFeatures: { trackChanges: false, trackChangesVisible: true }, }) cy.wrap(scope).as('scope') cy.mount( ) // 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 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' ) }) }) 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('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('should close panel when pressing close button', function () { cy.get('@review-panel').within(() => { cy.findByLabelText('Close').click({ scrollBehavior: false }) }) // We should collapse to the mini state cy.get('.review-panel-mini').should('exist') }) }) describe('navigation', function () { it('renders navigation', function () { cy.get('@review-panel').within(() => { cy.findByRole('tab', { name: /current file/i }) cy.findByRole('tab', { name: /overview/i }) }) }) it('selects the active tab', function () { cy.get('@review-panel').within(() => { cy.findByRole('tab', { name: /current file/i }).should( 'have.attr', 'aria-selected', 'true' ) cy.findByRole('tab', { name: /overview/i }).should( 'have.attr', 'aria-selected', 'false' ) cy.findByRole('tab', { name: /overview/i }).click() cy.findByRole('tab', { name: /current file/i }).should( 'have.attr', 'aria-selected', 'false' ) cy.findByRole('tab', { name: /overview/i }).should( 'have.attr', 'aria-selected', 'true' ) }) }) }) describe('comment entries', 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') }) }) 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') }) }) }) 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') }) 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') }) 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') }) 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') }) 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 () { it('renders inserted entries in current file mode', function () { cy.get('@review-panel').within(() => { cy.findByText('Added:').should('exist') cy.findByText('introduction').should('exist') }) }) it('renders deleted entries in current file mode', function () { cy.get('@review-panel').within(() => { cy.findByText('Deleted:').should('exist') cy.findByText('beautiful').should('exist') }) }) 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') }) // 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') }) }) }) 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 () { // eslint-disable-next-line mocha/no-skipped-tests it.skip('renders changed entries in current file mode', function () {}) // eslint-disable-next-line mocha/no-skipped-tests it.skip('renders changed entries in overview mode', function () {}) }) describe('add comment entry', 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') }) it('renders floating `add comment button`', function () { cy.get('@add-comment-button').should('exist') }) 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 () { 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' ) }) it('renders the reject and accept all buttons`', function () { cy.get('@accept-selected-changes').should('exist') cy.get('@reject-selected-changes').should('exist') }) 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 () { 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') }) 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 mini mode', function () { function render({ comments = [], changes = [], threads = {} }: any) { window.metaAttributesCache.set('ol-preventCompileOnLoad', true) cy.interceptEvents() cy.intercept('GET', '/project/*/changes/users', [ { id: USER_ID, email: USER_EMAIL, first_name: 'Test', last_name: 'User', }, ]) const getChanges = cy.stub().as('getChanges').returns([]) const removeChangeIds = cy.stub().as('removeChangeIds') const scope = mockScope(undefined, { docOptions: { rangesOptions: { comments, changes, getChanges, removeChangeIds, }, }, projectFeatures: { trackChangesVisible: true }, }) const project = mockProject({ projectFeatures: { trackChangesVisible: true }, }) cy.intercept('GET', '/project/*/ranges', [ { id: docId, ranges: { changes, comments, docId, }, }, ]) cy.intercept('GET', '/project/*/threads', threads) cy.intercept('POST', `/project/*/doc/${docId}/metadata`, {}) cy.wrap(scope).as('scope') cy.mount( ) // Wait for editor cy.get('.cm-content').should('have.css', 'opacity', '1') // Toggle the review panel twice to ensure data is loaded cy.findByText('contentLine 0').type('{command}jj', { scrollBehavior: false, }) cy.findByText('contentLine 1').type('{ctrl}jj', { scrollBehavior: false }) } it("doesn't render mini when no comments or changes are present in project", function () { render({ comments: [], changes: [], threads: {}, }) cy.get('.review-panel-mini').should('not.exist') }) it("doesn't render mini when no comments or changes are present in document", function () { render({ comments: [], changes: [], threads: { 'random-unrelated-thread': { messages: [ { content: 'a comment', id: 'random-unrelated-thread-1', timestamp: new Date('2025-01-01T01:00:00.000Z'), user: userData, user_id: USER_ID, }, ], }, }, }) cy.get('.review-panel-mini').should('not.exist') }) it("doesn't render mini when a resolved comment is present in document", function () { render({ comments: [ { id: resolvedThreadId, op: { p: 161, c: 'Your introduction', t: resolvedThreadId }, }, ], changes: [], threads: { [resolvedThreadId]: { resolved: true, resolved_at: new Date('2025-01-02T00:00:00.000Z').toISOString(), resolved_by_user_id: USER_ID, resolved_by_user: userData, messages: [ { content: 'a comment', id: `${resolvedThreadId}-1`, timestamp: new Date('2025-01-01T01:00:00.000Z'), user: userData, user_id: USER_ID, }, ], }, }, }) cy.get('.review-panel-mini').should('not.exist') }) it('renders mini when an unresolved comment is present in document', function () { render({ comments: [ { id: unresolvedThreadId, op: { p: 161, c: 'Your introduction', t: unresolvedThreadId }, }, ], changes: [], threads: { [unresolvedThreadId]: { messages: [ { content: 'a comment', id: `${unresolvedThreadId}-1`, timestamp: new Date('2025-01-01T01:00:00.000Z'), user: userData, user_id: USER_ID, }, ], }, }, }) cy.get('.review-panel-mini').should('exist') }) it('renders mini when a tracked change is present in document', function () { render({ comments: [], changes: [ { 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' }, }, ], threads: {}, }) cy.get('.review-panel-mini').should('exist') }) }) describe(' for free users', function () { function mountEditor(ownerId = USER_ID) { const scope = mockScope(undefined, { permissions: { write: true, trackedWrite: false, comment: true }, }) const project = mockProject({ projectFeatures: { trackChanges: false, trackChangesVisible: true }, projectOwner: { _id: ownerId, }, }) cy.wrap(scope).as('scope') cy.mount( ) cy.findByLabelText('Editing').click() cy.findByRole('menu').within(() => { cy.findByText(/Reviewing/).click() }) } beforeEach(function () { window.metaAttributesCache.set('ol-preventCompileOnLoad', true) cy.interceptEvents() cy.intercept('GET', '/project/*/changes/users', []) cy.intercept('GET', '/project/*/threads', {}) }) it('renders modal', function () { mountEditor() cy.findByRole('dialog').within(() => { cy.findByText('Upgrade to Review').should('exist') }) }) it('closes modal', function () { mountEditor() cy.findByRole('dialog').within(() => { cy.findByText('Close').click() }) cy.findByRole('dialog').should('not.exist') }) 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 () {}) 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') }) }) })