diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index ff5a2c79..dfdfab46 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -7,40 +7,40 @@ jobs: name: 'Build Elixir app' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Cache mix deps - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: | _build deps key: ${{ runner.os }}-build-deps-${{ hashFiles('mix.lock') }} - - run: docker-compose pull - - run: docker-compose build + - run: docker compose pull + - run: docker compose build - name: Build and test - run: docker-compose run app run-test + run: docker compose run app run-test - name: Security lint run: | - docker-compose run app mix sobelow --config - docker-compose run app mix deps.audit + docker compose run app mix sobelow --config + docker compose run app mix deps.audit lint-and-test: name: 'JavaScript Linting and Unit Tests' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: '16' + node-version: '20' - name: Cache node_modules id: cache-node-modules - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ./assets/node_modules key: node_modules-${{ hashFiles('./assets/package-lock.json') }} diff --git a/.gitignore b/.gitignore index 354e8069..da2c70b6 100644 --- a/.gitignore +++ b/.gitignore @@ -59,5 +59,5 @@ npm-debug.log /native/**/target /.cargo -# Jest coverage +# Vitest coverage /assets/coverage diff --git a/README.md b/README.md index 58037646..2cf81be3 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ![Philomena](/assets/static/images/phoenix.svg) ## Getting started -On systems with `docker` and `docker-compose` installed, the process should be as simple as: +On systems with `docker` and `docker compose` installed, the process should be as simple as: ``` -docker-compose build -docker-compose up +docker compose build +docker compose up ``` If you use `podman` and `podman-compose` instead, the process for constructing a rootless container is nearly identical: diff --git a/assets/.eslintignore b/assets/.eslintignore index 0171cdea..53c47bd8 100644 --- a/assets/.eslintignore +++ b/assets/.eslintignore @@ -1,3 +1,2 @@ js/vendor/* -webpack.config.js -jest.config.js +vite.config.ts diff --git a/assets/.eslintrc.yml b/assets/.eslintrc.yml index 6231dd9c..f5983245 100644 --- a/assets/.eslintrc.yml +++ b/assets/.eslintrc.yml @@ -10,7 +10,7 @@ parserOptions: plugins: - '@typescript-eslint' - - jest + - vitest globals: ga: false @@ -276,12 +276,14 @@ overrides: '@typescript-eslint/no-extra-parens': 2 no-shadow: 0 '@typescript-eslint/no-shadow': 2 - # Jest Tests (also written in TypeScript) + # Unit 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' + - 'plugin:vitest/legacy-recommended' rules: no-undefined: 0 + no-unused-expressions: 0 + vitest/valid-expect: 0 diff --git a/assets/css/common/_base.scss b/assets/css/common/_base.scss index b5375e07..7aa8ec97 100644 --- a/assets/css/common/_base.scss +++ b/assets/css/common/_base.scss @@ -9,13 +9,13 @@ @import "global"; // Because FA is a SPECIAL SNOWFLAKE. -$fa-font-path: '~@fortawesome/fontawesome-free/webfonts'; +$fa-font-path: '@fortawesome/fontawesome-free/webfonts'; -@import "~@fortawesome/fontawesome-free/scss/fontawesome.scss"; -@import "~@fortawesome/fontawesome-free/scss/solid.scss"; -@import "~@fortawesome/fontawesome-free/scss/regular.scss"; -@import "~@fortawesome/fontawesome-free/scss/brands.scss"; -@import "~normalize-scss/sass/normalize/import-now"; +@import "@fortawesome/fontawesome-free/scss/fontawesome.scss"; +@import "@fortawesome/fontawesome-free/scss/solid.scss"; +@import "@fortawesome/fontawesome-free/scss/regular.scss"; +@import "@fortawesome/fontawesome-free/scss/brands.scss"; +@import "normalize-scss/sass/normalize/import-now"; body { background-color: $background_color; @@ -469,26 +469,26 @@ span.stat { @import "shame"; @import "text"; -@import "~views/adverts"; -@import "~views/approval"; -@import "~views/badges"; -@import "~views/channels"; -@import "~views/comments"; -@import "~views/commissions"; -@import "~views/communications"; -@import "~views/duplicate_reports"; -@import "~views/filters"; -@import "~views/galleries"; -@import "~views/images"; -@import "~views/pages"; -@import "~views/polls"; -@import "~views/posts"; -@import "~views/profiles"; -@import "~views/pagination"; -@import "~views/search"; -@import "~views/staff"; -@import "~views/stats"; -@import "~views/tags"; +@import "views/adverts"; +@import "views/approval"; +@import "views/badges"; +@import "views/channels"; +@import "views/comments"; +@import "views/commissions"; +@import "views/communications"; +@import "views/duplicate_reports"; +@import "views/filters"; +@import "views/galleries"; +@import "views/images"; +@import "views/pages"; +@import "views/polls"; +@import "views/posts"; +@import "views/profiles"; +@import "views/pagination"; +@import "views/search"; +@import "views/staff"; +@import "views/stats"; +@import "views/tags"; .no-overflow { overflow: hidden; diff --git a/assets/css/common/_blocks.scss b/assets/css/common/_blocks.scss index 883302a8..44538285 100644 --- a/assets/css/common/_blocks.scss +++ b/assets/css/common/_blocks.scss @@ -124,6 +124,8 @@ a.block__header--single-item, .block__header a { .block__header--js-tabbed { @extend .block__header--light; background: transparent; + display: flex; + flex-wrap: wrap; border-bottom: $border; a { diff --git a/assets/css/common/_header.scss b/assets/css/common/_header.scss index e79e780a..5b159840 100644 --- a/assets/css/common/_header.scss +++ b/assets/css/common/_header.scss @@ -23,6 +23,11 @@ padding-left: 6px; } +.header__navigation { + display: flex; + flex-wrap: wrap; +} + a.header__link { display: inline-block; padding: 0 $header_spacing; diff --git a/assets/css/themes/dark.scss b/assets/css/themes/dark.scss index 38e2f6ad..1ab62cd6 100644 --- a/assets/css/themes/dark.scss +++ b/assets/css/themes/dark.scss @@ -190,4 +190,4 @@ $dnp_warning_hover_color: lighten($vote_down_color, 10%); $poll_form_label_background: lighten($border_color, 8); $tag_dropdown_hover_background: darken($meta_color, 4%); -@import "~common/base"; +@import "common/base"; diff --git a/assets/css/themes/default.scss b/assets/css/themes/default.scss index 17a9d6d3..8666be00 100644 --- a/assets/css/themes/default.scss +++ b/assets/css/themes/default.scss @@ -180,4 +180,4 @@ $dnp_warning_hover_color: lighten($vote_down_color, 10%); $poll_form_label_background: lighten($border_color, 8); $tag_dropdown_hover_background: darken($meta_color, 4%); -@import "~common/base"; +@import "common/base"; diff --git a/assets/css/themes/red.scss b/assets/css/themes/red.scss index 4dcf8078..44fe0365 100644 --- a/assets/css/themes/red.scss +++ b/assets/css/themes/red.scss @@ -192,4 +192,4 @@ $dnp_warning_hover_color: lighten($vote_down_color, 10%); $poll_form_label_background: lighten($border_color, 8); $tag_dropdown_hover_background: darken($meta_color, 4%); -@import "~common/base"; +@import "common/base"; diff --git a/assets/css/views/_images.scss b/assets/css/views/_images.scss index 76e1f74c..c3a956bd 100644 --- a/assets/css/views/_images.scss +++ b/assets/css/views/_images.scss @@ -92,12 +92,6 @@ div.image-container { overflow: hidden; /* prevent .media-box__overlay from overflowing the container */ text-align: center; - a::before { - content: ""; - display: inline-block; - height: 100%; - vertical-align: middle; - } img, video { vertical-align: middle; @@ -105,12 +99,12 @@ div.image-container { max-height: 100%; } /* Make the link cover the whole container if the image is oblong */ - a { + a, picture, video { width: 100%; height: 100%; - display: inline-block; - text-align: center; - vertical-align: middle; + display: inline-flex; + align-items: center; + justify-content: center; } } diff --git a/assets/css/views/_tags.scss b/assets/css/views/_tags.scss index 85150789..6626dda4 100644 --- a/assets/css/views/_tags.scss +++ b/assets/css/views/_tags.scss @@ -70,7 +70,11 @@ .tag > span { padding: 5px; display: table-cell; - white-space: pre; +} + +.tag-list { + display: flex; + flex-wrap: wrap; } .tag a { diff --git a/assets/jest.config.js b/assets/jest.config.js deleted file mode 100644 index 192b61fd..00000000 --- a/assets/jest.config.js +++ /dev/null @@ -1,41 +0,0 @@ -export default { - collectCoverage: true, - collectCoverageFrom: [ - 'js/**/*.{js,ts}', - ], - coveragePathIgnorePatterns: [ - '/node_modules/', - '/.*\\.test\\.ts$', - '.*\\.d\\.ts$', - ], - coverageDirectory: '/coverage/', - coverageThreshold: { - global: { - statements: 0, - branches: 0, - functions: 0, - lines: 0, - }, - './js/utils/**/*.ts': { - statements: 100, - branches: 100, - functions: 100, - lines: 100, - }, - }, - preset: 'ts-jest/presets/js-with-ts-esm', - setupFilesAfterEnv: ['/test/jest-setup.ts'], - testEnvironment: 'jsdom', - testPathIgnorePatterns: ['/node_modules/', '/dist/'], - moduleNameMapper: { - './js/(.*)': '/js/$1', - }, - transform: {}, - globals: { - extensionsToTreatAsEsm: ['.ts', '.js'], - 'ts-jest': { - tsconfig: '/tsconfig.json', - useESM: true, - }, - }, -}; diff --git a/assets/js/__tests__/input-duplicator.spec.ts b/assets/js/__tests__/input-duplicator.spec.ts new file mode 100644 index 00000000..55233ee7 --- /dev/null +++ b/assets/js/__tests__/input-duplicator.spec.ts @@ -0,0 +1,92 @@ +import { inputDuplicatorCreator } from '../input-duplicator'; +import { assertNotNull } from '../utils/assert'; +import { $, $$, removeEl } from '../utils/dom'; +import { fireEvent } from '@testing-library/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); + + fireEvent.click(assertNotNull($('.js-add-input'))); + + 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(); + + fireEvent.click(assertNotNull($('.js-add-input'))); + + expect($$('input')).toHaveLength(2); + }); + + it('should not create more input elements than the limit', () => { + runCreator(); + + for (let i = 0; i < 5; i += 1) { + fireEvent.click(assertNotNull($('.js-add-input'))); + } + + expect($$('input')).toHaveLength(3); + }); + + it('should remove duplicated input elements', () => { + runCreator(); + + fireEvent.click(assertNotNull($('.js-add-input'))); + fireEvent.click(assertNotNull($('.js-remove-input'))); + + expect($$('input')).toHaveLength(1); + }); + + it('should not remove the last input element', () => { + runCreator(); + + fireEvent.click(assertNotNull($('.js-remove-input'))); + fireEvent.click(assertNotNull($('.js-remove-input'))); + for (let i = 0; i < 5; i += 1) { + fireEvent.click(assertNotNull($('.js-remove-input'))); + } + + expect($$('input')).toHaveLength(1); + }); +}); diff --git a/assets/js/__tests__/timeago.spec.ts b/assets/js/__tests__/timeago.spec.ts new file mode 100644 index 00000000..e69e2702 --- /dev/null +++ b/assets/js/__tests__/timeago.spec.ts @@ -0,0 +1,114 @@ +import { timeAgo, setupTimestamps } from '../timeago'; + +const epochRfc3339 = '1970-01-01T00:00:00.000Z'; + +describe('Timeago functionality', () => { + // TODO: is this robust? do we need e.g. timekeeper to freeze the time? + function timeAgoWithSecondOffset(offset: number) { + const utc = new Date(new Date().getTime() + offset * 1000).toISOString(); + + const timeEl = document.createElement('time'); + timeEl.setAttribute('datetime', utc); + timeEl.textContent = utc; + + timeAgo([timeEl]); + return timeEl.textContent; + } + + /* eslint-disable no-implicit-coercion */ + it('should parse a time as less than a minute', () => { + expect(timeAgoWithSecondOffset(-15)).toEqual('less than a minute ago'); + expect(timeAgoWithSecondOffset(+15)).toEqual('less than a minute from now'); + }); + + it('should parse a time as about a minute', () => { + expect(timeAgoWithSecondOffset(-75)).toEqual('about a minute ago'); + expect(timeAgoWithSecondOffset(+75)).toEqual('about a minute from now'); + }); + + it('should parse a time as 30 minutes', () => { + expect(timeAgoWithSecondOffset(-(60 * 30))).toEqual('30 minutes ago'); + expect(timeAgoWithSecondOffset(+(60 * 30))).toEqual('30 minutes from now'); + }); + + it('should parse a time as about an hour', () => { + expect(timeAgoWithSecondOffset(-(60 * 60))).toEqual('about an hour ago'); + expect(timeAgoWithSecondOffset(+(60 * 60))).toEqual('about an hour from now'); + }); + + it('should parse a time as about 6 hours', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 6))).toEqual('about 6 hours ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 6))).toEqual('about 6 hours from now'); + }); + + it('should parse a time as a day', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 36))).toEqual('a day ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 36))).toEqual('a day from now'); + }); + + it('should parse a time as 25 days', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 25))).toEqual('25 days ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 25))).toEqual('25 days from now'); + }); + + it('should parse a time as about a month', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 35))).toEqual('about a month ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 35))).toEqual('about a month from now'); + }); + + it('should parse a time as 3 months', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 30 * 3))).toEqual('3 months ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 30 * 3))).toEqual('3 months from now'); + }); + + it('should parse a time as about a year', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 30 * 13))).toEqual('about a year ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 30 * 13))).toEqual('about a year from now'); + }); + + it('should parse a time as 5 years', () => { + expect(timeAgoWithSecondOffset(-(60 * 60 * 24 * 30 * 12 * 5))).toEqual('5 years ago'); + expect(timeAgoWithSecondOffset(+(60 * 60 * 24 * 30 * 12 * 5))).toEqual('5 years from now'); + }); + /* eslint-enable no-implicit-coercion */ + + it('should ignore time elements without a datetime attribute', () => { + const timeEl = document.createElement('time'); + const value = Math.random().toString(); + + timeEl.textContent = value; + timeAgo([timeEl]); + + expect(timeEl.textContent).toEqual(value); + }); + + it('should not reset title attribute if it already exists', () => { + const timeEl = document.createElement('time'); + const value = Math.random().toString(); + + timeEl.setAttribute('datetime', epochRfc3339); + timeEl.setAttribute('title', value); + timeAgo([timeEl]); + + expect(timeEl.getAttribute('title')).toEqual(value); + expect(timeEl.textContent).not.toEqual(epochRfc3339); + }); +}); + +describe('Automatic timestamps', () => { + it('should process all timestamps in the document', () => { + for (let i = 0; i < 5; i += 1) { + const timeEl = document.createElement('time'); + timeEl.setAttribute('datetime', epochRfc3339); + timeEl.textContent = epochRfc3339; + + document.documentElement.insertAdjacentElement('beforeend', timeEl); + } + + setupTimestamps(); + + for (const timeEl of document.getElementsByTagName('time')) { + expect(timeEl.textContent).not.toEqual(epochRfc3339); + } + }); +}); diff --git a/assets/js/__tests__/ujs.spec.ts b/assets/js/__tests__/ujs.spec.ts new file mode 100644 index 00000000..b5b3d231 --- /dev/null +++ b/assets/js/__tests__/ujs.spec.ts @@ -0,0 +1,330 @@ +import { fireEvent, waitFor } from '@testing-library/dom'; +import { assertType } from '../utils/assert'; +import '../ujs'; +import { fetchMock } from '../../test/fetch-mock'; + +const mockEndpoint = 'http://localhost/endpoint'; +const mockVerb = 'POST'; + +describe('Remote utilities', () => { + beforeAll(() => { + fetchMock.enableMocks(); + }); + + afterAll(() => { + fetchMock.disableMocks(); + }); + + beforeEach(() => { + window.booru.csrfToken = Math.random().toString(); + fetchMock.resetMocks(); + }); + + function addOneShotEventListener(name: string, cb: (e: Event) => void) { + const handler = (event: Event) => { + cb(event); + document.removeEventListener(name, handler); + }; + document.addEventListener(name, handler); + } + + describe('a[data-remote]', () => { + const submitA = ({ setMethod }: { setMethod: boolean; }) => { + const a = document.createElement('a'); + a.href = mockEndpoint; + a.dataset.remote = 'remote'; + if (setMethod) { + a.dataset.method = mockVerb; + } + + document.documentElement.insertAdjacentElement('beforeend', a); + fireEvent.click(a, { button: 0 }); + + return a; + }; + + it('should call native fetch with the correct parameters (without body)', () => { + submitA({ setMethod: true }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { + method: mockVerb, + credentials: 'same-origin', + headers: { + 'x-csrf-token': window.booru.csrfToken, + 'x-requested-with': 'XMLHttpRequest' + } + }); + }); + + it('should call native fetch for a get request without explicit method', () => { + submitA({ setMethod: false }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { + method: 'GET', + credentials: 'same-origin', + headers: { + 'x-csrf-token': window.booru.csrfToken, + 'x-requested-with': 'XMLHttpRequest' + } + }); + }); + + it('should emit fetchcomplete event', () => new Promise(resolve => { + let a: HTMLAnchorElement | null = null; + + addOneShotEventListener('fetchcomplete', event => { + expect(event.target).toBe(a); + resolve(); + }); + + a = submitA({ setMethod: true }); + })); + }); + + describe('a[data-method]', () => { + const submitA = () => { + const a = document.createElement('a'); + a.href = mockEndpoint; + a.dataset.method = mockVerb; + + document.documentElement.insertAdjacentElement('beforeend', a); + fireEvent.click(a); + + return a; + }; + + it('should submit a form with the given action', () => new Promise(resolve => { + addOneShotEventListener('submit', event => { + event.preventDefault(); + + const target = assertType(event.target, HTMLFormElement); + const [ csrf, method ] = target.querySelectorAll('input'); + + expect(csrf.name).toBe('_csrf_token'); + expect(csrf.value).toBe(window.booru.csrfToken); + + expect(method.name).toBe('_method'); + expect(method.value).toBe(mockVerb); + + resolve(); + }); + + submitA(); + })); + }); + + describe('form[data-remote]', () => { + // https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/ + let oldWindowLocation: Location; + + /* eslint-disable @typescript-eslint/no-explicit-any */ + beforeAll(() => { + oldWindowLocation = window.location; + delete (window as any).location; + + (window as any).location = Object.defineProperties( + {}, + { + ...Object.getOwnPropertyDescriptors(oldWindowLocation), + reload: { + configurable: true, + value: vi.fn(), + }, + }, + ); + }); + + beforeEach(() => { + (window.location.reload as any).mockReset(); + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + afterAll(() => { + // restore window.location to the jsdom Location object + window.location = oldWindowLocation; + }); + + const configureForm = () => { + const form = document.createElement('form'); + form.action = mockEndpoint; + form.dataset.remote = 'remote'; + document.documentElement.insertAdjacentElement('beforeend', form); + return form; + }; + + const submitForm = () => { + const form = configureForm(); + form.method = mockVerb; + fireEvent.submit(form); + return form; + }; + + it('should call native fetch with the correct parameters (with body)', () => { + submitForm(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { + method: mockVerb, + credentials: 'same-origin', + headers: { + 'x-csrf-token': window.booru.csrfToken, + 'x-requested-with': 'XMLHttpRequest' + }, + body: new FormData(), + }); + }); + + it('should submit a PUT request with put data-method specified', () => { + const form = configureForm(); + form.dataset.method = 'put'; + fireEvent.submit(form); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'x-csrf-token': window.booru.csrfToken, + 'x-requested-with': 'XMLHttpRequest' + }, + body: new FormData(), + }); + }); + + it('should emit fetchcomplete event', () => new Promise(resolve => { + let form: HTMLFormElement | null = null; + + addOneShotEventListener('fetchcomplete', event => { + expect(event.target).toBe(form); + resolve(); + }); + + form = submitForm(); + })); + + it('should reload the page on 300 multiple choices response', () => { + vi.spyOn(global, 'fetch').mockResolvedValue(new Response('', { status: 300})); + + submitForm(); + return waitFor(() => expect(window.location.reload).toHaveBeenCalledTimes(1)); + }); + }); +}); + +describe('Form utilities', () => { + beforeEach(() => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => { + cb(1); + return 1; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('[data-confirm]', () => { + const createA = () => { + const a = document.createElement('a'); + a.dataset.confirm = 'confirm'; + // We cannot use mockEndpoint here since anything except a hash change will log an error in the test output + a.href = '#hash'; + document.documentElement.insertAdjacentElement('beforeend', a); + return a; + }; + + it('should cancel the event on failed confirm', () => { + const a = createA(); + const confirm = vi.spyOn(window, 'confirm').mockImplementationOnce(() => false); + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + + expect(fireEvent(a, event)).toBe(false); + expect(confirm).toHaveBeenCalledTimes(1); + }); + + it('should allow the event on confirm', () => { + const a = createA(); + const confirm = vi.spyOn(window, 'confirm').mockImplementationOnce(() => true); + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + + expect(fireEvent(a, event)).toBe(true); + expect(confirm).toHaveBeenCalledTimes(1); + }); + }); + + describe('[data-disable-with][data-enable-with]', () => { + const createFormAndButton = (innerHTML: string, disableWith: string) => { + const form = document.createElement('form'); + form.action = mockEndpoint; + + // jsdom has no implementation for HTMLFormElement.prototype.submit + // and will return an error if the event's default isn't prevented + form.addEventListener('submit', event => event.preventDefault()); + + const button = document.createElement('button'); + button.type = 'submit'; + button.innerHTML = innerHTML; + button.dataset.disableWith = disableWith; + + form.insertAdjacentElement('beforeend', button); + document.documentElement.insertAdjacentElement('beforeend', form); + + return [ form, button ]; + }; + + const submitText = 'Submit'; + const loadingText = 'Loading...'; + const submitMarkup = 'Submit'; + const loadingMarkup = 'Loading...'; + + it('should disable submit button containing a text child on click', () => { + const [ , button ] = createFormAndButton(submitText, loadingText); + fireEvent.click(button); + + expect(button.textContent).toEqual(' Loading...'); + expect(button.dataset.enableWith).toEqual(submitText); + }); + + it('should disable submit button containing element children on click', () => { + const [ , button ] = createFormAndButton(submitMarkup, loadingMarkup); + fireEvent.click(button); + + expect(button.innerHTML).toEqual(loadingMarkup); + expect(button.dataset.enableWith).toEqual(submitMarkup); + }); + + it('should not disable anything when the form is invalid', () => { + const [ form, button ] = createFormAndButton(submitText, loadingText); + form.insertAdjacentHTML('afterbegin', ''); + fireEvent.click(button); + + expect(button.textContent).toEqual(submitText); + expect(button.dataset.enableWith).not.toBeDefined(); + }); + + it('should reset submit button containing a text child on completion', () => { + const [ form, button ] = createFormAndButton(submitText, loadingText); + fireEvent.click(button); + fireEvent(form, new CustomEvent('reset', { bubbles: true })); + + expect(button.textContent?.trim()).toEqual(submitText); + expect(button.dataset.enableWith).not.toBeDefined(); + }); + + it('should reset submit button containing element children on completion', () => { + const [ form, button ] = createFormAndButton(submitMarkup, loadingMarkup); + fireEvent.click(button); + fireEvent(form, new CustomEvent('reset', { bubbles: true })); + + expect(button.innerHTML).toEqual(submitMarkup); + expect(button.dataset.enableWith).not.toBeDefined(); + }); + + it('should reset disabled form elements on pageshow', () => { + const [ , button ] = createFormAndButton(submitText, loadingText); + fireEvent.click(button); + fireEvent(window, new CustomEvent('pageshow')); + + expect(button.textContent?.trim()).toEqual(submitText); + expect(button.dataset.enableWith).not.toBeDefined(); + }); + }); +}); diff --git a/assets/js/__tests__/upload-test.png b/assets/js/__tests__/upload-test.png new file mode 100644 index 00000000..770601f7 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 00000000..12442b6a 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 00000000..4f671666 --- /dev/null +++ b/assets/js/__tests__/upload.spec.ts @@ -0,0 +1,192 @@ +import { $, $$, removeEl } from '../utils/dom'; +import { assertNotNull, assertNotUndefined } from '../utils/assert'; + +import { fetchMock } from '../../test/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; + + const assertFetchButtonIsDisabled = () => { + if (!fetchButton.hasAttribute('disabled')) throw new Error('fetchButton is not disabled'); + }; + + 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(() => { + assertFetchButtonIsDisabled(); + 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(() => { + assertFetchButtonIsDisabled(); + 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(() => { + assertFetchButtonIsDisabled(); + 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(); + }); + fireEvent.submit(form); + }); + + 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' } }); + fireEvent.click(fetchButton); + + 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' } }); + fireEvent.click(fetchButton); + + 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/app.js b/assets/js/app.js index 5b1ebab6..4f23d655 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -5,14 +5,15 @@ // the compiled file. // -// Third-party code, polyfills -import './vendor/promise.polyfill'; -import './vendor/fetch.polyfill'; -import './vendor/closest.polyfill'; -import './vendor/customevent.polyfill'; -import './vendor/es6.polyfill'; -import './vendor/values-entries.polyfill'; - // Our code import './ujs'; import './when-ready'; + +// When developing CSS, include the relevant CSS you're working on here +// in order to enable HMR (live reload) on it. +// Would typically be either the theme file, or any additional file +// you later intend to put in the tag. + +// import '../css/themes/default.scss'; +// import '../css/themes/dark.scss'; +// import '../css/themes/red.scss'; diff --git a/assets/js/autocomplete.js b/assets/js/autocomplete.js index 40d85dfb..1844dda5 100644 --- a/assets/js/autocomplete.js +++ b/assets/js/autocomplete.js @@ -2,8 +2,8 @@ * Autocomplete. */ -import { LocalAutocompleter } from 'utils/local-autocompleter'; -import { handleError } from 'utils/requests'; +import { LocalAutocompleter } from './utils/local-autocompleter'; +import { handleError } from './utils/requests'; const cache = {}; let inputField, originalTerm; @@ -134,16 +134,19 @@ function listenAutocomplete() { document.addEventListener('input', event => { removeParent(); fetchLocalAutocomplete(event); + window.clearTimeout(timeout); if (localAc !== null && 'ac' in event.target.dataset) { inputField = event.target; originalTerm = `${inputField.value}`.toLowerCase(); const suggestions = localAc.topK(originalTerm, 5).map(({ name, imageCount }) => ({ label: `${name} (${imageCount})`, value: name })); - return showAutocomplete(suggestions, originalTerm, event.target); + + if (suggestions.length) { + return showAutocomplete(suggestions, originalTerm, event.target); + } } - window.clearTimeout(timeout); // Use a timeout to delay requests until the user has stopped typing timeout = window.setTimeout(() => { inputField = event.target; @@ -158,7 +161,11 @@ function listenAutocomplete() { } else { // inputField could get overwritten while the suggestions are being fetched - use event.target - getSuggestions(fetchedTerm).then(suggestions => showAutocomplete(suggestions, fetchedTerm, event.target)); + getSuggestions(fetchedTerm).then(suggestions => { + if (fetchedTerm === event.target.value) { + showAutocomplete(suggestions, fetchedTerm, event.target); + } + }); } } }, 300); diff --git a/assets/js/cable.js b/assets/js/cable.js deleted file mode 100644 index 03ed74e2..00000000 --- a/assets/js/cable.js +++ /dev/null @@ -1,11 +0,0 @@ -// Action Cable provides the framework to deal with WebSockets in Rails. -// You can generate new channels where WebSocket features live using the rails generate channel command. -let cable; - -function setupCable() { - if (window.booru.userIsSignedIn) { - cable = ActionCable.createConsumer(); - } -} - -export { cable, setupCable }; diff --git a/assets/js/comment.js b/assets/js/comment.js index f7b3797e..de245fa8 100644 --- a/assets/js/comment.js +++ b/assets/js/comment.js @@ -6,6 +6,7 @@ import { $ } from './utils/dom'; import { showOwnedComments } from './communications/comment'; import { filterNode } from './imagesclientside'; import { fetchHtml } from './utils/requests'; +import { timeAgo } from './timeago'; function handleError(response) { @@ -91,7 +92,7 @@ function insertParentPost(data, clickedLink, fullComment) { fullComment.previousSibling.classList.add('fetched-comment'); // Execute timeago on the new comment - it was not present when first run - window.booru.timeAgo(fullComment.previousSibling.getElementsByTagName('time')); + timeAgo(fullComment.previousSibling.getElementsByTagName('time')); // Add class active_reply_link to the clicked link clickedLink.classList.add('active_reply_link'); @@ -125,7 +126,7 @@ function displayComments(container, commentsHtml) { container.innerHTML = commentsHtml; // Execute timeago on comments - window.booru.timeAgo(document.getElementsByTagName('time')); + timeAgo(document.getElementsByTagName('time')); // Filter images in the comments filterNode(container); diff --git a/assets/js/image_expansion.js b/assets/js/image_expansion.js index d7bab189..7bd6cb26 100644 --- a/assets/js/image_expansion.js +++ b/assets/js/image_expansion.js @@ -86,10 +86,13 @@ function pickAndResize(elem) { clearEl(elem); } + const muted = store.get('unmute_videos') ? '' : 'muted'; + const autoplay = elem.classList.contains('hidden') ? '' : 'autoplay'; // Fix for spoilered image pages + if (imageFormat === 'mp4') { elem.classList.add('full-height'); elem.insertAdjacentHTML('afterbegin', - `