add scheduled daily test workflow with auto issue management. add --json flag to test-apps.py

This commit is contained in:
Richard Macias
2026-02-28 10:33:31 -06:00
parent 9a9937693e
commit 146d051cb8
4 changed files with 319 additions and 24 deletions

49
.github/workflows/scheduled-test.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Scheduled App Tests
on:
schedule:
- cron: "0 6 * * *" # Daily at 06:00 UTC
workflow_dispatch:
permissions:
issues: write
jobs:
test:
name: Live Test All Apps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Run tests
id: test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python scripts/test-apps.py --json > test-results.json 2>&1 || true
cat test-results.json
- name: Process results and manage issues
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python scripts/process-test-results.py \
test-results.json \
--run-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Fail if any tests failed
run: |
python -c "
import json, sys
with open('test-results.json') as f:
data = json.load(f)
failed = data.get('summary', {}).get('failed', 0)
if failed > 0:
print(f'{failed} app(s) failed')
sys.exit(1)
print('All apps passed')
"

View File

@@ -33,6 +33,10 @@ normalize:
test *args: test *args:
@python scripts/test-apps.py {{ args }} @python scripts/test-apps.py {{ args }}
# Dry-run the scheduled test workflow (no issues created)
test-cron *args:
@{{ if args == "-h" { "python scripts/test-apps.py -h" } else if args == "--help" { "python scripts/test-apps.py -h" } else { "python scripts/test-apps.py --json " + args + " > /tmp/test-results.json && python scripts/process-test-results.py /tmp/test-results.json --dry-run --run-url local" } }}
# Generate output files # Generate output files
generate *args: generate *args:
@{{ if args == "help" { "just _generate-help" } else if args == "-h" { "just _generate-help" } else if args == "--help" { "just _generate-help" } else if args == "table" { "just _generate-table" } else if args == "readme" { "just _generate-readme" } else if args == "standard" { "just _generate-standard" } else if args == "dual-screen" { "just _generate-dual-screen" } else { "just _generate-all" } }} @{{ if args == "help" { "just _generate-help" } else if args == "-h" { "just _generate-help" } else if args == "--help" { "just _generate-help" } else if args == "table" { "just _generate-table" } else if args == "readme" { "just _generate-readme" } else if args == "standard" { "just _generate-standard" } else if args == "dual-screen" { "just _generate-dual-screen" } else { "just _generate-all" } }}

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Process test-apps.py JSON output and manage GitHub issues.
Creates issues for newly failing apps, closes issues for recovered apps.
Designed to run in GitHub Actions as part of the scheduled test workflow.
"""
import argparse
import json
import subprocess
import sys
from typing import Any
from help_formatter import StyledHelpFormatter
ISSUE_LABEL = "automated-test-failure"
TITLE_PREFIX = "[Automated Test Failure]"
def _run_gh(args: list[str]) -> subprocess.CompletedProcess:
return subprocess.run(
["gh", *args],
capture_output=True,
text=True,
)
def _parse_gh_json(result: subprocess.CompletedProcess) -> list | None:
if result.returncode != 0 or not result.stdout.strip():
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return None
def _ensure_label_exists() -> None:
result = _run_gh(["label", "list", "--search", ISSUE_LABEL, "--json", "name"])
labels = _parse_gh_json(result)
if labels is None:
_run_gh([
"label", "create", ISSUE_LABEL,
"--description", "Automatically created when a scheduled app test fails",
"--color", "d93f0b",
])
return
if not any(label["name"] == ISSUE_LABEL for label in labels):
_run_gh([
"label", "create", ISSUE_LABEL,
"--description", "Automatically created when a scheduled app test fails",
"--color", "d93f0b",
])
def _find_open_issue(app_name: str) -> int | None:
"""Search for an open issue matching this app. Returns issue number or None."""
search_title = f"{TITLE_PREFIX} {app_name}"
result = _run_gh([
"issue", "list",
"--label", ISSUE_LABEL,
"--state", "open",
"--search", f"{search_title} in:title",
"--json", "number,title",
])
issues = _parse_gh_json(result)
if issues is None:
return None
for issue in issues:
if app_name in issue.get("title", ""):
return issue["number"]
return None
def _create_issue(app: dict[str, Any], run_url: str) -> None:
title = f"{TITLE_PREFIX} {app['app_name']}"
body = (
f"The scheduled test run detected a failure for **{app['app_name']}**.\n\n"
f"| Field | Value |\n"
f"|-------|-------|\n"
f"| App ID | `{app['app_id']}` |\n"
f"| Source | {app['source']} |\n"
f"| URL | {app['url']} |\n"
f"| Error | {app.get('error', 'unknown')} |\n\n"
)
if app.get("warnings"):
body += "**Warnings:**\n"
for w in app["warnings"]:
body += f"- {w}\n"
body += "\n"
body += f"[Workflow run]({run_url})\n"
_run_gh([
"issue", "create",
"--title", title,
"--body", body,
"--label", ISSUE_LABEL,
])
print(f" Created issue: {title}")
def _close_issue(issue_number: int, app_name: str, run_url: str) -> None:
comment = (
f"**{app_name}** is passing again in the latest scheduled test run.\n\n"
f"[Workflow run]({run_url})"
)
_run_gh(["issue", "close", str(issue_number), "--comment", comment])
print(f" Closed issue #{issue_number}: {app_name} recovered")
def main() -> int:
parser = argparse.ArgumentParser(
description="Process test results JSON and manage GitHub issues.",
formatter_class=StyledHelpFormatter,
)
parser.add_argument(
"results_file",
help="Path to test-results.json from test-apps.py --json",
)
parser.add_argument(
"--run-url",
default="",
help="URL of the GitHub Actions workflow run",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print planned actions without creating or closing issues",
)
args = parser.parse_args()
try:
with open(args.results_file, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f"Error loading results file: {e}")
return 1
if "error" in data:
print(f"Test run error: {data['error']}")
return 1
results = data.get("results", [])
if not results:
print("No test results to process.")
return 0
dry_run = args.dry_run
if dry_run:
print("DRY RUN: no issues will be created or closed\n")
else:
_ensure_label_exists()
failed = [r for r in results if not r["passed"]]
passed = [r for r in results if r["passed"]]
summary = data.get("summary", {})
print(
f"Processing {summary.get('total', len(results))} results: "
f"{summary.get('passed', len(passed))} passed, "
f"{summary.get('failed', len(failed))} failed"
)
for app in failed:
if dry_run:
print(f" Would create issue: {TITLE_PREFIX} {app['app_name']}")
print(f" Error: {app.get('error', 'unknown')}")
else:
existing = _find_open_issue(app["app_name"])
if existing:
print(f" Skipped {app['app_name']}: open issue #{existing} already exists")
else:
_create_issue(app, args.run_url)
for app in passed:
if dry_run:
print(f" Would check/close issue for: {app['app_name']} (passing)")
else:
existing = _find_open_issue(app["app_name"])
if existing:
_close_issue(existing, app["app_name"], args.run_url)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -181,6 +181,22 @@ class TestResult:
self.warnings: list[str] = [] self.warnings: list[str] = []
self.duration_ms = 0 self.duration_ms = 0
def to_dict(self) -> dict[str, Any]:
return {
"app_name": self.app_name,
"app_id": self.app_id,
"source": self.source,
"url": self.url,
"passed": self.passed,
"version": self.version,
"apk_count": self.apk_count,
"apk_urls": self.apk_urls,
"preferred_apk_index": self.preferred_apk_index,
"error": self.error,
"warnings": self.warnings,
"duration_ms": self.duration_ms,
}
def __repr__(self) -> str: def __repr__(self) -> str:
status = "PASS" if self.passed else "FAIL" status = "PASS" if self.passed else "FAIL"
return f"{status}: {self.app_name} ({self.source})" return f"{status}: {self.app_name} ({self.source})"
@@ -571,6 +587,15 @@ def print_result(
print(f" APK: {url}") print(f" APK: {url}")
def _print_json_error(message: str) -> None:
output = {
"summary": {"total": 0, "passed": 0, "failed": 0, "warned": 0, "wall_time_ms": 0, "cumulative_time_ms": 0},
"results": [],
"error": message,
}
print(json.dumps(output, indent=2))
def main() -> int: def main() -> int:
load_dotenv() load_dotenv()
@@ -609,6 +634,11 @@ def main() -> int:
default=8, default=8,
help="Number of parallel workers (default: 8, use 1 for serial)", help="Number of parallel workers (default: 8, use 1 for serial)",
) )
parser.add_argument(
"--json",
action="store_true",
help="Output results as JSON (for CI/scripting)",
)
args = parser.parse_args() args = parser.parse_args()
json_file = args.file json_file = args.file
@@ -617,12 +647,16 @@ def main() -> int:
verbose = args.verbose verbose = args.verbose
show_apks = args.apks show_apks = args.apks
workers = max(args.jobs, 1) workers = max(args.jobs, 1)
json_output = args.json
try: try:
with open(json_file, "r", encoding="utf-8") as f: with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
except (json.JSONDecodeError, FileNotFoundError) as e: except (json.JSONDecodeError, FileNotFoundError) as e:
print(f"Error loading {json_file}: {e}") if json_output:
_print_json_error(f"Error loading {json_file}: {e}")
else:
print(f"Error loading {json_file}: {e}")
return 1 return 1
apps = data.get("apps", []) apps = data.get("apps", [])
@@ -632,21 +666,27 @@ def main() -> int:
apps = [a for a in apps if name_filter in a.get("name", "").lower()] apps = [a for a in apps if name_filter in a.get("name", "").lower()]
if not apps: if not apps:
print("No apps matched the filter.") if json_output:
_print_json_error("No apps matched the filter.")
else:
print("No apps matched the filter.")
return 1 return 1
has_token = bool(os.environ.get("GITHUB_TOKEN")) if not json_output:
github_count = sum(1 for a in apps if _effective_source(a) == "GitHub") has_token = bool(os.environ.get("GITHUB_TOKEN"))
if github_count > 0 and not has_token: github_count = sum(1 for a in apps if _effective_source(a) == "GitHub")
print( if github_count > 0 and not has_token:
f"\033[33mNote\033[0m: {github_count} GitHub apps to test, " print(
"but GITHUB_TOKEN is not set. You may hit rate limits.\n" f"\033[33mNote\033[0m: {github_count} GitHub apps to test, "
" Set it with: export GITHUB_TOKEN=<your_token>\n" "but GITHUB_TOKEN is not set. You may hit rate limits.\n"
) " Set it with: export GITHUB_TOKEN=<your_token>\n"
)
serial = workers == 1 or len(apps) == 1 serial = workers == 1 or len(apps) == 1
mode = "serial" if serial else f"{workers} workers"
print(f"Testing {len(apps)} app(s) ({mode})...\n") if not json_output:
mode = "serial" if serial else f"{workers} workers"
print(f"Testing {len(apps)} app(s) ({mode})...\n")
wall_start = time.monotonic() wall_start = time.monotonic()
@@ -655,7 +695,8 @@ def main() -> int:
for app in apps: for app in apps:
result = test_app(app) result = test_app(app)
results.append(result) results.append(result)
print_result(result, verbose=verbose, show_apks=show_apks) if not json_output:
print_result(result, verbose=verbose, show_apks=show_apks)
else: else:
result_map: dict[str, TestResult] = {} result_map: dict[str, TestResult] = {}
with ThreadPoolExecutor(max_workers=workers) as pool: with ThreadPoolExecutor(max_workers=workers) as pool:
@@ -663,10 +704,10 @@ def main() -> int:
for future in as_completed(futures): for future in as_completed(futures):
result = future.result() result = future.result()
result_map[result.app_id] = result result_map[result.app_id] = result
# Print in original order
results = [result_map[app["id"]] for app in apps] results = [result_map[app["id"]] for app in apps]
for result in results: if not json_output:
print_result(result, verbose=verbose, show_apks=show_apks) for result in results:
print_result(result, verbose=verbose, show_apks=show_apks)
wall_ms = int((time.monotonic() - wall_start) * 1000) wall_ms = int((time.monotonic() - wall_start) * 1000)
passed = sum(1 for r in results if r.passed) passed = sum(1 for r in results if r.passed)
@@ -674,15 +715,29 @@ def main() -> int:
warned = sum(1 for r in results if r.warnings) warned = sum(1 for r in results if r.warnings)
sum_time = sum(r.duration_ms for r in results) sum_time = sum(r.duration_ms for r in results)
print(f"\n{'=' * 60}") if json_output:
print(f"Results: {passed} passed, {failed} failed, {warned} with warnings") output = {
print(f"Time: {wall_ms / 1000:.1f}s wall, {sum_time / 1000:.1f}s cumulative") "summary": {
"total": len(results),
"passed": passed,
"failed": failed,
"warned": warned,
"wall_time_ms": wall_ms,
"cumulative_time_ms": sum_time,
},
"results": [r.to_dict() for r in results],
}
print(json.dumps(output, indent=2))
else:
print(f"\n{'=' * 60}")
print(f"Results: {passed} passed, {failed} failed, {warned} with warnings")
print(f"Time: {wall_ms / 1000:.1f}s wall, {sum_time / 1000:.1f}s cumulative")
if failed > 0: if failed > 0:
print(f"\nFailed apps:") print(f"\nFailed apps:")
for r in results: for r in results:
if not r.passed: if not r.passed:
print(f" - {r.app_name}: {r.error}") print(f" - {r.app_name}: {r.error}")
return 1 if failed > 0 else 0 return 1 if failed > 0 else 0