From df2e336a24011bd8806ad56ffc7cbf593ae54ff0 Mon Sep 17 00:00:00 2001 From: liamwhite Date: Mon, 22 Apr 2024 18:43:36 -0400 Subject: [PATCH] upload: add pinning test (#231) --- assets/fix-jsdom.ts | 13 ++ assets/jest.config.js | 2 +- assets/js/__tests__/ujs.spec.ts | 14 +-- assets/js/__tests__/upload-test.png | Bin 0 -> 527 bytes assets/js/__tests__/upload-test.webm | Bin 0 -> 555 bytes assets/js/__tests__/upload.spec.ts | 178 +++++++++++++++++++++++++++ assets/js/upload.js | 12 +- assets/test/fix-event-listeners.ts | 26 ++++ 8 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 assets/fix-jsdom.ts create mode 100644 assets/js/__tests__/upload-test.png create mode 100644 assets/js/__tests__/upload-test.webm create mode 100644 assets/js/__tests__/upload.spec.ts create mode 100644 assets/test/fix-event-listeners.ts diff --git a/assets/fix-jsdom.ts b/assets/fix-jsdom.ts new file mode 100644 index 00000000..d83d15d2 --- /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 32c0a334..6251b5d2 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__/ujs.spec.ts b/assets/js/__tests__/ujs.spec.ts index 142e47c0..7f87b766 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 0000000000000000000000000000000000000000..770601f791c9ab903b5e3512dc49b685b638a34f GIT binary patch literal 527 zcmV+q0`UEbP)&Q5?tduUZsQ6jTt=a0jg=mx^dK3nVZQGtg@2K74`qD)%ZajzO!?&|}GJ zXlbssp*0AC9uUn9O+_ssw|kIM0)zh3hyOYM!#TeL?rKiet+oK@M$wFhf>J!OB6U2| z#vpz87?V}2FdK=4X;~k)xBzba;w=7GJzCOI!6g9!wO|$^Fq($mwHEL0Y|ibkJ>U6#0m1Zg z#*`8=)c^nh32;bRa{vG?BLDy{BLR4&KXw2B00(qQO+^Rj1Qie_2jv)t)c^nh8FWQh zbVF}#ZDnqB07G(RVRU6=Aa`kWXdp*PO;A^X4i^9b01Qb)K~xCWWBAX&000940RR}? Rjj#X!002ovPDHLkV1kc2)Z+jE literal 0 HcmV?d00001 diff --git a/assets/js/__tests__/upload-test.webm b/assets/js/__tests__/upload-test.webm new file mode 100644 index 0000000000000000000000000000000000000000..12442b6a330ba8ff13db04172b22415389c44b22 GIT binary patch literal 555 zcmb1gy}x+AQ(GgW({~{L)X3uWxsk)EsiizMDc7mJk;$pGkx3%BA)S!{1Q>q{`pz!d z<-5B(cy)`Y=gPF;HH`})Jh6~<*+AY6-`zbxIiZll>A`E77&ReWnc&?($tK39Zy@F{ zM1qZ@1p#u^CavomoB5p_d>eXw63f!e4D<}m^b8FQ!W~ihE}b0?E)Yk6oPTB=)OF6+ z8ySm_c5IoqS?=_S(`)85GAM1G(_EUD($UD!)*2Qc7GT-j$f*3dxrHeyis8YO4ULSu z8X0FbGKFsF2;JDo5W2IG2^0bj4aLO^k`FYbpP1#kxZTyy+26%A$fX_C5yi!~k`Htz zBdkk5u@qVL44@a1fnG2+1bV?pAty7bte`@-tiUq;#6s7_9WJ3kjv=1@elG2k4GgTn zNYB8;00J1~owt>4eBQ{gcugY%L&GA^W~T>0|FSVysK`ioF)%17FbMAd|9| { + 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/upload.js b/assets/js/upload.js index 84482341..62f749fb 100644 --- a/assets/js/upload.js +++ b/assets/js/upload.js @@ -132,21 +132,17 @@ 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 diff --git a/assets/test/fix-event-listeners.ts b/assets/test/fix-event-listeners.ts new file mode 100644 index 00000000..d4e0a8bf --- /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 = {}; + }); +}