diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index e5bef5273..bf68d4e97 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -397,3 +397,14 @@ jobs:
# construction — <= v1.4.0 passed over it (permanently green) and v1.5.0
# refused with exit 99 (permanently red). The scope is now
# `github.event.before...HEAD`, what the push actually changed.
+ # ── Cost controls (see ConductionNL/.github#596, #599) ───────────────
+ # Run PHPUnit and Playwright only when the diff could change their
+ # verdict. Fails safe TOWARDS running: an unreadable diff, a force-push,
+ # a branch creation or a dispatch all run everything, and a filtered
+ # skip is a declared state in the Quality Report, not a missing one.
+ enable-path-filtering: true
+ # PR-time PHPUnit runs the primary PHP against the newest declared
+ # Nextcloud; the full matrix still runs on every push to a default
+ # branch and on the release PR into beta. Breadth moves from
+ # per-commit to per-merge, it is not dropped.
+ reduce-pr-matrix: true
diff --git a/appinfo/info.xml b/appinfo/info.xml
index d4c70fe50..b5a4b06f7 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -64,7 +64,7 @@ Gratis en open source onder de EUPL-1.2 licentie.
which quoted the grep command in order to WARN about the problem —
made it read ")[^". The warning reproduced the bug it described.
-->
- 1.2.0
+ 1.2.2-unstable.20260910104940
EUPL-1.2
Conduction
Thematiq
diff --git a/composer.json b/composer.json
index aa2a335a4..4bfa7f6cc 100644
--- a/composer.json
+++ b/composer.json
@@ -40,10 +40,10 @@
"phpcs": "./vendor/bin/phpcs --standard=phpcs.xml",
"phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml",
"phpcs:output": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ 2>/dev/null | tail -1 > phpcs-output.json",
- "phpmd": "E=0; ./vendor/bin/phpmd lib text phpmd.xml || E=$?; ./vendor/bin/phpmd lib text vendor/conduction/hydra-gates/quality-config/phpmd-unusedparams.xml || E=$?; exit $E",
+ "phpmd": "E=0; php -d memory_limit=2G ./vendor/bin/phpmd lib text phpmd.xml || E=$?; php -d memory_limit=2G ./vendor/bin/phpmd lib text vendor/conduction/hydra-gates/quality-config/phpmd-unusedparams.xml || E=$?; exit $E",
"phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/",
"phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/",
- "psalm": "./vendor/bin/psalm --threads=1 --no-cache",
+ "psalm": "./vendor/bin/psalm --threads=1 --no-cache --memory-limit=2G",
"phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G",
"test:unit": "if [ ! -f vendor/bin/phpunit ]; then echo 'SKIPPED: phpunit not installed - run composer install'; elif [ ! -f ../../lib/base.php ]; then echo 'SKIPPED: the unit suite DID NOT RUN - it needs a Nextcloud server tree (../../lib/base.php not found).'; echo ' Verbatim failure in a bare checkout (measured 2026-08-04, PHP 8.4.22):'; echo ' Interface \"OCP\\Files\\AppData\\IAppDataFactory\" not found'; echo ' at tests/Unit/Controller/SettingsControllerAuditTest.php:40 -- phpunit exits 255'; echo ' A 255 is an ABORT, not a test verdict: the suite type-hints OCP interfaces that'; echo ' only a Nextcloud server checkout provides. Do NOT \"fix\" this by deleting the'; echo ' guard - that trades a documented skip for a crash that still runs zero tests.'; echo ' Measured with a server tree present: a failing suite exits 1 and a passing one'; echo ' exits 0, so this guard does not weaken a real run. Run from inside a Nextcloud'; echo ' checkout, or rely on the CI PHPUnit job, for a real test verdict.'; else ./vendor/bin/phpunit --colors=always --no-coverage; fi",
"test:all": "if [ ! -f vendor/bin/phpunit ]; then echo 'SKIPPED: phpunit not installed - run composer install'; elif [ ! -f ../../lib/base.php ]; then echo 'SKIPPED: the unit suite DID NOT RUN - it needs a Nextcloud server tree (../../lib/base.php not found).'; echo ' Verbatim failure in a bare checkout (measured 2026-08-04, PHP 8.4.22):'; echo ' Interface \"OCP\\Files\\AppData\\IAppDataFactory\" not found'; echo ' at tests/Unit/Controller/SettingsControllerAuditTest.php:40 -- phpunit exits 255'; echo ' A 255 is an ABORT, not a test verdict: the suite type-hints OCP interfaces that'; echo ' only a Nextcloud server checkout provides. Do NOT \"fix\" this by deleting the'; echo ' guard - that trades a documented skip for a crash that still runs zero tests.'; echo ' Measured with a server tree present: a failing suite exits 1 and a passing one'; echo ' exits 0, so this guard does not weaken a real run. Run from inside a Nextcloud'; echo ' checkout, or rely on the CI PHPUnit job, for a real test verdict.'; else ./vendor/bin/phpunit --colors=always --no-coverage; fi",
diff --git a/composer.lock b/composer.lock
index 1125f49e0..ce815566d 100644
--- a/composer.lock
+++ b/composer.lock
@@ -516,16 +516,16 @@
},
{
"name": "conduction/hydra-gates",
- "version": "v1.10.0",
+ "version": "v1.16.1",
"source": {
"type": "git",
"url": "https://github.com/ConductionNL/.github.git",
- "reference": "d143bc27aecbb44a66c843dba89d374628bf9eef"
+ "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ConductionNL/.github/zipball/d143bc27aecbb44a66c843dba89d374628bf9eef",
- "reference": "d143bc27aecbb44a66c843dba89d374628bf9eef",
+ "url": "https://api.github.com/repos/ConductionNL/.github/zipball/bfb34cc6caa9762f6aa3da66f9a2b573f8442358",
+ "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358",
"shasum": ""
},
"require": {
@@ -564,9 +564,9 @@
"support": {
"docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md",
"issues": "https://github.com/ConductionNL/.github/issues",
- "source": "https://github.com/ConductionNL/.github/tree/v1.10.0"
+ "source": "https://github.com/ConductionNL/.github/tree/v1.16.1"
},
- "time": "2026-08-27T16:18:04+00:00"
+ "time": "2026-09-07T08:01:54+00:00"
},
{
"name": "consolidation/annotated-command",
@@ -3224,11 +3224,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.2.10",
+ "version": "2.2.13",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/36d1509c998b0602811824143526b4a9ee2774e7",
- "reference": "36d1509c998b0602811824143526b4a9ee2774e7",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260",
+ "reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260",
"shasum": ""
},
"require": {
@@ -3284,7 +3284,7 @@
"type": "github"
}
],
- "time": "2026-08-30T12:46:16+00:00"
+ "time": "2026-09-03T20:38:19+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -8014,7 +8014,8 @@
},
"platform-dev": {},
"platform-overrides": {
- "php": "8.3"
+ "php": "8.3",
+ "ext-xsl": "1"
},
"plugin-api-version": "2.9.0"
}
diff --git a/lib/Mail/NLDesignEMailTemplate.php b/lib/Mail/NLDesignEMailTemplate.php
index 9e8acfde1..c8a2e0869 100644
--- a/lib/Mail/NLDesignEMailTemplate.php
+++ b/lib/Mail/NLDesignEMailTemplate.php
@@ -69,6 +69,7 @@ class NLDesignEMailTemplate extends EMailTemplate {
*/
protected function getEmailThemingService(): ?EmailThemingService {
try {
+ // phpcs:ignore CustomSniffs.Nextcloud.NoServiceLocator.GlobalContainerLookup -- Not a DI-built class: the server's Mailer::makeTemplate() constructs this template with a fixed argument list, so there is no constructor to inject a container into.
$service = \OCP\Server::get(EmailThemingService::class);
if ($service instanceof EmailThemingService === false) {
return null;
diff --git a/package-lock.json b/package-lock.json
index 76d00882a..d6888ee5b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,16 +16,17 @@
"@cyclonedx/cyclonedx-npm": "^6.0.1",
"@nextcloud/prettier-config": "^1.2.0",
"@openfun/cunningham-tokens": "^3.0.0",
- "@playwright/test": "^1.60.0",
- "@vitest/coverage-v8": "^4.1.11",
- "@vitest/ui": "^4.1.11",
+ "@playwright/test": "^1.63.0",
+ "@vitest/coverage-v8": "^5.0.0",
+ "@vitest/ui": "^5.0.0",
"jsdom": "^30.0.1",
"prettier": "^3.9.6",
- "stylelint": "^17.4.0",
+ "stylelint": "^17.15.0",
"stylelint-config-standard": "^40.0.0",
"ts-node": "^10.9.2",
"typescript": "^7.0.2",
- "vitest": "^4.1.11"
+ "vite": "^8.2.2",
+ "vitest": "^5.0.0"
},
"engines": {
"node": "^22.14 || ^24 || >=26",
@@ -387,9 +388,9 @@
}
},
"node_modules/@conduction/nextcloud-vue": {
- "version": "2.27.2",
- "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.27.2.tgz",
- "integrity": "sha512-FhDF3FvM+ee0Jx7z70Lki7o7Mm4DeX/GsOqHo33hOcdylzXR63UXLRbxKMedodEiIqgOFUAI6GTox735iyL8Dw==",
+ "version": "2.39.0",
+ "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.39.0.tgz",
+ "integrity": "sha512-LmiQwc2VizxNfdzrVXF/A2NwItjIBg5DGi2EbvkMZwA8wXAgSaDWO2uant5TU+AHXotK5Csfqe0OXSe/b5qJqA==",
"dev": true,
"license": "EUPL-1.2",
"dependencies": {
@@ -1726,13 +1727,13 @@
}
},
"node_modules/@playwright/test": {
- "version": "1.62.1",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
- "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright": "1.62.1"
+ "playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
@@ -2023,13 +2024,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@standard-schema/spec": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
- "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@svgdotjs/svg.draggable.js": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.6.tgz",
@@ -2674,29 +2668,27 @@
"license": "ISC"
},
"node_modules/@vitest/coverage-v8": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
- "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz",
+ "integrity": "sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.11",
- "ast-v8-to-istanbul": "^1.0.0",
- "istanbul-lib-coverage": "^3.2.2",
- "istanbul-lib-report": "^3.0.1",
- "istanbul-reports": "^3.2.0",
- "magicast": "^0.5.2",
- "obug": "^2.1.1",
- "std-env": "^4.0.0-rc.1",
- "tinyrainbow": "^3.1.0"
+ "@vitest/istanbul-lib-coverage": "^1.0.0",
+ "@vitest/istanbul-lib-report": "^1.0.0",
+ "ast-v8-to-istanbul": "^1.0.5",
+ "magicast": "^0.5.4",
+ "obug": "^2.1.4",
+ "std-env": "^4.2.0",
+ "tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "4.1.11",
- "vitest": "4.1.11"
+ "@vitest/browser": "5.0.0",
+ "vitest": "5.0.0"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2704,34 +2696,40 @@
}
}
},
- "node_modules/@vitest/expect": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
- "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "node_modules/@vitest/istanbul-lib-coverage": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz",
+ "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@vitest/istanbul-lib-report": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz",
+ "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@standard-schema/spec": "^1.1.0",
- "@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.11",
- "@vitest/utils": "4.1.11",
- "chai": "^6.2.2",
- "tinyrainbow": "^3.1.0"
+ "@vitest/istanbul-lib-coverage": "1.0.1"
},
- "funding": {
- "url": "https://opencollective.com/vitest"
+ "engines": {
+ "node": ">=22"
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
- "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
+ "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.11",
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
+ "magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -2749,53 +2747,33 @@
}
}
},
- "node_modules/@vitest/pretty-format": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
- "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^3.1.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
- "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
+ "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.11",
- "pathe": "^2.0.3"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/@vitest/snapshot": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
- "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "node_modules/@vitest/pretty-format": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-5.0.0.tgz",
+ "integrity": "sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.11",
- "@vitest/utils": "4.1.11",
- "magic-string": "^0.30.21",
- "pathe": "^2.0.3"
+ "tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
- "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
+ "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2803,37 +2781,36 @@
}
},
"node_modules/@vitest/ui": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.11.tgz",
- "integrity": "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-5.0.0.tgz",
+ "integrity": "sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.11",
- "fflate": "^0.8.2",
- "flatted": "^3.4.2",
+ "@vitest/utils": "5.0.0",
+ "fflate": "^0.8.3",
+ "flatted": "^3.4.4",
"pathe": "^2.0.3",
"sirv": "^3.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.1.0"
+ "tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vitest": "4.1.11"
+ "vitest": "5.0.0"
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
- "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-5.0.0.tgz",
+ "integrity": "sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.11",
+ "@vitest/pretty-format": "5.0.0",
"convert-source-map": "^2.0.0",
- "tinyrainbow": "^3.1.0"
+ "tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -5043,21 +5020,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
- "node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -5467,13 +5429,6 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
- "node_modules/html-escaper": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
- "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/html-tags": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz",
@@ -5844,45 +5799,6 @@
"node": ">=18"
}
},
- "node_modules/istanbul-lib-coverage": {
- "version": "3.2.2",
- "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
- "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/istanbul-lib-report": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
- "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "istanbul-lib-coverage": "^3.0.0",
- "make-dir": "^4.0.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/istanbul-reports": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
- "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "html-escaper": "^2.0.0",
- "istanbul-lib-report": "^3.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -6429,22 +6345,6 @@
"source-map-js": "^1.2.1"
}
},
- "node_modules/make-dir": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
- "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "semver": "^7.5.3"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -8037,28 +7937,25 @@
}
},
"node_modules/playwright": {
- "version": "1.62.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
- "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.62.1"
+ "playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
- },
- "optionalDependencies": {
- "fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
- "version": "1.62.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
- "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -9398,9 +9295,9 @@
}
},
"node_modules/stylelint": {
- "version": "17.14.1",
- "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz",
- "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==",
+ "version": "17.15.0",
+ "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.15.0.tgz",
+ "integrity": "sha512-mWIkesYQvQjf4Kvdeu9ns0IL8/K/wtjaGxPqsPd6DlLZvaQxGysJsocxUDrBcyHQ5VleAk4uSMat4yMrVED7PA==",
"dev": true,
"funding": [
{
@@ -9414,14 +9311,14 @@
],
"license": "MIT",
"dependencies": {
- "@csstools/css-calc": "^3.2.1",
+ "@csstools/css-calc": "^3.3.0",
"@csstools/css-parser-algorithms": "^4.0.0",
- "@csstools/css-syntax-patches-for-csstree": "^1.1.6",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.9",
"@csstools/css-tokenizer": "^4.0.0",
"@csstools/media-query-list-parser": "^5.0.0",
- "@csstools/selector-resolve-nested": "^4.0.0",
+ "@csstools/selector-resolve-nested": "^4.0.1",
"@csstools/selector-specificity": "^6.0.0",
- "colord": "^2.9.3",
+ "colord": "^2.10.0",
"cosmiconfig": "^9.0.2",
"css-functions-list": "^3.3.3",
"css-tree": "^3.2.1",
@@ -9430,21 +9327,21 @@
"fastest-levenshtein": "^1.0.16",
"file-entry-cache": "^11.1.5",
"global-modules": "^2.0.0",
- "globby": "^16.2.1",
+ "globby": "^16.2.4",
"globjoin": "^0.1.4",
"html-tags": "^5.1.0",
- "ignore": "^7.0.5",
+ "ignore": "^7.0.6",
"import-meta-resolve": "^4.2.0",
"mathml-tag-names": "^4.0.0",
"meow": "^14.1.0",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"picocolors": "^1.1.1",
- "postcss": "^8.5.16",
+ "postcss": "^8.5.26",
"postcss-safe-parser": "^7.0.1",
- "postcss-selector-parser": "^7.1.4",
+ "postcss-selector-parser": "^7.1.5",
"postcss-value-parser": "^4.2.0",
- "string-width": "^8.2.1",
+ "string-width": "^8.2.2",
"supports-hyperlinks": "^4.5.0",
"svg-tags": "^1.0.0",
"table": "^6.9.0",
@@ -9720,11 +9617,14 @@
}
},
"node_modules/tinybench": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
- "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
+ "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
},
"node_modules/tinyexec": {
"version": "1.3.0",
@@ -10467,38 +10367,31 @@
}
},
"node_modules/vitest": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
- "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
+ "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.11",
- "@vitest/mocker": "4.1.11",
- "@vitest/pretty-format": "4.1.11",
- "@vitest/runner": "4.1.11",
- "@vitest/snapshot": "4.1.11",
- "@vitest/spy": "4.1.11",
- "@vitest/utils": "4.1.11",
- "es-module-lexer": "^2.0.0",
- "expect-type": "^1.3.0",
- "magic-string": "^0.30.21",
- "obug": "^2.1.1",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.3",
- "std-env": "^4.0.0-rc.1",
- "tinybench": "^2.9.0",
- "tinyexec": "^1.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.1.0",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.0",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "6.1.4",
+ "tinyexec": "1.3.0",
+ "tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
- "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -10506,16 +10399,16 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
- "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.11",
- "@vitest/browser-preview": "4.1.11",
- "@vitest/browser-webdriverio": "4.1.11",
- "@vitest/coverage-istanbul": "4.1.11",
- "@vitest/coverage-v8": "4.1.11",
- "@vitest/ui": "4.1.11",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.0",
+ "@vitest/browser-preview": "5.0.0",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.0",
+ "@vitest/coverage-v8": "5.0.0",
+ "@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
@@ -10556,6 +10449,16 @@
}
}
},
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
+ "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
diff --git a/package.json b/package.json
index a8659a5ac..9cbca7bc6 100644
--- a/package.json
+++ b/package.json
@@ -60,16 +60,17 @@
"@cyclonedx/cyclonedx-npm": "^6.0.1",
"@nextcloud/prettier-config": "^1.2.0",
"@openfun/cunningham-tokens": "^3.0.0",
- "@playwright/test": "^1.60.0",
- "@vitest/coverage-v8": "^4.1.11",
- "@vitest/ui": "^4.1.11",
+ "@playwright/test": "^1.63.0",
+ "@vitest/coverage-v8": "^5.0.0",
+ "@vitest/ui": "^5.0.0",
"jsdom": "^30.0.1",
"prettier": "^3.9.6",
- "stylelint": "^17.4.0",
+ "stylelint": "^17.15.0",
"stylelint-config-standard": "^40.0.0",
"ts-node": "^10.9.2",
"typescript": "^7.0.2",
- "vitest": "^4.1.11"
+ "vite": "^8.2.2",
+ "vitest": "^5.0.0"
},
"prettier": "@nextcloud/prettier-config"
}
diff --git a/phpunit-unit.xml b/phpunit-unit.xml
index d5f84f78e..ea574b4ba 100644
--- a/phpunit-unit.xml
+++ b/phpunit-unit.xml
@@ -28,6 +28,16 @@
+
+
diff --git a/phpunit.xml b/phpunit.xml
index fd087a699..a5bb351fc 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -22,6 +22,16 @@
+
+
diff --git a/playwright.config.ts b/playwright.config.ts
index b6b8f8041..d50f6244b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -3,12 +3,16 @@
* SPDX-License-Identifier: EUPL-1.2
*
* Playwright config for nldesign.
- * Base URL: http://localhost:8080 (override with NEXTCLOUD_URL env var).
+ * Base URL: resolved in tests/e2e/base-url.ts. It still defaults to
+ * http://localhost:8080, but that is the shared dev instance and a run has to
+ * name it before the suite will go there.
* globalSetup logs in once and saves session to tests/e2e/.auth/admin.json.
*/
import { defineConfig } from '@playwright/test'
import * as path from 'path'
+import { resolveBaseUrl } from './tests/e2e/base-url'
+
export default defineConfig({
testDir: './tests/e2e',
globalSetup: path.resolve(__dirname, 'tests/e2e/global-setup.ts'),
@@ -34,7 +38,9 @@ export default defineConfig({
outputDir: 'tests/e2e/test-results',
use: {
- baseURL: process.env.NEXTCLOUD_URL || 'http://localhost:8080',
+ // Resolved in tests/e2e/base-url.ts, which is also where the
+ // shared-instance opt-in is checked. NEXTCLOUD_URL is still read there.
+ baseURL: resolveBaseUrl(),
// `on-first-retry` PAIRED WITH `retries: 0` writes zero traces, ever.
// There is no first retry to trigger on, so every CI failure in this
// repo's history has been debugged from a single screenshot and a stack
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 1871e410f..578b96be8 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -106,15 +106,103 @@
}
});
-if (!defined('OC_CONSOLE')) {
- if (file_exists(__DIR__ . '/../../../lib/base.php')) {
- require_once __DIR__ . '/../../../lib/base.php';
+/**
+ * Tell whether a Nextcloud root is an INSTALLED instance, not just a source tree.
+ *
+ * `lib/base.php` from a source tree that was never installed (the workspace
+ * checkout above apps-extra/ has a 0-byte config/config.php) still declares
+ * `OC` and builds `\OC::$server` before it throws "Not installed". That server
+ * cannot be undone (`OC::$server` is a typed static), so from then on every
+ * `\OC::$server->get()` in the code under test hits a container that knows
+ * none of this app's registrations and autowires from scratch; constructor
+ * cycles then recurse until memory runs out (19 GB and 6 GB of swap in one
+ * openregister run on 2026-09-08). So the decision has to be made BEFORE
+ * base.php is loaded, and the only cheap signal is the `installed` flag in
+ * config/config.php.
+ *
+ * @param string $ncRoot Candidate Nextcloud root.
+ *
+ * @return bool True when config/config.php declares `installed => true`.
+ */
+function thematiq_nc_root_is_installed(string $ncRoot): bool
+{
+ $configFile = $ncRoot . '/config/config.php';
+ if (is_file($configFile) === false || filesize($configFile) === 0) {
+ return false;
}
- if (file_exists(__DIR__ . '/../../../tests/autoload.php')) {
- require_once __DIR__ . '/../../../tests/autoload.php';
+ // The config file is a plain `$CONFIG = [...]` script; including it in a
+ // closure keeps `$CONFIG` out of the global scope.
+ $config = (static function () use ($configFile): array {
+ $CONFIG = [];
+ try {
+ include $configFile;
+ } catch (\Throwable) {
+ return [];
+ }
+
+ if (is_array($CONFIG) === false) {
+ return [];
+ }
+
+ return $CONFIG;
+ })();
+
+ return ($config['installed'] ?? false) === true;
+}//end thematiq_nc_root_is_installed()
+
+// Only an INSTALLED root is booted; a bare source tree runs in pure-unit mode
+// with the composer autoload and the stubs above. NC's tests/autoload.php
+// requires lib/base.php itself, so it sits behind the same guard.
+if (!defined('OC_CONSOLE')) {
+ $thematiqNcRoot = realpath(__DIR__ . '/../../..');
+ if ($thematiqNcRoot !== false && file_exists($thematiqNcRoot . '/lib/base.php') === true) {
+ if (thematiq_nc_root_is_installed($thematiqNcRoot) === true) {
+ try {
+ require_once $thematiqNcRoot . '/lib/base.php';
+
+ if (file_exists($thematiqNcRoot . '/tests/autoload.php') === true) {
+ require_once $thematiqNcRoot . '/tests/autoload.php';
+ }
+ } catch (\Throwable $e) {
+ // The tree IS installed, so the dangerous case this guard exists for
+ // (loading a bare source tree) did not happen. base.php still failed
+ // part-way.
+ //
+ // This does NOT abort. `OC::$server` is a typed static, so a half-built
+ // container cannot be unset, and aborting was tried: it turned all six
+ // PHPUnit legs red on a suite that passes (humaniq, 2026-09-08). The
+ // runaway this guard exists for needs an autowiring lookup to reach the
+ // poisoned container, this app has none in lib, and phpunit.xml's 2G cap
+ // bounds one anyway.
+ //
+ // So: say plainly that the container is unreliable, and let the pure unit
+ // tests run. A container-bound test failing loudly is the intended outcome.
+ fwrite(
+ STDERR,
+ sprintf(
+ "[thematiq/tests/bootstrap] Nextcloud at %s could not finish booting (%s).\n"
+ . " \\OC::\$server now holds a HALF-BUILT container and cannot be unset. Pure unit tests\n"
+ . " continue; anything resolving a service from that container is UNVERIFIED by this run.\n",
+ $thematiqNcRoot,
+ $e->getMessage()
+ )
+ );
+ }
+ } else {
+ fwrite(
+ STDERR,
+ sprintf(
+ "[thematiq/tests/bootstrap] Nextcloud root at %s is not an installed instance (config/config.php lacks installed => true); "
+ . "skipping lib/base.php and running with composer autoload only (pure-unit mode).\n",
+ $thematiqNcRoot
+ )
+ );
+ }
}
+ unset($thematiqNcRoot);
+
if (class_exists('\OC_App')) {
\OC_App::loadApps();
// The APP ID, which is `thematiq` since 2026-08-22. Loading `nldesign`
diff --git a/tests/e2e/base-url.ts b/tests/e2e/base-url.ts
new file mode 100644
index 000000000..231b6b1b2
--- /dev/null
+++ b/tests/e2e/base-url.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The one place the e2e suite learns which Nextcloud it talks to.
+ *
+ * Two places used to resolve the target on their own, and they could disagree.
+ * `playwright.config.ts` read `process.env.NEXTCLOUD_URL || 'http://localhost:8080'`
+ * and `tests/e2e/global-setup.ts` repeated the same expression for the login it
+ * performs before any spec runs. Both now call `resolveBaseUrl()`, so a target
+ * enters this suite once.
+ *
+ * The default is unchanged on purpose. This module adds a guard, it does not
+ * change which instance an unset environment picks: with nothing set the suite
+ * still resolves `http://localhost:8080`, and `assertInstancePermitted()` then
+ * refuses it unless the run named that origin. An accident that used to be
+ * silent is now an error you can read.
+ *
+ * Every name the fleet uses is accepted. The shared `ConductionNL/.github`
+ * quality workflow exports `BASE_URL`, `NEXTCLOUD_URL` and `NC_BASE_URL`, and a
+ * sibling repo that read `PLAYWRIGHT_BASE_URL` only hard-failed every CI run
+ * because of it.
+ */
+
+import { assertInstancePermitted } from './shared-instance'
+
+/** Environment variables that may carry the target, in priority order. */
+const CANDIDATES = [
+ 'PLAYWRIGHT_BASE_URL',
+ 'NEXTCLOUD_URL',
+ 'NC_BASE_URL',
+ 'BASE_URL',
+] as const
+
+/** The target used when the environment names none. Unchanged by this module. */
+const DEFAULT_BASE_URL = 'http://localhost:8080'
+
+/**
+ * Resolve the base URL of the Nextcloud under test.
+ *
+ * @throws {Error} When the resolved URL is the shared development instance and
+ * the run did not name it in the opt-in variable.
+ * @return {string} The base URL, without a trailing slash.
+ */
+export function resolveBaseUrl(): string {
+ let target = DEFAULT_BASE_URL
+ for (const name of CANDIDATES) {
+ const value = process.env[name]
+ if (value && value.trim() !== '') {
+ target = value.trim()
+ break
+ }
+ }
+
+ return assertInstancePermitted(target.replace(/\/+$/, ''))
+}
diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts
index 8e74df327..9967441d3 100644
--- a/tests/e2e/global-setup.ts
+++ b/tests/e2e/global-setup.ts
@@ -8,6 +8,8 @@ import { chromium, request, type FullConfig } from '@playwright/test'
import * as path from 'path'
import * as fs from 'fs'
+import { resolveBaseUrl } from './base-url'
+
const AUTH_DIR = path.resolve(__dirname, '.auth')
const STORAGE_STATE = path.join(AUTH_DIR, 'admin.json')
@@ -32,10 +34,11 @@ async function ensureNextcloudReachable(baseURL: string): Promise {
}
export default async function globalSetup(config: FullConfig): Promise {
+ // The literal that stood here was a second entrance: it logged in before
+ // any spec ran, so it reached the shared instance even when the config had
+ // been pointed elsewhere. Same resolver, same guard.
const baseURL =
- (config.projects[0]?.use?.baseURL as string | undefined)
- ?? process.env.NEXTCLOUD_URL
- ?? 'http://localhost:8080'
+ (config.projects[0]?.use?.baseURL as string | undefined) ?? resolveBaseUrl()
const username = process.env.NC_ADMIN_USER ?? 'admin'
const password = process.env.NC_ADMIN_PASS ?? 'admin'
diff --git a/tests/e2e/shared-instance.ts b/tests/e2e/shared-instance.ts
new file mode 100644
index 000000000..e1b917a0d
--- /dev/null
+++ b/tests/e2e/shared-instance.ts
@@ -0,0 +1,330 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Thematiq Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Whether this e2e run is allowed to touch the instance it is aimed at.
+ *
+ * GENERATED FROM hydra/templates/e2e/shared-instance.ts.tmpl. The only thing
+ * that differs per app is `APP_ID` below. Edit the template, not the copies.
+ *
+ * The problem
+ * -----------
+ * Every Conduction dev box runs one shared Nextcloud on port 8080 (or 80 when
+ * it is published without a port). It bind-mounts the host checkouts under
+ * `apps-extra/`, so it serves whatever everybody on the box is editing, and it
+ * carries data colleagues are working on. An e2e suite seeds OpenRegister
+ * objects and then deletes them again. Pointed at that instance it is not
+ * running tests, it is editing someone else's environment. Two apps were
+ * caught doing exactly this, by default, because their base URL fell back to
+ * `http://localhost:8080` when nothing was set.
+ *
+ * The rule
+ * --------
+ * Aiming at a shared instance is allowed. Aiming at one BY ACCIDENT is not.
+ * So the target has to be named twice: once as the base URL, and once in an
+ * opt-in variable that holds the origin you permit.
+ *
+ * E2E_ALLOW_SHARED_INSTANCE=http://localhost:8080 \
+ * PLAYWRIGHT_BASE_URL=http://localhost:8080 \
+ * npx playwright test
+ *
+ * The flag holds an ORIGIN, not `1`. A bare `1` left in a shell profile goes
+ * on permitting every shared instance the suite ever meets. An origin permits
+ * the one you typed and nothing else, so aiming somewhere new makes you type
+ * the new one.
+ *
+ * Two spellings are accepted and they mean the same thing:
+ *
+ * E2E_ALLOW_SHARED_INSTANCE fleet-wide, permits any app's suite
+ * THEMATIQ_E2E_ALLOW_SHARED_INSTANCE this app only
+ *
+ * The app-specific spelling is the safer one to leave in a profile, because it
+ * stops at this app. The fleet-wide one is for a session that deliberately
+ * drives several suites at one instance.
+ *
+ * CI is exempt
+ * ------------
+ * On a GitHub runner `localhost:8080` is the runner's own throwaway Nextcloud,
+ * started by the shared ConductionNL/.github workflow and destroyed with the
+ * job. Nothing there is shared with anybody. Treating it as shared would break
+ * every e2e run in the fleet, so `isCI()` short-circuits the whole rule.
+ *
+ * This is one of three guards, and they do not overlap
+ * ---------------------------------------------------
+ * 1. An age bound on the cross-run residue sweep stops one session deleting
+ * another session's LIVE fixtures.
+ * 2. A run ledger stops a run's own teardown reaching rows it never created,
+ * at any age. Content matching (`JSON.stringify(row).includes(prefix)`) is
+ * not ownership: it deletes anything that happens to carry the string.
+ * 3. This flag stops the cross-run sweep deleting anything at all on a shared
+ * box, because there it cannot tell your leftovers from a colleague's.
+ *
+ * Guard 3 is the only portable one, which is why it is the one in this
+ * template. Guard 2 needs a single create funnel and is written per app.
+ */
+
+/** The app this copy belongs to. Substituted when the template is rendered. */
+export const APP_ID = 'thematiq'
+
+/** The app-specific opt-in variable. Holds an origin, not a boolean. */
+export const SHARED_INSTANCE_FLAG = 'THEMATIQ_E2E_ALLOW_SHARED_INSTANCE'
+
+/** The fleet-wide opt-in variable. Same contract, wider blast radius. */
+export const FLEET_INSTANCE_FLAG = 'E2E_ALLOW_SHARED_INSTANCE'
+
+/**
+ * Both spellings, app-specific first so it wins a disagreement.
+ */
+export const SHARED_INSTANCE_FLAGS = [
+ SHARED_INSTANCE_FLAG,
+ FLEET_INSTANCE_FLAG,
+] as const
+
+/**
+ * Origins that belong to the shared Conduction development stack.
+ *
+ * Port 8080 is the `nextcloud` container every dev box runs, and port 80 is
+ * the same stack published without a port. A disposable rig gets its own high
+ * port (8095, 8614, 8731 and so on), never matches this list, and needs no
+ * flag.
+ */
+const SHARED_PORTS = new Set(['80', '8080'])
+
+/** Loopback spellings that all name the same host. */
+const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'])
+
+/** A URL split into the three things this module compares. */
+interface OriginParts {
+ /** `http:` or `https:`, including the colon. */
+ protocol: string
+ /** Hostname, with every loopback spelling folded onto `localhost`. */
+ host: string
+ /** Port as a string, with the protocol default made explicit. */
+ port: string
+}
+
+/**
+ * Split a URL into protocol, folded host and explicit port.
+ *
+ * The explicit port is the whole point. `new URL('http://127.0.0.1').port` is
+ * the empty string, not `80`, so a comparison against a port list silently
+ * misses every shared origin written without one. That is the failure this
+ * helper exists to make impossible, by being the only place a port is read.
+ *
+ * @param value A base URL.
+ * @return The parts, or null when the value will not parse.
+ */
+function splitOrigin(value: string): OriginParts | null {
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ return null
+ }
+ return {
+ protocol: url.protocol,
+ host: LOOPBACK.has(url.hostname) ? 'localhost' : url.hostname,
+ port: url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80',
+ }
+}
+
+/**
+ * Reduce a URL to `scheme://host:port`, with loopback spellings folded onto
+ * `localhost` and the default port made explicit.
+ *
+ * Returns the trimmed input when it will not parse, so a malformed value fails
+ * later on its own HTTP probe with a message about the real problem rather
+ * than here.
+ *
+ * @param value A base URL.
+ * @return The normalised origin.
+ */
+export function normaliseOrigin(value: string): string {
+ const parts = splitOrigin(value)
+ if (parts === null) return value.trim().replace(/\/+$/, '')
+ return `${parts.protocol}//${parts.host}:${parts.port}`
+}
+
+/**
+ * Whether this URL names an instance shared with other people.
+ *
+ * Shared means loopback on port 80 or 8080. A remote host is somebody's
+ * deployment and is out of scope here: this guard is about the box you are
+ * sitting at.
+ *
+ * @param value A base URL.
+ * @return True when the origin is the shared development stack.
+ */
+export function isSharedOrigin(value: string): boolean {
+ const parts = splitOrigin(value)
+ if (parts === null) return false
+ return parts.host === 'localhost' && SHARED_PORTS.has(parts.port)
+}
+
+/**
+ * Whether this process runs on a CI runner.
+ *
+ * @return True on GitHub Actions, or any CI that exports `CI`.
+ */
+export function isCI(): boolean {
+ return Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS)
+}
+
+/**
+ * The flag value that permits this origin, if any flag does.
+ *
+ * @param target The base URL being aimed at.
+ * @param env The environment to read.
+ * @return The variable name and its value, or null when none matches.
+ */
+export function permittingFlag(
+ target: string,
+ env: NodeJS.ProcessEnv = process.env,
+): { name: string; value: string } | null {
+ const wanted = normaliseOrigin(target)
+ for (const name of SHARED_INSTANCE_FLAGS) {
+ const value = (env[name] ?? '').trim()
+ if (value === '') continue
+ if (normaliseOrigin(value) === wanted) return { name, value }
+ }
+ return null
+}
+
+/**
+ * Whether this run deliberately targets an instance shared with other people.
+ *
+ * False on CI even at `localhost:8080`, and false for a rig on its own port.
+ * Every safety rule in a suite keys off this one answer: teardown deletes only
+ * recorded ids, the cross-run residue sweep reports instead of deleting, and
+ * specs that would change instance-wide settings refuse to run.
+ *
+ * @param target The base URL under test.
+ * @return True when the target is shared and this run said so.
+ */
+export function isSharedInstance(target: string): boolean {
+ return isCI() === false && isSharedOrigin(target)
+}
+
+/**
+ * The message an operator reads when they aimed at a shared instance without
+ * saying so.
+ *
+ * @param target The base URL they asked for.
+ * @param env The environment to read.
+ * @return The full error text.
+ */
+function refusalMessage(
+ target: string,
+ env: NodeJS.ProcessEnv = process.env,
+): string {
+ const origin = normaliseOrigin(target)
+ const setButWrong = SHARED_INSTANCE_FLAGS.map((name) => {
+ const value = (env[name] ?? '').trim()
+ if (value === '') return ''
+ return (
+ `${name} is set to "${value}", which normalises to `
+ + `${normaliseOrigin(value)} and does not match ${origin}.\n`
+ )
+ }).join('')
+
+ return (
+ `[${APP_ID} e2e] ${target} is the SHARED development instance, and this run `
+ + 'did not say it meant to go there.\n'
+ + setButWrong
+ + 'That instance bind-mounts host checkouts and holds data your colleagues '
+ + 'are working on. This suite seeds and deletes objects.\n\n'
+ + 'Point the suite at your own disposable rig:\n\n'
+ + ' PLAYWRIGHT_BASE_URL=http://localhost:8095 npx playwright test\n\n'
+ + 'Or aim at the shared instance on purpose, naming the origin you permit:\n\n'
+ + ` ${SHARED_INSTANCE_FLAG}=${origin} \\\n`
+ + ` PLAYWRIGHT_BASE_URL=${target} \\\n`
+ + ' npx playwright test\n\n'
+ + `Read the header of tests/e2e/shared-instance.ts first. The flag changes `
+ + 'what teardown may delete and what the residue sweep may remove.'
+ )
+}
+
+/**
+ * Refuse the run when it aims at a shared instance without the opt-in.
+ *
+ * Call this from the module that resolves the base URL, on the resolved value,
+ * so there is one place a target can enter the suite.
+ *
+ * @param target The base URL under test.
+ * @param env The environment to read.
+ * @return The target, unchanged, so the call can be inlined.
+ * @throws when the target is shared and no flag names it.
+ */
+export function assertInstancePermitted(
+ target: string,
+ env: NodeJS.ProcessEnv = process.env,
+): string {
+ if (isCI()) return target
+ if (isSharedOrigin(target) === false) return target
+ if (permittingFlag(target, env) !== null) return target
+ throw new Error(refusalMessage(target, env))
+}
+
+/**
+ * Refuse one capability on a shared instance, even when the flag permitted the
+ * suite itself.
+ *
+ * For work that changes instance-wide state: enabling a workflow engine,
+ * flipping an app setting, purging a register. Call it from `test.beforeAll`
+ * so the refusal is reported as a failure with its reason. A skip reads as
+ * "nothing to see here", which is the opposite of the message.
+ *
+ * @param target The base URL under test.
+ * @param what The spec or capability being refused.
+ * @param reason Why it must not run on a shared instance.
+ * @throws when this run targets a shared instance.
+ */
+export function refuseOnSharedInstance(
+ target: string,
+ what: string,
+ reason: string,
+): void {
+ if (isSharedInstance(target) === false) return
+ throw new Error(
+ `[${APP_ID} e2e] ${what} must not run on ${target}.\n`
+ + `${reason}\n`
+ + `${SHARED_INSTANCE_FLAG} permits the suite on a shared instance. It does `
+ + 'not permit this.\n'
+ + 'Start a disposable rig and point the suite at that instead:\n\n'
+ + ' PLAYWRIGHT_BASE_URL=http://localhost:8095 npx playwright test\n',
+ )
+}
+
+/**
+ * The occ invocation for the instance under test, as a command prefix.
+ *
+ * The binding matters on a shared box: `php occ` run from this checkout talks
+ * to whatever server root sits two directories up, which on CI is the instance
+ * under test and on a dev box may be a different one entirely. Naming the
+ * container is how the two are tied together.
+ *
+ * Resolution order:
+ * 1. `THEMATIQ_E2E_OCC`, a complete prefix, for any rig shape the guesses
+ * below do not cover.
+ * 2. `THEMATIQ_E2E_CONTAINER` or `NEXTCLOUD_CONTAINER`, a container name,
+ * turned into `docker exec -u www-data php occ`.
+ * 3. `php occ` from the server root, which is the CI case.
+ *
+ * @param env The environment to read.
+ * @return The argv prefix, already split, for `execFile` rather than a shell.
+ */
+export function occPrefix(env: NodeJS.ProcessEnv = process.env): string[] {
+ const explicit = (env.THEMATIQ_E2E_OCC ?? '').trim()
+ if (explicit !== '') return explicit.split(/\s+/).filter((p) => p !== '')
+
+ const container = (
+ env.THEMATIQ_E2E_CONTAINER
+ ?? env.NEXTCLOUD_CONTAINER
+ ?? ''
+ ).trim()
+ if (container !== '') {
+ return ['docker', 'exec', '-u', 'www-data', container, 'php', 'occ']
+ }
+
+ return ['php', 'occ']
+}
diff --git a/tests/vitest/shared-instance.spec.ts b/tests/vitest/shared-instance.spec.ts
new file mode 100644
index 000000000..342e9769d
--- /dev/null
+++ b/tests/vitest/shared-instance.spec.ts
@@ -0,0 +1,120 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Thematiq Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The guard that decides whether this suite may touch the instance it is aimed
+ * at, tested without an instance.
+ *
+ * GENERATED FROM hydra/templates/e2e/shared-instance.test.ts.tmpl.
+ *
+ * Worth testing rather than reading, because every case here is one somebody
+ * already got wrong: `http://127.0.0.1` parses with an EMPTY port, so a port
+ * comparison that trusts `URL.port` misses the shared instance written without
+ * one, and a flag holding `1` used to permit every shared instance the suite
+ * ever met.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ APP_ID,
+ assertInstancePermitted,
+ isSharedOrigin,
+ normaliseOrigin,
+ occPrefix,
+ SHARED_INSTANCE_FLAG,
+} from '../e2e/shared-instance.ts'
+
+/** An environment with neither flag set. */
+const NONE = {} as NodeJS.ProcessEnv
+
+/*
+ * The guard exempts CI, and a unit runner runs ON CI. So without this, the
+ * three refusal cases below pass on a laptop and fail on every runner:
+ * measured before the stub, `CI=true npx vitest run` gave 3 failed, 7 passed.
+ * A test whose verdict depends on where it runs is worse than no test.
+ */
+
+describe(`${APP_ID} shared-instance guard`, () => {
+ beforeEach(() => {
+ vi.stubEnv('CI', '')
+ vi.stubEnv('GITHUB_ACTIONS', '')
+ })
+
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ })
+
+ it('folds every loopback spelling onto localhost', () => {
+ expect(normaliseOrigin('http://127.0.0.1:8080')).toBe(
+ 'http://localhost:8080',
+ )
+ expect(normaliseOrigin('http://[::1]:8080')).toBe('http://localhost:8080')
+ expect(normaliseOrigin('http://localhost:8080/')).toBe(
+ 'http://localhost:8080',
+ )
+ })
+
+ it('makes the implicit port explicit', () => {
+ expect(normaliseOrigin('http://127.0.0.1')).toBe('http://localhost:80')
+ expect(normaliseOrigin('https://example.org')).toBe(
+ 'https://example.org:443',
+ )
+ })
+
+ it('calls loopback 80 and 8080 shared, and nothing else', () => {
+ expect(isSharedOrigin('http://localhost:8080')).toBe(true)
+ expect(isSharedOrigin('http://127.0.0.1')).toBe(true)
+ expect(isSharedOrigin('http://localhost:8095')).toBe(false)
+ expect(isSharedOrigin('http://nextcloud.example.org:8080')).toBe(false)
+ })
+
+ it('refuses a shared instance that no flag names', () => {
+ expect(() => assertInstancePermitted('http://localhost:8080', NONE)).toThrow(
+ /SHARED development instance/,
+ )
+ })
+
+ it('refuses a flag holding a boolean rather than an origin', () => {
+ const env = { [SHARED_INSTANCE_FLAG]: '1' } as unknown as NodeJS.ProcessEnv
+ expect(() => assertInstancePermitted('http://localhost:8080', env)).toThrow()
+ })
+
+ it('refuses a flag that names a different origin', () => {
+ const env = {
+ [SHARED_INSTANCE_FLAG]: 'http://localhost:80',
+ } as unknown as NodeJS.ProcessEnv
+ expect(() => assertInstancePermitted('http://localhost:8080', env)).toThrow()
+ })
+
+ it('permits the origin the flag names, in any loopback spelling', () => {
+ const env = {
+ [SHARED_INSTANCE_FLAG]: 'http://127.0.0.1:8080',
+ } as unknown as NodeJS.ProcessEnv
+ expect(assertInstancePermitted('http://localhost:8080', env)).toBe(
+ 'http://localhost:8080',
+ )
+ })
+
+ it('accepts the fleet-wide spelling too', () => {
+ const env = {
+ E2E_ALLOW_SHARED_INSTANCE: 'http://localhost:8080',
+ } as unknown as NodeJS.ProcessEnv
+ expect(assertInstancePermitted('http://localhost:8080', env)).toBe(
+ 'http://localhost:8080',
+ )
+ })
+
+ it('lets a disposable rig through without a flag', () => {
+ expect(assertInstancePermitted('http://localhost:8095', NONE)).toBe(
+ 'http://localhost:8095',
+ )
+ })
+
+ it('binds occ to a named container, and falls back to the server root', () => {
+ expect(occPrefix(NONE).join(' ')).toBe('php occ')
+ const env = { NEXTCLOUD_CONTAINER: 'nextcloud' } as NodeJS.ProcessEnv
+ expect(occPrefix(env).join(' ')).toBe(
+ 'docker exec -u www-data nextcloud php occ',
+ )
+ })
+})