diff --git a/.github/e2e-dashboard/index.html b/.github/e2e-dashboard/index.html new file mode 100644 index 000000000..99200c0bc --- /dev/null +++ b/.github/e2e-dashboard/index.html @@ -0,0 +1,334 @@ + + + + + + +E2E Nightly Dashboard — Databricks ADBC + + + + +
+
+

E2E Nightly Test Dashboard

+
Loading…
+
+ +
+ +
+

Trend (per nightly run)

+ +
+ +
+

Latest run — failure analysis

+
+
+ +
+

Run history (click a row to expand failures)

+ + + + + + + +
Date (UTC)ProtocolPass ratePassedFailedSkippedResult
+
+ + +
+ + + + diff --git a/.github/scripts/parse-trx-to-json.py b/.github/scripts/parse-trx-to-json.py new file mode 100644 index 000000000..09b7cc117 --- /dev/null +++ b/.github/scripts/parse-trx-to-json.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 ADBC Drivers Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Parse one or more VSTest .trx files into a single nightly-run JSON record. + +Usage: + parse-trx-to-json.py OUTPUT.json TRX [TRX ...] + +The output record captures the totals, per-test outcomes for everything that +did not pass, and a coarse "failure signature" for each failure so the +dashboard can group 100+ failures into a handful of root causes. Run metadata +(commit, branch, run id, timestamp, …) is read from the environment so the +script stays decoupled from the workflow. +""" +import json +import os +import re +import sys +import xml.etree.ElementTree as ET + +# TRX uses a default namespace; strip it so we can query with plain tag names. +_NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"} + + +def _localname(tag): + return tag.split("}", 1)[-1] if "}" in tag else tag + + +def signature_for(message): + """Collapse a failure message into a stable, human-readable bucket. + + The goal is triage, not precision: hundreds of parameterized failures that + share one root cause (a 404 warehouse, a read-only rejection, …) should + land in the same bucket regardless of the per-case query/value text. + """ + if not message: + return "Unknown / no message" + m = message.replace("\r", " ").replace("\n", " ") + + # Order matters: the most specific patterns win. In particular the + # Thrift-on-SEA and CloudFetch buckets must be matched before the generic + # "Couldn't connect / HttpRequestException" transport bucket, because their + # messages also contain that text. + patterns = [ + # --- Reyden capability gaps (expected; see SIG_CATEGORY) ------------- + # A Thrift session against a SEA/REST-only warehouse (e.g. Reyden) has + # no Thrift endpoint, so the server returns ENDPOINT_NOT_FOUND. This is + # NOT a missing/misconfigured warehouse — the warehouse works over REST. + (r"Thrift server error.*ENDPOINT_NOT_FOUND|ENDPOINT_NOT_FOUND.*Thrift server error", + "Thrift endpoint unavailable on SEA/Reyden warehouse"), + (r"Error in download process|CloudFetch.*download", + "CloudFetch not supported on Reyden (download failed)"), + # Specific unsupported features, split out so the dashboard shows each + # distinct gap (count + what it means) rather than one opaque bucket. + (r"Unsupported statement:\s*SHOW COLUMNS", "Unsupported statement: SHOW COLUMNS"), + (r"Unsupported CREATE type:\s*SCHEMA", "Unsupported CREATE type: SCHEMA"), + (r"CREATE OR REPLACE TABLE", "Unsupported feature: CREATE OR REPLACE TABLE"), + (r"interval year to month|interval day to second", "Unsupported type: INTERVAL"), + # Known Reyden backend bug: the Statement Execution API does not return a + # correct num_affected_rows for INSERT/UPDATE, so the driver surfaces -1. + # Surfaces as an xUnit "Expected: 1 / Actual: -1" assertion or an explicit + # "non-negative affected rows, got -1" — the root cause is the Reyden + # backend, not the driver, so it belongs in the expected-gap bucket. Must + # precede the generic assertion bucket below. Matched on "Actual: -1" + # (anchored with \b so -10/-100 don't match), which only the affected-rows + # count produces (ADBC's "unknown/not applicable" sentinel). + (r"non-negative affected rows|Actual:\s*-1\b", + "Reyden rows_affected not returned (known backend bug)"), + # Reyden does not expose the hive_metastore catalog, so a test asserting + # the default catalog is "hive_metastore" sees "main" instead. Placed + # after the SHOW COLUMNS bucket (whose messages can also mention + # hive_metastore) so only the catalog-default assertion lands here. + (r"hive_metastore", "Reyden does not support hive_metastore catalog"), + # Any other unsupported DDL/statement/type Reyden rejects. + (r"PARSER_UNSUPPORTED_FEATURE|UNSUPPORTED_FEATURE|DELTA_UNSUPPORTED|Unsupported statement|" + r"Unsupported CREATE type|Unsupported Delta table type|Unsupported .*type", + "Reyden unsupported feature (other)"), + # --- Environment / infra -------------------------------------------- + (r"ENDPOINT_NOT_FOUND", "Warehouse not found (ENDPOINT_NOT_FOUND / HTTP 404)"), + (r"read[- ]?only|READ_ONLY|cannot be modified|not.*allowed.*read", "Read-only warehouse rejected write/DDL"), + (r"PERMISSION_DENIED|not authorized|Forbidden|HTTP 403", "Permission denied (403)"), + (r"timeout|timed out|TimeoutException", "Timeout"), + (r"Couldn't connect|connection refused|HttpRequestException", "Connection / transport error"), + # --- Confirmed Reyden SEA backend bugs (filed under Epic SC-222102) - + # Reproduced against Reyden vs DBSQL / raw SEA API and filed as [M4] + # tickets. Matched ahead of the generic syntax/assertion buckets so each + # gets its own row and lands in CAT_REAL (the real, tracked-bug bucket) + # rather than the catch-alls. Everything NOT matched here is a known + # Reyden limitation (CAT_REYDEN_GAP) — see the category map below. + (r"at or near 'CATALOGS'", "Reyden SEA rejects SHOW SCHEMAS/TABLES IN ALL CATALOGS"), + (r"Expected identifier but got end of input", "Reyden SEA rejects bare SET statement"), + (r"Actual:\s*\"?col_0\"?", "Reyden SEA omits column aliases (returns col_0)"), + # --- Genuine SQL errors with specific codes ------------------------ + # Checked before the broad assertion bucket below, whose "expected" + # token also appears in "Expected identifier ..." syntax-error text. + (r"PARSE_SYNTAX_ERROR|SYNTAX_ERROR", "SQL syntax error"), + # --- Confirmed Reyden bug: ANSI strict-cast (filed SC-233355) ------- + # Heterogeneous ARRAY/MAP literals fail to coerce on Reyden under ANSI. + (r"CAST_INVALID_INPUT", "Type cast mismatch on round-trip"), + (r"TABLE_OR_VIEW_NOT_FOUND|cannot be found|does not exist", "Object not found"), + # Generic value mismatch — after the specific tracked-bug signatures + # above are extracted, the remaining value mismatches on Reyden are + # known limitations (e.g. write-not-persisted), so this is mapped to + # CAT_REYDEN_GAP, not the real-bug bucket. + (r"Assert\.|Equal\(|Xunit|expected", "Assertion failed (value mismatch)"), + # --- Generic DML/DDL rejection (catch-all, lowest priority) -------- + (r"INSERT|UPDATE|DELETE|MERGE|CREATE TABLE|DROP TABLE|ALTER TABLE", "DML/DDL rejected"), + ] + for pat, label in patterns: + if re.search(pat, m, re.IGNORECASE): + return label + # Fall back to the exception type at the head of the message, if present. + exc = re.match(r"\s*([A-Za-z0-9_.]+Exception)", m) + if exc: + return exc.group(1) + return "Other" + + +# Root-cause category layered on top of the fine-grained signature. The +# dashboard rolls failures up to these three buckets so a low raw pass-rate +# (dominated by expected Reyden gaps) doesn't mask the genuine driver bugs. +# +# Classification hinges on WHICH step failed, which the message already encodes: +# - A test with a CREATE TABLE/SCHEMA step that Reyden can't run fails AT that +# step with an "Unsupported …" message -> CAT_REYDEN_GAP (expected). +# - CAT_REAL is now the set of CONFIRMED + tracked Reyden SEA bugs: each was +# reproduced (Reyden vs DBSQL / raw SEA API) and filed under Epic SC-222102. +# Only the four specific signatures above (IN ALL CATALOGS, bare SET, col_0 +# aliases, ANSI cast) land here. Every other failure on this Reyden-only +# nightly is a known limitation -> CAT_REYDEN_GAP, including the generic +# value-mismatch / DML-rejected buckets. +CAT_REYDEN_GAP = "Reyden capability gap (expected)" +CAT_ENVIRONMENT = "Environment / infra" +CAT_REAL = "Real issue / to investigate" + +# Explicit signature -> category map. Any signature not listed here (including +# the dynamic "" fallbacks and the value/cast/DML/syntax buckets) +# is treated as CAT_REAL so genuine, unclassified failures surface rather than +# hide. +_SIGNATURE_CATEGORY = { + "Thrift endpoint unavailable on SEA/Reyden warehouse": CAT_REYDEN_GAP, + "CloudFetch not supported on Reyden (download failed)": CAT_REYDEN_GAP, + "Unsupported statement: SHOW COLUMNS": CAT_REYDEN_GAP, + "Unsupported CREATE type: SCHEMA": CAT_REYDEN_GAP, + "Unsupported feature: CREATE OR REPLACE TABLE": CAT_REYDEN_GAP, + "Unsupported type: INTERVAL": CAT_REYDEN_GAP, + "Reyden rows_affected not returned (known backend bug)": CAT_REYDEN_GAP, + "Reyden does not support hive_metastore catalog": CAT_REYDEN_GAP, + "Reyden unsupported feature (other)": CAT_REYDEN_GAP, + # Back-compat: older runs used one combined signature. + "Reyden unsupported feature (DDL / statement / type)": CAT_REYDEN_GAP, + "Warehouse not found (ENDPOINT_NOT_FOUND / HTTP 404)": CAT_ENVIRONMENT, + "Read-only warehouse rejected write/DDL": CAT_ENVIRONMENT, + "Permission denied (403)": CAT_ENVIRONMENT, + "Timeout": CAT_ENVIRONMENT, + "Connection / transport error": CAT_ENVIRONMENT, + # Generic value-mismatch and DML/DDL-rejected on this Reyden-only nightly are + # known limitations (the specific real bugs have their own signatures above). + "Assertion failed (value mismatch)": CAT_REYDEN_GAP, + "DML/DDL rejected": CAT_REYDEN_GAP, + # The four tracked Reyden SEA bugs ("Reyden SEA rejects …", "Reyden SEA omits + # column aliases (returns col_0)", "Type cast mismatch on round-trip") and any + # unclassified "" / "SQL syntax error" / "Object not found" / + # "Other" fall through to CAT_REAL so genuine new issues still surface. +} + + +def category_for(signature): + """Map a fine-grained signature to one of the four root-cause buckets.""" + return _SIGNATURE_CATEGORY.get(signature, CAT_REAL) + + +def class_of(test_name): + """Best-effort owning class: strip the parameter list and the method.""" + base = test_name.split("(", 1)[0] + return base.rsplit(".", 1)[0] if "." in base else base + + +def parse_trx(path): + tree = ET.parse(path) + root = tree.getroot() + + # Map testId -> testName from the section. + names = {} + for ut in root.iter(): + if _localname(ut.tag) == "UnitTest": + tm = None + for child in ut: + if _localname(child.tag) == "TestMethod": + tm = child + break + tid = ut.get("id") + if tid is not None and tm is not None: + cls = tm.get("className", "") + name = tm.get("name", "") + names[tid] = (f"{cls}.{name}" if cls else name) + + results = [] + for r in root.iter(): + if _localname(r.tag) != "UnitTestResult": + continue + outcome = r.get("outcome", "") + tid = r.get("testId") + test_name = names.get(tid, r.get("testName", "unknown")) + message = "" + stack = "" + for out in r: + if _localname(out.tag) != "Output": + continue + for err in out: + if _localname(err.tag) == "ErrorInfo": + for e in err: + ln = _localname(e.tag) + if ln == "Message": + message = (e.text or "").strip() + elif ln == "StackTrace": + stack = (e.text or "").strip() + results.append({ + "name": test_name, + "class": class_of(test_name), + "outcome": outcome, + "message": message, + "stack": stack, + }) + return results + + +def main(): + if len(sys.argv) < 3: + print("usage: parse-trx-to-json.py OUTPUT.json TRX [TRX ...]", file=sys.stderr) + sys.exit(2) + + out_path = sys.argv[1] + trx_paths = sys.argv[2:] + + all_results = [] + for p in trx_paths: + if os.path.isdir(p): + for name in sorted(os.listdir(p)): + if name.endswith(".trx"): + all_results.extend(parse_trx(os.path.join(p, name))) + elif os.path.exists(p): + all_results.extend(parse_trx(p)) + else: + print(f"WARNING: {p} not found, skipping", file=sys.stderr) + + passed = [r for r in all_results if r["outcome"] == "Passed"] + failed = [r for r in all_results if r["outcome"] == "Failed"] + skipped = [r for r in all_results if r["outcome"] in ("NotExecuted", "Inconclusive")] + + # Trim payload: keep full detail for failures only. + for r in failed: + r["signature"] = signature_for(r["message"]) + r["category"] = category_for(r["signature"]) + if len(r["message"]) > 2000: + r["message"] = r["message"][:2000] + " …(truncated)" + if len(r["stack"]) > 2000: + r["stack"] = r["stack"][:2000] + " …(truncated)" + + by_signature = {} + by_class = {} + by_category = {} + for r in failed: + by_signature[r["signature"]] = by_signature.get(r["signature"], 0) + 1 + by_class[r["class"]] = by_class.get(r["class"], 0) + 1 + by_category[r["category"]] = by_category.get(r["category"], 0) + 1 + + total = len(all_results) + record = { + "run_id": os.environ.get("GITHUB_RUN_ID", ""), + "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", ""), + "run_number": os.environ.get("GITHUB_RUN_NUMBER", ""), + "timestamp": os.environ.get("RUN_TIMESTAMP", ""), + "commit": os.environ.get("GITHUB_SHA", ""), + "branch": os.environ.get("GITHUB_REF_NAME", ""), + "protocol": os.environ.get("RUN_PROTOCOL", ""), + "read_only": os.environ.get("RUN_READ_ONLY", "") == "true", + "workflow": os.environ.get("GITHUB_WORKFLOW", ""), + "html_url": ( + f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/" + f"{os.environ.get('GITHUB_REPOSITORY', '')}/actions/runs/" + f"{os.environ.get('GITHUB_RUN_ID', '')}" + ), + "total": total, + "passed": len(passed), + "failed": len(failed), + "skipped": len(skipped), + "pass_rate": round(100.0 * len(passed) / total, 1) if total else 0.0, + "by_category": dict(sorted(by_category.items(), key=lambda kv: -kv[1])), + "by_signature": dict(sorted(by_signature.items(), key=lambda kv: -kv[1])), + "by_class": dict(sorted(by_class.items(), key=lambda kv: -kv[1])), + "failures": sorted(failed, key=lambda r: (r["category"], r["signature"], r["name"])), + } + + with open(out_path, "w") as f: + json.dump(record, f, indent=2) + print(f"Parsed {total} results ({record['passed']} passed, " + f"{record['failed']} failed, {record['skipped']} skipped) -> {out_path}") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/update-e2e-dashboard.py b/.github/scripts/update-e2e-dashboard.py new file mode 100644 index 000000000..d9b7b7e8b --- /dev/null +++ b/.github/scripts/update-e2e-dashboard.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 ADBC Drivers Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Merge a parsed nightly-run record into the gh-pages E2E dashboard. + +Usage: + update-e2e-dashboard.py RUN_RECORD.json DASHBOARD_DIR TEMPLATE_INDEX.html + + RUN_RECORD.json output of parse-trx-to-json.py + DASHBOARD_DIR e.g. /e2e-nightly + TEMPLATE_INDEX static dashboard page to (re)publish alongside the data + +Layout produced under DASHBOARD_DIR: + index.html the dashboard (copied from template) + data/runs.json lightweight history index (one row/run) + data/run--.json full per-run detail (failure list) + +History is append-only and keyed by (run_id, protocol) so a re-run replaces +its prior row rather than duplicating it. +""" +import json +import os +import shutil +import sys + +# Keep history bounded so the index stays small and the page stays fast. +MAX_RUNS = 365 + + +def main(): + if len(sys.argv) != 4: + print("usage: update-e2e-dashboard.py RUN_RECORD.json DASHBOARD_DIR TEMPLATE_INDEX.html", + file=sys.stderr) + sys.exit(2) + + record_path, dashboard_dir, template = sys.argv[1], sys.argv[2], sys.argv[3] + data_dir = os.path.join(dashboard_dir, "data") + os.makedirs(data_dir, exist_ok=True) + + with open(record_path) as f: + record = json.load(f) + + run_id = record.get("run_id", "0") + protocol = record.get("protocol", "") or "unknown" + detail_name = f"run-{run_id}-{protocol}.json" + + # Full detail (incl. failure messages) lives in its own file, fetched + # lazily by the dashboard only when a run is expanded. + with open(os.path.join(data_dir, detail_name), "w") as f: + json.dump(record, f, indent=2) + + # Lightweight summary row for the history index — drop the heavy bits. + summary = {k: record[k] for k in ( + "run_id", "run_attempt", "run_number", "timestamp", "commit", "branch", + "protocol", "read_only", "html_url", "total", "passed", "failed", + "skipped", "pass_rate", "by_category", "by_signature", "by_class", + ) if k in record} + summary["detail"] = detail_name + + runs_path = os.path.join(data_dir, "runs.json") + runs = [] + if os.path.exists(runs_path): + try: + with open(runs_path) as f: + runs = json.load(f) + except (ValueError, OSError): + runs = [] + + key = (summary.get("run_id"), summary.get("protocol")) + runs = [r for r in runs if (r.get("run_id"), r.get("protocol")) != key] + runs.append(summary) + runs.sort(key=lambda r: (r.get("timestamp", ""), r.get("run_id", ""))) + if len(runs) > MAX_RUNS: + runs = runs[-MAX_RUNS:] + + with open(runs_path, "w") as f: + json.dump(runs, f, indent=2) + + shutil.copyfile(template, os.path.join(dashboard_dir, "index.html")) + print(f"Dashboard updated: {len(runs)} runs in index, detail -> {detail_name}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/reyden-rest-nightly.yml b/.github/workflows/reyden-rest-nightly.yml index 94f0ef4b0..5487866a3 100644 --- a/.github/workflows/reyden-rest-nightly.yml +++ b/.github/workflows/reyden-rest-nightly.yml @@ -12,6 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +# Nightly E2E run against the Reyden REST warehouse. +# +# Self-contained workflow: the generic e2e-tests.yml does not carry any +# Reyden-specific routing (Reyden rejects DDL on the SQL statement API), so +# all Reyden-aware setup, test-config emission, test execution, and result +# publishing lives in this file. Setup/teardown talks to a regular DBSQL +# warehouse (TEST_PECO_WAREHOUSE_HTTP_PATH) for the DDL the Reyden warehouse +# would refuse; only the actual test run targets the Reyden warehouse +# (TEST_PECO_REYDEN_HTTP_PATH). name: Reyden REST Nightly E2E on: @@ -20,6 +29,10 @@ on: - cron: '0 2 * * *' workflow_dispatch: +# publish-dashboard pushes the parsed test results to the gh-pages branch. +permissions: + contents: write + concurrency: group: ${{ github.repository }}-reyden-rest-nightly cancel-in-progress: false @@ -30,7 +43,10 @@ jobs: runs-on: ubuntu-latest env: DATABRICKS_SERVER_HOSTNAME: ${{ secrets.DATABRICKS_HOST }} + # Reyden warehouse — used only for test execution (no DDL support) DATABRICKS_HTTP_PATH: ${{ secrets.TEST_PECO_REYDEN_HTTP_PATH }} + # Regular DBSQL warehouse — used for schema/table setup and teardown + SETUP_HTTP_PATH: ${{ secrets.TEST_PECO_WAREHOUSE_HTTP_PATH }} DATABRICKS_TEST_CLIENT_ID: ${{ secrets.DATABRICKS_TEST_CLIENT_ID }} DATABRICKS_TEST_CLIENT_SECRET: ${{ secrets.DATABRICKS_TEST_CLIENT_SECRET }} steps: @@ -68,9 +84,10 @@ jobs: PER_RUN_SCHEMA="adbc_testing_run_${{ github.run_id }}_${{ github.run_attempt }}_rest" echo "PER_RUN_SCHEMA=$PER_RUN_SCHEMA" >> $GITHUB_OUTPUT echo "Per-run schema: main.$PER_RUN_SCHEMA" - WAREHOUSE_ID=$(echo "${{ env.DATABRICKS_HTTP_PATH }}" | awk -F/ '{print $NF}') + # Use the regular warehouse ID for setup operations + WAREHOUSE_ID=$(echo "${{ env.SETUP_HTTP_PATH }}" | awk -F/ '{print $NF}') if [ -z "$WAREHOUSE_ID" ]; then - echo "ERROR: Could not extract warehouse id from DATABRICKS_HTTP_PATH" + echo "ERROR: Could not extract warehouse id from SETUP_HTTP_PATH" exit 1 fi echo "WAREHOUSE_ID=$WAREHOUSE_ID" >> $GITHUB_OUTPUT @@ -139,6 +156,21 @@ jobs: sys.exit(1) print(f"OK [{i}/{len(statements)}]: {stmt[:80].replace(chr(10), ' ')}") print(f"Seeded {len(statements)} statements into main.{os.environ['PER_RUN_SCHEMA']}") + + # Create an empty mutable table for INSERT tests. Reyden supports INSERT but + # not CREATE TABLE, so the table has to be pre-created here using the regular + # warehouse before tests start writing to it. + schema = os.environ["PER_RUN_SCHEMA"] + mutable_stmt = f"CREATE TABLE IF NOT EXISTS main.{schema}.adbc_testing_mutable (id INT, name STRING) USING DELTA" + payload = json.dumps({"warehouse_id": warehouse, "statement": mutable_stmt, "wait_timeout": "30s"}).encode() + req = urllib.request.Request(url, data=payload, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req) as resp: + body = json.loads(resp.read()) + if body.get("status", {}).get("state", "") != "SUCCEEDED": + print(f"ERROR: CREATE mutable table failed: {json.dumps(body, indent=2)}") + sys.exit(1) + print(f"Created mutable table: main.{schema}.adbc_testing_mutable") PYEOF - name: Create Databricks config file @@ -162,6 +194,8 @@ jobs: "query": "SELECT * FROM main.${PER_RUN_SCHEMA}.adbc_testing_table", "expectedResults": 12, "isCITesting": true, + "isReadOnly": true, + "mutableTable": "main.${PER_RUN_SCHEMA}.adbc_testing_mutable", "tracePropagationEnabled": "true", "traceParentHeaderName": "traceparent", "traceStateEnabled": "false", @@ -180,12 +214,30 @@ jobs: run: | ./ci/scripts/csharp_build.sh "${{ github.workspace }}" - - name: Run All Tests + - name: Run E2E tests shell: bash run: | export DATABRICKS_TEST_CONFIG_FILE="$HOME/.databricks/connection.json" - ./ci/scripts/csharp_test_databricks_e2e.sh "${{ github.workspace }}" + # Nightly only — skip unit tests (Tests.Unit namespace). Always emit a + # TRX so the dashboard can parse pass/fail/skip + per-test failure + # detail; set -e in the script still writes the TRX before the + # non-zero exit and the upload step is always(). + ./ci/scripts/csharp_test_databricks_e2e.sh "${{ github.workspace }}" \ + --filter FullyQualifiedName!~Tests.Unit \ + --logger "trx;LogFileName=results-rest.trx" \ + --results-directory "${{ github.workspace }}/TestResults" + - name: Upload test results (TRX) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: e2e-trx-rest + path: ${{ github.workspace }}/TestResults/*.trx + if-no-files-found: warn + retention-days: 7 + + # Always run, even on test failure or job cancellation, so we don't leak + # schemas in the workspace. - name: Drop per-run schema if: always() && steps.schema.outputs.PER_RUN_SCHEMA != '' env: @@ -201,8 +253,73 @@ jobs: -d "$PAYLOAD") STATUS=$(echo "$RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('status', {}).get('state', ''))") if [ "$STATUS" != "SUCCEEDED" ]; then + # Don't fail the job on cleanup error — leaked schemas can be + # garbage-collected later. But surface the failure. echo "WARNING: DROP SCHEMA failed (status=$STATUS)" echo "Response: $RESPONSE" else echo "Dropped schema: main.${PER_RUN_SCHEMA}" fi + + # Parse the TRX results and publish an append-only history to the gh-pages + # branch (served at https://adbc-drivers.github.io/databricks/e2e-nightly/). + # Runs always() so a failed test run is still recorded — surfacing failures + # is the whole point of the page. + publish-dashboard: + name: Publish E2E Dashboard + needs: reyden-rest-nightly + if: always() + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Download test results + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: e2e-trx-* + path: trx + + - name: Build dashboard and push to gh-pages + env: + # The Reyden nightly always exercises the read-only Reyden warehouse, + # so the parsed run record is tagged read-only for the dashboard. + RUN_READ_ONLY: 'true' + run: | + set -e + shopt -s nullglob + RUN_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + export RUN_TIMESTAMP + + dirs=(trx/e2e-trx-*) + if [ ${#dirs[@]} -eq 0 ]; then + echo "No TRX artifacts found — nothing to publish." + exit 0 + fi + + git fetch origin gh-pages + git worktree add /tmp/ghp gh-pages + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + for d in "${dirs[@]}"; do + proto="${d#trx/e2e-trx-}" + export RUN_PROTOCOL="$proto" + echo "::group::Parsing $proto" + python3 .github/scripts/parse-trx-to-json.py "/tmp/record-${proto}.json" "$d" + python3 .github/scripts/update-e2e-dashboard.py \ + "/tmp/record-${proto}.json" /tmp/ghp/e2e-nightly .github/e2e-dashboard/index.html + echo "::endgroup::" + done + + cd /tmp/ghp + git add e2e-nightly + if git diff --cached --quiet; then + echo "No dashboard changes to commit." + else + git commit -m "e2e-nightly: record run ${GITHUB_RUN_ID} (${GITHUB_REF_NAME})" + git push origin gh-pages + echo "Published: https://adbc-drivers.github.io/databricks/e2e-nightly/" + fi diff --git a/ci/scripts/csharp_test_databricks_e2e.sh b/ci/scripts/csharp_test_databricks_e2e.sh index de3a67054..a021a7014 100755 --- a/ci/scripts/csharp_test_databricks_e2e.sh +++ b/ci/scripts/csharp_test_databricks_e2e.sh @@ -16,10 +16,8 @@ set -ex -# Run all tests (both E2E and Unit tests) source_dir=${1}/csharp/test pushd ${source_dir} -# Run all tests in the Databricks test project -dotnet test --verbosity normal +dotnet test --verbosity normal ${@:2} popd