diff --git a/resources/devsetup/README.md b/resources/devsetup/README.md new file mode 100644 index 000000000..63c172c22 --- /dev/null +++ b/resources/devsetup/README.md @@ -0,0 +1,78 @@ +# 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 + ``` + + `testman.sql` already includes every migration in `../testman/`, so a fresh database + needs none of them. An existing one does; see the README there. + +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. + +## Anchoring the imported runs + +`import-live.php` writes runs straight into the database rather than through the web +service, so they arrive without their `base_order`, `ref` and `pr_number`. Roughly a third +of recent runs are pull request builds, and until they are anchored they are missing from +revision ranges and indistinguishable from master runs. Fix that afterwards with: + +``` +php ../testman/backfill-run-anchors.php +``` + +The first run sweeps the BuildBot's build index, which takes a few minutes and is then +cached. See `../testman/README.md`. + +### 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/resources/testman/README.md b/resources/testman/README.md new file mode 100644 index 000000000..0715010d8 --- /dev/null +++ b/resources/testman/README.md @@ -0,0 +1,101 @@ +# Testman database + +`testman.sql` is the schema after every migration here has been applied, so a **new** +database needs only that: + +``` +mysql testman < testman.sql +``` + +An **existing** database needs the migrations, in order, once each. + +## Upgrading an existing database + +``` +mysql testman < migrate-01-timeline.sql # maintenance window, see below +GITHUB_TOKEN=... php backfill-run-anchors.php # can run while the site is up +``` + +**`migrate-01-timeline.sql`** converts `winetest_runs` and `winetest_results` to InnoDB, +adds the indexes that date filtering and the run browser need, and adds the columns that +put a run on master's timeline. One `ALTER` per table, because each change rebuilds the +table anyway. The tables are MyISAM going in, so that rebuild holds a write lock and +submitting builders block; the file has the query to size the window first. + +**`backfill-run-anchors.php`** fills those columns for runs that predate them. Resumable +and re-runnable, and it caches every external answer, so a second run costs almost +nothing. `--help` for the options. + +## What the anchor columns are for + +A run's identity (the exact commit it built) and its position on master's timeline (what +it was built against) are not the same thing. For a master build they coincide. For a +build of `refs/pull/9453/merge` they do not — that hash is one GitHub synthesised, it is +not in gitinfo and never will be, which is why searching for such a run used to fail and a +revision range used to omit it silently. + +| Column | Master run | Pull request run | +| --- | --- | --- | +| `revision` | the exact commit that was built, unchanged | | +| `base_revision` | same as `revision` | the master commit underneath it | +| `base_order` | `master_revisions.id` of `base_revision` — the sortable axis | | +| `base_exact` | `1` | `0` when the position was guessed from the run's clock | +| `ref` | `refs/heads/master` | `refs/pull/9453/merge` or `.../head`, verbatim | +| `pr_number` | `NULL` | `9453`, parsed from `ref` | + +`ref` is stored exactly as the builder reports it. Normalising it to `pr/9453` would lose +the `/merge` versus `/head` distinction, and those are different things whose bases are +computed differently: `/merge` is the pull request merged into master, `/head` is the +pull request branch on its own. + +Old SVN runs keep `NULL` in every anchor column. They predate git, gitinfo has no ordinal +for them, and they stay reachable by date. + +## Where the backfill gets its answers + +Cheapest first, falling through: + +1. **Master builds** resolve against gitinfo alone, no network. That is most of the archive. +2. **The ref and PR number** come from the BuildBot. A run's `comment` starts with + `Build ` and its source name ends in the builder that ran it + (`Build GCCLin_x86 on Test KVM` → `Test KVM`), which together address one build whose + `branch` property is the ref. Build properties are kept forever, so this is always + recoverable. Both the bare `master` and `refs/heads/master` spellings appear; the + BuildBot used the first until around build 26,800. +3. **The base of a PR build** comes from the `prepare_source` step log, where `git show` + prints `Merge: `. But **the janitor prunes log contents after a few + hundred builds** — an old build still lists its `stdio` log and still answers `200`, + with an empty body — so this only covers recent builds. +4. **GitHub** covers the rest, one request per distinct commit: `/commits/` gives + `parents[0]` for a `/merge` ref, and `/compare/master...` gives + `merge_base_commit` for a `/head` ref, whose first parent is the previous commit of the + branch and *not* the base. +5. **The clock**, last resort: newest master commit at or before the run's timestamp, + stored with `base_exact = 0`. + +Set `GITHUB_TOKEN` for a full archive run because of step 4. Without it GitHub allows 60 +requests an hour instead of 5000, and the script stops and asks to be re-run rather than +filling the rest of the archive with clock guesses that nothing would ever revisit. + +The PR number is never scraped out of `comment`. The "Reason" half is free text someone +typed when triggering the build, and the live data has runs of PR 9310 whose reason reads +`PR 9449`. + +## Self-checks + +Against a development database and the local site, never production. + +``` +php selftest-anchors.php [base-url] [sourceid] [password] +php selftest-pr-history.php [base-url] +``` + +The first submits runs through the real web service and checks the anchor columns each one +gets: the three resolution cases, `/merge` versus `/head`, that an SVN revision number is +not mistaken for a hash prefix, and that a malformed `ref` or `baserevision` is rejected. +It deletes the runs it created. + +The second checks that pull request builds stay off master's own history: turning them on +adds rows to the suite history without moving any of master's change marks, a PR build is +never marked as a change itself, and a lone PR run's compare page picks its own master +baseline. Run the backfill first, or it has nothing to look at. diff --git a/resources/testman/backfill-run-anchors.php b/resources/testman/backfill-run-anchors.php new file mode 100644 index 000000000..e84e6583b --- /dev/null +++ b/resources/testman/backfill-run-anchors.php @@ -0,0 +1,847 @@ + default_cache_dir(), "limit" => 0, "restart" => FALSE, "dry-run" => FALSE, "refresh" => FALSE); + + foreach (array_slice($argv, 1) as $arg) + { + if (preg_match("#^--(cache|limit)=(.+)$#", $arg, $m)) + $options[$m[1]] = ($m[1] === "limit") ? (int)$m[2] : $m[2]; + else if (in_array($arg, array("--restart", "--dry-run", "--refresh"))) + $options[substr($arg, 2)] = TRUE; + else + usage(); + } + + if (!is_dir($options["cache"]) && !@mkdir($options["cache"], 0777, TRUE)) + die("Could not create the cache directory " . $options["cache"] . "\n"); + + + //// HELPERS //// + + function progress($message) + { + echo $message . "\n"; + flush(); + } + + /** + * A GET that gives the far end the benefit of the doubt twice before failing. + * + * @param int $status + * Out. The HTTP status of the last attempt, which callers need to tell a commit that + * is gone apart from a rate limit that will lift. + * + * @return + * The body, or NULL if the server answered 4xx. A missing build, a missing log and an + * exhausted rate limit are all normal outcomes here, not errors. + */ + function http_get($url, $headers = "", &$status = NULL) + { + for ($attempt = 1; $attempt <= 3; $attempt++) + { + $context = stream_context_create(array( + "http" => array( + "method" => "GET", + "header" => "User-Agent: " . USER_AGENT . "\r\n" . $headers, + "timeout" => 120, + "ignore_errors" => TRUE, + ) + )); + + $body = @file_get_contents($url, FALSE, $context); + $status = 0; + + foreach (isset($http_response_header) ? $http_response_header : array() as $header) + { + if (preg_match("#^HTTP/[0-9.]+ ([0-9]{3})#", $header, $m)) + $status = (int)$m[1]; + } + + // Retrying a 4xx just asks the same question again and gets the same answer. + if ($status >= 400 && $status < 500) + return NULL; + + if ($body !== FALSE && $status >= 200 && $status < 300) + return $body; + + if ($attempt < 3) + sleep($attempt * 2); + } + + throw new RuntimeException("Could not fetch $url (HTTP $status)"); + } + + function http_get_json($url, $headers = "") + { + $body = http_get($url, $headers); + if ($body === NULL) + return NULL; + + $data = json_decode($body, TRUE); + if (!is_array($data)) + throw new RuntimeException("Unexpected response from $url"); + + return $data; + } + + function cache_path($name) + { + global $options; + return $options["cache"] . DIRECTORY_SEPARATOR . $name; + } + + function cache_read($name) + { + $path = cache_path($name); + if (!is_file($path)) + return NULL; + + $data = json_decode(file_get_contents($path), TRUE); + return is_array($data) ? $data : NULL; + } + + function cache_write($name, $data) + { + // Write and rename, so an interrupted run cannot leave a half-written index that + // the next one would happily believe. + $path = cache_path($name); + $tmp = $path . ".tmp"; + + file_put_contents($tmp, json_encode($data)); + rename($tmp, $path); + } + + 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; + } + + + //// BUILDBOT //// + + /** + * The BuildBot's builders, as name => id. + */ + function get_builders() + { + static $builders = NULL; + + if ($builders === NULL) + { + $builders = cache_read("builders.json"); + + if ($builders === NULL) + { + $builders = array(); + + foreach (http_get_json(BUILDBOT_API . "/builders")["builders"] as $builder) + $builders[$builder["name"]] = (int)$builder["builderid"]; + + cache_write("builders.json", $builders); + } + } + + return $builders; + } + + /** + * Every build of one builder, as number => array(branch, got_revision). + * + * Swept in windows rather than one request per build: the whole archive of a builder + * is a hundred or so requests this way, and it is cached on disk afterwards, so a + * re-run of this script does not touch the BuildBot at all. + */ + function get_builder_builds($builder_id) + { + global $options; + static $indexes = array(); + + if (isset($indexes[$builder_id])) + return $indexes[$builder_id]; + + $name = "builder-$builder_id-builds.json"; + $index = $options["refresh"] ? NULL : cache_read($name); + + if ($index === NULL) + $index = array("swept_to" => 0, "builds" => array()); + + // Where the builder is now. Anything above what we swept last time is new. + $latest = http_get_json(BUILDBOT_API . "/builders/$builder_id/builds?limit=1&order=-number&field=number"); + $max = ($latest === NULL || !count($latest["builds"])) ? 0 : (int)$latest["builds"][0]["number"]; + + for ($from = (int)$index["swept_to"] + 1; $from <= $max; $from += BUILD_WINDOW) + { + $to = min($from + BUILD_WINDOW - 1, $max); + $url = BUILDBOT_API . "/builders/$builder_id/builds?number__ge=$from&number__le=$to" . + "&property=branch&property=got_revision&field=number&field=properties"; + + $page = http_get_json($url); + + foreach ((($page === NULL || !isset($page["builds"])) ? array() : $page["builds"]) as $build) + { + $props = isset($build["properties"]) ? $build["properties"] : array(); + $branch = isset($props["branch"]) ? $props["branch"][0] : NULL; + $revision = isset($props["got_revision"]) ? $props["got_revision"][0] : NULL; + + // Builds from before the BuildBot recorded these are of no use to us and + // only make the index bigger. + if ($branch === NULL && $revision === NULL) + continue; + + $index["builds"][(string)$build["number"]] = array($branch, $revision); + } + + $index["swept_to"] = $to; + progress(sprintf(" builder %d: swept up to build %d/%d (%d indexed)", $builder_id, $to, $max, count($index["builds"]))); + cache_write($name, $index); + } + + $indexes[$builder_id] = $index["builds"]; + return $indexes[$builder_id]; + } + + /** + * Which build of a prepare_source builder produced a given commit, as + * array(builder_id, number). Built once across every such builder and cached. + */ + function find_prepare_source_build($revision) + { + global $PREPARE_SOURCE_BUILDERS; + static $by_revision = NULL; + + if ($by_revision === NULL) + { + $builders = get_builders(); + $by_revision = array(); + + foreach ($PREPARE_SOURCE_BUILDERS as $name) + { + if (!isset($builders[$name])) + { + progress(" note: the BuildBot has no builder named '$name', skipping it"); + continue; + } + + $id = $builders[$name]; + + foreach (get_builder_builds($id) as $number => $build) + { + // First one wins: they all built the same commit, and re-fetching a + // second log for it would tell us the same thing. + if ($build[1] !== NULL && !isset($by_revision[$build[1]])) + $by_revision[$build[1]] = array($id, (int)$number); + } + } + } + + return isset($by_revision[$revision]) ? $by_revision[$revision] : NULL; + } + + /** + * The abbreviated first parent of a commit, read out of the "git show" output that + * prepare_source prints. + * + * For refs/pull/N/merge that first parent is the base by construction: GitHub builds + * the merge ref as "PR head merged into master", so parent 1 is the master commit it + * was merged into. A single-parent commit has no "Merge:" line and returns NULL. + */ + function parse_merge_parent($log, $revision) + { + // Guard against reading the wrong build's log entirely. + if (!preg_match("#^commit ([0-9a-f]{40})$#m", $log, $m) || $m[1] !== $revision) + return NULL; + + if (!preg_match("#^Merge: ([0-9a-f]{7,40}) [0-9a-f]{7,40}\s*$#m", $log, $m)) + return NULL; + + return $m[1]; + } + + + //// GITINFO //// + + function gitinfo_order($dbh, $rev_hash) + { + $stmt = $dbh->prepare("SELECT id FROM master_revisions WHERE rev_hash = :rev_hash"); + $stmt->execute(array(":rev_hash" => $rev_hash)); + $id = $stmt->fetchColumn(); + + return ($id === FALSE) ? NULL : (int)$id; + } + + /** + * Resolves the abbreviated hash out of a "Merge:" line, as array(rev_hash, id). + * An ambiguous prefix resolves to nothing rather than to a guess. + */ + function gitinfo_resolve_prefix($dbh, $prefix) + { + $stmt = $dbh->prepare("SELECT id, rev_hash FROM master_revisions WHERE rev_hash LIKE :prefix LIMIT 2"); + $stmt->execute(array(":prefix" => $prefix . "%")); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return (count($rows) === 1) ? array($rows[0]["rev_hash"], (int)$rows[0]["id"]) : NULL; + } + + function gitinfo_at_time($dbh, $timestamp) + { + $stmt = $dbh->prepare("SELECT id FROM master_revisions WHERE commit_timestamp <= FROM_UNIXTIME(:timestamp) ORDER BY id DESC LIMIT 1"); + $stmt->execute(array(":timestamp" => (int)$timestamp)); + $id = $stmt->fetchColumn(); + + return ($id === FALSE) ? NULL : (int)$id; + } + + /** + * Which of these revisions are master commits, as rev_hash => id. + */ + function gitinfo_orders($dbh, $revisions) + { + if (!count($revisions)) + return array(); + + $placeholders = implode(",", array_fill(0, count($revisions), "?")); + $stmt = $dbh->prepare("SELECT id, rev_hash FROM master_revisions WHERE rev_hash IN ($placeholders)"); + $stmt->execute(array_values($revisions)); + + $orders = array(); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) + $orders[$row["rev_hash"]] = (int)$row["id"]; + + return $orders; + } + + + //// BASE RESOLUTION //// + + /** + * The master commit a PR build sits on top of, as array(rev_hash, id), or NULL. + * + * Cached per revision including the failures, because a revision that cannot be + * resolved once will not resolve on the next run either, and re-running this script + * should not mean re-fetching the same logs. + */ + function resolve_pr_base($gitinfo, $revision, $ref, &$stats) + { + static $cache = NULL; + + if ($cache === NULL) + { + $cache = cache_read("bases.json"); + if ($cache === NULL) + $cache = array(); + } + + if (!array_key_exists($revision, $cache)) + { + $cache[$revision] = resolve_pr_base_uncached($gitinfo, $revision, $ref, $stats); + + // Cheap enough to persist every time: this file is the expensive thing to + // rebuild, and the script is meant to survive being killed. + cache_write("bases.json", $cache); + } + + $base = $cache[$revision]; + if ($base === NULL) + return NULL; + + // The hash is cached, its ordinal is not: gitinfo may have ingested the commit + // since the last run, which turns a previously unplaceable base into a placeable + // one without another network round trip. + $order = gitinfo_order($gitinfo, $base); + + return ($order === NULL) ? array($base, NULL) : array($base, $order); + } + + function resolve_pr_base_uncached($gitinfo, $revision, $ref, &$stats) + { + // First choice: the BuildBot logged the base itself at build time, in the "git + // show" output of prepare_source. Free, and no rate limit. + // + // It only reaches back a few hundred builds though. The janitor prunes log + // contents and leaves the metadata behind, so an old build still lists its stdio + // log and still answers 200 for it - with an empty body. Hence the emptiness + // check: this path covers recent builds, and GitHub covers the archive. + $build = find_prepare_source_build($revision); + + if ($build !== NULL) + { + $log = http_get(BUILDBOT_API . "/builders/{$build[0]}/builds/{$build[1]}/steps/prepare_source/logs/stdio/raw"); + + if ($log === NULL || trim($log) === "") + { + $stats["log_pruned"]++; + } + else + { + $prefix = parse_merge_parent($log, $revision); + + if ($prefix !== NULL) + { + $resolved = gitinfo_resolve_prefix($gitinfo, $prefix); + + if ($resolved !== NULL) + { + $stats["base_from_buildbot"]++; + return $resolved[0]; + } + + // A real commit that gitinfo cannot place, or an ambiguous prefix. + // GitHub answers with the full hash, so it is worth asking. + $stats["base_prefix_unresolved"]++; + } + } + } + + return resolve_base_from_github($revision, $ref, $stats); + } + + /** + * Raised when GitHub says to come back later. Not an error to swallow: writing a + * clock guess for a run whose base is perfectly recoverable would bake the guess in, + * because the next run of this script only looks at rows that are still unanchored. + */ + class RateLimitException extends RuntimeException + { + } + + function github_get_json($url, &$stats) + { + $token = getenv("GITHUB_TOKEN"); + $headers = ($token ? "Authorization: Bearer $token\r\n" : "") . "Accept: application/vnd.github+json\r\n"; + $status = 0; + + $body = http_get($url, $headers, $status); + + if ($status === 403 || $status === 429) + throw new RateLimitException("GitHub rate limit reached" . ($token ? "" : " - set GITHUB_TOKEN to raise it from 60 to 5000 requests an hour")); + + if ($body === NULL) + { + $stats["github_missing"]++; + return NULL; + } + + $data = json_decode($body, TRUE); + return is_array($data) ? $data : NULL; + } + + /** + * The base of a PR build, from GitHub, for the builds whose BuildBot log is gone. + * + * The two ref kinds need different questions asked. refs/pull/N/merge is GitHub's own + * "PR head merged into master", so its first parent is the base by construction. + * refs/pull/N/head is an ordinary commit on the PR branch, whose first parent is the + * previous commit of that branch - the base is the merge base with master instead. + */ + function resolve_base_from_github($revision, $ref, &$stats) + { + $head = (substr((string)$ref, -5) === "/head"); + $data = github_get_json(GITHUB_API . ($head ? "/compare/master..." : "/commits/") . $revision, $stats); + + if ($data === NULL) + { + $stats["base_unresolved"]++; + return NULL; + } + + if ($head) + $sha = isset($data["merge_base_commit"]["sha"]) ? $data["merge_base_commit"]["sha"] : NULL; + else + $sha = isset($data["parents"][0]["sha"]) ? $data["parents"][0]["sha"] : NULL; + + if ($sha === NULL) + { + $stats["base_unresolved"]++; + return NULL; + } + + $stats["base_from_github"]++; + return strtolower($sha); + } + + + //// MAIN //// + + /** + * "Build GCCLin_x86 on Test KVM (patched)" is tested by the builder "Test KVM". + * + * The part before " on " is whichever builder compiled it, which is not the one that + * submitted the result. Sources without " on " are named after their builder already. + */ + function source_to_builder($source_name) + { + $name = preg_replace("#\s*\(patched\)$#", "", $source_name); + $pos = strrpos($name, " on "); + + return ($pos === FALSE) ? $name : substr($name, $pos + 4); + } + + /** + * The BuildBot build number out of "Build 41434, Reason: whatever". + * + * Only the number is structured. The "Reason" half is free text a human typed and is + * deliberately never parsed - it says "PR 9449" on some runs, "Bcrypt PR" on others + * and nothing at all on half of them. + */ + function comment_to_build_number($comment) + { + return preg_match("#^Build ([0-9]+)#", (string)$comment, $m) ? (int)$m[1] : NULL; + } + + /** + * Both "master" and "refs/heads/master" mean master. The BuildBot wrote the bare form + * until around build 26,800 and the qualified one since. + */ + function normalise_ref($branch) + { + if ($branch === NULL || $branch === "") + return NULL; + + return ($branch === "master") ? "refs/heads/master" : $branch; + } + + function ref_to_pr_number($ref) + { + return ($ref !== NULL && preg_match("#^refs/pull/([0-9]+)/#", $ref, $m)) ? (int)$m[1] : NULL; + } + + /** + * Works out the five anchor columns for one run. + */ + function anchor_run($run, &$stats) + { + global $gitinfo, $builders; + + $anchor = array("base_revision" => NULL, "base_order" => NULL, "base_exact" => 0, "ref" => NULL, "pr_number" => NULL); + $revision = strtolower((string)$run["revision"]); + $is_hash = (bool)preg_match("#^[0-9a-f]{40}$#", $revision); + + // The run's own commit is on master, so it is its own base. Two thirds of the + // archive lands here, without asking anyone anything. + // + // gitinfo deliberately wins over the BuildBot's branch property here: it is the + // same answer for every run that has both, it is right for the older builds that + // have no branch property at all, and it keeps most of the archive off the + // network entirely. + if ($is_hash && isset($run["master_order"])) + { + $stats["master"]++; + return array("base_revision" => $revision, "base_order" => $run["master_order"], "base_exact" => 1, + "ref" => "refs/heads/master", "pr_number" => NULL); + } + + // Old SVN runs predate git entirely. There is no ordinal for them and inventing + // one from the clock would place them before the first commit gitinfo has. + if (!$is_hash) + { + $stats["svn"]++; + return $anchor; + } + + // Everything else has to be identified through the build it came from. + $builder_name = source_to_builder($run["source_name"]); + $number = comment_to_build_number($run["comment"]); + + if ($number !== NULL && isset($builders[$builder_name])) + { + $builds = get_builder_builds($builders[$builder_name]); + + if (isset($builds[(string)$number])) + { + list($branch, $got_revision) = $builds[(string)$number]; + + // Report rather than resolve: if these disagree, one of the two databases + // is wrong about this run and silently preferring either would hide it. + if ($got_revision !== NULL && strtolower($got_revision) !== $revision) + { + $stats["revision_mismatch"]++; + $stats["mismatches"][] = sprintf("run %d: testman has %s, %s build %d has %s", + $run["id"], substr($revision, 0, 12), $builder_name, $number, substr((string)$got_revision, 0, 12)); + } + + $anchor["ref"] = normalise_ref($branch); + $anchor["pr_number"] = ref_to_pr_number($anchor["ref"]); + } + else + { + $stats["build_not_found"]++; + } + } + else + { + $stats["build_unidentified"]++; + } + + if ($anchor["ref"] === "refs/heads/master") + { + // The BuildBot says master but gitinfo has never seen this commit, so it was + // force-pushed away or gitinfo is incomplete here. The base is still exactly + // the revision; only its position on the timeline is missing. + $stats["master_unknown_to_gitinfo"]++; + $anchor["base_revision"] = $revision; + $anchor["base_exact"] = 1; + return $anchor; + } + + if ($anchor["pr_number"] !== NULL) + { + $base = resolve_pr_base($gitinfo, $revision, $anchor["ref"], $stats); + + if ($base !== NULL) + { + $anchor["base_revision"] = $base[0]; + $anchor["base_order"] = $base[1]; + $anchor["base_exact"] = 1; + $stats["pr_anchored"]++; + + // Known base, but gitinfo cannot place it. Left for a later run to finish + // rather than downgraded to a guess. + if ($base[1] === NULL) + $stats["base_unplaceable"]++; + + return $anchor; + } + } + + // Last resort. The run lands roughly where it belongs and says so with + // base_exact = 0, rather than falling off the timeline altogether. + $anchor["base_order"] = gitinfo_at_time($gitinfo, $run["timestamp"]); + $stats[$anchor["base_order"] === NULL ? "unanchored" : "clock"]++; + + return $anchor; + } + + $stats = array( + "seen" => 0, "master" => 0, "svn" => 0, "clock" => 0, "unanchored" => 0, "pr_anchored" => 0, + "base_from_buildbot" => 0, "base_from_github" => 0, "base_unresolved" => 0, + "base_prefix_unresolved" => 0, "base_unplaceable" => 0, "log_pruned" => 0, "github_missing" => 0, + "build_not_found" => 0, "build_unidentified" => 0, "master_unknown_to_gitinfo" => 0, + "revision_mismatch" => 0, "mismatches" => array(), + ); + + try + { + $dbh = connect(TESTMAN_DB_HOST, TESTMAN_DB_NAME, TESTMAN_DB_USER, TESTMAN_DB_PASS); + $gitinfo = connect(GITINFO_DB_HOST, GITINFO_DB_NAME, GITINFO_DB_USER, GITINFO_DB_PASS); + $builders = get_builders(); + + $state = $options["restart"] ? NULL : cache_read("state.json"); + $last_id = ($state === NULL) ? 0 : (int)$state["last_run_id"]; + + if ($last_id) + progress("Resuming after run $last_id (--restart to start over)."); + + // Only rows that are still missing something. A run that has been anchored is + // left alone, so re-running this is cheap even without the resume point. + $select = $dbh->prepare( + "SELECT r.id, UNIX_TIMESTAMP(r.timestamp) AS timestamp, r.revision, r.comment, src.name AS source_name " . + "FROM winetest_runs r JOIN sources src ON r.source_id = src.id " . + "WHERE r.id > :last_id AND (r.base_order IS NULL OR r.ref IS NULL) " . + "ORDER BY r.id LIMIT " . RUN_BATCH + ); + + $update = $dbh->prepare( + "UPDATE winetest_runs SET base_revision = :base_revision, base_order = :base_order, " . + "base_exact = :base_exact, ref = :ref, pr_number = :pr_number WHERE id = :id" + ); + + $rate_limited = NULL; + + while (!$options["limit"] || $stats["seen"] < $options["limit"]) + { + $select->execute(array(":last_id" => $last_id)); + $runs = $select->fetchAll(PDO::FETCH_ASSOC); + + if (!count($runs)) + break; + + // One question to gitinfo for the whole batch instead of one per run. + $hashes = array(); + + foreach ($runs as $run) + { + if (preg_match("#^[0-9a-f]{40}$#", strtolower($run["revision"]))) + $hashes[strtolower($run["revision"])] = strtolower($run["revision"]); + } + + $orders = gitinfo_orders($gitinfo, $hashes); + $batch_last_id = $last_id; + + if (!$options["dry-run"]) + $dbh->beginTransaction(); + + foreach ($runs as $run) + { + if ($options["limit"] && $stats["seen"] >= $options["limit"]) + break; + + $revision = strtolower($run["revision"]); + + if (isset($orders[$revision])) + $run["master_order"] = $orders[$revision]; + + try + { + $anchor = anchor_run($run, $stats); + } + catch (RateLimitException $e) + { + // Stop here rather than anchoring the rest by the clock. Everything + // already done is committed below and the resume point stands, so + // re-running this later picks up exactly where it left off. + $rate_limited = $e->getMessage(); + break; + } + + $stats["seen"]++; + $batch_last_id = (int)$run["id"]; + + if ($options["dry-run"]) + continue; + + $update->bindValue(":id", (int)$run["id"], PDO::PARAM_INT); + $update->bindValue(":base_revision", $anchor["base_revision"], PDO::PARAM_STR); + $update->bindValue(":base_order", $anchor["base_order"], $anchor["base_order"] === NULL ? PDO::PARAM_NULL : PDO::PARAM_INT); + $update->bindValue(":base_exact", (int)$anchor["base_exact"], PDO::PARAM_INT); + $update->bindValue(":ref", $anchor["ref"], PDO::PARAM_STR); + $update->bindValue(":pr_number", $anchor["pr_number"], $anchor["pr_number"] === NULL ? PDO::PARAM_NULL : PDO::PARAM_INT); + $update->execute(); + } + + if (!$options["dry-run"]) + $dbh->commit(); + + $last_id = $batch_last_id; + + if (!$options["dry-run"]) + cache_write("state.json", array("last_run_id" => $last_id)); + + progress(sprintf(" %d runs done (through run %d)", $stats["seen"], $last_id)); + + if ($rate_limited !== NULL) + { + progress(""); + progress("Stopped: " . $rate_limited); + progress("Re-run this script once the limit resets; it continues from run $last_id."); + break; + } + } + } + catch (Exception $e) + { + if (isset($dbh) && $dbh->inTransaction()) + $dbh->rollBack(); + + echo "ERROR: " . $e->getMessage() . "\n"; + } + + progress(""); + progress("Runs processed: " . $stats["seen"]); + progress(" master builds: " . $stats["master"]); + progress(" PR builds, exact base: " . $stats["pr_anchored"]); + progress(" anchored by the clock: " . $stats["clock"]); + progress(" old SVN, left alone: " . $stats["svn"]); + progress(" master, gitinfo lacks it: " . $stats["master_unknown_to_gitinfo"]); + progress(" not anchored at all: " . $stats["unanchored"]); + progress(""); + progress("Bases resolved (once per distinct commit, then cached):"); + progress(" from the BuildBot log: " . $stats["base_from_buildbot"]); + progress(" from GitHub: " . $stats["base_from_github"]); + progress(" BuildBot log was pruned: " . $stats["log_pruned"]); + progress(" not recoverable at all: " . $stats["base_unresolved"]); + progress(""); + progress("Worth a look:"); + progress(" build not in the index: " . $stats["build_not_found"]); + progress(" source or comment unusable: " . $stats["build_unidentified"]); + progress(" base found but unplaceable: " . ($stats["base_prefix_unresolved"] + $stats["base_unplaceable"])); + progress(" commit gone from GitHub: " . $stats["github_missing"]); + progress(" revision mismatches: " . $stats["revision_mismatch"]); + + foreach (array_slice($stats["mismatches"], 0, 20) as $line) + progress(" " . $line); + + if (count($stats["mismatches"]) > 20) + progress(" ... and " . (count($stats["mismatches"]) - 20) . " more"); + + if ($options["dry-run"]) + progress("\nDry run: nothing was written."); diff --git a/resources/testman/migrate-01-timeline.sql b/resources/testman/migrate-01-timeline.sql new file mode 100644 index 000000000..eef19d0ee --- /dev/null +++ b/resources/testman/migrate-01-timeline.sql @@ -0,0 +1,83 @@ +-- Testman migration 01: put every run on master's timeline +-- +-- One ALTER per table, because each of these changes rebuilds the whole table anyway and +-- doing them separately would rebuild it three times. On MyISAM that rebuild holds a +-- table-level write lock and submitting builders block until it finishes, so run this in +-- a maintenance window and measure first to know how long it 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. +-- +-- +-- InnoDB +-- ------ +-- MyISAM means table-level write locks, so a builder submitting results blocks every +-- reader for the duration, and no transactions, so a half-submitted run cannot be rolled +-- back. gitinfo has been InnoDB all along. +-- +-- +-- Indexes +-- ------- +-- ix_runs_ts every search filters finished = 1 and then orders and 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. +-- ix_runs_base the revision range, which is now a numeric compare on base_order. +-- ix_runs_base_rev the hash search also matches what a run was built on top of, so that +-- typing a master commit finds the pull request builds based on it. +-- Without this that half of the OR cannot use an index at all. +-- ix_runs_pr every run of one pull request, across all builders. +-- +-- KEY (revision) already serves the other half of that search, the LIKE 'abc1234%' prefix +-- match at the full 40 chars. +-- +-- +-- Anchor columns +-- -------------- +-- A run's identity (the exact commit it built) and its position on master's timeline (what +-- it was built against) are not the same thing. For a master build they coincide; for a +-- build of refs/pull/N/merge they do not, and that hash is not in gitinfo and never will +-- be - which is why searching for such a run used to fail outright and a revision range +-- used to omit it silently. +-- +-- revision unchanged: the exact hash that was built. +-- base_revision the master commit it was built on top of. Same as revision for a master +-- build, the merge base for a PR build, NULL when only the approximate +-- position is known. +-- base_order master_revisions.id of base_revision. The sortable, rangeable axis, and +-- the only column timeline queries touch. +-- base_exact 0 when base_order was guessed from the run's clock rather than resolved. +-- ref what the builder checked out, verbatim: "refs/heads/master", +-- "refs/pull/9453/merge", "refs/pull/9453/head". Not normalised, because +-- /merge (the pull request merged into master) and /head (the pull request +-- branch alone) are different things with differently computed bases. +-- pr_number parsed out of ref, so "every run for PR N" is one indexed query. +-- +-- Old SVN runs keep NULL in all of these: they predate git and gitinfo has no ordinal for +-- them. They stay reachable by date. +-- +-- Fill these in for existing runs with backfill-run-anchors.php afterwards. + +ALTER TABLE `winetest_runs` + ENGINE=InnoDB, + ADD COLUMN `base_revision` char(40) DEFAULT NULL COMMENT 'Master commit this run was built on top of' AFTER `revision`, + ADD COLUMN `base_order` int(10) unsigned DEFAULT NULL COMMENT 'master_revisions.id of base_revision' AFTER `base_revision`, + ADD COLUMN `base_exact` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0 if base_order was guessed from the clock' AFTER `base_order`, + ADD COLUMN `ref` varchar(64) DEFAULT NULL COMMENT 'What the builder checked out, verbatim' AFTER `base_exact`, + ADD COLUMN `pr_number` int(10) unsigned DEFAULT NULL COMMENT 'Pull request number, parsed from ref' AFTER `ref`, + ADD KEY `ix_runs_ts` (`finished`,`timestamp`,`id`), + ADD KEY `ix_runs_src_plat` (`source_id`,`platform`,`timestamp`), + ADD KEY `ix_runs_base` (`base_order`), + ADD KEY `ix_runs_base_rev` (`base_revision`), + ADD KEY `ix_runs_pr` (`pr_number`); + +ALTER TABLE `winetest_results` + ENGINE=InnoDB; diff --git a/resources/testman/selftest-anchors.php b/resources/testman/selftest-anchors.php new file mode 100644 index 000000000..2759f9835 --- /dev/null +++ b/resources/testman/selftest-anchors.php @@ -0,0 +1,176 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + return $dbh; + } + + function post($url, $fields) + { + $context = stream_context_create(array( + "http" => array( + "method" => "POST", + "header" => "Content-Type: application/x-www-form-urlencoded\r\n", + "content" => http_build_query($fields), + "timeout" => 30, + // The web service reports failures in the body with a 200, so a non-200 + // would be a server error we still want to see. + "ignore_errors" => TRUE, + ) + )); + + $body = @file_get_contents($url, FALSE, $context); + if ($body === FALSE) + throw new RuntimeException("Could not reach $url"); + + return trim($body); + } + + function gettestid($fields) + { + global $base_url, $sourceid, $password, $created; + + $response = post($base_url . "/webservice/index.php", array_merge(array( + "sourceid" => $sourceid, + "password" => $password, + "action" => "gettestid", + "platform" => "reactos.0", + "comment" => "selftest-anchors", + ), $fields)); + + if (ctype_digit($response)) + $created[] = (int)$response; + + return $response; + } + + function check($name, $expected, $actual) + { + global $failures; + + // The columns come back from PDO as strings; compare loosely but keep NULL apart + // from 0, which is the whole point of base_exact. + $ok = ($expected === NULL) ? ($actual === NULL) : ($actual !== NULL && (string)$expected === (string)$actual); + + if (!$ok) + { + $failures++; + printf(" FAIL %-28s expected %-12s got %s\n", $name, var_export($expected, TRUE), var_export($actual, TRUE)); + } + else + { + printf(" ok %-28s %s\n", $name, var_export($actual, TRUE)); + } + } + + try + { + $dbh = connect(TESTMAN_DB_HOST, TESTMAN_DB_NAME, TESTMAN_DB_USER, TESTMAN_DB_PASS); + $gitinfo = connect(GITINFO_DB_HOST, GITINFO_DB_NAME, GITINFO_DB_USER, GITINFO_DB_PASS); + + // A commit gitinfo knows, and the one it considers newest right now. + $master = $gitinfo->query("SELECT id, rev_hash FROM master_revisions ORDER BY id DESC LIMIT 1 OFFSET 3")->fetch(PDO::FETCH_ASSOC); + $newest = $gitinfo->query("SELECT id FROM master_revisions WHERE commit_timestamp <= NOW() ORDER BY id DESC LIMIT 1")->fetchColumn(); + + if (!$master) + throw new RuntimeException("gitinfo is empty - run devsetup/import-live.php first"); + + $row_stmt = $dbh->prepare("SELECT revision, base_revision, base_order, base_exact, ref, pr_number FROM winetest_runs WHERE id = :id"); + + $fetch = function($id) use ($row_stmt) + { + $row_stmt->execute(array(":id" => $id)); + return $row_stmt->fetch(PDO::FETCH_ASSOC); + }; + + // A hash that is deliberately not on master, standing in for a PR merge commit. + $pr_hash = str_repeat("ab12cd34", 5); + + echo "Case 2: a master commit is its own base\n"; + $row = $fetch(gettestid(array("revision" => $master["rev_hash"]))); + check("base_revision", $master["rev_hash"], $row["base_revision"]); + check("base_order", $master["id"], $row["base_order"]); + check("base_exact", 1, $row["base_exact"]); + check("ref", "refs/heads/master", $row["ref"]); + check("pr_number", NULL, $row["pr_number"]); + + echo "Case 1: the submitter supplies ref and base\n"; + $row = $fetch(gettestid(array("revision" => $pr_hash, "ref" => "refs/pull/9453/merge", "baserevision" => $master["rev_hash"]))); + check("revision", $pr_hash, $row["revision"]); + check("base_revision", $master["rev_hash"], $row["base_revision"]); + check("base_order", $master["id"], $row["base_order"]); + check("base_exact", 1, $row["base_exact"]); + check("ref", "refs/pull/9453/merge", $row["ref"]); + check("pr_number", 9453, $row["pr_number"]); + + echo "Case 1: /head is kept apart from /merge\n"; + $row = $fetch(gettestid(array("revision" => $pr_hash, "ref" => "refs/pull/9453/head", "baserevision" => $master["rev_hash"]))); + check("ref", "refs/pull/9453/head", $row["ref"]); + check("pr_number", 9453, $row["pr_number"]); + + echo "Case 3: unknown commit, nothing supplied, anchored by the clock\n"; + $row = $fetch(gettestid(array("revision" => $pr_hash))); + check("base_revision", NULL, $row["base_revision"]); + check("base_order", $newest, $row["base_order"]); + check("base_exact", 0, $row["base_exact"]); + check("ref", NULL, $row["ref"]); + + echo "Case 3: a ref without a base still yields the PR number\n"; + $row = $fetch(gettestid(array("revision" => $pr_hash, "ref" => "refs/pull/9454/merge"))); + check("base_revision", NULL, $row["base_revision"]); + check("base_exact", 0, $row["base_exact"]); + check("pr_number", 9454, $row["pr_number"]); + + echo "An old SVN revision number is not mistaken for a hash prefix\n"; + $svn_clash = $gitinfo->query("SELECT SUBSTRING(rev_hash, 1, 5) FROM master_revisions WHERE rev_hash REGEXP '^[0-9]{5}' LIMIT 1")->fetchColumn(); + $row = $fetch(gettestid(array("revision" => $svn_clash !== FALSE ? $svn_clash : "12345"))); + check("base_revision", NULL, $row["base_revision"]); + check("base_exact", 0, $row["base_exact"]); + check("ref", NULL, $row["ref"]); + + echo "Malformed input is rejected\n"; + check("bad ref", "ref is not a valid Git ref name!", gettestid(array("revision" => $pr_hash, "ref" => "refs/pull/1/merge oops"))); + check("bad baserevision", "baserevision is not a commit hash!", gettestid(array("revision" => $pr_hash, "baserevision" => "nothex"))); + } + catch (Exception $e) + { + echo "ERROR: " . $e->getMessage() . "\n"; + $failures++; + } + + if (count($created)) + { + $in = implode(",", array_map("intval", $created)); + $dbh->exec("DELETE FROM winetest_runs WHERE id IN ($in)"); + echo "\nCleaned up " . count($created) . " test runs.\n"; + } + + echo $failures ? "\n$failures check(s) FAILED.\n" : "\nAll checks passed.\n"; + exit($failures ? 1 : 0); diff --git a/resources/testman/selftest-pr-history.php b/resources/testman/selftest-pr-history.php new file mode 100644 index 000000000..3417d02c2 --- /dev/null +++ b/resources/testman/selftest-pr-history.php @@ -0,0 +1,194 @@ + array("timeout" => 60, "ignore_errors" => TRUE))); + $body = @file_get_contents($url, FALSE, $context); + + if ($body === FALSE) + throw new RuntimeException("Could not reach $url"); + + return $body; + } + + /** + * The body rows of the suite history table, as array(class, revision). + */ + function suite_rows($url) + { + $html = get($url); + $rows = array(); + + if (preg_match_all('#(.*?)#s', $html, $matches, PREG_SET_ORDER)) + { + foreach ($matches as $match) + { + if (strpos($match[1], "head") !== FALSE) + continue; + + // The first link in the row is the run's own commit. + $revision = preg_match('#compare\.php\?ids=([0-9]+)#', $match[2], $m) ? $m[1] : "?"; + $rows[] = array("class" => $match[1], "run" => $revision); + } + } + + return $rows; + } + + try + { + $dbh = new PDO("mysql:host=" . TESTMAN_DB_HOST . ";dbname=" . TESTMAN_DB_NAME . ";charset=utf8mb4", TESTMAN_DB_USER, TESTMAN_DB_PASS); + $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Pick a source, platform and suite that has PR runs and a result that moves + // around within the page that gets rendered. A window in which nothing changed + // would pass every check below without proving anything, so candidates are tried + // until one actually produces change marks. + $candidates = $dbh->query( + "SELECT r.source_id, r.platform, e.suite_id, COUNT(DISTINCT e.failures) AS variants, " . + " SUM(r.pr_number IS NOT NULL) AS pr_runs " . + "FROM winetest_results e " . + "JOIN winetest_runs r ON r.id = e.test_id AND r.finished = 1 " . + "GROUP BY r.source_id, r.platform, e.suite_id " . + "HAVING variants > 1 AND pr_runs > 0 " . + "ORDER BY variants DESC, pr_runs DESC LIMIT 25" + )->fetchAll(PDO::FETCH_ASSOC); + + if (!count($candidates)) + throw new RuntimeException("No suite with both PR runs and varying results - run backfill-run-anchors.php first"); + + $pick = NULL; + + foreach ($candidates as $candidate) + { + $url = sprintf("%s/suite.php?suite=%d&source=%d&platform=%s", + $base_url, $candidate["suite_id"], $candidate["source_id"], urlencode($candidate["platform"])); + + $master = suite_rows($url); + $master_changed = array(); + + foreach ($master as $row) + { + if (strpos($row["class"], "changed") !== FALSE) + $master_changed[] = $row["run"]; + } + + if (count($master_changed)) + { + $pick = $candidate; + break; + } + } + + if ($pick === NULL) + throw new RuntimeException("None of the candidate suites changes within one page, so this check would prove nothing"); + + printf("Suite %d on source %d / %s (%d PR runs, %d master changes)\n", + $pick["suite_id"], $pick["source_id"], $pick["platform"], $pick["pr_runs"], count($master_changed)); + + $both = suite_rows($url . "&pr=1"); + + $both_changed = array(); + $pr_rows = 0; + $pr_marked_changed = 0; + + foreach ($both as $row) + { + $is_pr = (strpos($row["class"], "prrun") !== FALSE); + $is_changed = (strpos($row["class"], "changed") !== FALSE); + + if ($is_pr) + { + $pr_rows++; + + if ($is_changed) + $pr_marked_changed++; + } + else if ($is_changed) + { + $both_changed[] = $row["run"]; + } + } + + check("master-only view has rows", count($master) > 0, count($master) . " rows"); + check("master-only view has no PR rows", count(array_filter($master, function($r) { return strpos($r["class"], "prrun") !== FALSE; })) === 0); + check("PR view adds PR rows", $pr_rows > 0, "$pr_rows PR rows"); + check("a PR build is never marked as a change", $pr_marked_changed === 0, "$pr_marked_changed marked"); + + // The point of the whole exercise. Both pages cover the same newest N runs, so + // they do not end at the same commit; compare only the master runs they share. + $shared = array_intersect(array_column($master, "run"), array_column($both, "run")); + $a = array_values(array_intersect($master_changed, $shared)); + $b = array_values(array_intersect($both_changed, $shared)); + + check("master's change marks are worth comparing", count($a) > 0, count($a) . " marked"); + check("PR builds do not move master's change marks", $a === $b, + count($a) . " vs " . count($b) . " over " . count($shared) . " shared runs"); + + // A PR run's compare page must pick a master baseline by itself. + $pr_run = $dbh->query( + "SELECT id FROM winetest_runs WHERE pr_number IS NOT NULL AND base_order IS NOT NULL " . + "AND source_id = " . (int)$pick["source_id"] . " ORDER BY id DESC LIMIT 1" + )->fetchColumn(); + + if ($pr_run !== FALSE) + { + $html = get($base_url . "/compare.php?ids=" . (int)$pr_run); + + check("a lone PR run gets a baseline column", substr_count($html, "Revision") === 2); + check("the baseline is announced", strpos($html, "autobaseline") !== FALSE); + check("the row links to its pull request", strpos($html, "github.com/reactos/reactos/pull/") !== FALSE); + + // The baseline has to be a master run at or below this run's position. + $baseline = $dbh->query( + "SELECT m.pr_number IS NULL AND m.base_order <= r.base_order AS ok " . + "FROM winetest_runs r, winetest_runs m WHERE r.id = " . (int)$pr_run . " AND m.id = (" . + " SELECT m2.id FROM winetest_runs r2 JOIN winetest_runs m2 " . + " ON m2.source_id = r2.source_id AND m2.platform = r2.platform AND m2.finished = 1 " . + " AND m2.pr_number IS NULL AND m2.base_order IS NOT NULL AND m2.base_order <= r2.base_order AND m2.id <> r2.id " . + " WHERE r2.id = " . (int)$pr_run . " ORDER BY m2.base_order DESC, m2.id DESC LIMIT 1)" + )->fetchColumn(); + + check("the baseline is master at or below this position", (int)$baseline === 1); + } + } + catch (Exception $e) + { + echo "ERROR: " . $e->getMessage() . "\n"; + $failures++; + } + + echo $failures ? "\n$failures check(s) FAILED.\n" : "\nAll checks passed.\n"; + exit($failures ? 1 : 0); diff --git a/resources/testman/testman.sql b/resources/testman/testman.sql index a4284030a..2d1d96666 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, @@ -34,6 +34,11 @@ CREATE TABLE `winetest_runs` ( `finished` tinyint(1) NOT NULL DEFAULT '0', `source_id` int(10) unsigned NOT NULL, `revision` varchar(40) NOT NULL, + `base_revision` char(40) DEFAULT NULL COMMENT 'Master commit this run was built on top of', + `base_order` int(10) unsigned DEFAULT NULL COMMENT 'master_revisions.id of base_revision', + `base_exact` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0 if base_order was guessed from the clock', + `ref` varchar(64) DEFAULT NULL COMMENT 'What the builder checked out, verbatim', + `pr_number` int(10) unsigned DEFAULT NULL COMMENT 'Pull request number, parsed from ref', `platform` varchar(24) COLLATE latin1_general_ci NOT NULL, `comment` varchar(255) COLLATE latin1_general_ci DEFAULT NULL, `count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT 'Sum of all executed tests', @@ -46,8 +51,13 @@ 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`), + KEY `ix_runs_base` (`base_order`), + KEY `ix_runs_base_rev` (`base_revision`), + KEY `ix_runs_pr` (`pr_number`) +) 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/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/rosweb/gitinfo.php b/www/www.reactos.org/rosweb/gitinfo.php index cee7d0704..c40f90e3a 100644 --- a/www/www.reactos.org/rosweb/gitinfo.php +++ b/www/www.reactos.org/rosweb/gitinfo.php @@ -89,6 +89,48 @@ public function getRevisionInformation($rev_hash) return $stmt->fetch(PDO::FETCH_ASSOC); } + /** + * Returns the position of a master commit on the timeline, which is what + * winetest_runs.base_order stores. + * + * @param string $rev_hash + * The full hash. Deliberately not a prefix match: an old SVN revision number like + * "12345" would otherwise match any commit whose hash happens to start with it. + * Resolve a prefix with getLongHash() first. + * + * @return + * The ordinal, or FALSE if this is not a master commit. A PR merge commit is + * never in this table, which is exactly why runs carry a base separate from + * their own revision. + */ + public function getRevisionOrder($rev_hash) + { + $stmt = $this->_dbh->prepare("SELECT id FROM master_revisions WHERE rev_hash = :rev_hash"); + $stmt->bindParam(":rev_hash", $rev_hash); + $stmt->execute(); + $id = $stmt->fetchColumn(); + + return ($id === FALSE) ? FALSE : (int)$id; + } + + /** + * Returns the newest master commit that existed at $timestamp, as + * array("id", "rev_hash"). + * + * This is the approximate anchor for a run whose base could not be resolved: it + * puts the run in roughly the right place on the timeline instead of nowhere. + * Callers store it with base_exact = 0 so the guess is never mistaken for a fact. + */ + public function getRevisionAtTime($timestamp) + { + $stmt = $this->_dbh->prepare("SELECT id, rev_hash FROM master_revisions WHERE commit_timestamp <= FROM_UNIXTIME(:timestamp) ORDER BY id DESC LIMIT 1"); + $stmt->bindValue(":timestamp", (int)$timestamp, PDO::PARAM_INT); + $stmt->execute(); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + return ($row === FALSE) ? FALSE : $row; + } + public function getRevisionRange($start_hash, $end_hash) { $stmt = $this->_dbh->prepare("SELECT rev_hash FROM master_revisions WHERE id >= (SELECT id FROM master_revisions WHERE rev_hash = :start_hash) AND id <= (SELECT id FROM master_revisions WHERE rev_hash = :end_hash) LIMIT " . $this->_REV_RANGE_LIMIT); diff --git a/www/www.reactos.org/testman/ajax-search.php b/www/www.reactos.org/testman/ajax-search.php index 430724ab4..be8382bbd 100644 --- a/www/www.reactos.org/testman/ajax-search.php +++ b/www/www.reactos.org/testman/ajax-search.php @@ -35,35 +35,17 @@ // Check all other parameters and prepare the WHERE clause. $query = "FROM winetest_runs r JOIN sources src ON r.source_id = src.id WHERE r.finished = 1"; - if (array_key_exists("startrev", $_GET) && array_key_exists("endrev", $_GET)) - { - $startrev = $_GET["startrev"]; - $endrev = $_GET["endrev"]; - - if (preg_match($SVN_PATTERN, $startrev) && preg_match($SVN_PATTERN, $endrev)) - { - // The user wants to find old SVN test results. - $range = range((int)$startrev, (int)$endrev); - } - else - { - // The user wants to find GIT test results. - - // Get the long hashes for searching. - $start_hash = $gi->getLongHash($startrev); - $end_hash = $gi->getLongHash($endrev); - if (!$start_hash || !$end_hash) - throw new RuntimeException($shared_langres["invalidinput"]); - - // Get all revisions between $start_hash and $end_hash. - $range = $gi->getRevisionRange($start_hash, $end_hash); - } - - if (count($range) > REV_RANGE_LIMIT) - throw new RuntimeException(sprintf($shared_langres["rangelimitexceeded"], REV_RANGE_LIMIT)); - - $query .= " AND r.revision IN ('" . implode("','", $range) . "')"; - } + // "startrev" and "endrev" are gone. They used to expand into every hash between + // the two endpoints and splice the lot into this query, which capped a search at + // 3000 commits and silently dropped every PR run, because a PR merge commit is + // not a hash the range could ever contain. Runs now carry their position on + // master as a number, so api/runs.php answers the same question with + // "base_order BETWEEN a AND b" and no cap. + // + // Say so rather than ignoring them: answering a request for a narrow range with + // the whole archive would look like a working search. + if (array_key_exists("startrev", $_GET) || array_key_exists("endrev", $_GET)) + throw new ErrorMessageException("Revision ranges have moved to api/runs.php, as the rev_from and rev_to parameters."); if (array_key_exists("source", $_GET) && $_GET["source"]) { 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"); + } + + /** + * Turns a commit hash, or a prefix of one, into its position on master's timeline. + * + * @return + * The ordinal from gitinfo. Throws if the hash is not a master commit, which is the + * honest answer for a PR merge commit: it has no position of its own, only a base. + * Search for such a run with "rev" instead, or bound the range by the master commits + * around it. + */ + function get_revision_order_param($name) + { + static $gi = NULL; + + $value = get_param($name, "#^[0-9a-fA-F]{4,40}$#"); + if ($value === NULL) + return NULL; + + if ($gi === NULL) + $gi = new GitInfo(); + + $hash = $gi->getLongHash(strtolower($value)); + if ($hash === FALSE) + throw new InvalidArgumentException("'$name' is not a known master commit"); + + return $gi->getRevisionOrder($hash); + } + + 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, matched against what the run built and against what + // it was built on top of. A master run has the same value in both, so the second + // half of this only ever adds pull request builds based on the commit - which is + // the other thing someone typing a hash wants to know about it. + // + // Unlike the revision range of ajax-search.php this never consults 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 OR r.base_revision LIKE :rev)"; + $params[":rev"] = strtolower($rev) . "%"; + } + + // A revision range, as two positions on master's timeline rather than as the list + // of every hash in between. That list used to be spliced into the query and was + // capped at 3000 commits; two integers have no cap, and they also catch the PR + // runs that were built on top of a commit in the range, which a hash list could + // not do even in principle. + $rev_from = get_revision_order_param("rev_from"); + $rev_to = get_revision_order_param("rev_to"); + + // Endpoints given the wrong way round are a slip, not a request for no results. + if ($rev_from !== NULL && $rev_to !== NULL && $rev_from > $rev_to) + { + $swap = $rev_from; + $rev_from = $rev_to; + $rev_to = $swap; + } + + if ($rev_from !== NULL) + { + $where[] = "r.base_order >= :rev_from"; + $params[":rev_from"] = $rev_from; + } + + if ($rev_to !== NULL) + { + $where[] = "r.base_order <= :rev_to"; + $params[":rev_to"] = $rev_to; + } + + // "master" for master builds only, a number for one pull request, "all" or + // nothing for everything. The browser asks for "master" by default, because PR + // runs are noise while bisecting a regression - but it says so in the URL rather + // than hiding rows behind a default the query string does not mention. + $pr = get_param("pr", "#^(master|all|[0-9]+)$#"); + + if ($pr === "master") + { + $where[] = "r.pr_number IS NULL"; + } + else if ($pr !== NULL && $pr !== "all") + { + $where[] = "r.pr_number = :pr"; + $params[":pr"] = (int)$pr; + } + + // 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.base_revision, r.base_order, r.base_exact, r.ref, r.pr_number, " . + "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), + // Where the run sits on master, which for a PR build is not where its own + // commit sits - it has none. base_exact = 0 means the position was + // guessed from the clock and the client has to say so. + "base_revision" => $row["base_revision"], + "base_revision_short" => $row["base_revision"] === NULL ? NULL : substr($row["base_revision"], 0, 7), + "base_order" => $row["base_order"] === NULL ? NULL : (int)$row["base_order"], + "base_exact" => (bool)$row["base_exact"], + "ref" => $row["ref"], + "pr_number" => $row["pr_number"] === NULL ? NULL : (int)$row["pr_number"], + "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..a815c79b5 100644 --- a/www/www.reactos.org/testman/compare.php +++ b/www/www.reactos.org/testman/compare.php @@ -29,7 +29,26 @@ $gi = new GitInfo(); $reader = new WineTest_Reader(); - $result = $reader->setTestIDList($_GET["ids"]); + + // A pull request build on its own is not a useful page: the interesting question + // is what it changed, and the answer is the newest master run of the same builder + // at or below its position. Put that in front of it instead of making the visitor + // hunt for it in the search results. + $ids = $_GET["ids"]; + $auto_baseline = FALSE; + + if (is_numeric($ids)) + { + $baseline = $reader->findMasterBaseline($ids); + + if ($baseline !== NULL) + { + $ids = $baseline . "," . (int)$ids; + $auto_baseline = TRUE; + } + } + + $result = $reader->setTestIDList($ids); // Hide the filter to show only changed results if just one Test ID was passed. // We can't simply leave out the option entirely, because this would break the cookies storing the selected filters. @@ -61,16 +80,31 @@ $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"])); + + // A pull request build did not build the commit it is anchored to, so say + // which one it sits on top of. Without it the revision above reads as a point + // on master's history, which it is not. + if ($row["pr_number"] !== null) + { + $table_summary .= sprintf('
%s', + htmlspecialchars(sprintf(GITHUB_PR_URL, (int)$row["pr_number"])), + htmlspecialchars(sprintf($testman_langres["onepr"], (int)$row["pr_number"]))); + + if ($row["base_revision"] !== null) + $table_summary .= ' ' . htmlspecialchars(sprintf($testman_langres["basedon"], $gi->getShortHash($row["base_revision"]))) . ''; + + if (!$row["base_exact"]) + $table_summary .= sprintf(' ?', htmlspecialchars($testman_langres["approximate"])); + } + $table_summary .= ''; $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/compare.templ.php b/www/www.reactos.org/testman/compare.templ.php index e04ded571..6547b723e 100644 --- a/www/www.reactos.org/testman/compare.templ.php +++ b/www/www.reactos.org/testman/compare.templ.php @@ -16,10 +16,14 @@
- - + +

