Add JS utility unit tests with Jest (#144)

This commit is contained in:
David Joseph Guzsik 2022-01-31 20:28:38 +01:00 committed by GitHub
parent 5b422a8089
commit 997d6e0cb5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 8496 additions and 180 deletions

View file

@ -4,6 +4,7 @@ on: [push, pull_request]
jobs:
build:
name: 'Build Elixir app'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@ -26,7 +27,8 @@ jobs:
run: |
docker-compose run app mix sobelow --config
docker-compose run app mix deps.audit
lint:
lint-and-test:
name: 'JavaScript Linting and Unit Tests'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
@ -34,7 +36,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
node-version: '16'
- name: Cache node_modules
id: cache-node-modules
@ -48,6 +50,8 @@ jobs:
run: npm ci --ignore-scripts
working-directory: ./assets
- name: Run ESLint
run: npm run lint
- run: npm run lint
working-directory: ./assets
- run: npm run test
working-directory: ./assets

3
.gitignore vendored
View file

@ -57,3 +57,6 @@ npm-debug.log
# Rust binaries
/native/**/target
/.cargo
# Jest coverage
/assets/coverage

View file

@ -1,2 +1,3 @@
js/vendor/*
webpack.config.js
jest.config.js

View file

@ -10,6 +10,7 @@ parserOptions:
plugins:
- '@typescript-eslint'
- jest
globals:
ga: false
@ -223,7 +224,7 @@ rules:
prefer-rest-params: 2
prefer-spread: 0
prefer-template: 2
quote-props: [2, 'as-needed', ]
quote-props: [2, 'as-needed']
quotes: [2, 'single']
radix: 2
require-jsdoc: 0
@ -253,9 +254,34 @@ rules:
yield-star-spacing: 2
yoda: [2, 'never']
# Disable rules which are impossible to satisfy (types require .ts extension)
overrides:
# JavaScript Files
# Disable rules which are impossible to satisfy (types require .ts extension)
- files:
- '*.js'
rules:
'@typescript-eslint/explicit-module-boundary-types': 0
# TypeScript Files
# Some ESLint rules report false errors due to lacking type information. Replacement rules are provided for these, and the originals need to be disabled
- files:
- '*.ts'
rules:
# https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/FAQ.md#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
no-undef: 0
no-unused-vars: 0
'@typescript-eslint/no-unused-vars': [2, {vars: 'all', args: 'after-used'}]
no-redeclare: 0
'@typescript-eslint/no-redeclare': 2
no-extra-parens: 0
'@typescript-eslint/no-extra-parens': 2
no-shadow: 0
'@typescript-eslint/no-shadow': 2
# Jest Tests (also written in TypeScript)
# Disable rules that do not make sense in test files (e.g. testing for undefined input values should be allowed)
- files:
- '*.spec.ts'
- 'test/*.ts'
extends:
- 'plugin:jest/recommended'
rules:
no-undefined: 0

41
assets/jest.config.js Normal file
View file

@ -0,0 +1,41 @@
export default {
collectCoverage: true,
collectCoverageFrom: [
'js/**/*.{js,ts}',
],
coveragePathIgnorePatterns: [
'/node_modules/',
'/.*\\.test\\.ts$',
'.*\\.d\\.ts$',
],
coverageDirectory: '<rootDir>/coverage/',
coverageThreshold: {
global: {
statements: 0,
branches: 0,
functions: 0,
lines: 0,
},
'./js/utils/**/*.js': {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
preset: 'ts-jest/presets/js-with-ts-esm',
setupFilesAfterEnv: ['<rootDir>/test/jest-setup.ts'],
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
moduleNameMapper: {
'./js/(.*)': '<rootDir>/js/$1',
},
transform: {},
globals: {
extensionsToTreatAsEsm: ['.ts', '.js'],
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.json',
useESM: true,
},
},
};

View file

@ -874,4 +874,7 @@ SearchAST.prototype.dumpTree = function() {
return retStrArr.join('\n');
};
// Force module handling for Jest, can be removed after TypeScript migration
export {};
export default parseSearch;

View file

