Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/phpunit-sqlite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
9 changes: 1 addition & 8 deletions apps/settings/tests/UserMigration/AccountMigratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions tests/Core/Command/Apps/AppsEnableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
6 changes: 6 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
80 changes: 80 additions & 0 deletions tests/http-analyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/**
* Rank the outgoing HTTP requests recorded by Test\HttpRequestLogger.
*
* Usage: TEST_LOG_HTTP=http-requests.log phpunit ...
* php tests/http-analyzer.php [http-requests.log] [topN]
*
* Tests should not reach the network: anything listed needs either a mocked
* IClientService or a config value that prevents the request.
*/

$file = $argv[1] ?? 'http-requests.log';
$topCount = (int)($argv[2] ?? 20);

if (!is_readable($file)) {
fwrite(STDERR, "cannot read $file\n");
exit(1);
}

/** @var list<array{test: string, method: string, uri: string, outcome: string, duration: float}> $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'],
);
}
139 changes: 139 additions & 0 deletions tests/junit-analyzer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/**
* Analyse a PHPUnit JUnit log: slowest tests, slowest classes, and whether the
* suite degrades over execution order.
*
* Usage: php tests/junit-analyzer.php [junit.xml] [topN]
*
* The bucket table splits the run into equal chunks of execution order. A rising
* median means the suite itself degrades (accumulated DB rows, leaked memory);
* a flat median with rising sum/max means a few slow tests happen to run late.
*/

const BUCKETS = 10;

$file = $argv[1] ?? 'junit.xml';
$topCount = (int)($argv[2] ?? 30);

if (!is_readable($file)) {
fwrite(STDERR, "cannot read $file\n");
exit(1);
}

libxml_use_internal_errors(true);

$reader = new XMLReader();
if (!$reader->open($file)) {
fwrite(STDERR, "cannot open $file\n");
exit(1);
}

/** @var list<array{class: string, name: string, duration: float}> $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<string, array{duration: float, tests: int}> $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,
);
106 changes: 106 additions & 0 deletions tests/lib/HttpRequestLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace Test;

use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\PromiseInterface;
use OC\Http\Client\ClientService;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Server;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
* Records the outgoing HTTP requests of a test run for tests/http-analyzer.php.
* Enable by setting TEST_LOG_HTTP to the log file path.
*
* Covers IClientService only: a library building its own client or calling curl
* directly stays invisible, as does any test that mocks IClientService.
*/
final class HttpRequestLogger implements IClientService {
private const RUN_MARKER = 'TEST_LOG_HTTP_RUN';

private function __construct(
private IClientService $inner,
private string $logFile,
) {
}

public static function install(string $logFile): void {
// Resolving ClientService rather than IClientService avoids recursing into
// this decorator, and keeps the service lazy.
/** @psalm-suppress InternalMethod */
\OC::$server->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;
}
}
Loading