diff --git a/assets/fix-jsdom.ts b/assets/fix-jsdom.ts new file mode 100644 index 000000000..d83d15d2d --- /dev/null +++ b/assets/fix-jsdom.ts @@ -0,0 +1,13 @@ +import JSDOMEnvironment from 'jest-environment-jsdom'; + +export default class FixJSDOMEnvironment extends JSDOMEnvironment { + constructor(...args: ConstructorParameters) { + super(...args); + + // https://github.com/jsdom/jsdom/issues/1721#issuecomment-1484202038 + // jsdom URL and Blob are missing most of the implementation + // Use the node version of these types instead + this.global.URL = URL; + this.global.Blob = Blob; + } +} diff --git a/assets/jest.config.js b/assets/jest.config.js index 32c0a3344..6251b5d2e 100644 --- a/assets/jest.config.js +++ b/assets/jest.config.js @@ -25,7 +25,7 @@ export default { }, preset: 'ts-jest/presets/js-with-ts-esm', setupFilesAfterEnv: ['/test/jest-setup.ts'], - testEnvironment: 'jsdom', + testEnvironment: './fix-jsdom.ts', testPathIgnorePatterns: ['/node_modules/', '/dist/'], moduleNameMapper: { './js/(.*)': '/js/$1', diff --git a/assets/js/__tests__/input-duplicator.spec.ts b/assets/js/__tests__/input-duplicator.spec.ts new file mode 100644 index 000000000..fc7adf0b7 --- /dev/null +++ b/assets/js/__tests__/input-duplicator.spec.ts @@ -0,0 +1,91 @@ +import { inputDuplicatorCreator } from '../input-duplicator'; +import { assertNotNull } from '../utils/assert'; +import { $, $$, removeEl } from '../utils/dom'; + +describe('Input duplicator functionality', () => { + beforeEach(() => { + document.documentElement.insertAdjacentHTML('beforeend', `
+
3
+
+ + +
+
+ +
+
`); + }); + + afterEach(() => { + removeEl($$('form')); + }); + + function runCreator() { + inputDuplicatorCreator({ + addButtonSelector: '.js-add-input', + fieldSelector: '.js-input-source', + maxInputCountSelector: '.js-max-input-count', + removeButtonSelector: '.js-remove-input', + }); + } + + it('should ignore forms without a duplicator button', () => { + removeEl($$('button')); + expect(runCreator()).toBeUndefined(); + }); + + it('should duplicate the input elements', () => { + runCreator(); + + expect($$('input')).toHaveLength(1); + + assertNotNull($('.js-add-input')).click(); + + expect($$('input')).toHaveLength(2); + }); + + it('should duplicate the input elements when the button is before the inputs', () => { + const form = assertNotNull($('form')); + const buttonDiv = assertNotNull($('.js-button-container')); + removeEl(buttonDiv); + form.insertAdjacentElement('afterbegin', buttonDiv); + runCreator(); + + assertNotNull($('.js-add-input')).click(); + + expect($$('input')).toHaveLength(2); + }); + + it('should not create more input elements than the limit', () => { + runCreator(); + + for (let i = 0; i < 5; i += 1) { + assertNotNull($('.js-add-input')).click(); + } + + expect($$('input')).toHaveLength(3); + }); + + it('should remove duplicated input elements', () => { + runCreator(); + + assertNotNull($('.js-add-input')).click(); + assertNotNull($('.js-remove-input')).click(); + + expect($$('input')).toHaveLength(1); + }); + + it('should not remove the last input element', () => { + runCreator(); + + assertNotNull($('.js-remove-input')).click(); + assertNotNull($('.js-remove-input')).click(); + for (let i = 0; i < 5; i += 1) { + assertNotNull($('.js-remove-input')).click(); + } + + expect($$('input')).toHaveLength(1); + }); +}); diff --git a/assets/js/__tests__/ujs.spec.ts b/assets/js/__tests__/ujs.spec.ts index 142e47c0f..7f87b7664 100644 --- a/assets/js/__tests__/ujs.spec.ts +++ b/assets/js/__tests__/ujs.spec.ts @@ -1,5 +1,5 @@ import fetchMock from 'jest-fetch-mock'; -import { fireEvent } from '@testing-library/dom'; +import { fireEvent, waitFor } from '@testing-library/dom'; import { assertType } from '../utils/assert'; import '../ujs'; @@ -199,18 +199,10 @@ describe('Remote utilities', () => { })); it('should reload the page on 300 multiple choices response', () => { - const promiseLike = { - then(cb: (r: Response) => void) { - if (cb) { - cb(new Response('', { status: 300 })); - } - } - }; - - jest.spyOn(global, 'fetch').mockReturnValue(promiseLike as any); + jest.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300})); submitForm(); - expect(window.location.reload).toHaveBeenCalledTimes(1); + return waitFor(() => expect(window.location.reload).toHaveBeenCalledTimes(1)); }); }); }); diff --git a/assets/js/__tests__/upload-test.png b/assets/js/__tests__/upload-test.png new file mode 100644 index 000000000..770601f79 Binary files /dev/null and b/assets/js/__tests__/upload-test.png differ diff --git a/assets/js/__tests__/upload-test.webm b/assets/js/__tests__/upload-test.webm new file mode 100644 index 000000000..12442b6a3 Binary files /dev/null and b/assets/js/__tests__/upload-test.webm differ diff --git a/assets/js/__tests__/upload.spec.ts b/assets/js/__tests__/upload.spec.ts new file mode 100644 index 000000000..401582afe --- /dev/null +++ b/assets/js/__tests__/upload.spec.ts @@ -0,0 +1,178 @@ +import { $, $$, removeEl } from '../utils/dom'; +import { assertNotNull, assertNotUndefined } from '../utils/assert'; + +import fetchMock from 'jest-fetch-mock'; +import { fixEventListeners } from '../../test/fix-event-listeners'; +import { fireEvent, waitFor } from '@testing-library/dom'; +import { promises } from 'fs'; +import { join } from 'path'; + +import { setupImageUpload } from '../upload'; + +/* eslint-disable camelcase */ +const scrapeResponse = { + description: 'test', + images: [ + {url: 'http://localhost/images/1', camo_url: 'http://localhost/images/1'}, + {url: 'http://localhost/images/2', camo_url: 'http://localhost/images/2'}, + ], + source_url: 'http://localhost/images', + author_name: 'test', +}; +const nullResponse = null; +const errorResponse = { + errors: ['Error 1', 'Error 2'], +}; +/* eslint-enable camelcase */ + +describe('Image upload form', () => { + let mockPng: File; + let mockWebm: File; + + beforeAll(async() => { + const mockPngPath = join(__dirname, 'upload-test.png'); + const mockWebmPath = join(__dirname, 'upload-test.webm'); + + mockPng = new File([(await promises.readFile(mockPngPath, { encoding: null })).buffer], 'upload-test.png', { type: 'image/png' }); + mockWebm = new File([(await promises.readFile(mockWebmPath, { encoding: null })).buffer], 'upload-test.webm', { type: 'video/webm' }); + }); + + beforeAll(() => { + fetchMock.enableMocks(); + }); + + afterAll(() => { + fetchMock.disableMocks(); + }); + + fixEventListeners(window); + + let form: HTMLFormElement; + let imgPreviews: HTMLDivElement; + let fileField: HTMLInputElement; + let remoteUrl: HTMLInputElement; + let scraperError: HTMLDivElement; + let fetchButton: HTMLButtonElement; + let tagsEl: HTMLTextAreaElement; + let sourceEl: HTMLInputElement; + let descrEl: HTMLTextAreaElement; + + beforeEach(() => { + document.documentElement.insertAdjacentHTML('beforeend', ` +
+
+ + + + + + + + +
+ `); + + form = assertNotNull($('form')); + imgPreviews = assertNotNull($('#js-image-upload-previews')); + fileField = assertNotUndefined($$('.js-scraper')[0]); + remoteUrl = assertNotUndefined($$('.js-scraper')[1]); + scraperError = assertNotUndefined($$('.js-scraper')[2]); + tagsEl = assertNotNull($('.js-image-tags-input')); + sourceEl = assertNotNull($('.js-source-url')); + descrEl = assertNotNull($('.js-image-descr-input')); + fetchButton = assertNotNull($('#js-scraper-preview')); + + setupImageUpload(); + fetchMock.resetMocks(); + }); + + afterEach(() => { + removeEl(form); + }); + + it('should disable fetch button on empty source', () => { + fireEvent.input(remoteUrl, { target: { value: '' }}); + expect(fetchButton.disabled).toBe(true); + }); + + it('should enable fetch button on non-empty source', () => { + fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }}); + expect(fetchButton.disabled).toBe(false); + }); + + it('should create a preview element when an image file is uploaded', () => { + fireEvent.change(fileField, { target: { files: [mockPng] }}); + return waitFor(() => expect(imgPreviews.querySelectorAll('img')).toHaveLength(1)); + }); + + it('should create a preview element when a Matroska video file is uploaded', () => { + fireEvent.change(fileField, { target: { files: [mockWebm] }}); + return waitFor(() => expect(imgPreviews.querySelectorAll('video')).toHaveLength(1)); + }); + + it('should block navigation away after an image file is attached, but not after form submission', async() => { + fireEvent.change(fileField, { target: { files: [mockPng] }}); + await waitFor(() => { expect(imgPreviews.querySelectorAll('img')).toHaveLength(1); }); + + const failedUnloadEvent = new Event('beforeunload', { cancelable: true }); + expect(fireEvent(window, failedUnloadEvent)).toBe(false); + + await new Promise(resolve => { + form.addEventListener('submit', event => { + event.preventDefault(); + resolve(); + }); + form.submit(); + }); + + const succeededUnloadEvent = new Event('beforeunload', { cancelable: true }); + expect(fireEvent(window, succeededUnloadEvent)).toBe(true); + }); + + it('should scrape images when the fetch button is clicked', async() => { + fetchMock.mockResolvedValue(new Response(JSON.stringify(scrapeResponse), { status: 200 })); + fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }}); + + await new Promise(resolve => { + tagsEl.addEventListener('addtag', (event: Event) => { + expect((event as CustomEvent).detail).toEqual({name: 'artist:test'}); + resolve(); + }); + + fireEvent.keyDown(remoteUrl, { keyCode: 13 }); + }); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(imgPreviews.querySelectorAll('img')).toHaveLength(2)); + + expect(scraperError.innerHTML).toEqual(''); + expect(sourceEl.value).toEqual('http://localhost/images'); + expect(descrEl.value).toEqual('test'); + }); + + it('should show null scrape result', () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify(nullResponse), { status: 200 })); + + fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }}); + fetchButton.click(); + + return waitFor(() => { + expect(fetch).toHaveBeenCalledTimes(1); + expect(imgPreviews.querySelectorAll('img')).toHaveLength(0); + expect(scraperError.innerText).toEqual('No image found at that address.'); + }); + }); + + it('should show error scrape result', () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify(errorResponse), { status: 200 })); + + fireEvent.input(remoteUrl, { target: { value: 'http://localhost/images/1' }}); + fetchButton.click(); + + return waitFor(() => { + expect(fetch).toHaveBeenCalledTimes(1); + expect(imgPreviews.querySelectorAll('img')).toHaveLength(0); + expect(scraperError.innerText).toEqual('Error 1 Error 2'); + }); + }); +}); diff --git a/assets/js/input-duplicator.js b/assets/js/input-duplicator.js deleted file mode 100644 index 2ffa89bc1..000000000 --- a/assets/js/input-duplicator.js +++ /dev/null @@ -1,83 +0,0 @@ -import { $, $$, disableEl, enableEl, removeEl } from './utils/dom'; -import { delegate, leftClick } from './utils/events'; - -/** - * @typedef InputDuplicatorOptions - * @property {string} addButtonSelector - * @property {string} fieldSelector - * @property {string} maxInputCountSelector - * @property {string} removeButtonSelector - */ - -/** - * @param {InputDuplicatorOptions} options - */ -function inputDuplicatorCreator({ - addButtonSelector, - fieldSelector, - maxInputCountSelector, - removeButtonSelector -}) { - const addButton = $(addButtonSelector); - if (!addButton) { - return; - } - - const form = addButton.closest('form'); - const fieldRemover = (event, target) => { - event.preventDefault(); - - // Prevent removing the final field element to not "brick" the form - const existingFields = $$(fieldSelector, form); - if (existingFields.length <= 1) { - return; - } - - removeEl(target.closest(fieldSelector)); - enableEl(addButton); - }; - - delegate(document, 'click', { - [removeButtonSelector]: leftClick(fieldRemover) - }); - - - const maxOptionCount = parseInt($(maxInputCountSelector, form).innerHTML, 10); - addButton.addEventListener('click', e => { - e.preventDefault(); - - const existingFields = $$(fieldSelector, form); - let existingFieldsLength = existingFields.length; - if (existingFieldsLength < maxOptionCount) { - // The last element matched by the `fieldSelector` will be the last field, make a copy - const prevField = existingFields[existingFieldsLength - 1]; - const prevFieldCopy = prevField.cloneNode(true); - const prevFieldCopyInputs = $$('input', prevFieldCopy); - prevFieldCopyInputs.forEach(prevFieldCopyInput => { - // Reset new input's value - prevFieldCopyInput.value = ''; - prevFieldCopyInput.removeAttribute('value'); - // Increment sequential attributes of the input - ['name', 'id'].forEach(attr => { - prevFieldCopyInput.setAttribute(attr, prevFieldCopyInput[attr].replace(/\d+/g, `${existingFieldsLength}`)); - }); - }); - - // Insert copy before the last field's next sibling, or if none, at the end of its parent - if (prevField.nextElementSibling) { - prevField.parentNode.insertBefore(prevFieldCopy, prevField.nextElementSibling); - } - else { - prevField.parentNode.appendChild(prevFieldCopy); - } - existingFieldsLength++; - } - - // Remove the button if we reached the max number of options - if (existingFieldsLength >= maxOptionCount) { - disableEl(addButton); - } - }); -} - -export { inputDuplicatorCreator }; diff --git a/assets/js/input-duplicator.ts b/assets/js/input-duplicator.ts new file mode 100644 index 000000000..e82c892d7 --- /dev/null +++ b/assets/js/input-duplicator.ts @@ -0,0 +1,76 @@ +import { assertNotNull } from './utils/assert'; +import { $, $$, disableEl, enableEl, removeEl } from './utils/dom'; +import { delegate, leftClick } from './utils/events'; + +export interface InputDuplicatorOptions { + addButtonSelector: string; + fieldSelector: string; + maxInputCountSelector: string; + removeButtonSelector: string; +} + +export function inputDuplicatorCreator({ + addButtonSelector, + fieldSelector, + maxInputCountSelector, + removeButtonSelector +}: InputDuplicatorOptions) { + const addButton = $(addButtonSelector); + if (!addButton) { + return; + } + + const form = assertNotNull(addButton.closest('form')); + const fieldRemover = (event: MouseEvent, target: HTMLElement) => { + event.preventDefault(); + + // Prevent removing the final field element to not "brick" the form + const existingFields = $$(fieldSelector, form); + if (existingFields.length <= 1) { + return; + } + + removeEl(assertNotNull(target.closest(fieldSelector))); + enableEl(addButton); + }; + + delegate(form, 'click', { + [removeButtonSelector]: leftClick(fieldRemover) + }); + + + const maxOptionCountElement = assertNotNull($(maxInputCountSelector, form)); + const maxOptionCount = parseInt(maxOptionCountElement.innerHTML, 10); + + addButton.addEventListener('click', e => { + e.preventDefault(); + + const existingFields = $$(fieldSelector, form); + let existingFieldsLength = existingFields.length; + + if (existingFieldsLength < maxOptionCount) { + // The last element matched by the `fieldSelector` will be the last field, make a copy + const prevField = existingFields[existingFieldsLength - 1]; + const prevFieldCopy = prevField.cloneNode(true) as HTMLElement; + + $$('input', prevFieldCopy).forEach(prevFieldCopyInput => { + // Reset new input's value + prevFieldCopyInput.value = ''; + prevFieldCopyInput.removeAttribute('value'); + + // Increment sequential attributes of the input + prevFieldCopyInput.setAttribute('name', prevFieldCopyInput.name.replace(/\d+/g, `${existingFieldsLength}`)); + prevFieldCopyInput.setAttribute('id', prevFieldCopyInput.id.replace(/\d+/g, `${existingFieldsLength}`)); + }); + + prevField.insertAdjacentElement('afterend', prevFieldCopy); + + existingFieldsLength++; + } + + // Remove the button if we reached the max number of options + if (existingFieldsLength >= maxOptionCount) { + disableEl(addButton); + } + }); +} diff --git a/assets/js/upload.js b/assets/js/upload.js index 844823411..62f749fbd 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -132,22 +132,18 @@ function setupImageUpload() { }); // Enable/disable the fetch button based on content in the image scraper. Fetching with no URL makes no sense. - remoteUrl.addEventListener('input', () => { + function setFetchEnabled() { if (remoteUrl.value.length > 0) { enableFetch(); } else { disableFetch(); } - }); - - if (remoteUrl.value.length > 0) { - enableFetch(); - } - else { - disableFetch(); } + remoteUrl.addEventListener('input', () => setFetchEnabled()); + setFetchEnabled(); + // Catch unintentional navigation away from the page function beforeUnload(event) { diff --git a/assets/test/fix-event-listeners.ts b/assets/test/fix-event-listeners.ts new file mode 100644 index 000000000..d4e0a8bff --- /dev/null +++ b/assets/test/fix-event-listeners.ts @@ -0,0 +1,26 @@ +// Add helper to fix event listeners on a given target + +export function fixEventListeners(t: EventTarget) { + let eventListeners: Record; + + /* eslint-disable @typescript-eslint/no-explicit-any */ + beforeAll(() => { + eventListeners = {}; + const oldAddEventListener = t.addEventListener; + + t.addEventListener = function(type: string, listener: any, options: any): void { + eventListeners[type] = eventListeners[type] || []; + eventListeners[type].push(listener); + return oldAddEventListener(type, listener, options); + }; + }); + + afterEach(() => { + for (const key in eventListeners) { + for (const listener of eventListeners[key]) { + (t.removeEventListener as any)(key, listener); + } + } + eventListeners = {}; + }); +}