@ -4,7 +4,7 @@
import { fetchJson } from './utils/requests';
import { filterNode } from './imagesclientside';
import { hideEl, showEl } from './utils/dom.js';
import { hideEl, showEl } from './utils/dom';
function handleError(response) {
const errorMessage = '<div>Preview failed to load!</div>';

View file

@ -0,0 +1,191 @@
import { arraysEqual, moveElement } from '../array';
describe('Array Utilities', () => {
describe('moveElement', () => {
describe('empty array', () => {
it('should preserve unexpected behavior', () => {
const input: undefined[] = [];
moveElement(input, 1, 0);
expect(input).toEqual([undefined]);
});
});
describe('swap two items in a 2-item array', () => {
it('should work with descending index parameters', () => {
const input = [true, false];
moveElement(input, 1, 0);
expect(input).toEqual([false, true]);
});
it('should work with ascending index parameters', () => {
const input = [true, false];
moveElement(input, 0, 1);
expect(input).toEqual([false, true]);
});
});
describe('swap first and last item in a 3-item array', () => {
it('should work with descending index parameters', () => {
const input = ['a', 'b', 'c'];
moveElement(input, 2, 0);
expect(input).toEqual(['c', 'a', 'b']);
});
it('should work with ascending index parameters', () => {
const input = ['a', 'b', 'c'];
moveElement(input, 0, 2);
expect(input).toEqual(['b', 'c', 'a']);
});
});
describe('swap items in the middle of a 4-item array', () => {
it('should work with descending index parameters', () => {
const input = ['a', 'b', 'c', 'd'];
moveElement(input, 2, 1);
expect(input).toEqual(['a', 'c', 'b', 'd']);
});
it('should work with ascending index parameters', () => {
const input = ['a', 'b', 'c', 'd'];
moveElement(input, 1, 2);
expect(input).toEqual(['a', 'c', 'b', 'd']);
});
});
});
describe('arraysEqual', () => {
describe('positive cases', () => {
it('should return true for empty arrays', () => {
expect(arraysEqual([], [])).toBe(true);
});
it('should return true for matching arrays', () => {
// Numbers
expect(arraysEqual([0], [0])).toBe(true);
expect(arraysEqual([4e3], [4000])).toBe(true);
expect(arraysEqual([0, 1], [0, 1])).toBe(true);
expect(arraysEqual([1_000_000, 30_000_000], [1_000_000, 30_000_000])).toBe(true);
expect(arraysEqual([0, 1, 2], [0, 1, 2])).toBe(true);
expect(arraysEqual([0, 1, 2, 3], [0, 1, 2, 3])).toBe(true);
const randomNumber = Math.random();
expect(arraysEqual([randomNumber], [randomNumber])).toBe(true);
// Strings
expect(arraysEqual(['a'], ['a'])).toBe(true);
expect(arraysEqual(['abcdef'], ['abcdef'])).toBe(true);
expect(arraysEqual(['a', 'b'], ['a', 'b'])).toBe(true);
expect(arraysEqual(['aaaaa', 'bbbbb'], ['aaaaa', 'bbbbb'])).toBe(true);
expect(arraysEqual(['a', 'b', 'c'], ['a', 'b', 'c'])).toBe(true);
expect(arraysEqual(['a', 'b', 'c', 'd'], ['a', 'b', 'c', 'd'])).toBe(true);
// Object by reference
const uniqueValue = Symbol('item');
expect(arraysEqual([uniqueValue], [uniqueValue])).toBe(true);
// Mixed parameters
const mockObject = { value: Math.random() };
expect(arraysEqual(
['', null, false, uniqueValue, mockObject, Infinity, undefined],
['', null, false, uniqueValue, mockObject, Infinity, undefined]
)).toBe(true);
});
it('should return true for matching up to the first array\'s length', () => {
// Numbers
expect(arraysEqual([0], [0, 1])).toBe(true);
expect(arraysEqual([0, 1], [0, 1, 2])).toBe(true);
// Strings
expect(arraysEqual(['a'], ['a', 'b'])).toBe(true);
expect(arraysEqual(['a', 'b'], ['a', 'b', 'c'])).toBe(true);
// Object by reference
const uniqueValue1 = Symbol('item1');
const uniqueValue2 = Symbol('item2');
expect(arraysEqual([uniqueValue1], [uniqueValue1, uniqueValue2])).toBe(true);
// Mixed parameters
const mockObject = { value: Math.random() };
expect(arraysEqual(
[''],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
expect(arraysEqual(
['', null],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
expect(arraysEqual(
['', null, false],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
expect(arraysEqual(
['', null, false, mockObject],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
expect(arraysEqual(
['', null, false, mockObject, Infinity],
['', null, false, mockObject, Infinity, undefined]
)).toBe(true);
});
});
describe('negative cases', () => {
// FIXME This case should be handled
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should return false for arrays of different length', () => {
// Numbers
expect(arraysEqual([], [0])).toBe(false);
expect(arraysEqual([0], [])).toBe(false);
expect(arraysEqual([0], [0, 0])).toBe(false);
expect(arraysEqual([0, 0], [0])).toBe(false);
// Strings
expect(arraysEqual([], ['a'])).toBe(false);
expect(arraysEqual(['a'], [])).toBe(false);
expect(arraysEqual(['a'], ['a', 'a'])).toBe(false);
expect(arraysEqual(['a', 'a'], ['a'])).toBe(false);
// Mixed parameters
const mockObject = { value: Math.random() };
expect(arraysEqual([], [mockObject])).toBe(false);
expect(arraysEqual([mockObject], [])).toBe(false);
expect(arraysEqual([mockObject, mockObject], [mockObject])).toBe(false);
expect(arraysEqual([mockObject], [mockObject, mockObject])).toBe(false);
});
it('should return false if items up to the first array\'s length differ', () => {
// Numbers
expect(arraysEqual([0], [1])).toBe(false);
expect(arraysEqual([0, 1], [1, 2])).toBe(false);
expect(arraysEqual([0, 1, 2], [1, 2, 3])).toBe(false);
// Strings
expect(arraysEqual(['a'], ['b'])).toBe(false);
expect(arraysEqual(['a', 'b'], ['b', 'c'])).toBe(false);
expect(arraysEqual(['a', 'b', 'c'], ['b', 'c', 'd'])).toBe(false);
// Object by reference
const mockObject1 = { value1: Math.random() };
const mockObject2 = { value2: Math.random() };
expect(arraysEqual([mockObject1], [mockObject2])).toBe(false);
// Mixed parameters
expect(arraysEqual(
['a'],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(
['a', null, true],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(
['a', null, true, mockObject1],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
expect(arraysEqual(
['a', null, true, mockObject1, -Infinity],
['b', null, false, mockObject2, Infinity]
)).toBe(false);
});
});
});
});

Binary file not shown.

View file

@ -0,0 +1,434 @@
import {
$,
$$,
clearEl,
escapeCss,
escapeHtml,
hideEl,
insertBefore,
makeEl,
onLeftClick,
removeEl,
showEl,
toggleEl,
whenReady,
findFirstTextNode,
} from '../dom';
import { getRandomArrayItem, getRandomIntBetween } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom';
describe('DOM Utilities', () => {
const mockSelectors = ['#id', '.class', 'div', '#a .complex--selector:not(:hover)'];
const hiddenClass = 'hidden';
const createHiddenElement: Document['createElement'] = (...params: Parameters<Document['createElement']>) => {
const el = document.createElement(...params);
el.classList.add(hiddenClass);
return el;
};
describe('$', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should call the native querySelector method on document by default', () => {
const spy = jest.spyOn(document, 'querySelector');
mockSelectors.forEach((selector, nthCall) => {
$(selector);
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
});
});
it('should call the native querySelector method on the passed element', () => {
const mockElement = document.createElement('br');
const spy = jest.spyOn(mockElement, 'querySelector');
mockSelectors.forEach((selector, nthCall) => {
// FIXME This will not be necessary once the file is properly typed
$(selector, mockElement as unknown as Document);
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
});
});
});
describe('$$', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should call the native querySelectorAll method on document by default', () => {
const spy = jest.spyOn(document, 'querySelectorAll');
mockSelectors.forEach((selector, nthCall) => {
$$(selector);
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
});
});
it('should call the native querySelectorAll method on the passed element', () => {
const mockElement = document.createElement('br');
const spy = jest.spyOn(mockElement, 'querySelectorAll');
mockSelectors.forEach((selector, nthCall) => {
// FIXME This will not be necessary once the file is properly typed
$$(selector, mockElement as unknown as Document);
expect(spy).toHaveBeenNthCalledWith(nthCall + 1, selector);
});
});
});
describe('showEl', () => {
it(`should remove the ${hiddenClass} class from the provided element`, () => {
const mockElement = createHiddenElement('div');
showEl(mockElement);
expect(mockElement).not.toHaveClass(hiddenClass);
});
it(`should remove the ${hiddenClass} class from all provided elements`, () => {
const mockElements = [
createHiddenElement('div'),
createHiddenElement('a'),
createHiddenElement('strong'),
];
showEl(mockElements);
expect(mockElements[0]).not.toHaveClass(hiddenClass);
expect(mockElements[1]).not.toHaveClass(hiddenClass);
expect(mockElements[2]).not.toHaveClass(hiddenClass);
});
it(`should remove the ${hiddenClass} class from elements provided in multiple arrays`, () => {
const mockElements1 = [
createHiddenElement('div'),
createHiddenElement('a'),
];
const mockElements2 = [
createHiddenElement('strong'),
createHiddenElement('em'),
];
showEl(mockElements1, mockElements2);
expect(mockElements1[0]).not.toHaveClass(hiddenClass);
expect(mockElements1[1]).not.toHaveClass(hiddenClass);
expect(mockElements2[0]).not.toHaveClass(hiddenClass);
expect(mockElements2[1]).not.toHaveClass(hiddenClass);
});
});
describe('hideEl', () => {
it(`should add the ${hiddenClass} class to the provided element`, () => {
const mockElement = document.createElement('div');
hideEl(mockElement);
expect(mockElement).toHaveClass(hiddenClass);
});
it(`should add the ${hiddenClass} class to all provided elements`, () => {
const mockElements = [
document.createElement('div'),
document.createElement('a'),
document.createElement('strong'),
];
hideEl(mockElements);
expect(mockElements[0]).toHaveClass(hiddenClass);
expect(mockElements[1]).toHaveClass(hiddenClass);
expect(mockElements[2]).toHaveClass(hiddenClass);
});
it(`should add the ${hiddenClass} class to elements provided in multiple arrays`, () => {
const mockElements1 = [
document.createElement('div'),
document.createElement('a'),
];
const mockElements2 = [
document.createElement('strong'),
document.createElement('em'),
];
hideEl(mockElements1, mockElements2);
expect(mockElements1[0]).toHaveClass(hiddenClass);
expect(mockElements1[1]).toHaveClass(hiddenClass);
expect(mockElements2[0]).toHaveClass(hiddenClass);
expect(mockElements2[1]).toHaveClass(hiddenClass);
});
});
describe('toggleEl', () => {
it(`should toggle the ${hiddenClass} class on the provided element`, () => {
const mockVisibleElement = document.createElement('div');
toggleEl(mockVisibleElement);
expect(mockVisibleElement).toHaveClass(hiddenClass);
const mockHiddenElement = createHiddenElement('div');
toggleEl(mockHiddenElement);
expect(mockHiddenElement).not.toHaveClass(hiddenClass);
});
it(`should toggle the ${hiddenClass} class on all provided elements`, () => {
const mockElements = [
document.createElement('div'),
createHiddenElement('a'),
document.createElement('strong'),
createHiddenElement('em'),
];
toggleEl(mockElements);
expect(mockElements[0]).toHaveClass(hiddenClass);
expect(mockElements[1]).not.toHaveClass(hiddenClass);
expect(mockElements[2]).toHaveClass(hiddenClass);
expect(mockElements[3]).not.toHaveClass(hiddenClass);
});
it(`should toggle the ${hiddenClass} class on elements provided in multiple arrays`, () => {
const mockElements1 = [
createHiddenElement('div'),
document.createElement('a'),
];
const mockElements2 = [
createHiddenElement('strong'),
document.createElement('em'),
];
toggleEl(mockElements1, mockElements2);
expect(mockElements1[0]).not.toHaveClass(hiddenClass);
expect(mockElements1[1]).toHaveClass(hiddenClass);
expect(mockElements2[0]).not.toHaveClass(hiddenClass);
expect(mockElements2[1]).toHaveClass(hiddenClass);
});
});
describe('clearEl', () => {
it('should not throw an exception for empty element', () => {
const emptyElement = document.createElement('br');
expect(emptyElement.children).toHaveLength(0);
expect(() => clearEl(emptyElement)).not.toThrow();
expect(emptyElement.children).toHaveLength(0);
});
it('should remove a single child node', () => {
const baseElement = document.createElement('p');
baseElement.appendChild(document.createElement('br'));
expect(baseElement.children).toHaveLength(1);
clearEl(baseElement);
expect(baseElement.children).toHaveLength(0);
});
it('should remove a multiple child nodes', () => {
const baseElement = document.createElement('p');
const elementsToAdd = getRandomIntBetween(5, 10);
for (let i = 0; i < elementsToAdd; ++i) {
baseElement.appendChild(document.createElement('br'));
}
expect(baseElement.children).toHaveLength(elementsToAdd);
clearEl(baseElement);
expect(baseElement.children).toHaveLength(0);
});
it('should remove child nodes of elements provided in multiple arrays', () => {
const baseElement1 = document.createElement('p');
const elementsToAdd1 = getRandomIntBetween(5, 10);
for (let i = 0; i < elementsToAdd1; ++i) {
baseElement1.appendChild(document.createElement('br'));
}
expect(baseElement1.children).toHaveLength(elementsToAdd1);
const baseElement2 = document.createElement('p');
const elementsToAdd2 = getRandomIntBetween(5, 10);
for (let i = 0; i < elementsToAdd2; ++i) {
baseElement2.appendChild(document.createElement('br'));
}
expect(baseElement2.children).toHaveLength(elementsToAdd2);
clearEl([baseElement1], [baseElement2]);
expect(baseElement1.children).toHaveLength(0);
expect(baseElement2.children).toHaveLength(0);
});
});
describe('removeEl', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should throw error if element has no parent', () => {
const detachedElement = document.createElement('div');
expect(() => removeEl(detachedElement)).toThrow(/propert(y|ies).*null/);
});
it('should call the native removeElement method on parent', () => {
const parentNode = document.createElement('div');
const childNode = document.createElement('p');
parentNode.appendChild(childNode);
const spy = jest.spyOn(parentNode, 'removeChild');
removeEl(childNode);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenNthCalledWith(1, childNode);
});
});
describe('makeEl', () => {
it('should create br tag', () => {
const el = makeEl('br');
expect(el.nodeName).toEqual('BR');
});
it('should create a script tag', () => {
const mockSource = 'https://example.com/';
const el = makeEl('script', { src: mockSource, async: true, defer: true });
expect(el.nodeName).toEqual('SCRIPT');
expect(el.src).toEqual(mockSource);
expect(el.async).toEqual(true);
expect(el.defer).toEqual(true);
});
it('should create a link tag', () => {
const mockHref = 'https://example.com/';
const mockTarget = '_blank';
const el = makeEl('a', { href: mockHref, target: mockTarget });
expect(el.nodeName).toEqual('A');
expect(el.href).toEqual(mockHref);
expect(el.target).toEqual(mockTarget);
});
it('should create paragraph tag', () => {
const mockClassOne = 'class-one';
const mockClassTwo = 'class-two';
const el = makeEl('p', { className: `${mockClassOne} ${mockClassTwo}` });
expect(el.nodeName).toEqual('P');
expect(el).toHaveClass(mockClassOne);
expect(el).toHaveClass(mockClassTwo);
});
});
describe('insertBefore', () => {
it('should insert the new element before the existing element', () => {
const mockParent = document.createElement('p');
const mockExisingElement = document.createElement('span');
mockParent.appendChild(mockExisingElement);
const mockNewElement = document.createElement('strong');
insertBefore(mockExisingElement, mockNewElement);
expect(mockParent.children).toHaveLength(2);
expect(mockParent.children[0].tagName).toBe('STRONG');
expect(mockParent.children[1].tagName).toBe('SPAN');
});
it('should insert between two elements', () => {
const mockParent = document.createElement('p');
const mockFirstExisingElement = document.createElement('span');
const mockSecondExisingElement = document.createElement('em');
mockParent.appendChild(mockFirstExisingElement);
mockParent.appendChild(mockSecondExisingElement);
const mockNewElement = document.createElement('strong');
insertBefore(mockSecondExisingElement, mockNewElement);
expect(mockParent.children).toHaveLength(3);
expect(mockParent.children[0].tagName).toBe('SPAN');
expect(mockParent.children[1].tagName).toBe('STRONG');
expect(mockParent.children[2].tagName).toBe('EM');
});
it('should fail if there is no parent', () => {
const mockParent = document.createElement('p');
const mockNewElement = document.createElement('em');
expect(() => {
insertBefore(mockParent, mockNewElement);
}).toThrow(/propert(y|ies).*null/);
});
});
describe('onLeftClick', () => {
it('should call callback on left click', () => {
const mockCallback = jest.fn();
const element = document.createElement('div');
onLeftClick(mockCallback, element as unknown as Document);
fireEvent.click(element, { button: 0 });
expect(mockCallback).toHaveBeenCalledTimes(1);
});
it('should NOT call callback on non-left click', () => {
const mockCallback = jest.fn();
const element = document.createElement('div');
onLeftClick(mockCallback, element as unknown as Document);
const mockButton = getRandomArrayItem([1, 2, 3, 4, 5]);
fireEvent.click(element, { button: mockButton });
expect(mockCallback).not.toHaveBeenCalled();
});
it('should add click event listener to the document by default', () => {
const mockCallback = jest.fn();
onLeftClick(mockCallback);
fireEvent.click(document.body);
expect(mockCallback).toHaveBeenCalledTimes(1);
});
});
describe('whenReady', () => {
it('should call callback immediately if document ready state is not loading', () => {
const mockReadyStateValue = getRandomArrayItem<DocumentReadyState>(['complete', 'interactive']);
const readyStateSpy = jest.spyOn(document, 'readyState', 'get').mockReturnValue(mockReadyStateValue);
const mockCallback = jest.fn();
try {
whenReady(mockCallback);
expect(mockCallback).toHaveBeenCalledTimes(1);
}
finally {
readyStateSpy.mockRestore();
}
});
it('should add event listener with callback if document ready state is loading', () => {
const readyStateSpy = jest.spyOn(document, 'readyState', 'get').mockReturnValue('loading');
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
const mockCallback = jest.fn();
try {
whenReady(mockCallback);
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
expect(addEventListenerSpy).toHaveBeenNthCalledWith(1, 'DOMContentLoaded', mockCallback);
expect(mockCallback).not.toHaveBeenCalled();
}
finally {
readyStateSpy.mockRestore();
addEventListenerSpy.mockRestore();
}
});
});
describe('escapeHtml', () => {
it('should replace only the expected characters with their HTML entity equivalents', () => {
expect(escapeHtml('<script src="http://example.com/?a=1&b=2"></script>')).toBe('&lt;script src=&quot;http://example.com/?a=1&amp;b=2&quot;&gt;&lt;/script&gt;');
});
});
describe('escapeCss', () => {
it('should replace only the expected characters with their escaped equivalents', () => {
expect(escapeCss('url("https://example.com")')).toBe('url(\\"https://example.com\\")');
});
});
describe('findFirstTextNode', () => {
it('should return the first text node child', () => {
const mockText = `expected text ${Math.random()}`;
const mockNode = document.createElement('div');
mockNode.innerHTML = `<strong>bold</strong>${mockText}<em>italic</em>`;
const result: Node = findFirstTextNode(mockNode);
expect(result.nodeValue).toBe(mockText);
});
it('should return undefined if there is no text node child', () => {
const mockNode = document.createElement('div');
mockNode.innerHTML = '<strong>bold</strong><em>italic</em>';
const result: Node = findFirstTextNode(mockNode);
expect(result).toBe(undefined);
});
});
});

View file

@ -0,0 +1,285 @@
import { initDraggables } from '../draggable';
import { fireEvent } from '@testing-library/dom';
import { getRandomArrayItem } from '../../../test/randomness';
describe('Draggable Utilities', () => {
// jsdom lacks proper support for window.DragEvent so this is an attempt at a minimal recreation
const createDragEvent = (name: string, init?: DragEventInit): DragEvent => {
const mockEvent = new Event(name, { bubbles: true, cancelable: true }) as unknown as DragEvent;
let dataTransfer = init?.dataTransfer;
if (!dataTransfer) {
const items: Pick<DataTransferItem, 'type' | 'getAsString'>[] = [];
dataTransfer = {
items: items as unknown as DataTransferItemList,
setData(format: string, data: string) {
items.push({ type: format, getAsString: (callback: FunctionStringCallback) => callback(data) });
}
} as unknown as DataTransfer;
}
Object.assign(mockEvent, { dataTransfer });
return mockEvent;
};
const createDraggableElement = (): HTMLDivElement => {
const el = document.createElement('div');
el.setAttribute('draggable', 'true');
return el;
};
describe('initDraggables', () => {
const draggingClass = 'dragging';
const dragContainerClass = 'drag-container';
const dragOverClass = 'over';
let documentEventListenerSpy: jest.SpyInstance;
let mockDragContainer: HTMLDivElement;
let mockDraggable: HTMLDivElement;
beforeEach(() => {
mockDragContainer = document.createElement('div');
mockDragContainer.classList.add(dragContainerClass);
document.body.appendChild(mockDragContainer);
mockDraggable = createDraggableElement();
mockDragContainer.appendChild(mockDraggable);
// Redirect all document event listeners to this element for easier cleanup
documentEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation((...params) => {
mockDragContainer.addEventListener(...params);
});
});
afterEach(() => {
document.body.removeChild(mockDragContainer);
documentEventListenerSpy.mockRestore();
});
describe('dragStart', () => {
it('should add the dragging class to the element that starts moving', () => {
initDraggables();
const mockEvent = createDragEvent('dragstart');
fireEvent(mockDraggable, mockEvent);
expect(mockDraggable).toHaveClass(draggingClass);
});
it('should add dummy data to the dragstart event if it\'s empty', () => {
initDraggables();
const mockEvent = createDragEvent('dragstart');
expect(mockEvent.dataTransfer?.items).toHaveLength(0);
fireEvent(mockDraggable, mockEvent);
expect(mockEvent.dataTransfer?.items).toHaveLength(1);
const dataTransferItem = (mockEvent.dataTransfer as DataTransfer).items[0];
expect(dataTransferItem.type).toEqual('text/plain');
let stringValue: string | undefined;
dataTransferItem.getAsString(value => {
stringValue = value;
});
expect(stringValue).toEqual('');
});
it('should keep data in the dragstart event if it\'s present', () => {
initDraggables();
const mockTransferItemType = getRandomArrayItem(['text/javascript', 'image/jpg', 'application/json']);
const mockDataTransferItem: DataTransferItem = {
type: mockTransferItemType,
} as unknown as DataTransferItem;
const mockEvent = createDragEvent('dragstart', { dataTransfer: { items: [mockDataTransferItem] as unknown as DataTransferItemList } } as DragEventInit);
expect(mockEvent.dataTransfer?.items).toHaveLength(1);
fireEvent(mockDraggable, mockEvent);
expect(mockEvent.dataTransfer?.items).toHaveLength(1);
const dataTransferItem = (mockEvent.dataTransfer as DataTransfer).items[0];
expect(dataTransferItem.type).toEqual(mockTransferItemType);
});
it('should set the allowed effect to move on the data transfer', () => {
initDraggables();
const mockEvent = createDragEvent('dragstart');
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
fireEvent(mockDraggable, mockEvent);
expect(mockEvent.dataTransfer?.effectAllowed).toEqual('move');
});
});
describe('dragOver', () => {
it('should cancel event and set the drop effect to move on the data transfer', () => {
initDraggables();
const mockEvent = createDragEvent('dragover');
fireEvent(mockDraggable, mockEvent);
expect(mockEvent.defaultPrevented).toBe(true);
expect(mockEvent.dataTransfer?.dropEffect).toEqual('move');
});
});
describe('dragEnter', () => {
it('should add the over class to the target', () => {
initDraggables();
const mockEvent = createDragEvent('dragenter');
fireEvent(mockDraggable, mockEvent);
expect(mockDraggable).toHaveClass(dragOverClass);
});
});
describe('dragLeave', () => {
it('should remove the over class from the target', () => {
initDraggables();
mockDraggable.classList.add(dragOverClass);
const mockEvent = createDragEvent('dragleave');
fireEvent(mockDraggable, mockEvent);
expect(mockDraggable).not.toHaveClass(dragOverClass);
});
});
describe('drop', () => {
it('should cancel the event and remove dragging class if dropped on same element', () => {
initDraggables();
const mockStartEvent = createDragEvent('dragstart');
fireEvent(mockDraggable, mockStartEvent);
expect(mockDraggable).toHaveClass(draggingClass);
const mockDropEvent = createDragEvent('drop');
fireEvent(mockDraggable, mockDropEvent);
expect(mockDropEvent.defaultPrevented).toBe(true);
expect(mockDraggable).not.toHaveClass(draggingClass);
});
it('should cancel the event and insert source before target if dropped on left side', () => {
initDraggables();
const mockSecondDraggable = createDraggableElement();
mockDragContainer.appendChild(mockSecondDraggable);
const mockStartEvent = createDragEvent('dragstart');
fireEvent(mockSecondDraggable, mockStartEvent);
expect(mockSecondDraggable).toHaveClass(draggingClass);
const mockDropEvent = createDragEvent('drop');
Object.assign(mockDropEvent, { clientX: 124 });
const boundingBoxSpy = jest.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({
left: 100,
width: 50,
} as unknown as DOMRect);
fireEvent(mockDraggable, mockDropEvent);
try {
expect(mockDropEvent.defaultPrevented).toBe(true);
expect(mockSecondDraggable).not.toHaveClass(draggingClass);
expect(mockSecondDraggable.nextElementSibling).toBe(mockDraggable);
}
finally {
boundingBoxSpy.mockRestore();
}
});
it('should cancel the event and insert source after target if dropped on right side', () => {
initDraggables();
const mockSecondDraggable = createDraggableElement();
mockDragContainer.appendChild(mockSecondDraggable);
const mockStartEvent = createDragEvent('dragstart');
fireEvent(mockSecondDraggable, mockStartEvent);
expect(mockSecondDraggable).toHaveClass(draggingClass);
const mockDropEvent = createDragEvent('drop');
Object.assign(mockDropEvent, { clientX: 125 });
const boundingBoxSpy = jest.spyOn(mockDraggable, 'getBoundingClientRect').mockReturnValue({
left: 100,
width: 50,
} as unknown as DOMRect);
fireEvent(mockDraggable, mockDropEvent);
try {
expect(mockDropEvent.defaultPrevented).toBe(true);
expect(mockSecondDraggable).not.toHaveClass(draggingClass);
expect(mockDraggable.nextElementSibling).toBe(mockSecondDraggable);
}
finally {
boundingBoxSpy.mockRestore();
}
});
});
describe('dragEnd', () => {
it('should remove dragging class from source and over class from target\'s descendants', () => {
initDraggables();
const mockStartEvent = createDragEvent('dragstart');
fireEvent(mockDraggable, mockStartEvent);
expect(mockDraggable).toHaveClass(draggingClass);
const mockOverElement = createDraggableElement();
mockOverElement.classList.add(dragOverClass);
mockDraggable.parentNode?.appendChild(mockOverElement);
const mockOverEvent = createDragEvent('dragend');
fireEvent(mockOverElement, mockOverEvent);
const mockDropEvent = createDragEvent('dragend');
fireEvent(mockDraggable, mockDropEvent);
expect(mockDraggable).not.toHaveClass(draggingClass);
expect(mockOverElement).not.toHaveClass(dragOverClass);
});
});
describe('wrapper', () => {
it('should do nothing when event target has no closest method', () => {
initDraggables();
const mockEvent = createDragEvent('dragstart');
Object.assign(mockDraggable, { closest: undefined });
fireEvent(mockDraggable, mockEvent);
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
});
it('should do nothing when event target does not have a parent matching the predefined selector', () => {
initDraggables();
const mockEvent = createDragEvent('dragstart');
const documentClosestSpy = jest.spyOn(mockDraggable, 'closest').mockReturnValue(null);
try {
fireEvent(mockDraggable, mockEvent);
expect(mockEvent.dataTransfer?.effectAllowed).toBeFalsy();
}
finally {
documentClosestSpy.mockRestore();
}
});
});
});
});

View file

@ -0,0 +1,143 @@
import { delegate, fire, leftClick, on } from '../events';
import { getRandomArrayItem } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom';
describe('Event utils', () => {
const mockEvent = getRandomArrayItem(['click', 'blur', 'mouseleave']);
describe('fire', () => {
it('should call the native dispatchEvent method on the element', () => {
const mockElement = document.createElement('div');
const dispatchEventSpy = jest.spyOn(mockElement, 'dispatchEvent');
const mockDetail = getRandomArrayItem([0, 'test', null]);
fire(mockElement, mockEvent, mockDetail);
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
const [customEvent] = dispatchEventSpy.mock.calls[0] as (Event | CustomEvent)[];
expect(customEvent).toBeInstanceOf(CustomEvent);
expect(customEvent.type).toBe(mockEvent);
expect(customEvent.bubbles).toBe(true);
expect(customEvent.cancelable).toBe(true);
expect((customEvent as CustomEvent).detail).toBe(mockDetail);
});
});
describe('on', () => {
it('should fire handler on descendant click', () => {
const mockElement = document.createElement('div');
const mockWrapperElement = document.createElement('div');
mockWrapperElement.classList.add('wrapper');
mockElement.appendChild(mockWrapperElement);
const mockInnerElement = document.createElement('div');
const innerClass = 'inner';
mockInnerElement.classList.add(innerClass);
mockWrapperElement.appendChild(mockInnerElement);
const mockButton = document.createElement('button');
mockButton.classList.add('mock-button');
mockInnerElement.appendChild(mockButton);
const mockHandler = jest.fn();
on(mockElement, 'click', `.${innerClass}`, mockHandler);
fireEvent(mockButton, new Event('click', { bubbles: true }));
expect(mockHandler).toBeCalledTimes(1);
const [event, target] = mockHandler.mock.calls[0];
expect(event).toBeInstanceOf(Event);
expect(target).toBe(mockInnerElement);
});
});
describe('leftClick', () => {
it('should fire on left click', () => {
const mockButton = document.createElement('button');
const mockHandler = jest.fn();
mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton));
fireEvent.click(mockButton, { button: 0 });
expect(mockHandler).toBeCalledTimes(1);
});
it('should NOT fire on any other click', () => {
const mockButton = document.createElement('button');
const mockHandler = jest.fn();
const mockButtonNumber = getRandomArrayItem([1, 2, 3, 4, 5]);
mockButton.addEventListener('click', e => leftClick(mockHandler)(e, mockButton));
fireEvent.click(mockButton, { button: mockButtonNumber });
expect(mockHandler).toBeCalledTimes(0);
});
});
describe('delegate', () => {
it('should call the native addEventListener method on the element', () => {
const mockElement = document.createElement('div');
const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener');
delegate(mockElement, mockEvent, {});
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
const [event, handler] = addEventListenerSpy.mock.calls[0];
expect(event).toBe(mockEvent);
expect(typeof handler).toBe('function');
});
it('should call the function for the selector', () => {
const mockElement = document.createElement('div');
const parentClass = 'parent';
mockElement.classList.add(parentClass);
const mockButton = document.createElement('button');
mockElement.appendChild(mockButton);
const mockHandler = jest.fn();
delegate(mockElement, 'click', { [`.${parentClass}`]: mockHandler });
fireEvent(mockButton, new Event('click', { bubbles: true }));
expect(mockHandler).toBeCalledTimes(1);
const [event, target] = mockHandler.mock.calls[0];
expect(event).toBeInstanceOf(Event);
expect(target).toBe(mockElement);
});
it('should stop executing handlers after one returns with false', () => {
const mockElement = document.createElement('div');
const parentClass = 'parent';
mockElement.classList.add(parentClass);
const mockWrapperElement = document.createElement('div');
const wrapperClass = 'wrapper';
mockWrapperElement.classList.add(wrapperClass);
mockElement.appendChild(mockWrapperElement);
const mockButton = document.createElement('button');
mockWrapperElement.appendChild(mockButton);
const mockParentHandler = jest.fn();
const mockWrapperHandler = jest.fn().mockReturnValue(false);
delegate(mockElement, 'click', {
[`.${wrapperClass}`]: mockWrapperHandler,
[`.${parentClass}`]: mockParentHandler,
});
fireEvent(mockButton, new Event('click', { bubbles: true }));
expect(mockWrapperHandler).toBeCalledTimes(1);
expect(mockParentHandler).not.toBeCalled();
});
});
});

