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
2 changes: 1 addition & 1 deletion .coverage-baseline
Original file line number Diff line number Diff line change
@@ -1 +1 @@
49.47
80.00
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ LaunchPad supercharges the Nextcloud dashboard. Create multiple personalized wor

📚 **[Step-by-step tutorials](https://launchpad.conduction.nl/docs/category/tutorials)** — user + admin walkthroughs with screenshots, kept in sync with the live UI via the [journeydoc](https://github.com/ConductionNL/hydra/blob/development/openspec/architecture/adr-030-journeydoc-pattern.md) capture spec.

## Test coverage

<!-- coverage:start -->
Measured test coverage: 81.0% of statements (PHP 81.0% of 21,952 statements), at commit `b3fd2179` on 2026-11-11. The JavaScript frontend is not in this figure, because it has no coverage measurement yet. The figures come from [`coverage-summary.json`](coverage-summary.json); refresh them with `composer coverage:publish`.
<!-- coverage:end -->

## Screenshots

<table>
Expand Down
5 changes: 5 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
"@phpqa:full"
],
"test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always",
"coverage:publish": [
"@putenv XDEBUG_MODE=coverage",
"@php -d pcov.enabled=1 -d pcov.directory=lib vendor/bin/phpunit -c phpunit.xml --do-not-fail-on-risky --coverage-clover=coverage/clover.xml",
"@php scripts/coverage-publish.php"
],
"coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"",
"quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"",
"quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'",
Expand Down
47 changes: 47 additions & 0 deletions coverage-summary.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"total": {
"lines": {
"total": 21952,
"covered": 17784,
"skipped": 0,
"pct": 81.01
},
"statements": {
"total": 21952,
"covered": 17784,
"skipped": 0,
"pct": 81.01
},
"functions": {
"total": 2509,
"covered": 1727,
"skipped": 0,
"pct": 68.83
}
},
"suites": {
"phpunit": {
"statements": {
"total": 21952,
"covered": 17784,
"skipped": 0,
"pct": 81.01
},
"functions": {
"total": 2509,
"covered": 1727,
"skipped": 0,
"pct": 68.83
},
"lines": {
"total": 21952,
"covered": 17784,
"skipped": 0,
"pct": 81.01
}
}
},
"measuredAt": "2026-11-11",
"commit": "b3fd2179",
"generator": "scripts/coverage-publish.php"
}
139 changes: 139 additions & 0 deletions scripts/coverage-publish.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env php
<?php
/**
* Publishes the measured test coverage of this app.
*
* Reads the reports the test suites write and turns them into two things:
* - coverage-summary.json at the repository root, in the Istanbul
* json-summary shape ("total.lines.pct"), with the PHP and JavaScript
* suites listed separately;
* - the block between the coverage markers in README.md.
*
* It measures nothing itself. Run it through `composer coverage:publish`,
* which first runs PHPUnit and Vitest with coverage enabled.
*
* Usage:
* php scripts/coverage-publish.php [--clover=coverage/clover.xml]
* [--vitest=coverage-vitest/coverage-summary.json] [--readme=README.md]
* [--out=coverage-summary.json]
*
* Exit codes: 0 published, 2 missing or unreadable input.
*
* SPDX-FileCopyrightText: 2026 Conduction B.V.
* SPDX-License-Identifier: EUPL-1.2
*/

$root = dirname(__DIR__);
$opts = getopt('', ['clover::', 'vitest::', 'readme::', 'out::']);
$clover = $root . '/' . ($opts['clover'] ?? 'coverage/clover.xml');
$vitest = $root . '/' . ($opts['vitest'] ?? 'coverage-vitest/coverage-summary.json');
$readme = $root . '/' . ($opts['readme'] ?? 'README.md');
$out = $root . '/' . ($opts['out'] ?? 'coverage-summary.json');

