Skip to content
Merged
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
14 changes: 9 additions & 5 deletions quality/private/mutation_gate.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ visibility("//quality/...")

# Most timed-out mutants, in percent of total, that still count as killed. A
# starved CPU makes tests time out, so past this share timeouts say the run
# was starved, not that the tests caught the mutants. One real run on 2 CPUs
# timed out 212 of 849 mutants, about 25%. The gate and both runners read this
# one value: the gate through its template, the runners through the
# environment variable below, which each requires.
# was starved, not that the tests caught the mutants. The gate and both
# runners read this one value: the gate through its template, the runners
# through the environment variable below, which each requires.
MUTATION_MAX_TIMEOUT_PERCENT = 10
MUTATION_RUNNER_ENV = {"MUTATION_MAX_TIMEOUT_PERCENT": str(MUTATION_MAX_TIMEOUT_PERCENT)}

Expand Down Expand Up @@ -51,7 +50,12 @@ def _mutation_gate_impl(ctx):
is_executable = True,
)
executable, runfiles = shell_executable(ctx, script, ctx.runfiles(files = reports))
return [DefaultInfo(executable = executable, runfiles = runfiles)]
return [
DefaultInfo(executable = executable, runfiles = runfiles),
# Bazel's test sandbox passes only listed variables; the gate appends
# its timeout line to the job summary.
RunEnvironmentInfo(inherited_environment = ["GITHUB_STEP_SUMMARY"]),
]