View file

@ -0,0 +1,569 @@
import { hideThumb, showBlock, showThumb, spoilerBlock, spoilerThumb } from '../image';
import { getRandomArrayItem } from '../../../test/randomness';
import { mockStorage } from '../../../test/mock-storage';
import { createEvent, fireEvent } from '@testing-library/dom';
import { EventType } from '@testing-library/dom/types/events';
describe('Image utils', () => {
const hiddenClass = 'hidden';
const spoilerOverlayClass = 'js-spoiler-info-overlay';
const serveHidpiStorageKey = 'serve_hidpi';
const mockSpoilerReason = 'Mock reason';
const mockSpoilerUri = '/images/tagblocked.svg';
const mockImageUri = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///////yH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
const getMockImageSizeUrls = (extension: string) => ({
thumb: `https://example.com/thumb.${extension}`,
small: `https://example.com/small.${extension}`,
medium: `https://example.com/medium.${extension}`,
large: `https://example.com/large.${extension}`,
});
type ImageSize = keyof ReturnType<typeof getMockImageSizeUrls>;
const PossibleImageSizes: ImageSize[] = ['thumb', 'small', 'medium', 'large'];
const applyMockDataAttributes = (element: HTMLElement, extension: string, size?: ImageSize) => {
const mockSize = size || getRandomArrayItem(PossibleImageSizes);
const mockSizeUrls = getMockImageSizeUrls(extension);
element.setAttribute('data-size', mockSize);
element.setAttribute('data-uris', JSON.stringify(mockSizeUrls));
return { mockSize, mockSizeUrls };
};
const createMockSpoilerOverlay = () => {
const mockSpoilerOverlay = document.createElement('div');
mockSpoilerOverlay.classList.add(spoilerOverlayClass);
return mockSpoilerOverlay;
};
const createMockElementWithPicture = (extension: string, size?: ImageSize) => {
const mockElement = document.createElement('div');
const { mockSizeUrls, mockSize } = applyMockDataAttributes(mockElement, extension, size);
const mockPicture = document.createElement('picture');
mockElement.appendChild(mockPicture);
const mockSizeImage = new Image();
mockPicture.appendChild(mockSizeImage);
const mockSpoilerOverlay = createMockSpoilerOverlay();
mockElement.appendChild(mockSpoilerOverlay);
return {
mockElement,
mockPicture,
mockSize,
mockSizeImage,
mockSizeUrls,
mockSpoilerOverlay,
};
};
describe('showThumb', () => {
let mockServeHidpiValue: string | null = null;
mockStorage({
getItem(key: string) {
if (key !== serveHidpiStorageKey) return null;
return mockServeHidpiValue;
},
});
describe('video thumbnail', () => {
type CreateMockElementsOptions = {
extension: string;
videoClasses?: string[];
imgClasses?: string[];
}
const createMockElements = ({ videoClasses, imgClasses, extension }: CreateMockElementsOptions) => {
const mockElement = document.createElement('div');
const { mockSize, mockSizeUrls } = applyMockDataAttributes(mockElement, extension);
const mockImage = new Image();
mockImage.src = mockImageUri;
if (imgClasses) {
imgClasses.forEach(videoClass => {
mockImage.classList.add(videoClass);
});
}
mockElement.appendChild(mockImage);
const mockVideo = document.createElement('video');
if (videoClasses) {
videoClasses.forEach(videoClass => {
mockVideo.classList.add(videoClass);
});
}
mockElement.appendChild(mockVideo);
const playSpy = jest.spyOn(mockVideo, 'play').mockReturnValue(Promise.resolve());
const mockSpoilerOverlay = createMockSpoilerOverlay();
mockElement.appendChild(mockSpoilerOverlay);
return {
mockElement,
mockImage,
mockSize,
mockSizeUrls,
mockSpoilerOverlay,
mockVideo,
playSpy,
};
};
it('should hide the img element and show the video instead if no picture element is present', () => {
const {
mockElement,
mockImage,
playSpy,
mockVideo,
mockSize,
mockSizeUrls,
mockSpoilerOverlay,
} = createMockElements({
extension: 'webm',
videoClasses: ['hidden'],
});
const result = showThumb(mockElement);
expect(mockImage).toHaveClass(hiddenClass);
expect(mockVideo.children).toHaveLength(2);
const webmSourceElement = mockVideo.children[0];
const webmSource = mockSizeUrls[mockSize];
expect(webmSourceElement.nodeName).toEqual('SOURCE');
expect(webmSourceElement.getAttribute('type')).toEqual('video/webm');
expect(webmSourceElement.getAttribute('src')).toEqual(webmSource);
const mp4SourceElement = mockVideo.children[1];
expect(mp4SourceElement.nodeName).toEqual('SOURCE');
expect(mp4SourceElement.getAttribute('type')).toEqual('video/mp4');
expect(mp4SourceElement.getAttribute('src')).toEqual(webmSource.replace('webm', 'mp4'));
expect(mockVideo).not.toHaveClass(hiddenClass);
expect(playSpy).toHaveBeenCalledTimes(1);
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
expect(result).toBe(true);
});
it('should return early if there is no video element', () => {
const { mockElement, mockVideo, playSpy } = createMockElements({
extension: 'webm',
});
mockElement.removeChild(mockVideo);
const result = showThumb(mockElement);
expect(result).toBe(false);
expect(playSpy).not.toHaveBeenCalled();
});
it('should return early if img element is missing', () => {
const { mockElement, mockImage, playSpy } = createMockElements({
extension: 'webm',
imgClasses: ['hidden'],
});
mockElement.removeChild(mockImage);
const result = showThumb(mockElement);
expect(result).toBe(false);
expect(playSpy).not.toHaveBeenCalled();
});
it('should return early if img element already has the hidden class', () => {
const { mockElement, playSpy } = createMockElements({
extension: 'webm',
imgClasses: ['hidden'],
});
const result = showThumb(mockElement);
expect(result).toBe(false);
expect(playSpy).not.toHaveBeenCalled();
});
});
it('should show the correct thumbnail image for jpg extension', () => {
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSize,
mockSpoilerOverlay,
} = createMockElementWithPicture('jpg');
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
expect(mockSizeImage.srcset).toBe('');
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
expect(result).toBe(true);
});
it('should show the correct thumbnail image for gif extension', () => {
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSize,
mockSpoilerOverlay,
} = createMockElementWithPicture('gif');
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
expect(mockSizeImage.srcset).toBe('');
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
expect(result).toBe(true);
});
it('should show the correct thumbnail image for webm extension', () => {
const {
mockElement,
mockSpoilerOverlay,
mockSizeImage,
mockSizeUrls,
mockSize,
} = createMockElementWithPicture('webm');
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize].replace('webm', 'gif'));
expect(mockSizeImage.srcset).toBe('');
expect(mockSpoilerOverlay).not.toHaveClass(hiddenClass);
expect(mockSpoilerOverlay).toHaveTextContent('WebM');
expect(result).toBe(true);
});
describe('high DPI srcset handling', () => {
beforeEach(() => {
mockServeHidpiValue = 'true';
});
const checkSrcsetAttribute = (size: ImageSize, x2size: ImageSize) => {
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSpoilerOverlay,
} = createMockElementWithPicture('jpg', size);
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[size]);
expect(mockSizeImage.srcset).toContain(`${mockSizeUrls[size]} 1x`);
expect(mockSizeImage.srcset).toContain(`${mockSizeUrls[x2size]} 2x`);
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
return result;
};
it('should set correct srcset on img if thumbUri is NOT a gif at small size', () => {
const result = checkSrcsetAttribute('small', 'medium');
expect(result).toBe(true);
});
it('should set correct srcset on img if thumbUri is NOT a gif at medium size', () => {
const result = checkSrcsetAttribute('medium', 'large');
expect(result).toBe(true);
});
it('should NOT set srcset on img if thumbUri is a gif at small size', () => {
const mockSize = 'small';
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSpoilerOverlay,
} = createMockElementWithPicture('gif', mockSize);
const result = showThumb(mockElement);
expect(mockSizeImage.src).toBe(mockSizeUrls[mockSize]);
expect(mockSizeImage.srcset).toBe('');
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
expect(result).toBe(true);
});
});
it('should return false if img cannot be found', () => {
const { mockElement, mockPicture, mockSizeImage } = createMockElementWithPicture('jpg');
mockPicture.removeChild(mockSizeImage);
const result = showThumb(mockElement);
expect(result).toBe(false);
});
it('should return false if img source already matches thumbUri', () => {
const {
mockElement,
mockSizeImage,
mockSizeUrls,
mockSize,
} = createMockElementWithPicture('jpg');
mockSizeImage.src = mockSizeUrls[mockSize];
const result = showThumb(mockElement);
expect(result).toBe(false);
});
});
describe('showBlock', () => {
const imageFilteredClass = 'image-filtered';
const imageShowClass = 'image-show';
const spoilerPendingClass = 'spoiler-pending';
it('should hide the filtered image element and show the image', () => {
const mockElement = document.createElement('div');
const mockFilteredImageElement = document.createElement('div');
mockFilteredImageElement.classList.add(imageFilteredClass);
mockElement.appendChild(mockFilteredImageElement);
const mockShowElement = document.createElement('div');
mockShowElement.classList.add(imageShowClass);
mockShowElement.classList.add(hiddenClass);
mockElement.appendChild(mockShowElement);
showBlock(mockElement);
expect(mockFilteredImageElement).toHaveClass(hiddenClass);
expect(mockShowElement).not.toHaveClass(hiddenClass);
expect(mockShowElement).toHaveClass(spoilerPendingClass);
});
});
describe('hideThumb', () => {
describe('hideVideoThumb', () => {
it('should return early if picture AND video elements are missing', () => {
const mockElement = document.createElement('div');
const querySelectorSpy = jest.spyOn(mockElement, 'querySelector');
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
try {
expect(querySelectorSpy).toHaveBeenCalledTimes(2);
expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video');
}
finally {
querySelectorSpy.mockRestore();
}
});
it('should return early if picture and img elements are missing BUT video element is present', () => {
const mockElement = document.createElement('div');
const mockVideo = document.createElement('video');
mockElement.appendChild(mockVideo);
const pauseSpy = jest.spyOn(mockVideo, 'pause').mockReturnValue(undefined);
const querySelectorSpy = jest.spyOn(mockElement, 'querySelector');
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
try {
expect(querySelectorSpy).toHaveBeenCalledTimes(4);
expect(querySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
expect(querySelectorSpy).toHaveBeenNthCalledWith(2, 'video');
expect(querySelectorSpy).toHaveBeenNthCalledWith(3, 'img');
expect(querySelectorSpy).toHaveBeenNthCalledWith(4, `.${spoilerOverlayClass}`);
expect(mockVideo).not.toHaveClass(hiddenClass);
}
finally {
querySelectorSpy.mockRestore();
pauseSpy.mockRestore();
}
});
it('should hide video thumbnail if picture element is missing BUT video element is present', () => {
const mockElement = document.createElement('div');
const mockVideo = document.createElement('video');
mockElement.appendChild(mockVideo);
const pauseSpy = jest.spyOn(mockVideo, 'pause').mockReturnValue(undefined);
const mockImage = document.createElement('img');
mockImage.classList.add(hiddenClass);
mockElement.appendChild(mockImage);
const mockOverlay = document.createElement('span');
mockOverlay.classList.add(spoilerOverlayClass, hiddenClass);
mockElement.appendChild(mockOverlay);
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
try {
expect(mockImage).not.toHaveClass(hiddenClass);
expect(mockImage).toHaveAttribute('src', mockSpoilerUri);
expect(mockOverlay).toHaveTextContent(mockSpoilerReason);
expect(mockVideo).toBeEmptyDOMElement();
expect(mockVideo).toHaveClass(hiddenClass);
expect(pauseSpy).toHaveBeenCalled();
}
finally {
pauseSpy.mockRestore();
}
});
});
it('should return early if picture element is present AND img element is missing', () => {
const mockElement = document.createElement('div');
const mockPicture = document.createElement('picture');
mockElement.appendChild(mockPicture);
const imgQuerySelectorSpy = jest.spyOn(mockElement, 'querySelector');
const pictureQuerySelectorSpy = jest.spyOn(mockPicture, 'querySelector');
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
try {
expect(imgQuerySelectorSpy).toHaveBeenCalledTimes(2);
expect(pictureQuerySelectorSpy).toHaveBeenCalledTimes(1);
expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'picture');
expect(pictureQuerySelectorSpy).toHaveBeenNthCalledWith(1, 'img');
expect(imgQuerySelectorSpy).toHaveBeenNthCalledWith(2, `.${spoilerOverlayClass}`);
}
finally {
imgQuerySelectorSpy.mockRestore();
pictureQuerySelectorSpy.mockRestore();
}
});
it('should hide img thumbnail if picture element is present AND img element is present', () => {
const mockElement = document.createElement('div');
const mockPicture = document.createElement('picture');
mockElement.appendChild(mockPicture);
const mockImage = document.createElement('img');
mockPicture.appendChild(mockImage);
const mockOverlay = document.createElement('span');
mockOverlay.classList.add(spoilerOverlayClass, hiddenClass);
mockElement.appendChild(mockOverlay);
hideThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
expect(mockImage).toHaveAttribute('srcset', '');
expect(mockImage).toHaveAttribute('src', mockSpoilerUri);
expect(mockOverlay).toContainHTML(mockSpoilerReason);
expect(mockOverlay).not.toHaveClass(hiddenClass);
});
});
describe('spoilerThumb', () => {
const testSpoilerThumb = (handlers?: [EventType, EventType]) => {
const { mockElement, mockSpoilerOverlay, mockSizeImage } = createMockElementWithPicture('jpg');
const addEventListenerSpy = jest.spyOn(mockElement, 'addEventListener');
spoilerThumb(mockElement, mockSpoilerUri, mockSpoilerReason);
// Element should be hidden by the call
expect(mockSizeImage).toHaveAttribute('src', mockSpoilerUri);
expect(mockSpoilerOverlay).not.toHaveClass(hiddenClass);
expect(mockSpoilerOverlay).toContainHTML(mockSpoilerReason);
// If addEventListener calls are not expected, bail
if (!handlers) {
expect(addEventListenerSpy).not.toHaveBeenCalled();
return;
}
const [firstHandler, secondHandler] = handlers;
// Event listeners should be attached to correct events
expect(addEventListenerSpy).toHaveBeenCalledTimes(2);
expect(addEventListenerSpy.mock.calls[0][0]).toBe(firstHandler.toLowerCase());
expect(addEventListenerSpy.mock.calls[1][0]).toBe(secondHandler.toLowerCase());
// Clicking once should reveal the image and hide spoiler elements
let clickEvent = createEvent[firstHandler](mockElement);
fireEvent(mockElement, clickEvent);
if (firstHandler === 'click') {
expect(clickEvent.defaultPrevented).toBe(true);
}
expect(mockSizeImage).not.toHaveAttribute('src', mockSpoilerUri);
expect(mockSpoilerOverlay).toHaveClass(hiddenClass);
if (firstHandler === 'click') {
// Second attempt to click a shown spoiler should not cause default prevention
clickEvent = createEvent.click(mockElement);
fireEvent(mockElement, clickEvent);
expect(clickEvent.defaultPrevented).toBe(false);
}
// Moving the mouse away should hide the image and show the overlay again
const mouseLeaveEvent = createEvent.mouseLeave(mockElement);
fireEvent(mockElement, mouseLeaveEvent);
expect(mockSizeImage).toHaveAttribute('src', mockSpoilerUri);
expect(mockSpoilerOverlay).not.toHaveClass(hiddenClass);
expect(mockSpoilerOverlay).toContainHTML(mockSpoilerReason);
};
let lastSpoilerType: SpoilerType;
beforeEach(() => {
lastSpoilerType = window.booru.spoilerType;
});
afterEach(() => {
window.booru.spoilerType = lastSpoilerType;
});
it('should add click and mouseleave handlers for click spoiler type', () => {
window.booru.spoilerType = 'click';
expect.hasAssertions();
testSpoilerThumb(['click', 'mouseLeave']);
});
it('should add mouseenter and mouseleave handlers for hover spoiler type', () => {
window.booru.spoilerType = 'hover';
expect.hasAssertions();
testSpoilerThumb(['mouseEnter', 'mouseLeave']);
});
it('should not add event handlers for off spoiler type', () => {
window.booru.spoilerType = 'off';
expect.hasAssertions();
testSpoilerThumb();
});
it('should not add event handlers for static spoiler type', () => {
window.booru.spoilerType = 'static';
expect.hasAssertions();
testSpoilerThumb();
});
});
describe('spoilerBlock', () => {
const imageFilteredClass = 'image-filtered';
const filterExplanationClass = 'filter-explanation';
const imageShowClass = 'image-show';
const createFilteredImageElement = () => {
const mockImageFiltered = document.createElement('div');
mockImageFiltered.classList.add(imageFilteredClass, hiddenClass);
const mockImage = new Image();
mockImage.src = mockImageUri;
mockImageFiltered.appendChild(mockImage);
return { mockImageFiltered, mockImage };
};
it('should do nothing if image element is missing', () => {
const mockElement = document.createElement('div');
expect(() => spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason)).not.toThrow();
});
it('should update the elements with the parameters and set classes if image element is found', () => {
const mockElement = document.createElement('div');
const { mockImageFiltered, mockImage } = createFilteredImageElement();
mockElement.appendChild(mockImageFiltered);
const mockExplanation = document.createElement('span');
mockExplanation.classList.add(filterExplanationClass);
mockElement.appendChild(mockExplanation);
const mockImageShow = document.createElement('div');
mockImageShow.classList.add(imageShowClass);
mockElement.appendChild(mockImageShow);
spoilerBlock(mockElement, mockSpoilerUri, mockSpoilerReason);
expect(mockImage).toHaveAttribute('src', mockSpoilerUri);
expect(mockExplanation).toContainHTML(mockSpoilerReason);
expect(mockImageShow).toHaveClass(hiddenClass);
expect(mockImageFiltered).not.toHaveClass(hiddenClass);
});
});
});

