Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions src/repo2rlenv/log_parsers/jest_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,19 @@
○ / "skipped" → SKIPPED

Mocha (the other common JS runner) uses the same ✓/✕ glyphs, so this parser
covers most mocha output too. Vitest's default reporter is jest-compatible
by design — also covered.
covers most mocha output too.

Vitest (3+) is detected by its ` RUN vX.Y.Z` / ` Test Files` markers and read
from `--reporter=verbose`, which prints one fully qualified line per test:

✓ src/foo.test.ts > Foo > returns 200 1ms
× |unit| src/foo.test.ts > Foo > returns 500 3ms

The name is kept as printed, minus the duration and retry/heap/note suffixes.
Vitest's default reporter collapses fully passing files to one summary line,
so its per-test lines are ignored: a test would vanish from the log as soon as
a patch fixed the rest of its file. Vitest also colors output whenever TERM is
unset, as in a non-TTY `docker exec`, so escape codes are stripped first.

Released under Apache-2.0.
"""
Expand All @@ -44,8 +55,9 @@

# File header: `PASS src/foo.test.ts (123 ms)` or `FAIL src/foo.test.ts`.
# Captures the file path so we can prefix it onto test names.
# With color, the label is padded (` FAIL `), leaving one leading space.
_JEST_FILE_RE = re.compile(
r"^(?:PASS|FAIL)\s+(?P<path>\S+\.(?:ts|tsx|js|jsx|mjs|cjs))\b",
r"^ ?(?:PASS|FAIL)\s+(?P<path>\S+\.(?:ts|tsx|js|jsx|mjs|cjs))\b",
)

# Per-test glyph line. Indented arbitrarily; the glyph is the discriminator.
Expand All @@ -65,6 +77,44 @@
"◯": "SKIPPED",
}

# Terminal escape codes (colors), stripped before any matching.
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")

# ` RUN v5.0.1 /repo` starts a vitest run; ` Test Files 1 passed (1)` ends it.
_VITEST_MARKER_RE = re.compile(r"^\s*(?:RUN\s+v\d+\.\d+|Test Files\s+\d)", re.MULTILINE)

# A `--reporter=verbose` test line. The optional project label is `|unit| `
# without color and ` unit ` with it. Suffixes follow vitest's
# getTestCaseSuffix: duration, retries, repeats, heap usage, skip note.
_VITEST_TEST_RE = re.compile(
r"^\s*(?P<glyph>[✓×↓□]) (?:\|(?P<project>[^|]+)\| | (?P<label>\S+) )?"
r"(?P<name>\S+ > .+?)(?P<duration> \d{1,9}ms)?"
r"(?: \(retry x\d{1,9}\))?(?: \(repeat x\d{1,9}\))?(?: \d{1,9} MB heap used)?(?: \[[^\[\]]*\])?$"
)

_VITEST_STATUS: dict[str, TestStatus] = {
"✓": "PASSED",
"×": "FAILED",
"↓": "SKIPPED",
"□": "SKIPPED",
}


def _parse_vitest(log: str) -> dict[str, TestStatus]:
out: dict[str, TestStatus] = {}
for raw in log.split("\n"):
m = _VITEST_TEST_RE.match(raw.rstrip())
if not m:
continue
glyph = m.group("glyph")
name = m.group("name")
# Only finished tests print a duration; keep a skipped title's own "5ms".
if glyph in "↓□" and m.group("duration"):
name += m.group("duration")
project = m.group("project") or m.group("label")
out[f"|{project}| {name}" if project else name] = _VITEST_STATUS[glyph]
return out


def parse_jest(log: str) -> dict[str, TestStatus]:
"""Return {test_name -> status} parsed from Jest / Mocha / Vitest output.
Expand All @@ -78,6 +128,9 @@ def parse_jest(log: str) -> dict[str, TestStatus]:
out: dict[str, TestStatus] = {}
if not log:
return out
log = _ANSI_RE.sub("", log)
if _VITEST_MARKER_RE.search(log):
return _parse_vitest(log)

current_file: str | None = None
# describe stack indexed by indent depth (in characters). On a new test
Expand Down
29 changes: 28 additions & 1 deletion src/repo2rlenv/pipelines/_pr_runtime_verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def parse_cargo_test(log: str) -> dict[str, str]:
return out


