From 583c9fc00d98cde21ae2a8d991849de024f500d2 Mon Sep 17 00:00:00 2001 From: Mark Jansen Date: Sun, 30 Aug 2026 13:36:37 +0200 Subject: [PATCH 1/3] Add importer and fix some small bugs --- resources/devsetup/README.md | 61 ++++ resources/devsetup/import-live.php | 369 ++++++++++++++++++++++++ www/www.reactos.org/getbuilds/index.php | 4 +- www/www.reactos.org/testman/detail.php | 3 + www/www.reactos.org/testman/index.php | 4 +- 5 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 resources/devsetup/README.md create mode 100644 resources/devsetup/import-live.php diff --git a/resources/devsetup/README.md b/resources/devsetup/README.md new file mode 100644 index 000000000..d6f91f1ec --- /dev/null +++ b/resources/devsetup/README.md @@ -0,0 +1,61 @@ +# Development Setup + +Scripts for filling a local development database with real data from the live website, +so that Testman and GetBuilds show something useful without a production dump. + +## Prerequisites + +1. Create the `testman` and `gitinfo` databases and import the schemas: + + ``` + mysql testman < ../testman/testman.sql + mysql gitinfo < ../gitinfo/gitinfo.sql + ``` + +2. Point `www/www.reactos.org_config/testman-connect.php` and `gitinfo-connect.php` at them. + +3. Enable the GD extension in `php.ini` (`extension=gd`) and restart the web server. + `compare.php` generates its indicator images with it and fatals without it. + +## import-live.php + +``` +php import-live.php [days] [--no-gitinfo] +``` + +Imports the test runs of the last `days` days (default: 7) from reactos.org, plus the +Git commits they refer to. Both parts are safe to re-run. + +* **Commits** come from the GitHub API and go into `gitinfo.master_revisions`. That table + is truncated first, because GitInfo derives the commit order from the auto increment + `id` and appending older commits would silently break revision range searches. + The production `gitinfo-connect.php` only grants `SELECT`, so either give the local user + `INSERT` too, or override the credentials with the `GITINFO_DB_USER` and + `GITINFO_DB_PASS` environment variables. + +* **Test runs** come from `ajax-search.php` (for the run IDs and comments) and + `export.php?f=xml` (for everything else). Run and result IDs of the live website are + preserved, so local `detail.php` and `export.php` URLs match the ones on reactos.org. + +Roughly 1400 results and 200 KB of XML per run, and about 30 runs per day, so keep `days` +small unless you want to wait. + +### What is not imported + +`winetest_logs`. The live website only exposes logs as HTML inside `detail.php`, one +request per result. Detail and diff views will therefore be empty for imported runs; +everything else works. + +## Missing CSS + +The stylesheets referenced by the shared page header (`/css/bootstrap.min.css` and +friends) are not part of this repository. They are built from the +[web-content](https://github.com/reactos/web-content) repository and served from +`www.reactos.org_content`. For a local setup, either build that repository and serve it as +the fallback document root, or redirect those paths to the live site from your vhost: + +```apache +RedirectMatch 302 "^/((?:css|js|img|fonts|fork-awesome)/.*|favicon\.ico)$" "https://reactos.org/$1" +``` + +Use `reactos.org`, not `www.reactos.org`, which redirects every asset. diff --git a/resources/devsetup/import-live.php b/resources/devsetup/import-live.php new file mode 100644 index 000000000..20042d100 --- /dev/null +++ b/resources/devsetup/import-live.php @@ -0,0 +1,369 @@ + array( + "method" => "GET", + "header" => "User-Agent: " . USER_AGENT . "\r\nAccept: application/json\r\n", + "timeout" => 60, + ) + )); + + $data = @file_get_contents($url, false, $context); + if ($data === FALSE) + throw new RuntimeException("Could not fetch $url"); + + return $data; + } + + function connect($host, $name, $user, $pass) + { + $dbh = new PDO("mysql:host=$host;dbname=$name;charset=utf8mb4", $user, $pass); + $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + return $dbh; + } + + function progress($message) + { + echo $message . "\n"; + } + + + //// GITINFO //// + + /** + * Fetches commits from the GitHub API and refills the "master_revisions" table. + * + * The table is truncated first: GitInfo derives the commit order from the auto + * increment "id", so appending older commits to an existing table would silently + * break getRevisionRange() and friends. + */ + function import_gitinfo($cutoff) + { + $user = getenv("GITINFO_DB_USER") ?: GITINFO_DB_USER; + $pass = getenv("GITINFO_DB_PASS") ?: GITINFO_DB_PASS; + $dbh = connect(GITINFO_DB_HOST, GITINFO_DB_NAME, $user, $pass); + + $since = gmdate("Y-m-d\TH:i:s\Z", $cutoff - COMMIT_MARGIN_DAYS * 86400); + $commits = array(); + + for ($page = 1; $page <= MAX_COMMIT_PAGES; $page++) + { + $url = GITHUB_API . "?since=" . urlencode($since) . "&per_page=100&page=$page"; + $batch = json_decode(http_get($url), true); + + if (!is_array($batch)) + throw new RuntimeException("Unexpected response from the GitHub API"); + + if (!count($batch)) + break; + + $commits = array_merge($commits, $batch); + progress(" fetched " . count($commits) . " commits"); + + if (count($batch) < 100) + break; + } + + if (!count($commits)) + throw new RuntimeException("The GitHub API returned no commits since $since"); + + // The API returns the newest commit first, but "id" has to ascend chronologically. + $commits = array_reverse($commits); + + $dbh->exec("TRUNCATE TABLE master_revisions"); + $stmt = $dbh->prepare( + "INSERT INTO master_revisions (rev_hash, author_name, author_email, commit_timestamp, message) " . + "VALUES (:rev_hash, :author_name, :author_email, FROM_UNIXTIME(:commit_timestamp), COMPRESS(:message))" + ); + + $dbh->beginTransaction(); + + foreach ($commits as $commit) + { + $stmt->execute(array( + ":rev_hash" => $commit["sha"], + ":author_name" => (string)$commit["commit"]["author"]["name"], + ":author_email" => (string)$commit["commit"]["author"]["email"], + ":commit_timestamp" => strtotime($commit["commit"]["committer"]["date"]), + ":message" => (string)$commit["commit"]["message"], + )); + } + + $dbh->commit(); + progress(" imported " . count($commits) . " revisions"); + } + + + //// TESTMAN //// + + /** + * Collects the IDs of all finished test runs that are newer than $cutoff. + * + * ajax-search.php has no date filter, so we page through the results (newest first) + * until we run past the cutoff. Its dates are formatted in the web server's timezone, + * which makes the boundary fuzzy by a couple of hours. That is good enough here. + */ + function get_run_ids($cutoff) + { + $runs = array(); + + for ($page = 1; $page <= MAX_SEARCH_PAGES; $page++) + { + $url = LIVE_URL . "/ajax-search.php?desc=1&resultlist=1&limit=" . SEARCH_PAGE_SIZE . "&page=$page"; + $xml = simplexml_load_string(http_get($url), "SimpleXMLElement", LIBXML_NONET); + + if ($xml === FALSE) + throw new RuntimeException("Could not parse the response of ajax-search.php"); + + if (isset($xml->error)) + throw new RuntimeException("ajax-search.php: " . (string)$xml->error); + + if (!count($xml->result)) + break; + + foreach ($xml->result as $result) + { + if (strtotime((string)$result->date) < $cutoff) + return $runs; + + // The comment is the only field we need that export.php does not provide. + $runs[(int)$result->id] = (string)$result->comment; + } + + progress(" found " . count($runs) . " runs"); + } + + return $runs; + } + + function get_source_id($dbh, $name) + { + static $cache = array(); + + if (isset($cache[$name])) + return $cache[$name]; + + $stmt = $dbh->prepare("SELECT id FROM sources WHERE name = :name"); + $stmt->execute(array(":name" => $name)); + $id = $stmt->fetchColumn(); + + if ($id === FALSE) + { + // Password is only relevant for submitting results through the webservice. + $stmt = $dbh->prepare("INSERT INTO sources (name, password) VALUES (:name, MD5('devpassword'))"); + $stmt->execute(array(":name" => $name)); + $id = $dbh->lastInsertId(); + } + + $cache[$name] = (int)$id; + return (int)$id; + } + + function get_suite_id($dbh, $module, $test) + { + static $cache = array(); + $key = "$module:$test"; + + if (isset($cache[$key])) + return $cache[$key]; + + $stmt = $dbh->prepare("SELECT id FROM winetest_suites WHERE module = :module AND test = :test"); + $stmt->execute(array(":module" => $module, ":test" => $test)); + $id = $stmt->fetchColumn(); + + if ($id === FALSE) + { + $stmt = $dbh->prepare("INSERT INTO winetest_suites (module, test) VALUES (:module, :test)"); + $stmt->execute(array(":module" => $module, ":test" => $test)); + $id = $dbh->lastInsertId(); + } + + $cache[$key] = (int)$id; + return (int)$id; + } + + /** + * Imports a single test run from export.php, keeping the IDs of the live website so + * that local detail.php and export.php URLs match the ones on reactos.org. + */ + function import_run($dbh, $id, $comment) + { + $url = LIVE_URL . "/export.php?f=xml&ids=" . $id; + $xml = simplexml_load_string(http_get($url), "SimpleXMLElement", LIBXML_NONET); + + if ($xml === FALSE || !isset($xml->run)) + throw new RuntimeException("Could not parse the response of export.php for run $id"); + + $run = $xml->run; + $source_id = get_source_id($dbh, (string)$run["source"]); + + $stmt = $dbh->prepare( + "INSERT IGNORE INTO winetest_runs " . + "(id, timestamp, finished, source_id, revision, platform, comment, boot_cycles, context_switches, interrupts, reboots, system_calls, time) " . + "VALUES (:id, FROM_UNIXTIME(:timestamp), 1, :source_id, :revision, :platform, :comment, :boot_cycles, :context_switches, :interrupts, :reboots, :system_calls, :time)" + ); + $stmt->bindValue(":id", (int)$run["id"], PDO::PARAM_INT); + $stmt->bindValue(":timestamp", (int)$run["timestamp"], PDO::PARAM_INT); + $stmt->bindValue(":source_id", $source_id, PDO::PARAM_INT); + $stmt->bindValue(":revision", (string)$run["revision"]); + $stmt->bindValue(":platform", (string)$run["platform"]); + $stmt->bindValue(":comment", $comment); + // boot_cycles regularly exceeds PHP_INT_MAX, so it has to stay a string. + $stmt->bindValue(":boot_cycles", (string)$run["bootcycles"]); + $stmt->bindValue(":context_switches", (int)$run["contextswitches"], PDO::PARAM_INT); + $stmt->bindValue(":interrupts", (int)$run["interrupts"], PDO::PARAM_INT); + $stmt->bindValue(":reboots", (int)$run["reboots"], PDO::PARAM_INT); + $stmt->bindValue(":system_calls", (int)$run["systemcalls"], PDO::PARAM_INT); + // export.php reports the run time in minutes, the column stores seconds. + $stmt->bindValue(":time", (float)$run["time"] * 60); + $stmt->execute(); + + $stmt = $dbh->prepare( + "INSERT IGNORE INTO winetest_results (id, test_id, suite_id, status, count, failures, skipped, todo, time) " . + "VALUES (:id, :test_id, :suite_id, :status, :count, :failures, :skipped, :todo, :time)" + ); + + // detail.php joins winetest_logs, so every result needs a row there. The real logs + // are not exported by the live website, hence the placeholder. + $log_stmt = $dbh->prepare("INSERT IGNORE INTO winetest_logs (id, log) VALUES (:id, COMPRESS(:log))"); + $log_text = "This result was imported from " . LIVE_URL . " by import-live.php.\n" . + "The live website does not export logs, so this one is not available.\n"; + + $dbh->beginTransaction(); + $count = 0; + + foreach ($run->test as $test) + { + $stmt->execute(array( + ":id" => (int)$test["id"], + ":test_id" => (int)$run["id"], + ":suite_id" => get_suite_id($dbh, (string)$test["module"], (string)$test["test"]), + ":status" => (string)$test["status"], + ":count" => (int)$test["count"], + ":failures" => (int)$test["failures"], + ":skipped" => (int)$test["skipped"], + ":todo" => (int)$test["todo"], + ":time" => (float)$test["time"], + )); + + $log_stmt->execute(array(":id" => (int)$test["id"], ":log" => $log_text)); + $count++; + } + + $dbh->commit(); + + // Same aggregation that WineTest_Writer::finish() performs. + $stmt = $dbh->prepare( + "UPDATE winetest_runs SET " . + "count = (SELECT SUM(count) FROM winetest_results WHERE test_id = :id), " . + "failures = (SELECT SUM(failures) FROM winetest_results WHERE test_id = :id) " . + "WHERE id = :id" + ); + $stmt->execute(array(":id" => (int)$run["id"])); + + return $count; + } + + function import_testman($cutoff) + { + $dbh = connect(TESTMAN_DB_HOST, TESTMAN_DB_NAME, TESTMAN_DB_USER, TESTMAN_DB_PASS); + + $runs = get_run_ids($cutoff); + if (!count($runs)) + { + progress(" no test runs in that time range"); + return; + } + + // Import oldest first, so that an interrupted run leaves a contiguous range. + $runs = array_reverse($runs, true); + $i = 0; + + foreach ($runs as $id => $comment) + { + $i++; + $count = import_run($dbh, $id, $comment); + progress(" [$i/" . count($runs) . "] run $id: $count results"); + } + } + + + //// MAIN //// + + $days = 7; + $gitinfo = TRUE; + + foreach (array_slice($argv, 1) as $arg) + { + if ($arg === "--no-gitinfo") + $gitinfo = FALSE; + else if (ctype_digit($arg) && (int)$arg > 0) + $days = (int)$arg; + else + usage(); + } + + $cutoff = time() - $days * 86400; + + try + { + if ($gitinfo) + { + progress("Importing commits of the last $days days..."); + import_gitinfo($cutoff); + } + + progress("Importing test runs of the last $days days..."); + import_testman($cutoff); + + progress("Done."); + } + catch (Exception $e) + { + die("ERROR: " . $e->getMessage() . "\n"); + } diff --git a/www/www.reactos.org/getbuilds/index.php b/www/www.reactos.org/getbuilds/index.php index 2ba2c1303..eaa11860c 100644 --- a/www/www.reactos.org/getbuilds/index.php +++ b/www/www.reactos.org/getbuilds/index.php @@ -22,8 +22,8 @@ { $gi = new GitInfo(); $revisions = $gi->getLatestRevisions(2); - $rev = $gi->getShortHash($revisions[0]); - $rev_before = $gi->getShortHash($revisions[1]); + $rev = isset($revisions[0]) ? $gi->getShortHash($revisions[0]) : ""; + $rev_before = isset($revisions[1]) ? $gi->getShortHash($revisions[1]) : ""; } catch (Exception $e) { diff --git a/www/www.reactos.org/testman/detail.php b/www/www.reactos.org/testman/detail.php index 94a37638c..7dd26b541 100644 --- a/www/www.reactos.org/testman/detail.php +++ b/www/www.reactos.org/testman/detail.php @@ -46,6 +46,9 @@ $stmt->execute(); $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) + throw new ErrorMessageException("No result with this ID"); + // Post-process the log for convenience. $module_urls = array(); $search_urls = array("modules/rostests/winetests", "modules/rostests/apitests"); diff --git a/www/www.reactos.org/testman/index.php b/www/www.reactos.org/testman/index.php index 5bc1291de..1d1a4d040 100644 --- a/www/www.reactos.org/testman/index.php +++ b/www/www.reactos.org/testman/index.php @@ -23,8 +23,8 @@ { $gi = new GitInfo(); $revisions = $gi->getLatestRevisions(2); - $rev = $gi->getShortHash($revisions[0]); - $rev_before = $gi->getShortHash($revisions[1]); + $rev = isset($revisions[0]) ? $gi->getShortHash($revisions[0]) : ""; + $rev_before = isset($revisions[1]) ? $gi->getShortHash($revisions[1]) : ""; // Connect to the database. $dbh = new PDO("mysql:host=" . TESTMAN_DB_HOST . ";dbname=" . TESTMAN_DB_NAME, TESTMAN_DB_USER, TESTMAN_DB_PASS); From 2b114392c293d2b00e9274225c2a071c1e4877a6 Mon Sep 17 00:00:00 2001 From: Mark Jansen Date: Sun, 30 Aug 2026 17:33:32 +0200 Subject: [PATCH 2/3] First step revamping testman --- .../testman/migrate-01-indexes-innodb.sql | 36 ++ resources/testman/testman.sql | 8 +- .../testman/api/config.inc.php | 14 + www/www.reactos.org/testman/api/runs.php | 212 ++++++++ www/www.reactos.org/testman/compare.php | 4 +- www/www.reactos.org/testman/config.inc.php | 11 +- www/www.reactos.org/testman/css/index.css | 28 + www/www.reactos.org/testman/css/suite.css | 34 ++ www/www.reactos.org/testman/css/testman.css | 32 ++ www/www.reactos.org/testman/detail.php | 6 +- www/www.reactos.org/testman/index.php | 189 +++++-- www/www.reactos.org/testman/indicator.php | 37 ++ www/www.reactos.org/testman/js/index.js | 492 +++++++++--------- www/www.reactos.org/testman/lang/de.inc.php | 21 +- www/www.reactos.org/testman/lang/en.inc.php | 21 +- www/www.reactos.org/testman/lang/en.js | 5 +- www/www.reactos.org/testman/lang/pl.inc.php | 21 +- .../testman/lib/Indicator.class.php | 52 +- .../testman/lib/WineTest_Reader.class.php | 2 +- www/www.reactos.org/testman/suite.php | 269 ++++++++++ www/www.reactos.org/testman/utils.inc.php | 20 + 21 files changed, 1170 insertions(+), 344 deletions(-) create mode 100644 resources/testman/migrate-01-indexes-innodb.sql create mode 100644 www/www.reactos.org/testman/api/config.inc.php create mode 100644 www/www.reactos.org/testman/api/runs.php create mode 100644 www/www.reactos.org/testman/css/suite.css create mode 100644 www/www.reactos.org/testman/css/testman.css create mode 100644 www/www.reactos.org/testman/indicator.php create mode 100644 www/www.reactos.org/testman/suite.php diff --git a/resources/testman/migrate-01-indexes-innodb.sql b/resources/testman/migrate-01-indexes-innodb.sql new file mode 100644 index 000000000..be2ada1d9 --- /dev/null +++ b/resources/testman/migrate-01-indexes-innodb.sql @@ -0,0 +1,36 @@ +-- Testman migration 01: timeline indexes and InnoDB conversion +-- +-- Adds the indexes that date filtering and the run browser need, and converts the two +-- big tables to InnoDB. Both happen in a single ALTER per table, because either change +-- rebuilds the whole table anyway. +-- +-- ix_runs_ts every search filters finished = 1 and then orders/pages by id; +-- the timestamp in the middle makes the date filter an index range. +-- ix_runs_src_plat covers "this builder on this platform, newest first", which is what +-- the landing view and the suite history page ask for. +-- +-- KEY (revision) already serves the LIKE 'abc1234%' prefix search at the full 40 chars, +-- so no extra index is needed for the hash filter. +-- +-- These tables are MyISAM, so the rebuild holds a table-level write lock and submitting +-- builders block until it finishes. Run it in a maintenance window, and measure first to +-- know how long that window has to be: +-- +-- SELECT table_name, engine, table_rows, +-- ROUND(data_length /1024/1024) AS data_mb, +-- ROUND(index_length/1024/1024) AS index_mb +-- FROM information_schema.tables +-- WHERE table_schema = 'testman' +-- ORDER BY data_length DESC; +-- +-- winetest_logs is deliberately left alone. It is by far the largest table (one +-- mediumblob per suite per run) and is only ever read by primary key, so converting it +-- would dominate the window without buying anything. + +ALTER TABLE `winetest_runs` + ENGINE=InnoDB, + ADD KEY `ix_runs_ts` (`finished`,`timestamp`,`id`), + ADD KEY `ix_runs_src_plat` (`source_id`,`platform`,`timestamp`); + +ALTER TABLE `winetest_results` + ENGINE=InnoDB; diff --git a/resources/testman/testman.sql b/resources/testman/testman.sql index a4284030a..f75127e57 100644 --- a/resources/testman/testman.sql +++ b/resources/testman/testman.sql @@ -26,7 +26,7 @@ CREATE TABLE `winetest_results` ( PRIMARY KEY (`id`), UNIQUE KEY `test_and_suite` (`test_id`,`suite_id`), KEY `suite_id` (`suite_id`) -) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_general_ci; CREATE TABLE `winetest_runs` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT, @@ -46,8 +46,10 @@ CREATE TABLE `winetest_runs` ( `time` float unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `revision` (`revision`), - KEY `platform` (`platform`) -) ENGINE=MyISAM DEFAULT CHARSET=latin1 COLLATE=latin1_general_ci; + KEY `platform` (`platform`), + KEY `ix_runs_ts` (`finished`,`timestamp`,`id`), + KEY `ix_runs_src_plat` (`source_id`,`platform`,`timestamp`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_general_ci; CREATE TABLE `winetest_suites` ( `id` int(10) unsigned NOT NULL auto_increment, diff --git a/www/www.reactos.org/testman/api/config.inc.php b/www/www.reactos.org/testman/api/config.inc.php new file mode 100644 index 000000000..33d4ab760 --- /dev/null +++ b/www/www.reactos.org/testman/api/config.inc.php @@ -0,0 +1,14 @@ +modify("+1 day"); + + return $date->format("Y-m-d H:i:s"); + } + + header("Content-Type: application/json; charset=utf-8"); + + try + { + // Every filter is optional and independent, so adding one is a block here and + // nothing else. + $where = array("r.finished = 1"); + $params = array(); + + $from = get_date_param("from"); + if ($from !== NULL) + { + $where[] = "r.timestamp >= :from"; + $params[":from"] = $from; + } + + $to = get_date_param("to", TRUE); + if ($to !== NULL) + { + $where[] = "r.timestamp < :to"; + $params[":to"] = $to; + } + + // A prefix of the commit hash. KEY (revision) serves this at the full 40 chars, + // and unlike the revision range of ajax-search.php it does not consult gitinfo, + // so it also finds runs whose commit gitinfo never saw. + $rev = get_param("rev", "#^[0-9a-fA-F]{4,40}$#"); + if ($rev !== NULL) + { + $where[] = "r.revision LIKE :rev"; + $params[":rev"] = strtolower($rev) . "%"; + } + + // sources.id, not a LIKE on the display name. + $source = get_int_param("source", 1); + if ($source !== NULL) + { + $where[] = "r.source_id = :source"; + $params[":source"] = $source; + } + + $platform = get_param("platform", "#^[A-Za-z0-9._-]{1,24}$#"); + if ($platform !== NULL) + { + $where[] = "r.platform LIKE :platform"; + $params[":platform"] = $platform . "%"; + } + + $min_failures = get_int_param("min_failures", 0); + if ($min_failures !== NULL) + { + $where[] = "r.failures >= :min_failures"; + $params[":min_failures"] = $min_failures; + } + + // Keyset paging. "older" continues below the last id of the previous page, + // "newer" continues above its first id. No offsets, and no COUNT(*): the one + // extra row fetched below is what tells us whether a further page exists. + $newer = (get_param("dir", "#^(older|newer)$#") === "newer"); + $cursor = get_int_param("cursor", 1); + if ($cursor !== NULL) + { + $where[] = $newer ? "r.id > :cursor" : "r.id < :cursor"; + $params[":cursor"] = $cursor; + } + + $limit = get_int_param("limit", 1); + if ($limit === NULL) + $limit = API_PAGE_SIZE; + + $limit = min($limit, API_MAX_PAGE_SIZE); + + $dbh = new PDO("mysql:host=" . TESTMAN_DB_HOST . ";dbname=" . TESTMAN_DB_NAME, TESTMAN_DB_USER, TESTMAN_DB_PASS); + $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // a date filter leaves a filesort inside the ix_runs_ts range, which is + // cheap for windows up to about a year. If wider windows ever get slow, resolve + // from/to into an id range first - timestamp is monotonic with id, because both + // are assigned when the run is inserted - and page on the primary key alone. + $stmt = $dbh->prepare( + "SELECT r.id, UNIX_TIMESTAMP(r.timestamp) AS timestamp, r.source_id, src.name AS source, " . + "r.revision, r.platform, r.comment, r.count, r.failures " . + "FROM winetest_runs r " . + "JOIN sources src ON r.source_id = src.id " . + "WHERE " . implode(" AND ", $where) . " " . + "ORDER BY r.id " . ($newer ? "ASC" : "DESC") . " " . + "LIMIT " . ($limit + 1) + ); + $stmt->execute($params); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // The extra row is only a probe, it never gets rendered. + $has_more = (count($rows) > $limit); + if ($has_more) + array_pop($rows); + + // Always hand out the page newest first, whichever direction it was read in. + if ($newer) + $rows = array_reverse($rows); + + $runs = array(); + + foreach ($rows as $row) + { + $runs[] = array( + "id" => (int)$row["id"], + "timestamp" => (int)$row["timestamp"], + // Preformatted here so that the client agrees with every page that renders + // a date server-side, whatever timezone the browser happens to be in. + "date" => GetDateString($row["timestamp"]), + "source_id" => (int)$row["source_id"], + "source" => $row["source"], + "revision" => $row["revision"], + "revision_short" => substr($row["revision"], 0, 7), + "platform" => $row["platform"], + "platform_name" => GetPlatformString($row["platform"]), + "comment" => $row["comment"], + "count" => (int)$row["count"], + "failures" => (int)$row["failures"], + ); + } + + echo json_encode(array( + "runs" => $runs, + // Whether a further page exists in the direction that was asked for. + "has_more" => $has_more, + // Cursors for the two directions: "newer" from first_id, "older" from last_id. + "first_id" => count($runs) ? $runs[0]["id"] : NULL, + "last_id" => count($runs) ? $runs[count($runs) - 1]["id"] : NULL, + )); + } + catch (InvalidArgumentException $e) + { + http_response_code(400); + echo json_encode(array("error" => $e->getMessage())); + } + catch (Exception $e) + { + error_log("testman api/runs.php: " . $e->getFile() . ":" . $e->getLine() . " - " . $e->getMessage()); + http_response_code(500); + echo json_encode(array("error" => "Internal error")); + } diff --git a/www/www.reactos.org/testman/compare.php b/www/www.reactos.org/testman/compare.php index 2311078c4..935044390 100644 --- a/www/www.reactos.org/testman/compare.php +++ b/www/www.reactos.org/testman/compare.php @@ -61,8 +61,6 @@ $stmt = $reader->getTestRunInfoStatement($i); $row = $stmt->fetch(PDO::FETCH_ASSOC); - $indicator = new Indicator($row["id"]); - $table_summary .= ''; $table_summary .= sprintf($testman_langres["resulthead"], $gi->getShortHash($row["revision"]), htmlspecialchars($row["comment"]), GetDateString($row["timestamp"]), $row["name"], GetPlatformString($row["platform"])); $table_summary .= ''; @@ -70,7 +68,7 @@ $table_totals .= ''; $table_totals .= sprintf('
%s %s
', $testman_langres["totaltests"], $row["count"], GetDifference($row, $prev_row, "count")); $table_totals .= sprintf('
%d %s
', $testman_langres["failedtests"], ($row["failures"] > 0 ? 'real' : 'zero'), $row["failures"], GetDifference($row, $prev_row, "failures")); - $table_totals .= sprintf('
healthindicator
', $indicator->getImagePath()); + $table_totals .= sprintf('
healthindicator
', $row["id"]); $table_totals .= ''; $table_separator .= " "; diff --git a/www/www.reactos.org/testman/config.inc.php b/www/www.reactos.org/testman/config.inc.php index 4000dca82..877222a1c 100644 --- a/www/www.reactos.org/testman/config.inc.php +++ b/www/www.reactos.org/testman/config.inc.php @@ -7,15 +7,18 @@ */ define("ROOT_PATH", "../"); - define("INDICATORS_PATH", "indicators/"); - define("DEFAULT_SEARCH_LIMIT", 10); - define("DEFAULT_SEARCH_SOURCE", "Build GCCLin_x86 on Test KVM"); - define("MAX_COMPARE_RESULTS", 8); + define("MAX_COMPARE_RESULTS", 16); define("RESULTS_PER_PAGE", 10); define("MACHINE_REBOOTS_THRESHOLD", 2); define("REV_RANGE_LIMIT", 3000); + // A source is listed on the front page if it submitted a result within this many days. + define("LANDING_ACTIVE_DAYS", 30); + + // Runs per page of the test suite history. + define("SUITE_HISTORY_PAGE_SIZE", 50); + define("VIEWVC", "https://git.reactos.org/?p=reactos.git"); define("VIEWVC_TRUNK", VIEWVC . ";a=blob"); define("BLACKLIST_URL", "blacklist.txt"); diff --git a/www/www.reactos.org/testman/css/index.css b/www/www.reactos.org/testman/css/index.css index 18b39640c..7dfe8b68d 100644 --- a/www/www.reactos.org/testman/css/index.css +++ b/www/www.reactos.org/testman/css/index.css @@ -40,3 +40,31 @@ .comboedit div input { border-right: 0; } + +.testman-filters label { + margin-bottom: 2px; +} + +.testman-filters .date-range { + display: flex; + align-items: center; +} + +.testman-filters .date-range input { + flex: 1 1 auto; + min-width: 0; +} + +.testman-filters .date-range span { + padding: 0 6px; +} + +/* Lines the buttons up with the inputs beside them, which each sit under a label. */ +.filter-actions { + margin-top: 24px; +} + +.filter-actions .opennewwindow { + font-weight: normal; + margin-left: 8px; +} diff --git a/www/www.reactos.org/testman/css/suite.css b/www/www.reactos.org/testman/css/suite.css new file mode 100644 index 000000000..adf1c2563 --- /dev/null +++ b/www/www.reactos.org/testman/css/suite.css @@ -0,0 +1,34 @@ +/* + PROJECT: ReactOS Web Test Manager + LICENSE: GNU GPLv2 or any later version as published by the Free Software Foundation + PURPOSE: Stylesheet for the Test Suite History Page + COPYRIGHT: Copyright 2026 Mark Jansen +*/ + +.pagesbox { + margin-bottom: 10px; +} + +/* The rows where the result differs from the run before it. Everything else is the + same outcome repeated, which is exactly what you want to skip over. */ +#suitetable tr.changed td { + background-color: #fcf8e3; + font-weight: bold; +} + +#suitetable td.notrun { + color: #999; + font-style: italic; +} + +#suitetable .diff { + color: #999; + font-weight: normal; +} + +/* The date and the links each fit on one line; letting them wrap doubled the height of + every row in the table. The comment column has the width to spare. */ +#suitetable td:first-child, +#suitetable td:last-child { + white-space: nowrap; +} diff --git a/www/www.reactos.org/testman/css/testman.css b/www/www.reactos.org/testman/css/testman.css new file mode 100644 index 000000000..45a3223c2 --- /dev/null +++ b/www/www.reactos.org/testman/css/testman.css @@ -0,0 +1,32 @@ +/* + PROJECT: ReactOS Web Test Manager + LICENSE: GNU GPLv2 or any later version as published by the Free Software Foundation + PURPOSE: Chrome shared by the Testman tool pages + COPYRIGHT: Copyright 2026 Mark Jansen +*/ + +/* The page header band is decoration. On a tool page it pushed the first result most of + a screen down, so it gets the space it needs and no more. */ +#heading-breadcrumbs { + padding-top: 8px; + padding-bottom: 8px; + margin-bottom: 15px; +} + +#heading-breadcrumbs h1 { + margin: 8px 0; +} + +/* The shared header ends with an empty container that the site layout grows to push the + footer down. Testman renders its content in the siblings after it, so on a page short + enough to leave slack, all of that slack piles up above the breadcrumb band as blank + space. Let the content area take the slack instead, which is where it was meant to go. */ +body > .container-fluid { + /* !important because the winning declaration is in the shared stylesheet served from + reactos.org, which testman does not control and cannot reorder. */ + flex-grow: 0 !important; +} + +#content { + flex-grow: 1; +} diff --git a/www/www.reactos.org/testman/detail.php b/www/www.reactos.org/testman/detail.php index 7dd26b541..58e4d1b35 100644 --- a/www/www.reactos.org/testman/detail.php +++ b/www/www.reactos.org/testman/detail.php @@ -34,7 +34,7 @@ // Get information about this result. $stmt = $dbh->prepare( - "SELECT UNCOMPRESS(l.log) AS log, e.status, e.count, e.failures, e.skipped, e.todo, e.time, s.module, s.test, UNIX_TIMESTAMP(r.timestamp) AS timestamp, r.revision, r.platform, src.name, r.comment " . + "SELECT UNCOMPRESS(l.log) AS log, e.status, e.count, e.failures, e.skipped, e.todo, e.time, s.module, s.test, UNIX_TIMESTAMP(r.timestamp) AS timestamp, r.revision, r.platform, src.name, r.comment, e.suite_id, r.source_id " . "FROM winetest_results e " . "JOIN winetest_logs l ON e.id = l.id " . "JOIN winetest_suites s ON e.suite_id = s.id " . @@ -145,6 +145,10 @@ function get_file_url($module, $file) echo ''; } ?> + + + &source=&platform="> + :
diff --git a/www/www.reactos.org/testman/index.php b/www/www.reactos.org/testman/index.php index 1d1a4d040..7a386183b 100644 --- a/www/www.reactos.org/testman/index.php +++ b/www/www.reactos.org/testman/index.php @@ -5,10 +5,12 @@ * PURPOSE: Front Page for managing ReactOS Regression Test results over the web * COPYRIGHT: Copyright 2008-2020 Colin Finck (colin@reactos.org) * Copyright 2012-2013 Aleksey Bragin (aleksey@reactos.org) + * Copyright 2026 Mark Jansen (mark.jansen@reactos.org) */ require_once("config.inc.php"); require_once(ROOT_PATH . "../www.reactos.org_config/testman-connect.php"); + require_once("utils.inc.php"); require_once("languages.inc.php"); require_once(ROOT_PATH . "rosweb/gitinfo.php"); require_once(ROOT_PATH . "rosweb/rosweb.php"); @@ -19,16 +21,65 @@ require_once(ROOT_PATH . "rosweb/lang/$lang.inc.php"); require_once("lang/$lang.inc.php"); + // The filters the page understands. They are the parameters of api/runs.php, so a + // search is fully described by the query string and can be bookmarked and shared. + $FILTER_KEYS = array("from", "to", "rev", "source", "platform", "min_failures", "cursor", "dir"); + try { $gi = new GitInfo(); - $revisions = $gi->getLatestRevisions(2); - $rev = isset($revisions[0]) ? $gi->getShortHash($revisions[0]) : ""; - $rev_before = isset($revisions[1]) ? $gi->getShortHash($revisions[1]) : ""; + $rev = $gi->getShortHash($gi->getLatestRevision()); // Connect to the database. $dbh = new PDO("mysql:host=" . TESTMAN_DB_HOST . ";dbname=" . TESTMAN_DB_NAME, TESTMAN_DB_USER, TESTMAN_DB_PASS); $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // With a filter in the URL the JavaScript renders the matching runs, so the + // overview below would only be in the way. + $has_filter = (bool)array_intersect($FILTER_KEYS, array_keys($_GET)); + $cutoff = time() - LANDING_ACTIVE_DAYS * 86400; + + $sources = $dbh->query("SELECT id, name FROM sources ORDER BY name")->fetchAll(PDO::FETCH_ASSOC); + + // Only offer platforms that something actually still submits for. + $stmt = $dbh->prepare("SELECT DISTINCT platform FROM winetest_runs WHERE finished = 1 AND timestamp >= FROM_UNIXTIME(:cutoff) ORDER BY platform"); + $stmt->execute(array(":cutoff" => $cutoff)); + $platforms = $stmt->fetchAll(PDO::FETCH_COLUMN); + + // The overview: the newest run of every source that is still submitting, plus the + // one before it so the row can show what changed. + // one small query per source. There is a handful of them and each is + // served by ix_runs_src_plat; fold it into a single window function query if the + // source list ever grows. + $overview = array(); + + if (!$has_filter) + { + $stmt = $dbh->prepare( + "SELECT r.id, UNIX_TIMESTAMP(r.timestamp) AS timestamp, r.revision, r.platform, r.count, r.failures, r.comment " . + "FROM winetest_runs r " . + "WHERE r.finished = 1 AND r.source_id = :source_id " . + "ORDER BY r.id DESC LIMIT 2" + ); + + foreach ($sources as $source) + { + $stmt->execute(array(":source_id" => $source["id"])); + $runs = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (!count($runs) || $runs[0]["timestamp"] < $cutoff) + continue; + + $overview[] = array( + "source" => $source, + "run" => $runs[0], + "prev" => isset($runs[1]) ? $runs[1] : null, + ); + } + + // Whoever finished last goes on top. + usort($overview, function($a, $b) { return $b["run"]["timestamp"] - $a["run"]["timestamp"]; }); + } } catch (Exception $e) { @@ -41,17 +92,14 @@ <?php echo $testman_langres["index_title"]; ?> printHead(); ?> - + "> + "> - - - - + + + @@ -68,64 +116,91 @@
-

