Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
"""Tests for scripts/ci/assemble_review_comment.py.
The assembler collects status from every CI sub-workflow into ReviewItems
classified by severity (error / action_required / warning / info / debug), then
renders them into a single PR comment body.
Status data comes from two sources:
1. --review-statuses-json: JSON array of {source, results: [...]} objects
from workflow_call jobs. Each result has kind/title/summary/detail/
how_to_fix/link. The assembler flattens all results into ReviewItems.
2. --needs-json: {job_name: result} from all-checks-pass. Failed jobs not
claimed by any status become synthesized ❌ Error items.
Layout rules tested here:
- group headers: ## ❌ Job failures, ## ⚠️ Action required, ## ⚠️ Warnings
- each item is a ### section under its group header
- errors + action_required always visible
- warnings shown only when present
- info above the fold; debug in a collapsible <details> block
- sections separated by ---
- how_to_fix rendered at bottom of action_required items
- empty → clean banner
- jobs with declared statuses excluded from failed-jobs list
- per-job URLs used for failed job links when available
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import pytest
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "assemble_review_comment.py"
_spec = importlib.util.spec_from_file_location("assemble_review_comment", _PATH)
if _spec is None or _spec.loader is None:
raise ImportError("Failed to load assemble_review_comment.py")
_mod = importlib.util.module_from_spec(_spec)
sys.modules["assemble_review_comment"] = _mod
_spec.loader.exec_module(_mod)
MARKER = _mod.MARKER
ReviewItem = _mod.ReviewItem
def _status(source: str, results: list[dict]) -> str:
"""Helper: build a review_statuses JSON string with one source entry."""
return json.dumps([{"source": source, "results": results}])
# ─── collect_from_statuses ──────────────────────────────────────────
def test_statuses_empty_json():
items, sources = _mod.collect_from_statuses("")
assert items == []
assert sources == set()
def test_statuses_bad_json():
items, sources = _mod.collect_from_statuses("not json")
assert items == []
assert sources == set()
def test_statuses_info():
statuses = _status("review-label-gate", [{
"kind": "info",
"title": "CI-sensitive file review",
"summary": "Label present.",
}])
items, sources = _mod.collect_from_statuses(statuses)
assert len(items) == 1
assert items[0].severity == "info"
assert sources == {"review-label-gate"}
# ─── collect_failed_jobs ─────────────────────────────────────────────
def test_failed_jobs_empty_needs():
assert _mod.collect_failed_jobs("", "https://run") == []
def test_failed_jobs_excluded_by_source():
"""Jobs whose name contains a declared source are excluded."""
needs = json.dumps({
"Review label gate / Review label gate": "failure",
"tests": "failure",
})
items = _mod.collect_failed_jobs(needs, "https://run", exclude_sources={"review-label-gate"})
assert len(items) == 1
assert items[0].title == "tests"
# ─── render_comment ───────────────────────────────────────────────────
def test_render_group_header_for_errors():
"""Errors appear under a '## ❌ Job failures' group header."""
items = [
ReviewItem(severity="error", title="tests", summary="Job **tests** failed.", link="https://run"),
ReviewItem(severity="error", title="lint", summary="Job **lint** failed.", link="https://run"),
]
body = _mod.render_comment(items)
assert "## ❌ Job failures" in body
assert "### tests" in body
assert "### lint" in body
assert body.index("## ❌ Job failures") < body.index("### tests")
# ─── render_comment (pending jobs) ────────────────────────────────────
def test_render_pending_only_shows_header_with_clock():
"""Pending jobs only — header has 'still waiting', footer lists jobs, no sections."""
body = _mod.render_comment([], pending_jobs=["ci-timings"])
assert body.startswith(MARKER)
assert "૮ >ﻌ< ა" in body
assert "Still running" in body
assert "`ci-timings`" in body
assert "##" not in body
def test_render_pending_notif():
items = [ReviewItem(severity="info", title="lockfile", summary="No changes.")]
body = _mod.render_comment(items, pending_jobs=["ci-timings"])
assert "૮ >ﻌ< ა" in body
assert "<sub>Still running 1 job: `ci-timings`</sub>" in body
# ─── render_comment (waiting for jobs to start) ───────────────────────
def test_waiting_with_no_items_shows_waiting_not_all_good():
"""A run with no jobs yet must not render the final 'all good!' banner."""
body = _mod.render_comment([], waiting=True)
assert "all good" not in body
assert "waiting for jobs to start" in body
def test_waiting_with_items_but_no_pending_keeps_a_live_footer():
"""Between job waves: results exist, nothing pending, run not done."""
items = [ReviewItem(severity="info", title="lockfile", summary="No changes.")]
body = _mod.render_comment(items, waiting=True)
assert "waiting for more jobs to start" in body
assert "### lockfile" in body
def test_not_waiting_and_no_items_still_renders_all_good():
body = _mod.render_comment([])
assert "all good!" in body
def test_assemble_passes_waiting_through():
body = _mod.assemble(waiting=True)
assert "waiting for jobs to start" in body
assert "all good" not in body
# ─── assemble (integration) ──────────────────────────────────────────
def test_assemble_review_status_detail_renders_sensitive_file_links():
statuses = _status("review-label-gate", [{
"kind": "action_required",
"title": "CI-sensitive file review",
"summary": "Changes detected.",
"detail": "**Sensitive files:**\n- [`ci.yml`](https://example.test/ci.yml)",
}])
body = _mod.assemble(review_statuses_json=statuses)
assert "**Sensitive files:**" in body
assert "[`ci.yml`](https://example.test/ci.yml)" in body
def test_assemble_info_keeps_screenshot_details_visible_below_its_summary():
statuses = _status("playwright e2e", [{
"kind": "info",
"title": "Desktop E2E screenshots",
"summary": "1 screenshot captured; 0 visual diffs.",
"detail": "<details>\n<summary>1 captured screenshot</summary>\n\n- [`proof.png`](https://example.test/artifact)\n\n</details>",
}])
body = _mod.assemble(review_statuses_json=statuses)
assert "## ️ Info" in body
assert "1 screenshot captured; 0 visual diffs." in body
assert "<summary>1 captured screenshot</summary>" in body
assert "[`proof.png`](https://example.test/artifact)" in body
def test_assemble_with_timings_status():
"""Timings status from the nested format renders as debug or warning."""
statuses = _status("ci-timings", [{
"kind": "debug",
"title": "CI timings",
"summary": "Wall time 3m (no baseline yet).",
"detail": "",
"link": "https://report",
}])
body = _mod.assemble(review_statuses_json=statuses)
assert "<details>" in body
assert "### CI timings" in body
assert "Wall time 3m" in body
assert "## ❌" not in body
assert "## ⚠️" not in body
def test_assemble_with_lockfile_status():
"""Lockfile no-changes status renders as visible info."""
statuses = _status("lockfile-diff", [{
"kind": "info",
"title": "package-lock.json",
"summary": "No lockfile changes — locked versions match the target branch.",
}])
body = _mod.assemble(review_statuses_json=statuses)
assert "## ️ Info" in body
assert "### package-lock.json" in body
assert "No lockfile changes" in body
# ─── _attach_job_urls ────────────────────────────────────────────────
def test_attach_job_urls_fills_missing_links():
"""Items without a link get one from job_urls via source matching."""
items = [
ReviewItem(severity="info", title="Supply chain scan",
summary="No risks.", source="supply chain"),
ReviewItem(severity="warning", title="CI timings",
summary="Slower.", source="ci timings",
link="https://report"), # already has a link
]
job_urls = {
"Supply Chain Audit / Scan PR for critical supply chain risks": "https://run/1/job/2",
}
_mod._attach_job_urls(items, job_urls, "https://fallback")
# First item gets the per-job URL as job_url (link untouched)
assert items[0].job_url == "https://run/1/job/2"
assert items[0].link == "" # no emitted link
# Second item keeps its existing link, job_url is set separately
assert items[1].link == "https://report"
assert items[1].job_url == "https://fallback" # fell back to run_url
def test_render_commit_info_below_header():
"""Commit info is rendered below the header, above the content."""
body = _mod.render_comment(
[ReviewItem(severity="error", title="tests", summary="failed.")],
commit_info="<sub>running on [abc1234](https://commit-url) — fix: thing</sub>",
)
assert "# ૮ >ﻌ< ა ci review" in body
assert "running on [abc1234](https://commit-url)" in body
assert "fix: thing" in body
# Commit info appears before the content
assert body.index("abc1234") < body.index("## ❌")
def test_assemble_passes_commit_info():
"""assemble() passes commit_info through to render_comment."""
body = _mod.assemble(commit_info="<sub>running on abc1234</sub>")
assert "running on abc1234" in body
assert "all good!" in body
def test_render_both_emitted_link_and_job_url():
"""An item with both an emitted link and a job_url shows both."""
item = ReviewItem(
severity="warning",
title="CI timings",
summary="Slower.",
link="https://artifact/report.html",
link_label="View report",
source="ci timings",
job_url="https://github.com/run/1/job/5",
)
body = _mod.render_comment([item])
assert "[View report](https://artifact/report.html)" in body
assert "[View job](https://github.com/run/1/job/5)" in body
# Both links on the same line, separated by ·
assert " · " in body
+414
View File
@@ -0,0 +1,414 @@
"""Tests for scripts/ci/classify_changes.py.
Check some common patterns of file modifications and the CI lanes they should run.
We should always fail open. We may run a lane we didn't need, never skip one a
change could have broken.
"""
from __future__ import annotations
import importlib.util
import io
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "classify_changes.py"
_spec = importlib.util.spec_from_file_location("classify_changes", _PATH)
if _spec is None or _spec.loader is None:
raise ImportError("Failed to load classify_changes.py")
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
classify = _mod.classify
ci_review_files = _mod.ci_review_files
pull_request_changed_files = _mod.pull_request_changed_files
main = _mod.main
DEFAULT = {
"python": True,
"python_prod": True,
"frontend": True,
"docker": True,
"docker_meta": True,
"nix": True,
"site": True,
"scan": True,
"deps": True,
"uv_lock": True,
"npm_lock": True,
"installer": True,
"desktop_updater": True,
"rust": True,
"mcp_catalog": False,
"ci_review": True,
}
def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, uv_lock=False, npm_lock=False, installer=False, desktop_updater=False, rust=False, mcp_catalog=False, docker_meta=False, ci_review=False, python_prod=None, nix=None, docker=None) -> dict[str, bool]:
# python_prod tracks python except for tests-only diffs; default it to
# python so the majority of cases don't need to spell it out.
#
# docker and nix are derived: both build the product, so both ride on
# python_prod and frontend. The image ships the built web assets, and the
# flake bundles the compiled ui-tui. Pass either explicitly to override.
_python_prod = python if python_prod is None else python_prod
_product = _python_prod or frontend
return {
"python": python,
"python_prod": _python_prod,
"docker": (docker_meta or _product) if docker is None else docker,
"nix": _product if nix is None else nix,
"frontend": frontend,
"docker_meta": docker_meta,
"site": site,
"scan": scan,
"deps": deps,
"uv_lock": uv_lock,
"npm_lock": npm_lock,
"installer": installer,
"desktop_updater": desktop_updater,
"rust": rust,
"mcp_catalog": mcp_catalog,
"ci_review": ci_review,
}
CASES = {
"docs-only → nothing heavy": (["README.md", "docs/guide.md"], _lanes()),
"python source → python": (["run_agent.py"], _lanes(python=True, scan=True)),
# pyproject.toml declares the pytest markers the OS lanes select on, so it
# also re-arms the desktop_updater integration tests (fail-open).
"dep manifest → python": (["pyproject.toml"], _lanes(python=True, scan=True, deps=True, uv_lock=True, desktop_updater=True)),
"uv.lock → python": (["uv.lock"], _lanes(python=True, uv_lock=True)),
"ts package → frontend": (["apps/desktop/src/app.tsx"], _lanes(frontend=True)),
"ui-tui → frontend": (["ui-tui/src/entry.ts"], _lanes(frontend=True)),
# Lockfile bump shifts every TS package's tree, but not the Python suite.
"root lockfile → frontend, not python": (["package-lock.json"], _lanes(frontend=True, npm_lock=True)),
"nested lockfile → npm_lock": (["website/package-lock.json"], _lanes(site=True, npm_lock=True)),
# A website file the Python suite cannot read stays site-only.
"website config → site": (["website/docusaurus.config.ts"], _lanes(site=True)),
# uv lock --check re-resolves against PyPI, so it must stay off for any
# diff that can't desync the lockfile — a registry blip on a docs PR
# otherwise shows up as a blocking "uv.lock out of sync" red X.
"docs → no uv_lock": (
["website/docs/developer-guide/plugins/index.md"],
_lanes(python=True, site=True),
),
"frontend → no uv_lock": (["apps/desktop/src/store/profile.ts"], _lanes(frontend=True)),
# The published CIMD document is asserted about by the Python suite, so a
# lone edit there must not skip the lane that would catch a bad edit.
"cimd document → python + site": (
["website/static/oauth/client-metadata.json"],
_lanes(python=True, site=True),
),
# A new docs page must reach llms.txt, and the generator that puts it there
# has its own tests. Skipping Python on either is how the index drifted to
# 53% coverage while every PR stayed green.
"docs page → python + site": (
["website/docs/user-guide/bot-mode.md"],
_lanes(python=True, site=True),
),
"docs generator → python + site": (
["website/scripts/generate-llms-txt.py"],
_lanes(python=True, scan=True, site=True),
),
# SKILL.md reads like docs, but the skill-doc tests read skills/, so a
# skill edit must still run Python.
"skill md → python + site": (["skills/github/SKILL.md"], _lanes(python=True, site=True)),
"dockerfile → docker meta": (["Dockerfile"], _lanes(docker_meta=True)),
# Only the flake reads these, so they run nix alone. No Python test opens
# them, unlike pyproject.toml and uv.lock below.
"nix module → nix only": (["nix/homeManagerModules.nix"], _lanes(nix=True)),
"flake.nix → nix only": (["flake.nix"], _lanes(nix=True)),
"flake.lock → nix only": (["flake.lock"], _lanes(nix=True)),
# A flake-only file must not mask a Python change beside it.
"nix + python → both": (["nix/checks.nix", "agent/x.py"], _lanes(python=True, scan=True)),
# Nine checks run the built binary, so product Python is a nix input even
# when the diff touches no file under nix/.
"product python → nix": (["hermes_cli/config.py"], _lanes(python=True, scan=True)),
# tests/ is not packaged, so the built binary cannot change.
"tests-only → no nix": (
["tests/agent/test_foo.py"],
_lanes(python=True, python_prod=False, scan=True),
),
# Prose cannot change the closure or the binary.
"docs-only → no nix": (["README.md"], _lanes()),
# install.ps1 is a shell script Python never imports, but it's also not
# provably prose, so python stays on (fail-open) alongside the Windows lane.
"install.ps1 → installer": (["scripts/install.ps1"], _lanes(python=True, installer=True)),
"installer test → installer": (
["scripts/tests/test-install-ps1-longpath.ps1"],
_lanes(python=True, installer=True),
),
"python source alone → no installer lane": (["run_agent.py"], _lanes(python=True, scan=True)),
# The Windows desktop-update hand-off is a PowerShell integration surface:
# its tests spawn the real script and poll its loopback server. They run
# when the script, the Electron side that launches it, or their own test
# files change — not on every hermes_state.py PR.
"windows.ps1 → desktop_updater": (
["scripts/desktop-update/windows.ps1"],
_lanes(python=True, desktop_updater=True),
),
"desktop-update test → desktop_updater": (
["tests/test_desktop_update_windows_progress.py"],
_lanes(python=True, python_prod=False, scan=True, desktop_updater=True),
),
"updater-process.ts → desktop_updater": (
["apps/desktop/electron/updater-process.ts"],
_lanes(frontend=True, desktop_updater=True),
),
"python source alone → no desktop_updater lane": (["hermes_state.py"], _lanes(python=True, scan=True)),
# `.rs` lives under apps/, so it matches `frontend` too. That lane builds
# TypeScript and cannot notice a Rust error — before `rust` existed it was
# the ONLY lane a Rust change ran, and the crate's tests never executed.
"rust source → rust": (
["apps/bootstrap-installer/src-tauri/src/powershell.rs"],
_lanes(frontend=True, rust=True),
),
"cargo lockfile → rust": (
["apps/bootstrap-installer/src-tauri/Cargo.lock"],
_lanes(frontend=True, rust=True),
),
# Non-.rs files in the crate still change what cargo builds.
"tauri config → rust": (
["apps/bootstrap-installer/src-tauri/tauri.conf.json"],
_lanes(frontend=True, rust=True),
),
"ts source alone → no rust lane": (
["apps/bootstrap-installer/src/main.tsx"],
_lanes(frontend=True),
),
# Unknown top-level file keeps Python on rather than risk a silent skip.
"unknown toplevel → python": (["Makefile"], _lanes(python=True)),
"mixed docs+python → python": (["README.md", "agent/x.py"], _lanes(python=True, scan=True)),
"mixed docs+frontend → frontend": (["README.md", "apps/x.tsx"], _lanes(frontend=True)),
# tests-only diffs: pytest lanes stay ON, product jobs (Desktop E2E,
# Docker) gate on python_prod and skip.
"tests-only → python without python_prod": (
["tests/agent/test_foo.py"],
_lanes(python=True, python_prod=False, scan=True),
),
# conftest.py owns the _OS_MARKS skip logic, so it re-arms the
# desktop_updater integration tests too (fail-open).
"conftest → python + desktop_updater": (
["tests/conftest.py"],
_lanes(python=True, python_prod=False, scan=True, desktop_updater=True),
),
"tests + prod source → both lanes": (
["tests/agent/test_foo.py", "agent/x.py"],
_lanes(python=True, scan=True),
),
# Runner infrastructure is NOT tests-only — a bad runner edit can mask
# real failures, so it keeps the conservative full lane set.
"test runner script → python_prod stays on": (
["scripts/run_tests_parallel.py"],
_lanes(python=True, scan=True),
),
# Supply-chain lanes
".pth file → scan": (["evil.pth"], _lanes(python=True, scan=True)),
"setup.py → scan": (["setup.py"], _lanes(python=True, scan=True)),
"mcp catalog manifest → mcp_catalog": (
["optional-mcps/foo/manifest.yaml"],
_lanes(python=True, mcp_catalog=True),
),
"mcp_catalog.py → mcp_catalog": (
["hermes_cli/mcp_catalog.py"],
_lanes(python=True, scan=True, mcp_catalog=True),
),
# CI-sensitive files require explicit review label.
"eslint config → ci_review": (
["apps/desktop/eslint.config.mjs"],
_lanes(frontend=True, ci_review=True),
),
"shared eslint config → ci_review": (
["eslint.config.shared.mjs"],
_lanes(python=True, ci_review=True),
),
"ui-tui eslint config → ci_review": (
["ui-tui/eslint.config.mjs"],
_lanes(frontend=True, ci_review=True),
),
"web eslint config → ci_review": (
["web/eslint.config.js"],
_lanes(frontend=True, ci_review=True),
),
"shared package eslint config → ci_review": (
["apps/shared/eslint.config.mjs"],
_lanes(frontend=True, ci_review=True),
),
"bootstrap-installer eslint config → ci_review": (
["apps/bootstrap-installer/eslint.config.mjs"],
_lanes(frontend=True, ci_review=True),
),
"prettier config → ci_review": (
[".prettierrc"],
_lanes(python=True, ci_review=True),
),
"workflow yml → ci_review (also fail-open all)": (
[".github/workflows/typecheck.yml"],
DEFAULT,
),
"composite action → ci_review (also fail-open all)": (
[".github/actions/retry/action.yml"],
DEFAULT,
),
# Normal desktop source doesn't trigger ci_review.
"desktop src → no ci_review": (
["apps/desktop/src/app.tsx"],
_lanes(frontend=True),
),
# Fail open: CI-config / empty / blank diffs run everything.
".github change → all": ([".github/workflows/tests.yml"], DEFAULT),
"action change → all": ([".github/actions/detect-changes/action.yml"], DEFAULT),
"empty diff → all": ([], DEFAULT),
"blank lines → all": (["", " "], DEFAULT),
}
@pytest.mark.parametrize("files,expected", CASES.values(), ids=CASES.keys())
def test_classify(files, expected):
assert classify(files) == expected
_REPO = Path(__file__).resolve().parents[2]
def _yaml(rel: str) -> dict:
yaml = pytest.importorskip("yaml")
return yaml.safe_load((_REPO / rel).read_text(encoding="utf-8"))
def test_every_lane_reaches_the_composite_action():
"""The action is the one surface every consumer reads, so it must carry all
of them — ci.yaml, nix.yml and docker.yml each re-export a different subset.
"""
lanes = set(classify(["run_agent.py"]))
action_outputs = set(_yaml(".github/actions/detect-changes/action.yml")["outputs"])
assert lanes - action_outputs == set(), "lane(s) missing from the composite action's outputs"
def test_ci_jobs_only_gate_on_detect_outputs_that_detect_actually_declares():
"""An ``if`` that reads an undeclared output resolves to the empty string.
The lane then reports "skipping" on every PR, forever, and nothing goes red
— there is no error for referencing an output a job never declared. That is
exactly how the ``rust`` lane shipped dead: the classifier emitted it and
the composite action re-exported it, but ci.yaml's ``detect`` job did not,
so ``needs.detect.outputs.rust`` was never anything but "".
"""
ci = _yaml(".github/workflows/ci.yaml")
declared = set(ci["jobs"]["detect"]["outputs"])
referenced: set[str] = set()
for job in ci["jobs"].values():
for expr in _iter_if_expressions(job):
referenced.update(re.findall(r"needs\.detect\.outputs\.(\w+)", expr))
assert referenced, "found no detect-gated jobs — the walk is broken, not the wiring"
assert referenced - declared == set(), "job(s) gate on an output detect never declares"
def _iter_if_expressions(job: object):
"""Yield every ``if:`` string in a job, including inside its steps."""
if not isinstance(job, dict):
return
if isinstance(cond := job.get("if"), str):
yield cond
for step in job.get("steps", []) or []:
if isinstance(step, dict) and isinstance(cond := step.get("if"), str):
yield cond
def test_ci_review_files_returns_only_sensitive_paths_sorted_and_unique():
assert ci_review_files([
"apps/desktop/src/app.tsx",
".github/workflows/ci.yml",
"apps/desktop/eslint.config.mjs",
".github/workflows/ci.yml",
]) == [
".github/workflows/ci.yml",
"apps/desktop/eslint.config.mjs",
]
def _write_event(tmp_path, number: int | None = 88442) -> Path:
payload = {"pull_request": {"number": number}} if number is not None else {}
path = tmp_path / "event.json"
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def test_pull_request_changed_files_skips_non_pr_events(monkeypatch):
monkeypatch.setenv("EVENT_NAME", "push")
monkeypatch.setenv("REPO", "NousResearch/hermes-agent")
assert pull_request_changed_files() == []
def test_pull_request_changed_files_skips_without_pr_number(tmp_path, monkeypatch):
monkeypatch.setenv("EVENT_NAME", "pull_request")
monkeypatch.setenv("REPO", "NousResearch/hermes-agent")
monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_event(tmp_path, number=None)))
assert pull_request_changed_files() == []
def test_pull_request_changed_files_parses_gh_output(tmp_path, monkeypatch):
monkeypatch.setenv("EVENT_NAME", "pull_request")
monkeypatch.setenv("REPO", "NousResearch/hermes-agent")
monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_event(tmp_path)))
def fake_run(*args, **kwargs):
return subprocess.CompletedProcess(
args[0],
0,
stdout="scripts/install.sh\ntests/test_install_sh_node_deps_workspaces.py\n",
stderr="",
)
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
assert pull_request_changed_files() == [
"scripts/install.sh",
"tests/test_install_sh_node_deps_workspaces.py",
]
def test_pull_request_changed_files_returns_empty_when_gh_fails(tmp_path, monkeypatch):
monkeypatch.setenv("EVENT_NAME", "pull_request")
monkeypatch.setenv("REPO", "NousResearch/hermes-agent")
monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_event(tmp_path)))
def fake_run(*args, **kwargs):
return subprocess.CompletedProcess(args[0], 1, stdout="", stderr="gh: Not Found")
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
assert pull_request_changed_files() == []
def test_main_recovers_pr_files_instead_of_fail_open_ci_review(monkeypatch, capsys):
"""A fork compare 404 must not demand ci-reviewed for a CLI-only install."""
monkeypatch.setattr(
_mod,
"pull_request_changed_files",
lambda: ["scripts/install.sh", "tests/test_install_sh_node_deps_workspaces.py"],
)
monkeypatch.setattr(sys, "stdin", io.StringIO("\n"))
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
assert main() == 0
out = capsys.readouterr().out
assert "ci_review=false" in out
assert "python=true" in out
assert "python_prod=true" in out
def test_main_still_fail_opens_when_recovery_is_empty(monkeypatch, capsys):
monkeypatch.setattr(_mod, "pull_request_changed_files", lambda: [])
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
assert main() == 0
out = capsys.readouterr().out
assert "ci_review=true" in out
+57
View File
@@ -0,0 +1,57 @@
"""Tests for scripts/ci/e2e_screenshot_status.py."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "e2e_screenshot_status.py"
_spec = importlib.util.spec_from_file_location("e2e_screenshot_status", _PATH)
if _spec is None or _spec.loader is None:
raise ImportError("Failed to load e2e_screenshot_status.py")
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
def test_status_selects_only_new_explicit_screenshots_and_all_diffs(tmp_path):
for name in (
"explicit-proof.png",
"test-finished-1.png",
"visual-actual.png",
"visual-expected.png",
"visual-diff.png",
):
(tmp_path / name).write_bytes(b"png")
base_manifest = tmp_path / "main-manifest.json"
base_manifest.write_text('{"screenshot_names":["already-on-main.png"]}', encoding="utf-8")
(tmp_path / "already-on-main.png").write_bytes(b"png")
selection = _mod.select_evidence(tmp_path, base_manifest)
status = _mod.build_status(selection, "https://github.test/artifacts/1")
result = status[0]["results"][0]
assert result["kind"] == "info"
assert result["summary"] == "1 new screenshot vs main; 1 visual diff."
assert _mod.EVIDENCE_START in result["detail"]
assert "already-on-main.png" not in result["detail"]
assert result["link"] == "https://github.test/artifacts/1"
def test_cli_output_ends_with_newline_for_github_output_delimiter(tmp_path, monkeypatch):
output = tmp_path / "review-status.json"
manifest = tmp_path / "main-manifest.json"
evidence_dir = tmp_path / "evidence"
monkeypatch.setattr(sys, "argv", [
"e2e_screenshot_status.py",
"--results-dir", str(tmp_path),
"--manifest-output", str(manifest),
"--evidence-dir", str(evidence_dir),
"--output", str(output),
])
assert _mod.main() == 0
assert output.read_text(encoding="utf-8") == "[]\n"
assert manifest.read_text(encoding="utf-8") == '{"screenshot_names": [], "version": 1}\n'
assert evidence_dir.joinpath("e2e-evidence.json").is_file()
+60
View File
@@ -0,0 +1,60 @@
"""Tests for scripts/ci/emit_review_status.py."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "emit_review_status.py"
_spec = importlib.util.spec_from_file_location("emit_review_status", _PATH)
if _spec is None or _spec.loader is None:
raise ImportError("Failed to load emit_review_status.py")
_mod = importlib.util.module_from_spec(_spec)
sys.modules["emit_review_status"] = _mod
_spec.loader.exec_module(_mod)
def test_ci_review_status_links_to_each_sensitive_file_change():
results = _mod.build_results(
ci_review=True,
mcp_catalog=False,
supply_chain=False,
label_present=False,
ci_review_files='[".github/workflows/ci.yml", "apps/desktop/eslint.config.mjs"]',
repo_url="https://github.com/nousresearch/hermes-agent",
base_sha="base456",
head_sha="abc123",
)
assert results[0]["detail"] == (
"**Sensitive files changed:**\n"
"- [`.github/workflows/ci.yml`](https://github.com/nousresearch/hermes-agent/compare/base456...abc123#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03f)\n"
"- [`apps/desktop/eslint.config.mjs`](https://github.com/nousresearch/hermes-agent/compare/base456...abc123#diff-a45471520795db6e46840d1ba2a82c1f8a2841039bd60fb50624488c5f192438)"
)
def test_approved_ci_review_is_visible_info():
results = _mod.build_results(
ci_review=True,
mcp_catalog=False,
supply_chain=False,
label_present=True,
ci_review_files='[".github/workflows/ci.yml"]',
repo_url="https://github.com/nousresearch/hermes-agent",
base_sha="base456",
head_sha="abc123",
)
assert results == [{
"kind": "info",
"title": "CI-sensitive file review",
"summary": (
"PR touches sensitive files, but the `ci-reviewed` label has been "
"added, approving them."
),
"detail": (
"**Sensitive files changed:**\n"
"- [`.github/workflows/ci.yml`](https://github.com/nousresearch/hermes-agent/compare/base456...abc123#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03f)"
),
}]
+136
View File
@@ -0,0 +1,136 @@
"""Tests for ``scripts/ci/list_os_marked_tests.py``.
The helper decides which files the macOS / Windows CI lanes import. Its
failure modes matter more than its happy path: if it silently returned an
empty list, the OS lane would run zero tests and still report green — the
exact silent-coverage-loss the lanes exist to prevent. So the contracts under
test are "finds real markers", "refuses to emit nothing", and "rejects an
unknown marker".
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "ci" / "list_os_marked_tests.py"
def _run(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True,
text=True,
timeout=120,
cwd=REPO_ROOT,
)
def _write(root: Path, relpath: str, body: str) -> Path:
path = root / relpath
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
return path
@pytest.mark.parametrize("marker", ["linux_only", "macos_only", "windows_only"])
def test_finds_decorator_and_pytestmark_forms(tmp_path, marker):
"""Both the decorator form and module-level ``pytestmark`` are detected."""
_write(
tmp_path,
"test_decorated.py",
f"import pytest\n\n\n@pytest.mark.{marker}\ndef test_x():\n pass\n",
)
_write(
tmp_path,
"nested/test_module_level.py",
f"import pytest\n\npytestmark = pytest.mark.{marker}\n\n\ndef test_y():\n pass\n",
)
# A file with no marker at all must not be selected.
_write(tmp_path, "test_plain.py", "def test_z():\n pass\n")
result = _run(marker, str(tmp_path))
assert result.returncode == 0, result.stderr
listed = result.stdout.split()
assert any(p.endswith("test_decorated.py") for p in listed)
assert any(p.endswith("test_module_level.py") for p in listed)
assert not any(p.endswith("test_plain.py") for p in listed)
def test_marker_matched_as_whole_word(tmp_path):
"""``macos_only`` must not match a longer identifier that contains it."""
_write(
tmp_path,
"test_lookalike.py",
"import pytest\n\n\n@pytest.mark.macos_only_extra\ndef test_x():\n pass\n",
)
result = _run("macos_only", str(tmp_path))
# No genuine match: the helper must fail rather than emit nothing.
assert result.returncode == 1
assert "macos_only" in result.stderr
def test_exits_nonzero_when_no_file_carries_the_marker(tmp_path):
"""The load-bearing guard: an empty result is an error, never a silent pass."""
_write(tmp_path, "test_plain.py", "def test_z():\n pass\n")
result = _run("windows_only", str(tmp_path))
assert result.returncode == 1
assert result.stdout.strip() == ""
assert "renamed or dropped" in result.stderr
def test_rejects_unknown_marker(tmp_path):
result = _run("bsd_only", str(tmp_path))
assert result.returncode == 2
assert "unknown marker" in result.stderr
def test_rejects_missing_root():
result = _run("macos_only", "/nonexistent/path/for/this/test")
assert result.returncode == 2
assert "no such directory" in result.stderr
def test_emits_repo_relative_posix_paths():
"""Output feeds a bash command line on the Windows runner, so separators
must be POSIX and paths repo-relative.
Asserted against the real ``tests/`` tree, which is the only case CI
exercises — an out-of-repo root can't be made repo-relative and is
emitted absolute instead.
"""
result = _run("windows_only")
assert result.returncode == 0, result.stderr
listed = result.stdout.split()
assert listed
for line in listed:
assert "\\" not in line
assert not Path(line).is_absolute()
def test_real_tree_selects_files_for_every_marker():
"""Against the actual ``tests/`` tree each marker resolves to real files.
This is the invariant the CI lanes depend on — not a snapshot of which
files those are, only that each marker is in use and every listed path
exists.
"""
for marker in ("linux_only", "macos_only", "windows_only"):
result = _run(marker)
assert result.returncode == 0, f"{marker}: {result.stderr}"
listed = result.stdout.split()
assert listed, f"{marker} selected no files"
for rel in listed:
assert (REPO_ROOT / rel).is_file(), f"{marker} listed missing {rel}"
+159
View File
@@ -0,0 +1,159 @@
"""Tests for scripts/ci/live_comment.py run selection.
The poller now reports on a run it is not part of, and merges jobs from
sibling runs of the same commit (the Docker image build, which left ci.yml
to stop holding the CI run open). ``select_watched_runs`` decides which
sibling runs count.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "live_comment.py"
_spec = importlib.util.spec_from_file_location("live_comment", _PATH)
if _spec is None or _spec.loader is None:
raise ImportError("Failed to load live_comment.py")
_mod = importlib.util.module_from_spec(_spec)
sys.modules["live_comment"] = _mod
_spec.loader.exec_module(_mod)
select_watched_runs = _mod.select_watched_runs
classify_jobs = _mod.classify_jobs
DOCKER = "Docker Build, Test, and Publish"
def _run(run_id: int, name: str, created_at: str) -> dict:
return {"id": run_id, "name": name, "created_at": created_at}
def test_selects_only_named_workflows():
runs = [
_run(1, DOCKER, "2026-08-08T10:00:00Z"),
_run(2, "Deploy site", "2026-08-08T10:00:00Z"),
_run(3, "CI", "2026-08-08T10:00:00Z"),
]
selected = select_watched_runs(runs, [DOCKER])
assert [r["id"] for r in selected] == [1]
def test_keeps_newest_attempt_per_workflow():
"""A rerun makes a second run for the same commit; the old one is stale."""
runs = [
_run(1, DOCKER, "2026-08-08T10:00:00Z"),
_run(2, DOCKER, "2026-08-08T11:30:00Z"),
]
selected = select_watched_runs(runs, [DOCKER])
assert [r["id"] for r in selected] == [2]
def test_excludes_the_ci_run_itself():
runs = [_run(7, "CI", "2026-08-08T10:00:00Z")]
assert select_watched_runs(runs, ["CI"], exclude_run_id="7") == []
assert len(select_watched_runs(runs, ["CI"], exclude_run_id="8")) == 1
def test_no_watch_names_selects_nothing():
runs = [_run(1, DOCKER, "2026-08-08T10:00:00Z")]
assert select_watched_runs(runs, []) == []
assert select_watched_runs(runs, [""]) == []
def test_watched_run_jobs_carry_the_workflow_name_into_the_comment():
"""A watched run's jobs must stay distinguishable from CI's own jobs."""
jobs = [
{"name": "build (amd64)", "status": "completed", "conclusion": "failure",
"html_url": "https://example/1", "_workflow_name": DOCKER},
{"name": "Python tests", "status": "completed", "conclusion": "success",
"html_url": "https://example/2"},
]
completed, pending, job_urls = classify_jobs(jobs)
assert completed[f"{DOCKER} / build (amd64)"] == "failure"
assert completed["Python tests"] == "success"
assert pending == []
assert job_urls[f"{DOCKER} / build (amd64)"] == "https://example/1"
def test_parse_watch_workflows_keeps_commas_inside_a_name():
"""Workflow names contain commas, so the list is newline-separated."""
assert _mod.parse_watch_workflows("Docker Build, Test, and Publish\n") == [
"Docker Build, Test, and Publish"
]
assert _mod.parse_watch_workflows("A\nB\n\n C \n") == ["A", "B", "C"]
assert _mod.parse_watch_workflows("") == []
def test_workflow_watch_list_names_a_workflow_that_exists():
"""The names the workflow passes must match real workflow ``name:`` values.
A name that matches nothing makes the poller silently drop that run
from the comment, which no unit test on its own would notice.
"""
yaml = pytest.importorskip("yaml")
root = Path(__file__).resolve().parents[2]
caller = yaml.safe_load(
(root / ".github/workflows/ci-review-comment.yml").read_text(encoding="utf-8")
)
step = next(
s for s in caller["jobs"]["comment"]["steps"]
if "WATCH_WORKFLOWS" in (s.get("env") or {})
)
watched = _mod.parse_watch_workflows(step["env"]["WATCH_WORKFLOWS"])
assert watched, "the poller is watching nothing"
known = set()
for path in (root / ".github/workflows").glob("*.yml"):
doc = yaml.safe_load(path.read_text(encoding="utf-8"))
if isinstance(doc, dict) and isinstance(doc.get("name"), str):
known.add(doc["name"])
assert set(watched) <= known, f"unknown workflow names: {set(watched) - known}"
def test_poller_never_watches_its_own_workflow():
"""The poller's own run must never gate completion.
``runs_all_completed`` waits until every relevant run is completed.
The poller's run is in progress for as long as it polls, so watching
itself would make the loop wait for itself and only ever exit on
timeout.
"""
yaml = pytest.importorskip("yaml")
root = Path(__file__).resolve().parents[2]
doc = yaml.safe_load(
(root / ".github/workflows/ci-review-comment.yml").read_text(encoding="utf-8")
)
own_name = doc["name"]
step = next(
s for s in doc["jobs"]["comment"]["steps"]
if "WATCH_WORKFLOWS" in (s.get("env") or {})
)
watched = _mod.parse_watch_workflows(step["env"]["WATCH_WORKFLOWS"])
assert own_name not in watched
# ─── runs_all_completed ───────────────────────────────────────────────
def test_runs_all_completed_true_only_when_every_run_finished():
done = {"status": "completed"}
running = {"status": "in_progress"}
queued = {"status": "queued"}
assert _mod.runs_all_completed([done])
assert _mod.runs_all_completed([done, done])
assert not _mod.runs_all_completed([done, running])
assert not _mod.runs_all_completed([queued])
def test_runs_all_completed_empty_list_is_not_done():
"""No run info at all must not read as 'everything passed'."""
assert not _mod.runs_all_completed([])
def test_runs_all_completed_missing_status_is_not_done():
assert not _mod.runs_all_completed([{}])
+93
View File
@@ -0,0 +1,93 @@
"""Tests for scripts/ci/lockfile_diff.py.
The differ's job is semantic comparison: reordering and integrity-hash
churn in the lockfile text must produce an empty diff, while actual
version movement must show up as added/removed/updated regardless of
where in the file it appears.
"""
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "lockfile_diff.py"
_spec = importlib.util.spec_from_file_location("lockfile_diff", _PATH)
if _spec is None or _spec.loader is None:
raise ImportError("Failed to load lockfile_diff.py")
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
def _lock(packages: dict[str, dict]) -> str:
return json.dumps(
{
"name": "hermes",
"lockfileVersion": 3,
"packages": {"": {"name": "hermes"}, **packages},
}
)
BASE = _lock(
{
"node_modules/react": {"version": "18.2.0", "integrity": "sha512-aaa"},
"node_modules/left-pad": {"version": "1.3.0", "integrity": "sha512-bbb"},
"node_modules/foo/node_modules/react": {"version": "17.0.2"},
}
)
def test_add_remove_update_all_detected():
head = _lock(
{
"node_modules/react": {"version": "18.3.1"}, # updated
"node_modules/is-even": {"version": "1.0.0"}, # added
"node_modules/foo/node_modules/react": {"version": "17.0.2"}, # unchanged
# left-pad removed
}
)
d = _mod.diff_locks(_mod.parse_lockfile(BASE), _mod.parse_lockfile(head))
assert d["added"] == [("node_modules/is-even", "1.0.0")]
assert d["removed"] == [("node_modules/left-pad", "1.3.0")]
assert d["updated"] == [("node_modules/react", "18.2.0", "18.3.1")]
def test_nested_dedup_is_distinct_entry():
# The same package at two nesting levels must be tracked separately —
# bumping only the nested copy must not look like a top-level change.
head = _lock(
{
"node_modules/react": {"version": "18.2.0"},
"node_modules/left-pad": {"version": "1.3.0"},
"node_modules/foo/node_modules/react": {"version": "17.0.3"},
}
)
d = _mod.diff_locks(_mod.parse_lockfile(BASE), _mod.parse_lockfile(head))
assert d["updated"] == [("node_modules/foo/node_modules/react", "17.0.2", "17.0.3")]
def test_render_markdown_contains_versions_and_nested_display():
d = _mod.diff_locks(
_mod.parse_lockfile(BASE),
_mod.parse_lockfile(_lock({"node_modules/react": {"version": "19.0.0"}})),
)
md = _mod.render_markdown({"apps/desktop/package-lock.json": d})
# Fragment starts directly with the per-lockfile subsection header.
assert md.startswith("#### `apps/desktop/package-lock.json`")
assert "`18.2.0`" in md and "`19.0.0`" in md
# nested display name keeps the parent chain visible
assert "nested under foo" in md
def test_render_markdown_omits_unchanged_lockfiles():
changed = _mod.diff_locks({}, {"node_modules/x": "1.0.0"})
unchanged = _mod.diff_locks({}, {})
md = _mod.render_markdown({"a/package-lock.json": changed, "b/package-lock.json": unchanged})
assert "a/package-lock.json" in md
assert "b/package-lock.json" not in md
+217
View File
@@ -0,0 +1,217 @@
"""Tests for scripts/ci/publish_e2e_evidence.py."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "publish_e2e_evidence.py"
_spec = importlib.util.spec_from_file_location("publish_e2e_evidence", _PATH)
if _spec is None or _spec.loader is None:
raise ImportError("Failed to load publish_e2e_evidence.py")
_mod = importlib.util.module_from_spec(_spec)
sys.modules["publish_e2e_evidence"] = _mod
_spec.loader.exec_module(_mod)
def _png(width: int = 4, height: int = 3) -> bytes:
return _mod.PNG_SIGNATURE + b"\x00\x00\x00\rIHDR" + width.to_bytes(4, "big") + height.to_bytes(4, "big")
def test_load_evidence_validates_manifest_and_pngs(tmp_path):
(tmp_path / "shot.png").write_bytes(_png())
(tmp_path / "diff.png").write_bytes(_png())
(tmp_path / "actual.png").write_bytes(_png())
(tmp_path / "expected.png").write_bytes(_png())
(tmp_path / "e2e-evidence.json").write_text(
"""{
"version": 1,
"screenshots": [{"name": "main-view.png", "file": "shot.png"}],
"diffs": [{"name": "main-view", "diff": "diff.png", "actual": "actual.png", "expected": "expected.png"}]
}""",
encoding="utf-8",
)
files, payloads = _mod.load_evidence(tmp_path)
assert [item.label for item in files] == [
"new screenshot: main-view.png",
"visual diff: main-view",
"visual actual: main-view",
"visual expected: main-view",
]
assert set(payloads) == {"shot.png", "diff.png", "actual.png", "expected.png"}
def test_load_evidence_rejects_path_escape_and_non_png(tmp_path):
(tmp_path / "e2e-evidence.json").write_text(
'{"version":1,"screenshots":[{"name":"bad","file":"../secret.png"}],"diffs":[]}',
encoding="utf-8",
)
with pytest.raises(ValueError, match="unsafe filename"):
_mod.load_evidence(tmp_path)
(tmp_path / "e2e-evidence.json").write_text(
'{"version":1,"screenshots":[{"name":"bad","file":"not-png.png"}],"diffs":[]}',
encoding="utf-8",
)
(tmp_path / "not-png.png").write_bytes(b"not a png")
with pytest.raises(ValueError, match="not a PNG"):
_mod.load_evidence(tmp_path)
def test_upload_evidence_accepts_only_attachment_urls(tmp_path, monkeypatch):
shot = tmp_path / "shot.png"
shot.write_bytes(_png())
calls = []
def fake_run(args, **kwargs):
calls.append((args, kwargs))
return _mod.subprocess.CompletedProcess(
args,
0,
stdout="![shot.png](https://github.com/user-attachments/assets/12345678-1234-1234-1234-123456789abc)\n",
)
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
result = _mod.upload_evidence(
[_mod.EvidenceFile("shot.png", "new screenshot: shot.png")],
tmp_path,
"NousResearch/hermes-agent",
"bot-session-token",
)
assert result == {"shot.png": "https://github.com/user-attachments/assets/12345678-1234-1234-1234-123456789abc"}
assert calls[0][0] == ["gh", "image", "--repo", "NousResearch/hermes-agent", str(shot)]
assert calls[0][1]["env"]["GH_SESSION_TOKEN"] == "bot-session-token"
def test_upload_evidence_reports_gh_image_error(tmp_path, monkeypatch, capsys):
shot = tmp_path / "shot.png"
shot.write_bytes(_png())
def fake_run(args, **kwargs):
raise _mod.subprocess.CalledProcessError(
1,
args,
output="upload output",
stderr="upload error",
)
monkeypatch.setattr(_mod.subprocess, "run", fake_run)
with pytest.raises(RuntimeError, match="Failed to upload shot.png.*upload error"):
_mod.upload_evidence(
[_mod.EvidenceFile("shot.png", "new screenshot: shot.png")],
tmp_path,
"NousResearch/hermes-agent",
"bot-session-token",
)
captured = capsys.readouterr()
assert "Failed to upload shot.png" in captured.err
assert "upload output" in captured.err
assert "upload error" in captured.err
def test_publish_marks_evidence_upload_failure_in_pr_comment(tmp_path, monkeypatch):
comment = {
"id": 123,
"body": "before\n<!-- hermes-e2e-evidence:start -->\npending\n<!-- hermes-e2e-evidence:end -->\nafter",
}
updates = []
monkeypatch.setattr(
_mod,
"load_evidence",
lambda evidence_dir: (
[_mod.EvidenceFile("shot.png", "new screenshot: shot.png")],
{},
),
)
monkeypatch.setattr(_mod, "_wait_for_review_comment", lambda *args: comment)
monkeypatch.setattr(
_mod,
"upload_evidence",
lambda *args: (_ for _ in ()).throw(
RuntimeError("Failed to upload shot.png: bad <response>")
),
)
monkeypatch.setattr(
_mod,
"_api_request",
lambda url, token, method, payload: updates.append((
url,
token,
method,
payload,
)),
)
with pytest.raises(RuntimeError, match="Failed to upload shot.png"):
_mod.publish(
"github-token",
"NousResearch/hermes-agent",
tmp_path,
"69868",
"image-token",
)
assert updates == [
(
"https://api.github.com/repos/NousResearch/hermes-agent/issues/comments/123",
"github-token",
"PATCH",
{
"body": "before\n<!-- hermes-e2e-evidence:start -->\n<sub>inline evidence upload failed.</sub>\n\n<pre>Failed to upload shot.png: bad &lt;response&gt;</pre>\n<!-- hermes-e2e-evidence:end -->\nafter"
},
)
]
def test_publish_skips_when_no_review_comment_exists(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(
_mod,
"load_evidence",
lambda evidence_dir: (
[_mod.EvidenceFile("shot.png", "new screenshot: shot.png")],
{},
),
)
monkeypatch.setattr(_mod, "_wait_for_review_comment", lambda *args: None)
monkeypatch.setattr(
_mod,
"upload_evidence",
lambda *args: (_ for _ in ()).throw(AssertionError("must not upload")),
)
assert _mod.publish(
"github-token",
"NousResearch/hermes-agent",
tmp_path,
"83202",
"image-token",
) is False
assert "no CI review comment" in capsys.readouterr().out
def test_find_review_comment_requires_the_evidence_marker():
pending = "<!-- hermes-ci-review-bot -->\n<!-- hermes-e2e-evidence:start -->\npending\n<!-- hermes-e2e-evidence:end -->"
assert _mod._find_review_comment([{"body": "<!-- hermes-ci-review-bot --> no evidence"}]) is None
assert _mod._find_review_comment([{"body": pending, "id": 123}]) == {"body": pending, "id": 123}
def test_replace_evidence_marker_requires_exactly_one_marker():
with pytest.raises(ValueError, match="does not contain one"):
_mod.replace_evidence_marker("no marker", "evidence")
+125
View File
@@ -0,0 +1,125 @@
"""Tests for scripts/ci/timings_report.py — generate_review_status().
The review status is a JSON array in the unified nested format consumed
by the review comment assembler. It classifies the CI timings result as
info/warning (never error — timings is an observability job, not a gate)
and provides a one-line summary plus optional per-job delta detail.
"""
from __future__ import annotations
import importlib.util
from datetime import datetime, timezone
from pathlib import Path
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "timings_report.py"
_spec = importlib.util.spec_from_file_location("timings_report", _PATH)
if _spec is None or _spec.loader is None:
raise ImportError("Failed to load timings_report.py")
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
_T0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
def _ts(seconds: float) -> str:
"""ISO timestamp `seconds` after T0."""
dt = _T0.timestamp() + seconds
return datetime.fromtimestamp(dt, tz=timezone.utc).isoformat().replace("+00:00", "Z")
def _job(name: str, dur_s: float, start_s: float = 0.0, conclusion: str = "success") -> dict:
"""Build a normalized job dict with realistic timestamps for wall-time math."""
return {
"name": name,
"duration_s": dur_s,
"conclusion": conclusion,
"started_at": _ts(start_s),
"completed_at": _ts(start_s + dur_s),
"wait_s": 0.0,
}
def _timings(jobs: list[dict]) -> dict:
return {"run_id": "123", "head_sha": "abc", "created_at": "", "jobs": jobs}
def _result(statuses: list[dict]) -> dict:
"""Extract the single result dict from the nested format."""
assert len(statuses) == 1
assert statuses[0]["source"] == "ci timing"
results = statuses[0]["results"]
assert len(results) == 1
return results[0]
def test_no_baseline_is_debug():
t = _timings([_job("tests", 60.0)])
result = _result(_mod.generate_review_status(t, None))
assert result["kind"] == "debug"
assert "no baseline" in result["summary"].lower()
assert "link" not in result # no report_url → no link field
def test_no_regression_is_debug():
cur = _timings([_job("tests", 60.0)])
bl = _timings([_job("tests", 60.0)])
result = _result(_mod.generate_review_status(cur, bl))
assert result["kind"] == "debug"
assert "+0.0%" in result["summary"]
def test_small_regression_is_debug():
cur = _timings([_job("tests", 65.0)])
bl = _timings([_job("tests", 60.0)])
result = _result(_mod.generate_review_status(cur, bl))
# +8.3% — well under the 25% warning threshold
assert result["kind"] == "debug"
def test_large_regression_is_warning():
cur = _timings([_job("tests", 80.0)])
bl = _timings([_job("tests", 60.0)])
result = _result(_mod.generate_review_status(cur, bl))
# +33% — above the 25% threshold
assert result["kind"] == "warning"
assert "+33" in result["summary"]
def test_detail_shows_top_deltas():
cur = _timings([_job("slow-job", 120.0), _job("fast-job", 30.0, start_s=120.0)])
bl = _timings([_job("slow-job", 60.0), _job("fast-job", 60.0, start_s=60.0)])
result = _result(_mod.generate_review_status(cur, bl))
assert "slow-job" in result["detail"]
assert "fast-job" in result["detail"]
# Sorted by abs delta — slow-job (+60) before fast-job (-30)
assert result["detail"].index("slow-job") < result["detail"].index("fast-job")
def test_report_url_passed_through():
t = _timings([_job("tests", 60.0)])
result = _result(_mod.generate_review_status(t, None, report_url="https://artifact/123"))
assert result["link"] == "https://artifact/123"
assert result["link_label"] == "View report"
def test_nested_format_structure():
"""The return value is a list with one {source, results: [...]} entry."""
t = _timings([_job("tests", 60.0)])
statuses = _mod.generate_review_status(t, None)
assert isinstance(statuses, list)
assert len(statuses) == 1
assert statuses[0]["source"] == "ci timing"
assert isinstance(statuses[0]["results"], list)
assert len(statuses[0]["results"]) == 1
r = statuses[0]["results"][0]
assert r["kind"] == "debug"
assert r["title"] == "CI timings"
assert "summary" in r
assert "detail" in r