276 lines
10 KiB
JavaScript
276 lines
10 KiB
JavaScript
import { Range } from 'monaco-editor';
|
|
import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame';
|
|
import setWindowLocation from 'helpers/set_window_location_helper';
|
|
import {
|
|
ERROR_INSTANCE_REQUIRED_FOR_EXTENSION,
|
|
EDITOR_TYPE_CODE,
|
|
EDITOR_TYPE_DIFF,
|
|
} from '~/editor/constants';
|
|
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
|
|
|
|
jest.mock('~/helpers/startup_css_helper', () => {
|
|
return {
|
|
waitForCSSLoaded: jest.fn().mockImplementation((cb) => {
|
|
// We have to artificially put the callback's execution
|
|
// to the end of the current call stack to be able to
|
|
// test that the callback is called after waitForCSSLoaded.
|
|
// setTimeout with 0 delay does exactly that.
|
|
// Otherwise we might end up with false positive results
|
|
setTimeout(() => {
|
|
cb.apply();
|
|
}, 0);
|
|
}),
|
|
};
|
|
});
|
|
|
|
describe('The basis for an Source Editor extension', () => {
|
|
const defaultLine = 3;
|
|
let ext;
|
|
let event;
|
|
|
|
const defaultOptions = { foo: 'bar' };
|
|
const findLine = (num) => {
|
|
return document.querySelector(`.line-numbers:nth-child(${num})`);
|
|
};
|
|
const generateLines = () => {
|
|
let res = '';
|
|
for (let line = 1, lines = 5; line <= lines; line += 1) {
|
|
res += `<div class="line-numbers">${line}</div>`;
|
|
}
|
|
return res;
|
|
};
|
|
const generateEventMock = ({ line = defaultLine, el = null } = {}) => {
|
|
return {
|
|
target: {
|
|
element: el || findLine(line),
|
|
position: {
|
|
lineNumber: line,
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
beforeEach(() => {
|
|
setFixtures(generateLines());
|
|
event = generateEventMock();
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('resets the layout in waitForCSSLoaded callback', async () => {
|
|
const instance = {
|
|
layout: jest.fn(),
|
|
};
|
|
ext = new SourceEditorExtension({ instance });
|
|
expect(instance.layout).not.toHaveBeenCalled();
|
|
|
|
// We're waiting for the waitForCSSLoaded mock to kick in
|
|
await jest.runOnlyPendingTimers();
|
|
|
|
expect(instance.layout).toHaveBeenCalled();
|
|
});
|
|
|
|
it.each`
|
|
description | instance | options
|
|
${'accepts configuration options and instance'} | ${{}} | ${defaultOptions}
|
|
${'leaves instance intact if no options are passed'} | ${{}} | ${undefined}
|
|
${'does not fail if both instance and the options are omitted'} | ${undefined} | ${undefined}
|
|
${'throws if only options are passed'} | ${undefined} | ${defaultOptions}
|
|
`('$description', ({ instance, options } = {}) => {
|
|
SourceEditorExtension.deferRerender = jest.fn();
|
|
const originalInstance = { ...instance };
|
|
|
|
if (instance) {
|
|
if (options) {
|
|
Object.entries(options).forEach((prop) => {
|
|
expect(instance[prop]).toBeUndefined();
|
|
});
|
|
// Both instance and options are passed
|
|
ext = new SourceEditorExtension({ instance, ...options });
|
|
Object.entries(options).forEach(([prop, value]) => {
|
|
expect(ext[prop]).toBeUndefined();
|
|
expect(instance[prop]).toBe(value);
|
|
});
|
|
} else {
|
|
ext = new SourceEditorExtension({ instance });
|
|
expect(instance).toEqual(originalInstance);
|
|
}
|
|
} else if (options) {
|
|
// Options are passed without instance
|
|
expect(() => {
|
|
ext = new SourceEditorExtension({ ...options });
|
|
}).toThrow(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
|
|
} else {
|
|
// Neither options nor instance are passed
|
|
expect(() => {
|
|
ext = new SourceEditorExtension();
|
|
}).not.toThrow();
|
|
}
|
|
});
|
|
|
|
it('initializes the line highlighting', () => {
|
|
SourceEditorExtension.deferRerender = jest.fn();
|
|
const spy = jest.spyOn(SourceEditorExtension, 'highlightLines');
|
|
ext = new SourceEditorExtension({ instance: {} });
|
|
expect(spy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('sets up the line linking for code instance', () => {
|
|
SourceEditorExtension.deferRerender = jest.fn();
|
|
const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking');
|
|
const instance = {
|
|
getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_CODE),
|
|
onMouseMove: jest.fn(),
|
|
onMouseDown: jest.fn(),
|
|
};
|
|
ext = new SourceEditorExtension({ instance });
|
|
expect(spy).toHaveBeenCalledWith(instance);
|
|
});
|
|
|
|
it('does not set up the line linking for diff instance', () => {
|
|
SourceEditorExtension.deferRerender = jest.fn();
|
|
const spy = jest.spyOn(SourceEditorExtension, 'setupLineLinking');
|
|
const instance = {
|
|
getEditorType: jest.fn().mockReturnValue(EDITOR_TYPE_DIFF),
|
|
};
|
|
ext = new SourceEditorExtension({ instance });
|
|
expect(spy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('highlightLines', () => {
|
|
const revealSpy = jest.fn();
|
|
const decorationsSpy = jest.fn();
|
|
const instance = {
|
|
revealLineInCenter: revealSpy,
|
|
deltaDecorations: decorationsSpy,
|
|
};
|
|
const defaultDecorationOptions = { isWholeLine: true, className: 'active-line-text' };
|
|
|
|
useFakeRequestAnimationFrame();
|
|
|
|
beforeEach(() => {
|
|
setWindowLocation('https://localhost');
|
|
});
|
|
|
|
it.each`
|
|
desc | hash | shouldReveal | expectedRange
|
|
${'properly decorates a single line'} | ${'#L10'} | ${true} | ${[10, 1, 10, 1]}
|
|
${'properly decorates multiple lines'} | ${'#L7-42'} | ${true} | ${[7, 1, 42, 1]}
|
|
${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${true} | ${[7, 1, 42, 1]}
|
|
${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${true} | ${[7, 1, 7, 1]}
|
|
${'does not highlight if there is no hash'} | ${''} | ${false} | ${null}
|
|
${'does not highlight if the hash is undefined'} | ${undefined} | ${false} | ${null}
|
|
${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${false} | ${null}
|
|
${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${false} | ${null}
|
|
`('$desc', ({ hash, shouldReveal, expectedRange } = {}) => {
|
|
window.location.hash = hash;
|
|
SourceEditorExtension.highlightLines(instance);
|
|
if (!shouldReveal) {
|
|
expect(revealSpy).not.toHaveBeenCalled();
|
|
expect(decorationsSpy).not.toHaveBeenCalled();
|
|
} else {
|
|
expect(revealSpy).toHaveBeenCalledWith(expectedRange[0]);
|
|
expect(decorationsSpy).toHaveBeenCalledWith(
|
|
[],
|
|
[
|
|
{
|
|
range: new Range(...expectedRange),
|
|
options: defaultDecorationOptions,
|
|
},
|
|
],
|
|
);
|
|
}
|
|
});
|
|
|
|
it('stores the line decorations on the instance', () => {
|
|
decorationsSpy.mockReturnValue('foo');
|
|
window.location.hash = '#L10';
|
|
expect(instance.lineDecorations).toBeUndefined();
|
|
SourceEditorExtension.highlightLines(instance);
|
|
expect(instance.lineDecorations).toBe('foo');
|
|
});
|
|
});
|
|
|
|
describe('setupLineLinking', () => {
|
|
const instance = {
|
|
onMouseMove: jest.fn(),
|
|
onMouseDown: jest.fn(),
|
|
deltaDecorations: jest.fn(),
|
|
lineDecorations: 'foo',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
SourceEditorExtension.onMouseMoveHandler(event); // generate the anchor
|
|
});
|
|
|
|
it.each`
|
|
desc | spy
|
|
${'onMouseMove'} | ${instance.onMouseMove}
|
|
${'onMouseDown'} | ${instance.onMouseDown}
|
|
`('sets up the $desc listener', ({ spy } = {}) => {
|
|
SourceEditorExtension.setupLineLinking(instance);
|
|
expect(spy).toHaveBeenCalled();
|
|
});
|
|
|
|
it.each`
|
|
desc | eventTrigger | shouldRemove
|
|
${'does not remove the line decorations if the event is triggered on a wrong node'} | ${null} | ${false}
|
|
${'removes existing line decorations when clicking a line number'} | ${'.link-anchor'} | ${true}
|
|
`('$desc', ({ eventTrigger, shouldRemove } = {}) => {
|
|
event = generateEventMock({ el: eventTrigger ? document.querySelector(eventTrigger) : null });
|
|
instance.onMouseDown.mockImplementation((fn) => {
|
|
fn(event);
|
|
});
|
|
|
|
SourceEditorExtension.setupLineLinking(instance);
|
|
if (shouldRemove) {
|
|
expect(instance.deltaDecorations).toHaveBeenCalledWith(instance.lineDecorations, []);
|
|
} else {
|
|
expect(instance.deltaDecorations).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('onMouseMoveHandler', () => {
|
|
it('stops propagation for contextmenu event on the generated anchor', () => {
|
|
SourceEditorExtension.onMouseMoveHandler(event);
|
|
const anchor = findLine(defaultLine).querySelector('a');
|
|
const contextMenuEvent = new Event('contextmenu');
|
|
|
|
jest.spyOn(contextMenuEvent, 'stopPropagation');
|
|
anchor.dispatchEvent(contextMenuEvent);
|
|
|
|
expect(contextMenuEvent.stopPropagation).toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates an anchor if it does not exist yet', () => {
|
|
expect(findLine(defaultLine).querySelector('a')).toBe(null);
|
|
SourceEditorExtension.onMouseMoveHandler(event);
|
|
expect(findLine(defaultLine).querySelector('a')).not.toBe(null);
|
|
});
|
|
|
|
it('does not create a new anchor if it exists', () => {
|
|
SourceEditorExtension.onMouseMoveHandler(event);
|
|
expect(findLine(defaultLine).querySelector('a')).not.toBe(null);
|
|
|
|
SourceEditorExtension.createAnchor = jest.fn();
|
|
SourceEditorExtension.onMouseMoveHandler(event);
|
|
expect(SourceEditorExtension.createAnchor).not.toHaveBeenCalled();
|
|
expect(findLine(defaultLine).querySelectorAll('a')).toHaveLength(1);
|
|
});
|
|
|
|
it('does not create a link if the event is triggered on a wrong node', () => {
|
|
setFixtures('<div class="wrong-class">3</div>');
|
|
SourceEditorExtension.createAnchor = jest.fn();
|
|
const wrongEvent = generateEventMock({ el: document.querySelector('.wrong-class') });
|
|
|
|
SourceEditorExtension.onMouseMoveHandler(wrongEvent);
|
|
expect(SourceEditorExtension.createAnchor).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|