+ +
+ +
diff --git a/www/www.reactos.org/testman/config.inc.php b/www/www.reactos.org/testman/config.inc.php index 4000dca82..2f0ea7563 100644 --- a/www/www.reactos.org/testman/config.inc.php +++ b/www/www.reactos.org/testman/config.inc.php @@ -7,18 +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"); - - // We never had builds < r10000 and never reached > r99999... - $SVN_PATTERN = "#^[0-9]{5}$#"; + define("GITHUB_PR_URL", "https://github.com/reactos/reactos/pull/%u"); diff --git a/www/www.reactos.org/testman/css/compare.css b/www/www.reactos.org/testman/css/compare.css index 52b25926e..b2c803967 100644 --- a/www/www.reactos.org/testman/css/compare.css +++ b/www/www.reactos.org/testman/css/compare.css @@ -198,3 +198,20 @@ body { #comparetable tr.separator { height: 10px; } + +/* Pull request build: which commit it was built on top of, beside its own hash. */ +#comparetable .prlink { + font-weight: bold; +} + +#comparetable .anchor { + font-family: monospace; + font-weight: normal; +} + +#comparetable .approx { + border: 1px solid #d8c9a3; + border-radius: 3px; + padding: 0 4px; + cursor: help; +} diff --git a/www/www.reactos.org/testman/css/index.css b/www/www.reactos.org/testman/css/index.css index 18b39640c..8fbeee9bf 100644 --- a/www/www.reactos.org/testman/css/index.css +++ b/www/www.reactos.org/testman/css/index.css @@ -40,3 +40,51 @@ .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; +} + +/* A pull request build. Tinted so it never reads as a point on master's own history. */ +#resulttable tr.prrun > td { + background-color: #fbf6e6; +} + +#resulttable .anchor { + color: #777; + font-family: monospace; +} + +/* Shown when base_order was guessed from the run's clock rather than resolved. */ +#resulttable .approx { + color: #8a6d3b; + font-size: 90%; + border: 1px solid #d8c9a3; + border-radius: 3px; + padding: 0 4px; + cursor: help; +} 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..a885f1526 --- /dev/null +++ b/www/www.reactos.org/testman/css/suite.css @@ -0,0 +1,58 @@ +/* + 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; +} + +/* Pull request builds are not part of master's series, so they never look like it. */ +#suitetable tr.prrun > td { + background-color: #fbf6e6; +} + +#suitetable .anchor { + color: #777; + font-family: monospace; + font-size: 90%; +} + +#suitetable .approx { + color: #8a6d3b; + border: 1px solid #d8c9a3; + border-radius: 3px; + padding: 0 4px; + cursor: help; +} + +.pagesbox .includepr { + font-weight: normal; + margin-right: 12px; +} 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 94a37638c..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 " . @@ -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"); @@ -142,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 5bc1291de..bd07150ae 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", "rev_from", "rev_to", "pr", "source", "platform", "min_failures", "cursor", "dir"); + try { $gi = new GitInfo(); - $revisions = $gi->getLatestRevisions(2); - $rev = $gi->getShortHash($revisions[0]); - $rev_before = $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,15 @@ <?php echo $testman_langres["index_title"]; ?> printHead(); ?> - + "> + "> - - - - + + + @@ -68,64 +117,110 @@
-

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

