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 = `
+
+ Check all
+ Uncheck all
+
+`;
+
+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 = `
-
- Check all
- Uncheck all
-
- `;
-
- 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(`
+
+
+
+ Toggle
+
+
+
+ `);
+ 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(`
+
+
+
+ Toggle
+
+
+
+ `);
+ 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(`
+
+
+
+ Toggle
+
+
+ -- Select --
+ Option A
+ Option B
+
+
+ `);
+ 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(`
+
+ Submit
+
+ `);
+ 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(`
+
+ Submit
+
+ `);
+ 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(`
+
+ `);
+ 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(`
+
+ `);
+ 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(`
+
+ `);
+ 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(`
+
+ `);
+ 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(`
+
+ A
+ B
+ C
+
+ `);
+ 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) => `Item ${i} `).join('');
+
+ ctx.appendHTML(`
+
+ ${options}
+
+ `);
+ 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) => `Item ${i} `).join('');
+
+ ctx.appendHTML(`
+
+ ${options}
+
+ `);
+ 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(`
+
+
+
+ Toggle
+
+
+ Conditional content
+
+
+ `);
+ 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(`
+
+
+
+ Toggle
+
+
+ Shown when unchecked
+
+
+ `);
+ 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(`
+
+
+
+ Toggle
+
+
+ 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(`
+
+
+
+ Toggle
+
+
+ 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(`
+
+
+ -- Select --
+ Type A
+ Type B
+
+
+
+
+ `);
+ 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(`
+
+
+ Simple
+ Advanced
+
+
+
+ `);
+ 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 = `
+
+
+
+
+
+
+
+
+ Name
+ Value
+ Notes
+
+
+
+
+ Row 1
+ 100
+ Note
+
+
+
+`;
+
+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(`
+
+ `);
+ 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
+
+
+
+ Toggle
+
+
+`;
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
-
-
-
- Toggle
-
-
- `;
-
- 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
-
-
-
- Toggle
-
-
- `;
+
+
+
+ Short text
+
+
+
+ Toggle
+
+
+ `;
- 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
-
-
-
- Toggle
-
-
- `;
+
+
+
+ This is a very long text that should definitely be truncated
+
+
+
+ Toggle
+
+
+ `;
- 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
-
-
-
- Toggle
-
-
- `;
-
- 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
-
-
-
- Toggle
-
-
- `;
+ 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
-
-
-
- Toggle
-
-
- `;
+ 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"