fix(acceptance): recognize native Node test summaries (#6953)

* fix(acceptance): recognize native Node test summaries

* test(acceptance): isolate native Node suite labels

* fix(acceptance): recognize native Node test summaries

* fix(acceptance): filter Node warning points

* test(acceptance): cover Node warning reporter points

* test(acceptance): cover Node warning labels with native reporters

* fix(acceptance): keep failure counts on one diagnostic line
This commit is contained in:
Guo-Yixin 2026-10-16 04:09:02 -05:00 committed by GitHub
parent 2adc667fec
commit 34ccd79351
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 316 additions and 9 deletions

View File

@ -2208,6 +2208,12 @@ failures do not count as passes. Successful runs with all-skipped,
all-expected-failure, empty or incomplete summaries remain `UNVERIFIED`;
nonzero exits, failures and unexpected successes still take precedence.
For `tests_passed:node --test`, complete TAP or spec counter summaries must
contain an actual pass and no failed or cancelled tests. Individual TAP `ok`
points do not prove the run passed. Empty, all-skipped, all-todo, incomplete or
inconsistent summaries remain `UNVERIFIED`; execution and shell-provenance
checks still apply.
For Maven and Gradle test acceptance, a `BUILD SUCCESS` / `BUILD SUCCESSFUL`
banner alone remains `UNVERIFIED`: builds can succeed without running tests.
Complete `Tests run: N, Failures: F, Errors: E, Skipped: S` or `N tests completed`

View File

@ -91,6 +91,12 @@ These summaries also participate in the command/output attribution guard.
Cargo: complete positive siblings permit empty targets. All-empty/ignored or bare banners stay UNVERIFIED; failures win.
Unittest needs complete counts with ordinary passes; uppercase OK is not Go output. Failures win.
Node TAP/spec acceptance uses complete, consistent tests/suites/pass/fail/cancelled/skipped/todo counters.
Normalize ANSI SGR for evidence matching and the output-attribution guard.
TAP `ok N` points are not Go package passes; skip/todo-only runs supply no proof.
Failures/cancellations win, and the normal command/output admission guards still apply.
Bounded bash evidence drops its first partial physical line when tail slicing
cuts a line; otherwise a long test label can lose its reporter prefix.
**Events**: `task_started`, `task_running`, `task_completed`/`task_failed`/`task_timed_out`
**Handled LLM failures**: `LLMErrorHandlingMiddleware` deliberately converts provider/model exceptions into an `AIMessage` so the graph can end cleanly, stamping `additional_kwargs.deerflow_error_fallback=true` plus error metadata. Clean graph termination does not imply subagent success: `SubagentExecutor` inspects the last assistant message at terminalization and maps a marked fallback to `SubagentStatus.FAILED`, which then emits `task_failed` and the existing structured `subagent_error`. Only the marker is authoritative — error-looking assistant prose without it remains a normal completed result, so neither the executor nor frontend parses display text as a status protocol.

View File

@ -146,6 +146,36 @@ def _unittest_test_summary_holds(text: str) -> bool:
return False
# Node's TAP/spec reporters share the same ordered counters. Individual TAP
# points (including skipped/todo tests) are not a complete run summary and
# must not be mistaken for Go's package-level `ok` record above.
_NODE_TEST_POINT_RE = re.compile(r"^[ \t]*(?:\#[ \t]+Subtest:[^\r\n]*|(?:not )?ok[ \t]+[0-9]+(?:[ \t]+[-#][^\r\n]*|[ \t]*)|[✔✖﹣▶⚠][ \t]+[^\r\n]*)\r?$", re.MULTILINE)
_ANSI_SGR_RE = re.compile(r"\x1b\[[0-9;:]*m")
_NODE_TEST_SUMMARY_RE = re.compile(
r"^(?P<prefix>\#|ℹ)[ \t]+tests (?P<total>[0-9]{1,9})\r?\n"
r"(?P=prefix)[ \t]+suites [0-9]{1,9}\r?\n"
r"(?P=prefix)[ \t]+pass (?P<passed>[0-9]{1,9})\r?\n"
r"(?P=prefix)[ \t]+fail (?P<failed>[0-9]{1,9})\r?\n"
r"(?P=prefix)[ \t]+cancelled (?P<cancelled>[0-9]{1,9})\r?\n"
r"(?P=prefix)[ \t]+skipped (?P<skipped>[0-9]{1,9})\r?\n"
r"(?P=prefix)[ \t]+todo (?P<todo>[0-9]{1,9})[ \t]*\r?$",
re.MULTILINE,
)
def _node_test_summary_holds(text: str) -> bool | None:
"""Use complete, consistent counts; failures/cancellations win."""
positive = False
for match in _NODE_TEST_SUMMARY_RE.finditer(text):
total, passed, failed, cancelled, skipped, todo = (int(match[key]) for key in ("total", "passed", "failed", "cancelled", "skipped", "todo"))
if total != passed + failed + cancelled + skipped + todo:
continue
if failed or cancelled:
return False
positive = positive or passed > 0
return True if positive else None
#: Zero-test evidence vetoes the pass shapes the count-bearing alternatives
#: cannot see: "0 passed", go's no-test markers, unittest "Ran 0 tests".
_TEST_ZERO_SHAPE_RE = re.compile(r"\b0\s+passed\b|\[no test files\]|\[no tests to run\]|\bRan 0 tests\b", re.IGNORECASE)
@ -238,8 +268,8 @@ def _build_test_summary_holds(text: str) -> bool | None:
_TEST_FAIL_SHAPE_RE = re.compile(
r"\b[1-9]\d*\s+failed\b" # pytest / jest: "1 failed"
r"|\b[1-9]\d*\s+errors?\b" # pytest: "1 error" — an errored collection means part of the selection never ran
r"\b[1-9]\d*[ \t]+failed\b" # pytest / jest: same-line "1 failed", never a diagnostic location on the previous line
r"|\b[1-9]\d*[ \t]+errors?\b" # pytest: "1 error" — an errored collection means part of the selection never ran
r"|^FAILED\b" # unittest summary line
r"|^ERROR\s+\S" # pytest short summary: "ERROR tests/unit/test_auth.py"
r"|test result: FAILED" # cargo test
@ -891,6 +921,7 @@ def _carries_summary_shape(text: str) -> bool:
or zero-test veto). The pass shapes match as substrings, so
subagent-chosen strings — a ``cd`` argument, a ``CDPATH`` value — must
not be allowed to lend the recorded tail a summary shape."""
text = _ANSI_SGR_RE.sub("", text)
return bool(
_TEST_PASS_SHAPE_RE.search(text)
or _GO_TEST_PASS_SHAPE_RE.search(text)
@ -900,6 +931,7 @@ def _carries_summary_shape(text: str) -> bool:
or _MAVEN_TEST_SUMMARY_RE.search(text)
or _GRADLE_TEST_SUMMARY_RE.search(text)
or _BUILD_SUCCESS_RE.search(text)
or _NODE_TEST_SUMMARY_RE.search(text)
)
@ -2051,21 +2083,25 @@ def _check_tests_passed_leaf(command: str, bash_executions: list[dict[str, Any]]
else:
base["detail"] = f"latest matching run recorded status={status or 'unknown'}"
return base
output_tail = str(latest.get("output_tail") or "")
output_tail = _ANSI_SGR_RE.sub("", str(latest.get("output_tail") or ""))
unattributable = _output_attribution(str(latest.get("command") or ""), thread_data)
if unattributable is not None:
# Neither a pass nor a fail shape here can be trusted either way.
base["detail"] = unattributable
return base
# Test names are arbitrary data, not runner summaries. Exclude TAP/spec
# point labels from negative/zero evidence too (`test('1 failed', ...)`).
output_tail = _NODE_TEST_POINT_RE.sub("", output_tail)
build_summary = _build_test_summary_holds(output_tail)
if _TEST_FAIL_SHAPE_RE.search(output_tail) or build_summary is False:
node_summary = _node_test_summary_holds(output_tail)
if _TEST_FAIL_SHAPE_RE.search(output_tail) or build_summary is False or node_summary is False:
base["checked"] = True
base["detail"] = "recorded output carries a failing test summary"
return base
# Empty packages/targets cannot veto complete positive sibling evidence.
summary_tail = _without_empty_cargo_targets(_GO_ZERO_TEST_LINE_RE.sub("", output_tail))
go_ok = _GO_TEST_PASS_SHAPE_RE.search(summary_tail) and _go_package_summary_is_evidence(summary_tail)
passing = _TEST_PASS_SHAPE_RE.search(summary_tail) or go_ok or _unittest_test_summary_holds(summary_tail) or build_summary is True
passing = _TEST_PASS_SHAPE_RE.search(summary_tail) or go_ok or _unittest_test_summary_holds(summary_tail) or build_summary is True or node_summary is True
if passing and not _TEST_ZERO_SHAPE_RE.search(summary_tail):
base["checked"] = True
base["holds"] = True

View File

@ -590,13 +590,18 @@ def _harvest_bash_executions(
meta_status = str(meta.get("status") or getattr(message, "status", "success") or "success")
content = message.content if isinstance(message.content, str) else json.dumps(message.content, sort_keys=True, default=str)
status, status_marker = _bash_evidence_status(content, meta_status)
output_tail = content[-_BASH_EVIDENCE_OUTPUT_TAIL_CHARS:]
if len(content) > _BASH_EVIDENCE_OUTPUT_TAIL_CHARS and content[-_BASH_EVIDENCE_OUTPUT_TAIL_CHARS - 1] != "\n":
# A clipped test name/log line can look like a summary after
# losing its reporter prefix. Retain only complete lines.
output_tail = output_tail.partition("\n")[2]
executions.append(
{
"tool_call_id": tool_call_id,
"tool_name": name,
"command": command,
"command_truncated": command_truncated,
"output_tail": content[-_BASH_EVIDENCE_OUTPUT_TAIL_CHARS:],
"output_tail": output_tail,
"status": status,
"status_marker": status_marker,
}

View File

@ -0,0 +1,241 @@
"""Node's native reporters must prove executed tests, not TAP point labels."""
import asyncio
import importlib.util
import os
import shlex
import shutil
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import StructuredTool
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping
from deerflow.subagents.acceptance_checks import _carries_summary_shape, check_acceptance_criteria, render_acceptance_section
def _summary(prefix="#", *, passed=1, failed=0, cancelled=0, skipped=0, todo=0, total=None):
counters = {"tests": passed + failed + cancelled + skipped + todo if total is None else total, "suites": 0, "pass": passed, "fail": failed, "cancelled": cancelled, "skipped": skipped, "todo": todo}
return "\n".join(f"{prefix} {key} {value}" for key, value in counters.items()) + f"\n{prefix} duration_ms 12.5"
def _verdict(output, **overrides):
execution = {"command": "node --test", "status": "success", "shell_persistent": False, "output_tail": output, **overrides}
return check_acceptance_criteria(["tests_passed:node --test"], bash_executions=[execution])
@pytest.mark.parametrize("prefix", ["#", "ℹ"])
@pytest.mark.parametrize("newline", ["\n", "\r\n"])
def test_native_reporters_accept_nonempty_passes(prefix, newline):
verdict = _verdict(_summary(prefix, skipped=1, todo=1).replace("\n", newline))
assert verdict["all_hold"] is True
assert "[holds]" in render_acceptance_section(verdict)
@pytest.mark.parametrize("prefix", ["#", "ℹ"])
@pytest.mark.parametrize("counts", [{"failed": 1}, {"cancelled": 1}])
def test_native_failures_override_incidental_pass_text(prefix, counts):
verdict = _verdict("ok 1 - all tests passed\n" + _summary(prefix, **counts))
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (True, False)
@pytest.mark.parametrize("prefix", ["#", "ℹ"])
@pytest.mark.parametrize("counts", [{}, {"skipped": 2}, {"todo": 2}, {"skipped": 1, "todo": 1}])
def test_empty_skipped_and_todo_summaries_supply_no_proof(prefix, counts):
verdict = _verdict("ok 1 - all tests passed # SKIP\n" + _summary(prefix, passed=0, **counts))
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (False, False)
@pytest.mark.parametrize(
"output",
[
"ok 1 - sample",
"ok 12 # SKIP",
_summary(total=4),
_summary(passed="9" * 100, total=1),
_summary().replace("# fail 0\n", ""),
_summary().replace("# fail 0", "ℹ fail 0"),
_summary().replace("# pass 1", "# pass -1"),
_summary().split("# todo")[0],
],
)
def test_tap_points_and_incomplete_or_inconsistent_counts_remain_unverified(output):
assert _verdict(output)["leaves"][0]["checked"] is False
@pytest.mark.parametrize("reverse", [False, True])
def test_failing_summary_wins_over_successful_sibling(reverse):
summaries = [_summary(), _summary(failed=1)]
verdict = _verdict("\n".join(reversed(summaries) if reverse else summaries))
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (True, False)
@pytest.mark.parametrize(
"overrides",
[{"shell_persistent": True}, {"shell_persistent": None}, {"command_truncated": True}, {"command": "node --test sample.test.cjs"}, {"command": "echo other; node --test"}],
)
def test_node_counts_do_not_bypass_execution_admission(overrides):
assert _verdict(_summary(), **overrides)["leaves"][0]["checked"] is False
def test_go_package_and_pytest_controls_still_pass():
assert _verdict("ok\texample.org/project\t0.005s")["all_hold"] is True
assert _verdict("ok\t123\t0.005s")["all_hold"] is True
assert _verdict("2 passed in 0.01s")["all_hold"] is True
@pytest.mark.parametrize(
"point",
[
"ok 1 - all tests passed # SKIP",
"ok 12 # all tests passed",
"✔ all tests passed (1ms)",
"﹣ all tests passed (1ms)",
"▶ all tests passed",
"⚠ all tests passed",
" ok 1 - all tests passed # SKIP",
"# Subtest: all tests passed\nok 1 - all tests passed # SKIP",
],
)
@pytest.mark.parametrize("tail", ["", _summary(passed=0, skipped=1).split("# todo")[0]])
def test_point_labels_cannot_supply_pass_evidence(point, tail):
verdict = _verdict(point + "\n" + tail)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (False, False)
def test_recorded_exit_error_overrides_node_positive_counts():
verdict = _verdict(_summary(), status="error")
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (True, False)
def test_newest_matching_execution_is_decisive():
first = {"command": "node --test", "status": "success", "shell_persistent": False, "output_tail": _summary(failed=1)}
second = {**first, "output_tail": _summary()}
assert check_acceptance_criteria(["tests_passed:node --test"], bash_executions=[first, second])["all_hold"] is True
assert check_acceptance_criteria(["tests_passed:node --test"], bash_executions=[second, first])["all_hold"] is False
def test_empty_node_sibling_does_not_veto_another_runner():
output = "2 passed in 0.01s\n" + _summary(passed=0)
execution = {"command": "make test", "status": "success", "shell_persistent": False, "output_tail": output}
assert check_acceptance_criteria(["tests_passed:make test"], bash_executions=[execution])["all_hold"] is True
@pytest.mark.parametrize("label", ["0 passed", "1 failed", "1 error"])
@pytest.mark.parametrize("point", ["# Subtest: {label}\n ok 1 - {label}", "⚠ {label} # TODO"])
def test_test_names_cannot_supply_failure_or_zero_evidence(label, point):
verdict = _verdict(point.format(label=label) + "\n" + _summary())
assert verdict["all_hold"] is True
@pytest.mark.parametrize("newline", ["\n", "\r\n"])
def test_todo_diagnostic_location_cannot_supply_error_count(newline):
output = "⚠ 1 failed # TODO\n" + _summary("ℹ", todo=1) + "\ntest at sample.test.cjs:1:57\n\n Error: fixture"
assert _verdict(output.replace("\n", newline))["all_hold"] is True
def test_color_codes_are_normalized_for_counts_and_output_admission():
output = "\x1b[90m﹣ all tests passed\x1b[39m\n" + "\n".join(f"\x1b[34m{line}\x1b[39m" for line in _summary("ℹ", passed=0, skipped=1).splitlines())
assert _verdict(output)["leaves"][0]["checked"] is False
assert _carries_summary_shape(output) is True
@pytest.fixture
def executor_module(monkeypatch):
path = Path(__file__).parents[1] / "packages/harness/deerflow/subagents/executor.py"
spec = importlib.util.spec_from_file_location("_node_evidence_executor", path)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)
return module
class SandboxMessages(MessagesState):
sandbox: dict
@pytest.mark.parametrize("label", ["all tests passed", "1 failed", "0 passed"])
def test_harvest_drops_a_partial_label_before_interpreting_counts(executor_module, monkeypatch, label):
output = "ok 1 - " + "x" * 1500 + " " + label + " # SKIP\n" + _summary(passed=0, skipped=1)
messages = [AIMessage(content="", tool_calls=[{"name": "bash", "id": "long-name", "args": {"command": "node --test"}}]), ToolMessage(content=output, tool_call_id="long-name")]
monkeypatch.setattr(executor_module, "_harvest_shell_persistence", lambda state: False)
executions = executor_module._harvest_bash_executions({"messages": messages})
assert executions[0]["output_tail"] == _summary(passed=0, skipped=1)
assert messages[-1].content == output
verdict = check_acceptance_criteria(["tests_passed:node --test"], bash_executions=executions)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == (False, False)
@pytest.mark.skipif(os.name == "nt" or shutil.which("node") is None, reason="Requires POSIX LocalSandbox and Node.js")
@pytest.mark.asyncio
@pytest.mark.parametrize("reporter", ["tap", "spec", "color-spec"])
@pytest.mark.parametrize(
"body,expected",
[
("test('sample', () => {});", (True, True)),
("test('0 passed', () => {}); test('1 failed', () => {});", (True, True)),
("test('sample', () => {}); test.skip('optional', () => {});", (True, True)),
("test.skip('all tests passed', () => {});", (False, False)),
("test.skip('x'.repeat(1500) + ' all tests passed', () => {});", (False, False)),
("test('x'.repeat(1500) + ' 1 failed', () => {});", (True, True)),
("test('x'.repeat(1500) + ' 0 passed', () => {});", (True, True)),
("test.todo('pending');", (False, False)),
("test('all tests passed', { todo: true }, () => { throw Error('fixture'); });", (False, False)),
("test('sample', () => {}); test('1 failed', { todo: true }, () => { throw Error('fixture'); });", (True, True)),
("describe('empty suite', () => {});", (False, False)),
("describe('all tests passed', () => { test.skip('optional', () => {}); });", (False, False)),
("describe('all tests passed', () => { test.todo('pending'); });", (False, False)),
("describe('1 failed', () => { test('sample', () => {}); });", (True, True)),
("test('sample', () => { throw Error('fixture'); });", (True, False)),
("test('pending', { timeout: 20 }, () => new Promise(() => {}));", (True, False)),
],
ids=[
"pass",
"summary-like-names",
"pass-and-skip",
"all-skipped",
"long-skipped",
"long-failure-name",
"long-zero-name",
"all-todo",
"failing-todo-pass-name",
"failing-todo-failure-name",
"empty-suite",
"skipped-suite",
"todo-suite",
"summary-like-suite",
"failure",
"cancelled",
],
)
async def test_real_node_results_reach_parent_acceptance(tmp_path, monkeypatch, executor_module, reporter, body, expected):
(tmp_path / "sample.test.cjs").write_text("const { test, describe } = require('node:test');\n" + body, encoding="utf-8")
sandbox = LocalSandbox("node-evidence", path_mappings=[PathMapping("/mnt/user-data/workspace", str(tmp_path))])
monkeypatch.setattr("deerflow.sandbox.sandbox_provider.get_sandbox_provider", lambda: SimpleNamespace(get=lambda sandbox_id: sandbox if sandbox_id == sandbox.id else None))
outputs = []
async def bash(command: str) -> str:
output = await asyncio.to_thread(sandbox.execute_command, command, timeout=30)
outputs.append(output)
return output
tool = StructuredTool.from_function(coroutine=bash, name="bash", description="Run offline native Node tests.")
graph = StateGraph(SandboxMessages)
graph.add_node("tools", ToolNode([tool], awrap_tool_call=ToolErrorHandlingMiddleware().awrap_tool_call))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
color = "FORCE_COLOR=1 " if reporter == "color-spec" else ""
reporter = reporter.removeprefix("color-")
command = f"{color}{shlex.quote(shutil.which('node'))} --test --test-reporter={reporter} /mnt/user-data/workspace/sample.test.cjs"
state = await graph.compile().ainvoke({"sandbox": {"sandbox_id": sandbox.id}, "messages": [AIMessage(content="", tool_calls=[{"name": "bash", "id": "node-call", "args": {"command": command}}])]})
assert len(outputs) == 1 and "duration_ms" in outputs[0], outputs
executions = executor_module._harvest_bash_executions(state)
assert len(executions) == 1 and executions[0]["shell_persistent"] is False
verdict = check_acceptance_criteria([f"tests_passed:{command}"], bash_executions=executions)
assert (verdict["leaves"][0]["checked"], verdict["all_hold"]) == expected, (outputs, verdict)