+
+ + +
-
- - +
+
+ + + + + + +
+
-
- -
+
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..b05f96ba7 100644 --- a/www/www.reactos.org/testman/js/index.js +++ b/www/www.reactos.org/testman/js/index.js @@ -1,29 +1,53 @@ /* * 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", + rev_from: "search_rev_from", + rev_to: "search_rev_to", + pr: "search_pr", + source: "search_source", + platform: "search_platform", + min_failures: "search_min_failures" +}; + +// What a field shows when the query string does not mention it. The form starts on +// master-only, but a URL that leaves "pr" out really does mean every build, so the +// dropdown has to say so rather than silently disagreeing with the results below it. +var FILTER_DEFAULTS = { + pr: "all" +}; + +// 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 +98,323 @@ 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); -} + // "pr" can also be a single pull request number, which is not one of the two options + // the dropdown ships with. Give it one, or pressing Search would quietly widen the + // view back to every build. + var select = document.getElementById(FILTER_FIELDS["pr"]); + var pr = query["pr"]; -function SearchButton_OnClick() -{ - if (!GetRevisions()) + for (var i = select.options.length - 1; i >= 0; i--) { - alert(shared_langres["invalidrev"]); - return; + if (select.options[i].dataset.onepr) + select.remove(i); } - 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 (pr && /^[0-9]+$/.test(pr)) + { + var option = document.createElement("option"); - if (window.localStorage) - localStorage.setItem("testman_source", data["source"]); + option.value = pr; + option.text = testman_langres["pullrequest"].replace(/\{1\}/, pr); + option.dataset.onepr = "1"; + select.add(option); + } - SearchCall(); + for (var name in FILTER_FIELDS) + { + var fallback = (name in FILTER_DEFAULTS) ? FILTER_DEFAULTS[name] : ""; + document.getElementById(FILTER_FIELDS[name]).value = (name in query) ? query[name] : fallback; + } } -function ResizeIFrame() +function BuildQueryString(query) { - var iframe = document.getElementById("comparepage_frame"); - iframe.height = iframe.contentDocument.body.offsetHeight + 40; + var parts = new Array(); + + for (var name in query) + { + if (query[name] !== null && query[name] !== "") + parts.push(encodeURIComponent(name) + "=" + encodeURIComponent(query[name])); + } + + return parts.join("&"); } -function Load() +function ParseQueryString(search) { - // React on Return key presses. - var f = function(keyevent) - { - // 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; + var query = new Object(); + var parts = search.replace(/^\?/, "").split("&"); - // Load the settings. - if (window.localStorage) + for (var i = 0; i < parts.length; i++) { - 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; + if (!parts[i]) + continue; + + 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) +/** + * What the run was built on top of, which for a pull request build is not the commit it + * built. A master run is its own base, so saying so again in its own column would be + * noise; it gets an empty cell. + */ +function RenderAnchor(run) { - // Check for an error - if (HttpRequest.responseXML.getElementsByTagName("error").length > 0) - { - alert(HttpRequest.responseXML.getElementsByTagName("error")[0].firstChild.data) - return; - } - 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) + if (run.pr_number !== null) { - FirstRev = HttpRequest.responseXML.getElementsByTagName("firstrev")[0].firstChild.data; - LastRev = HttpRequest.responseXML.getElementsByTagName("lastrev")[0].firstChild.data; + html += ''; + html += Escape(testman_langres["pullrequest"].replace(/\{1\}/, run.pr_number)) + '<\/a>'; + html += ' ☰<\/a>'; } - if (data["requesttype"] == REQUESTTYPE_FULLLOAD || data["requesttype"] == REQUESTTYPE_PAGESWITCH) - { - // Build a new infobox - html += '
'; + if (run.base_revision !== null && run.base_revision !== run.revision) + html += ' ' + Escape(run.base_revision_short) + '<\/span>'; - if(data["requesttype"] == REQUESTTYPE_FULLLOAD) - { - ResultCount = RequestResultCount; - PageCount = 1; - html += testman_langres["foundresults"].replace(/\{1\}/, ResultCount); - } - else - { - html += document.getElementById("infobox").innerHTML; - } + if (!run.base_exact && run.base_order !== null) + html += ' ' + Escape(testman_langres["approximate"]) + '<\/span>'; - html += '<\/div>'; + if (run.base_order === null && run.pr_number !== null) + html += ' ' + Escape(testman_langres["unanchored"]) + '<\/span>'; - html += '
'; - html += testman_langres["status"].replace(/\{1\}/, '' + SelectedResultCount + '<\/span>'); - html += '