From 27fcd2b73b2da10c5b3238dcd3c0d9d919c3b413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 16 Aug 2026 16:07:07 +0200 Subject: [PATCH 1/9] test: Fix overwriting and restoring config values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- .../UserMigration/AccountMigratorTest.php | 9 +- tests/lib/TestCase.php | 37 ++++++++ tests/lib/TestCaseTest.php | 91 +++++++++++++++++++ 3 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 tests/lib/TestCaseTest.php diff --git a/apps/settings/tests/UserMigration/AccountMigratorTest.php b/apps/settings/tests/UserMigration/AccountMigratorTest.php index 0938f36c48cf0..748dc45c57100 100644 --- a/apps/settings/tests/UserMigration/AccountMigratorTest.php +++ b/apps/settings/tests/UserMigration/AccountMigratorTest.php @@ -12,9 +12,7 @@ use OCP\Accounts\IAccountManager; use OCP\AppFramework\App; use OCP\IAvatarManager; -use OCP\IConfig; use OCP\IUserManager; -use OCP\Server; use OCP\UserMigration\IExportDestination; use OCP\UserMigration\IImportSource; use PHPUnit\Framework\Constraint\JsonMatches; @@ -45,7 +43,7 @@ protected function setUp(): void { $app = new App(Application::APP_ID); $container = $app->getContainer(); - $container->get(IConfig::class)->setSystemValue('has_internet_connection', false); + $this->overwriteSystemConfig('has_internet_connection', false); $this->userManager = $container->get(IUserManager::class); $this->avatarManager = $container->get(IAvatarManager::class); @@ -56,11 +54,6 @@ protected function setUp(): void { $this->output = $this->createMock(OutputInterface::class); } - protected function tearDown(): void { - Server::get(IConfig::class)->setSystemValue('has_internet_connection', true); - parent::tearDown(); - } - public static function dataImportExportAccount(): array { return array_map( static function (string $filename): array { diff --git a/tests/lib/TestCase.php b/tests/lib/TestCase.php index 635813f557a3d..4028b748bc7df 100644 --- a/tests/lib/TestCase.php +++ b/tests/lib/TestCase.php @@ -45,10 +45,13 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase { protected static ?IDBConnection $realDatabase = null; private static bool $wasDatabaseAllowed = false; protected array $services = []; + /** Original values keyed by config key; null means the key was unset. */ + private array $systemConfigValues = []; #[\Override] protected function onNotSuccessfulTest(\Throwable $t): never { $this->restoreAllServices(); + $this->restoreAllSystemConfig(); // restore database connection if (!$this->IsDatabaseAccessAllowed()) { @@ -113,6 +116,39 @@ public function restoreAllServices(): void { } } + /** + * Sets a system config value for the duration of the test, restoring the + * previous one in tearDown. System config is persisted to config.php, so a + * leaked value would outlive the whole run. + */ + protected function overwriteSystemConfig(string $key, mixed $value): void { + $config = Server::get(IConfig::class); + + if (!array_key_exists($key, $this->systemConfigValues)) { + $this->systemConfigValues[$key] = $config->getSystemValue($key, null); + } + + $config->setSystemValue($key, $value); + } + + public function restoreAllSystemConfig(): void { + if ($this->systemConfigValues === []) { + return; + } + + $config = Server::get(IConfig::class); + foreach ($this->systemConfigValues as $key => $value) { + // null reads back as the default, so remove the key instead. + if ($value === null) { + $config->deleteSystemValue($key); + } else { + $config->setSystemValue($key, $value); + } + } + + $this->systemConfigValues = []; + } + protected function getTestTraits(): array { $traits = []; $class = $this; @@ -160,6 +196,7 @@ protected function setUp(): void { #[\Override] protected function tearDown(): void { $this->restoreAllServices(); + $this->restoreAllSystemConfig(); // restore database connection if (!$this->IsDatabaseAccessAllowed()) { diff --git a/tests/lib/TestCaseTest.php b/tests/lib/TestCaseTest.php new file mode 100644 index 0000000000000..81d9c68ca67ef --- /dev/null +++ b/tests/lib/TestCaseTest.php @@ -0,0 +1,91 @@ +config = Server::get(IConfig::class); + $this->config->deleteSystemValue(self::KEY); + } + + #[\Override] + protected function tearDown(): void { + parent::tearDown(); + + $this->config->deleteSystemValue(self::KEY); + } + + public function testOverwriteSetsTheValue(): void { + $this->overwriteSystemConfig(self::KEY, 'overwritten'); + + $this->assertSame('overwritten', $this->config->getSystemValue(self::KEY)); + } + + public function testRestoreRemovesAPreviouslyUnsetKey(): void { + $this->overwriteSystemConfig(self::KEY, 'overwritten'); + $this->restoreAllSystemConfig(); + + $this->assertSame('fallback', $this->config->getSystemValue(self::KEY, 'fallback')); + } + + public function testRestoreReturnsThePreviousValue(): void { + $this->config->setSystemValue(self::KEY, 'original'); + + $this->overwriteSystemConfig(self::KEY, 'overwritten'); + $this->restoreAllSystemConfig(); + + $this->assertSame('original', $this->config->getSystemValue(self::KEY)); + } + + public function testRestoreReturnsThePreviousValueAfterRepeatedOverwrites(): void { + $this->config->setSystemValue(self::KEY, 'original'); + + $this->overwriteSystemConfig(self::KEY, 'first'); + $this->overwriteSystemConfig(self::KEY, 'second'); + $this->restoreAllSystemConfig(); + + $this->assertSame('original', $this->config->getSystemValue(self::KEY)); + } + + public function testRestoreIsIdempotent(): void { + $this->config->setSystemValue(self::KEY, 'original'); + + $this->overwriteSystemConfig(self::KEY, 'overwritten'); + $this->restoreAllSystemConfig(); + $this->config->setSystemValue(self::KEY, 'set afterwards'); + $this->restoreAllSystemConfig(); + + $this->assertSame('set afterwards', $this->config->getSystemValue(self::KEY)); + } + + /** false is falsy but set: an isset-based check would wrongly delete the key. */ + public function testRestoreReturnsAPreviousFalseValue(): void { + $this->config->setSystemValue(self::KEY, false); + + $this->overwriteSystemConfig(self::KEY, true); + $this->restoreAllSystemConfig(); + + $this->assertFalse($this->config->getSystemValue(self::KEY, 'fallback')); + } +} From b202a6e1735b03471fdfa4e8404d487e7cc0c7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 16 Aug 2026 16:07:32 +0200 Subject: [PATCH 2/9] test: Disable appstore explicitly in AppsEnableTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- tests/Core/Command/Apps/AppsEnableTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Core/Command/Apps/AppsEnableTest.php b/tests/Core/Command/Apps/AppsEnableTest.php index ea1e6ff8cb42b..5136c9d311ce8 100644 --- a/tests/Core/Command/Apps/AppsEnableTest.php +++ b/tests/Core/Command/Apps/AppsEnableTest.php @@ -36,6 +36,10 @@ protected function setUp(): void { $this->commandTester = new CommandTester($command); + // Not every setup disables the app store, and enabling an unknown app pulls + // the whole catalogue (~30 MB, cached for an hour) to find it is not there. + $this->overwriteSystemConfig('appstoreenabled', false); + Server::get(IAppManager::class)->disableApp('admin_audit'); Server::get(IAppManager::class)->disableApp('comments'); } From 144befcbb8d50b461618f47336b345f43bfde25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 16 Aug 2026 16:08:17 +0200 Subject: [PATCH 3/9] test: Don't forcefully enable appstore in InstallerTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- tests/lib/InstallerTest.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/lib/InstallerTest.php b/tests/lib/InstallerTest.php index 5852a11437254..d547e92f969c0 100644 --- a/tests/lib/InstallerTest.php +++ b/tests/lib/InstallerTest.php @@ -30,7 +30,6 @@ #[\PHPUnit\Framework\Attributes\Group('DB')] class InstallerTest extends TestCase { private static $appid = 'testapp'; - private $appstore; private AppFetcher&MockObject $appFetcher; private IClientService&MockObject $clientService; private ITempManager&MockObject $tempManager; @@ -51,9 +50,6 @@ protected function setUp(): void { $this->appManager = $this->createMock(AppManager::class); $this->l10nFactory = $this->createMock(IFactory::class); - $config = Server::get(IConfig::class); - $this->appstore = $config->setSystemValue('appstoreenabled', true); - $config->setSystemValue('appstoreenabled', true); $installer = Server::get(Installer::class); $installer->removeApp(self::$appid); } @@ -75,7 +71,6 @@ protected function getInstaller() { protected function tearDown(): void { $installer = Server::get(Installer::class); $installer->removeApp(self::$appid); - Server::get(IConfig::class)->setSystemValue('appstoreenabled', $this->appstore); parent::tearDown(); } From 252c10d36e8631050fa87e522cb39c7db6ea4a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 16 Aug 2026 16:11:27 +0200 Subject: [PATCH 4/9] chore: Add junit-analyzer to check the phpunit performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- tests/junit-analyzer.php | 139 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/junit-analyzer.php diff --git a/tests/junit-analyzer.php b/tests/junit-analyzer.php new file mode 100644 index 0000000000000..0580d5e62e826 --- /dev/null +++ b/tests/junit-analyzer.php @@ -0,0 +1,139 @@ +open($file)) { + fwrite(STDERR, "cannot open $file\n"); + exit(1); +} + +/** @var list $tests in execution order */ +$tests = []; +while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->name !== 'testcase') { + continue; + } + $tests[] = [ + 'class' => $reader->getAttribute('class') ?: '(none)', + 'name' => (string)$reader->getAttribute('name'), + 'duration' => (float)$reader->getAttribute('time'), + ]; +} +$reader->close(); + +$testCount = count($tests); +if ($testCount === 0) { + $error = libxml_get_errors()[0] ?? null; + fwrite(STDERR, "no testcase elements found in $file" + . ($error !== null ? ': ' . trim($error->message) : '') . "\n"); + exit(1); +} + +$totalDuration = array_sum(array_column($tests, 'duration')); +printf("%d tests, %.1fs total (%.1f min)\n\n", $testCount, $totalDuration, $totalDuration / 60); + +if ($totalDuration <= 0) { + fwrite(STDERR, "no timing data to rank\n"); + exit(0); +} + +printf("Execution order, %d buckets (are later tests slower?)\n", BUCKETS); +echo " bucket tests sum(s) mean(ms) median(ms) max(s) cum%\n"; + +$durationSoFar = 0.0; +for ($bucket = 0; $bucket < BUCKETS; $bucket++) { + $from = (int)floor($testCount * $bucket / BUCKETS); + $to = (int)floor($testCount * ($bucket + 1) / BUCKETS); + + $durations = array_column(array_slice($tests, $from, $to - $from), 'duration'); + sort($durations); + $inBucket = count($durations); + $bucketDuration = array_sum($durations); + $durationSoFar += $bucketDuration; + + printf( + " %3d-%3d%% %7d %9.1f %10.2f %12.2f %9.2f %5.1f%%\n", + $bucket * (100 / BUCKETS), + ($bucket + 1) * (100 / BUCKETS), + $inBucket, + $bucketDuration, + $inBucket ? $bucketDuration / $inBucket * 1000 : 0, + $inBucket ? $durations[intdiv($inBucket, 2)] * 1000 : 0, + $inBucket ? max($durations) : 0, + $durationSoFar / $totalDuration * 100, + ); +} + +$slowestFirst = $tests; +usort($slowestFirst, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']); + +printf("\nTop %d slowest tests\n", $topCount); +foreach (array_slice($slowestFirst, 0, $topCount) as $test) { + printf(" %8.2fs %s::%s\n", $test['duration'], $test['class'], $test['name']); +} + +/** @var array $classes */ +$classes = []; +foreach ($tests as $test) { + $classes[$test['class']] ??= ['duration' => 0.0, 'tests' => 0]; + $classes[$test['class']]['duration'] += $test['duration']; + $classes[$test['class']]['tests']++; +} +uasort($classes, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']); + +printf("\nTop %d slowest classes (sum of its tests)\n", $topCount); +printf(" %9s %7s %10s %s\n", 'sum', 'tests', 'mean(ms)', 'class'); +foreach (array_slice($classes, 0, $topCount, true) as $class => $stats) { + printf( + " %8.2fs %7d %10.2f %s\n", + $stats['duration'], + $stats['tests'], + $stats['duration'] / $stats['tests'] * 1000, + $class, + ); +} + +// How top-heavy is the run? A handful of tests dominating reads very differently +// from the cost being spread evenly. +$durationSoFar = 0.0; +$testsInHalfTheRuntime = 0; +foreach ($slowestFirst as $test) { + $durationSoFar += $test['duration']; + $testsInHalfTheRuntime++; + if ($durationSoFar >= $totalDuration / 2) { + break; + } +} +printf( + "\nThe slowest %d tests (%.1f%% of tests) account for 50%% of the runtime.\n", + $testsInHalfTheRuntime, + $testsInHalfTheRuntime / $testCount * 100, +); From 0415ddf2118814182e386a1cbc1fe63ebb4a6b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 16 Aug 2026 16:12:16 +0200 Subject: [PATCH 5/9] chore: Update phpunit to 11.5.56 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- vendor-bin/behat/composer.lock | 18 +++++++++--------- vendor-bin/phpunit/composer.lock | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/vendor-bin/behat/composer.lock b/vendor-bin/behat/composer.lock index 6b9ec4eb29b18..fa645fd508c89 100644 --- a/vendor-bin/behat/composer.lock +++ b/vendor-bin/behat/composer.lock @@ -341,20 +341,20 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -389,15 +389,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", diff --git a/vendor-bin/phpunit/composer.lock b/vendor-bin/phpunit/composer.lock index dec1fe700927d..e24aaa161af5c 100644 --- a/vendor-bin/phpunit/composer.lock +++ b/vendor-bin/phpunit/composer.lock @@ -8,20 +8,20 @@ "packages": [ { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -56,15 +56,15 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nikic/php-parser", From a37baf123723b2e62beff4b124e3533d7b3547f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 16 Aug 2026 16:12:40 +0200 Subject: [PATCH 6/9] chore: Don't build sourcemaps on deprecation warning in phpunit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- tests/phpunit-autotest.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/phpunit-autotest.xml b/tests/phpunit-autotest.xml index 11dd78dc8095b..f9ea65179d11d 100644 --- a/tests/phpunit-autotest.xml +++ b/tests/phpunit-autotest.xml @@ -12,14 +12,16 @@ timeoutForMediumTests="300" timeoutForLargeTests="600" cacheDirectory=".phpunit.cache" - xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"> + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"> lib/ Core/ ../apps/ ../apps/user_ldap/tests/Integration - + + .. From 88531546b3de71e69f8a6c90f9dbcf9c5bc4f784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 16 Aug 2026 16:51:06 +0200 Subject: [PATCH 7/9] chore: Reduce hashing and encryption complexity on test run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- tests/preseed-config.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/preseed-config.php b/tests/preseed-config.php index 1fc98a4226ac0..4084e3a82b60d 100644 --- a/tests/preseed-config.php +++ b/tests/preseed-config.php @@ -9,6 +9,15 @@ */ $CONFIG = [ 'appstoreenabled' => false, + // Argon2 at its minimum cost. The suite creates users constantly and the + // default parameters make every password hash take ~100ms. + 'hashingMemoryCost' => 8, + 'hashingTimeCost' => 1, + 'hashingThreads' => 1, + // Only used when argon2 is unavailable and bcrypt is the fallback. + 'hashingCost' => 4, + // Smaller keys are faster to generate and the tests do not need 4096 bit. + 'openssl' => ['private_key_bits' => 2048], 'apps_paths' => [ [ 'path' => OC::$SERVERROOT . '/apps', From 744fd40d9844b8ecac6ca33b00088ffbd713db95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Tue, 18 Aug 2026 12:20:44 +0200 Subject: [PATCH 8/9] fix: Use array_chunk in junit-analyzer.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- tests/junit-analyzer.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/junit-analyzer.php b/tests/junit-analyzer.php index 0580d5e62e826..75d61df6afdb3 100644 --- a/tests/junit-analyzer.php +++ b/tests/junit-analyzer.php @@ -65,15 +65,15 @@ exit(0); } -printf("Execution order, %d buckets (are later tests slower?)\n", BUCKETS); +$chunks = array_chunk($tests, (int)ceil($testCount / BUCKETS)); +$buckets = count($chunks); + +printf("Execution order, %d buckets (are later tests slower?)\n", $buckets); echo " bucket tests sum(s) mean(ms) median(ms) max(s) cum%\n"; $durationSoFar = 0.0; -for ($bucket = 0; $bucket < BUCKETS; $bucket++) { - $from = (int)floor($testCount * $bucket / BUCKETS); - $to = (int)floor($testCount * ($bucket + 1) / BUCKETS); - - $durations = array_column(array_slice($tests, $from, $to - $from), 'duration'); +foreach ($chunks as $bucket => $chunk) { + $durations = array_column($chunk, 'duration'); sort($durations); $inBucket = count($durations); $bucketDuration = array_sum($durations); @@ -81,13 +81,13 @@ printf( " %3d-%3d%% %7d %9.1f %10.2f %12.2f %9.2f %5.1f%%\n", - $bucket * (100 / BUCKETS), - ($bucket + 1) * (100 / BUCKETS), + $bucket * 100 / $buckets, + ($bucket + 1) * 100 / $buckets, $inBucket, $bucketDuration, - $inBucket ? $bucketDuration / $inBucket * 1000 : 0, - $inBucket ? $durations[intdiv($inBucket, 2)] * 1000 : 0, - $inBucket ? max($durations) : 0, + $bucketDuration / $inBucket * 1000, + $durations[intdiv($inBucket, 2)] * 1000, + max($durations), $durationSoFar / $totalDuration * 100, ); } From c4bef9f901c19ebd630a0c5d6b95addf9817621a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Tue, 18 Aug 2026 12:40:11 +0200 Subject: [PATCH 9/9] feat: Log outgoing http requests in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: Log outgoing http requests in tests Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller [skip ci] --- .github/workflows/phpunit-sqlite.yml | 10 +++ tests/bootstrap.php | 6 ++ tests/http-analyzer.php | 80 ++++++++++++++++++++ tests/lib/HttpRequestLogger.php | 106 +++++++++++++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 tests/http-analyzer.php create mode 100644 tests/lib/HttpRequestLogger.php diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml index 9e4fcc871c76c..cad61a700c19a 100644 --- a/.github/workflows/phpunit-sqlite.yml +++ b/.github/workflows/phpunit-sqlite.yml @@ -130,6 +130,16 @@ jobs: with: flags: phpunit-sqlite + - name: Outgoing HTTP requests + if: always() + continue-on-error: true + run: | + { + echo '```' + php tests/http-analyzer.php http-requests.log 20 + echo '```' + } | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" + - name: Print logs if: always() run: | diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1fb54344d4978..fffffbeeca794 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -35,6 +35,12 @@ } } +// Recorded on CI by default; set TEST_LOG_HTTP to a path for a manual run. +$logHttp = getenv('TEST_LOG_HTTP') ?: (getenv('CI') ? OC::$SERVERROOT . '/http-requests.log' : ''); +if ($logHttp !== '') { + \Test\HttpRequestLogger::install($logHttp); +} + OC_Hook::clear(); set_include_path( diff --git a/tests/http-analyzer.php b/tests/http-analyzer.php new file mode 100644 index 0000000000000..7e010dcbd8a98 --- /dev/null +++ b/tests/http-analyzer.php @@ -0,0 +1,80 @@ + $requests */ +$requests = []; +foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $request = json_decode($line, true); + if (is_array($request)) { + $requests[] = $request; + } +} + +if ($requests === []) { + echo "No outgoing HTTP requests were recorded.\n"; + exit(0); +} + +$totalDuration = array_sum(array_column($requests, 'duration')); +printf("%d requests, %.1fs total\n", count($requests), $totalDuration); + +/** @param callable(array): string $key */ +function group(array $requests, callable $key): array { + $groups = []; + foreach ($requests as $request) { + $name = $key($request); + $groups[$name] ??= ['duration' => 0.0, 'requests' => 0]; + $groups[$name]['duration'] += $request['duration']; + $groups[$name]['requests']++; + } + uasort($groups, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']); + return $groups; +} + +foreach ([ + 'host' => static fn (array $r): string => parse_url($r['uri'], PHP_URL_HOST) ?: '(unparsed)', + 'test' => static fn (array $r): string => $r['test'], +] as $label => $key) { + $groups = group($requests, $key); + printf("\nRequests by %s\n", $label); + printf(" %9s %9s %s\n", 'sum', 'requests', $label); + foreach (array_slice($groups, 0, $topCount, true) as $name => $stats) { + printf(" %8.2fs %9d %s\n", $stats['duration'], $stats['requests'], $name); + } +} + +usort($requests, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']); +printf("\nTop %d slowest requests\n", $topCount); +foreach (array_slice($requests, 0, $topCount) as $request) { + printf( + " %8.2fs %-6s %-4s %s\n %s\n", + $request['duration'], + $request['method'], + $request['outcome'], + $request['uri'], + $request['test'], + ); +} diff --git a/tests/lib/HttpRequestLogger.php b/tests/lib/HttpRequestLogger.php new file mode 100644 index 0000000000000..644d95c4e2f09 --- /dev/null +++ b/tests/lib/HttpRequestLogger.php @@ -0,0 +1,106 @@ +registerService(IClientService::class, static fn (): IClientService + => new self(Server::get(ClientService::class), $logFile)); + + // A test running in a separate process re-runs the bootstrap, so only the + // process that owns the run may truncate. Children inherit the marker. + if (getenv(self::RUN_MARKER) === false) { + putenv(self::RUN_MARKER . '=' . getmypid()); + file_put_contents($logFile, ''); + } + } + + #[\Override] + public function newClient(?callable $handler = null): IClient { + $next = $handler ?? new CurlHandler(); + + return $this->inner->newClient( + function (RequestInterface $request, array $options) use ($next): PromiseInterface { + $start = microtime(true); + + return $next($request, $options)->then( + function (ResponseInterface $response) use ($request, $start): ResponseInterface { + $this->record($request, $start, (string)$response->getStatusCode()); + return $response; + }, + function (mixed $reason) use ($request, $start): PromiseInterface { + $this->record($request, $start, 'error'); + return Create::rejectionFor($reason); + }, + ); + }, + ); + } + + private function record(RequestInterface $request, float $start, string $outcome): void { + $line = json_encode([ + 'test' => self::currentTest(), + 'method' => $request->getMethod(), + 'uri' => (string)$request->getUri(), + 'outcome' => $outcome, + 'duration' => round(microtime(true) - $start, 6), + ], JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES); + + if ($line !== false) { + file_put_contents($this->logFile, $line . "\n", FILE_APPEND); + } + } + + /** PHPUnit exposes no global for the running test, so walk the stack for it. */ + private static function currentTest(): string { + $test = '(unknown)'; + + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $frame) { + $class = $frame['class'] ?? ''; + $function = $frame['function'] ?? ''; + + if ($class !== '' + && str_starts_with($function, 'test') + && is_subclass_of($class, \PHPUnit\Framework\TestCase::class) + ) { + $test = $class . '::' . $function; + } + } + + return $test; + } +}