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/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/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'); } 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/junit-analyzer.php b/tests/junit-analyzer.php new file mode 100644 index 0000000000000..75d61df6afdb3 --- /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); +} + +$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; +foreach ($chunks as $bucket => $chunk) { + $durations = array_column($chunk, '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, + $bucketDuration / $inBucket * 1000, + $durations[intdiv($inBucket, 2)] * 1000, + max($durations), + $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, +); 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; + } +} 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(); } 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')); + } +} 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 - + + .. 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', 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",