View file

@ -0,0 +1,100 @@
import { LocalAutocompleter } from '../local-autocompleter';
import { promises } from 'fs';
import { join } from 'path';
import { TextDecoder } from 'util';
describe('Local Autocompleter', () => {
let mockData: ArrayBuffer;
const defaultK = 5;
beforeAll(async() => {
const mockDataPath = join(__dirname, 'autocomplete-compiled-v2.bin');
/**
* Read pre-generated binary autocomplete data
*
* Contains the tags: safe (6), forest (3), flower (1), flowers -> flower, fog (1),
* force field (1), artist:test (1), explicit (0), grimdark (0),
* grotesque (0), questionable (0), semi-grimdark (0), suggestive (0)
*/
mockData = (await promises.readFile(mockDataPath, { encoding: null })).buffer;
// Polyfills for jsdom
global.TextDecoder = TextDecoder as unknown as typeof global.TextDecoder;
});
afterAll(() => {
delete (global as Partial<typeof global>).TextEncoder;
delete (global as Partial<typeof global>).TextDecoder;
});
describe('instantiation', () => {
it('should be constructable with compatible data', () => {
const result = new LocalAutocompleter(mockData);
expect(result).toBeInstanceOf(LocalAutocompleter);
});
it('should NOT be constructable with incompatible data', () => {
const versionDataOffset = 12;
const mockIncompatibleDataArray = new Array(versionDataOffset).fill(0);
// Set data version to 1
mockIncompatibleDataArray[mockIncompatibleDataArray.length - versionDataOffset] = 1;
const mockIncompatibleData = new Uint32Array(mockIncompatibleDataArray).buffer;
expect(() => new LocalAutocompleter(mockIncompatibleData)).toThrow('Incompatible autocomplete format version');
});
});
describe('topK', () => {
let localAc: LocalAutocompleter;
beforeAll(() => {
localAc = new LocalAutocompleter(mockData);
});
beforeEach(() => {
window.booru.hiddenTagList = [];
});
it('should return suggestions for exact tag name match', () => {
const result = localAc.topK('safe', defaultK);
expect(result).toEqual([expect.objectContaining({ name: 'safe', imageCount: 6 })]);
});
it('should return suggestion for original tag when passed an alias', () => {
const result = localAc.topK('flowers', defaultK);
expect(result).toEqual([expect.objectContaining({ name: 'flower', imageCount: 1 })]);
});
it('should return suggestions sorted by image count', () => {
const result = localAc.topK('fo', defaultK);
expect(result).toEqual([
expect.objectContaining({ name: 'forest', imageCount: 3 }),
expect.objectContaining({ name: 'fog', imageCount: 1 }),
expect.objectContaining({ name: 'force field', imageCount: 1 }),
]);
});
it('should return namespaced suggestions without including namespace', () => {
const result = localAc.topK('test', defaultK);
expect(result).toEqual([
expect.objectContaining({ name: 'artist:test', imageCount: 1 }),
]);
});
it('should return only the required number of suggestions', () => {
const result = localAc.topK('fo', 1);
expect(result).toEqual([expect.objectContaining({ name: 'forest', imageCount: 3 })]);
});
it('should NOT return suggestions associated with hidden tags', () => {
window.booru.hiddenTagList = [1];
const result = localAc.topK('fo', defaultK);
expect(result).toEqual([]);
});
it('should return empty array for empty prefix', () => {
const result = localAc.topK('', defaultK);
expect(result).toEqual([]);
});
});
});

