blob: e164913be21936d3affc3e64cc4294cff9ef2f67 [file]
/**
* @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);
});
});
});