From b693ce325b9c5f66eaa52294f84feb9dae92f83c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:29:30 +0200 Subject: [PATCH 01/15] [#75300] Add @testing-library/jest-dom Provides semantic DOM matchers (`toBeVisible`, `toHaveAttribute`, `toHaveAccessibleName`, etc.) for use with Vitest in Stimulus controller specs. Works alongside the already-installed `@testing-library/dom`. https://community.openproject.org/work_packages/75300 --- frontend/package-lock.json | 154 +++++++++++++++++++++++++++++++++++++ frontend/package.json | 1 + 2 files changed, 155 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d021ae4babd..9a1dc5f7e18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -140,6 +140,7 @@ "@html-eslint/parser": "^0.59.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", @@ -185,6 +186,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", @@ -9288,6 +9296,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", @@ -13891,6 +13926,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", @@ -16983,6 +17025,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", @@ -19730,6 +19782,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", @@ -22128,6 +22190,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", @@ -23889,6 +23965,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", @@ -26546,6 +26635,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", @@ -32120,6 +32215,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", @@ -35232,6 +35349,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", @@ -37465,6 +37588,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", @@ -39260,6 +39389,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", @@ -40901,6 +41036,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", @@ -42162,6 +42307,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 329d8cd17ed..5775e9da6d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@html-eslint/parser": "^0.59.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", From 756b76a3b74819a81c806a205a207c453f3c0175 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:29:39 +0200 Subject: [PATCH 02/15] [#75300] Wire up jest-dom and browser polyfills Adds `@testing-library/jest-dom/vitest` import to make semantic matchers available globally in all specs. Creates `test-browser-polyfills.ts` to shim `process.env` for Playwright browser runners where `picocolors` (a jest-dom transitive dependency) reads it at import time. Registers both files in `angular.json` setupFiles and `tsconfig.spec.json`. https://community.openproject.org/work_packages/75300 --- frontend/angular.json | 2 +- frontend/src/test-browser-polyfills.ts | 7 +++++++ frontend/src/test-setup.ts | 1 + frontend/tsconfig.spec.json | 4 +++- 4 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 frontend/src/test-browser-polyfills.ts diff --git a/frontend/angular.json b/frontend/angular.json index 0a5f664086c..ce0d6827442 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -151,7 +151,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/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" From dbe42bcebd2e14d5c938cff8d61319b7f5a690e5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:29:47 +0200 Subject: [PATCH 03/15] [#75300] Add shared Stimulus test helpers Introduces `setupStimulusTest()` for integration tests and `createControllerInstance()` for lightweight unit tests. `setupStimulusTest` handles Application lifecycle, controller registration, DOM fixture insertion, and exposes DOM Testing Library `screen` queries scoped to the test container. Typed `getController()` accessor included. https://community.openproject.org/work_packages/75300 --- frontend/src/stimulus/test-helpers.ts | 110 ++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 frontend/src/stimulus/test-helpers.ts 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; +} From 9c9019a0f0cb499ecaaa090e5a27cc2de952aa73 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:29:55 +0200 Subject: [PATCH 04/15] Migrate check-all spec to shared test helpers Replaces manual Application lifecycle, duplicate `nextFrame` and `appendTemplate` helpers, and raw `querySelector` calls with `setupStimulusTest` context and DOM Testing Library queries (`getByRole`, `getAllByRole`). Uses jest-dom `toHaveAttribute` matcher. --- .../controllers/check-all.controller.spec.ts | 121 ++++++++---------- 1 file changed, 52 insertions(+), 69 deletions(-) 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(); - }); }); From bc9024844d47ddff21f7a67279d7cd37a8349fdd Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:34:38 +0200 Subject: [PATCH 05/15] Migrate checkable spec to shared test helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `Object.create(CheckableController.prototype)` with `createControllerInstance(CheckableController)` from the shared test helpers module. No other changes needed — this spec already used the lightweight unit-test pattern. --- .../src/stimulus/controllers/checkable.controller.spec.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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); From 23cfa556f9447ad3dba8b8d8e0bf3b669563d4a9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:34:43 +0200 Subject: [PATCH 06/15] Migrate truncation spec to shared test helpers Replaces manual Application lifecycle and duplicate helpers with `setupStimulusTest`. Uses DTL `getByRole` with `hidden: true` for button queries inside the conditionally hidden expander container. Uses jest-dom `toHaveAttribute` and `toHaveClass` matchers. --- .../controllers/truncation.controller.spec.ts | 302 +++++++----------- 1 file changed, 110 insertions(+), 192 deletions(-) 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; - } - }); }); From a3a994959d2aae766170410f9c9d01e61b519354 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:34:47 +0200 Subject: [PATCH 07/15] Migrate drag-and-drop spec to shared test helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `Object.create(…) as GenericDragAndDropController` with `createControllerInstance(GenericDragAndDropController)`. --- .../dynamic/generic-drag-and-drop.controller.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) { From 43301ab38d0c26041f4a9560047c83a5df738fa0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:45:19 +0200 Subject: [PATCH 08/15] Add flash controller spec Tests autohide scheduling, non-autohide retention, and empty container cleanup on flash target disconnect. --- .../controllers/flash.controller.spec.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 frontend/src/stimulus/controllers/flash.controller.spec.ts 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(); + }); + }); +}); From cd2805b42c7113b144a26e6955aab190eaeadf79 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:45:21 +0200 Subject: [PATCH 09/15] Add disable-when-checked controller spec Tests basic disable-on-check, re-enable on uncheck, reversed mode, and select option reset when selected option becomes disabled. --- .../disable-when-checked.controller.spec.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 frontend/src/stimulus/controllers/disable-when-checked.controller.spec.ts 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(''); + }); + }); +}); From 5d4377f2ead51ae92b464f96d5650eeb59ba9197 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:45:23 +0200 Subject: [PATCH 10/15] Add show-when-checked controller spec Tests show-when="checked" and show-when="unchecked" toggling, reversed mode, and CSS visibility toggle via data-set-visibility attribute. --- .../show-when-checked.controller.spec.ts | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 frontend/src/stimulus/controllers/show-when-checked.controller.spec.ts 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); + }); + }); +}); From 2140e116fa50ce66292ac7957cdaf0f780c13599 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:45:25 +0200 Subject: [PATCH 11/15] Add show-when-value-selected controller spec Tests data-value matching, value swapping on selection change, and data-not-value inverse matching. --- ...how-when-value-selected.controller.spec.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 frontend/src/stimulus/controllers/show-when-value-selected.controller.spec.ts 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(); + }); + }); +}); From b586a7d183da660dbd772d541d58108de50147f0 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:45:29 +0200 Subject: [PATCH 12/15] Add disable-when-clicked controller spec Tests button disabling after click and optional text replacement via text value. --- .../disable-when-clicked.controller.spec.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 frontend/src/stimulus/controllers/disable-when-clicked.controller.spec.ts 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(); + }); +}); From 37937e042990183663c9599290ef44dc26759296 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:45:31 +0200 Subject: [PATCH 13/15] Add table-highlighting controller spec Tests hover class on colgroup col elements during header mouseenter/mouseleave, data-highlight="false" opt-out, and graceful handling of tables without colgroup. --- .../table-highlighting.controller.spec.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 frontend/src/stimulus/controllers/table-highlighting.controller.spec.ts 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(); + }); +}); From 1dc3a0ddb073db1ea071393bce7615a8d9c2c71c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:45:33 +0200 Subject: [PATCH 14/15] Add select-autosize controller spec Tests size-to-option-count on connect, configurable size limit, and default limit of 10. Uses DTL `waitFor` to handle debounced `updateSize`. --- .../select-autosize.controller.spec.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 frontend/src/stimulus/controllers/select-autosize.controller.spec.ts 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); + }); + }); +}); From 911a1f5b65e3bb6316c8637b026a0ec8f2d11004 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 23 May 2026 00:45:34 +0200 Subject: [PATCH 15/15] Add scroll-into-view controller spec Verifies `scrollIntoView({ block: 'center' })` is called on the controller element after connect. --- .../scroll-into-view.controller.spec.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 frontend/src/stimulus/controllers/scroll-into-view.controller.spec.ts 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(); + }); +});