View file

@ -0,0 +1,84 @@
import { fetchHtml, fetchJson, handleError } from '../requests';
import fetchMock from 'jest-fetch-mock';
describe('Request utils', () => {
const mockEndpoint = '/endpoint';
beforeAll(() => {
fetchMock.enableMocks();
});
afterAll(() => {
fetchMock.disableMocks();
});
beforeEach(() => {
window.booru.csrfToken = Math.random().toString();
fetchMock.resetMocks();
});
describe('fetchJson', () => {
it('should call native fetch with the correct parameters (without body)', () => {
const mockVerb = 'GET';
fetchJson(mockVerb, mockEndpoint);
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
method: mockVerb,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest'
},
});
});
it('should call native fetch with the correct parameters (with body)', () => {
const mockVerb = 'POST';
const mockBody = { mockField: Math.random() };
fetchJson(mockVerb, mockEndpoint, mockBody);
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
method: mockVerb,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest'
},
body: JSON.stringify({
...mockBody,
_method: mockVerb
})
});
});
});
describe('fetchHtml', () => {
it('should call native fetch with the correct parameters', () => {
fetchHtml(mockEndpoint);
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
credentials: 'same-origin',
headers: {
'x-csrf-token': window.booru.csrfToken,
'x-requested-with': 'xmlhttprequest'
},
});
});
});
describe('handleError', () => {
it('should throw if ok property is false', () => {
const mockResponse = { ok: false } as unknown as Response;
expect(() => handleError(mockResponse)).toThrow('Received error from server');
});
it('should return response if ok property is true', () => {
const mockResponse = { ok: true } as unknown as Response;
expect(handleError(mockResponse)).toEqual(mockResponse);
});
});
});