/**
* Builds one Istanbul metric entry.
*
* @param int $total Number of measured items.
* @param int $covered Number of those that ran.
*
* @return array{total: int, covered: int, skipped: int, pct: float|int}
*/
function metric(int $total, int $covered): array
{
$pct = 0;
if ($total > 0) {
$pct = round(($covered / $total) * 100, 2);
}

return [
'total' => $total,
'covered' => $covered,
'skipped' => 0,
'pct' => $pct,
];
}

$suites = [];

if (is_file($clover) === false) {
fwrite(STDERR, "coverage-publish: $clover not found; run the PHPUnit suite with --coverage-clover first\n");
exit(2);
}
$xml = simplexml_load_file($clover);
if ($xml === false || isset($xml->project->metrics) === false) {
fwrite(STDERR, "coverage-publish: $clover is not a clover report\n");
exit(2);
}
$m = $xml->project->metrics;
$suites['phpunit'] = [
'statements' => metric((int) $m['statements'], (int) $m['coveredstatements']),
'functions' => metric((int) $m['methods'], (int) $m['coveredmethods']),
];
$suites['phpunit']['lines'] = $suites['phpunit']['statements'];

if (is_file($vitest) === true) {
$js = json_decode((string) file_get_contents($vitest), true);
if (is_array($js) === false || isset($js['total']['statements']) === false) {
fwrite(STDERR, "coverage-publish: $vitest is not a json-summary report\n");
exit(2);
}
foreach (['lines', 'statements', 'functions'] as $key) {
$suites['vitest'][$key] = metric((int) $js['total'][$key]['total'], (int) $js['total'][$key]['covered']);
}
}

$total = [];
foreach (['lines', 'statements', 'functions'] as $key) {
$sum = 0;
$hit = 0;
foreach ($suites as $suite) {
$sum += $suite[$key]['total'];
$hit += $suite[$key]['covered'];
}
$total[$key] = metric($sum, $hit);
}

$commit = trim((string) shell_exec('git -C ' . escapeshellarg($root) . ' rev-parse --short HEAD 2>/dev/null'));
$summary = [
'total' => $total,
'suites' => $suites,
'measuredAt' => gmdate('Y-m-d'),
'commit' => $commit,
'generator' => 'scripts/coverage-publish.php',
];
file_put_contents($out, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");

$fmt = static fn (float $pct): string => number_format($pct, 1) . '%';
$parts = ['PHP ' . $fmt($suites['phpunit']['statements']['pct']) . ' of ' . number_format($suites['phpunit']['statements']['total']) . ' statements'];
if (isset($suites['vitest']) === true) {
$parts[] = 'JavaScript ' . $fmt($suites['vitest']['statements']['pct'])
. ' of ' . number_format($suites['vitest']['statements']['total']) . ' statements';
}

$jsNote = 'The JavaScript frontend is not in this figure, because it has no coverage measurement yet. ';
if (isset($suites['vitest']) === true) {
$jsNote = '';
}
$block = "<!-- coverage:start -->\n"
. 'Measured test coverage: ' . $fmt($total['statements']['pct']) . ' of statements (' . implode(', ', $parts) . '), '
. 'at commit `' . $commit . '` on ' . $summary['measuredAt'] . '. '
. $jsNote
. "The figures come from [`coverage-summary.json`](coverage-summary.json); refresh them with `composer coverage:publish`.\n"
. '<!-- coverage:end -->';

if (is_file($readme) === true) {
$text = (string) file_get_contents($readme);
$pattern = '/<!-- coverage:start -->.*?<!-- coverage:end -->/s';
if (preg_match($pattern, $text) === 1) {
$text = preg_replace($pattern, $block, $text);
file_put_contents($readme, $text);
} else {
fwrite(
STDERR,
"coverage-publish: no coverage markers in README.md; add <!-- coverage:start --><!-- coverage:end --> where the line belongs\n"
);
}
}

echo 'Coverage ' . $fmt($total['statements']['pct']) . ' (' . implode(', ', $parts) . ")\n";
exit(0);
Loading