diff --git a/frontend/angular.json b/frontend/angular.json index d691aaf5b43..eaab10211db 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -142,7 +142,7 @@ "tsConfig": "tsconfig.spec.json", "buildTarget": "OpenProject:build", "providersFile": "src/test-providers.ts", - "setupFiles": ["src/test-setup.ts"], + "setupFiles": ["src/test-browser-polyfills.ts", "src/test-setup.ts"], "browsers": ["chromium", "firefox", "webkit"], "reporters": ["dot"] } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fe0b297c114..57f1b1263f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -139,6 +139,7 @@ "@html-eslint/parser": "^0.60.0", "@stylistic/eslint-plugin": "^5.7.1", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/codemirror": "5.60.5", "@types/dom-navigation": "^1.0.7", @@ -184,6 +185,13 @@ "fsevents": "*" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -9267,6 +9275,33 @@ "dequal": "^2.0.3" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", @@ -13481,6 +13516,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -16570,6 +16612,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -19317,6 +19369,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz", @@ -21693,6 +21755,20 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -23438,6 +23514,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -25967,6 +26056,12 @@ } }, "dependencies": { + "@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true + }, "@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -31542,6 +31637,28 @@ } } }, + "@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "dependencies": { + "dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + } + } + }, "@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", @@ -34437,6 +34554,12 @@ "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", "dev": true }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -36670,6 +36793,12 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -38460,6 +38589,12 @@ "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==" }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, "mini-css-extract-plugin": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz", @@ -40091,6 +40226,16 @@ "picomatch": "^2.2.1" } }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, "reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -41344,6 +41489,15 @@ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 00969e6cba6..016cb43c8fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@html-eslint/parser": "^0.60.0", "@stylistic/eslint-plugin": "^5.7.1", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/codemirror": "5.60.5", "@types/dom-navigation": "^1.0.7", diff --git a/frontend/src/stimulus/controllers/check-all.controller.spec.ts b/frontend/src/stimulus/controllers/check-all.controller.spec.ts index ada71a74f5c..90a0748df12 100644 --- a/frontend/src/stimulus/controllers/check-all.controller.spec.ts +++ b/frontend/src/stimulus/controllers/check-all.controller.spec.ts @@ -27,122 +27,105 @@ * See COPYRIGHT and LICENSE files for more details. * ++ */ -import { Application } from '@hotwired/stimulus'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import CheckAllController from './check-all.controller'; import CheckableController from './checkable.controller'; -const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); +const checkAllTemplate = ` +
+ + +
+`; + +const checkableTemplate = ` +
+ + + +
+`; describe('CheckAllController', () => { - let Stimulus:Application; - let fixturesElement:HTMLElement; - - beforeEach(() => { - fixturesElement = document.createElement('div'); - document.body.appendChild(fixturesElement); - }); + let ctx:StimulusTestContext; beforeEach(async () => { - Stimulus = Application.start(); - // Stimulus.debug = true; - Stimulus.handleError = (error, message, detail) => { - console.error(error, message, detail); - }; - Stimulus.register('checkable', CheckableController); - Stimulus.register('check-all', CheckAllController); - await nextFrame(); + ctx = await setupStimulusTest({ + controllers: { + 'check-all': CheckAllController, + checkable: CheckableController, + }, + }); }); - const checkAllTemplate = ` -
- - -
- `; - - const checkableTemplate = ` -
- - - -
- `; - - function appendTemplate(html:string) { - const template = document.createElement('template'); - template.innerHTML = html.trim(); - fixturesElement.appendChild(template.content.cloneNode(true)); - } + afterEach(() => ctx.dispose()); describe('without checkable controller', () => { beforeEach(async () => { - appendTemplate(checkAllTemplate); - await nextFrame(); + ctx.appendHTML(checkAllTemplate); + await ctx.nextFrame(); }); it('does nothing and does not error', () => { expect(() => { - document.getElementById('check-all')!.click(); - document.getElementById('uncheck-all')!.click(); + ctx.screen.getByRole('button', { name: 'Check all' }).click(); + ctx.screen.getByRole('button', { name: 'Uncheck all' }).click(); }).not.toThrow(); }); }); describe('with checkable controller', () => { beforeEach(async () => { - appendTemplate(checkableTemplate); - appendTemplate(checkAllTemplate); - await nextFrame(); + ctx.appendHTML(checkableTemplate); + ctx.appendHTML(checkAllTemplate); + await ctx.nextFrame(); }); it('toggles checkboxes', async () => { - const inputs = Array.from(document.querySelectorAll('input[type="checkbox"]')); + const inputs = ctx.screen.getAllByRole('checkbox'); expect(inputs).toHaveLength(3); - expect(inputs.every((i) => !i.checked)).toBe(true); + inputs.forEach((input) => { + expect(input).not.toBeChecked(); + }); - document.getElementById('check-all')!.click(); - await nextFrame(); + ctx.screen.getByRole('button', { name: 'Check all' }).click(); + await ctx.nextFrame(); - expect(inputs.every((i) => i.checked)).toBe(true); + inputs.forEach((input) => { + expect(input).toBeChecked(); + }); - document.getElementById('uncheck-all')!.click(); - await nextFrame(); + ctx.screen.getByRole('button', { name: 'Uncheck all' }).click(); + await ctx.nextFrame(); - expect(inputs.every((i) => !i.checked)).toBe(true); + inputs.forEach((input) => { + expect(input).not.toBeChecked(); + }); }); it('applies aria-controls for connected outlet', () => { - const checkAllEl = document.querySelector('[data-controller="check-all"]')!; + const checkAllEl = ctx.container.querySelector('[data-controller="check-all"]')!; - expect(checkAllEl).toBeDefined(); + expect(checkAllEl).toHaveAttribute('aria-controls'); - const ariaControls = checkAllEl.getAttribute('aria-controls'); + const ariaControls = checkAllEl.getAttribute('aria-controls')!; - expect(ariaControls).toBeTruthy(); - expect(ariaControls!.split(/\s+/)).toContain('checkables'); + expect(ariaControls.split(/\s+/)).toContain('checkables'); }); it('removes aria-controls entry when outlet disconnects', async () => { - const checkAllEl = document.querySelector('[data-controller="check-all"]')!; + const checkAllEl = ctx.container.querySelector('[data-controller="check-all"]')!; const ariaBefore = checkAllEl.getAttribute('aria-controls') ?? ''; - // Scenarios with connected checkable outlets + expect(ariaBefore.split(/\s+/)).toContain('checkables'); - // Remove the outlet element to trigger outlet disconnect - document.getElementById('checkables')!.remove(); - - await nextFrame(); + ctx.container.querySelector('#checkables')!.remove(); + await ctx.nextFrame(); const ariaAfter = checkAllEl.getAttribute('aria-controls') ?? ''; expect(ariaAfter.split(/\s+/)).not.toContain('checkables'); }); }); - - afterEach(() => { - fixturesElement.remove(); - - Stimulus.stop(); - }); }); diff --git a/frontend/src/stimulus/controllers/checkable.controller.spec.ts b/frontend/src/stimulus/controllers/checkable.controller.spec.ts index 3b937c1771f..fc859a995ab 100644 --- a/frontend/src/stimulus/controllers/checkable.controller.spec.ts +++ b/frontend/src/stimulus/controllers/checkable.controller.spec.ts @@ -31,14 +31,14 @@ import { ActionEvent } from '@hotwired/stimulus'; import CheckableController from './checkable.controller'; +import { createControllerInstance } from 'core-stimulus/test-helpers'; describe('CheckableController', () => { let controller:any; let inputs:HTMLInputElement[]; beforeEach(() => { - // Create a plain object that uses the controller prototype so we can call methods - controller = Object.create(CheckableController.prototype); + controller = createControllerInstance(CheckableController); inputs = [0, 1, 2].map(() => { const input = document.createElement('input'); @@ -103,7 +103,6 @@ describe('CheckableController', () => { }); describe('toggleSelection', () => { - // Helper to create an ActionEvent-like object with params function createActionEvent(params:ActionEvent['params']):ActionEvent { const event = new Event('click') as ActionEvent; event.params = params; @@ -129,7 +128,6 @@ describe('CheckableController', () => { }); it('toggles only checkboxes matching the key/value pair', () => { - // Add data attributes to checkboxes inputs[0].dataset.role = 'admin'; inputs[1].dataset.role = 'member'; inputs[2].dataset.role = 'admin'; @@ -138,7 +136,6 @@ describe('CheckableController', () => { controller.toggleSelection(event); - // Only admin checkboxes should be checked expect(inputs[0].checked).toBe(true); expect(inputs[1].checked).toBe(false); expect(inputs[2].checked).toBe(true); @@ -156,7 +153,6 @@ describe('CheckableController', () => { controller.toggleSelection(event); - // Only admin checkboxes should be unchecked expect(inputs[0].checked).toBe(false); expect(inputs[1].checked).toBe(true); // member stays checked expect(inputs[2].checked).toBe(false); diff --git a/frontend/src/stimulus/controllers/disable-when-checked.controller.spec.ts b/frontend/src/stimulus/controllers/disable-when-checked.controller.spec.ts new file mode 100644 index 00000000000..06a60c32c76 --- /dev/null +++ b/frontend/src/stimulus/controllers/disable-when-checked.controller.spec.ts @@ -0,0 +1,155 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import OpDisableWhenCheckedController from './disable-when-checked.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('OpDisableWhenCheckedController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'disable-when-checked': OpDisableWhenCheckedController }, + }); + }); + + afterEach(() => ctx.dispose()); + + describe('basic disable on check', () => { + beforeEach(async () => { + ctx.appendHTML(` +
+ + +
+ `); + await ctx.nextFrame(); + }); + + it('disables effect targets when cause is checked', async () => { + const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' }); + const textField = ctx.screen.getByRole('textbox', { name: 'Text field' }); + + expect(textField).toBeEnabled(); + + checkbox.click(); + await ctx.nextFrame(); + + expect(textField).toBeDisabled(); + }); + + it('re-enables effect targets when cause is unchecked', async () => { + const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' }); + const textField = ctx.screen.getByRole('textbox', { name: 'Text field' }); + + checkbox.click(); + await ctx.nextFrame(); + + expect(textField).toBeDisabled(); + + checkbox.click(); + await ctx.nextFrame(); + + expect(textField).toBeEnabled(); + }); + }); + + describe('reversed mode', () => { + beforeEach(async () => { + ctx.appendHTML(` +
+ + +
+ `); + await ctx.nextFrame(); + }); + + it('enables effect targets when cause is checked', async () => { + const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' }); + const textField = ctx.screen.getByRole('textbox', { name: 'Text field' }); + + checkbox.click(); + await ctx.nextFrame(); + + expect(textField).toBeEnabled(); + }); + }); + + describe('select option handling', () => { + it('resets select value when selected option becomes disabled', async () => { + ctx.appendHTML(` +
+ + +
+ `); + await ctx.nextFrame(); + + const select = ctx.container.querySelector('select[aria-label="Options"]')!; + + expect(select.value).toBe('a'); + + ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click(); + await ctx.nextFrame(); + + expect(select.value).toBe(''); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/disable-when-clicked.controller.spec.ts b/frontend/src/stimulus/controllers/disable-when-clicked.controller.spec.ts new file mode 100644 index 00000000000..5bf690290d2 --- /dev/null +++ b/frontend/src/stimulus/controllers/disable-when-clicked.controller.spec.ts @@ -0,0 +1,79 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import DisableWhenClickedController from './disable-when-clicked.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('DisableWhenClickedController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'disable-when-clicked': DisableWhenClickedController }, + }); + }); + + afterEach(() => ctx.dispose()); + + it('disables button after click', async () => { + ctx.appendHTML(` + + `); + await ctx.nextFrame(); + + const button = ctx.screen.getByRole('button', { name: 'Submit' }); + + button.click(); + // setTimeout(fn) defers by one task; nextFrame (rAF) fires after + await ctx.nextFrame(); + + expect(button).toBeDisabled(); + }); + + it('replaces button text when text value is set', async () => { + ctx.appendHTML(` + + `); + await ctx.nextFrame(); + + const button = ctx.screen.getByRole('button', { name: 'Submit' }); + + button.click(); + await ctx.nextFrame(); + + expect(button).toHaveTextContent('Processing...'); + expect(button).toBeDisabled(); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts index c3e5d3225e5..12c9d180eb7 100644 --- a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.spec.ts @@ -29,12 +29,13 @@ */ import GenericDragAndDropController from './generic-drag-and-drop.controller'; +import { createControllerInstance } from 'core-stimulus/test-helpers'; describe('GenericDragAndDropController', () => { let controller:GenericDragAndDropController; beforeEach(() => { - controller = Object.create(GenericDragAndDropController.prototype) as GenericDragAndDropController; + controller = createControllerInstance(GenericDragAndDropController); }); function setValue(name:'handleValue' | 'handleSelectorValue', value:boolean | string) { diff --git a/frontend/src/stimulus/controllers/flash.controller.spec.ts b/frontend/src/stimulus/controllers/flash.controller.spec.ts new file mode 100644 index 00000000000..43df78666bc --- /dev/null +++ b/frontend/src/stimulus/controllers/flash.controller.spec.ts @@ -0,0 +1,117 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import FlashController, { SUCCESS_AUTOHIDE_TIMEOUT } from './flash.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('FlashController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { flash: FlashController }, + }); + }); + + afterEach(() => ctx.dispose()); + + describe('without autohide', () => { + it('keeps flash items visible', async () => { + ctx.appendHTML(` +
+
+ Success message +
+
+ `); + await ctx.nextFrame(); + + expect(ctx.screen.getByRole('alert')).toBeInTheDocument(); + }); + }); + + describe('with autohide', () => { + it('schedules removal of autohide items', async () => { + const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + + ctx.appendHTML(` +
+
+ Success message +
+
+ `); + await ctx.nextFrame(); + + const autohideCall = timeoutSpy.mock.calls.find(([, delay]) => delay === SUCCESS_AUTOHIDE_TIMEOUT); + + expect(autohideCall).toBeDefined(); + + timeoutSpy.mockRestore(); + }); + + it('does not schedule removal for items without data-autohide', async () => { + const timeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + + ctx.appendHTML(` +
+
+ Error message +
+
+ `); + await ctx.nextFrame(); + + const autohideCall = timeoutSpy.mock.calls.find(([, delay]) => delay === SUCCESS_AUTOHIDE_TIMEOUT); + + expect(autohideCall).toBeUndefined(); + + timeoutSpy.mockRestore(); + }); + }); + + describe('flashTargetDisconnected', () => { + it('removes empty item containers when flash target is removed', async () => { + ctx.appendHTML(` +
+
+
Content
+
+ `); + await ctx.nextFrame(); + + ctx.screen.getByTestId('flash-content').remove(); + await ctx.nextFrame(); + + expect(ctx.screen.queryByTestId('item-container')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/scroll-into-view.controller.spec.ts b/frontend/src/stimulus/controllers/scroll-into-view.controller.spec.ts new file mode 100644 index 00000000000..6c66682d0d3 --- /dev/null +++ b/frontend/src/stimulus/controllers/scroll-into-view.controller.spec.ts @@ -0,0 +1,62 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import ScrollIntoViewController from './scroll-into-view.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('ScrollIntoViewController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'scroll-into-view': ScrollIntoViewController }, + }); + }); + + afterEach(() => ctx.dispose()); + + it('calls scrollIntoView on connect', async () => { + const scrollSpy = vi.spyOn(Element.prototype, 'scrollIntoView'); + + ctx.appendHTML(` +
+ Target element +
+ `); + // Two frames: one for Stimulus connect, one for the setTimeout(fn, 0) + await ctx.nextFrame(); + await ctx.nextFrame(); + + expect(scrollSpy).toHaveBeenCalledWith({ block: 'center' }); + + scrollSpy.mockRestore(); + }); +}); diff --git a/frontend/src/stimulus/controllers/select-autosize.controller.spec.ts b/frontend/src/stimulus/controllers/select-autosize.controller.spec.ts new file mode 100644 index 00000000000..94f56eebe8e --- /dev/null +++ b/frontend/src/stimulus/controllers/select-autosize.controller.spec.ts @@ -0,0 +1,99 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { waitFor } from '@testing-library/dom'; +import SelectAutosizeController from './select-autosize.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('SelectAutosizeController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'select-autosize': SelectAutosizeController }, + }); + }); + + afterEach(() => ctx.dispose()); + + // updateSize is debounced (100ms), so assertions use waitFor + it('sets size to option count on connect', async () => { + ctx.appendHTML(` + + `); + await ctx.nextFrame(); + + const select = ctx.container.querySelector('select')!; + + await waitFor(() => { + expect(select.size).toBe(3); + }); + }); + + it('respects size limit', async () => { + const options = Array.from({ length: 15 }, (_, i) => ``).join(''); + + ctx.appendHTML(` + + `); + await ctx.nextFrame(); + + const select = ctx.container.querySelector('select')!; + + await waitFor(() => { + expect(select.size).toBe(5); + }); + }); + + it('defaults size limit to 10', async () => { + const options = Array.from({ length: 20 }, (_, i) => ``).join(''); + + ctx.appendHTML(` + + `); + await ctx.nextFrame(); + + const select = ctx.container.querySelector('select')!; + + await waitFor(() => { + expect(select.size).toBe(10); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/show-when-checked.controller.spec.ts b/frontend/src/stimulus/controllers/show-when-checked.controller.spec.ts new file mode 100644 index 00000000000..dff7c006a6e --- /dev/null +++ b/frontend/src/stimulus/controllers/show-when-checked.controller.spec.ts @@ -0,0 +1,194 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import OpShowWhenCheckedController from './show-when-checked.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('OpShowWhenCheckedController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'show-when-checked': OpShowWhenCheckedController }, + }); + }); + + afterEach(() => ctx.dispose()); + + describe('show-when="checked"', () => { + beforeEach(async () => { + ctx.appendHTML(` +
+ + +
+ `); + await ctx.nextFrame(); + }); + + it('shows element when checkbox is checked', async () => { + const el = ctx.screen.getByTestId('conditional'); + + expect(el).not.toBeVisible(); + + ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click(); + await ctx.nextFrame(); + + expect(el).toBeVisible(); + }); + + it('hides element when checkbox is unchecked', async () => { + const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' }); + const el = ctx.screen.getByTestId('conditional'); + + checkbox.click(); + await ctx.nextFrame(); + + checkbox.click(); + await ctx.nextFrame(); + + expect(el).not.toBeVisible(); + }); + }); + + describe('show-when="unchecked"', () => { + it('hides element when checkbox is checked, shows when unchecked', async () => { + ctx.appendHTML(` +
+ + +
+ `); + await ctx.nextFrame(); + + const checkbox = ctx.screen.getByRole('checkbox', { name: 'Toggle' }); + const el = ctx.screen.getByTestId('conditional'); + + expect(el).not.toBeVisible(); + + checkbox.click(); + await ctx.nextFrame(); + + expect(el.hidden).toBe(true); + + checkbox.click(); + await ctx.nextFrame(); + + expect(el).toBeVisible(); + }); + }); + + describe('reversed mode', () => { + it('inverts the checked/unchecked logic', async () => { + ctx.appendHTML(` +
+ +
+ Content +
+
+ `); + await ctx.nextFrame(); + + const el = ctx.screen.getByTestId('conditional'); + + ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click(); + await ctx.nextFrame(); + + expect(el).not.toBeVisible(); + }); + }); + + describe('visibility toggle via data-set-visibility', () => { + it('uses CSS visibility instead of hidden attribute', async () => { + ctx.appendHTML(` +
+ +
+ Content +
+
+ `); + await ctx.nextFrame(); + + const el = ctx.screen.getByTestId('conditional'); + + expect(el).not.toBeVisible(); + expect(el.hidden).toBe(false); + + ctx.screen.getByRole('checkbox', { name: 'Toggle' }).click(); + await ctx.nextFrame(); + + expect(el).toBeVisible(); + expect(el.hidden).toBe(false); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/show-when-value-selected.controller.spec.ts b/frontend/src/stimulus/controllers/show-when-value-selected.controller.spec.ts new file mode 100644 index 00000000000..0b63407b737 --- /dev/null +++ b/frontend/src/stimulus/controllers/show-when-value-selected.controller.spec.ts @@ -0,0 +1,162 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import OpShowWhenValueSelectedController from './show-when-value-selected.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +describe('OpShowWhenValueSelectedController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'show-when-value-selected': OpShowWhenValueSelectedController }, + }); + }); + + afterEach(() => ctx.dispose()); + + describe('data-value matching', () => { + beforeEach(async () => { + ctx.appendHTML(` +
+ + + +
+ `); + await ctx.nextFrame(); + }); + + it('shows matching effect and hides non-matching', async () => { + const select = ctx.container.querySelector('select[aria-label="Type"]')!; + const effectA = ctx.container.querySelector('input[aria-label="Effect A"]')!; + const effectB = ctx.container.querySelector('input[aria-label="Effect B"]')!; + + expect(effectA.hidden).toBe(true); + expect(effectA).toBeDisabled(); + expect(effectB.hidden).toBe(true); + expect(effectB).toBeDisabled(); + + select.value = 'a'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + expect(effectA.hidden).toBe(false); + expect(effectA).toBeEnabled(); + expect(effectB.hidden).toBe(true); + expect(effectB).toBeDisabled(); + }); + + it('swaps visibility when selection changes', async () => { + const select = ctx.container.querySelector('select[aria-label="Type"]')!; + const effectA = ctx.container.querySelector('input[aria-label="Effect A"]')!; + const effectB = ctx.container.querySelector('input[aria-label="Effect B"]')!; + + expect(effectA.hidden).toBe(true); + expect(effectA).toBeDisabled(); + expect(effectB.hidden).toBe(true); + expect(effectB).toBeDisabled(); + + select.value = 'a'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + select.value = 'b'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + expect(effectA.hidden).toBe(true); + expect(effectA).toBeDisabled(); + expect(effectB.hidden).toBe(false); + expect(effectB).toBeEnabled(); + }); + }); + + describe('data-not-value matching', () => { + it('hides effect when select matches not-value', async () => { + ctx.appendHTML(` +
+ + +
+ `); + await ctx.nextFrame(); + + const select = ctx.container.querySelector('select[aria-label="Mode"]')!; + const effect = ctx.container.querySelector('input[aria-label="Advanced options"]')!; + + expect(effect.hidden).toBe(true); + expect(effect).toBeDisabled(); + + select.value = 'simple'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + expect(effect.hidden).toBe(true); + expect(effect).toBeDisabled(); + + select.value = 'advanced'; + select.dispatchEvent(new Event('change', { bubbles: true })); + await ctx.nextFrame(); + + expect(effect.hidden).toBe(false); + expect(effect).toBeEnabled(); + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/table-highlighting.controller.spec.ts b/frontend/src/stimulus/controllers/table-highlighting.controller.spec.ts new file mode 100644 index 00000000000..2cc928e3d54 --- /dev/null +++ b/frontend/src/stimulus/controllers/table-highlighting.controller.spec.ts @@ -0,0 +1,117 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import TableHighlightingController from './table-highlighting.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; + +const tableTemplate = ` + + + + + + + + + + + + + + + + + + + + +
NameValueNotes
Row 1100Note
+`; + +describe('TableHighlightingController', () => { + let ctx:StimulusTestContext; + + beforeEach(async () => { + ctx = await setupStimulusTest({ + controllers: { 'table-highlighting': TableHighlightingController }, + }); + + ctx.appendHTML(tableTemplate); + await ctx.nextFrame(); + }); + + afterEach(() => ctx.dispose()); + + it('adds hover class to col on header mouseenter', () => { + const th = ctx.screen.getByRole('columnheader', { name: 'Name' }); + const col = ctx.container.querySelector('colgroup col:first-child')!; + + th.dispatchEvent(new MouseEvent('mouseenter')); + + expect(col).toHaveClass('hover'); + }); + + it('removes hover class on header mouseleave', () => { + const th = ctx.screen.getByRole('columnheader', { name: 'Name' }); + const col = ctx.container.querySelector('colgroup col:first-child')!; + + th.dispatchEvent(new MouseEvent('mouseenter')); + + expect(col).toHaveClass('hover'); + + th.dispatchEvent(new MouseEvent('mouseleave')); + + expect(col).not.toHaveClass('hover'); + }); + + it('skips columns with data-highlight="false"', () => { + const th = ctx.screen.getByRole('columnheader', { name: 'Notes' }); + const col = ctx.container.querySelector('colgroup col:nth-child(3)')!; + + th.dispatchEvent(new MouseEvent('mouseenter')); + + expect(col).not.toHaveClass('hover'); + }); + + it('does not error on tables without colgroup', async () => { + ctx.appendHTML(` + + + +
Col
Val
+ `); + await ctx.nextFrame(); + + expect(() => { + ctx.screen.getAllByRole('columnheader').at(-1)! + .dispatchEvent(new MouseEvent('mouseenter')); + }).not.toThrow(); + }); +}); diff --git a/frontend/src/stimulus/controllers/truncation.controller.spec.ts b/frontend/src/stimulus/controllers/truncation.controller.spec.ts index b8cfeccc684..3f994d53c67 100644 --- a/frontend/src/stimulus/controllers/truncation.controller.spec.ts +++ b/frontend/src/stimulus/controllers/truncation.controller.spec.ts @@ -27,24 +27,29 @@ * See COPYRIGHT and LICENSE files for more details. * ++ */ -/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ -import { Application } from '@hotwired/stimulus'; import TruncationController from './truncation.controller'; +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; -const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); +const truncationTemplate = ` +
+
+ + This is a very long text that should be truncated when it exceeds the container width + +
+
+ +
+
+`; describe('TruncationController', () => { - let Stimulus:Application; - let fixturesElement:HTMLElement; + let ctx:StimulusTestContext; let originalI18n:any; beforeEach(() => { - fixturesElement = document.createElement('div'); - document.body.appendChild(fixturesElement); - - // Save original I18n and configure translations - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment originalI18n = (window as any).I18n; if (originalI18n && typeof originalI18n.store === 'function') { originalI18n.store({ @@ -59,278 +64,217 @@ describe('TruncationController', () => { }); beforeEach(async () => { - Stimulus = Application.start(); - Stimulus.handleError = (error, message, detail) => { - console.error(error, message, detail); - }; - Stimulus.register('truncation', TruncationController); - await nextFrame(); + ctx = await setupStimulusTest({ + controllers: { truncation: TruncationController }, + }); }); - const truncationTemplate = ` -
-
- - This is a very long text that should be truncated when it exceeds the container width - -
-
- -
-
- `; - - function appendTemplate(html:string) { - const template = document.createElement('template'); - template.innerHTML = html.trim(); - fixturesElement.appendChild(template.content.cloneNode(true)); - } + afterEach(() => { + try { + ctx.dispose(); + } finally { + if (originalI18n) { + (window as any).I18n = originalI18n; + } + } + }); describe('initialization', () => { beforeEach(async () => { - appendTemplate(truncationTemplate); - await nextFrame(); + ctx.appendHTML(truncationTemplate); + await ctx.nextFrame(); }); it('connects successfully', () => { - const controller = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); + const controller = ctx.getController('truncation'); expect(controller).toBeDefined(); }); it('sets initial aria attributes on expander button', () => { - const button = document.querySelector('[data-truncation-target="expander"] button')!; + const button = ctx.screen.getByRole('button', { name: 'Expand text', hidden: true }); - expect(button.getAttribute('aria-label')).toBe('Expand text'); - expect(button.getAttribute('aria-expanded')).toBe('false'); + expect(button).toHaveAttribute('aria-expanded', 'false'); }); it('adds Truncate--expanded class when expanded value is true', async () => { - const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; + const truncateEl = ctx.container.querySelector('[data-truncation-target="truncate"]')!; - expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); + expect(truncateEl).not.toHaveClass('Truncate--expanded'); - const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); + const controller = ctx.getController('truncation'); controller.expandedValue = true; - await nextFrame(); + await ctx.nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true); + expect(truncateEl).toHaveClass('Truncate--expanded'); }); }); describe('expander button click', () => { beforeEach(async () => { - appendTemplate(truncationTemplate); - await nextFrame(); + ctx.appendHTML(truncationTemplate); + await ctx.nextFrame(); }); it('toggles expanded state', async () => { - const button = document.querySelector('[data-truncation-target="expander"] button')!; - const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; + const button = ctx.screen.getByRole('button', { name: 'Expand text', hidden: true }); + const truncateEl = ctx.container.querySelector('[data-truncation-target="truncate"]')!; - expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); - expect(button.getAttribute('aria-expanded')).toBe('false'); + expect(truncateEl).not.toHaveClass('Truncate--expanded'); + expect(button).toHaveAttribute('aria-expanded', 'false'); button.click(); - await nextFrame(); + await ctx.nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true); - expect(button.getAttribute('aria-expanded')).toBe('true'); - expect(button.getAttribute('aria-label')).toBe('Collapse text'); + expect(truncateEl).toHaveClass('Truncate--expanded'); + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(button).toHaveAttribute('aria-label', 'Collapse text'); button.click(); - await nextFrame(); + await ctx.nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); - expect(button.getAttribute('aria-expanded')).toBe('false'); - expect(button.getAttribute('aria-label')).toBe('Expand text'); + expect(truncateEl).not.toHaveClass('Truncate--expanded'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(button).toHaveAttribute('aria-label', 'Expand text'); }); }); describe('expandedValue changes', () => { beforeEach(async () => { - appendTemplate(truncationTemplate); - await nextFrame(); + ctx.appendHTML(truncationTemplate); + await ctx.nextFrame(); }); it('updates aria-label when expanded', async () => { - const button = document.querySelector('[data-truncation-target="expander"] button')!; - const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); + const button = ctx.screen.getByRole('button', { name: 'Expand text', hidden: true }); + const controller = ctx.getController('truncation'); - expect(button.getAttribute('aria-label')).toBe('Expand text'); + expect(button).toHaveAttribute('aria-label', 'Expand text'); controller.expandedValue = true; - await nextFrame(); + await ctx.nextFrame(); - expect(button.getAttribute('aria-label')).toBe('Collapse text'); + expect(button).toHaveAttribute('aria-label', 'Collapse text'); }); it('updates aria-expanded attribute', async () => { - const button = document.querySelector('[data-truncation-target="expander"] button')!; - const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); + const button = ctx.screen.getByRole('button', { name: 'Expand text', hidden: true }); + const controller = ctx.getController('truncation'); - expect(button.getAttribute('aria-expanded')).toBe('false'); + expect(button).toHaveAttribute('aria-expanded', 'false'); controller.expandedValue = true; - await nextFrame(); + await ctx.nextFrame(); - expect(button.getAttribute('aria-expanded')).toBe('true'); + expect(button).toHaveAttribute('aria-expanded', 'true'); }); it('toggles Truncate--expanded class', async () => { - const truncateEl = document.querySelector('[data-truncation-target="truncate"]')!; - const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); + const truncateEl = ctx.container.querySelector('[data-truncation-target="truncate"]')!; + const controller = ctx.getController('truncation'); - expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); + expect(truncateEl).not.toHaveClass('Truncate--expanded'); controller.expandedValue = true; - await nextFrame(); + await ctx.nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBe(true); + expect(truncateEl).toHaveClass('Truncate--expanded'); controller.expandedValue = false; - await nextFrame(); + await ctx.nextFrame(); - expect(truncateEl.classList.contains('Truncate--expanded')).toBe(false); + expect(truncateEl).not.toHaveClass('Truncate--expanded'); }); }); describe('expander visibility', () => { - // Helper to wait for ResizeObserver to process updates + // Wait multiple frames to ensure ResizeObserver has fired const waitForResize = async () => { - // Wait multiple frames to ensure ResizeObserver has fired - await nextFrame(); - await nextFrame(); + await ctx.nextFrame(); + await ctx.nextFrame(); }; it('hides expander when content is not truncated', async () => { const shortTextTemplate = ` -
-
- - Short text - -
-
- -
-
- `; +
+
+ + Short text + +
+
+ +
+
+ `; - appendTemplate(shortTextTemplate); + ctx.appendHTML(shortTextTemplate); await waitForResize(); - const expander = document.querySelector('[data-truncation-target="expander"]')!; + const expander = ctx.container.querySelector('[data-truncation-target="expander"]')!; - // When content is not truncated, expander should be hidden expect(expander.hidden).toBe(true); }); it('shows expander when content is truncated', async () => { const longTextTemplate = ` -
-
- - This is a very long text that should definitely be truncated - -
-
- -
-
- `; +
+
+ + This is a very long text that should definitely be truncated + +
+
+ +
+
+ `; - appendTemplate(longTextTemplate); + ctx.appendHTML(longTextTemplate); - const truncateText = document.querySelector('.Truncate-text')!; + const truncateText = ctx.container.querySelector('.Truncate-text')!; Object.defineProperty(truncateText, 'scrollWidth', { value: 300, configurable: true }); Object.defineProperty(truncateText, 'clientWidth', { value: 50, configurable: true }); await waitForResize(); - const expander = document.querySelector('[data-truncation-target="expander"]')!; + const expander = ctx.container.querySelector('[data-truncation-target="expander"]')!; - // When content is truncated, expander should be visible expect(expander.hidden).toBe(false); }); }); describe('resize() method', () => { - it('calls update() when resize is triggered', async () => { - const template = ` -
-
- - Test text - -
-
- -
-
- `; - - appendTemplate(template); - await nextFrame(); - - const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); - - // Spy on the private update method to verify resize() calls it - const updateSpy = vi.spyOn(controller, 'update'); - - controller.resize(); - - expect(updateSpy).toHaveBeenCalledWith(); - }); - it('updates expander visibility when content dimensions change', async () => { - const template = ` -
-
- - Test - -
-
- -
-
- `; + ctx.appendHTML(truncationTemplate); + await ctx.nextFrame(); - appendTemplate(template); - await nextFrame(); + const controller = ctx.getController('truncation'); + const expander = ctx.container.querySelector('[data-truncation-target="expander"]')!; + const truncateText = ctx.container.querySelector('.Truncate-text')!; - const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); - const expander = document.querySelector('[data-truncation-target="expander"]')!; - const truncateText = document.querySelector('.Truncate-text')!; - - // Mock scrollWidth and clientWidth to simulate truncation state const originalScrollWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollWidth'); const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); - // Simulate not truncated: scrollWidth === clientWidth Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 100 }); Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 }); controller.resize(); expect(expander.hidden).toBe(true); - // Simulate truncated: scrollWidth > clientWidth Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 200 }); Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 100 }); controller.resize(); expect(expander.hidden).toBe(false); - // Simulate not truncated again Object.defineProperty(truncateText, 'scrollWidth', { configurable: true, value: 50 }); Object.defineProperty(truncateText, 'clientWidth', { configurable: true, value: 50 }); controller.resize(); expect(expander.hidden).toBe(true); - // Restore original descriptors if (originalScrollWidth) { Object.defineProperty(HTMLElement.prototype, 'scrollWidth', originalScrollWidth); } @@ -340,46 +284,20 @@ describe('TruncationController', () => { }); it('keeps expander visible when expanded even if not truncated', async () => { - const template = ` -
-
- - Short - -
-
- -
-
- `; + ctx.appendHTML(truncationTemplate); + await ctx.nextFrame(); - appendTemplate(template); - await nextFrame(); + const controller = ctx.getController('truncation'); + const expander = ctx.container.querySelector('[data-truncation-target="expander"]')!; - const controller:any = Stimulus.getControllerForElementAndIdentifier(document.querySelector('[data-controller="truncation"]')!, 'truncation'); - const expander = document.querySelector('[data-truncation-target="expander"]')!; - - // Initially short text, expander should be hidden controller.resize(); expect(expander.hidden).toBe(true); - // Expand the text controller.expandedValue = true; - await nextFrame(); + await ctx.nextFrame(); - // When expanded, expander should remain visible even if not truncated expect(expander.hidden).toBe(false); }); }); - - afterEach(() => { - fixturesElement.remove(); - Stimulus.stop(); - // Restore original I18n - if (originalI18n) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - (window as any).I18n = originalI18n; - } - }); }); diff --git a/frontend/src/stimulus/test-helpers.ts b/frontend/src/stimulus/test-helpers.ts new file mode 100644 index 00000000000..8f99f7115b2 --- /dev/null +++ b/frontend/src/stimulus/test-helpers.ts @@ -0,0 +1,110 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Application, Controller } from '@hotwired/stimulus'; +import { type ControllerConstructor } from '@hotwired/stimulus/dist/types/core/controller'; +import { getQueriesForElement, queries, type BoundFunctions } from '@testing-library/dom'; + +export interface StimulusTestContext { + application:Application; + container:HTMLElement; + screen:BoundFunctions; + appendHTML(html:string):void; + getController(identifier:string, element?:Element):T; + nextFrame():Promise; + dispose():void; +} + +export interface SetupOptions { + controllers:Record; +} + +export async function setupStimulusTest(options:SetupOptions):Promise { + const container = document.createElement('div'); + document.body.appendChild(container); + + const application = Application.start(container); + const stimulusErrors:Error[] = []; + application.handleError = (error:Error, message:string, detail:Record) => { + console.error(error, message, detail); + stimulusErrors.push(error); + }; + + for (const [identifier, ctor] of Object.entries(options.controllers)) { + application.register(identifier, ctor); + } + + const screen = getQueriesForElement(container); + + const ctx:StimulusTestContext = { + application, + container, + screen, + + appendHTML(html:string) { + const template = document.createElement('template'); + template.innerHTML = html.trim(); + container.appendChild(template.content.cloneNode(true)); + }, + + getController(identifier:string, element?:Element):T { + const el = element ?? container.querySelector(`[data-controller~="${identifier}"]`); + if (!el) { + throw new Error(`No element found matching [data-controller~="${identifier}"]`); + } + const controller = application.getControllerForElementAndIdentifier(el, identifier); + if (!controller) { + throw new Error(`Controller "${identifier}" not connected on element`); + } + return controller as T; + }, + + nextFrame() { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); + }, + + dispose() { + application.stop(); + container.remove(); + if (stimulusErrors.length > 0) { + throw stimulusErrors[0]; + } + }, + }; + + await ctx.nextFrame(); + return ctx; +} + +export function createControllerInstance( + ControllerClass:{ new (...args:unknown[]):T; prototype:T }, +):T { + return Object.create(ControllerClass.prototype) as T; +} diff --git a/frontend/src/test-browser-polyfills.ts b/frontend/src/test-browser-polyfills.ts new file mode 100644 index 00000000000..46db733ee69 --- /dev/null +++ b/frontend/src/test-browser-polyfills.ts @@ -0,0 +1,7 @@ +// Browser test runners (Playwright) lack Node.js globals that some +// libraries read at import time (e.g. picocolors reads process.env). +const globalWithProcess = globalThis as unknown as { process?:{ env:Record } }; + +if (typeof globalWithProcess.process === 'undefined') { + globalWithProcess.process = { env: {} }; +} diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index 665287ef166..52aeab1f9f2 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1,5 +1,6 @@ import { I18n } from 'i18n-js'; import lodash from 'lodash'; +import '@testing-library/jest-dom/vitest'; import { registerDialogStreamAction } from 'core-turbo/dialog-stream-action'; registerDialogStreamAction(); diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json index 48d3e2416b9..98e3d45b06b 100644 --- a/frontend/tsconfig.spec.json +++ b/frontend/tsconfig.spec.json @@ -3,10 +3,12 @@ "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ - "vitest/globals" + "vitest/globals", + "@testing-library/jest-dom/vitest" ] }, "files": [ + "src/test-browser-polyfills.ts", "src/test-setup.ts", "src/test-providers.ts", "src/polyfills.ts"