View file

@ -0,0 +1,216 @@
import store, { lastUpdatedSuffix } from '../store';
import { mockStorageImpl } from '../../../test/mock-storage';
import { getRandomIntBetween } from '../../../test/randomness';
import { fireEvent } from '@testing-library/dom';
import { mockDateNow } from '../../../test/mock-date-now';
describe('Store utilities', () => {
const { setItemSpy, getItemSpy, removeItemSpy, forceStorageError, setStorageValue } = mockStorageImpl();
const initialDateNow = 1640645432942;
describe('set', () => {
it('should be able to set various types of items correctly', () => {
const mockKey = `mock-set-key-${getRandomIntBetween(1, 10)}`;
const mockValues = [
1,
false,
null,
Math.random(),
'some string\n value\twith trailing whitespace ',
{ complex: { value: true, key: 'string' } },
];
mockValues.forEach((mockValue, i) => {
const result = store.set(mockKey, mockValue);
expect(setItemSpy).toHaveBeenNthCalledWith(i + 1, mockKey, JSON.stringify(mockValue));
expect(result).toBe(true);
});
expect(setItemSpy).toHaveBeenCalledTimes(mockValues.length);
});
it('should gracefully handle failure to set key', () => {
const mockKey = 'mock-set-key';
const mockValue = Math.random();
let result: boolean | undefined;
forceStorageError(() => {
result = store.set(mockKey, mockValue);
});
expect(result).toBe(false);
});
});
describe('get', () => {
it('should be able to get various types of items correctly', () => {
const initialValues = {
int: 1,
boolean: false,
null: null,
float: Math.random(),
string: '\t\t\thello\nthere\n ',
object: {
rolling: {
in: {
the: {
deep: true,
},
},
},
},
};
const initialValueKeys = Object.keys(initialValues) as (keyof typeof initialValues)[];
setStorageValue(initialValueKeys.reduce((acc, key) => {
return { ...acc, [key]: JSON.stringify(initialValues[key]) };
}, {}));
initialValueKeys.forEach((key, i) => {
const result = store.get(key);
expect(getItemSpy).toHaveBeenNthCalledWith(i + 1, key);
expect(result).toEqual(initialValues[key]);
});
expect(getItemSpy).toHaveBeenCalledTimes(initialValueKeys.length);
});
it('should return original value if item cannot be parsed', () => {
const mockKey = 'mock-get-key';
const malformedValue = '({[+:"`';
setStorageValue({
[mockKey]: malformedValue,
});
const result = store.get(mockKey);
expect(getItemSpy).toHaveBeenCalledTimes(1);
expect(getItemSpy).toHaveBeenNthCalledWith(1, mockKey);
expect(result).toBe(malformedValue);
});
it('should return null if item is not set', () => {
const mockKey = `mock-get-key-${getRandomIntBetween(1, 10)}`;
const result = store.get(mockKey);
expect(getItemSpy).toHaveBeenCalledTimes(1);
expect(getItemSpy).toHaveBeenNthCalledWith(1, mockKey);
expect(result).toBe(null);
});
});
describe('remove', () => {
it('should remove the provided key', () => {
const mockKey = `mock-remove-key-${getRandomIntBetween(1, 10)}`;
const result = store.remove(mockKey);
expect(removeItemSpy).toHaveBeenCalledTimes(1);
expect(removeItemSpy).toHaveBeenNthCalledWith(1, mockKey);
expect(result).toBe(true);
});
it('should gracefully handle failure to remove key', () => {
const mockKey = `mock-remove-key-${getRandomIntBetween(1, 10)}`;
let result: boolean | undefined;
forceStorageError(() => {
result = store.remove(mockKey);
});
expect(result).toBe(false);
});
});
describe('watch', () => {
it('should attach a storage event listener and fire when the provide key changes', () => {
const mockKey = `mock-watch-key-${getRandomIntBetween(1, 10)}`;
const mockValue = Math.random();
const mockCallback = jest.fn();
setStorageValue({
[mockKey]: JSON.stringify(mockValue),
});
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const cleanup = store.watch(mockKey, mockCallback);
// Should not get the item just yet, only register the event handler
expect(getItemSpy).not.toHaveBeenCalled();
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
expect(addEventListenerSpy.mock.calls[0][0]).toEqual('storage');
// Should not call callback for unknown key
let storageEvent = new StorageEvent('storage', { key: 'unknown-key' });
fireEvent(window, storageEvent);
expect(getItemSpy).not.toHaveBeenCalled();
expect(mockCallback).not.toHaveBeenCalled();
// Should call callback with the value from the store
storageEvent = new StorageEvent('storage', { key: mockKey });
fireEvent(window, storageEvent);
expect(getItemSpy).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenNthCalledWith(1, mockValue);
// Remove the listener
cleanup();
storageEvent = new StorageEvent('storage', { key: mockKey });
fireEvent(window, storageEvent);
// Expect unchanged call counts due to removed handler
expect(getItemSpy).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenNthCalledWith(1, mockValue);
});
});
describe('setWithExpireTime', () => {
mockDateNow(initialDateNow);
it('should set both original and last update key', () => {
const mockKey = `mock-setWithExpireTime-key-${getRandomIntBetween(1, 10)}`;
const mockValue = 'mock value';
const mockMaxAge = 3600;
store.setWithExpireTime(mockKey, mockValue, mockMaxAge);
expect(setItemSpy).toHaveBeenCalledTimes(2);
expect(setItemSpy).toHaveBeenNthCalledWith(1, mockKey, JSON.stringify(mockValue));
expect(setItemSpy).toHaveBeenNthCalledWith(2, mockKey + lastUpdatedSuffix, JSON.stringify(initialDateNow + mockMaxAge));
});
});
describe('hasExpired', () => {
mockDateNow(initialDateNow);
const mockKey = `mock-hasExpired-key-${getRandomIntBetween(1, 10)}`;
const mockLastUpdatedKey = mockKey + lastUpdatedSuffix;
it('should return true for values that have no expiration key', () => {
const result = store.hasExpired('undefined-key');
expect(result).toBe(true);
});
it('should return true for keys with last update timestamp smaller than the current time', () => {
setStorageValue({
[mockLastUpdatedKey]: JSON.stringify(initialDateNow - 1),
});
const result = store.hasExpired(mockKey);
expect(getItemSpy).toHaveBeenCalledTimes(1);
expect(result).toBe(true);
});
it('should return false for keys with last update timestamp equal to the current time', () => {
setStorageValue({
[mockLastUpdatedKey]: JSON.stringify(initialDateNow),
});
const result = store.hasExpired(mockKey);
expect(getItemSpy).toHaveBeenCalledTimes(1);
expect(result).toBe(false);
});
it('should return false for keys with last update timestamp greater than the current time', () => {
setStorageValue({
[mockLastUpdatedKey]: JSON.stringify(initialDateNow + 1),
});
const result = store.hasExpired(mockKey);
expect(getItemSpy).toHaveBeenCalledTimes(1);
expect(result).toBe(false);
});
});
});

View file