-
- -
-
- +
+
+
+ + +
-
- - +
+ +
-
-
- - -
-
- -
+
+ +
+ "> + + ">
-
- -
- -
-
+
+ +
-
-
-
- -

-
+
+
+ + "> +
-
- - +
+ + + + + + +
+
-
- -
+
diff --git a/www/www.reactos.org/testman/indicator.php b/www/www.reactos.org/testman/indicator.php new file mode 100644 index 000000000..9658ab54b --- /dev/null +++ b/www/www.reactos.org/testman/indicator.php @@ -0,0 +1,37 @@ +render(); + + header("Content-Type: image/png"); + + // A finished run never changes again, so the browser keeps the image instead of + // the web server keeping one file per run forever. + if ($indicator->isFinished()) + header("Cache-Control: public, max-age=31536000, immutable"); + else + header("Cache-Control: no-store"); + + echo $png; + } + catch (Exception $e) + { + http_response_code(400); + header("Content-Type: text/plain"); + echo $e->getMessage(); + } diff --git a/www/www.reactos.org/testman/js/index.js b/www/www.reactos.org/testman/js/index.js index 734d25849..6e54c17ff 100644 --- a/www/www.reactos.org/testman/js/index.js +++ b/www/www.reactos.org/testman/js/index.js @@ -1,29 +1,43 @@ /* * PROJECT: ReactOS Testman - * LICENSE: GPL-2.0+ (https://spdx.org/licenses/GPL-2.0+) + * LICENSE: GPL-2.0-or-later (https://spdx.org/licenses/GPL-2.0-or-later) * PURPOSE: JavaScript file for the Testman Front Page * COPYRIGHT: Copyright 2008-2017 Colin Finck (colin@reactos.org) * Copyright 2014 Kamil Hornicek (kamil.hornicek@reactos.org) + * Copyright 2026 Mark Jansen (mark.jansen@reactos.org) */ -var CurrentPage; -var data; -var RevisionRangeStart; -var RevisionRangeEnd; -var PageCount; -var ResultCount; +// The filters of api/runs.php, mapped to the form fields that hold them. The whole +// search state lives in the query string, so every search is a shareable URL. +var FILTER_FIELDS = { + from: "search_from", + to: "search_to", + rev: "search_revision", + source: "search_source", + platform: "search_platform", + min_failures: "search_min_failures" +}; + +// The response of the page that is currently shown, for the Newer/Older buttons. +var CurrentQuery = null; +var CurrentResponse = null; + var SelectedResults = new Object(); var SelectedResultCount = 0; -var REQUESTTYPE_FULLLOAD = 1; -var REQUESTTYPE_ADDPAGE = 2; -var REQUESTTYPE_PAGESWITCH = 3; - function SetLoading(value) { document.getElementById("ajax_loading_search").style.visibility = (value ? "visible" : "hidden"); } +function Escape(value) +{ + if (value === null || value === undefined) + return ""; + + return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + /** * Make sure that all checkboxes for the results in SelectedResults are checked. */ @@ -74,270 +88,268 @@ function ResultCell_OnClick(elem) OpenComparePage(IDArray); } -function GetRevisions() +/** + * Collects the filters from the form, leaving out the ones that were not filled in. + */ +function ReadFilters() { - var revisions = document.getElementById("search_revision").value; + var filters = new Object(); - // If the user didn't enter any revision number at all, he doesn't want to search for a specific revision - if (!revisions) + for (var name in FILTER_FIELDS) { - RevisionRangeStart = ""; - RevisionRangeEnd = ""; - return true; - } + var value = document.getElementById(FILTER_FIELDS[name]).value.trim(); - var hyphen = revisions.indexOf("-"); - if (hyphen > 0) - { - RevisionRangeStart = revisions.substr(0, hyphen); - RevisionRangeEnd = revisions.substr(hyphen + 1); - } - else - { - RevisionRangeStart = revisions; - RevisionRangeEnd = revisions; + if (value) + filters[name] = value; } - return (RevisionRangeStart && RevisionRangeEnd); + return filters; } -function SearchCall() +function WriteFilters(query) { - SetLoading(true); - AjaxGet("ajax-search.php", "SearchCallback", data); + for (var name in FILTER_FIELDS) + document.getElementById(FILTER_FIELDS[name]).value = (name in query) ? query[name] : ""; } -function SearchButton_OnClick() +function BuildQueryString(query) { - if (!GetRevisions()) + var parts = new Array(); + + for (var name in query) { - alert(shared_langres["invalidrev"]); - return; + if (query[name] !== null && query[name] !== "") + parts.push(encodeURIComponent(name) + "=" + encodeURIComponent(query[name])); } - CurrentPage = 1; - data = new Array(); - data["startrev"] = RevisionRangeStart; - data["endrev"] = RevisionRangeEnd; - data["source"] = document.getElementById("search_source").value; - data["platform"] = document.getElementById("search_platform").value; - data["page"] = CurrentPage; - data["resultlist"] = 1; - data["requesttype"] = REQUESTTYPE_FULLLOAD; - - if (window.localStorage) - localStorage.setItem("testman_source", data["source"]); - - SearchCall(); + return parts.join("&"); } -function ResizeIFrame() +function ParseQueryString(search) { - var iframe = document.getElementById("comparepage_frame"); - iframe.height = iframe.contentDocument.body.offsetHeight + 40; -} + var query = new Object(); + var parts = search.replace(/^\?/, "").split("&"); -function Load() -{ - // React on Return key presses. - var f = function(keyevent) + for (var i = 0; i < parts.length; i++) { - // keyevent.which - supported under NS 4.0, Opera 5.12, Firefox, Konqueror 3.3, Safari - // window.event - for IE Browsers - if((keyevent && keyevent.which == 13) || (window.event && window.event.keyCode == 13)) - SearchButton_OnClick(); - }; - document.getElementById("search_revision").onkeypress = f; - document.getElementById("search_source").onkeypress = f; - document.getElementById("search_platform").onkeypress = f; + if (!parts[i]) + continue; - // Load the settings. - if (window.localStorage) - { - document.getElementById("opennewwindow").checked = parseInt(window.localStorage.getItem("testman_opennewwindow")); - document.getElementById("search_source").value = window.localStorage.getItem("testman_source") ? window.localStorage.getItem("testman_source") : DEFAULT_SEARCH_SOURCE; + var pair = parts[i].split("="); + query[decodeURIComponent(pair[0])] = decodeURIComponent((pair[1] || "").replace(/\+/g, " ")); } - // Search for the 10 last results, sorted with the newest on top. - // Descending order and limiting is not doable with the regular Search function, so we have to do the call ourselves. - CurrentPage = 1; - data = new Array(); - data["desc"] = 1; - data["limit"] = DEFAULT_SEARCH_LIMIT; - data["source"] = document.getElementById("search_source").value; - data["page"] = CurrentPage; - data["resultlist"] = 1; - data["requesttype"] = REQUESTTYPE_FULLLOAD; - - SearchCall(); + return query; } -function GetTagData(RootElement, TagName) +/** + * Runs the query against api/runs.php and renders the result. + * + * @param query + * The filters plus the optional "cursor" and "dir" of the page to show. + * + * @param push + * Whether to add the query to the browser history. False when the query came from the + * history in the first place, i.e. on page load and when going back. + */ +function Search(query, push) { - var Child = RootElement.getElementsByTagName(TagName)[0].firstChild; - return Child ? Child.data : ""; + var querystring = BuildQueryString(query); + + CurrentQuery = query; + SetLoading(true); + + // Paging is a seek on the run id, so there is exactly one request per page and no + // total count. Nothing walks the result set any more. + fetch("api/runs.php?" + querystring) + .then(function(response) { return response.json(); }) + .then(function(data) + { + SetLoading(false); + + if (data.error) + { + alert(data.error); + return; + } + + CurrentResponse = data; + RenderResults(data); + + document.getElementById("overview").style.display = "none"; + + if (push) + history.pushState(query, "", querystring ? "?" + querystring : location.pathname); + }) + .catch(function(error) + { + SetLoading(false); + alert(testman_langres["loadfailed"] + "\n\n" + error); + }); } -function SearchCallback(HttpRequest) +function RenderResults(data) { - // Check for an error - if (HttpRequest.responseXML.getElementsByTagName("error").length > 0) - { - alert(HttpRequest.responseXML.getElementsByTagName("error")[0].firstChild.data) - return; - } + // has_more only speaks about the direction that was asked for. In the other one we + // either came from a page (so it exists) or we are at the newest run (so it does not). + var newer = (CurrentQuery["dir"] == "newer"); + var HasOlder = newer ? true : data.has_more; + var HasNewer = newer ? data.has_more : !!CurrentQuery["cursor"]; var html = ""; - var RequestResultCount = parseInt(HttpRequest.responseXML.getElementsByTagName("resultcount")[0].firstChild.data); - var MoreResults = (RequestResultCount > RESULTS_PER_PAGE); - var FirstRev = ""; - var LastRev = ""; - if (RequestResultCount > 0) + html += '
'; + html += testman_langres["showingresults"].replace(/\{1\}/, data.runs.length); + html += '<\/div>'; + + html += '
'; + html += testman_langres["status"].replace(/\{1\}/, '' + SelectedResultCount + '<\/span>'); + html += '