dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxSelfTestRunner.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxSelfTest;
3
14{
15 public const LOG_VERSION = 1;
16 private const ACTIVE_RUN_GRACE_SECONDS = 600;
17
18 private string $baseDir;
19 private string $logDir;
20 private ?array $catalogCache = null;
21 private ?string $phpCliBinaryCache = null;
22 private int $maxOutputBytes = 131072;
23
24 public function __construct(?string $baseDir = null, ?string $logDir = null)
25 {
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);
31 }
32
33 public function baseDir(): string
34 {
35 return $this->baseDir;
36 }
37
38 public function logDir(): string
39 {
40 return $this->logDir;
41 }
42
46 public function catalog(string $profile = 'full'): array
47 {
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']];
53 });
54 $this->catalogCache = $tests;
55 }
56
57 $profile = $this->normalizeProfile($profile);
58 if ($profile === 'full') {
59 return $this->catalogCache;
60 }
61
62 return array_values(array_filter(
63 $this->catalogCache,
64 static fn(array $test): bool => ($test['tier'] ?? 'full') === 'quick'
65 ));
66 }
67
68 public function catalogById(): array
69 {
70 $out = array();
71 foreach ($this->catalog('full') as $test) {
72 $out[(string)$test['id']] = $test;
73 }
74 return $out;
75 }
76
80 public function startRun(string $profile = 'full', array $testIds = array(), string $mode = 'complete'): array
81 {
82 $profile = $this->normalizeProfile($profile);
83 $catalog = $this->catalogById();
84 if ($testIds === array()) {
85 $testIds = array_column($this->catalog($profile), 'id');
86 }
87
88 $selected = array();
89 foreach ($testIds as $id) {
90 $id = (string)$id;
91 if (isset($catalog[$id]) && !in_array($id, $selected, true)) {
92 $selected[] = $id;
93 }
94 }
95 if ($selected === array()) {
96 throw new \RuntimeException('Keine gueltigen Tests ausgewaehlt.');
97 }
98
99 $id = gmdate('Ymd-His') . '-' . bin2hex(random_bytes(5));
100 $now = $this->now();
101 $run = array(
102 'version' => self::LOG_VERSION,
103 'id' => $id,
104 'mode' => in_array($mode, array('complete', 'selection', 'single', 'cli'), true) ? $mode : 'complete',
105 'profile' => $profile,
106 'status' => 'running',
107 'started_at' => $now,
108 'finished_at' => null,
109 'duration_ms' => 0,
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,
117 'sapi' => PHP_SAPI,
118 'host' => (string)($_SERVER['HTTP_HOST'] ?? gethostname() ?: ''),
119 ),
120 );
121 $this->writeRun($run);
122 return $run;
123 }
124
128 public function executeRunTest(string $runId, string $testId): array
129 {
130 $run = $this->loadRun($runId);
131 if (!$run) {
132 throw new \RuntimeException('Testlauf wurde nicht gefunden.');
133 }
134 if (($run['status'] ?? '') !== 'running') {
135 throw new \RuntimeException('Testlauf ist bereits abgeschlossen.');
136 }
137 if (!in_array($testId, $run['test_ids'] ?? array(), true)) {
138 throw new \RuntimeException('Test gehoert nicht zu diesem Lauf.');
139 }
140
141 foreach ($run['results'] ?? array() as $existing) {
142 if (($existing['test_id'] ?? '') === $testId) {
143 return $existing;
144 }
145 }
146
147 $catalog = $this->catalogById();
148 if (!isset($catalog[$testId])) {
149 throw new \RuntimeException('Test ist nicht mehr im Katalog vorhanden.');
150 }
151
152 $run['current_test_id'] = $testId;
153 $this->writeRun($run);
154 $result = $this->executeTest($catalog[$testId]);
155
156 $run = $this->loadRun($runId) ?: $run;
157 $run['results'][] = $result;
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);
162 return $result;
163 }
164
169 public function recordBrowserTestResult(string $runId, string $testId, array $payload): array
170 {
171 $run = $this->loadRun($runId);
172 if (!$run || ($run['status'] ?? '') !== 'running') {
173 throw new \RuntimeException('Aktiver Testlauf wurde nicht gefunden.');
174 }
175 if (!in_array($testId, $run['test_ids'] ?? array(), true)) {
176 throw new \RuntimeException('Test gehoert nicht zu diesem Lauf.');
177 }
178 foreach ($run['results'] ?? array() as $existing) {
179 if (($existing['test_id'] ?? '') === $testId) {
180 return $existing;
181 }
182 }
183 $catalog = $this->catalogById();
184 $test = $catalog[$testId] ?? null;
185 if (!is_array($test) || ($test['type'] ?? '') !== 'js') {
186 throw new \RuntimeException('Nur JavaScript-Tests duerfen Browserergebnisse melden.');
187 }
188
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)));
192 $now = $this->now();
193 $result = array(
194 'test_id' => (string)$test['id'],
195 'name' => (string)$test['name'],
196 'category' => (string)$test['category'],
197 'type' => 'js',
198 'execution' => 'browser',
199 'status' => $status,
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, ''),
206 'output' => $output,
207 'relative_path' => (string)$test['relative_path'],
208 );
209 $run['results'][] = $result;
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);
214 return $result;
215 }
216
217 public function finishRun(string $runId, bool $aborted = false): array
218 {
219 $run = $this->loadRun($runId);
220 if (!$run) {
221 throw new \RuntimeException('Testlauf wurde nicht gefunden.');
222 }
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';
231 } else {
232 $run['status'] = 'passed';
233 }
234 $this->writeRun($run);
235 return $run;
236 }
237
241 public function runProfile(string $profile = 'full', array $testIds = array(), ?callable $onResult = null): array
242 {
243 $run = $this->startRun($profile, $testIds, 'cli');
244 foreach ($run['test_ids'] as $id) {
245 $result = $this->executeRunTest((string)$run['id'], (string)$id);
246 if ($onResult) {
247 $onResult($result, $this->loadRun((string)$run['id']));
248 }
249 }
250 return $this->finishRun((string)$run['id']);
251 }
252
253 public function loadRun(string $runId): ?array
254 {
255 if (!$this->validRunId($runId)) {
256 return null;
257 }
258 $path = $this->runPath($runId);
259 if (!is_file($path)) {
260 return null;
261 }
262 $handle = @fopen($path, 'rb');
263 if (!$handle) {
264 return null;
265 }
266 flock($handle, LOCK_SH);
267 $json = stream_get_contents($handle);
268 flock($handle, LOCK_UN);
269 fclose($handle);
270 $data = json_decode((string)$json, true);
271 return is_array($data) ? $data : null;
272 }
273
274 public function history(int $limit = 20): array
275 {
276 $files = glob($this->logDir . '/*.json') ?: array();
277 usort($files, static fn(string $a, string $b): int => (filemtime($b) ?: 0) <=> (filemtime($a) ?: 0));
278 $items = array();
279 foreach (array_slice($files, 0, max(1, min(100, $limit))) as $file) {
280 $run = $this->loadRun(pathinfo($file, PATHINFO_FILENAME));
281 if (!$run) {
282 continue;
283 }
284 $status = (string)($run['status'] ?? '');
285 $modified = (int)(filemtime($file) ?: 0);
286 $run['display_status'] = $status === 'running' && $modified < time() - self::ACTIVE_RUN_GRACE_SECONDS
287 ? 'interrupted'
288 : $status;
289 unset($run['results'], $run['test_ids'], $run['environment']);
290 $items[] = $run;
291 }
292 return $items;
293 }
294
295 public function runLogPath(string $runId): ?string
296 {
297 $path = $this->validRunId($runId) ? $this->runPath($runId) : '';
298 return $path !== '' && is_file($path) ? $path : null;
299 }
300
301 private function builtinCatalog(): array
302 {
303 return 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),
313 );
314 }
315
316 private function builtin(string $key, string $name, string $description, string $tier, int $timeout): array
317 {
318 return array(
319 'id' => 'system.' . $key,
320 'name' => $name,
321 'description' => $description,
322 'type' => 'system',
323 'category' => 'System',
324 'tier' => $tier,
325 'timeout' => $timeout,
326 'relative_path' => '',
327 'handler' => $key,
328 );
329 }
330
331 private function discoverFileTests(): array
332 {
333 $tests = array();
334 $root = $this->baseDir . '/dbx';
335 if (!is_dir($root)) {
336 return $tests;
337 }
338 $iterator = new \RecursiveIteratorIterator(
339 new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
340 );
341 foreach ($iterator as $file) {
342 if (!$file->isFile()) {
343 continue;
344 }
345 $path = str_replace('\\', '/', $file->getPathname());
346 if (!str_contains($path, '/tests/')) {
347 continue;
348 }
349 $name = $file->getFilename();
350 $type = str_ends_with($name, '_test.php') ? 'php' : (str_ends_with($name, '_test.js') ? 'js' : '');
351 if ($type === '') {
352 continue;
353 }
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)
357 ? 'full'
358 : 'quick';
359 $tests[] = array(
360 'id' => $type . '.' . substr(hash('sha256', $relative), 0, 20),
361 'name' => preg_replace('/_test\.(?:php|js)$/i', '', $name),
362 'description' => $relative,
363 'type' => $type,
364 'execution' => $type === 'js' ? 'browser' : 'server',
365 'category' => $module,
366 'tier' => $tier,
367 'timeout' => $tier === 'full' ? 180 : 90,
368 'relative_path' => $relative,
369 'handler' => '',
370 );
371 }
372 return $tests;
373 }
374
375 private function moduleFromPath(string $relative): string
376 {
377 if (preg_match('~^dbx/modules/([^/]+)/~', $relative, $match)) {
378 return (string)$match[1];
379 }
380 return 'Core';
381 }
382
383 private function executeTest(array $test): array
384 {
385 $started = microtime(true);
386 $startedAt = $this->now();
387 if (($test['type'] ?? '') === 'system') {
388 $raw = $this->executeBuiltin((string)$test['handler'], (int)$test['timeout']);
389 } else {
390 $raw = $this->executeFileTest($test);
391 }
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)) {
396 $status = 'failed';
397 }
398 return array(
399 'test_id' => (string)$test['id'],
400 'name' => (string)$test['name'],
401 'category' => (string)$test['category'],
402 'type' => (string)$test['type'],
403 'status' => $status,
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'] ?? '')),
410 'output' => $output,
411 'relative_path' => (string)($test['relative_path'] ?? ''),
412 );
413 }
414
415 private function executeFileTest(array $test): array
416 {
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);
421 }
422 $type = (string)($test['type'] ?? '');
423 if ($type === 'php') {
424 $php = $this->resolvePhpCliBinary();
425 if ($php === null) {
426 return array(
427 'status' => 'failed',
428 'exit_code' => 127,
429 'output' => 'PHP-CLI wurde nicht gefunden. Bitte DBX_PHP_BINARY auf den PHP-CLI-Interpreter setzen.',
430 );
431 }
432 $command = array($php, $path);
433 } elseif ($type === 'js') {
434 $node = $this->resolveExecutable('node');
435 if ($node === null) {
436 return array(
437 'status' => 'skipped',
438 'exit_code' => 0,
439 'output' => 'Node.js ist nicht installiert. Dieser JavaScript-Test wird im Web-Dashboard vom Browser ausgefuehrt.',
440 );
441 }
442 $command = array($node, $path);
443 } else {
444 return array('status' => 'failed', 'exit_code' => 2, 'output' => 'Unbekannter Testtyp.');
445 }
446 return $this->runProcess($command, $this->baseDir, (int)($test['timeout'] ?? 90));
447 }
448
449 private function executeBuiltin(string $handler, int $timeout): array
450 {
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),
462 };
463 }
464
465 private function checkEnvironment(): array
466 {
467 $errors = array();
468 if (version_compare(PHP_VERSION, '8.2.0', '<')) {
469 $errors[] = 'PHP 8.2 oder neuer ist erforderlich; aktiv: ' . PHP_VERSION;
470 }
471 foreach (array('json', 'session', 'pdo', 'pdo_sqlite', 'openssl', 'fileinfo') as $extension) {
472 if (!extension_loaded($extension)) {
473 $errors[] = 'PHP-Erweiterung fehlt: ' . $extension;
474 }
475 }
476 if (!function_exists('proc_open')) {
477 $errors[] = 'proc_open ist deaktiviert; isolierte Tests sind nicht moeglich.';
478 }
479 $phpCli = $this->resolvePhpCliBinary();
480 if ($phpCli === null) {
481 $errors[] = 'PHP-CLI wurde nicht gefunden; serverseitige Einzeltests sind nicht moeglich.';
482 }
483 $node = $this->resolveExecutable('node');
484 $lines = array(
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'),
490 );
491 return $this->checkResult($errors, $lines);
492 }
493
494 private function checkFilesystem(): array
495 {
496 $errors = array();
497 $lines = 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/')
503 && is_dir($parent)
504 && is_writable($parent)
505 ) {
506 $lines[] = $relative . ': wird bei Bedarf in einem schreibbaren Laufzeitverzeichnis angelegt';
507 continue;
508 }
509 $errors[] = 'Verzeichnis fehlt: ' . $relative;
510 continue;
511 }
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;
515 }
516 }
517 $htaccess = @file_get_contents($this->baseDir . '/.htaccess');
518 if (!is_string($htaccess) || !str_contains($htaccess, 'sqlite3') || !str_contains($htaccess, 'files/(media|sys|temp)')) {
519 $errors[] = '.htaccess schuetzt private Laufzeit- oder Datenbankdateien nicht vollstaendig.';
520 } else {
521 $lines[] = '.htaccess: private Dateien werden gesperrt';
522 }
523 return $this->checkResult($errors, $lines);
524 }
525
526 private function checkModules(): array
527 {
528 $errors = array();
529 $count = 0;
530 foreach (glob($this->baseDir . '/dbx/modules/*', GLOB_ONLYDIR) ?: array() as $dir) {
531 $module = basename($dir);
532 $entry = $dir . '/' . $module . '.class.php';
533 $config = $dir . '/cfg/config.php';
534 if (!is_file($entry)) {
535 $errors[] = $module . ': Hauptklasse fehlt';
536 continue;
537 }
538 if (!is_file($config)) {
539 $errors[] = $module . ': cfg/config.php fehlt';
540 }
541 $source = (string)@file_get_contents($entry);
542 if (!preg_match('/namespace\s+dbx\\\\' . preg_quote($module, '/') . '\s*;/', $source)) {
543 $errors[] = $module . ': Namespace stimmt nicht';
544 }
545 if (!preg_match('/class\s+' . preg_quote($module, '/') . '\b/i', $source)) {
546 $errors[] = $module . ': Klasse ' . $module . ' fehlt';
547 }
548 $count++;
549 }
550 return $this->checkResult($errors, array($count . ' Module geprueft.'));
551 }
552
553 private function checkConflictMarkers(): array
554 {
555 $errors = array();
556 $checked = 0;
557 foreach ($this->projectFiles(array('php', 'js', 'css', 'htm', 'html', 'json', 'md', 'cfx', 'cfg')) as $file) {
558 $checked++;
559 $handle = @fopen($file, 'rb');
560 if (!$handle) {
561 continue;
562 }
563 $lineNo = 0;
564 while (($line = fgets($handle)) !== false) {
565 $lineNo++;
566 if (preg_match('/^(<<<<<<< |=======\s*$|>>>>>>> )/', rtrim($line, "\r\n"))) {
567 $errors[] = $this->relativePath($file) . ':' . $lineNo;
568 if (count($errors) >= 50) {
569 break 2;
570 }
571 }
572 }
573 fclose($handle);
574 }
575 return $this->checkResult($errors, array($checked . ' Dateien geprueft.'));
576 }
577
578 private function checkPageCache(): array
579 {
580 $dir = $this->baseDir . '/files/cache/content/full-page';
581 if (!is_dir($dir)) {
582 return $this->checkResult(array(), array('Noch kein Gastseiten-Cache vorhanden.'));
583 }
584 $generation = trim((string)@file_get_contents($dir . '/.generation'));
585 $errors = array();
586 if (!preg_match('/^[a-f0-9]{24}$/', $generation)) {
587 $errors[] = 'Cache-Generation fehlt oder ist ungueltig.';
588 }
589 $files = glob($dir . '/*.htm') ?: array();
590 foreach ($files as $file) {
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;
594 }
595 }
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;
600 }
601 }
602 return $this->checkResult($errors, array(count($files) . ' aktuelle Gastseiten in genau einer Generation.'));
603 }
604
605 private function checkPhpSyntax(int $timeout): array
606 {
607 $php = $this->resolvePhpCliBinary();
608 if ($php === null) {
609 return array(
610 'status' => 'failed',
611 'exit_code' => 127,
612 'output' => 'PHP-CLI wurde nicht gefunden; die Syntaxpruefung kann nicht ausgefuehrt werden.',
613 );
614 }
615 $errors = array();
616 $checked = 0;
617 $deadline = microtime(true) + max(30, $timeout);
618 foreach ($this->projectFiles(array('php')) as $file) {
619 // PHP-Generatorvorlagen enthalten absichtlich noch nicht ersetzte
620 // Platzhalter oder Fragmente und sind keine direkt ladbaren Dateien.
621 if (str_contains(str_replace('\\', '/', $file), '/tpl/php/')) {
622 continue;
623 }
624 if (microtime(true) >= $deadline) {
625 return array(
626 'status' => 'failed',
627 'exit_code' => 124,
628 'timed_out' => true,
629 'output' => 'PHP-Syntaxpruefung nach ' . $checked . ' Dateien abgebrochen: Gesamtzeit ueberschritten.',
630 );
631 }
632 $checked++;
633 // Syntaxpruefung benoetigt keine php.ini. -n spart unter XAMPP pro
634 // Datei den teuren Modul- und Konfigurationsstart.
635 $result = $this->runProcess(array($php, '-n', '-l', $file), $this->baseDir, 20);
636 if ((int)($result['exit_code'] ?? 1) !== 0) {
637 $errors[] = $this->relativePath($file) . "\n" . trim((string)($result['output'] ?? ''));
638 if (count($errors) >= 25) {
639 break;
640 }
641 }
642 }
643 return $this->checkResult($errors, array($checked . ' PHP-Dateien ohne Syntaxfehler.'));
644 }
645
646 private function checkJavaScriptSyntax(int $timeout): array
647 {
648 $node = $this->resolveExecutable('node');
649 if ($node === null) {
650 return array(
651 'status' => 'skipped',
652 'exit_code' => 0,
653 'output' => 'Node.js ist optional und auf diesem Server nicht installiert. Browserfaehige JavaScript-Tests laufen im Web-Dashboard.',
654 );
655 }
656 $errors = array();
657 $checked = 0;
658 $deadline = microtime(true) + max(30, $timeout);
659 foreach ($this->projectFiles(array('js')) as $file) {
660 if (microtime(true) >= $deadline) {
661 return array(
662 'status' => 'failed',
663 'exit_code' => 124,
664 'timed_out' => true,
665 'output' => 'JavaScript-Syntaxpruefung nach ' . $checked . ' Dateien abgebrochen: Gesamtzeit ueberschritten.',
666 );
667 }
668 $checked++;
669 $result = $this->runProcess(array($node, '--check', $file), $this->baseDir, 20);
670 if ((int)($result['exit_code'] ?? 1) !== 0) {
671 $errors[] = $this->relativePath($file) . "\n" . trim((string)($result['output'] ?? ''));
672 if (count($errors) >= 25) {
673 break;
674 }
675 }
676 }
677 return $this->checkResult($errors, array($checked . ' JavaScript-Dateien ohne Syntaxfehler.'));
678 }
679
680 private function runComposer(array $arguments, int $timeout): array
681 {
682 $composer = $this->resolveComposerCommand();
683 if ($composer === null) {
684 return array('status' => 'failed', 'exit_code' => 127, 'output' => 'Composer wurde nicht gefunden.');
685 }
686 return $this->runProcess(array_merge($composer, $arguments), $this->baseDir . '/dbx', $timeout);
687 }
688
689 private function resolveComposerCommand(): ?array
690 {
691 $candidate = $this->resolveExecutable('composer');
692 if ($candidate === null) {
693 return null;
694 }
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;
700 }
701 }
702 return array($candidate);
703 }
704
710 private function resolvePhpCliBinary(): ?string
711 {
712 if ($this->phpCliBinaryCache !== null) {
713 return $this->phpCliBinaryCache !== '' ? $this->phpCliBinaryCache : null;
714 }
715
716 $executable = PHP_OS_FAMILY === 'Windows' ? 'php.exe' : 'php';
717 $candidates = array();
718 $configured = trim((string)getenv('DBX_PHP_BINARY'), " \t\n\r\0\x0B\"");
719 if ($configured !== '') {
720 $candidates[] = $configured;
721 }
722 if (preg_match('/^php(?:\.exe)?$/i', basename(PHP_BINARY))) {
723 $candidates[] = PHP_BINARY;
724 }
725 $ini = php_ini_loaded_file();
726 if (is_string($ini) && $ini !== '') {
727 $candidates[] = dirname($ini) . DIRECTORY_SEPARATOR . $executable;
728 }
729 if (defined('PHP_BINDIR')) {
730 $candidates[] = rtrim((string)PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . $executable;
731 }
732
733 // XAMPP and similar layouts: <root>/htdocs/project + <root>/php/php.exe.
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) {
739 break;
740 }
741 $directory = $parent;
742 }
743 $fromPath = $this->resolveExecutable('php');
744 if ($fromPath !== null) {
745 $candidates[] = $fromPath;
746 }
747
748 foreach (array_unique($candidates) as $candidate) {
749 if (is_file($candidate) && (PHP_OS_FAMILY === 'Windows' || is_executable($candidate))) {
750 $this->phpCliBinaryCache = $candidate;
751 return $candidate;
752 }
753 }
754 $this->phpCliBinaryCache = '';
755 return null;
756 }
757
758 private function runProcess(array $command, string $cwd, int $timeout): array
759 {
760 if (!function_exists('proc_open')) {
761 return array('status' => 'failed', 'exit_code' => 127, 'output' => 'proc_open ist nicht verfuegbar.');
762 }
763 $descriptors = array(
764 0 => array('pipe', 'r'),
765 1 => array('pipe', 'w'),
766 2 => array('pipe', 'w'),
767 );
768 $pipes = array();
769 $environment = getenv();
770 if (!is_array($environment)) {
771 $environment = array();
772 }
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));
776 if (!is_resource($process)) {
777 return array('status' => 'failed', 'exit_code' => 127, 'output' => 'Prozess konnte nicht gestartet werden.');
778 }
779 fclose($pipes[0]);
780 stream_set_blocking($pipes[1], false);
781 stream_set_blocking($pipes[2], false);
782 $started = microtime(true);
783 $output = '';
784 $timedOut = false;
785 $lastExit = -1;
786 while (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'];
792 break;
793 }
794 if ((microtime(true) - $started) > max(1, $timeout)) {
795 $timedOut = true;
796 @proc_terminate($process);
797 usleep(100000);
798 $status = proc_get_status($process);
799 if ($status['running']) {
800 @proc_terminate($process, 9);
801 }
802 break;
803 }
804 usleep(20000);
805 }
806 $this->appendOutput($output, (string)stream_get_contents($pipes[1]));
807 $this->appendOutput($output, (string)stream_get_contents($pipes[2]));
808 fclose($pipes[1]);
809 fclose($pipes[2]);
810 $closedExit = proc_close($process);
811 $exitCode = $timedOut ? 124 : ($lastExit >= 0 ? $lastExit : (int)$closedExit);
812 return array(
813 'status' => $exitCode === 0 ? 'passed' : 'failed',
814 'exit_code' => $exitCode,
815 'timed_out' => $timedOut,
816 'output' => $output,
817 );
818 }
819
820 private function appendOutput(string &$output, string $chunk): void
821 {
822 if ($chunk === '') {
823 return;
824 }
825 $output .= $chunk;
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);
831 }
832 }
833
834 private function projectFiles(array $extensions): array
835 {
836 $extensions = array_fill_keys(array_map('strtolower', $extensions), true);
837 $files = 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()) {
843 return true;
844 }
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)) {
848 return false;
849 }
850 }
851 return true;
852 }
853 )
854 );
855 foreach ($iterator as $file) {
856 if ($file->isFile() && isset($extensions[strtolower($file->getExtension())])) {
857 $files[] = str_replace('\\', '/', $file->getPathname());
858 }
859 }
860 sort($files);
861 return $files;
862 }
863
864 private function resolveExecutable(string $name): ?string
865 {
866 $pathValue = (string)getenv('PATH');
867 $extensions = PHP_OS_FAMILY === 'Windows'
868 ? array('.exe', '.bat', '.cmd', '')
869 : array('');
870 foreach (explode(PATH_SEPARATOR, $pathValue) as $directory) {
871 $directory = trim($directory, " \t\n\r\0\x0B\"");
872 if ($directory === '') {
873 continue;
874 }
875 foreach ($extensions as $extension) {
876 $candidate = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $name . $extension;
877 if (is_file($candidate) && (PHP_OS_FAMILY === 'Windows' || is_executable($candidate))) {
878 return $candidate;
879 }
880 }
881 }
882 return null;
883 }
884
885 private function checkResult(array $errors, array $successLines): array
886 {
887 if ($errors !== array()) {
888 return array(
889 'status' => 'failed',
890 'exit_code' => 1,
891 'output' => "FAIL\n- " . implode("\n- ", $errors),
892 );
893 }
894 return array(
895 'status' => 'passed',
896 'exit_code' => 0,
897 'output' => "PASS\n" . implode("\n", $successLines),
898 );
899 }
900
901 private function calculateTotals(array $run): array
902 {
903 $totals = $this->totals(count($run['test_ids'] ?? array()));
904 foreach ($run['results'] ?? array() as $result) {
905 $status = (string)($result['status'] ?? 'failed');
906 $totals['completed']++;
907 if (isset($totals[$status])) {
908 $totals[$status]++;
909 } else {
910 $totals['failed']++;
911 }
912 $totals['duration_ms'] += (int)($result['duration_ms'] ?? 0);
913 }
914 $totals['pending'] = max(0, $totals['total'] - $totals['completed']);
915 return $totals;
916 }
917
918 private function totals(int $total): array
919 {
920 return array(
921 'total' => $total,
922 'completed' => 0,
923 'pending' => $total,
924 'passed' => 0,
925 'failed' => 0,
926 'skipped' => 0,
927 'duration_ms' => 0,
928 );
929 }
930
931 private function resultSummary(string $status, string $output, string $explicit): string
932 {
933 if ($explicit !== '') {
934 return $explicit;
935 }
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';
939 }
940 foreach ($lines as $line) {
941 if (preg_match('/(?:FAIL|Fatal|Error|Assertion|Exception|fehlt|missing)/i', $line)) {
942 return $this->shorten($line, 300);
943 }
944 }
945 return $lines !== array() ? $this->shorten((string)end($lines), 300) : 'Fehlgeschlagen';
946 }
947
948 private function shorten(string $value, int $length): string
949 {
950 return function_exists('mb_substr')
951 ? (string)mb_substr($value, 0, $length)
952 : substr($value, 0, $length);
953 }
954
955 private function cleanOutput(string $output): string
956 {
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 ...";
961 }
962 return trim($output);
963 }
964
965 private function safeProjectFile(string $relative): ?string
966 {
967 if ($relative === '' || str_contains($relative, "\0") || str_contains($relative, '..')) {
968 return null;
969 }
970 $candidate = realpath($this->baseDir . '/' . ltrim(str_replace('\\', '/', $relative), '/'));
971 if ($candidate === false) {
972 return null;
973 }
974 $normalized = str_replace('\\', '/', $candidate);
975 return str_starts_with($normalized . '/', $this->baseDir . '/') ? $normalized : null;
976 }
977
978 private function relativePath(string $file): string
979 {
980 $file = str_replace('\\', '/', $file);
981 return str_starts_with($file, $this->baseDir . '/') ? substr($file, strlen($this->baseDir) + 1) : $file;
982 }
983
984 private function normalizeProfile(string $profile): string
985 {
986 return $profile === 'quick' ? 'quick' : 'full';
987 }
988
989 private function validRunId(string $runId): bool
990 {
991 return preg_match('/^[0-9]{8}-[0-9]{6}-[a-f0-9]{10}$/', $runId) === 1;
992 }
993
994 private function runPath(string $runId): string
995 {
996 return rtrim($this->logDir, '/\\') . DIRECTORY_SEPARATOR . $runId . '.json';
997 }
998
999 private function writeRun(array $run): void
1000 {
1001 $id = (string)($run['id'] ?? '');
1002 if (!$this->validRunId($id)) {
1003 throw new \RuntimeException('Ungueltige Testlauf-ID.');
1004 }
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.');
1008 }
1009 $handle = @fopen($this->runPath($id), 'c+b');
1010 if (!$handle) {
1011 throw new \RuntimeException('Testprotokoll konnte nicht geoeffnet werden.');
1012 }
1013 if (!flock($handle, LOCK_EX)) {
1014 fclose($handle);
1015 throw new \RuntimeException('Testprotokoll konnte nicht gesperrt werden.');
1016 }
1017 ftruncate($handle, 0);
1018 rewind($handle);
1019 fwrite($handle, $json);
1020 fflush($handle);
1021 flock($handle, LOCK_UN);
1022 fclose($handle);
1023 }
1024
1025 private function ensureDirectory(string $directory): void
1026 {
1027 if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
1028 throw new \RuntimeException('Self-Test-Protokollverzeichnis konnte nicht angelegt werden.');
1029 }
1030 }
1031
1032 private function now(): string
1033 {
1034 return gmdate('Y-m-d\TH:i:s\Z');
1035 }
1036
1037 private function durationSince(string $started): int
1038 {
1039 $time = strtotime($started);
1040 return $time === false ? 0 : max(0, (int)round((microtime(true) - $time) * 1000));
1041 }
1042
1043 private function durationBetween(string $started, string $finished): int
1044 {
1045 $a = strtotime($started);
1046 $b = strtotime($finished);
1047 return $a === false || $b === false ? 0 : max(0, ($b - $a) * 1000);
1048 }
1049}
Zentraler, auch ohne Web-Kernel nutzbarer Test-Orchestrator.
startRun(string $profile='full', array $testIds=array(), string $mode='complete')
Startet einen protokollierten Lauf.
__construct(?string $baseDir=null, ?string $logDir=null)
runProfile(string $profile='full', array $testIds=array(), ?callable $onResult=null)
Synchroner Einstieg fuer CLI und CI.
finishRun(string $runId, bool $aborted=false)
executeRunTest(string $runId, string $testId)
Fuehrt genau einen zum Lauf gehoerenden Test aus und protokolliert ihn.
recordBrowserTestResult(string $runId, string $testId, array $payload)
Uebernimmt das Ergebnis eines im Admin-Browser isoliert ausgefuehrten JavaScript-Tests.
catalog(string $profile='full')
Liefert den vollstaendigen oder auf ein Profil reduzierten Testkatalog.
$_SERVER['REQUEST_METHOD']
if($resolved !==$expectedBase . 'files/test/') $config
if(strpos($sitemapSource, 'collectUserMenuLinks') !==false||strpos($sitemapSource, 'dbxContentHome::masterCid')===false||strpos($sitemapSource, 'isNoindex')===false||strpos($sitemapSource, ' $lngs=array($masterLng)')===false) $htaccess
if( $demoId<=0) foreach(array('create_date', 'create_uid', 'update_date', 'update_uid', 'owner',) as $systemField) $items
$profile
Definition run.php:6