mutation_gate_test = rule(
implementation = _mutation_gate_impl,
Expand Down
17 changes: 14 additions & 3 deletions quality/private/mutation_gate.sh.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
#
# A timed-out mutant counts as killed and stays listed (its mutant.timeout or
# mutant.timed_out lines print). The count line goes to stdout, and to
# $GITHUB_STEP_SUMMARY when that is set.
# $GITHUB_STEP_SUMMARY when that variable is set and writable; a failed write
# there only warns on stderr, and delivery isn't guaranteed under a sandbox or
# with a cached test result.
#
# Fails on: any lived, not_covered or errored mutant; timeouts above
# MAX_TIMEOUT_PERCENT of total, in the sum and in any one report; an
Expand Down Expand Up @@ -199,7 +201,10 @@ if ((timeout > 0 && total > 0)); then
# mutmut says timeout, gomutants timed_out; the lines print as reported.
printf '%s\n' "$mutants" | grep -E '^mutant\.(timeout|timed_out)=' || true
if [[ -n ${GITHUB_STEP_SUMMARY:-} ]]; then
echo "$timeout_line" >>"$GITHUB_STEP_SUMMARY"
# A summary that cannot be written must not end the run before the verdict,
# even with stderr closed, so the warning's own failure is ignored too.
echo "$timeout_line" >>"$GITHUB_STEP_SUMMARY" ||
echo "mutation_gate: could not write to GITHUB_STEP_SUMMARY" >&2 || true
fi
fi

Expand Down Expand Up @@ -237,4 +242,10 @@ if ((${#problems[@]} > 0)); then
fi
exit 1
fi
echo "mutation_gate: ok: every mutant was killed"
# Only claim kills when some mutant was killed or timed out; an empty or
# all-skipped report (a diff that selected none) checked nothing.
if ((killed + timeout > 0)); then
echo "mutation_gate: ok: every mutant was killed"
else
echo "mutation_gate: ok: no mutant failed (none were checked)"
fi
56 changes: 55 additions & 1 deletion quality/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("//quality:defs.bzl", "mutation_gate_test")
load("//repo:defs.bzl", "repo_files")
load("//testing:defs.bzl", "expect_failure_test")
load(":gate_output_test.bzl", "gate_output_test")
load(":gate_output_test.bzl", "gate_output_test", "gate_summary_error_test")
load(":mutation_changes_test.bzl", "mutation_changes_test")
load(":slice_test.bzl", "changes_slice")

Expand Down Expand Up @@ -89,6 +89,22 @@ PROOF_REASONS = {
for proof, pattern in PROOF_REASONS.items()
]

# A diff-scoped report with no selected mutants checked nothing, so the gate
# passes without saying every mutant was killed.
mutation_gate_test(
name = "diff_empty_gate",
reports = ["testdata/diff_empty.txt"],
tags = PROOF_TAGS,
)

gate_output_test(
name = "diff_empty_output_test",
gate = ":diff_empty_gate",
lines = [
"mutation_gate: ok: no mutant failed (none were checked)",
],
)

# Timeouts count as killed up to 10% of the mutants (10 of 100 passes, 5
# passes, 11 fails), stay listed, and are counted on stdout and in the job
# summary. Gate failures list them too.
Expand Down Expand Up @@ -123,6 +139,44 @@ gate_output_test(
summary_lines = ["mutation_gate: 10 timed out of 100 (10.0%), counted as killed"],
)

# A job summary that cannot be written (here a missing directory) ends neither a passing
# nor a failing run early: the verdict, the stdout line and the listed
# timeouts stay, and stderr notes the failed write.
gate_summary_error_test(
name = "summary_error_passes_test",
gate = ":timeout_below_cap_gate",
lines = [
"mutation_gate: 5 timed out of 100 (5.0%), counted as killed",
"mutant.timeout=pkg.x_f__mutmut_5",
"mutation_gate: ok: every mutant was killed",
],
status = 0,
)

gate_summary_error_test(
name = "summary_error_fails_test",
err_lines = ["mutation_gate: FAIL: 1 lived"],
gate = ":proof_timeout_with_survivor",
lines = [
"mutation_gate: 5 timed out of 100 (5.0%), counted as killed",
"mutant.timeout=pkg.x_f__mutmut_5",
],
status = 1,
)

# With stderr closed too, the warning's own write fails and still must not end
# the run before the verdict.
gate_summary_error_test(
name = "summary_error_closed_stderr_test",
close_stderr = True,
gate = ":timeout_below_cap_gate",
lines = [
"mutation_gate: 5 timed out of 100 (5.0%), counted as killed",
"mutation_gate: ok: every mutant was killed",
],
status = 0,
)

# Timed-out mutants are listed in the gate's output, passing or failing.
expect_failure_test(
name = "proof_timeout_with_survivor_lists_timeouts_fails",
Expand Down
71 changes: 71 additions & 0 deletions quality/tests/gate_output_test.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,74 @@ each of `summary_lines`, then once more with it unset.""",
"summary_lines": attr.string_list(doc = "Whole lines the summary file must hold."),
},
)

_SUMMARY_ERROR_SCRIPT = """#!/usr/bin/env bash
set -uo pipefail
gate="$PWD/{gate}"
work="$TEST_TMPDIR/gate_summary_error"
mkdir -p "$work"
status=0
: >"$work/err.log"
if (({close_stderr})); then
# Nothing can be written to stderr, the warning included.
GITHUB_STEP_SUMMARY="$work/missing/summary.md" "$gate" >"$work/out.log" 2>&- || status=$?
else
GITHUB_STEP_SUMMARY="$work/missing/summary.md" "$gate" >"$work/out.log" 2>"$work/err.log" || status=$?
fi
if ((status != {status})); then
echo "gate_summary_error_test: FAIL: the gate exited $status, not {status}" >&2
cat "$work/out.log" "$work/err.log" >&2
exit 1
fi
for line in {lines}; do
if ! grep -qxF -- "$line" "$work/out.log"; then
echo "gate_summary_error_test: FAIL: stdout lacks '$line'" >&2
cat "$work/out.log" >&2
exit 1
fi
done
if ! (({close_stderr})); then
for line in "mutation_gate: could not write to GITHUB_STEP_SUMMARY" {err_lines}; do
if ! grep -qxF -- "$line" "$work/err.log"; then
echo "gate_summary_error_test: FAIL: stderr lacks '$line'" >&2
cat "$work/err.log" >&2
exit 1
fi
done
fi
"""

def _summary_error_impl(ctx):
gate = ctx.attr.gate[DefaultInfo].files_to_run.executable
script = ctx.actions.declare_file(ctx.label.name + ".sh")
ctx.actions.write(
output = script,
content = _SUMMARY_ERROR_SCRIPT.format(
gate = gate.short_path,
status = ctx.attr.status,
close_stderr = 1 if ctx.attr.close_stderr else 0,
err_lines = " ".join([shell.quote(line) for line in ctx.attr.err_lines]),
lines = " ".join([shell.quote(line) for line in ctx.attr.lines]),
),
is_executable = True,
)
runfiles = ctx.runfiles(files = [gate]).merge(ctx.attr.gate[DefaultInfo].default_runfiles)
executable, runfiles = shell_executable(ctx, script, runfiles)
return [DefaultInfo(executable = executable, runfiles = runfiles)]

gate_summary_error_test = rule(
implementation = _summary_error_impl,
test = True,
toolchains = SHELL_TOOLCHAINS,
doc = """Passes when `gate`, run with `GITHUB_STEP_SUMMARY` in a missing directory,
still exits with `status`, prints each of `lines` to stdout and notes the
failed write on stderr, with each of `err_lines`. With `close_stderr` the gate
runs with stderr closed and nothing is read from it.""",
attrs = SHELL_ATTRS | {
"close_stderr": attr.bool(default = False, doc = "Run the gate with stderr closed."),
"err_lines": attr.string_list(doc = "More whole lines the gate's stderr must hold."),
"gate": attr.label(mandatory = True, executable = True, cfg = "target"),
"lines": attr.string_list(mandatory = True, doc = "Whole lines the gate's stdout must hold."),
"status": attr.int(mandatory = True, values = [0, 1], doc = "The exit status the gate must keep."),
},
)
Loading