Files
fish-shell/tests/test_driver.py
Daniel Rainer 64e3b419b6 ci: disable pexpect and tmux tests in sanitizer jobs
These tests are unreliable in CI when running with address sanitiation
enabled, resulting in intermittent CI failures.
Disable them to get rid of the many false positives to reduce annoyance
and to avoid desensitization regarding failures of the asan CI job.

Suggested in
https://github.com/fish-shell/fish-shell/pull/12132#issuecomment-3605639954

Closes #12142
Closes #12132
Closes #12126
2025-12-10 16:15:41 +01:00

398 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import asyncio
import os
import resource
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
# TODO(python>3.8): use dict
# TODO(python>3.8): use |
from typing import Dict, Optional, Union
import littlecheck
try:
import pexpect
PEXPECT = True
except ImportError:
PEXPECT = False
RESET = "\033[0m"
GREEN = "\033[32m"
BLUE = "\033[34m"
RED = "\033[31m"
def makeenv(script_path: Path, home: Path) -> Dict[str, str]:
xdg_config = home / "xdg_config_home"
func_dir = xdg_config / "fish" / "functions"
os.makedirs(func_dir)
os.makedirs(xdg_config / "fish" / "conf.d")
for func in (script_path / "test_functions").glob("*.fish"):
shutil.copy(func, func_dir / func.parts[-1])
shutil.copy(
script_path / "interactive.config",
xdg_config / "fish" / "conf.d" / "interactive.fish",
)
xdg_data = home / "xdg_data_home"
os.makedirs(xdg_data)
xdg_runtime = home / "xdg_runtime_home"
os.makedirs(xdg_runtime)
xdg_cache = home / "xdg_cache_home"
os.makedirs(xdg_cache)
tmp = home / "temp"
os.makedirs(tmp)
# Set up the environment variables for the test.
env = os.environ.copy()
# unset LANG, TERM, ...
for var in [
"XDG_DATA_DIRS",
"LANGUAGE",
"MC_SID",
"COLORTERM",
"KONSOLE_VERSION",
"STY",
"TERM", # Erase this since we still respect TERM=dumb etc.
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
]:
if var in env:
del env[var]
langvars = [key for key in os.environ.keys() if key.startswith("LC_")]
for key in langvars:
del env[key]
env.update(
{
"HOME": str(home),
"TMPDIR": str(tmp),
"FISH_FAST_FAIL": "1",
"FISH_UNIT_TESTS_RUNNING": "1",
"XDG_CONFIG_HOME": str(xdg_config),
"XDG_DATA_HOME": str(xdg_data),
"XDG_RUNTIME_DIR": str(xdg_runtime),
"XDG_CACHE_HOME": str(xdg_cache),
"fish_test_helper": str(home.parent / "fish_test_helper"),
"LANG": "C",
}
)
return env
def compile_test_helper(source_path: Path, binary_path: Path) -> None:
subprocess.run(
[
"cc",
source_path,
"-o",
binary_path,
],
check=True,
)
async def main():
if len(sys.argv) < 2:
print("Usage: test_driver.py FISH_DIRECTORY TESTS")
return 1
# TODO(python>3.8): no need for abspath
script_path = Path(os.path.abspath(__file__)).parent
argparser = argparse.ArgumentParser(
description="test_driver: Run fish's test suite"
)
argparser.add_argument(
"--max-concurrency",
type=int,
help="Maximum number of tests to run concurrently. The default is to run all tests concurrently.",
default=os.environ.get("FISH_TEST_MAX_CONCURRENCY"),
)
argparser.add_argument(
"fish",
nargs=1,
help="Directory containing fish binaries to test (typically 'target/debug')",
)
argparser.add_argument("file", nargs="*", help="Tests to run")
args = argparser.parse_args()
max_concurrency = args.max_concurrency
if max_concurrency is not None and max_concurrency < 1:
print("--max-concurrency must be at least 1")
sys.exit(1)
fishdir = Path(args.fish[0]).absolute()
if not fishdir.is_dir():
fishdir = fishdir.parent
failcount = 0
failed = []
passcount = 0
skipcount = 0
def_subs = {"%": "%"}
lconfig = littlecheck.Config()
lconfig.colorize = sys.stdout.isatty()
lconfig.progress = True
for bin in ["fish", "fish_indent", "fish_key_reader"]:
if os.path.exists(fishdir / bin):
def_subs[bin] = str(fishdir / bin)
else:
print(f"Binary does not exist: {fishdir / bin}")
return 127
if args.file:
files = [(os.path.abspath(path), path) for path in args.file]
else:
files = [
(os.path.abspath(path), str(path.relative_to(Path.cwd())))
for path in sorted(script_path.glob("checks/*.fish"))
+ sorted(script_path.glob("pexpects/*.py"))
]
if os.environ.get("FISH_CI_SAN"):
def run_in_ci_san(path) -> bool:
if path.endswith(".py"):
return False
if os.path.basename(path).startswith("tmux-"):
return False
return True
files = [path_pair for path_pair in files if run_in_ci_san(path_pair[0])]
if not PEXPECT and any(x.endswith(".py") for (x, _) in files):
print(f"{RED}Skipping pexpect tests because pexpect is not installed{RESET}")
longest_test_name_length = max([len(arg) for _, arg in files])
max_expected_digits_duration = 5
def print_result(arg, result, color, duration=None, suffix=None):
duration_str = (
""
if duration is None
else f" {str(duration_ms).rjust(max_expected_digits_duration)} ms"
)
suffix_str = "" if suffix is None else f"\n{suffix}"
print(
f"{arg.ljust(longest_test_name_length)} {color}{result}{RESET} {duration_str}{suffix_str}"
)
with tempfile.TemporaryDirectory(prefix="fishtest-root-") as tmp_root:
tmp_root = Path(tmp_root)
compile_test_helper(
script_path / "fish_test_helper.c",
tmp_root / "fish_test_helper",
)
semaphore = asyncio.Semaphore(max_concurrency) if max_concurrency else None
async def run(f, arg) -> TestResult:
# TODO(python>3.8): use "async with"
if semaphore is not None:
await semaphore.acquire()
try:
return await run_test(
tmp_root, f, arg, script_path, def_subs, lconfig, fishdir
)
finally:
if semaphore is not None:
semaphore.release()
tasks = [create_task(run(f, arg), name=arg) for f, arg in files]
while tasks:
done, tasks = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in done:
try:
result = await task
except Exception as e:
arg = task.get_name()
result = TestFail(
arg, None, f"Test '{arg}' raised an exception: {e}"
)
# TODO(python>3.8): use match statement
if isinstance(result, TestSkip):
arg = result.arg
skipcount += 1
print_result(arg, "SKIPPED", BLUE)
elif isinstance(result, TestFail):
# fmt: off
arg, duration_ms, error_message = result.arg, result.duration_ms, result.error_message
# fmt: on
failcount += 1
failed += [arg]
print_result(arg, "FAILED", RED, duration_ms, error_message)
elif isinstance(result, TestPass):
arg, duration_ms = result.arg, result.duration_ms
passcount += 1
print_result(arg, "PASSED", GREEN, duration_ms)
if passcount + failcount + skipcount > 1:
print(f"{passcount} / {passcount + failcount} passed ({skipcount} skipped)")
if failcount:
failstr = "\n ".join(failed)
print(f"{RED}Failed tests{RESET}:\n {failstr}")
if passcount == 0 and failcount == 0 and skipcount:
return 125
return 1 if failcount else 0
# TODO(python>=3.7): @dataclass
class TestSkip:
arg: str
def __init__(self, arg: str):
self.arg = arg
class TestFail:
arg: str
duration_ms: Optional[int]
error_message: Optional[str]
def __init__(
self, arg: str, duration_ms: Optional[int], error_message: Optional[str]
):
self.arg = arg
self.duration_ms = duration_ms
self.error_message = error_message
class TestPass:
arg: str
duration_ms: int
def __init__(self, arg: str, duration_ms: int):
self.arg = arg
self.duration_ms = duration_ms
TestResult = Union[TestSkip, TestFail, TestPass]
# TODO(python>3.8): use asyncio.create_task
def create_task(coro, name: str) -> asyncio.Task:
task = asyncio.Task(coro)
if sys.version_info >= (3, 8):
task.set_name(name)
return task
async def run_test(
tmp_root: Path,
test_file_path: str,
arg,
script_path: Path,
def_subs,
lconfig,
fishdir: Path,
) -> TestResult:
if not test_file_path.endswith(".fish") and not test_file_path.endswith(".py"):
return TestFail(arg, None, f"Not a valid test file: {arg}")
starttime = datetime.now()
home = Path(tempfile.mkdtemp(prefix="fishtest-", dir=tmp_root))
test_env = makeenv(script_path, home)
if test_file_path.endswith(".fish"):
subs = def_subs.copy()
subs.update(
{
"s": test_file_path,
"fish_test_helper": str(tmp_root / "fish_test_helper"),
}
)
# littlecheck
ret = await littlecheck.check_path_async(
test_file_path,
subs,
lconfig,
lambda x: print(x.message()),
env=test_env,
cwd=home,
)
endtime = datetime.now()
duration_ms = round((endtime - starttime).total_seconds() * 1000)
if ret is littlecheck.SKIP:
return TestSkip(arg)
elif ret:
return TestPass(arg, duration_ms)
else:
return TestFail(arg, duration_ms, f"Tmpdir is {home}")
elif test_file_path.endswith(".py"):
test_env.update(
{
"PYTHONPATH": str(script_path),
"fish": str(fishdir / "fish"),
"fish_key_reader": str(fishdir / "fish_key_reader"),
"fish_indent": str(fishdir / "fish_indent"),
"TERM": "dumb",
"FISH_FORCE_COLOR": "1" if sys.stdout.isatty() else "0",
}
)
if not PEXPECT:
return TestSkip(arg)
PIPE = asyncio.subprocess.PIPE
proc = await asyncio.subprocess.create_subprocess_exec(
"python3",
test_file_path,
stdout=PIPE,
stderr=PIPE,
env=test_env,
cwd=home,
)
stdout, stderr = await proc.communicate()
endtime = datetime.now()
duration_ms = round((endtime - starttime).total_seconds() * 1000)
returncode = proc.returncode
if returncode == 0:
return TestPass(arg, duration_ms)
elif returncode == 127:
return TestSkip(arg)
else:
error_message = ""
if stdout:
error_message += stdout.decode("utf-8")
if stderr:
error_message += stderr.decode("utf-8")
error_message += f"Tmpdir is {home}"
return TestFail(arg, duration_ms, error_message)
else:
return TestFail(arg, None, "Error in test driver. This should be unreachable.")
if sys.version_info < (3, 7):
def asyncio_run(coro):
loop = asyncio.get_event_loop()
try:
return loop.run_until_complete(coro)
finally:
if not loop.is_closed():
loop.close()
else:
asyncio_run = asyncio.run
if __name__ == "__main__":
# Increase the maximum number of open files to at least 4096,
# as we run tests concurrently.
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
if soft < 4096:
resource.setrlimit(resource.RLIMIT_NOFILE, (min(4096, hard), hard))
try:
ret = asyncio_run(main())
sys.exit(ret)
except KeyboardInterrupt:
sys.exit(130)