_JEST_FILE_RE = re.compile(r"^(?:PASS|FAIL)\s+(?P<path>\S+\.(?:ts|tsx|js|jsx|mjs|cjs))\b")
_JEST_FILE_RE = re.compile(r"^ ?(?:PASS|FAIL)\s+(?P<path>\S+\.(?:ts|tsx|js|jsx|mjs|cjs))\b")
_JEST_TEST_RE = re.compile(
r"^(?P<indent>\s*)(?P<glyph>✓|√|✕|×|✗|○|◯)\s+(?P<name>.+?)(?:\s+\(\d+(?:\.\d+)?\s*m?s\))?$"
)
Expand All @@ -186,13 +186,40 @@ def parse_cargo_test(log: str) -> dict[str, str]:
"○": SKIPPED,
"◯": SKIPPED,
}
# Keep these patterns in sync with log_parsers/jest_parser.py.
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
_VITEST_MARKER_RE = re.compile(r"^\s*(?:RUN\s+v\d+\.\d+|Test Files\s+\d)", re.MULTILINE)
_VITEST_TEST_RE = re.compile(
r"^\s*(?P<glyph>[✓×↓□]) (?:\|(?P<project>[^|]+)\| | (?P<label>\S+) )?"
r"(?P<name>\S+ > .+?)(?P<duration> \d{1,9}ms)?"
r"(?: \(retry x\d{1,9}\))?(?: \(repeat x\d{1,9}\))?(?: \d{1,9} MB heap used)?(?: \[[^\[\]]*\])?$"
)
_VITEST_STATUS = {"✓": PASSED, "×": FAILED, "↓": SKIPPED, "□": SKIPPED}


def _parse_vitest(log: str) -> dict[str, str]:
"""{test_name -> status} from vitest `--reporter=verbose` lines only."""
out: dict[str, str] = {}
for raw in log.split("\n"):
m = _VITEST_TEST_RE.match(raw.rstrip())
if not m:
continue
glyph, name = m.group("glyph"), m.group("name")
if glyph in "↓□" and m.group("duration"):
name += m.group("duration")
project = m.group("project") or m.group("label")
out[f"|{project}| {name}" if project else name] = _VITEST_STATUS[glyph]
return out


def parse_jest(log: str) -> dict[str, str]:
"""{test_name -> status} from Jest / Mocha / Vitest output."""
out: dict[str, str] = {}
if not log:
return out
log = _ANSI_RE.sub("", log)
if _VITEST_MARKER_RE.search(log):
return _parse_vitest(log)
current_file: str | None = None
describe_stack: list[tuple[int, str]] = []
last_test_indent: int | None = None
Expand Down
16 changes: 12 additions & 4 deletions src/repo2rlenv/pipelines/pr_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,9 +629,12 @@ def normalize_test_cmds_for_runtime(test_cmds: list[str]) -> list[str]:
- Add `-v` if missing (default `go test` doesn't print --- PASS lines)
cargo test:
- Default output is already parseable; no transform needed
jest / npm test:
jest / mocha / npm test:
- Add `--verbose` if not present, so per-test ✓/✕ lines are emitted
- Some configs swallow stdout via `--silent`; we strip that
vitest:
- Use `--reporter=verbose` instead: vitest rejects `--verbose`, and its
default reporter collapses fully passing files to one summary line
"""
out: list[str] = []
for cmd in test_cmds:
Expand Down Expand Up @@ -674,9 +677,14 @@ def normalize_test_cmds_for_runtime(test_cmds: list[str]) -> list[str]:
# --- jest / npm test / yarn test / pnpm test ---
elif re.search(r"\b(?:jest|mocha|vitest|npm\s+test|yarn\s+test|pnpm\s+test)\b", cleaned):
cleaned = re.sub(r"\s+--silent\b", "", cleaned)
# Add --verbose if the cmd is the runner itself (skip wrappers
# where flags need to go after `--`)
if re.search(r"\b(?:jest|mocha|vitest)\b", cleaned) and not re.search(
# Add a per-test reporter if the cmd is the runner itself (skip
# wrappers where flags need to go after `--`)
if re.search(r"\bvitest\b", cleaned):
# `vitest --verbose` exits with `CACError: Unknown option`.
cleaned = re.sub(r"\s+--verbose\b", "", cleaned)
if not re.search(r"\s--reporter\b", cleaned):
cleaned = cleaned.rstrip() + " --reporter=verbose"
elif re.search(r"\b(?:jest|mocha)\b", cleaned) and not re.search(
r"\s--verbose\b|\s--reporter\b", cleaned
):
cleaned = cleaned.rstrip() + " --verbose"
Expand Down
15 changes: 15 additions & 0 deletions tests/test_pipeline_pr_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,21 @@ def test_normalize_jest_adds_verbose_and_strips_silent():
assert normalize_test_cmds_for_runtime(["jest --verbose"]) == ["jest --verbose"]


def test_normalize_vitest_uses_verbose_reporter():
# `vitest --verbose` exits with `CACError: Unknown option`.
assert normalize_test_cmds_for_runtime(["npx vitest run"]) == [
"npx vitest run --reporter=verbose"
]
assert normalize_test_cmds_for_runtime(["npx vitest run --verbose --silent"]) == [
"npx vitest run --reporter=verbose"
]
# An explicit reporter is the repo's choice ⇒ keep
assert normalize_test_cmds_for_runtime(["vitest run --reporter=junit"]) == [
"vitest run --reporter=junit"
]
assert normalize_test_cmds_for_runtime(["npx mocha"]) == ["npx mocha --verbose"]


def test_normalize_strips_trailing_pipe_head_or_tail():
"""Bootstrap agents sometimes save commands with a `| head -N` tail truncator.

Expand Down
Loading