Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .github/workflows/phpunit-sqlite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,26 @@ jobs:
- name: PHPUnit database tests
run: composer run test:db -- --log-junit junit.xml

- name: Slowest tests
if: always()
continue-on-error: true
run: |
{
echo '```'
php tests/junit-analyzer.php junit.xml 30
echo '```'
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"

- 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 @@ -13,9 +13,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 @@ -46,7 +44,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 @@ -57,11 +55,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
37 changes: 20 additions & 17 deletions apps/sharing/tests/Controller/ApiV1ControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,26 @@ public function testDefaultShareAccessContext(): void {
$user = Server::get(IUserManager::class)->createUser('user', 'password');
$this->assertNotFalse($user);

self::loginAsUser($user->getUID());

$controller = new ApiV1Controller(
'',
Server::get(IRequest::class),
Server::get(IUserSession::class),
Server::get(ISharingManager::class),
$this->registry,
Server::get(IFactory::class),
Server::get(IURLGenerator::class),
Server::get(IUserManager::class),
Server::get(IDBConnection::class),
);

$this->assertEquals(new ShareAccessContext($user), $controller->accessContext);

self::logout();
try {
self::loginAsUser($user->getUID());

$controller = new ApiV1Controller(
'',
Server::get(IRequest::class),
Server::get(IUserSession::class),
Server::get(ISharingManager::class),
$this->registry,
Server::get(IFactory::class),
Server::get(IURLGenerator::class),
Server::get(IUserManager::class),
Server::get(IDBConnection::class),
);

$this->assertEquals(new ShareAccessContext($user), $controller->accessContext);
} finally {
self::logout();
$user->delete();
}
}

/**
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",
Comment thread
come-nc marked this conversation as resolved.
$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,
);
Loading
Loading