View File

@ -6509,15 +6509,28 @@ class TestBashExecutionHarvest:
assert result.bash_executions is not None
assert [e["command"] for e in result.bash_executions] == ["make test"]
def test_output_tail_is_bounded(self, classes, monkeypatch):
@pytest.mark.parametrize(
"output,expected,status",
[
("x" * 5000, "", "success"),
("x" * 5000 + "\n12 passed in 1s", "12 passed in 1s", "success"),
("x" * 5000 + "\n" + "a" * 1000, "a" * 1000, "success"),
("x" * 5000 + "\r\n" + "a" * 1000, "a" * 1000, "success"),
("x" * 5000 + "\n" + "a" * 1001, "", "success"),
("x" * 5000 + "\nExit Code: 3", "Exit Code: 3", "error"),
],
)
def test_output_tail_is_bounded(self, classes, monkeypatch, output, expected, status):
executor_module = importlib.import_module("deerflow.subagents.executor")
monkeypatch.setitem(sys.modules, "deerflow.agents.middlewares.tool_result_meta", _module("deerflow.agents.middlewares.tool_result_meta", TOOL_META_KEY="deerflow_tool_meta"))
state = self._final_state(classes)
state["messages"][2].content = "x" * 5000
state["messages"][2].content = output
executions = executor_module._harvest_bash_executions(state)
assert len(executions[0]["output_tail"]) == 1000
assert len(executions[0]["output_tail"]) <= 1000
assert executions[0]["output_tail"] == expected
assert executions[0]["status"] == status
def test_long_command_is_capped_and_flagged_truncated(self, classes, monkeypatch):
"""PR review: the matcher must know the command lost its suffix."""