From d6ff40978a6bfb8d01183651f60782bd0f897241 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 7 Sep 2026 16:45:42 +0200 Subject: [PATCH 01/12] chore(deps): refresh the shared Conduction locks (#563) hydra-gates v1.10.0 -> v1.16.0 nc-vue 2.27.2 -> 2.39.0 Lock-only: both packages are already declared with caret ranges that permit these versions, so nothing about what this app ACCEPTS changes - only what it currently resolves to. Opened by the weekly fleet shared-dependency bump, because a lock nobody re-resolves is a pin nobody chose. Merging is gated by this repository's own suite, deliberately: taking hydra-gates v1.8.1 added patchObject() to a published interface, which is a load-time fatal for any concrete double that implements it without the method. CI is the only thing that can tell a safe bump from that. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- composer.lock | 15 ++++++++------- package-lock.json | 6 +++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/composer.lock b/composer.lock index 1125f49e0..6a81ccb1c 100644 --- a/composer.lock +++ b/composer.lock @@ -516,16 +516,16 @@ }, { "name": "conduction/hydra-gates", - "version": "v1.10.0", + "version": "v1.16.0", "source": { "type": "git", "url": "https://github.com/ConductionNL/.github.git", - "reference": "d143bc27aecbb44a66c843dba89d374628bf9eef" + "reference": "90d4e4b94052b5423d62bec24a61a0af781961dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ConductionNL/.github/zipball/d143bc27aecbb44a66c843dba89d374628bf9eef", - "reference": "d143bc27aecbb44a66c843dba89d374628bf9eef", + "url": "https://api.github.com/repos/ConductionNL/.github/zipball/90d4e4b94052b5423d62bec24a61a0af781961dc", + "reference": "90d4e4b94052b5423d62bec24a61a0af781961dc", "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.0" }, - "time": "2026-08-27T16:18:04+00:00" + "time": "2026-09-05T17:51:59+00:00" }, { "name": "consolidation/annotated-command", @@ -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/package-lock.json b/package-lock.json index 76d00882a..89c55599e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -387,9 +387,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": { From 7466a5a35aa0780664c6586209bc599b2a391c23 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 8 Sep 2026 10:20:34 +0200 Subject: [PATCH 02/12] fix(tests): cap PHPUnit memory and only boot an installed Nextcloud in the test bootstrap (#565) The CLI php.ini sets memory_limit=-1, so a runaway test had nothing to stop it: on 2026-09-08 one openregister test recursed inside the DI container and took 19 GB of RAM. The test bootstrap also loaded the workspace's lib/base.php from a source tree that was never installed, which declares OC and builds a half-built OC::$server before throwing, and that state cannot be undone. - phpunit.xml and phpunit-unit.xml: hard memory_limit of 2G (the loadable part of the unit suite peaks at 69 MB without coverage) - tests/bootstrap.php: only boot a root whose config/config.php declares installed => true, otherwise say so on STDERR and run in pure-unit mode; if base.php still throws, stop with exit 1 instead of continuing half-booted - composer.json: psalm gets --memory-limit=2G and each phpmd call runs under php -d memory_limit=2G Co-authored-by: Conduction Release Bot --- composer.json | 4 +- phpunit-unit.xml | 10 +++++ phpunit.xml | 10 +++++ tests/bootstrap.php | 91 ++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 108 insertions(+), 7 deletions(-) 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/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/tests/bootstrap.php b/tests/bootstrap.php index 1871e410f..eef37332b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -106,15 +106,96 @@ } }); -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 root passed the installed check but base.php still + // failed (unreachable database, broken app, ...). `OC::$server` + // is a typed static that already holds a half-built container, + // so falling through to "composer autoload only" would be a lie + // that costs gigabytes. Stop the run and say why. + fwrite( + STDERR, + sprintf( + "[thematiq/tests/bootstrap] Nextcloud root at %s could not be initialised (%s).\n" + . " A half-booted server cannot be undone, so the run stops here rather than pretending to be pure-unit.\n" + . " Fix the instance, or define OC_CONSOLE for pure-unit mode.\n", + $thematiqNcRoot, + $e->getMessage() + ) + ); + exit(1); + } + } 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` From c9dbb62331240be5153f14db4023a8239c6331f0 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 8 Sep 2026 11:52:45 +0200 Subject: [PATCH 03/12] refactor: mark the mail template's container lookup as deliberate (#567) The fleet sniff NoServiceLocator flags every \OCP\Server::get() outside lib/AppInfo, lib/AppHost, lib/Migration, lib/Repair and lib/Resources, because outside a booted Nextcloud that call autowires services from scratch and can recurse through a constructor cycle until memory runs out. Thematiq has exactly one such site, and it cannot be injected. The server constructs NLDesignEMailTemplate itself, from Mailer::makeTemplate(), with a fixed argument list, so the class has no constructor of its own to take a container. The lookup stays where it is and now says why, so the sniff counts it as resolved rather than as an unexamined lookup. No behaviour changes. Co-authored-by: Conduction Release Bot --- lib/Mail/NLDesignEMailTemplate.php | 1 + 1 file changed, 1 insertion(+) 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; From 7526fe9a7fe5aa50fde8237ee17dca088412e5ca Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 8 Sep 2026 12:50:19 +0200 Subject: [PATCH 04/12] fix(tests): a mid-boot Nextcloud failure warns, it does not abort the suite (#569) Co-authored-by: Conduction Release Bot --- tests/bootstrap.php | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index eef37332b..578b96be8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -165,22 +165,29 @@ function thematiq_nc_root_is_installed(string $ncRoot): bool require_once $thematiqNcRoot . '/tests/autoload.php'; } } catch (\Throwable $e) { - // The root passed the installed check but base.php still - // failed (unreachable database, broken app, ...). `OC::$server` - // is a typed static that already holds a half-built container, - // so falling through to "composer autoload only" would be a lie - // that costs gigabytes. Stop the run and say why. + // 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 root at %s could not be initialised (%s).\n" - . " A half-booted server cannot be undone, so the run stops here rather than pretending to be pure-unit.\n" - . " Fix the instance, or define OC_CONSOLE for pure-unit mode.\n", + "[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() ) ); - exit(1); } } else { fwrite( From 0bc8f4210784536455e7e90bd7ca1177d40b98a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:40:57 +0200 Subject: [PATCH 05/12] chore(deps-dev): Bump @playwright/test from 1.62.1 to 1.63.0 (#576) Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.62.1 to 1.63.0. - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.62.1...v1.63.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-version: 1.63.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 42 ++++++++++++------------------------------ package.json | 2 +- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 89c55599e..6c8927981 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@cyclonedx/cyclonedx-npm": "^6.0.1", "@nextcloud/prettier-config": "^1.2.0", "@openfun/cunningham-tokens": "^3.0.0", - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.63.0", "@vitest/coverage-v8": "^4.1.11", "@vitest/ui": "^4.1.11", "jsdom": "^30.0.1", @@ -1726,13 +1726,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" @@ -5043,21 +5043,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", @@ -8037,28 +8022,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": { diff --git a/package.json b/package.json index a8659a5ac..024828128 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@cyclonedx/cyclonedx-npm": "^6.0.1", "@nextcloud/prettier-config": "^1.2.0", "@openfun/cunningham-tokens": "^3.0.0", - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.63.0", "@vitest/coverage-v8": "^4.1.11", "@vitest/ui": "^4.1.11", "jsdom": "^30.0.1", From 7f943800f7b1b83171e23bcd5aeff709b6420a21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:41:02 +0200 Subject: [PATCH 06/12] chore(deps-dev): Bump stylelint from 17.14.1 to 17.15.0 (#574) Bumps [stylelint](https://github.com/stylelint/stylelint) from 17.14.1 to 17.15.0. - [Release notes](https://github.com/stylelint/stylelint/releases) - [Changelog](https://github.com/stylelint/stylelint/blob/main/CHANGELOG.md) - [Commits](https://github.com/stylelint/stylelint/compare/17.14.1...17.15.0) --- updated-dependencies: - dependency-name: stylelint dependency-version: 17.15.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 26 +++++++++++++------------- package.json | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6c8927981..8337cfe5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@vitest/ui": "^4.1.11", "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", @@ -9380,9 +9380,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": [ { @@ -9396,14 +9396,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", @@ -9412,21 +9412,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", diff --git a/package.json b/package.json index 024828128..ba792b351 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "@vitest/ui": "^4.1.11", "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", From 811e3ed07c009a2a8c5c3738a051b52f2650c7c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:41:07 +0200 Subject: [PATCH 07/12] chore(deps-dev): Bump conduction/hydra-gates from 1.16.0 to 1.16.1 (#572) Bumps [conduction/hydra-gates](https://github.com/ConductionNL/.github) from 1.16.0 to 1.16.1. - [Release notes](https://github.com/ConductionNL/.github/releases) - [Commits](https://github.com/ConductionNL/.github/compare/v1.16.0...v1.16.1) --- updated-dependencies: - dependency-name: conduction/hydra-gates dependency-version: 1.16.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 6a81ccb1c..4687611f2 100644 --- a/composer.lock +++ b/composer.lock @@ -516,16 +516,16 @@ }, { "name": "conduction/hydra-gates", - "version": "v1.16.0", + "version": "v1.16.1", "source": { "type": "git", "url": "https://github.com/ConductionNL/.github.git", - "reference": "90d4e4b94052b5423d62bec24a61a0af781961dc" + "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ConductionNL/.github/zipball/90d4e4b94052b5423d62bec24a61a0af781961dc", - "reference": "90d4e4b94052b5423d62bec24a61a0af781961dc", + "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.16.0" + "source": "https://github.com/ConductionNL/.github/tree/v1.16.1" }, - "time": "2026-09-05T17:51:59+00:00" + "time": "2026-09-07T08:01:54+00:00" }, { "name": "consolidation/annotated-command", From fa2c35f794ec832faa694a703e9e4064ac967b37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:41:12 +0200 Subject: [PATCH 08/12] chore(deps-dev): Bump phpstan/phpstan from 2.2.10 to 2.2.13 (#571) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.10 to 2.2.13. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.13 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 4687611f2..ce815566d 100644 --- a/composer.lock +++ b/composer.lock @@ -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", From a98dd8fe710ef9b26dc1cc02df1a2b640663758d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 9 Sep 2026 12:15:16 +0200 Subject: [PATCH 09/12] chore(deps-dev): bump vitest, its coverage plugin and its ui together (#579) `@vitest/coverage-v8` and `@vitest/ui` both declare `peer vitest` as an exact version, not a caret, so all three can only ever move as a set. Dependabot opened them as #573, #575 and #577, and each fails to resolve while the other two sit at 4. `vite` is declared explicitly because vitest 5 resolves it from the project where 4 did not; 8.2.2 was already in the lock transitively, so nothing new is pulled in. npm run test:unit -> 9 files, 119 tests passed, exit 0 npm run test:coverage -> same, 48.93% statements, exit 0 Co-authored-by: Conduction Release Bot --- package-lock.json | 291 +++++++++++++++++----------------------------- package.json | 7 +- 2 files changed, 110 insertions(+), 188 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8337cfe5f..d6888ee5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,15 +17,16 @@ "@nextcloud/prettier-config": "^1.2.0", "@openfun/cunningham-tokens": "^3.0.0", "@playwright/test": "^1.63.0", - "@vitest/coverage-v8": "^4.1.11", - "@vitest/ui": "^4.1.11", + "@vitest/coverage-v8": "^5.0.0", + "@vitest/ui": "^5.0.0", "jsdom": "^30.0.1", "prettier": "^3.9.6", "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", @@ -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" @@ -5452,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", @@ -5829,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", @@ -6414,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", @@ -9702,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", @@ -10449,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" @@ -10488,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": { @@ -10538,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 ba792b351..9cbca7bc6 100644 --- a/package.json +++ b/package.json @@ -61,15 +61,16 @@ "@nextcloud/prettier-config": "^1.2.0", "@openfun/cunningham-tokens": "^3.0.0", "@playwright/test": "^1.63.0", - "@vitest/coverage-v8": "^4.1.11", - "@vitest/ui": "^4.1.11", + "@vitest/coverage-v8": "^5.0.0", + "@vitest/ui": "^5.0.0", "jsdom": "^30.0.1", "prettier": "^3.9.6", "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" } From 77fb1cd1252966e20d8346629597e4c304f713d1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 10 Sep 2026 12:51:20 +0200 Subject: [PATCH 10/12] chore(release): 1.2.2-unstable.20260910104940 (#581) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 640065dfb..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.1.12-unstable.20260831125624 + 1.2.2-unstable.20260910104940 EUPL-1.2 Conduction Thematiq From 0d5e41baf5f4ea971d3c5e1bc2afa258423de5d4 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 12 Sep 2026 14:10:14 +0200 Subject: [PATCH 11/12] ci: run the fast tier on pull requests (path filter, one PHPUnit leg) (#583) --- .github/workflows/code-quality.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 From a38b909e1afbcffccb525f63914acfc770f3103a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 12 Sep 2026 22:33:31 +0200 Subject: [PATCH 12/12] feat(e2e): refuse the shared instance unless the run names it (#594) The dev box runs one Nextcloud on localhost:8080 that bind-mounts everyone's checkouts. This suite defaulted straight to it: playwright.config.ts read NEXTCLOUD_URL || 'http://localhost:8080' and tests/e2e/global-setup.ts repeated the same expression for the login it performs before any spec runs, so an unset environment logged into a colleague's instance and drove 142 tests through it. tests/e2e/base-url.ts is now the one place a target enters the suite, and both of those callers go through it. The default is unchanged: nothing set still resolves http://localhost:8080. What changed is that the guard in tests/e2e/shared-instance.ts then refuses it unless the run names that origin in THEMATIQ_E2E_ALLOW_SHARED_INSTANCE. CI is exempt, where localhost:8080 is the runner's own throwaway instance. No spec files changed. --- playwright.config.ts | 10 +- tests/e2e/base-url.ts | 56 +++++ tests/e2e/global-setup.ts | 9 +- tests/e2e/shared-instance.ts | 330 +++++++++++++++++++++++++++ tests/vitest/shared-instance.spec.ts | 120 ++++++++++ 5 files changed, 520 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/base-url.ts create mode 100644 tests/e2e/shared-instance.ts create mode 100644 tests/vitest/shared-instance.spec.ts 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/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', + ) + }) +})