16 private const ACTIVE_RUN_GRACE_SECONDS = 600;
18 private string $baseDir;
19 private string $logDir;
20 private ?array $catalogCache =
null;
21 private ?
string $phpCliBinaryCache =
null;
22 private int $maxOutputBytes = 131072;
24 public function __construct(?
string $baseDir =
null, ?
string $logDir =
null)
26 $root = $baseDir ?: dirname(__DIR__, 4);
27 $real = realpath(
$root);
28 $this->
baseDir = rtrim(str_replace(
'\\',
'/', $real !==
false ? $real :
$root),
'/');
29 $this->
logDir = $logDir ?: $this->
baseDir .
'/files/sys/selftest';
30 $this->ensureDirectory($this->
logDir);
35 return $this->baseDir;
48 if ($this->catalogCache ===
null) {
49 $tests = $this->builtinCatalog();
50 $tests = array_merge($tests, $this->discoverFileTests());
51 usort($tests,
static function (array $a, array $b):
int {
52 return [$a[
'category'], $a[
'name']] <=> [$b[
'category'], $b[
'name']];
54 $this->catalogCache = $tests;
59 return $this->catalogCache;
62 return array_values(array_filter(
64 static fn(array $test):
bool => ($test[
'tier'] ??
'full') ===
'quick'
71 foreach ($this->
catalog(
'full') as $test) {
72 $out[(string)$test[
'id']] = $test;
84 if ($testIds === array()) {
89 foreach ($testIds as $id) {
91 if (isset(
$catalog[$id]) && !in_array($id, $selected,
true)) {
95 if ($selected === array()) {
96 throw new \RuntimeException(
'Keine gueltigen Tests ausgewaehlt.');
99 $id = gmdate(
'Ymd-His') .
'-' . bin2hex(random_bytes(5));
102 'version' => self::LOG_VERSION,
104 'mode' => in_array(
$mode, array(
'complete',
'selection',
'single',
'cli'),
true) ?
$mode :
'complete',
106 'status' =>
'running',
107 'started_at' =>
$now,
108 'finished_at' =>
null,
110 'test_ids' => $selected,
111 'current_test_id' =>
null,
112 'results' => array(),
113 'totals' => $this->totals(count($selected)),
114 'environment' => array(
115 'php' => PHP_VERSION,
116 'os' => PHP_OS_FAMILY,
118 'host' => (
string)(
$_SERVER[
'HTTP_HOST'] ?? gethostname() ?:
''),
121 $this->writeRun(
$run);
132 throw new \RuntimeException(
'Testlauf wurde nicht gefunden.');
134 if ((
$run[
'status'] ??
'') !==
'running') {
135 throw new \RuntimeException(
'Testlauf ist bereits abgeschlossen.');
137 if (!in_array(
$testId,
$run[
'test_ids'] ?? array(),
true)) {
138 throw new \RuntimeException(
'Test gehoert nicht zu diesem Lauf.');
141 foreach (
$run[
'results'] ?? array() as $existing) {
142 if (($existing[
'test_id'] ??
'') ===
$testId) {
149 throw new \RuntimeException(
'Test ist nicht mehr im Katalog vorhanden.');
153 $this->writeRun(
$run);
158 $run[
'current_test_id'] =
null;
159 $run[
'totals'] = $this->calculateTotals(
$run);
160 $run[
'duration_ms'] = $this->durationSince((
string)
$run[
'started_at']);
161 $this->writeRun(
$run);
172 if (!
$run || (
$run[
'status'] ??
'') !==
'running') {
173 throw new \RuntimeException(
'Aktiver Testlauf wurde nicht gefunden.');
175 if (!in_array(
$testId,
$run[
'test_ids'] ?? array(),
true)) {
176 throw new \RuntimeException(
'Test gehoert nicht zu diesem Lauf.');
178 foreach (
$run[
'results'] ?? array() as $existing) {
179 if (($existing[
'test_id'] ??
'') ===
$testId) {
185 if (!is_array($test) || ($test[
'type'] ??
'') !==
'js') {
186 throw new \RuntimeException(
'Nur JavaScript-Tests duerfen Browserergebnisse melden.');
189 $status = ($payload[
'status'] ??
'') ===
'passed' ?
'passed' :
'failed';
190 $output = $this->cleanOutput((
string)($payload[
'output'] ??
''));
191 $duration = max(0, min(3600000, (
int)($payload[
'duration_ms'] ?? 0)));
194 'test_id' => (
string)$test[
'id'],
195 'name' => (
string)$test[
'name'],
196 'category' => (
string)$test[
'category'],
198 'execution' =>
'browser',
200 'exit_code' => $status ===
'passed' ? 0 : 1,
201 'timed_out' => !empty($payload[
'timed_out']),
202 'duration_ms' => $duration,
203 'started_at' => (
string)($payload[
'started_at'] ??
$now),
204 'finished_at' =>
$now,
205 'summary' => $this->resultSummary($status, $output,
''),
207 'relative_path' => (
string)$test[
'relative_path'],
210 $run[
'current_test_id'] =
null;
211 $run[
'totals'] = $this->calculateTotals(
$run);
212 $run[
'duration_ms'] = $this->durationSince((
string)
$run[
'started_at']);
213 $this->writeRun(
$run);
217 public function finishRun(
string $runId,
bool $aborted =
false): array
221 throw new \RuntimeException(
'Testlauf wurde nicht gefunden.');
223 $run[
'current_test_id'] =
null;
224 $run[
'totals'] = $this->calculateTotals(
$run);
225 $run[
'finished_at'] = $this->now();
226 $run[
'duration_ms'] = $this->durationBetween((
string)
$run[
'started_at'], (
string)
$run[
'finished_at']);
227 if ($aborted || (
int)
$run[
'totals'][
'completed'] < (
int)
$run[
'totals'][
'total']) {
228 $run[
'status'] =
'aborted';
229 } elseif ((
int)
$run[
'totals'][
'failed'] > 0) {
230 $run[
'status'] =
'failed';
232 $run[
'status'] =
'passed';
234 $this->writeRun(
$run);
241 public function runProfile(
string $profile =
'full', array $testIds = array(), ?callable $onResult =
null): array
244 foreach (
$run[
'test_ids'] as $id) {
253 public function loadRun(
string $runId): ?array
255 if (!$this->validRunId($runId)) {
258 $path = $this->runPath($runId);
259 if (!is_file($path)) {
262 $handle = @fopen($path,
'rb');
266 flock($handle, LOCK_SH);
267 $json = stream_get_contents($handle);
268 flock($handle, LOCK_UN);
270 $data = json_decode((
string)
$json,
true);
271 return is_array($data) ? $data :
null;
274 public function history(
int $limit = 20): array
277 usort(
$files,
static fn(
string $a,
string $b):
int => (filemtime($b) ?: 0) <=> (filemtime($a) ?: 0));
279 foreach (array_slice(
$files, 0, max(1, min(100, $limit))) as
$file) {
284 $status = (string)(
$run[
'status'] ??
'');
285 $modified = (int)(filemtime(
$file) ?: 0);
286 $run[
'display_status'] = $status ===
'running' && $modified < time() - self::ACTIVE_RUN_GRACE_SECONDS
289 unset(
$run[
'results'],
$run[
'test_ids'],
$run[
'environment']);
297 $path = $this->validRunId($runId) ? $this->runPath($runId) :
'';
298 return $path !==
'' && is_file($path) ? $path :
null;
301 private function builtinCatalog(): array
304 $this->builtin(
'environment',
'Laufzeitumgebung',
'Prueft PHP-Version, Erweiterungen und Prozessausfuehrung.',
'quick', 15),
305 $this->builtin(
'filesystem',
'Dateisystem und Schutzregeln',
'Prueft notwendige Verzeichnisse, Schreibrechte und private Dateischutzregeln.',
'quick', 15),
306 $this->builtin(
'modules',
'Modul-Einstiegspunkte',
'Prueft Hauptklassen, Namespaces und Konfiguration aller Module.',
'quick', 30),
307 $this->builtin(
'conflict_markers',
'Ungelöste Konfliktmarker',
'Sucht nach nicht aufgeloesten Git-Konflikten in Quell- und Konfigurationsdateien.',
'quick', 30),
308 $this->builtin(
'page_cache',
'Gastseiten-Cache Integrität',
'Prueft Generationen, veraltete HTML-Dateien und liegengebliebene Schreibdateien.',
'quick', 30),
309 $this->builtin(
'php_syntax',
'PHP-Syntax Gesamtsystem',
'Fuehrt php -l fuer alle eigenen PHP-Dateien aus.',
'full', 300),
310 $this->builtin(
'js_syntax',
'JavaScript-Syntax Gesamtsystem',
'Prueft alle eigenen JavaScript-Dateien mit Node.js; ohne Node.js laufen Browser-Tests weiterhin im Web-Dashboard.',
'full', 300),
311 $this->builtin(
'composer_validate',
'Composer-Konfiguration',
'Validiert dbx/composer.json im Strict-Modus.',
'full', 60),
312 $this->builtin(
'composer_audit',
'Composer-Sicherheitsaudit',
'Prueft produktive Composer-Abhaengigkeiten auf bekannte Schwachstellen.',
'full', 120),
316 private function builtin(
string $key,
string $name,
string $description,
string $tier,
int $timeout): array
319 'id' =>
'system.' . $key,
321 'description' => $description,
323 'category' =>
'System',
325 'timeout' => $timeout,
326 'relative_path' =>
'',
331 private function discoverFileTests(): array
335 if (!is_dir(
$root)) {
338 $iterator = new \RecursiveIteratorIterator(
339 new \RecursiveDirectoryIterator(
$root, \FilesystemIterator::SKIP_DOTS)
342 if (!
$file->isFile()) {
345 $path = str_replace(
'\\',
'/',
$file->getPathname());
346 if (!str_contains($path,
'/tests/')) {
349 $name =
$file->getFilename();
350 $type = str_ends_with($name,
'_test.php') ?
'php' : (str_ends_with($name,
'_test.js') ?
'js' :
'');
354 $relative = ltrim(substr($path, strlen($this->
baseDir)),
'/');
355 $module = $this->moduleFromPath($relative);
356 $tier = preg_match(
'/(?:integration|roundtrip|performance|migration|install|update|doxygen|maintenance)/i', $name)
360 'id' => $type .
'.' . substr(hash(
'sha256', $relative), 0, 20),
361 'name' => preg_replace(
'/_test\.(?:php|js)$/i',
'', $name),
362 'description' => $relative,
364 'execution' => $type ===
'js' ?
'browser' :
'server',
367 'timeout' => $tier ===
'full' ? 180 : 90,
368 'relative_path' => $relative,
375 private function moduleFromPath(
string $relative): string
377 if (preg_match(
'~^dbx/modules/([^/]+)/~', $relative, $match)) {
378 return (
string)$match[1];
383 private function executeTest(array $test): array
385 $started = microtime(
true);
386 $startedAt = $this->now();
387 if (($test[
'type'] ??
'') ===
'system') {
388 $raw = $this->executeBuiltin((
string)$test[
'handler'], (
int)$test[
'timeout']);
390 $raw = $this->executeFileTest($test);
392 $finishedAt = $this->now();
393 $output = $this->cleanOutput((
string)($raw[
'output'] ??
''));
394 $status = (string)($raw[
'status'] ?? ((
int)($raw[
'exit_code'] ?? 1) === 0 ?
'passed' :
'failed'));
395 if (!in_array($status, array(
'passed',
'failed',
'skipped'),
true)) {
399 'test_id' => (
string)$test[
'id'],
400 'name' => (
string)$test[
'name'],
401 'category' => (
string)$test[
'category'],
402 'type' => (
string)$test[
'type'],
404 'exit_code' => isset($raw[
'exit_code']) ? (
int)$raw[
'exit_code'] :
null,
405 'timed_out' => !empty($raw[
'timed_out']),
406 'duration_ms' => (
int)round((microtime(
true) - $started) * 1000),
407 'started_at' => $startedAt,
408 'finished_at' => $finishedAt,
409 'summary' => $this->resultSummary($status, $output, (
string)($raw[
'summary'] ??
'')),
411 'relative_path' => (
string)($test[
'relative_path'] ??
''),
415 private function executeFileTest(array $test): array
417 $relative = (string)($test[
'relative_path'] ??
'');
418 $path = $this->safeProjectFile($relative);
419 if ($path ===
null || !is_file($path)) {
420 return array(
'status' =>
'failed',
'exit_code' => 2,
'output' =>
'Testdatei fehlt: ' . $relative);
422 $type = (string)($test[
'type'] ??
'');
423 if ($type ===
'php') {
424 $php = $this->resolvePhpCliBinary();
427 'status' =>
'failed',
429 'output' =>
'PHP-CLI wurde nicht gefunden. Bitte DBX_PHP_BINARY auf den PHP-CLI-Interpreter setzen.',
432 $command = array($php, $path);
433 } elseif ($type ===
'js') {
434 $node = $this->resolveExecutable(
'node');
435 if ($node ===
null) {
437 'status' =>
'skipped',
439 'output' =>
'Node.js ist nicht installiert. Dieser JavaScript-Test wird im Web-Dashboard vom Browser ausgefuehrt.',
442 $command = array($node, $path);
444 return array(
'status' =>
'failed',
'exit_code' => 2,
'output' =>
'Unbekannter Testtyp.');
446 return $this->runProcess($command, $this->
baseDir, (
int)($test[
'timeout'] ?? 90));
449 private function executeBuiltin(
string $handler,
int $timeout): array
451 return match ($handler) {
452 'environment' => $this->checkEnvironment(),
453 'filesystem' => $this->checkFilesystem(),
454 'modules' => $this->checkModules(),
455 'conflict_markers' => $this->checkConflictMarkers(),
456 'page_cache' => $this->checkPageCache(),
457 'php_syntax' => $this->checkPhpSyntax($timeout),
458 'js_syntax' => $this->checkJavaScriptSyntax($timeout),
459 'composer_validate' => $this->runComposer(array(
'validate',
'--strict',
'--no-check-publish'), $timeout),
460 'composer_audit' => $this->runComposer(array(
'audit',
'--no-dev'), $timeout),
461 default => array(
'status' =>
'failed',
'exit_code' => 2,
'output' =>
'Unbekannter Systemtest: ' . $handler),
465 private function checkEnvironment(): array
468 if (version_compare(PHP_VERSION,
'8.2.0',
'<')) {
469 $errors[] =
'PHP 8.2 oder neuer ist erforderlich; aktiv: ' . PHP_VERSION;
471 foreach (array(
'json',
'session',
'pdo',
'pdo_sqlite',
'openssl',
'fileinfo') as $extension) {
472 if (!extension_loaded($extension)) {
473 $errors[] =
'PHP-Erweiterung fehlt: ' . $extension;
476 if (!function_exists(
'proc_open')) {
477 $errors[] =
'proc_open ist deaktiviert; isolierte Tests sind nicht moeglich.';
479 $phpCli = $this->resolvePhpCliBinary();
481 $errors[] =
'PHP-CLI wurde nicht gefunden; serverseitige Einzeltests sind nicht moeglich.';
483 $node = $this->resolveExecutable(
'node');
485 'PHP ' . PHP_VERSION .
' (' . PHP_SAPI .
')',
486 'PHP-CLI: ' . (
$phpCli ??
'nicht gefunden'),
487 'Betriebssystem: ' . PHP_OS_FAMILY,
488 'Speicherlimit: ' . (
string)ini_get(
'memory_limit'),
489 'Node.js: ' . ($node !==
null ?
'verfuegbar (optional)' :
'nicht installiert; Browser-Adapter aktiv'),
491 return $this->checkResult(
$errors, $lines);
494 private function checkFilesystem(): array
498 foreach (array(
'dbx/modules',
'files/sys',
'files/temp') as $relative) {
499 $path = $this->
baseDir .
'/' . $relative;
500 if (!is_dir($path)) {
501 $parent = dirname($path);
502 if (str_starts_with($relative,
'files/')
504 && is_writable($parent)
506 $lines[] = $relative .
': wird bei Bedarf in einem schreibbaren Laufzeitverzeichnis angelegt';
509 $errors[] =
'Verzeichnis fehlt: ' . $relative;
512 $lines[] = $relative .
': vorhanden' . (is_writable($path) ?
', schreibbar' :
', nicht schreibbar');
513 if (str_starts_with($relative,
'files/') && !is_writable($path)) {
514 $errors[] =
'Laufzeitverzeichnis ist nicht schreibbar: ' . $relative;
519 $errors[] =
'.htaccess schuetzt private Laufzeit- oder Datenbankdateien nicht vollstaendig.';
521 $lines[] =
'.htaccess: private Dateien werden gesperrt';
523 return $this->checkResult(
$errors, $lines);
526 private function checkModules(): array
530 foreach (glob($this->
baseDir .
'/dbx/modules/*', GLOB_ONLYDIR) ?: array() as
$dir) {
534 if (!is_file($entry)) {
541 $source = (string)@file_get_contents($entry);
542 if (!preg_match(
'/namespace\s+dbx\\\\' . preg_quote(
$module,
'/') .
'\s*;/',
$source)) {
545 if (!preg_match(
'/class\s+' . preg_quote(
$module,
'/') .
'\b/i',
$source)) {
550 return $this->checkResult(
$errors, array(
$count .
' Module geprueft.'));
553 private function checkConflictMarkers(): array
557 foreach ($this->projectFiles(array(
'php',
'js',
'css',
'htm',
'html',
'json',
'md',
'cfx',
'cfg')) as
$file) {
559 $handle = @fopen(
$file,
'rb');
564 while (($line = fgets($handle)) !==
false) {
566 if (preg_match(
'/^(<<<<<<< |=======\s*$|>>>>>>> )/', rtrim($line,
"\r\n"))) {
575 return $this->checkResult(
$errors, array(
$checked .
' Dateien geprueft.'));
578 private function checkPageCache(): array
580 $dir = $this->
baseDir .
'/files/cache/content/full-page';
582 return $this->checkResult(array(), array(
'Noch kein Gastseiten-Cache vorhanden.'));
584 $generation = trim((
string)@file_get_contents(
$dir .
'/.generation'));
586 if (!preg_match(
'/^[a-f0-9]{24}$/', $generation)) {
587 $errors[] =
'Cache-Generation fehlt oder ist ungueltig.';
591 if ($generation !==
'' && !str_ends_with(strtolower(basename(
$file)),
'_' . $generation .
'_v3.htm')) {
592 $errors[] =
'Veraltete Cache-Generation: ' . basename(
$file);
593 if (count(
$errors) >= 25)
break;
596 foreach (glob(
$dir .
'/*.tmp-*') ?: array() as $temporary) {
597 if ((
int)@filemtime($temporary) < time() - 300) {
598 $errors[] =
'Verwaiste temporaere Cache-Datei: ' . basename($temporary);
599 if (count(
$errors) >= 25)
break;
602 return $this->checkResult(
$errors, array(count(
$files) .
' aktuelle Gastseiten in genau einer Generation.'));
605 private function checkPhpSyntax(
int $timeout): array
607 $php = $this->resolvePhpCliBinary();
610 'status' =>
'failed',
612 'output' =>
'PHP-CLI wurde nicht gefunden; die Syntaxpruefung kann nicht ausgefuehrt werden.',
617 $deadline = microtime(
true) + max(30, $timeout);
618 foreach ($this->projectFiles(array(
'php')) as
$file) {
621 if (str_contains(str_replace(
'\\',
'/',
$file),
'/tpl/php/')) {
624 if (microtime(
true) >= $deadline) {
626 'status' =>
'failed',
629 'output' =>
'PHP-Syntaxpruefung nach ' .
$checked .
' Dateien abgebrochen: Gesamtzeit ueberschritten.',
636 if ((
int)(
$result[
'exit_code'] ?? 1) !== 0) {
643 return $this->checkResult(
$errors, array(
$checked .
' PHP-Dateien ohne Syntaxfehler.'));
646 private function checkJavaScriptSyntax(
int $timeout): array
648 $node = $this->resolveExecutable(
'node');
649 if ($node ===
null) {
651 'status' =>
'skipped',
653 'output' =>
'Node.js ist optional und auf diesem Server nicht installiert. Browserfaehige JavaScript-Tests laufen im Web-Dashboard.',
658 $deadline = microtime(
true) + max(30, $timeout);
659 foreach ($this->projectFiles(array(
'js')) as
$file) {
660 if (microtime(
true) >= $deadline) {
662 'status' =>
'failed',
665 'output' =>
'JavaScript-Syntaxpruefung nach ' .
$checked .
' Dateien abgebrochen: Gesamtzeit ueberschritten.',
670 if ((
int)(
$result[
'exit_code'] ?? 1) !== 0) {
677 return $this->checkResult(
$errors, array(
$checked .
' JavaScript-Dateien ohne Syntaxfehler.'));
680 private function runComposer(array $arguments,
int $timeout): array
682 $composer = $this->resolveComposerCommand();
683 if ($composer ===
null) {
684 return array(
'status' =>
'failed',
'exit_code' => 127,
'output' =>
'Composer wurde nicht gefunden.');
686 return $this->runProcess(array_merge($composer, $arguments), $this->
baseDir .
'/dbx', $timeout);
689 private function resolveComposerCommand(): ?array
691 $candidate = $this->resolveExecutable(
'composer');
692 if ($candidate ===
null) {
695 if (preg_match(
'/\.bat$/i', $candidate)) {
696 $phar = dirname($candidate) . DIRECTORY_SEPARATOR .
'composer.phar';
697 if (is_file($phar)) {
698 $php = $this->resolvePhpCliBinary();
699 return $php !==
null ? array($php, $phar) : null;
702 return array($candidate);
710 private function resolvePhpCliBinary(): ?string
712 if ($this->phpCliBinaryCache !==
null) {
713 return $this->phpCliBinaryCache !==
'' ? $this->phpCliBinaryCache :
null;
716 $executable = PHP_OS_FAMILY ===
'Windows' ?
'php.exe' :
'php';
718 $configured = trim((
string)getenv(
'DBX_PHP_BINARY'),
" \t\n\r\0\x0B\"");
719 if ($configured !==
'') {
722 if (preg_match(
'/^php(?:\.exe)?$/i', basename(PHP_BINARY))) {
725 $ini = php_ini_loaded_file();
726 if (is_string($ini) && $ini !==
'') {
727 $candidates[] = dirname($ini) . DIRECTORY_SEPARATOR . $executable;
729 if (defined(
'PHP_BINDIR')) {
730 $candidates[] = rtrim((
string)PHP_BINDIR,
'/\\') . DIRECTORY_SEPARATOR . $executable;
734 $directory = $this->baseDir;
735 for ($level = 0; $level < 4; $level++) {
736 $candidates[] = $directory . DIRECTORY_SEPARATOR .
'php' . DIRECTORY_SEPARATOR . $executable;
737 $parent = dirname($directory);
738 if ($parent === $directory) {
741 $directory = $parent;
743 $fromPath = $this->resolveExecutable(
'php');
744 if ($fromPath !==
null) {
748 foreach (array_unique(
$candidates) as $candidate) {
749 if (is_file($candidate) && (PHP_OS_FAMILY ===
'Windows' || is_executable($candidate))) {
750 $this->phpCliBinaryCache = $candidate;
754 $this->phpCliBinaryCache =
'';
758 private function runProcess(array $command,
string $cwd,
int $timeout): array
760 if (!function_exists(
'proc_open')) {
761 return array(
'status' =>
'failed',
'exit_code' => 127,
'output' =>
'proc_open ist nicht verfuegbar.');
763 $descriptors = array(
764 0 => array(
'pipe',
'r'),
765 1 => array(
'pipe',
'w'),
766 2 => array(
'pipe',
'w'),
769 $environment = getenv();
770 if (!is_array($environment)) {
771 $environment = array();
773 $environment[
'DBX_SELFTEST'] =
'1';
774 unset($environment[
'DBX_SELFTEST_ALLOW_SYSMSG']);
775 $process = @proc_open($command, $descriptors, $pipes, $cwd, $environment, array(
'bypass_shell' =>
true));
777 return array(
'status' =>
'failed',
'exit_code' => 127,
'output' =>
'Prozess konnte nicht gestartet werden.');
780 stream_set_blocking($pipes[1],
false);
781 stream_set_blocking($pipes[2],
false);
782 $started = microtime(
true);
787 $this->appendOutput($output, (
string)stream_get_contents($pipes[1]));
788 $this->appendOutput($output, (
string)stream_get_contents($pipes[2]));
789 $status = proc_get_status(
$process);
790 if (!$status[
'running']) {
791 $lastExit = (int)$status[
'exitcode'];
794 if ((microtime(
true) - $started) > max(1, $timeout)) {
798 $status = proc_get_status(
$process);
799 if ($status[
'running']) {
806 $this->appendOutput($output, (
string)stream_get_contents($pipes[1]));
807 $this->appendOutput($output, (
string)stream_get_contents($pipes[2]));
811 $exitCode = $timedOut ? 124 : ($lastExit >= 0 ? $lastExit : (int)$closedExit);
813 'status' => $exitCode === 0 ?
'passed' :
'failed',
814 'exit_code' => $exitCode,
815 'timed_out' => $timedOut,
820 private function appendOutput(
string &$output,
string $chunk): void
826 if (strlen($output) > $this->maxOutputBytes) {
827 $half = intdiv($this->maxOutputBytes, 2);
828 $output = substr($output, 0, $half)
829 .
"\n... Ausgabe gekuerzt ...\n"
830 . substr($output, -$half);
834 private function projectFiles(array
$extensions): array
838 $iterator = new \RecursiveIteratorIterator(
839 new \RecursiveCallbackFilterIterator(
840 new \RecursiveDirectoryIterator($this->
baseDir, \FilesystemIterator::SKIP_DOTS),
841 function (\SplFileInfo $current):
bool {
842 if (!$current->isDir()) {
845 $path = str_replace(
'\\',
'/', $current->getPathname());
846 foreach (array(
'/.git',
'/dbx/vendor',
'/files/',
'/output/',
'/reference/',
'/.playwright-cli') as $excluded) {
847 if (str_contains($path, $excluded)) {
857 $files[] = str_replace(
'\\',
'/',
$file->getPathname());
864 private function resolveExecutable(
string $name): ?string
866 $pathValue = (string)getenv(
'PATH');
868 ? array(
'.exe',
'.bat',
'.cmd',
'')
870 foreach (explode(PATH_SEPARATOR, $pathValue) as $directory) {
871 $directory = trim($directory,
" \t\n\r\0\x0B\"");
872 if ($directory ===
'') {
876 $candidate = rtrim($directory,
'/\\') . DIRECTORY_SEPARATOR . $name . $extension;
877 if (is_file($candidate) && (PHP_OS_FAMILY ===
'Windows' || is_executable($candidate))) {
885 private function checkResult(array
$errors, array $successLines): array
889 'status' =>
'failed',
891 'output' =>
"FAIL\n- " . implode(
"\n- ",
$errors),
895 'status' =>
'passed',
897 'output' =>
"PASS\n" . implode(
"\n", $successLines),
901 private function calculateTotals(array
$run): array
903 $totals = $this->totals(count(
$run[
'test_ids'] ?? array()));
905 $status = (string)(
$result[
'status'] ??
'failed');
906 $totals[
'completed']++;
907 if (isset($totals[$status])) {
912 $totals[
'duration_ms'] += (int)(
$result[
'duration_ms'] ?? 0);
914 $totals[
'pending'] = max(0, $totals[
'total'] - $totals[
'completed']);
918 private function totals(
int $total): array
931 private function resultSummary(
string $status,
string $output,
string $explicit): string
933 if ($explicit !==
'') {
936 $lines = array_values(array_filter(array_map(
'trim', preg_split(
'/\R/', $output) ?: array())));
937 if ($status ===
'passed') {
938 return $lines !== array() ? (string)end($lines) :
'Bestanden';
940 foreach ($lines as $line) {
941 if (preg_match(
'/(?:FAIL|Fatal|Error|Assertion|Exception|fehlt|missing)/i', $line)) {
942 return $this->shorten($line, 300);
945 return $lines !== array() ? $this->shorten((
string)end($lines), 300) :
'Fehlgeschlagen';
948 private function shorten(
string $value,
int $length): string
950 return function_exists(
'mb_substr')
951 ? (string)mb_substr(
$value, 0, $length)
952 : substr(
$value, 0, $length);
955 private function cleanOutput(
string $output): string
957 $output = preg_replace(
'/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -\/]*[@-~])/',
'', $output) ?? $output;
958 $output = str_replace(
"\0",
'', $output);
959 if (strlen($output) > $this->maxOutputBytes) {
960 $output = substr($output, 0, $this->maxOutputBytes) .
"\n... Ausgabe gekuerzt ...";
962 return trim($output);
965 private function safeProjectFile(
string $relative): ?string
967 if ($relative ===
'' || str_contains($relative,
"\0") || str_contains($relative,
'..')) {
970 $candidate = realpath($this->
baseDir .
'/' . ltrim(str_replace(
'\\',
'/', $relative),
'/'));
971 if ($candidate ===
false) {
978 private function relativePath(
string $file): string
984 private function normalizeProfile(
string $profile): string
986 return $profile ===
'quick' ?
'quick' :
'full';
989 private function validRunId(
string $runId): bool
991 return preg_match(
'/^[0-9]{8}-[0-9]{6}-[a-f0-9]{10}$/', $runId) === 1;
994 private function runPath(
string $runId): string
996 return rtrim($this->
logDir,
'/\\') . DIRECTORY_SEPARATOR . $runId .
'.json';
999 private function writeRun(array
$run): void
1001 $id = (string)(
$run[
'id'] ??
'');
1002 if (!$this->validRunId($id)) {
1003 throw new \RuntimeException(
'Ungueltige Testlauf-ID.');
1005 $json = json_encode(
$run, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1006 if (!is_string(
$json)) {
1007 throw new \RuntimeException(
'Testprotokoll konnte nicht serialisiert werden.');
1009 $handle = @fopen($this->runPath($id),
'c+b');
1011 throw new \RuntimeException(
'Testprotokoll konnte nicht geoeffnet werden.');
1013 if (!flock($handle, LOCK_EX)) {
1015 throw new \RuntimeException(
'Testprotokoll konnte nicht gesperrt werden.');
1017 ftruncate($handle, 0);
1019 fwrite($handle,
$json);
1021 flock($handle, LOCK_UN);
1025 private function ensureDirectory(
string $directory): void
1027 if (!is_dir($directory) && !@mkdir($directory, 0775,
true) && !is_dir($directory)) {
1028 throw new \RuntimeException(
'Self-Test-Protokollverzeichnis konnte nicht angelegt werden.');
1032 private function now(): string
1034 return gmdate(
'Y-m-d\TH:i:s\Z');
1037 private function durationSince(
string $started): int
1039 $time = strtotime($started);
1040 return $time ===
false ? 0 : max(0, (
int)round((microtime(
true) - $time) * 1000));
1043 private function durationBetween(
string $started,
string $finished): int
1045 $a = strtotime($started);
1047 return $a ===
false || $b ===
false ? 0 : max(0, ($b - $a) * 1000);