@ -0,0 +1,186 @@
import { displayTags, getHiddenTags, getSpoileredTags, imageHitsComplex, imageHitsTags } from '../tag';
import { mockStorage } from '../../../test/mock-storage';
import { getRandomArrayItem } from '../../../test/randomness';
import parseSearch from '../../match_query';
// TODO Move to source file when rewriting in TypeScript
interface StorageTagInfo {
id: number;
name: string;
images: number;
spoiler_image_uri: string | null;
}
describe('Tag utilities', () => {
const tagStorageKeyPrefix = 'bor_tags_';
const mockTagInfo: Record<string, StorageTagInfo> = {
1: {
id: 1,
name: 'safe',
images: 69,
spoiler_image_uri: null,
},
2: {
id: 2,
name: 'fox',
images: 1,
spoiler_image_uri: '/mock-fox-spoiler-image.svg',
},
3: {
id: 3,
name: 'paw pads',
images: 42,
spoiler_image_uri: '/mock-paw-pads-spoiler-image.svg',
},
4: {
id: 4,
name: 'whiskers',
images: 42,
spoiler_image_uri: null,
},
5: {
id: 5,
name: 'lilo & stitch',
images: 6,
spoiler_image_uri: null,
},
};
const getEnabledSpoilerType = () => getRandomArrayItem<SpoilerType>(['click', 'hover', 'static']);
mockStorage({
getItem(key: string): string | null {
if (key.startsWith(tagStorageKeyPrefix)) {
const tagId = key.substring(tagStorageKeyPrefix.length);
const tagInfo = mockTagInfo[tagId];
return tagInfo ? JSON.stringify(tagInfo) : null;
}
return null;
},
});
describe('getHiddenTags', () => {
it('should get a single hidden tag\'s information', () => {
window.booru.hiddenTagList = [1, 1];
const result = getHiddenTags();
expect(result).toHaveLength(1);
expect(result).toEqual([mockTagInfo[1]]);
});
it('should get the list of multiple hidden tags in the correct order', () => {
window.booru.hiddenTagList = [1, 2, 2, 2, 3, 4, 4];
const result = getHiddenTags();
expect(result).toHaveLength(4);
expect(result).toEqual([
mockTagInfo[3],
mockTagInfo[2],
mockTagInfo[1],
mockTagInfo[4],
]);
});
});
describe('getSpoileredTags', () => {
it('should return an empty array if spoilers are off', () => {
window.booru.spoileredTagList = [1, 2, 3, 4];
window.booru.spoilerType = 'off';
const result = getSpoileredTags();
expect(result).toHaveLength(0);
});
it('should get a single spoilered tag\'s information', () => {
window.booru.spoileredTagList = [1, 1];
window.booru.ignoredTagList = [];
window.booru.spoilerType = getEnabledSpoilerType();
const result = getSpoileredTags();
expect(result).toHaveLength(1);
expect(result).toEqual([mockTagInfo[1]]);
});
it('should get the list of multiple spoilered tags in the correct order', () => {
window.booru.spoileredTagList = [1, 1, 2, 2, 3, 4, 4];
window.booru.ignoredTagList = [];
window.booru.spoilerType = getEnabledSpoilerType();
const result = getSpoileredTags();
expect(result).toHaveLength(4);
expect(result).toEqual([
mockTagInfo[2],
mockTagInfo[3],
mockTagInfo[1],
mockTagInfo[4],
]);
});
it('should omit ignored tags from the list', () => {
window.booru.spoileredTagList = [1, 2, 2, 3, 4, 4, 4];
window.booru.ignoredTagList = [2, 3];
window.booru.spoilerType = getEnabledSpoilerType();
const result = getSpoileredTags();
expect(result).toHaveLength(2);
expect(result).toEqual([
mockTagInfo[1],
mockTagInfo[4],
]);
});
});
describe('imageHitsTags', () => {
it('should return the list of tags that apply to the image', () => {
const mockImageTags = [1, 4];
const mockImage = new Image();
mockImage.dataset.imageTags = JSON.stringify(mockImageTags);
const result = imageHitsTags(mockImage, [mockTagInfo[1], mockTagInfo[2], mockTagInfo[3], mockTagInfo[4]]);
expect(result).toHaveLength(mockImageTags.length);
expect(result).toEqual([
mockTagInfo[1],
mockTagInfo[4],
]);
});
});
describe('imageHitsComplex', () => {
it('should return true if image matches the complex filter', () => {
const mockSearchAST = parseSearch('safe || solo');
const mockImageTagAliases = mockTagInfo[1].name;
const mockImage = new Image();
mockImage.dataset.imageTagAliases = mockImageTagAliases;
const result = imageHitsComplex(mockImage, mockSearchAST);
expect(result).toBe(true);
});
});
describe('displayTags', () => {
it('should return the correct value for a single tag', () => {
const result = displayTags([mockTagInfo[1]]);
expect(result).toEqual(mockTagInfo[1].name);
});
it('should return the correct value for two tags', () => {
const result = displayTags([mockTagInfo[1], mockTagInfo[4]]);
expect(result).toEqual(`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}">, ${mockTagInfo[4].name}</span>`);
});
it('should return the correct value for three tags', () => {
const result = displayTags([mockTagInfo[1], mockTagInfo[4], mockTagInfo[3]]);
expect(result).toEqual(`${mockTagInfo[1].name}<span title="${mockTagInfo[4].name}, ${mockTagInfo[3].name}">, ${mockTagInfo[4].name}, ${mockTagInfo[3].name}</span>`);
});
it('should escape HTML in the tag name', () => {
const result = displayTags([mockTagInfo[5]]);
expect(result).toEqual('lilo &amp; stitch');
});
});
});

View file

