| /** |
| * @license |
| * Copyright 2026 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {assert, fixture, html} from '@open-wc/testing'; |
| import '../../../test/common-test-setup'; |
| import './gr-diff-markdown-viewer'; |
| import {GrDiffMarkdownViewer} from './gr-diff-markdown-viewer'; |
| import {DiffInfo} from '../../../types/diff'; |
| import {CommentSide, DiffViewMode} from '../../../constants/constants'; |
| import { |
| createAccountDetailWithId, |
| createComment, |
| createCommentThread, |
| createDiff, |
| createPatchRange, |
| } from '../../../test/test-data-generators'; |
| import {testResolver} from '../../../test/common-test-setup'; |
| import { |
| CommentsModel, |
| commentsModelToken, |
| } from '../../../models/comments/comments-model'; |
| import {UserModel, userModelToken} from '../../../models/user/user-model'; |
| import { |
| CommentThread, |
| DraftInfo, |
| RevisionPatchSetNum, |
| } from '../../../types/common'; |
| import sinon from 'sinon'; |
| |
| suite('gr-diff-markdown-viewer tests', () => { |
| let element: GrDiffMarkdownViewer; |
| let commentsModel: CommentsModel; |
| let userModel: UserModel; |
| |
| setup(async () => { |
| commentsModel = testResolver(commentsModelToken); |
| userModel = testResolver(userModelToken); |
| userModel.setAccount(createAccountDetailWithId(1)); |
| |
| element = await fixture<GrDiffMarkdownViewer>( |
| html`<gr-diff-markdown-viewer></gr-diff-markdown-viewer>` |
| ); |
| }); |
| |
| test('renders empty when no diff is provided', () => { |
| const grid = element.shadowRoot!.querySelector('.diff-grid'); |
| assert.isNotNull(grid); |
| assert.equal(grid.children.length, 0); |
| }); |
| |
| test('renders aligned markdown rows', async () => { |
| const diff: DiffInfo = { |
| ...createDiff(), |
| content: [ |
| {ab: ['# Title', '']}, |
| {a: ['Old paragraph.'], b: ['New paragraph.']}, |
| {b: ['', '- Added bullet']}, |
| ], |
| }; |
| |
| element.diff = diff; |
| await element.updateComplete; |
| |
| const grid = element.shadowRoot!.querySelector('.diff-grid'); |
| assert.isNotNull(grid); |
| // At least 3 rows = 6 cells (left and right) |
| const cells = grid.querySelectorAll('.diff-cell'); |
| assert.isAtLeast(cells.length, 4); |
| |
| // Verify left and right column headers |
| const leftHeader = element.shadowRoot!.querySelector('.column-header.left'); |
| const rightHeader = element.shadowRoot!.querySelector( |
| '.column-header.right' |
| ); |
| assert.equal(leftHeader!.textContent!.trim(), 'Base'); |
| assert.equal(rightHeader!.textContent!.trim(), 'Revision'); |
| }); |
| |
| test('highlights modified and added cells correctly', async () => { |
| const diff: DiffInfo = { |
| ...createDiff(), |
| content: [{a: ['Deleted text.'], b: ['Added text.']}], |
| }; |
| |
| element.diff = diff; |
| await element.updateComplete; |
| |
| const modifiedLeft = element.shadowRoot!.querySelector<HTMLElement>( |
| '.diff-cell.modified-left' |
| ); |
| const modifiedRight = element.shadowRoot!.querySelector<HTMLElement>( |
| '.diff-cell.modified-right' |
| ); |
| assert.isNotNull(modifiedLeft); |
| assert.isNotNull(modifiedRight); |
| const styleLeft = window.getComputedStyle(modifiedLeft); |
| const styleRight = window.getComputedStyle(modifiedRight); |
| assert.isOk(styleLeft.backgroundColor); |
| assert.notEqual(styleLeft.backgroundColor, 'rgba(0, 0, 0, 0)'); |
| assert.notEqual(styleLeft.backgroundColor, 'transparent'); |
| assert.isOk(styleRight.backgroundColor); |
| assert.notEqual(styleRight.backgroundColor, 'rgba(0, 0, 0, 0)'); |
| assert.notEqual(styleRight.backgroundColor, 'transparent'); |
| }); |
| |
| test('renders deleted block with red background in side-by-side mode', async () => { |
| const diff: DiffInfo = { |
| ...createDiff(), |
| content: [{a: ['Only deleted paragraph.']}], |
| }; |
| |
| element.diff = diff; |
| await element.updateComplete; |
| |
| const deletedCell = |
| element.shadowRoot!.querySelector<HTMLElement>('.diff-cell.deleted'); |
| assert.isNotNull(deletedCell); |
| const style = window.getComputedStyle(deletedCell); |
| assert.isOk(style.backgroundColor); |
| assert.notEqual(style.backgroundColor, 'rgba(0, 0, 0, 0)'); |
| assert.notEqual(style.backgroundColor, 'transparent'); |
| }); |
| |
| test('renders unified diff mode correctly', async () => { |
| const diff: DiffInfo = { |
| ...createDiff(), |
| content: [ |
| {ab: ['# Unchanged title', '']}, |
| {a: ['Deleted block.'], b: ['Added block.']}, |
| ], |
| }; |
| |
| element.diff = diff; |
| element.viewMode = DiffViewMode.UNIFIED; |
| await element.updateComplete; |
| |
| const unifiedContainer = |
| element.shadowRoot!.querySelector('.unified-container'); |
| assert.isNotNull(unifiedContainer); |
| |
| const headers = element.shadowRoot!.querySelector('.column-headers'); |
| assert.isNull(headers); |
| |
| const unchangedBlock = element.shadowRoot!.querySelector( |
| '.unified-block.unchanged' |
| ); |
| assert.isNotNull(unchangedBlock); |
| |
| const deletedBlock = element.shadowRoot!.querySelector( |
| '.unified-block.deleted' |
| ); |
| assert.isNotNull(deletedBlock); |
| |
| const addedBlock = element.shadowRoot!.querySelector( |
| '.unified-block.added' |
| ); |
| assert.isNotNull(addedBlock); |
| |
| // Switch back to side-by-side (split) mode |
| element.viewMode = DiffViewMode.SIDE_BY_SIDE; |
| await element.updateComplete; |
| |
| assert.isNull(element.shadowRoot!.querySelector('.unified-container')); |
| assert.isNotNull(element.shadowRoot!.querySelector('.diff-grid')); |
| assert.isNotNull(element.shadowRoot!.querySelector('.column-headers')); |
| }); |
| |
| suite('comment threads rendering and creation', () => { |
| const diff: DiffInfo = { |
| ...createDiff(), |
| content: [ |
| {ab: ['# Title', '']}, |
| {a: ['Old paragraph.'], b: ['New paragraph.']}, |
| ], |
| }; |
| |
| test('renders file-level and block-level threads in side-by-side mode', async () => { |
| const fileThread: CommentThread = createCommentThread([ |
| {...createComment(), line: undefined, message: 'File comment'}, |
| ]); |
| const titleThread: CommentThread = createCommentThread([ |
| { |
| ...createComment(), |
| line: 1, |
| message: 'Title comment', |
| patch_set: 1 as RevisionPatchSetNum, |
| }, |
| ]); |
| const baseParagraphThread: CommentThread = createCommentThread([ |
| { |
| ...createComment(), |
| line: 3, |
| message: 'Old paragraph comment', |
| side: CommentSide.PARENT, |
| }, |
| ]); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| element.threads = [fileThread, titleThread, baseParagraphThread]; |
| await element.updateComplete; |
| |
| // File level threads section |
| const fileSection = element.shadowRoot!.querySelector( |
| '.file-level-threads' |
| ); |
| assert.isNotNull(fileSection); |
| const fileThreads = fileSection.querySelectorAll('gr-comment-thread'); |
| assert.equal(fileThreads.length, 1); |
| |
| // Block threads inside diff cells |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| // Row 0 left: Title (unchanged, line 1) |
| // Row 0 right: Title (unchanged, line 1) -> contains titleThread |
| const rightTitleCell = cells[1]; |
| const rightTitleThreads = |
| rightTitleCell.querySelectorAll('gr-comment-thread'); |
| assert.equal(rightTitleThreads.length, 1); |
| |
| // Row 1 left: Old paragraph (modified-left, line 3) -> contains baseParagraphThread |
| const leftOldCell = cells[2]; |
| const leftOldThreads = leftOldCell.querySelectorAll('gr-comment-thread'); |
| assert.equal(leftOldThreads.length, 1); |
| }); |
| |
| test('clicking add comment button calls commentsModel.addNewDraft', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| // Row 0 right (Title, line 1) |
| const rightBtn = cells[1].querySelector<HTMLElement>('.add-comment-btn'); |
| assert.isNotNull(rightBtn); |
| rightBtn.click(); |
| |
| assert.isTrue(addDraftSpy.calledOnce); |
| const draft1: DraftInfo = addDraftSpy.firstCall.firstArg; |
| assert.equal(draft1.path, 'test.md'); |
| assert.equal(draft1.side, CommentSide.REVISION); |
| assert.equal(draft1.line, 1); |
| |
| // Row 1 left (Old paragraph, line 3) |
| const leftBtn = cells[2].querySelector<HTMLElement>('.add-comment-btn'); |
| assert.isNotNull(leftBtn); |
| leftBtn.click(); |
| |
| assert.isTrue(addDraftSpy.calledTwice); |
| const draft2: DraftInfo = addDraftSpy.secondCall.firstArg; |
| assert.equal(draft2.path, 'test.md'); |
| assert.equal(draft2.side, CommentSide.PARENT); |
| assert.equal(draft2.line, 3); |
| }); |
| |
| test('prevents commenting when logged out and fires show-auth-required', async () => { |
| userModel.setAccount(undefined); |
| await element.updateComplete; |
| |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| let authFired = false; |
| element.addEventListener('show-auth-required', () => { |
| authFired = true; |
| }); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const btn = |
| element.shadowRoot!.querySelector<HTMLElement>('.add-comment-btn'); |
| assert.isNotNull(btn); |
| btn.click(); |
| |
| assert.isFalse(addDraftSpy.called); |
| assert.isTrue(authFired); |
| }); |
| |
| test('renders threads and handles commenting in unified mode', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| const thread: CommentThread = createCommentThread([ |
| { |
| ...createComment(), |
| line: 1, |
| message: 'Unified comment', |
| patch_set: 1 as RevisionPatchSetNum, |
| }, |
| ]); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| element.threads = [thread]; |
| element.viewMode = DiffViewMode.UNIFIED; |
| await element.updateComplete; |
| |
| const unifiedContainer = |
| element.shadowRoot!.querySelector('.unified-container'); |
| assert.isNotNull(unifiedContainer); |
| |
| const threads = unifiedContainer.querySelectorAll('gr-comment-thread'); |
| assert.equal(threads.length, 1); |
| |
| const btn = |
| unifiedContainer.querySelector<HTMLElement>('.add-comment-btn'); |
| assert.isNotNull(btn); |
| btn.click(); |
| |
| assert.isTrue(addDraftSpy.calledOnce); |
| const draft: DraftInfo = addDraftSpy.firstCall.firstArg; |
| assert.equal(draft.path, 'test.md'); |
| assert.equal(draft.line, 1); |
| }); |
| |
| test('pressing c when cell is hovered creates draft on that line and side', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| // Row 1 left (line 3, Side.LEFT) |
| const leftCell = cells[2]; |
| leftCell.dispatchEvent(new MouseEvent('mouseenter')); |
| |
| window.dispatchEvent( |
| new KeyboardEvent('keydown', {key: 'c', bubbles: true}) |
| ); |
| |
| assert.isTrue(addDraftSpy.calledOnce); |
| const draft: DraftInfo = addDraftSpy.firstCall.firstArg; |
| assert.equal(draft.path, 'test.md'); |
| assert.equal(draft.side, CommentSide.PARENT); |
| assert.equal(draft.line, 3); |
| }); |
| |
| test('selecting text displays selection action box and clicking creates draft', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| const rightCell = cells[1]; |
| const textNode = rightCell.querySelector('.cell-content')?.firstChild; |
| assert.isNotNull(textNode); |
| |
| const range = document.createRange(); |
| range.selectNodeContents(textNode!); |
| const selection = window.getSelection()!; |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| document.dispatchEvent(new Event('selectionchange')); |
| await element.updateComplete; |
| |
| const actionBox = element.shadowRoot!.querySelector( |
| '.selection-action-box' |
| ); |
| assert.isNotNull(actionBox); |
| const actionBoxStyle = window.getComputedStyle(actionBox); |
| assert.equal(actionBoxStyle.zIndex, '500'); |
| |
| const commentBtn = actionBox.querySelector<HTMLElement>( |
| '.selection-comment-btn' |
| ); |
| assert.isNotNull(commentBtn); |
| const btnStyle = window.getComputedStyle(commentBtn); |
| assert.isOk(btnStyle.backgroundColor); |
| assert.notEqual(btnStyle.backgroundColor, 'transparent'); |
| assert.notEqual(btnStyle.backgroundColor, 'rgba(0, 0, 0, 0)'); |
| |
| commentBtn.click(); |
| |
| assert.isTrue(addDraftSpy.calledOnce); |
| const draft: DraftInfo = addDraftSpy.firstCall.firstArg; |
| assert.equal(draft.path, 'test.md'); |
| assert.equal(draft.side, CommentSide.REVISION); |
| assert.equal(draft.line, 1); |
| }); |
| |
| test('text selection is preserved after selectionchange event', async () => { |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| const rightCell = cells[1]; |
| const textNode = rightCell.querySelector('.cell-content')?.firstChild; |
| assert.isNotNull(textNode); |
| |
| const range = document.createRange(); |
| range.selectNodeContents(textNode!); |
| const selection = window.getSelection()!; |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| document.dispatchEvent(new Event('selectionchange')); |
| await element.updateComplete; |
| |
| assert.isFalse(selection.isCollapsed); |
| assert.equal(selection.rangeCount, 1); |
| assert.isTrue( |
| element.shadowRoot!.contains(selection.getRangeAt(0).startContainer) |
| ); |
| }); |
| |
| test('pressing c with active text selection creates draft', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| const leftCell = cells[2]; |
| const textNode = leftCell.querySelector('.cell-content')?.firstChild; |
| assert.isNotNull(textNode); |
| |
| const range = document.createRange(); |
| range.selectNodeContents(textNode!); |
| const selection = window.getSelection()!; |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| document.dispatchEvent(new Event('selectionchange')); |
| await element.updateComplete; |
| |
| window.dispatchEvent( |
| new KeyboardEvent('keydown', {key: 'c', bubbles: true}) |
| ); |
| |
| assert.isTrue(addDraftSpy.calledOnce); |
| const draft: DraftInfo = addDraftSpy.firstCall.firstArg; |
| assert.equal(draft.path, 'test.md'); |
| assert.equal(draft.side, CommentSide.PARENT); |
| assert.equal(draft.line, 3); |
| }); |
| |
| test('pressing C (uppercase) with active text selection creates draft', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| const leftCell = cells[2]; |
| const textNode = leftCell.querySelector('.cell-content')?.firstChild; |
| assert.isNotNull(textNode); |
| |
| const range = document.createRange(); |
| range.selectNodeContents(textNode!); |
| const selection = window.getSelection()!; |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| document.dispatchEvent(new Event('selectionchange')); |
| await element.updateComplete; |
| |
| window.dispatchEvent( |
| new KeyboardEvent('keydown', {key: 'C', bubbles: true}) |
| ); |
| |
| assert.isTrue(addDraftSpy.calledOnce); |
| const draft: DraftInfo = addDraftSpy.firstCall.firstArg; |
| assert.equal(draft.path, 'test.md'); |
| assert.equal(draft.side, CommentSide.PARENT); |
| assert.equal(draft.line, 3); |
| }); |
| |
| test('pressing c and C in unified mode with active selection creates draft', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| element.viewMode = DiffViewMode.UNIFIED; |
| await element.updateComplete; |
| |
| const block = element.shadowRoot!.querySelector('.unified-block.added'); |
| assert.isNotNull(block); |
| const textNode = block.querySelector('.cell-content')?.firstChild; |
| assert.isNotNull(textNode); |
| |
| const range = document.createRange(); |
| range.selectNodeContents(textNode!); |
| const selection = window.getSelection()!; |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| document.dispatchEvent(new Event('selectionchange')); |
| await element.updateComplete; |
| |
| assert.isTrue(element.hasActiveSelection()); |
| |
| window.dispatchEvent( |
| new KeyboardEvent('keydown', {key: 'C', bubbles: true}) |
| ); |
| |
| assert.isTrue(addDraftSpy.calledOnce); |
| const draft: DraftInfo = addDraftSpy.firstCall.firstArg; |
| assert.equal(draft.path, 'test.md'); |
| assert.equal(draft.side, CommentSide.REVISION); |
| assert.equal(draft.line, 3); |
| }); |
| |
| test('pressing Ctrl+C or Meta+C with selection does not create draft', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| const rightCell = cells[1]; |
| const textNode = rightCell.querySelector('.cell-content')?.firstChild; |
| assert.isNotNull(textNode); |
| |
| const range = document.createRange(); |
| range.selectNodeContents(textNode!); |
| const selection = window.getSelection()!; |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| document.dispatchEvent(new Event('selectionchange')); |
| await element.updateComplete; |
| |
| window.dispatchEvent( |
| new KeyboardEvent('keydown', {key: 'c', ctrlKey: true, bubbles: true}) |
| ); |
| window.dispatchEvent( |
| new KeyboardEvent('keydown', {key: 'c', metaKey: true, bubbles: true}) |
| ); |
| window.dispatchEvent( |
| new KeyboardEvent('keydown', {key: 'C', ctrlKey: true, bubbles: true}) |
| ); |
| |
| assert.isFalse(addDraftSpy.called); |
| }); |
| |
| test('hasActiveSelection returns true when selection is inside diff and false otherwise', async () => { |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| assert.isFalse(element.hasActiveSelection()); |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| const rightCell = cells[1]; |
| const textNode = rightCell.querySelector('.cell-content')?.firstChild; |
| assert.isNotNull(textNode); |
| |
| const range = document.createRange(); |
| range.selectNodeContents(textNode!); |
| const selection = window.getSelection()!; |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| assert.isTrue(element.hasActiveSelection()); |
| |
| selection.removeAllRanges(); |
| assert.isFalse(element.hasActiveSelection()); |
| }); |
| |
| test('createCommentFromSelectionOrHover creates comment on selection', async () => { |
| const addDraftSpy = sinon.spy(commentsModel, 'addNewDraft'); |
| |
| element.diff = diff; |
| element.patchRange = createPatchRange(); |
| element.path = 'test.md'; |
| await element.updateComplete; |
| |
| const cells = element.shadowRoot!.querySelectorAll('.diff-cell'); |
| const rightCell = cells[1]; |
| const textNode = rightCell.querySelector('.cell-content')?.firstChild; |
| assert.isNotNull(textNode); |
| |
| const range = document.createRange(); |
| range.selectNodeContents(textNode!); |
| const selection = window.getSelection()!; |
| selection.removeAllRanges(); |
| selection.addRange(range); |
| |
| element.createCommentFromSelectionOrHover(); |
| |
| assert.isTrue(addDraftSpy.calledOnce); |
| const draft: DraftInfo = addDraftSpy.firstCall.firstArg; |
| assert.equal(draft.path, 'test.md'); |
| assert.equal(draft.side, CommentSide.REVISION); |
| assert.equal(draft.line, 1); |
| }); |
| }); |
| }); |