blob: ca927f740d6de9be2e7dbba9a2bed80578959f14 [file]
# SPDX-License-Identifier: Apache-2.0
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Exercise the actual tests-ok shell gate, including discovery failures.
The field reader supports literal needs lists (inline or block), scalar
conditions, quotes, and comments. It is not a general YAML parser: aliases,
tags, and folded scalars are outside this workflow test's supported syntax.
"""
from __future__ import annotations
import os
import re
import shlex
import shutil
import subprocess
from pathlib import Path
from textwrap import dedent
from typing import NoReturn
import pytest
_WORKFLOW = Path(__file__).resolve().parents[3] / ".github/workflows/tests.yml"
_RESULTS = ("success", "failure", "cancelled", "skipped")
_BASH_FLAGS = ("--noprofile", "--norc", "-e", "-o", "pipefail")
def _field(job: str, name: str, indent: int = 4) -> str:
lines = job.splitlines()
for index, line in enumerate(lines):
match = re.fullmatch(rf" {{{indent}}}{re.escape(name)}:[ \t]*(.*)", line)
if match is None:
continue
value = [match[1]]
for following in lines[index + 1 :]:
stripped = following.lstrip()
if not stripped or stripped.startswith("#"):
continue
depth = len(following) - len(stripped)
if depth < indent or (depth == indent and not stripped.startswith("- ")):
break
value.append(stripped)
return "\n".join(value)
raise AssertionError(f"tests-ok is missing {name}")
def _tokens(value: str, sequence: bool = False) -> list[str]:
lexer = shlex.shlex(value, posix=True)
lexer.whitespace_split = True
if sequence:
lexer.whitespace += ",[]"
return list(lexer)
def _expression(value: str, require_wrapper: bool = False) -> str:
value = " ".join(_tokens(value)).strip()
wrapped = value.startswith("${{") and value.endswith("}}")
if require_wrapper:
assert wrapped, "the environment value must evaluate an Actions expression"
if wrapped:
value = value[3:-2].strip()
return value
def _assert_dependencies(job: str) -> None:
needs = [token for token in _tokens(_field(job, "needs"), sequence=True) if token != "-"]
assert set(needs) == {"members", "pytest"}
assert _expression(_field(job, "if")) == "always()"
def _unusable_bash(reason: str) -> NoReturn:
# Skip only off CI: on the Linux runner a broken Bash must fail, or every
# gate test would quietly skip and the required check would stay green.
message = f"the workflow gate needs a working Bash ({reason})"
if os.environ.get("CI"):
pytest.fail(message)
pytest.skip(message)
def _working_bash() -> str:
executable = shutil.which("bash")
if executable is None:
_unusable_bash("not found")
try:
probe = subprocess.run(
[executable, *_BASH_FLAGS, "-c", '[ "${BATCHES:-}" = "[]" ]'],
env={**os.environ, "BATCHES": "[]"},
capture_output=True,
text=True,
timeout=5,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as error:
_unusable_bash(type(error).__name__)
else:
if probe.returncode != 0:
_unusable_bash(f"probe exit {probe.returncode}")
return executable
@pytest.fixture(scope="module")
def gate_job() -> str:
# Read only this job's literal block; no YAML dependency is needed.
match = re.search(r"(?ms)^ tests-ok:\n(.*?)(?=^ \S|\Z)", _WORKFLOW.read_text(encoding="utf-8"))
assert match is not None, "tests-ok job is missing"
return match[1]
@pytest.fixture(scope="module")
def bash() -> str:
return _working_bash()
def test_gate_waits_for_both_jobs_and_runs_after_failure(gate_job: str) -> None:
_assert_dependencies(gate_job)
def test_gate_reads_batches_from_discovery(gate_job: str) -> None:
value = _field(gate_job, "BATCHES", indent=10)
assert _expression(value, require_wrapper=True) == "needs.members.outputs.batches"
def _run_gate(
gate_job: str, bash: str, members_result: str, pytest_result: str, batches: str | None = "[]"
) -> subprocess.CompletedProcess[str]:
# The assert step's own `run: |` block, found by its name, so a step added
# before it can never be the script under test.
run = re.search(
r"(?ms)^ - name: Assert member discovery and pytest matrix results\n.*?^ run: \|\n((?: .*\n|\n)+)",
gate_job,
)
assert run is not None, "tests-ok shell assertion is missing"
script = dedent(run[1])
for job, result in (("members", members_result), ("pytest", pytest_result)):
script = script.replace("${{ needs." + job + ".result }}", result)
assert "${{" not in script, "unresolved Actions expression in the gate"
env = os.environ.copy()
env.pop("BATCHES", None)
if batches is not None:
env["BATCHES"] = batches
return subprocess.run(
[bash, *_BASH_FLAGS, "-c", script],
env=env,
capture_output=True,
text=True,
timeout=20,
check=False,
)
def _assert_result(completed: subprocess.CompletedProcess[str], code: int, diagnostic: str) -> None:
details = f"exit={completed.returncode}\n{completed.stdout}{completed.stderr}"
assert completed.returncode == code, details
assert diagnostic in completed.stdout, details
@pytest.mark.parametrize("members_result", _RESULTS)
@pytest.mark.parametrize("pytest_result", _RESULTS)
def test_gate_results(gate_job: str, bash: str, members_result: str, pytest_result: str) -> None:
completed = _run_gate(gate_job, bash, members_result, pytest_result)
if members_result != "success":
_assert_result(completed, 1, f"workspace member discovery result: {members_result}")
elif pytest_result not in ("success", "skipped"):
_assert_result(completed, 1, f"pytest matrix result: {pytest_result}")
else:
_assert_result(completed, 0, f"pytest gate satisfied (matrix result: {pytest_result})")
@pytest.mark.parametrize(
"batches",
[None, "", "[ ]", '[{"name":"dev"}]', "not-json", "$(exit 23)", "`exit 24`"],
ids=["missing", "empty", "noncanonical-empty", "nonempty", "malformed", "substitution", "backticks"],
)
def test_skipped_matrix_needs_a_confirmed_empty_selection(
gate_job: str, bash: str, batches: str | None
) -> None:
completed = _run_gate(gate_job, bash, "success", "skipped", batches)
_assert_result(completed, 1, "pytest matrix skipped without an empty selection")
@pytest.mark.parametrize("members_result", ["failure", "cancelled", "skipped"])
def test_discovery_failure_is_reported_before_invalid_skip(
gate_job: str, bash: str, members_result: str
) -> None:
completed = _run_gate(gate_job, bash, members_result, "skipped", '[{"name":"dev"}]')
_assert_result(completed, 1, f"workspace member discovery result: {members_result}")
def test_successful_matrix_accepts_a_nonempty_selection(gate_job: str, bash: str) -> None:
completed = _run_gate(gate_job, bash, "success", "success", '[{"name":"dev"}]')
_assert_result(completed, 0, "pytest gate satisfied (matrix result: success)")
@pytest.mark.parametrize(
("needs", "condition"),
[
("[members, pytest]", "always()"),
("[members, pytest] # comment", "always()"),
("['members', 'pytest']", "always()"),
('["members", "pytest"]', "always()"),
("\n - members\n - pytest", "always()"),
("\n - 'members'\n - 'pytest' # comment", "${{ always() }}"),
("[members, pytest]", "${{ always() }}"),
("[members, pytest]", "'${{ always() }}' # comment"),
],
)
def test_equivalent_dependency_declarations(needs: str, condition: str) -> None:
_assert_dependencies(f" needs: {needs}\n if: {condition}\n")
@pytest.mark.parametrize(
("needs", "condition"),
[
("[pytest]", "always()"),
("[members]", "always()"),
("[members, pytest]", "success()"),
("[members, pytest]", "al ways()"),
("[members, pytest, other]", "always()"),
],
)
def test_incorrect_dependency_declarations_are_rejected(needs: str, condition: str) -> None:
with pytest.raises(AssertionError):
_assert_dependencies(f" needs: {needs}\n if: {condition}\n")
@pytest.mark.parametrize("mode", ["missing", "launch-error", "timeout", "nonzero"])
def test_unusable_bash_is_skipped(monkeypatch: pytest.MonkeyPatch, mode: str) -> None:
monkeypatch.delenv("CI", raising=False)
monkeypatch.setattr(shutil, "which", lambda name: None if mode == "missing" else "/probe/bash")
def probe(*args, **kwargs):
if mode == "launch-error":
raise OSError("cannot launch Bash")
if mode == "timeout":
raise subprocess.TimeoutExpired("bash", 5)
return subprocess.CompletedProcess(args[0], 1, "", "WSL launcher unavailable")
monkeypatch.setattr(subprocess, "run", probe)
with pytest.raises(pytest.skip.Exception, match="needs a working Bash"):
_working_bash()
def test_bash_probe_skips_shell_without_gate_environment(bash: str, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI", raising=False)
monkeypatch.setattr(shutil, "which", lambda name: bash)
run = subprocess.run
def without_batches(*args, **kwargs):
env = kwargs.get("env", os.environ).copy()
env.pop("BATCHES", None)
return run(*args, **{**kwargs, "env": env})
monkeypatch.setattr(subprocess, "run", without_batches)
with pytest.raises(pytest.skip.Exception, match="needs a working Bash"):
_working_bash()
def test_unusable_bash_fails_on_ci(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CI", "true")
monkeypatch.setattr(shutil, "which", lambda name: None)
with pytest.raises(pytest.fail.Exception, match="needs a working Bash"):
_working_bash()
def test_gate_step_runs_with_the_tested_shell_flags(gate_job: str) -> None:
step = gate_job.split("- name: Assert member discovery and pytest matrix results", 1)[1]
assert re.search(r"(?m)^ shell: bash\s*$", step), (
"the gate step must set `shell: bash` to match _BASH_FLAGS"
)