@ -1,13 +1,11 @@
// http://stackoverflow.com/a/5306832/1726690
function moveElement(array, from, to) {
export function moveElement(array, from, to) {
array.splice(to, 0, array.splice(from, 1)[0]);
}
function arraysEqual(array1, array2) {
export function arraysEqual(array1, array2) {
for (let i = 0; i < array1.length; ++i) {
if (array1[i] !== array2[i]) return false;
}
return true;
}
export { moveElement, arraysEqual };

View file

@ -26,7 +26,7 @@ function showVideoThumb(img) {
return true;
}
function showThumb(img) {
export function showThumb(img) {
const size = img.dataset.size;
const uris = JSON.parse(img.dataset.uris);
const thumbUri = uris[size].replace(/webm$/, 'gif');
@ -57,7 +57,7 @@ function showThumb(img) {
return true;
}
function showBlock(img) {
export function showBlock(img) {
img.querySelector('.image-filtered').classList.add('hidden');
const imageShowClasses = img.querySelector('.image-show').classList;
imageShowClasses.remove('hidden');
@ -82,7 +82,7 @@ function hideVideoThumb(img, spoilerUri, reason) {
vidEl.pause();
}
function hideThumb(img, spoilerUri, reason) {
export function hideThumb(img, spoilerUri, reason) {
const picEl = img.querySelector('picture');
if (!picEl) return hideVideoThumb(img, spoilerUri, reason);
@ -97,7 +97,7 @@ function hideThumb(img, spoilerUri, reason) {
imgOverlay.classList.remove('hidden');
}
function spoilerThumb(img, spoilerUri, reason) {
export function spoilerThumb(img, spoilerUri, reason) {
hideThumb(img, spoilerUri, reason);
switch (window.booru.spoilerType) {
@ -114,7 +114,7 @@ function spoilerThumb(img, spoilerUri, reason) {
}
}
function spoilerBlock(img, spoilerUri, reason) {
export function spoilerBlock(img, spoilerUri, reason) {
const imgEl = img.querySelector('.image-filtered img');
const imgReason = img.querySelector('.filter-explanation');
@ -126,5 +126,3 @@ function spoilerBlock(img, spoilerUri, reason) {
img.querySelector('.image-show').classList.add('hidden');
img.querySelector('.image-filtered').classList.remove('hidden');
}
export { showThumb, showBlock, spoilerThumb, spoilerBlock, hideThumb };

View file

@ -1,7 +1,4 @@
//@ts-check
/*
* Client-side tag completion.
*/
// Client-side tag completion.
import store from './store';
/**
@ -132,8 +129,6 @@ export class LocalAutocompleter {
let min = 0;
let max = this.numTags;
/** @type {number[]} */
//@ts-expect-error No type for window.booru yet
const hiddenTags = window.booru.hiddenTagList;
while (min < max - 1) {

View file

@ -1,8 +1,6 @@
/**
* Request Utils
*/
// Request Utils
function fetchJson(verb, endpoint, body) {
export function fetchJson(verb, endpoint, body) {
const data = {
method: verb,
credentials: 'same-origin',
@ -21,7 +19,7 @@ function fetchJson(verb, endpoint, body) {
return fetch(endpoint, data);
}
function fetchHtml(endpoint) {
export function fetchHtml(endpoint) {
return fetch(endpoint, {
credentials: 'same-origin',
headers: {
@ -31,11 +29,9 @@ function fetchHtml(endpoint) {
});
}
function handleError(response) {
export function handleError(response) {
if (!response.ok) {
throw new Error('Received error from server');
}
return response;
}
export { fetchJson, fetchHtml, handleError };

View file

@ -2,7 +2,7 @@
* localStorage utils
*/
const lastUpdatedSuffix = '__lastUpdated';
export const lastUpdatedSuffix = '__lastUpdated';
export default {
@ -38,9 +38,11 @@ export default {
// Watch changes to a specified key - returns value on change
watch(key, callback) {
window.addEventListener('storage', event => {
const handler = event => {
if (event.key === key) callback(this.get(key));
});
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
},
// set() with an additional key containing the current time + expiration time
@ -56,11 +58,7 @@ export default {
const lastUpdatedKey = key + lastUpdatedSuffix;
const lastUpdatedTime = this.get(lastUpdatedKey);
if (Date.now() > lastUpdatedTime) {
return true;
}
return false;
return Date.now() > lastUpdatedTime;
},
};

View file

@ -19,13 +19,13 @@ function sortTags(hidden, a, b) {
return a.spoiler_image_uri ? -1 : 1;
}
function getHiddenTags() {
export function getHiddenTags() {
return unique(window.booru.hiddenTagList)
.map(tagId => getTag(tagId))
.sort(sortTags.bind(null, true));
}
function getSpoileredTags() {
export function getSpoileredTags() {
if (window.booru.spoilerType === 'off') return [];
return unique(window.booru.spoileredTagList)
@ -34,16 +34,16 @@ function getSpoileredTags() {
.sort(sortTags.bind(null, false));
}
function imageHitsTags(img, matchTags) {
export function imageHitsTags(img, matchTags) {
const imageTags = JSON.parse(img.dataset.imageTags);
return matchTags.filter(t => imageTags.indexOf(t.id) !== -1);
}
function imageHitsComplex(img, matchComplex) {
export function imageHitsComplex(img, matchComplex) {
return matchComplex.hitsImage(img);
}
function displayTags(tags) {
export function displayTags(tags) {
const mainTag = tags[0], otherTags = tags.slice(1);
let list = escapeHtml(mainTag.name), extras;
@ -54,5 +54,3 @@ function displayTags(tags) {
return list;
}
export { getHiddenTags, getSpoileredTags, imageHitsTags, imageHitsComplex, displayTags };

View file

@ -13,7 +13,7 @@ import { registerEvents } from './boorujs';
import { setupBurgerMenu } from './burger';
import { bindCaptchaLinks } from './captcha';
import { setupComments } from './comment';
import { setupDupeReports } from './duplicate_reports.js';
import { setupDupeReports } from './duplicate_reports';
import { setFingerprintCookie } from './fingerprint';
import { setupGalleryEditing } from './galleries';
import { initImagesClientside } from './imagesclientside';
@ -31,7 +31,7 @@ import { setupTagEvents } from './tagsmisc';
import { setupTimestamps } from './timeago';
import { setupImageUpload } from './upload';
import { setupSearch } from './search';
import { setupToolbar } from './markdowntoolbar.js';
import { setupToolbar } from './markdowntoolbar';
import { hideStaffTools } from './staffhider';
import { pollOptionCreator } from './poll';

6046
assets/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,10 @@
{
"type": "module",
"scripts": {
"deploy": "cross-env NODE_ENV=production webpack",
"lint": "eslint . --ext .js,.ts",
"test": "jest --ci",
"test:watch": "jest --watch",
"watch": "webpack --watch"
},
"dependencies": {
@ -13,8 +16,12 @@
},
"devDependencies": {
"@rollup/plugin-typescript": "^8.2.5",
"@typescript-eslint/eslint-plugin": "^4.32.0",
"@typescript-eslint/parser": "^4.32.0",
"@testing-library/dom": "^8.7.2",
"@testing-library/jest-dom": "^5.14.1",
"@types/jest": "^27.0.3",
"@types/web": "^0.0.40",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"acorn": "^7.4.1",
"autoprefixer": "^10.3.5",
"copy-webpack-plugin": "^6.4.1",
@ -22,9 +29,13 @@
"css-loader": "^5.2.7",
"css-minimizer-webpack-plugin": "^2.0.0",
"eslint": "^7.32.0",
"eslint-plugin-jest": "^25.7.0",
"eslint-plugin-jest-dom": "^4.0.1",
"eslint-webpack-plugin": "^3.0.1",
"file-loader": "^6.2.0",
"ignore-emit-webpack-plugin": "^2.0.6",
"jest": "^27.4.7",
"jest-fetch-mock": "^3.0.3",
"mini-css-extract-plugin": "^2.3.0",
"normalize-scss": "^7.0.1",
"postcss-loader": "^5.3.0",
@ -40,6 +51,7 @@
"source-map-support": "^0.5.20",
"style-loader": "^1.3.0",
"terser-webpack-plugin": "^3.1.0",
"ts-jest": "^27.0.4",
"typescript": "^4.4",
"webpack": "^5.53.0",
"webpack-cli": "^4.8.0",

23
assets/test/jest-setup.ts Normal file
View file

@ -0,0 +1,23 @@
import '@testing-library/jest-dom';
const blankFilter = {
leftOperand: null,
negate: false,
op: null,
rightOperand: null,
};
window.booru = {
csrfToken: 'mockCsrfToken',
hiddenTag: '/mock-tagblocked.svg',
hiddenTagList: [],
ignoredTagList: [],
imagesWithDownvotingDisabled: [],
spoilerType: 'off',
spoileredTagList: [],
userCanEditFilter: false,
userIsSignedIn: false,
watchedTagList: [],
hiddenFilter: blankFilter,
spoileredFilter: blankFilter,
};

View file

@ -0,0 +1,9 @@
export function mockDateNow(initialDateNow: number): void {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(initialDateNow);
});
afterAll(() => {
jest.useRealTimers();
});
}

View file

@ -0,0 +1,89 @@
type MockStorageKeys = 'getItem' | 'setItem' | 'removeItem';
export function mockStorage<Keys extends MockStorageKeys>(options: Pick<Storage, Keys>): { [k in `${Keys}Spy`]: jest.SpyInstance } {
const getItemSpy = 'getItem' in options ? jest.spyOn(Storage.prototype, 'getItem') : undefined;
const setItemSpy = 'setItem' in options ? jest.spyOn(Storage.prototype, 'setItem') : undefined;
const removeItemSpy = 'removeItem' in options ? jest.spyOn(Storage.prototype, 'removeItem') : undefined;
beforeAll(() => {
getItemSpy && getItemSpy.mockImplementation((options as Storage).getItem);
setItemSpy && setItemSpy.mockImplementation((options as Storage).setItem);
removeItemSpy && removeItemSpy.mockImplementation((options as Storage).removeItem);
});
afterEach(() => {
getItemSpy && getItemSpy.mockClear();
setItemSpy && setItemSpy.mockClear();
removeItemSpy && removeItemSpy.mockClear();
});
afterAll(() => {
getItemSpy && getItemSpy.mockRestore();
setItemSpy && setItemSpy.mockRestore();
removeItemSpy && removeItemSpy.mockRestore();
});
return { getItemSpy, setItemSpy, removeItemSpy } as ReturnType<typeof mockStorage>;
}
type MockStorageImplApi = { [k in `${MockStorageKeys}Spy`]: jest.SpyInstance } & {
/**
* Forces the mock storage back to its default (empty) state
* @param value
*/
clearStorage: VoidFunction,
/**
* Forces the mock storage to be in the specific state provided as the parameter
* @param value
*/
setStorageValue: (value: Record<string, string>) => void,
/**
* Forces the mock storage to throw an error for the duration of the provided function's execution,
* or in case a promise is returned by the function, until that promise is resolved.
*/
forceStorageError: <Args, Return>(func: (...args: Args[]) => Return | Promise<Return>) => void
}
export function mockStorageImpl(): MockStorageImplApi {
let shouldThrow = false;
let tempStorage: Record<string, string> = {};
const mockStorageSpies = mockStorage({
setItem(key, value) {
if (shouldThrow) throw new Error('Mock error thrown by mockStorageImpl.setItem');
tempStorage[key] = String(value);
},
getItem(key: string): string | null {
if (shouldThrow) throw new Error('Mock error thrown by mockStorageImpl.getItem');
return key in tempStorage ? tempStorage[key] : null;
},
removeItem(key: string) {
if (shouldThrow) throw new Error('Mock error thrown by mockStorageImpl.removeItem');
delete tempStorage[key];
},
});
const forceStorageError: MockStorageImplApi['forceStorageError'] = func => {
shouldThrow = true;
const value = func();
if (!(value instanceof Promise)) {
shouldThrow = false;
return;
}
value.then(() => {
shouldThrow = false;
});
};
const setStorageValue: MockStorageImplApi['setStorageValue'] = value => {
tempStorage = value;
};
const clearStorage = () => setStorageValue({});
afterEach(() => {
clearStorage();
});
return { ...mockStorageSpies, clearStorage, forceStorageError, setStorageValue };
}

27
assets/test/randomness.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* @fileOverview Utilities to aid in unit testing where multiple potential values need to be considered.
* Picking from a pool of limited data randomly can add further resilience.
*/
/**
* Randomly returns either `true` or `false`
*/
export const getRandomBoolean = (): boolean => Math.random() > 0.5;
/**
* Returns a random integer between `min` and `max`, inclusive
*/
export function getRandomIntBetween(min: number, max: number): number {
const intMin = Math.ceil(min);
const intMax = Math.floor(max);
return Math.floor(Math.random() * (intMax - intMin + 1) + intMin);
}
/**
* Returns a random item from the provided `array`, throws an error if `array is empty
*/
export const getRandomArrayItem = <T>(array: T[]): T => {
if (array.length === 0) throw new Error('Attempt to pick random item from empty array');
return array[Math.floor(Math.random() * array.length)];
};

View file

@ -3,7 +3,13 @@
"noEmit": true,
"baseUrl": "./js",
"target": "ES2018",
"esModuleInterop": true,
"moduleResolution": "Node",
"allowJs": true,
"lib": [
"ES2018",
"DOM"
],
"strict": true
}
}

58
assets/types/booru-object.d.ts vendored Normal file
View file

@ -0,0 +1,58 @@
type SpoilerType = 'click' | 'hover' | 'static' | 'off';
interface BooruObject {
csrfToken: string;
/**
* One of the specified values, based on user setting
*/
spoilerType: SpoilerType;
/**
* List of numeric image IDs as strings
*/
imagesWithDownvotingDisabled: string[];
/**
* Array of watched tag IDs as numbers
*/
watchedTagList: number[];
/**
* Array of spoilered tag IDs as numbers
*/
spoileredTagList: number[];
/**
* Array of ignored tag IDs as numbers
*/
ignoredTagList: number[];
/**
* Array of hidden tag IDs as numbers
*/
hiddenTagList: number[];
/**
* Stores the URL of the default "tag blocked" image
*/
hiddenTag: string;
userIsSignedIn: boolean;
/**
* Indicates if the current user has edit rights to the currently selected filter
*/
userCanEditFilter: boolean;
/**
* SearchAST instance for hidden tags, converted from raw AST data in {@see import('../js/booru.js')}
*
* TODO Properly type after TypeScript migration
*
* @type {import('../js/match_query.js').SearchAST}
*/
hiddenFilter: unknown;
/**
* SearchAST instance for spoilered tags, converted from raw AST data in {@see import('../js/booru.js')}
*
* TODO Properly type after TypeScript migration
*
* @type {import('../js/match_query.js').SearchAST}
*/
spoileredFilter: unknown;
}
interface Window {
booru: BooruObject;
}

View file

@ -1,18 +1,25 @@
const fs = require('fs');
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const IgnoreEmitPlugin = require('ignore-emit-webpack-plugin');
const ESLintPlugin = require('eslint-webpack-plugin');
import fs from 'fs';
import path from 'path';
import url from 'url';
import TerserPlugin from 'terser-webpack-plugin';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import CopyPlugin from 'copy-webpack-plugin';
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import IgnoreEmitPlugin from 'ignore-emit-webpack-plugin';
import ESLintPlugin from 'eslint-webpack-plugin';
import autoprefixer from 'autoprefixer';
import rollupPluginIncludepaths from 'rollup-plugin-includepaths';
import rollupPluginMultiEntry from 'rollup-plugin-multi-entry';
import rollupPluginBuble from 'rollup-plugin-buble';
import rollupPluginTypescript from '@rollup/plugin-typescript';
const isDevelopment = process.env.NODE_ENV !== 'production';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const includePaths = require('rollup-plugin-includepaths')();
const multiEntry = require('rollup-plugin-multi-entry')();
const buble = require('rollup-plugin-buble')({ transforms: { dangerousForOf: true } });
const typescript = require('@rollup/plugin-typescript')();
const includePaths = rollupPluginIncludepaths();
const multiEntry = rollupPluginMultiEntry();
const buble = rollupPluginBuble({ transforms: { dangerousForOf: true } });
const typescript = rollupPluginTypescript();
let plugins = [
new IgnoreEmitPlugin(/css\/.*(?<!css)$/),
@ -56,7 +63,7 @@ for (const name of themeNames) {
themes[`css/${name}`] = `./css/themes/${name}.scss`;
}
module.exports = {
export default {
mode: isDevelopment ? 'development' : 'production',
entry: {
'js/app.js': './js/app.js',
@ -126,7 +133,7 @@ module.exports = {
ident: 'postcss',
syntax: 'postcss-scss',
plugins: [
require('autoprefixer')(),
autoprefixer(),
],
},
},