From e88ae797b53067426c1f1d933c40694b38a0d0b6 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Thu, 12 Nov 2026 12:49:35 +0100 Subject: [PATCH 1/4] Run the pre-commit hooks on Windows Every hook now passes on a Windows checkout (core.autocrlf, gbk locale, core.symlinks=false); Linux behaviour is unchanged. - language: python hooks call `python`: a Windows venv has no python3.exe, so python3 was the system interpreter without pyyaml/pyusb. - end-of-file-fixer skips the AGENTS.md/.agents symlinks, which Windows checks out as plain files; codespell skips tools/codespell/ and ignores "pres" (2.2.4 never matches an exclude-file.txt line on a CRLF checkout, so -w rewrote the line and the exclude file itself). - unit tests: the SIGALRM watchdogs move to test_watchdog.h, a no-op on Windows, so ceedling builds there. - Python: explicit utf-8 on text I/O, os.devnull/os.pathsep, no reopening of open temp files, shell stubs run through bash with LF endings, gh resolved by PATH; tests of the POSIX-only rig harness (fcntl, termios, process groups) and of fetch.sh without jq are skipped on Windows. --- .claude/test/test_hil_remote.py | 9 +- .codespellrc | 3 +- .github/scripts/ci_set_matrix.py | 4 +- .github/scripts/code_size_ci.py | 8 +- .github/scripts/hil_ci_set_matrix.py | 4 +- .pre-commit-config.yaml | 19 +- test/hil/helper/hil_report.py | 4 +- test/hil/sysview_ci.py | 24 +- test/hil/test/test_change_impact.py | 43 ++-- test/hil/test/test_ci_metrics.py | 84 +++--- test/hil/test/test_code_size.py | 10 +- test/hil/test/test_hil_agentrc.py | 11 +- test/hil/test/test_hil_args.py | 2 +- test/hil/test/test_hil_bounded.py | 3 + test/hil/test/test_hil_dual_parse.py | 3 + test/hil/test/test_hil_farm_check.py | 3 + test/hil/test/test_hil_flash.py | 3 + test/hil/test/test_hil_lock.py | 3 + test/hil/test/test_hil_net.py | 1 + test/hil/test/test_hil_pyocd.py | 1 + test/hil/test/test_hil_remote.py | 2 +- test/hil/test/test_hil_report.py | 243 +++++++++--------- test/hil/test/test_hil_reset_order.py | 3 + test/hil/test/test_hil_rtt.py | 3 + test/hil/test/test_hil_stale_setup.py | 3 + test/hil/test/test_hil_sysview_capture.py | 3 + test/hil/test/test_hil_sysview_ci.py | 84 +++--- test/hil/test/test_hil_teardown_stress.py | 3 + test/hil/test/test_hil_tty_raw.py | 6 +- test/hil/test/test_hil_usbtest_id.py | 3 + test/hil/test/test_hil_usbtest_tt.py | 3 + test/hil/test/test_hil_usbtest_verdict.py | 3 + test/hil/test/test_hil_util.py | 8 +- test/hil/test/test_hil_wedge_sweep.py | 3 + test/hil/test/test_setup_toolchain.py | 18 +- .../test/host/dwc2/test_dwc2_split.c | 11 +- test/unit-test/test/support/test_watchdog.h | 37 +++ .../test/support/video_device_test.h | 11 +- tools/codespell/ignore-words.txt | 1 + 39 files changed, 414 insertions(+), 276 deletions(-) create mode 100644 test/unit-test/test/support/test_watchdog.h diff --git a/.claude/test/test_hil_remote.py b/.claude/test/test_hil_remote.py index 830a2425ce..82996c51f8 100644 --- a/.claude/test/test_hil_remote.py +++ b/.claude/test/test_hil_remote.py @@ -39,6 +39,9 @@ needs_rig_python = unittest.skipUnless(shutil.which('python3', path=RIG_PATH), f'no python3 on the fake rig PATH ({RIG_PATH})') # the fake ssh, rsync and python3 are #! scripts Windows cannot exec: a run there would reach the real ones needs_fake_rig = unittest.skipIf(os.name == 'nt', 'the fake rig is #! scripts and a POSIX shell') +# Windows refusals keep git, which the agentrc loader runs, from a dir with no real ssh or rsync +GIT_DIR = os.path.dirname(shutil.which('git') or '') +GIT_ALONE = bool(GIT_DIR) and not any(shutil.which(t, path=GIT_DIR) for t in ('ssh', 'rsync')) FAKE_SSH = f'''#!{sys.executable} import json, os, subprocess, sys @@ -151,13 +154,15 @@ def build(self, *variants, root='cmake-build'): def env(self): e = {k: v for k, v in os.environ.items() if not k.startswith('HIL_') and k not in ('CI', 'GITHUB_ACTIONS')} e.update(ROOT_DIR=str(self.root), REMOTE='rig', REMOTE_DIR=str(self.remote), AGENTRC_DIR=AGENTRC['root'], - PATH=f'{self.bin}:{os.environ["PATH"]}', FAKE_LOG=str(self.log), + PATH=os.pathsep.join([str(self.bin), os.environ['PATH']]), FAKE_LOG=str(self.log), FAKE_HOME=str(self.home), FAKE_RUN=str(self.run_file), FAKE_DEFAULT=DEFAULT_REPORT) e.pop('CONFIG', None) if os.name == 'nt': # only the refusals run here: a REMOTE_DIR they accept, and no real ssh or rsync on PATH - e.update(REMOTE_DIR='/tmp/hil-remote-test', PATH=str(self.bin)) + if not GIT_ALONE: + self.skipTest(f'git dir {GIT_DIR or "(none)"} also holds ssh or rsync') + e.update(REMOTE_DIR='/tmp/hil-remote-test', PATH=os.pathsep.join([str(self.bin), GIT_DIR])) return e def hil_remote(self, *argv, **env): diff --git a/.codespellrc b/.codespellrc index 1f06e0c5ca..5748daca4f 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,7 +1,8 @@ # See: https://github.com/codespell-project/codespell#using-a-config-file [codespell] # In the event of a false positive, add the problematic word, in all lowercase, to 'ignore-words.txt' (one word per line). -# Or copy & paste the whole problematic line to 'exclude-file.txt' +# Or copy & paste the whole problematic line to 'exclude-file.txt' (codespell 2.2.4 matches it with its line +# ending, so an entry misses on a CRLF checkout, as Windows has: prefer ignore-words.txt) ignore-words = tools/codespell/ignore-words.txt exclude-file = tools/codespell/exclude-file.txt check-filenames = diff --git a/.github/scripts/ci_set_matrix.py b/.github/scripts/ci_set_matrix.py index fea9fa23a8..e38bb61f85 100755 --- a/.github/scripts/ci_set_matrix.py +++ b/.github/scripts/ci_set_matrix.py @@ -118,7 +118,7 @@ def pinned_boards(repo_root=REPO): - with open(os.path.join(repo_root, '.github', 'ci-pinned-boards.json')) as f: + with open(os.path.join(repo_root, '.github', 'ci-pinned-boards.json'), encoding='utf-8') as f: return {e['board'] for e in json.load(f)['boards']} @@ -288,7 +288,7 @@ def main(): if args.select: select = json.loads(args.select) elif args.select_file: - with open(args.select_file) as f: + with open(args.select_file, encoding='utf-8') as f: select = json.load(f) elif args.base: r = subprocess.run([sys.executable, os.path.join(REPO, '.claude', 'skills', 'build', 'scripts', diff --git a/.github/scripts/code_size_ci.py b/.github/scripts/code_size_ci.py index 2dbcc1b54f..7e0b1d2711 100644 --- a/.github/scripts/code_size_ci.py +++ b/.github/scripts/code_size_ci.py @@ -19,6 +19,7 @@ import argparse import json import os +import shutil import subprocess import sys @@ -33,11 +34,14 @@ def gh(path, paginate=False): """Parsed `gh api` GET of `path`; with `paginate`, the list of every page.""" - cmd = ['gh', 'api', '-H', 'Accept: application/vnd.github+json'] + (['--paginate', '--slurp'] if paginate else []) + # by PATH: Windows' CreateProcess searches System32 first and only for gh.exe + cmd = [shutil.which('gh') or 'gh', 'api', '-H', 'Accept: application/vnd.github+json'] + (['--paginate', '--slurp'] if paginate else []) try: ret = subprocess.run(cmd + [path], capture_output=True, text=True, check=False, timeout=GH_TIMEOUT) except subprocess.TimeoutExpired: raise RuntimeError(f'gh api {path}: no reply in {GH_TIMEOUT} s') from None + except OSError as e: # no runnable gh is a failed lookup too + raise RuntimeError(f'gh api {path}: {e}') from None if ret.returncode != 0: raise RuntimeError(f'gh api {path}: {ret.stderr.strip()}') return json.loads(ret.stdout) @@ -127,7 +131,7 @@ def baseline(args): info = lookup(args.repo, args.current) except (RuntimeError, KeyError, TypeError, ValueError) as e: # an API failure or an unexpected reply info = {'sha': None, 'url': None, 'exact': False, 'run_id': None, 'note': f'the baseline lookup failed: {e}'} - with open(args.info, 'w') as f: + with open(args.info, 'w', encoding='utf-8') as f: json.dump(info, f, indent=1, sort_keys=True) f.write('\n') print(json.dumps(info)) diff --git a/.github/scripts/hil_ci_set_matrix.py b/.github/scripts/hil_ci_set_matrix.py index 9a1fc1ae30..957aa144fc 100644 --- a/.github/scripts/hil_ci_set_matrix.py +++ b/.github/scripts/hil_ci_set_matrix.py @@ -47,7 +47,7 @@ def main(): hil = None try: if args.select_file: - with open(args.select_file) as f: + with open(args.select_file, encoding='utf-8') as f: raw = f.read() if raw: # imported here, under the fall-open: a broken selector must still leave the @@ -83,7 +83,7 @@ def append_build_arg(toolchain, build_arg): matrix[toolchain].append(build_arg) for config_file in args.config_files: - with open(_resolve_config_path(config_file)) as f: + with open(_resolve_config_path(config_file), encoding='utf-8') as f: config = json.load(f) for board in config['boards']: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4deca0097a..73b25593f6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,14 +12,19 @@ repos: (?x)^( hw/bsp/mcx/sdk/ ) + # AGENTS.md and .agents are symlinks: without core.symlinks (Windows) git checks them out as + # plain files holding the target, which would get a newline appended - id: end-of-file-fixer exclude: | (?x)^( .idea/| - hw/bsp/mcx/sdk/ + hw/bsp/mcx/sdk/| + AGENTS\.md$| + \.agents$ ) - id: forbid-submodules +# codespell itself is excluded from tools/codespell/, whose files hold the misspellings on purpose - repo: https://github.com/codespell-project/codespell rev: v2.2.4 hooks: @@ -28,7 +33,8 @@ repos: exclude: | (?x)^( lib/| - hw/bsp/mcx/sdk/ + hw/bsp/mcx/sdk/| + tools/codespell/ ) - repo: local @@ -75,11 +81,12 @@ repos: # examples/device/mtp/src is in scope: test_hil_bounded parses README_TXT_CONTENT # and md5-checks the logo header from there as its MTP fixtures. # language: python so pre-commit's venv supplies pyusb and pyserial, which the rig - # scripts under test import. + # scripts under test import. language: python hooks run `python`, not `python3`: + # a Windows venv has no python3.exe, so python3 would be the system interpreter. - id: hil-test name: hil-test files: ^(test/hil/|examples/device/mtp/src/|\.claude/skills/hil-contract/scripts/hil_remote\.py$|\.github/workflows/pr_comment\.yml$) - entry: python3 -m unittest discover -s test/hil/test -p 'test_hil*.py' + entry: python -m unittest discover -s test/hil/test -p 'test_hil*.py' pass_filenames: false language: python additional_dependencies: [pyusb, pyserial] @@ -115,7 +122,7 @@ repos: - id: claude-scripts name: claude-scripts files: ^(\.claude/(skills/.*/(scripts/|SKILL\.md|[^/]*\.py$)|test/)|tools/(build_utils|family_json|get_deps)\.py$|test/hil/(agentrc\.json|helper/agentrc\.py)$) - entry: python3 -m unittest discover -s .claude/test -p 'test_*.py' + entry: python -m unittest discover -s .claude/test -p 'test_*.py' pass_filenames: false language: python additional_dependencies: [pyyaml] @@ -123,7 +130,7 @@ repos: - id: drivers-coverage name: drivers-coverage files: ^(\.github/ci-pinned-boards\.json$|\.github/scripts/ci_set_matrix\.py$|tools/drivers_coverage_check\.py$|tools/change_impact\.py$|tools/build_utils\.py$|hw/bsp/|src/portable/|examples/|test/hil/test/test_drivers_coverage\.py$|tools/build\.py$|test/hil/test/test_ci_boards\.py$|tools/code_size\.py$|tools/code_size_profile\.py$|test/hil/agentrc\.json$|test/hil/helper/agentrc\.py$|test/hil/test/test_code_size\.py$|\.github/scripts/code_size_ci\.py$|test/hil/test/test_code_size_ci\.py$|tools/membrowse_cli\.py$|test/hil/test/test_membrowse_cli\.py$|test/hil/tinyusb\.json$|test/hil/hfp\.json$) - entry: sh -c "cd test/hil/test && python3 -m unittest -q test_drivers_coverage test_ci_boards test_code_size test_code_size_ci test_membrowse_cli" + entry: sh -c "cd test/hil/test && python -m unittest -q test_drivers_coverage test_ci_boards test_code_size test_code_size_ci test_membrowse_cli" pass_filenames: false language: python additional_dependencies: [pyyaml, pyelftools] diff --git a/test/hil/helper/hil_report.py b/test/hil/helper/hil_report.py index 48f7b1edb7..5deff193db 100644 --- a/test/hil/helper/hil_report.py +++ b/test/hil/helper/hil_report.py @@ -835,7 +835,7 @@ def sequence(config_path, requested: list, report_dir: Path) -> dict: tests on the same rows. `current` is the last attempt's own run, its rows the requested boards'. summarize() stays the snapshot verdict.""" c, s = core(), schema() - cfg = json.loads(Path(config_path).read_text()) + cfg = json.loads(Path(config_path).read_text(encoding='utf-8')) roster = c.roster_identity(config_path) chain, locked_before, cells_before = [], None, {} for ctx in c.read_chain(report_dir, s): @@ -931,7 +931,7 @@ def _report_args(argv: list, **parser) -> tuple | None: help='boards to report on; default: every board in the config') ap.add_argument('--report-dir', default='.', help=f'where {REPORT_JSON} lives (default: cwd)') a = ap.parse_args(argv) - cfg = json.loads(Path(a.config_file).read_text()) + cfg = json.loads(Path(a.config_file).read_text(encoding='utf-8')) jpath = Path(a.report_dir) / REPORT_JSON if not jpath.is_file(): print(f'error: {jpath} not found -- did hil_test.py run in this directory?', file=sys.stderr) diff --git a/test/hil/sysview_ci.py b/test/hil/sysview_ci.py index 845883a3aa..15fb5b93f1 100644 --- a/test/hil/sysview_ci.py +++ b/test/hil/sysview_ci.py @@ -22,7 +22,7 @@ def workload_event(): """The workload window's anchor: the span of tud_cdc_read activity, the throughput test's own footprint.""" - with open(EVENT_MAP) as f: + with open(EVENT_MAP, encoding="utf-8") as f: return next(int(i) for i, n in json.load(f).items() if n == WORKLOAD_EVENT_NAME) GATE_MIN_N = 50 @@ -51,7 +51,7 @@ def load_set(d): # recursive: a base download puts each artifact in its own subdirectory for p in glob.glob(os.path.join(d, "**", "sysview-*.json"), recursive=True): try: - with open(p) as f: + with open(p, encoding="utf-8") as f: j = json.load(f) except (OSError, ValueError): continue @@ -431,9 +431,9 @@ def unrecorded_boards(reports_dir, pr_dir, roster_dir=None): for p in glob.glob(os.path.join(reports_dir, "hil-report-*", hil_report.REPORT_JSON)): roster = os.path.join(roster_dir, os.path.basename(os.path.dirname(p))[len("hil-report-"):]) try: - with open(roster) as f: + with open(roster, encoding="utf-8") as f: cfg = json.load(f) - with open(p) as f: + with open(p, encoding="utf-8") as f: doc = json.load(f) rows = {r["board"]: r.get("cells") for r in doc.get("rows") or [] if isinstance(r, dict) and isinstance(r.get("board"), str) and not r.get("retired")} @@ -549,7 +549,7 @@ def _err_excerpt(text, head=500, tail=500): def read_manifest(fw): """The image's .sysview.json (family_support.cmake) and the image's sha256.""" import hashlib - with open(os.path.splitext(fw)[0] + ".sysview.json") as f: + with open(os.path.splitext(fw)[0] + ".sysview.json", encoding="utf-8") as f: m = json.load(f) with open(fw, "rb") as f: return m, hashlib.sha256(f.read()).hexdigest() @@ -647,7 +647,7 @@ def capture_result(board, commit, capture=None, error=None, park_error=None, ide "metrics": None, "error": error or (NOT_DECODED if capture else None), **(identity or {})} def write_result(out_dir, result): - with open(os.path.join(out_dir, f"sysview-{result['board']}.json"), "w") as f: + with open(os.path.join(out_dir, f"sysview-{result['board']}.json"), "w", encoding="utf-8") as f: json.dump(result, f) def capture_one(board, fw, manifest, out_dir): @@ -856,7 +856,7 @@ def baseline(repo, pr, current): records = [] for p in glob.glob(os.path.join(current, "**", "sysview-*.json"), recursive=True): try: - with open(p) as f: + with open(p, encoding="utf-8") as f: records.append(json.load(f)) except (OSError, ValueError): records.append(None) @@ -922,17 +922,17 @@ def main(): info = baseline(args.repo, args.pr, args.current) except Exception as e: # an API failure or an unexpected reply is a result, the comment says so info = {**NO_BASELINE, "note": f"the baseline lookup failed: {type(e).__name__}: {e}"} - with open(args.info, "w") as f: + with open(args.info, "w", encoding="utf-8") as f: json.dump(info, f) print(json.dumps(info)) elif args.cmd == "report": info = None if args.baseline_info: - with open(args.baseline_info) as f: + with open(args.baseline_info, encoding="utf-8") as f: info = json.load(f) unrecorded = unrecorded_boards(args.hil_reports, args.pr_dir) if args.hil_reports else () md = report(args.base_dir, args.pr_dir, info, unrecorded) - with open(args.out, "w") as f: + with open(args.out, "w", encoding="utf-8") as f: f.write(md) print(f"{'empty (no captures)' if not md else args.out}") elif args.cmd == "unrecorded": @@ -945,13 +945,13 @@ def main(): # recursive: a downloaded artifact sits in its own subdirectory, its raw files beside its records for path in sorted(glob.glob(os.path.join(args.dir, "**", "sysview-*.json"), recursive=True)): try: - with open(path) as f: + with open(path, encoding="utf-8") as f: result = json.load(f) capture = result.get("capture") or {} # an undecoded capture only: a decoded one may have no raw left beside it to decode again if capture.get("status") == "captured" and result.get("error") == NOT_DECODED: result = decode_one(result, os.path.dirname(path), deadline) - with open(path, "w") as f: + with open(path, "w", encoding="utf-8") as f: json.dump(result, f) park_error = capture.get("park_error") print(f"{result['board']}: {result['error'] or 'ok'}" + (f" ({park_error})" if park_error else "")) diff --git a/test/hil/test/test_change_impact.py b/test/hil/test/test_change_impact.py index a184cdfef4..e0f19c2e81 100644 --- a/test/hil/test/test_change_impact.py +++ b/test/hil/test/test_change_impact.py @@ -19,6 +19,7 @@ import os import pathlib import re +import shutil import subprocess import sys import tempfile @@ -46,7 +47,7 @@ def real_rosters(): rosters = [] for name in ('tinyusb.json', 'hfp.json'): path = os.path.join(REPO, 'test/hil', name) - with open(path) as f: + with open(path, encoding='utf-8') as f: rosters.append((f'test/hil/{name}', json.load(f)['boards'])) return rosters @@ -58,7 +59,7 @@ def roster_flashers(): name went stale.""" for name in ('tinyusb.json', 'hfp.json'): path = os.path.join(REPO, 'test/hil', name) - with open(path) as f: + with open(path, encoding='utf-8') as f: cfg = hil_boards().resolve_roster(json.load(f)) for key in ('boards', 'boards-skip'): for b in cfg.get(key, []): @@ -993,7 +994,7 @@ def test_a_second_tree_is_not_answered_from_the_first(self): # the board does not exist in this tree at all -> unknown board -> skip self.assertTrue(build_utils.skip_example('host/bare_api', 'metro_m0_express'), 'the empty tree was answered from the repo tree cache') - os.chdir(REPO) + os.chdir(REPO) # Windows cannot remove the cwd self.assertFalse(build_utils.skip_example('host/bare_api', 'metro_m0_express'), 'and the repo answer must survive the excursion') finally: @@ -1510,7 +1511,7 @@ def test_hil_a_traced_build_dependency_selects_the_capture_boards(self): self.assertEqual((b['full'], b['families']), (False, [])) def test_hil_the_systemview_pin_selects_the_capture_boards(self): - with open(os.path.join(REPO, 'tools/get_deps.py')) as f: + with open(os.path.join(REPO, 'tools/get_deps.py'), encoding='utf-8') as f: real = f.read() sys.path.insert(0, os.path.join(REPO, 'tools')) import get_deps @@ -1630,7 +1631,7 @@ def test_family_list_change_unions_both_sides(self): def test_real_get_deps_parses(self): - with open(os.path.join(REPO, 'tools/get_deps.py')) as f: + with open(os.path.join(REPO, 'tools/get_deps.py'), encoding='utf-8') as f: real = f.read() self.assertEqual(change_impact.get_deps_changed_families(real, real, REPO), set()) # a real optional entry bumped resolves to that entry's real family. The commit @@ -1696,7 +1697,7 @@ def test_hil_without_a_base_is_full(self): self.assertTrue(change_impact.classify(['tools/get_deps.py'], REPO, ROSTERS)['full']) def test_a_dep_bump_selects_only_its_families(self): - with open(os.path.join(REPO, 'tools/get_deps.py')) as f: + with open(os.path.join(REPO, 'tools/get_deps.py'), encoding='utf-8') as f: real = f.read() sys.path.insert(0, os.path.join(REPO, 'tools')) import get_deps @@ -2149,7 +2150,7 @@ def test_duplicate_board_unions_the_test_lists(self): class TestCliJson(unittest.TestCase): def test_build_key_without_rosters(self): r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/change_impact.py'), - '--diff-file', '/dev/null'], capture_output=True, text=True) + '--diff-file', os.devnull], capture_output=True, text=True) self.assertEqual(r.returncode, 0, r.stderr) j = json.loads(r.stdout) self.assertIn('build', j) @@ -2214,7 +2215,7 @@ def test_pinned_keeps_only_families_with_a_pinned_board(self): pinned = _json.loads(self.run_matrix('--pinned').stdout) full = _json.loads(self.run_matrix().stdout) self.assertEqual(set(pinned), set(full)) # every toolchain key kept - with open(os.path.join(REPO, '.github', 'ci-pinned-boards.json')) as f: + with open(os.path.join(REPO, '.github', 'ci-pinned-boards.json'), encoding='utf-8') as f: boards = {e['board'] for e in _json.load(f)['boards']} fams = set().union(*pinned.values()) for fam in fams: @@ -2440,7 +2441,7 @@ def test_the_unscoped_matrices_survive_a_broken_selector(self): def test_every_rp2040_board_gets_the_pico_sdk(self): # cmake-required hands get_deps a bare `-b `: the action keys the SDK # checkout on the arg's spelling, so every rp2040 board must match it - with open(os.path.join(REPO, '.github', 'actions', 'get_deps', 'action.yml')) as f: + with open(os.path.join(REPO, '.github', 'actions', 'get_deps', 'action.yml'), encoding='utf-8') as f: text = f.read() cond = text[text.index('- name: Checkout pico-sdk'):text.index('uses:', text.index('- name: Checkout pico-sdk'))] keys = re.findall(r"contains\(inputs\.arg, '([^']+)'\)", cond) @@ -2461,7 +2462,7 @@ def membrowse(self, pinned, hil=None, with_esp=True): def test_membrowse_partition_covers_every_leg_once(self): full_pinned = json.loads(self.run_matrix('--pinned').stdout) m, esp = self.membrowse(full_pinned) - with open(os.path.join(REPO, '.github', 'ci-pinned-boards.json')) as f: + with open(os.path.join(REPO, '.github', 'ci-pinned-boards.json'), encoding='utf-8') as f: pinned = sorted(e['board'] for e in json.load(f)['boards'] if not e['board'].startswith('espressif')) self.assertEqual(m['all'], [f'-b {b}' for b in pinned] + esp) self.assertEqual(m['identical'], []) # a full build measures everything @@ -2578,16 +2579,12 @@ def run_matrix(self, *args): def test_an_esp_idf_variant_must_name_itself(self): # hil-build-esp sizes and uploads every leg under its --build-name: an unnamed # esp-idf variant would collide with the plain board; other buckets never upload - import tempfile other = {'name': 'metro_m4_express', 'uid': 'm', 'flasher': {'name': 'jlink', 'uid': 'p'}, 'variant': [{'name': 'metro_m4_express', 'defines': ['MAX3421_HOST=1']}]} for vname, ok in (('espressif_s3_devkitm', False), ('espressif_s3_devkitm-X', True)): esp = {'name': 'espressif_s3_devkitm', 'uid': 'u', 'flasher': {'name': 'esptool', 'uid': 'p'}, 'variant': [{'name': vname, 'defines': ['X=1']}]} - with tempfile.NamedTemporaryFile('w', suffix='.json') as f: - json.dump({'boards': [esp, other]}, f) - f.flush() - r = subprocess.run([sys.executable, HIL_SET_MATRIX, f.name], capture_output=True, text=True) + r = self._matrix_of([esp, other]) self.assertEqual(r.returncode == 0, ok, r.stderr) if ok: self.assertEqual(json.loads(r.stdout)['arm-gcc'], ['-b metro_m4_express -DMAX3421_HOST=1']) @@ -2595,11 +2592,13 @@ def test_an_esp_idf_variant_must_name_itself(self): self.assertIn('an esp-idf variant needs a name of its own', r.stderr) def _matrix_of(self, boards, *args): - import tempfile - with tempfile.NamedTemporaryFile('w', suffix='.json') as f: + # a closed file: Windows cannot reopen a NamedTemporaryFile the test still holds + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + path = os.path.join(d, 'roster.json') + with open(path, 'w', encoding='utf-8') as f: json.dump({'boards': boards}, f) - f.flush() - return subprocess.run([sys.executable, HIL_SET_MATRIX, *args, f.name], capture_output=True, text=True) + return subprocess.run([sys.executable, HIL_SET_MATRIX, *args, path], capture_output=True, text=True) def test_the_traced_leg_only_beside_a_device_or_dual_test(self): # hil_test.py captures only then: the park image alone or a host-only selection builds no traced image @@ -2637,12 +2636,8 @@ def test_a_sysview_board_with_several_variants_is_refused(self): self.assertIn('cannot tell which one to trace', r.stderr) def test_an_openocd_esp_board_builds_under_esp_idf(self): - import tempfile p4 = {'name': 'espressif_p4_function_ev', 'uid': 'u', 'flasher': {'name': 'openocd_esp', 'uid': 'p'}} - with tempfile.NamedTemporaryFile('w', suffix='.json') as f: - json.dump({'boards': [p4]}, f) - f.flush() - r = subprocess.run([sys.executable, HIL_SET_MATRIX, f.name], capture_output=True, text=True) + r = self._matrix_of([p4]) self.assertEqual(r.returncode, 0, r.stderr) m = json.loads(r.stdout) self.assertEqual((m['esp-idf'], m['arm-gcc']), (['-b espressif_p4_function_ev'], [])) diff --git a/test/hil/test/test_ci_metrics.py b/test/hil/test/test_ci_metrics.py index 925dbcb906..2833cec041 100644 --- a/test/hil/test/test_ci_metrics.py +++ b/test/hil/test/test_ci_metrics.py @@ -19,6 +19,17 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from test_change_impact import mf # noqa: E402 a manifest v1 fixture SENTINEL = 'example-map-default' +# by PATH: Windows' CreateProcess looks in System32 first and would run WSL's bash.exe +BASH = shutil.which('bash') or 'bash' +# the CI steps below pipe through jq, which Git for Windows does not ship; CI runners have it +NO_JQ = ('jq is not installed (Git for Windows ships none)' + if sys.platform == 'win32' and not shutil.which('jq') else '') + + +def _read(path): + # a fixed encoding: the workflows hold non-ASCII text a non-UTF-8 locale cannot decode + with open(path, encoding='utf-8') as f: + return f.read() class TestCircleCiSentinelContract(unittest.TestCase): @@ -28,8 +39,8 @@ class TestCircleCiSentinelContract(unittest.TestCase): fallback that no CI job reports, so pin the contract here.""" def setUp(self): - self.config = open(os.path.join(CIRCLECI, 'config.yml')).read() - self.config2 = open(os.path.join(CIRCLECI, 'config2.yml')).read() + self.config = _read(os.path.join(CIRCLECI, 'config.yml')) + self.config2 = _read(os.path.join(CIRCLECI, 'config2.yml')) def test_sentinel_appears_once_on_a_default_line(self): marker = f'# {SENTINEL}: rewritten in-place by config.yml set-matrix' @@ -75,6 +86,7 @@ def test_the_selector_gate_runs_both_suites(self): self.assertIn(suite, self.config, f'{suite} does not gate the CircleCI selector') +@unittest.skipIf(NO_JQ, NO_JQ) class TestCircleCiExampleMapBudget(unittest.TestCase): """Every generated job inlines the example map, so a broad selection multiplies it past CircleCI's compiled-config cap (#4194). Runs the set-matrix step for real with @@ -83,7 +95,7 @@ class TestCircleCiExampleMapBudget(unittest.TestCase): def _run_set_matrix(self, matrix, example_map, fork=False): """Returns (config2.yml example-map default, generated job count, stdout).""" - with open(os.path.join(CIRCLECI, 'config.yml')) as fh: + with open(os.path.join(CIRCLECI, 'config.yml'), encoding='utf-8') as fh: config = fh.read() i = config.index('command: |\n', config.index('name: Set matrix')) + len('command: |\n') j = config.index(' - continuation/continue', i) @@ -93,7 +105,7 @@ def _run_set_matrix(self, matrix, example_map, fork=False): def write(path, text, mode=0o644): path = os.path.join(d, path) os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'w') as fh: + with open(path, 'w', encoding='utf-8', newline='\n') as fh: fh.write(text) os.chmod(path, mode) os.mkdir(os.path.join(d, '.circleci')) @@ -114,10 +126,10 @@ def write(path, text, mode=0o644): STUB_MATRIX=json.dumps(matrix), STUB_MAP=json.dumps(example_map)) if fork: env['CIRCLE_PR_USERNAME'] = 'contributor' - r = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', block], cwd=d, env=env, + r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, env=env, capture_output=True, text=True) self.assertEqual(r.returncode, 0, r.stdout + r.stderr) - with open(os.path.join(d, '.circleci', 'config2.yml')) as fh: + with open(os.path.join(d, '.circleci', 'config2.yml'), encoding='utf-8') as fh: config2 = fh.read() default = re.search(rf"default: '(.*)' # {SENTINEL}$", config2, re.M).group(1) # jq pretty-prints each family list over several lines @@ -167,8 +179,8 @@ class TestWorkflowSelectionHandOff(unittest.TestCase): def setUp(self): wf = os.path.join(os.path.dirname(CIRCLECI), '.github', 'workflows') - self.build = open(os.path.join(wf, 'build.yml')).read() - self.util = open(os.path.join(wf, 'build_util.yml')).read() + self.build = _read(os.path.join(wf, 'build.yml')) + self.util = _read(os.path.join(wf, 'build_util.yml')) self.jobs = {j.split(':', 1)[0]: j for j in re.split(r'\n (?=[a-z][\w-]*:\n)', self.build)} def test_no_step_execs_with_the_selection_in_its_environment(self): @@ -195,7 +207,7 @@ def test_pr_derived_env_values_are_character_guarded(self): self.assertEqual(self.util.count(guard), 1, f'{guard}: each GITHUB_ENV write screens its value exactly once') # CircleCI builds from the same PR-derived map and uses $EX_ARGS unquoted - cci = open(os.path.join(CIRCLECI, 'config2.yml')).read() + cci = _read(os.path.join(CIRCLECI, 'config2.yml')) self.assertIn('case "$EX_ARGS" in', cci, 'the CircleCI copy of the example filter needs the same screen') self.assertIn('case "$BUILD_ARGS" in', self.build) @@ -227,19 +239,20 @@ def test_membrowse_identical_needs_no_toolchain_and_follows_the_gate(self): for producer in ('cmake', 'hil-build-esp'): self.assertIn("needs.check-paths.outputs.code_changed == 'true'", self.jobs[producer]) + @unittest.skipIf(NO_JQ, NO_JQ) def test_membrowse_identical_uploads_every_leg_past_a_failure(self): import subprocess, tempfile i = self.build.index('run: |\n', self.build.index('- name: Membrowse identical upload')) + len('run: |\n') block = re.sub(r'^ {10}', '', self.build[i:self.build.index('\n\n', i)], flags=re.M) with tempfile.TemporaryDirectory() as d: - with open(os.path.join(d, 'python3'), 'w') as f: + with open(os.path.join(d, 'python3'), 'w', encoding='utf-8', newline='\n') as f: f.write('#!/bin/sh\necho "$*" >> log\ncase "$*" in *bad*) exit 1 ;; esac\n') os.chmod(os.path.join(d, 'python3'), 0o755) - r = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, text=True, + r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, text=True, env={**os.environ, 'PATH': d + os.pathsep + os.environ['PATH'], 'LEGS': json.dumps(['-b bad', '-b x --build-name x-DMA --cflag=-DY=1'])}) self.assertNotEqual(r.returncode, 0) - with open(os.path.join(d, 'log')) as f: + with open(os.path.join(d, 'log'), encoding='utf-8') as f: self.assertEqual(sorted(f.read().splitlines()), [ 'tools/build.py -s cmake --target examples-membrowse-upload -j 1 -b bad', 'tools/build.py -s cmake --target examples-membrowse-upload -j 1 -b x --build-name x-DMA ' @@ -288,7 +301,7 @@ def ok(cls, value): self.assertTrue(ok(classes['args'], hfp), f'{path}: hfp {hfp!r}') # BUILD_ARGS is the hfp job's `-b [-e ...]` list, not the -bt # test filter above - screen the value that step actually builds - with open(os.path.join(d, 'sel.json'), 'w') as fh: + with open(os.path.join(d, 'sel.json'), 'w', encoding='utf-8', newline='\n') as fh: fh.write(r.stdout) hm = subprocess.run( [sys.executable, os.path.join(repo, '.github/scripts/hil_ci_set_matrix.py'), @@ -330,6 +343,7 @@ def test_the_toolchain_lists_match_ci_set_matrix(self): self.assertEqual(json.loads(m.group(1).replace("'", '"')), ci_set_matrix.REQUIRED_TOOLCHAINS) self.assertIn('needs.set-matrix.outputs.required_json', self.jobs['cmake-required']) + @unittest.skipIf(NO_JQ, NO_JQ) def test_the_code_size_scope_lists_every_cmake_leg_and_esp_leg(self): # pr_comment.yml's compare expects a snapshot artifact per listed leg: the list # must be the cmake job's matrix, from the one toolchain list both read, plus every @@ -351,11 +365,11 @@ def test_the_code_size_scope_lists_every_cmake_leg_and_esp_leg(self): def scope(changed, owner='hathach'): with tempfile.TemporaryDirectory() as d: - subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, check=True, + subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, check=True, env={**os.environ, 'TOOLCHAINS': m.group(1), 'PINNED_JSON': json.dumps(pinned), 'EXAMPLE_MAP': '{"stm32f4": ["device/cdc_msc"]}', 'CODE_CHANGED': changed, 'HIL_JSON': json.dumps(hil), 'OWNER': owner}) - with open(os.path.join(d, 'code-size-scope', 'scope.json')) as fh: + with open(os.path.join(d, 'code-size-scope', 'scope.json'), encoding='utf-8') as fh: return json.load(fh) cmake = [{'toolchain': 'arm-gcc', 'arg': 'stm32f4'}, {'toolchain': 'arm-gcc', 'arg': 'imxrt'}, {'toolchain': 'riscv-gcc', 'arg': 'fomu'}] @@ -367,7 +381,7 @@ def scope(changed, owner='hathach'): def test_the_esp_snapshot_runs_in_the_idf_image_with_the_leg_as_data(self): import subprocess, tempfile - with open(os.path.join(REPO, '.github', 'workflows', 'build_util.yml')) as f: + with open(os.path.join(REPO, '.github', 'workflows', 'build_util.yml'), encoding='utf-8') as f: text = f.read() def step_script(name): i = text.index('run: |\n', text.index(f'- name: {name}\n')) + len('run: |\n') @@ -380,7 +394,7 @@ def step_script(name): 'git': 'echo "git $*" >> "$LOG"', 'pip': 'echo "pip $*" >> "$LOG"', 'python': '{ printf "python"; printf " [%s]" "$@"; echo; } >> "$LOG"'} for name, body in stubs.items(): - with open(os.path.join(d, name), 'w') as f: + with open(os.path.join(d, name), 'w', encoding='utf-8', newline='\n') as f: f.write(f'#!/bin/bash\n{body}\n') os.chmod(os.path.join(d, name), 0o755) env = {**os.environ, 'PATH': d + os.pathsep + os.environ['PATH'], 'LOG': os.path.join(d, 'log'), @@ -389,19 +403,21 @@ def step_script(name): def run(arg, toolchain='esp-idf'): open(env['LOG'], 'w').close() - r = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, + r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, text=True, env={**env, 'ARG': arg, 'TOOLCHAIN': toolchain}) self.assertEqual(r.returncode, 0, r.stderr) - with open(env['LOG']) as f: + with open(env['LOG'], encoding='utf-8') as f: return f.read().splitlines() log = run('-b espressif_s3_devkitm $(touch pwned) ;id') + # the mount is bash's $PWD: d itself on POSIX, its /c/... spelling under Git Bash + pwd = subprocess.run([BASH, '-c', 'pwd'], cwd=d, capture_output=True, text=True).stdout.strip() self.assertFalse(os.path.exists(os.path.join(d, 'pwned'))) # the engine is agentrc's at the pin, fetched on the runner before anything sizes fetch = 'python [test/hil/helper/agentrc.py] [fetch]' self.assertEqual(log.pop(0), fetch) # the container selects the pin as the runner does (CI), and its git may read the pin's checkout self.assertTrue(log[0].startswith('docker run --rm -e ARG -e EX_ARGS -e BUILD_OUTCOME -e GITHUB_EVENT_NAME ' - f'-e CI -e GITHUB_ACTIONS -v {d}:/project -w /project ' + f'-e CI -e GITHUB_ACTIONS -v {pwd}:/project -w /project ' 'espressif/idf:tinyusb bash -c')) self.assertEqual(log[1:4], ['git config --global --add safe.directory /project', 'git config --global --add safe.directory /project/lib/agentrc', @@ -420,7 +436,7 @@ def test_the_code_size_comment_is_written_even_without_usable_snapshots(self): # a run without usable snapshots, and a baseline lookup that fails, must still # leave a comment.md, so the post replaces an earlier push's table import subprocess, tempfile - with open(os.path.join(REPO, '.github', 'workflows', 'pr_comment.yml')) as f: + with open(os.path.join(REPO, '.github', 'workflows', 'pr_comment.yml'), encoding='utf-8') as f: text = f.read() def step_script(name): @@ -431,21 +447,23 @@ def step_script(name): env = {**os.environ, 'RUNNER_TEMP': d, 'REPO': 'x/y', 'RUN_URL': 'https://example/run', 'GITHUB_STEP_SUMMARY': os.path.join(d, 'summary.md'), 'GITHUB_OUTPUT': os.path.join(d, 'out'), 'PATH': d + os.pathsep + os.environ['PATH']} - with open(os.path.join(d, 'gh'), 'w') as f: # any API call fails + with open(os.path.join(d, 'gh'), 'w', encoding='utf-8', newline='\n') as f: # any API call fails f.write('#!/bin/sh\nexit 1\n') os.chmod(os.path.join(d, 'gh'), 0o755) + with open(os.path.join(d, 'gh.bat'), 'w', encoding='utf-8') as f: # what Windows' which() finds + f.write('@exit /b 1\n') leg = os.path.join(d, 'code-size', 'current', 'code-size-arm-gcc-x') os.makedirs(leg) - with open(os.path.join(leg, 'leg.json'), 'w') as f: + with open(os.path.join(leg, 'leg.json'), 'w', encoding='utf-8', newline='\n') as f: json.dump({'schema': 1, 'boards': [], 'examples': None, 'build_outcome': 'success', 'sha': 'a' * 40, 'base_sha': 'a' * 40, 'head_sha': 'a' * 40}, f) for name in ('Find the baseline', 'Compare against the baseline'): - r = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', step_script(name)], cwd=REPO, + r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', step_script(name)], cwd=REPO, capture_output=True, text=True, env=env) self.assertEqual(r.returncode, 0, f'{name}: {r.stderr}') - with open(os.path.join(d, 'out')) as f: + with open(os.path.join(d, 'out'), encoding='utf-8') as f: self.assertEqual(f.read(), 'run_id=\n') # no baseline download - with open(os.path.join(d, 'code-size', 'out', 'comment.md')) as f: + with open(os.path.join(d, 'code-size', 'out', 'comment.md'), encoding='utf-8') as f: comment = f.read() self.assertTrue(comment.startswith('## Code size')) self.assertIn('Baseline: unavailable - the baseline lookup failed', comment) @@ -462,15 +480,15 @@ def _run_block(self, block, sel, setup=None): def _run_block_in(self, d, block, sel, setup=None): import subprocess - with open(os.path.join(d, 'selection.json'), 'w') as fh: + with open(os.path.join(d, 'selection.json'), 'w', encoding='utf-8', newline='\n') as fh: json.dump(sel, fh) out = os.path.join(d, 'gh_output') open(out, 'w').close() env = {**os.environ, **((setup and setup(d)) or {}), 'GITHUB_OUTPUT': out} - r = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', block], cwd=d, + r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, text=True, env=env) self.assertEqual(r.returncode, 0, r.stderr) - with open(out) as fh: + with open(out, encoding='utf-8') as fh: return dict(l.split('=', 1) for l in fh.read().splitlines() if '=' in l) def _run_matrix_step(self, sel, fail_pinned=False): @@ -491,7 +509,7 @@ def setup(d): os.symlink(os.path.join(repo, name), os.path.join(d, name)) bin_dir = os.path.join(d, 'bin') os.mkdir(bin_dir) - with open(os.path.join(bin_dir, 'python'), 'w') as fh: + with open(os.path.join(bin_dir, 'python'), 'w', encoding='utf-8', newline='\n') as fh: fh.write('#!/bin/sh\n') if fail_pinned: fh.write('case " $* " in *" --pinned "*--select-file*) exit 1 ;; esac\n') @@ -551,7 +569,7 @@ def setup(d): for name in ('test', 'tools'): os.symlink(os.path.join(REPO, name), os.path.join(d, name)) os.mkdir(os.path.join(d, 'bin')) - with open(os.path.join(d, 'bin', 'python3'), 'w') as fh: + with open(os.path.join(d, 'bin', 'python3'), 'w', encoding='utf-8', newline='\n') as fh: fh.write('#!/bin/sh\n' f'case "$*" in *test_change_impact.py*|*test_build_select.py*) exit {suites_rc} ;;\n' ' *check_build.py*) echo "$*" > argv; exec cat selection.json ;; esac\n' @@ -565,7 +583,7 @@ def setup(d): out = self._run_block_in(d, block, sel, setup) self.assertEqual(out['suites_ok'], 'true' if suites_rc == 0 and 'argv' in os.listdir(d) else 'false') argv = os.path.join(d, 'argv') - return out['code'], (open(argv).read().split() if os.path.exists(argv) else None) + return out['code'], (_read(argv).split() if os.path.exists(argv) else None) def test_the_gate_skips_only_what_the_selector_clears(self): doc = {'path': 'README.rst', 'effect': 'none'} @@ -672,7 +690,7 @@ def test_the_build_extras_drop_when_the_matrix_falls_open(self): self.assertIn("grep -q 'ci_set_matrix: UNSCOPED'", self.build) self.assertIn('BUILD_SELECT_FILE', self.build) scripts = os.path.join(os.path.dirname(CIRCLECI), '.github', 'scripts') - matrix = open(os.path.join(scripts, 'ci_set_matrix.py')).read() + matrix = _read(os.path.join(scripts, 'ci_set_matrix.py')) # count-independent: pin the INVARIANT, not the number of fall-open paths - # every message that emits the full matrix must carry the marker, and a purely # informational note (a partial family miss) must not claim to have done so. diff --git a/test/hil/test/test_code_size.py b/test/hil/test/test_code_size.py index 0e385897de..38395563cf 100644 --- a/test/hil/test/test_code_size.py +++ b/test/hil/test/test_code_size.py @@ -13,6 +13,7 @@ import tempfile import types import unittest +from pathlib import Path from unittest import mock REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..')) @@ -183,9 +184,10 @@ def test_elfs_are_the_examples_own_not_helpers_or_fetched_tools(self): with tempfile.TemporaryDirectory() as tmp: _touch(tmp, 'device/a/a.elf', 'device/a/sub/bs2_default.elf', '_deps/picotool/x/enc.elf', 'host/b/b.elf') - self.assertEqual(sorted(os.path.relpath(e, tmp) for e in prof.elfs(tmp, None)), - ['device/a/a.elf', 'host/b/b.elf']) - self.assertEqual([os.path.relpath(e, tmp) for e in prof.elfs(tmp, 'device/a')], ['device/a/a.elf']) + def rel(e): + return Path(os.path.relpath(e, tmp)).as_posix() + self.assertEqual(sorted(rel(e) for e in prof.elfs(tmp, None)), ['device/a/a.elf', 'host/b/b.elf']) + self.assertEqual([rel(e) for e in prof.elfs(tmp, 'device/a')], ['device/a/a.elf']) class Espressif(unittest.TestCase): @@ -348,6 +350,8 @@ def test_ci_adds_the_pinned_boards_after_the_named_ones_once_each(self): self.assertEqual(built, ['extra', 'b1', 'b2']) @mock.patch.object(sd, 'WINDOWS', new=False) + @mock.patch.object(sd, 'out_dir_lock', new=contextlib.nullcontext) # fcntl's, with WINDOWS False + @mock.patch.object(prof, '_idf_image', new=lambda: True) # CI's image, whether or not docker is here def test_ci_builds_pinned_espressif_boards_too(self): with tempfile.TemporaryDirectory() as tmp: built = self._boards_built(tmp, '{"boards": [{"board": "b1"}, ' diff --git a/test/hil/test/test_hil_agentrc.py b/test/hil/test/test_hil_agentrc.py index 75b2215f80..2886e0df3d 100644 --- a/test/hil/test/test_hil_agentrc.py +++ b/test/hil/test/test_hil_agentrc.py @@ -46,7 +46,7 @@ def scratch_agentrc(dest, changes=None, drop=()): shutil.copy(Path(REAL['root']) / rel, dest / rel) for rel, text in {'.gitignore': '__pycache__/\n', **(changes or {})}.items(): # as agentrc's own (dest / rel).parent.mkdir(parents=True, exist_ok=True) - (dest / rel).write_text(text) + (dest / rel).write_text(text, encoding='utf-8') git(dest, 'init', '-q') git(dest, 'add', '-A') git(dest, 'commit', '-qm', 'scratch agentrc') @@ -77,7 +77,7 @@ def checkout(self, name, changes=None): def pin(self, commit, url=None): (self.root / 'test/hil/agentrc.json').write_text(json.dumps({'url': url or self.install.as_uri(), - 'commit': commit})) + 'commit': commit}), encoding='utf-8') def env(self, ci=None, agentrc_dir=None): return scratch_env(agentrc_dir or self.install, **({} if ci is None else {'CI': ci})) @@ -115,7 +115,7 @@ def test_a_dev_pc_loads_the_installed_agentrc(self): self.assertEqual((ident['source'], ident['root'], ident['commit'], ident['dirty']), ('installed', str(self.install), git(self.install, 'rev-parse', 'HEAD'), False)) self.assertEqual(ident['files'], REAL['files']) - (self.install / RTT_PY).write_text((self.install / RTT_PY).read_text() + '\n') + (self.install / RTT_PY).write_text((self.install / RTT_PY).read_text(encoding='utf-8') + '\n', encoding='utf-8') self.assertTrue(self.check()['dirty'], 'an uncommitted closure edit is a dirty identity') def test_ci_loads_the_pin_never_the_installed_agentrc(self): @@ -153,7 +153,7 @@ def test_a_staged_tree_wins_over_ci_and_the_installed_agentrc(self): def test_a_staged_tree_unlike_its_manifest_never_falls_back(self): self.stage() staged = self.root / '.agentrc' - (staged / RTT_PY).write_text('edited on the rig\n') + (staged / RTT_PY).write_text('edited on the rig\n', encoding='utf-8') self.refused(self.bootstrap('check'), f'differ from their manifest: {RTT_PY}', 'stage the run again') (staged / RTT_PY).unlink() self.refused(self.bootstrap('check'), f'staged agentrc {RTT_PY} is missing') @@ -178,7 +178,7 @@ def test_fetch_checks_out_the_pin_once_and_refuses_a_changed_checkout(self): self.assertEqual(r.returncode, 0, r.stderr) self.assertEqual(git(self.root / agentrc.DEP, 'rev-parse', 'HEAD'), commit) self.assertEqual(self.check(ci='true')['commit'], commit) - (self.root / agentrc.DEP / RTT_PY).write_text('edited\n') + (self.root / agentrc.DEP / RTT_PY).write_text('edited\n', encoding='utf-8') self.refused(self.bootstrap('fetch', ci='true'), 'is not a clean checkout', 'remove it and fetch again') # --- what the bootstrap refuses ------------------------------------------------------------- @@ -220,6 +220,7 @@ def test_a_dependency_edited_after_the_identity_is_refused(self): self.refused(r, 'agentrc skills/hil/scripts/hil_process.py changed since this process loaded it') self.assertNotIn('DRIFT RAN', r.stderr) + @unittest.skipIf(sys.platform == 'win32', 'hil_openocd_esp imports fcntl: POSIX only') def test_a_dependency_behind_a_retargeted_skill_link_never_runs(self): # an install of skill symlinks: hil_load's ROOT is the checkout they resolve into, so the esp_session that # hil_openocd_esp loads itself by path is the identity's, never one a link retargeted since now leads to diff --git a/test/hil/test/test_hil_args.py b/test/hil/test/test_hil_args.py index 4c7c59ccd5..01de8c18e2 100644 --- a/test/hil/test/test_hil_args.py +++ b/test/hil/test/test_hil_args.py @@ -57,7 +57,7 @@ def test_rejects_unknown_option_and_missing_config(self): hil_args.build_parser().parse_args(argv) def test_hil_test_builds_no_parser_of_its_own(self): - tree = ast.parse((HIL_DIR / 'hil_test.py').read_text()) + tree = ast.parse((HIL_DIR / 'hil_test.py').read_text(encoding='utf-8')) calls = {ast.unparse(n.func) for n in ast.walk(tree) if isinstance(n, ast.Call)} self.assertIn('hil_args.build_parser', calls) self.assertNotIn('argparse.ArgumentParser', calls) diff --git a/test/hil/test/test_hil_bounded.py b/test/hil/test/test_hil_bounded.py index d9712e281c..9213247229 100644 --- a/test/hil/test/test_hil_bounded.py +++ b/test/hil/test/test_hil_bounded.py @@ -37,6 +37,9 @@ # the modules under test live in the parent dir (test/hil), not here sys.path.insert(0, os.path.dirname(TEST_DIR)) +if sys.platform == 'win32': + raise unittest.SkipTest("hil_test imports agentrc's rig harness (fcntl locks, process groups): POSIX only") + import usbtest_harness import hil_flash import hil_test diff --git a/test/hil/test/test_hil_dual_parse.py b/test/hil/test/test_hil_dual_parse.py index cb0314ccf0..84f2eadf41 100644 --- a/test/hil/test/test_hil_dual_parse.py +++ b/test/hil/test/test_hil_dual_parse.py @@ -10,6 +10,9 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if sys.platform == 'win32': + raise unittest.SkipTest("hil_test imports agentrc's rig harness (fcntl locks, process groups): POSIX only") + import usbtest_harness # noqa: E402 - stubs pyserial before hil_test imports it import hil_test # noqa: E402 from helper import hil_util # noqa: E402 diff --git a/test/hil/test/test_hil_farm_check.py b/test/hil/test/test_hil_farm_check.py index 1ee5df3e5a..ccc436f5d5 100644 --- a/test/hil/test/test_hil_farm_check.py +++ b/test/hil/test/test_hil_farm_check.py @@ -19,6 +19,9 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if sys.platform == 'win32': + raise unittest.SkipTest("the farm check takes agentrc's fcntl board locks: POSIX only") + from usbtest_harness import patch # noqa: E402 - stubs pyserial import hil_flash # noqa: E402 from helper import agentrc, farm_profile, hil_farm_check, hil_lock, hil_util # noqa: E402 diff --git a/test/hil/test/test_hil_flash.py b/test/hil/test/test_hil_flash.py index edefae74cd..cadce4c380 100644 --- a/test/hil/test/test_hil_flash.py +++ b/test/hil/test/test_hil_flash.py @@ -24,6 +24,9 @@ REPO = str(HIL_DIR.parents[1]) sys.path.insert(0, str(HIL_DIR)) sys.path.insert(0, os.path.join(REPO, 'tools')) +if sys.platform == 'win32': + raise unittest.SkipTest("agentrc's flash provider imports hil_process (fcntl, process groups): POSIX only") + import change_impact # noqa: E402 import hil_flash # noqa: E402 from helper import agentrc, hil_util # noqa: E402 diff --git a/test/hil/test/test_hil_lock.py b/test/hil/test/test_hil_lock.py index 6c62c6dadd..8d3df0c198 100644 --- a/test/hil/test/test_hil_lock.py +++ b/test/hil/test/test_hil_lock.py @@ -27,6 +27,9 @@ REPO = os.path.dirname(os.path.dirname(HIL_DIR)) sys.path.insert(0, HIL_DIR) +if sys.platform == 'win32': + raise unittest.SkipTest("agentrc's board lock is an fcntl flock: POSIX only") + from usbtest_harness import patch # noqa: E402 - stubs pyserial before hil_test imports it import hil_flash # noqa: E402 import hil_test # noqa: E402 diff --git a/test/hil/test/test_hil_net.py b/test/hil/test/test_hil_net.py index 88503324c8..4c2cfbb899 100644 --- a/test/hil/test/test_hil_net.py +++ b/test/hil/test/test_hil_net.py @@ -348,6 +348,7 @@ def test_unreachable_port_is_not_read_as_missing_server(self): self.run.assert_not_called() +@unittest.skipIf(sys.platform == 'win32', "hil_test imports agentrc's rig harness (fcntl locks, process groups): POSIX only") class ReportCell(unittest.TestCase): def setUp(self): import usbtest_harness # noqa: F401 - stubs serial before hil_test is imported diff --git a/test/hil/test/test_hil_pyocd.py b/test/hil/test/test_hil_pyocd.py index 0dc422d066..6457a7e625 100644 --- a/test/hil/test/test_hil_pyocd.py +++ b/test/hil/test/test_hil_pyocd.py @@ -26,6 +26,7 @@ def test_it_exists_and_holds_no_pyocd_config(self): self.assertFalse((hil_flash.PYOCD_PROJECT_DIR / name).exists(), name) +@unittest.skipIf(sys.platform == 'win32', "hil_flash's provider and hil_test load agentrc's hil_process (fcntl): POSIX only") class Flasher(unittest.TestCase): FLASHER = {'name': 'pyocd', 'uid': 'GSA0CQEQ', 'vid_pid': '0x1fc9 0x0090', 'args': '-t lpc55s28'} diff --git a/test/hil/test/test_hil_remote.py b/test/hil/test/test_hil_remote.py index 017bf599d7..8f409d2a7f 100644 --- a/test/hil/test/test_hil_remote.py +++ b/test/hil/test/test_hil_remote.py @@ -268,7 +268,7 @@ def test_a_run_stages_the_bytes_the_receipt_checked(self): @contextlib.contextmanager def lease(remote, remote_dir, root, mkdirs=()): self.example('b', 'device/cdc').write_text('rebuilt while the rig was set up') - yield TREE + yield TREE._replace(root=Path(root)) # the checkout rsync runs in: Windows names local files from it def rsync(tree, *args, optional=False, staging=False): if '--prune-empty-dirs' not in args: diff --git a/test/hil/test/test_hil_report.py b/test/hil/test/test_hil_report.py index 4097a903c7..96d4349fea 100644 --- a/test/hil/test/test_hil_report.py +++ b/test/hil/test/test_hil_report.py @@ -26,6 +26,11 @@ CAVEAT = '**HIL run aborted: worker pool timed out after 3600s.**\n' ROSTER = Path(HIL_DIR) / 'tinyusb.json' +# a report dir no OS can create: its parent is a regular file ('/proc/...' is a plain +# creatable path on Windows, so a run there left a report on the drive's root) +UNWRITABLE = Path(__file__) / 'nope' +# hil_test.py imports agentrc's rig harness (fcntl locks, process groups) at load +DRIVES_HIL_TEST = unittest.skipIf(sys.platform == 'win32', 'drives hil_test.py, whose rig harness is POSIX only') def begin(rd, accumulate=True): @@ -244,7 +249,7 @@ def test_scope_is_recorded_in_the_sidecar(self): rd = Path(td.name) hil_report.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '-b boardA', '') - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual(doc['scope'], '-b boardA') def test_an_unscoped_run_records_an_empty_scope(self): @@ -252,7 +257,7 @@ def test_an_unscoped_run_records_an_empty_scope(self): self.addCleanup(td.cleanup) rd = Path(td.name) hil_report.accumulate_report(self._rows('boardA', 'cdc_msc'), rd, True, '', '') - self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text())['scope'], '') + self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['scope'], '') class EveryExitPathLeavesBothArtifacts(unittest.TestCase): @@ -265,8 +270,8 @@ def test_the_no_boards_exit_writes_json_too(self): rd = Path(td.name) hil_report.write_report(rd, {'rows': [], 'scope': '', 'caveat': '**HIL run selected no boards.** why\n'}) - self.assertIn('selected no boards', (rd / hil_report.REPORT_MD).read_text()) - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + self.assertIn('selected no boards', (rd / hil_report.REPORT_MD).read_text(encoding='utf-8')) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual(doc['rows'], []) self.assertIn('selected no boards', doc['caveat']) @@ -275,12 +280,12 @@ def test_write_report_raises_so_its_callers_can_report_it(self): write_timeout_report's warning and hil_test's fallback-of-the-fallback dead code -- an unwritable report dir produced no artifact and no message.""" with self.assertRaises(hil_report.core().ReportError): - hil_report.write_report(Path('/proc/nonexistent/nope'), + hil_report.write_report(UNWRITABLE, {'rows': [], 'scope': '', 'caveat': 'x\n'}) def test_the_guarded_callers_still_do_not_raise(self): """They run on an abort path, where a raise would replace the abort's own error.""" - bad = Path('/proc/nonexistent/nope') + bad = UNWRITABLE hil_report.mark_report_no_boards(bad, 'filters intersected to nothing') import io from contextlib import redirect_stdout @@ -294,8 +299,8 @@ class MarkdownIsAlwaysARenderingOfTheJson(unittest.TestCase): directly rather than inferred from the writers.""" def _check(self, rd): - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) - self.assertEqual((rd / hil_report.REPORT_MD).read_text(), + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) + self.assertEqual((rd / hil_report.REPORT_MD).read_text(encoding='utf-8'), hil_report.render_report(doc) + '\n') def test_normal_path(self): @@ -341,7 +346,7 @@ def test_writes_a_report_where_there_would_be_none(self): with TemporaryDirectory() as td: begin(Path(td)) hil_report.write_timeout_report(Path(td), [{'name': 'ra6m5_ek'}], CAVEAT) - md = (Path(td) / hil_report.REPORT_MD).read_text() + md = (Path(td) / hil_report.REPORT_MD).read_text(encoding='utf-8') self.assertIn(CAVEAT, md) self.assertIn('ra6m5_ek', md) @@ -356,9 +361,9 @@ def test_the_prior_attempts_rows_survive(self): [('done', 0, 0, [('done', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '') begin(rd) hil_report.write_timeout_report(rd, [{'name': 'stuck'}], CAVEAT) - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual([r['board'] for r in doc['rows']], ['done', 'stuck']) - md = (rd / hil_report.REPORT_MD).read_text() + md = (rd / hil_report.REPORT_MD).read_text(encoding='utf-8') self.assertIn('done', md) self.assertIn('stuck', md) self.assertIn('aborted', md) @@ -375,7 +380,7 @@ def test_timeout_report_writes_the_sidecar(self): begin(rd) hil_report.write_timeout_report(rd, [{'name': 'boardA'}], CAVEAT) self.assertTrue((rd / hil_report.REPORT_JSON).is_file()) - self.assertIn('boardA', (rd / hil_report.REPORT_JSON).read_text()) + self.assertIn('boardA', (rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) def test_the_sidecar_keeps_a_previous_attempts_rows(self): """An earlier attempt's finished boards are real results and this attempt has none @@ -385,20 +390,20 @@ def test_the_sidecar_keeps_a_previous_attempts_rows(self): rd = Path(td.name) (rd / hil_report.REPORT_JSON).write_text(json.dumps( {'rows': [{'board': 'done', 'cells': {'cdc_msc': 'pass'}, 'duration': '1s'}], - 'scope': '', 'caveat': ''})) + 'scope': '', 'caveat': ''}), encoding='utf-8') begin(rd) hil_report.write_timeout_report(rd, [{'name': 'stuck'}], CAVEAT) - rows = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows'] + rows = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows'] self.assertEqual([r['board'] for r in rows], ['done', 'stuck']) def test_a_torn_sidecar_does_not_lose_the_stuck_boards(self): td = TemporaryDirectory() self.addCleanup(td.cleanup) rd = Path(td.name) - (rd / hil_report.REPORT_JSON).write_text('{ truncated mid-') + (rd / hil_report.REPORT_JSON).write_text('{ truncated mid-', encoding='utf-8') begin(rd) hil_report.write_timeout_report(rd, [{'name': 'stuck'}], CAVEAT) - rows = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows'] + rows = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows'] self.assertEqual([r['board'] for r in rows], ['stuck']) def test_a_roster_entry_without_a_name_does_not_escape(self): @@ -409,12 +414,12 @@ def test_a_roster_entry_without_a_name_does_not_escape(self): rd = Path(td.name) begin(rd) hil_report.write_timeout_report(rd, [{}], CAVEAT) - rows = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows'] + rows = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows'] self.assertEqual([r['board'] for r in rows], ['?']) def test_unwritable_dir_does_not_raise(self): """The caller raises its abort right after; a raise here would replace it.""" - hil_report.write_timeout_report(Path('/proc/nonexistent/nope'), [], CAVEAT) + hil_report.write_timeout_report(UNWRITABLE, [], CAVEAT) class SummaryFoldsReportToBoards(unittest.TestCase): @@ -427,9 +432,9 @@ def _sum(self, boards, rows, cfg_boards=None): self.addCleanup(td.cleanup) d = Path(td.name) (d / 'hil_report.json').write_text(json.dumps( - {'rows': [{'board': b, 'cells': c, 'duration': '1s'} for b, c in rows]})) + {'rows': [{'board': b, 'cells': c, 'duration': '1s'} for b, c in rows]}), encoding='utf-8') cfg = d / 'cfg.json' - cfg.write_text(json.dumps({'boards': cfg_boards or [{'name': b} for b in boards]})) + cfg.write_text(json.dumps({'boards': cfg_boards or [{'name': b} for b in boards]}), encoding='utf-8') args = [a for b in boards for a in ('-b', b)] r = subprocess.run(['python3', str(Path(TEST_DIR).parents[0] / 'helper' / 'hil_report.py'), str(cfg), *args, '--report-dir', str(d)], @@ -585,7 +590,7 @@ def test_a_wedge_outranks_a_lock_cell(self): def _acc(self, d, mret, fresh): if not fresh: begin(d) - return json.loads((d / 'hil_report.json').read_text()) if hil_report.accumulate_report( + return json.loads((d / 'hil_report.json').read_text(encoding='utf-8')) if hil_report.accumulate_report( mret, d, fresh) is not None else None def test_an_accumulated_rerun_that_ran_clears_last_attempts_wedge(self): @@ -630,7 +635,7 @@ def test_a_build_refusal_keeps_the_earlier_cells_and_a_real_run_clears_it(self): [('b-fs', {'usbtest': 'pass'})]) self.assertEqual([(r['board'], r['history']) for r in doc['rows'] if not r['cells']], [('b', [{hil_report.LOCKED_CELL: 'fail'}])]) - self.assertNotIn('| b ', (d / hil_report.REPORT_MD).read_text()) + self.assertNotIn('| b ', (d / hil_report.REPORT_MD).read_text(encoding='utf-8')) r = hil_report.summarize(cfg, ['b'], doc)['results'][0] self.assertEqual((r['ran'], r['pass']), (True, True)) @@ -652,9 +657,9 @@ def test_the_caveat_reaches_the_agents_verdict(self): (d / 'hil_report.json').write_text(json.dumps( {'rows': [{'board': 'boardA', 'cells': {'cdc_msc': 'pass'}, 'duration': '1s'}], 'scope': '', - 'caveat': '**HIL run aborted: the worker pool would not shut down.**\n'})) + 'caveat': '**HIL run aborted: the worker pool would not shut down.**\n'}), encoding='utf-8') cfg = d / 'cfg.json' - cfg.write_text(json.dumps({'boards': [{'name': 'boardA'}]})) + cfg.write_text(json.dumps({'boards': [{'name': 'boardA'}]}), encoding='utf-8') r = subprocess.run( ['python3', str(Path(TEST_DIR).parents[0] / 'helper' / 'hil_report.py'), str(cfg), '-b', 'boardA', '--report-dir', str(d)], @@ -680,7 +685,7 @@ def test_an_unwritable_dir_reaches_the_callers_warning(self): from contextlib import redirect_stdout buf = io.StringIO() with redirect_stdout(buf): - hil_report.write_timeout_report(Path('/proc/nonexistent/nope'), + hil_report.write_timeout_report(UNWRITABLE, [{'name': 'b1'}], CAVEAT) self.assertIn('warning', buf.getvalue().lower(), 'the failure was silent') @@ -696,7 +701,7 @@ def test_a_stuck_board_with_a_prior_row_still_gets_the_cell(self): [('stm32f4', 0, 0, [('stm32f4', {'cdc_msc': 'OK'}, '1s')], 0)], rd, True, '', '') begin(rd) hil_report.write_timeout_report(rd, [{'name': 'stm32f4'}], CAVEAT) - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) verdict = hil_report.summarize({'boards': [{'name': 'stm32f4'}]}, ['stm32f4'], doc) self.assertFalse(verdict['results'][0]['pass'], 'a board that hung the pool was published as a pass') @@ -712,7 +717,7 @@ def test_a_clean_retry_clears_the_cell(self): begin(rd) hil_report.accumulate_report( [('stuck', 0, 0, [('stuck', {'cdc_msc': 'OK'}, '2s')], 0)], rd, False, '', '') - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertNotIn('pool-timeout', doc['rows'][0]['cells']) verdict = hil_report.summarize({'boards': [{'name': 'stuck'}]}, ['stuck'], doc) self.assertTrue(verdict['results'][0]['pass']) @@ -723,13 +728,13 @@ def test_a_torn_sidecar_does_not_destroy_an_intact_markdown(self): td = TemporaryDirectory() self.addCleanup(td.cleanup) rd = Path(td.name) - (rd / hil_report.REPORT_MD).write_text('| Board | t |\n| a | OK |\n| b | OK |\n') - (rd / hil_report.REPORT_JSON).write_text('{ truncated') + (rd / hil_report.REPORT_MD).write_text('| Board | t |\n| a | OK |\n| b | OK |\n', encoding='utf-8') + (rd / hil_report.REPORT_JSON).write_text('{ truncated', encoding='utf-8') begin(rd) hil_report.write_timeout_report(rd, [{'name': 'stuck'}], CAVEAT) - md = (rd / hil_report.REPORT_MD).read_text() + md = (rd / hil_report.REPORT_MD).read_text(encoding='utf-8') self.assertIn('| a | OK |', md, "an earlier attempt's real results were destroyed") - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual([r['board'] for r in doc['rows']], ['stuck']) @@ -753,7 +758,7 @@ def attempt(self, rows, fresh=False): return self.doc() def doc(self): - return json.loads((self.rd / hil_report.REPORT_JSON).read_text()) + return json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) def results(self, doc, boards): return {r['board']: r for r in hil_report.summarize(self.CFG, boards, doc)['results']} @@ -768,7 +773,7 @@ def test_each_changed_row_keeps_its_earlier_cells(self): {'usbtest': '❌ 28/30', 'cdc': 'pass'}]) self.assertNotIn('history', rows['b'], 'a re-run that changed nothing gained a history') bare = {**doc, 'rows': [{k: v for k, v in r.items() if k != 'history'} for r in doc['rows']]} - self.assertEqual((self.rd / hil_report.REPORT_MD).read_text(), hil_report.render_report(bare) + '\n', + self.assertEqual((self.rd / hil_report.REPORT_MD).read_text(encoding='utf-8'), hil_report.render_report(bare) + '\n', 'the history reached the markdown') res = self.results(doc, ['a', 'b']) self.assertTrue(res['a']['pass']) @@ -807,7 +812,7 @@ def test_a_variant_boards_board_name_row_keeps_its_lock(self): row = next(r for r in doc['rows'] if r['board'] == 'nano') self.assertEqual((row['cells'], row['history'], row['retired']), ({}, [{hil_report.LOCKED_CELL: 'fail'}], True)) - md = (self.rd / hil_report.REPORT_MD).read_text() + md = (self.rd / hil_report.REPORT_MD).read_text(encoding='utf-8') self.assertNotIn('| nano ', md) res = self.results(doc, ['nano'])['nano'] self.assertTrue(res['pass']) @@ -835,7 +840,7 @@ def test_an_emptied_variant_row_still_renders_and_keeps_its_history(self): rows = {r['board']: r for r in doc['rows']} self.assertEqual((rows['b-hs']['cells'], rows['b-hs']['history']), ({}, [{boundary: 'fail'}])) self.assertNotIn('retired', rows['b-hs']) - self.assertIn('| b-hs ', (self.rd / hil_report.REPORT_MD).read_text()) + self.assertIn('| b-hs ', (self.rd / hil_report.REPORT_MD).read_text(encoding='utf-8')) res = hil_report.summarize(cfg, ['b'], doc)['results'][0] self.assertEqual(res['detail'], '2 variant(s), 1 cell(s) ok') self.assertEqual(res['history'], {'b-hs': [{boundary: 'fail'}]}) @@ -849,7 +854,7 @@ def test_a_retired_row_that_gets_a_cell_again_renders(self): hil_report.write_timeout_report(self.rd, [{'name': 'nano'}], CAVEAT) row = next(r for r in self.doc()['rows'] if r['board'] == 'nano') self.assertNotIn('retired', row) - self.assertIn('| nano ', (self.rd / hil_report.REPORT_MD).read_text()) + self.assertIn('| nano ', (self.rd / hil_report.REPORT_MD).read_text(encoding='utf-8')) def test_a_fresh_run_starts_without_history(self): self.attempt([('a', {'cdc': 'fail'})], fresh=True) @@ -868,7 +873,7 @@ def test_a_malformed_history_is_dropped_not_fatal(self): (self.rd / hil_report.REPORT_JSON).write_text(json.dumps({'rows': [ {'board': 'a', 'cells': {'cdc': 'pass'}, 'history': [{'cdc': 'fail', 'x': 3}, 'no', None]}, {'board': 'b', 'cells': {'cdc': 'pass'}, 'history': {'cdc': ['fail']}, 'retired': True}, - {'board': 'c', 'cells': {}, 'retired': 'yes'}]})) + {'board': 'c', 'cells': {}, 'retired': 'yes'}]}), encoding='utf-8') doc, _ = hil_report._load(self.rd) self.assertEqual(doc['rows'][0]['history'], [{'cdc': 'fail'}]) self.assertNotIn('history', doc['rows'][1]) @@ -974,10 +979,10 @@ def test_it_does_not_wipe_an_accumulated_sidecar(self): begin(rd) hil_report.mark_report_no_boards(rd, 'No boards left after the flasher filter', fresh=False) - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual([r['board'] for r in doc['rows']], ['a']) self.assertIn('selected no boards', doc['caveat']) - self.assertIn('selected no boards', (rd / hil_report.REPORT_MD).read_text()) + self.assertIn('selected no boards', (rd / hil_report.REPORT_MD).read_text(encoding='utf-8')) class TheMergeBehavioursAreActuallyPinned(unittest.TestCase): @@ -994,7 +999,7 @@ def test_a_cleared_boundary_drops_the_previous_attempts_mark(self): begin(rd) hil_report.accumulate_report( [('b', 0, 0, [('b-v', {'cdc_msc': 'OK'}, '2s')], 0)], rd, False, '', '') - cells = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows'][0]['cells'] + cells = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows'][0]['cells'] self.assertNotIn(hil_report.BOUNDARY_CELL, cells) def test_a_board_that_really_ran_drops_its_stale_lock_cell(self): @@ -1008,7 +1013,7 @@ def test_a_board_that_really_ran_drops_its_stale_lock_cell(self): begin(rd) hil_report.accumulate_report( [('b', 0, 0, [('b', {'cdc_msc': 'OK'}, '2s')], 0)], rd, False, '', '') - rows = json.loads((rd / hil_report.REPORT_JSON).read_text())['rows'] + rows = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows'] self.assertEqual([r['board'] for r in rows], ['b']) self.assertNotIn(key, rows[0]['cells']) @@ -1024,7 +1029,7 @@ def test_a_lock_refusal_replaces_the_other_kind(self): begin(rd) md = hil_report.accumulate_report( [('b', 1, [], [('b', {key: val}, None)], None)], rd, fresh, '', '') - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual(doc['rows'][0]['cells'], {second[0]: second[1]}) self.assertIn('❌ 1 failed', md) got = hil_report.summarize({'boards': [{'name': 'b'}]}, ['b'], doc)['results'][0] @@ -1042,7 +1047,7 @@ def test_a_filtered_rerun_keeps_the_previous_duration(self): hil_report.accumulate_report( [('b', 0, 0, [('b', {'cdc_msc': 'OK'}, None)], 0)], rd, False, '', '') self.assertEqual(json.loads( - (rd / hil_report.REPORT_JSON).read_text())['rows'][0]['duration'], '119s') + (rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows'][0]['duration'], '119s') class TheFooterCountsAreNotSwapped(unittest.TestCase): @@ -1073,7 +1078,7 @@ def test_a_clean_retry_drops_the_previous_abort_notice(self): begin(rd) hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '2s')], 0)], rd, False, '', '') - self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text())['caveat'], '') + self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['caveat'], '') class AMalformedSidecarNeverCostsTheReport(unittest.TestCase): @@ -1081,7 +1086,7 @@ class AMalformedSidecarNeverCostsTheReport(unittest.TestCase): reachable from outside the harness.""" def _write(self, rd, doc): - (rd / hil_report.REPORT_JSON).write_text(json.dumps(doc)) + (rd / hil_report.REPORT_JSON).write_text(json.dumps(doc), encoding='utf-8') def test_a_null_text_field_does_not_kill_a_successful_run(self): td = TemporaryDirectory() @@ -1102,7 +1107,7 @@ def test_a_null_cells_row_still_gets_its_pool_timeout_cell(self): 'caveat': '', 'scope': ''}) begin(rd) hil_report.write_timeout_report(rd, [{'name': 'boardA'}], CAVEAT) - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) v = hil_report.summarize({'boards': [{'name': 'boardA'}]}, ['boardA'], doc) self.assertFalse(v['results'][0]['pass'], 'a board that ate the whole pool guard was published as a pass') @@ -1153,7 +1158,7 @@ def test_a_fresh_run_does_not_republish_the_previous_rows(self): hil_report.accumulate_report([('a', 0, 0, [('a', {'t': 'OK'}, '1s')], 0)], rd, True, '', '') hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=True) - self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text())['rows'], []) + self.assertEqual(json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows'], []) def test_an_accumulate_run_keeps_them(self): td = TemporaryDirectory() @@ -1164,7 +1169,7 @@ def test_an_accumulate_run_keeps_them(self): begin(rd) hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=False) self.assertEqual([r['board'] for r in json.loads( - (rd / hil_report.REPORT_JSON).read_text())['rows']], ['a']) + (rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows']], ['a']) def test_it_does_not_overwrite_an_abort_notice(self): td = TemporaryDirectory() @@ -1177,9 +1182,10 @@ def test_it_does_not_overwrite_an_abort_notice(self): begin(rd) hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=False) self.assertIn('aborted', - json.loads((rd / hil_report.REPORT_JSON).read_text())['caveat']) + json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['caveat']) +@DRIVES_HIL_TEST class TheNoBoardsCallSiteIsWired(unittest.TestCase): """The fresh/accumulate branches of mark_report_no_boards were tested by calling it DIRECTLY, so both passed while hil_test.py's one real call site never passed the flag @@ -1192,17 +1198,17 @@ def _run(self, *extra): self.addCleanup(td.cleanup) rd = Path(td.name) (rd / 'cfg.json').write_text(json.dumps( - {'boards': [{'name': 'alpha', 'uid': '1', 'flasher': {'name': 'jlink', 'uid': '2'}}]})) + {'boards': [{'name': 'alpha', 'uid': '1', 'flasher': {'name': 'jlink', 'uid': '2'}}]}), encoding='utf-8') (rd / hil_report.REPORT_JSON).write_text(json.dumps( {'rows': [{'board': 'earlier', 'cells': {'t': 'pass'}, 'duration': '1s'}], - 'scope': '', 'caveat': ''})) + 'scope': '', 'caveat': ''}), encoding='utf-8') r = subprocess.run( [sys.executable, str(Path(HIL_DIR) / 'hil_test.py'), '--flasher', 'nonexistent', *extra, str(rd / 'cfg.json')], capture_output=True, text=True, timeout=120, env={**os.environ, 'HIL_REPORT_DIR': str(rd)}) self.assertEqual(r.returncode, 1, r.stdout + r.stderr) - return json.loads((rd / hil_report.REPORT_JSON).read_text()) + return json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) def test_an_accumulate_run_keeps_the_accumulated_rows(self): doc = self._run('--accumulate') @@ -1226,12 +1232,12 @@ def test_accumulate_report_does_not_commit_json_then_fail_to_render(self): rd = Path(td.name) (rd / hil_report.REPORT_JSON).write_text(json.dumps( {'rows': [{'board': 'boardA', 'cells': {'t': 'pass'}, 'duration': 119.0}], - 'caveat': '', 'scope': ''})) + 'caveat': '', 'scope': ''}), encoding='utf-8') begin(rd) hil_report.accumulate_report([('boardB', 0, 0, [('boardB', {'t': 'OK'}, '1s')], 0)], rd, False, '', '') - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) - self.assertEqual((rd / hil_report.REPORT_MD).read_text(), + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) + self.assertEqual((rd / hil_report.REPORT_MD).read_text(encoding='utf-8'), hil_report.render_report(doc) + '\n') @@ -1242,10 +1248,10 @@ def test_an_absent_sidecar_keeps_the_prior_table(self): td = TemporaryDirectory() self.addCleanup(td.cleanup) rd = Path(td.name) - (rd / hil_report.REPORT_MD).write_text('| Board | t |\n| a | OK |\n| b | OK |\n') + (rd / hil_report.REPORT_MD).write_text('| Board | t |\n| a | OK |\n| b | OK |\n', encoding='utf-8') begin(rd) hil_report.write_timeout_report(rd, [{'name': 'stuck'}], CAVEAT) - self.assertIn('| a | OK |', (rd / hil_report.REPORT_MD).read_text()) + self.assertIn('| a | OK |', (rd / hil_report.REPORT_MD).read_text(encoding='utf-8')) class LoadIsTheOnlyTrustBoundary(unittest.TestCase): @@ -1254,7 +1260,7 @@ class LoadIsTheOnlyTrustBoundary(unittest.TestCase): def _seed(self, rd, raw): (rd / hil_report.REPORT_JSON).write_text(raw if isinstance(raw, str) - else json.dumps(raw)) + else json.dumps(raw), encoding='utf-8') def test_a_non_list_rows_does_not_raise(self): td = TemporaryDirectory() @@ -1279,7 +1285,7 @@ def test_an_unhashable_cell_value_does_not_raise(self): hil_report.accumulate_report([('b', 0, 0, [('b', {'t': 'OK'}, '1s')], 0)], rd, False, '', '') cells = {r['board']: r['cells'] - for r in json.loads((rd / hil_report.REPORT_JSON).read_text())['rows']} + for r in json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows']} self.assertNotIn('t', cells['a'], 'a corrupt cell must drop, not become a pass') self.assertIn('u', cells['a']) @@ -1288,7 +1294,7 @@ def test_summarize_survives_a_malformed_sidecar_via_load(self): td = TemporaryDirectory() self.addCleanup(td.cleanup) rd = Path(td.name) - (rd / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'a'}]})) + (rd / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'a'}]}), encoding='utf-8') self._seed(rd, {'rows': [{'board': 1, 'cells': 'notadict'}, {'board': 'a', 'cells': {'t': 'pass'}}], 'caveat': '', 'scope': ''}) @@ -1310,7 +1316,7 @@ def test_a_fresh_run_carries_nothing_from_the_prior_sidecar(self): hil_report.accumulate_report([('old', 0, 0, [('old', {'t': 'OK'}, '1s')], 0)], rd, True, '3 board(s) — a, b, c') hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=True) - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual(doc['rows'], []) self.assertEqual(doc['scope'], '', 'a stale scope note was republished') @@ -1322,7 +1328,7 @@ def test_an_accumulate_run_keeps_rows_and_scope(self): rd, True, '3 board(s) — a, b, c') begin(rd) hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=False) - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual(doc['scope'], '3 board(s) — a, b, c') self.assertEqual([r['board'] for r in doc['rows']], ['old']) @@ -1337,7 +1343,7 @@ def test_a_fresh_run_is_not_blocked_by_a_prior_abort(self): begin(rd) hil_report.write_timeout_report(rd, [], CAVEAT) hil_report.mark_report_no_boards(rd, 'filters emptied', fresh=True) - doc = json.loads((rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual(doc['rows'], []) self.assertIn('selected no boards', doc['caveat']) @@ -1380,7 +1386,7 @@ def test_the_sidecar_is_the_native_document_plus_hil(self): doc = {'rows': [{'board': 'b', 'cells': {'device/cdc_msc': 'pass'}, 'duration': '1s'}], 'scope': '1 board(s) — b', 'caveat': ''} hil_report.write_report(self.rd, doc) - text = (self.rd / hil_report.REPORT_JSON).read_text() + text = (self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8') published = json.loads(text) meta = published.pop('_hil') self.assertEqual(published, doc) @@ -1392,12 +1398,12 @@ def test_the_sidecar_is_the_native_document_plus_hil(self): def test_the_cli_output_is_that_of_the_native_document(self): cfg = {'boards': [{'name': 'b'}, {'name': 'c', 'variant': [{'name': 'c-x'}]}]} - (self.rd / 'cfg.json').write_text(json.dumps(cfg)) + (self.rd / 'cfg.json').write_text(json.dumps(cfg), encoding='utf-8') doc = {'rows': [{'board': 'b', 'cells': {'t': 'pass'}, 'duration': '1s'}, {'board': 'c-x', 'cells': {'t': '❌ 3/30', 'u': 'skip'}, 'duration': None}], 'scope': '', 'caveat': ''} out = [] - for write in (lambda: (self.rd / hil_report.REPORT_JSON).write_text(json.dumps(doc)), + for write in (lambda: (self.rd / hil_report.REPORT_JSON).write_text(json.dumps(doc), encoding='utf-8'), lambda: hil_report.write_report(self.rd, doc)): write() r = _cli(str(self.rd / 'cfg.json'), '--report-dir', str(self.rd)) @@ -1406,34 +1412,34 @@ def test_the_cli_output_is_that_of_the_native_document(self): self.assertEqual(out[0], out[1]) def test_an_unknown_schema_or_version_is_never_read_or_replaced(self): - (self.rd / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'b'}]})) + (self.rd / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'b'}]}), encoding='utf-8') future = json.dumps({'rows': [{'board': 'b', 'cells': {'t': 'pass'}}], 'scope': '', 'caveat': '', '_hil': {'version': 2}}) - (self.rd / hil_report.REPORT_JSON).write_text(future) + (self.rd / hil_report.REPORT_JSON).write_text(future, encoding='utf-8') r = _cli(str(self.rd / 'cfg.json'), '--report-dir', str(self.rd)) self.assertEqual((r.returncode, r.stdout), (1, '')) self.assertIn('version 2', r.stderr) with self.assertRaises(hil_report.core().ReportError): hil_report.start_attempt(self.rd, True, self.rd / 'cfg.json', ['b']) hil_report.mark_report_no_boards(self.rd, 'filters emptied') - self.assertEqual((self.rd / hil_report.REPORT_JSON).read_text(), future) + self.assertEqual((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'), future) def test_a_corrupt_hil_document_yields_no_rows(self): - (self.rd / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'b'}]})) + (self.rd / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'b'}]}), encoding='utf-8') (self.rd / hil_report.REPORT_JSON).write_text(json.dumps( {'rows': [{'board': 'b', 'cells': {'t': 'pass'}}], 'scope': '', 'caveat': '', - '_hil': {'version': 1, 'body_schema': hil_report.SCHEMA_NAME, 'body_version': 1}})) + '_hil': {'version': 1, 'body_schema': hil_report.SCHEMA_NAME, 'body_version': 1}}), encoding='utf-8') r = _cli(str(self.rd / 'cfg.json'), '--report-dir', str(self.rd)) self.assertEqual(r.returncode, 0, r.stderr) self.assertEqual(json.loads(r.stdout)['results'][0]['detail'], 'no report row for this board') def test_an_unreadable_sidecars_markdown_is_kept_as_labelled_legacy_text(self): - (self.rd / hil_report.REPORT_MD).write_text('| Board | t |\n| a | OK |\n', encoding='utf-8') - (self.rd / hil_report.REPORT_JSON).write_text('{ truncated') + (self.rd / hil_report.REPORT_MD).write_text('| Board | t |\n| a | OK |\n', encoding='utf-8', newline='\n') + (self.rd / hil_report.REPORT_JSON).write_text('{ truncated', encoding='utf-8') begin(self.rd) hil_report.write_timeout_report(self.rd, [{'name': 'stuck'}], CAVEAT) md = (self.rd / hil_report.REPORT_MD).read_text(encoding='utf-8') - doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertTrue(md.startswith(hil_report.render_report(doc) + '\n'), 'the table first, as rendered') self.assertIn('_Legacy text:', md) self.assertTrue(md.endswith('| Board | t |\n| a | OK |\n')) @@ -1449,14 +1455,14 @@ def setUp(self): self.addCleanup(td.cleanup) self.rd = Path(td.name) self.cfg = self.rd / 'cfg.json' - self.cfg.write_text(json.dumps({'boards': [{'name': 'alpha', 'uid': '1', 'flasher': {'name': 'jlink', 'uid': '2'}}]})) + self.cfg.write_text(json.dumps({'boards': [{'name': 'alpha', 'uid': '1', 'flasher': {'name': 'jlink', 'uid': '2'}}]}), encoding='utf-8') def run_once(self, accumulate, cell): hil_report.start_attempt(self.rd, accumulate, self.cfg, ['b'], invocation=['hil_test.py']) return hil_report.accumulate_report([('b', 0, 0, [('b', {'t': cell}, '1s')], 0)], self.rd, not accumulate) def meta(self): - return json.loads((self.rd / hil_report.REPORT_JSON).read_text())['_hil'] + return json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['_hil'] def test_a_fresh_run_archives_the_previous_and_an_accumulated_one_links_it(self): self.run_once(False, 'fail') @@ -1468,7 +1474,7 @@ def test_a_fresh_run_archives_the_previous_and_an_accumulated_one_links_it(self) second = self.meta() self.assertEqual(second['previous_attempt'], first['attempt_id']) archived = self.rd / hil_report.REPORT_ATTEMPTS / first['attempt_id'] - self.assertEqual(json.loads((archived / hil_report.REPORT_JSON).read_text())['_hil'], first) + self.assertEqual(json.loads((archived / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['_hil'], first) self.run_once(False, 'pass') self.assertIsNone(self.meta()['previous_attempt']) self.assertEqual(len(list((self.rd / hil_report.REPORT_ATTEMPTS).iterdir())), 2) @@ -1481,13 +1487,14 @@ def test_repeated_writes_inside_one_attempt_are_one_attempt(self): self.assertEqual(self.meta()['termination'], 'timeout') def test_a_legacy_report_is_archived_and_linked(self): - (self.rd / hil_report.REPORT_JSON).write_text(json.dumps({'rows': [], 'scope': '', 'caveat': ''})) + (self.rd / hil_report.REPORT_JSON).write_text(json.dumps({'rows': [], 'scope': '', 'caveat': ''}), encoding='utf-8') self.run_once(True, 'pass') self.assertTrue(self.meta()['previous_attempt'].startswith('legacy-')) + @DRIVES_HIL_TEST def test_hil_test_begins_its_attempt_before_the_no_boards_notice(self): (self.rd / hil_report.REPORT_JSON).write_text(json.dumps( - {'rows': [{'board': 'earlier', 'cells': {'t': 'pass'}, 'duration': '1s'}], 'scope': '', 'caveat': ''})) + {'rows': [{'board': 'earlier', 'cells': {'t': 'pass'}, 'duration': '1s'}], 'scope': '', 'caveat': ''}), encoding='utf-8') r = subprocess.run([sys.executable, str(Path(HIL_DIR) / 'hil_test.py'), '--flasher', 'nonexistent', '--accumulate', str(self.cfg)], capture_output=True, text=True, timeout=120, env={**os.environ, 'HIL_REPORT_DIR': str(self.rd)}) @@ -1496,29 +1503,32 @@ def test_hil_test_begins_its_attempt_before_the_no_boards_notice(self): self.assertEqual((meta['termination'], meta['selection']), ('no-boards', [])) self.assertTrue((self.rd / hil_report.REPORT_ATTEMPTS / meta['previous_attempt']).is_dir()) + @DRIVES_HIL_TEST def test_a_config_error_aborts_the_boards_it_selected(self): self.cfg.write_text(json.dumps({'boards': [{'name': 'alpha', 'uid': '1', 'logger': 'bogus', - 'flasher': {'name': 'jlink', 'uid': '2'}}]})) + 'flasher': {'name': 'jlink', 'uid': '2'}}]}), encoding='utf-8') r = subprocess.run([sys.executable, str(Path(HIL_DIR) / 'hil_test.py'), str(self.cfg)], capture_output=True, text=True, timeout=120, env={**os.environ, 'HIL_REPORT_DIR': str(self.rd)}) self.assertEqual(r.returncode, 1, r.stdout + r.stderr) - doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual((doc['_hil']['termination'], doc['_hil']['selection']), ('aborted', ['alpha'])) self.assertTrue(doc['caveat'].startswith('**HIL run aborted: config error: unknown "logger" value'), doc['caveat']) seq = hil_report.sequence(self.cfg, ['alpha'], self.rd) self.assertEqual(seq['current']['rows'][0]['verdict'], 'blocker', 'never not-run: boards were selected') + @DRIVES_HIL_TEST def test_a_board_the_registry_refuses_aborts_over_a_passing_report(self): (self.rd / hil_report.REPORT_JSON).write_text(json.dumps( - {'rows': [{'board': 'alpha', 'cells': {'t': 'pass'}, 'duration': '1s'}], 'scope': '', 'caveat': ''})) - self.cfg.write_text(json.dumps({'boards': [{'name': 'alpha', 'uid': 'A', 'probe': 'not-in-the-registry'}]})) + {'rows': [{'board': 'alpha', 'cells': {'t': 'pass'}, 'duration': '1s'}], 'scope': '', 'caveat': ''}), encoding='utf-8') + self.cfg.write_text(json.dumps({'boards': [{'name': 'alpha', 'uid': 'A', 'probe': 'not-in-the-registry'}]}), encoding='utf-8') r = subprocess.run([sys.executable, str(Path(HIL_DIR) / 'hil_test.py'), str(self.cfg)], capture_output=True, text=True, timeout=120, env={**os.environ, 'HIL_REPORT_DIR': str(self.rd)}) self.assertEqual(r.returncode, 1, r.stdout + r.stderr) - doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual((doc['_hil']['termination'], doc['_hil']['selection']), ('aborted', ['alpha'])) self.assertIn("probe 'not-in-the-registry' is not in agentrc's board registry", doc['caveat']) + @DRIVES_HIL_TEST def test_an_incomplete_sysview_entry_aborts_before_a_flash(self): """capture_result reads example, workload and duration_s unchecked: a gap would lose the board's record.""" good = {'example': 'device/cdc_msc', 'workload': 'cdc_burst', 'duration_s': 15} @@ -1539,31 +1549,32 @@ def test_an_incomplete_sysview_entry_aborts_before_a_flash(self): # json.dumps writes NaN and Infinity, as json.load reads them self.cfg.write_text(json.dumps({'boards': [ {'name': name, 'uid': str(i), 'sysview': sv, 'flasher': {'name': 'jlink', 'uid': f'f{i}'}} - for i, (name, sv) in enumerate(zip(('alpha', 'beta', 'gamma'), entries))]})) + for i, (name, sv) in enumerate(zip(('alpha', 'beta', 'gamma'), entries))]}), encoding='utf-8') r = subprocess.run([sys.executable, str(Path(HIL_DIR) / 'hil_test.py'), str(self.cfg)], capture_output=True, text=True, timeout=120, env={**os.environ, 'HIL_REPORT_DIR': str(self.rd)}) self.assertEqual(r.returncode, 1, r.stdout + r.stderr) self.assertIn(f'ERROR: "sysview" entry refused: {why}', r.stdout) self.assertNotIn('Traceback', r.stdout + r.stderr) - doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual(doc['_hil']['termination'], 'aborted') # a complete entry passes on to the next check self.cfg.write_text(json.dumps({'boards': [{'name': 'alpha', 'uid': '1', 'sysview': good, 'logger': 'bogus', - 'flasher': {'name': 'jlink', 'uid': '2'}}]})) + 'flasher': {'name': 'jlink', 'uid': '2'}}]}), encoding='utf-8') r = subprocess.run([sys.executable, str(Path(HIL_DIR) / 'hil_test.py'), str(self.cfg)], capture_output=True, text=True, timeout=120, env={**os.environ, 'HIL_REPORT_DIR': str(self.rd)}) self.assertIn('ERROR: unknown "logger" value', r.stdout, r.stdout + r.stderr) + @DRIVES_HIL_TEST def test_hil_test_refuses_to_start_over_a_report_it_cannot_read(self): future = json.dumps({'rows': [], 'scope': '', 'caveat': '', '_hil': {'version': 2}}) - (self.rd / hil_report.REPORT_JSON).write_text(future) + (self.rd / hil_report.REPORT_JSON).write_text(future, encoding='utf-8') r = subprocess.run([sys.executable, str(Path(HIL_DIR) / 'hil_test.py'), '--flasher', 'nonexistent', str(self.cfg)], capture_output=True, text=True, timeout=120, env={**os.environ, 'HIL_REPORT_DIR': str(self.rd)}) self.assertEqual(r.returncode, 1, r.stdout + r.stderr) self.assertIn('cannot begin the report', r.stdout) - self.assertEqual((self.rd / hil_report.REPORT_JSON).read_text(), future) + self.assertEqual((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'), future) class ConsumerCommands(unittest.TestCase): @@ -1578,7 +1589,7 @@ def setUp(self): def test_prepare_renders_a_stale_markdown_again(self): doc = {'rows': [{'board': 'b', 'cells': {'t': 'pass'}, 'duration': '1s'}], 'scope': '', 'caveat': ''} hil_report.write_report(self.rd, doc) - (self.rd / hil_report.REPORT_MD).write_text('an earlier table') + (self.rd / hil_report.REPORT_MD).write_text('an earlier table', encoding='utf-8') r = _cli('prepare', '--report-dir', str(self.rd)) self.assertEqual(r.returncode, 0, r.stderr) self.assertIn('hil_report.json: valid', r.stdout) @@ -1588,7 +1599,7 @@ def test_prepare_renders_a_stale_markdown_again(self): def test_prepare_leaves_the_markdown_to_an_active_writer(self): import fcntl (self.rd / hil_report.REPORT_MD).write_text('| b | ✅ |\n', encoding='utf-8') - with open(self.rd / '.hil_report.lock', 'a') as held: + with open(self.rd / '.hil_report.lock', 'a', encoding='utf-8') as held: fcntl.flock(held, fcntl.LOCK_EX) r = _cli('prepare', '--report-dir', str(self.rd)) self.assertEqual(r.returncode, 1) @@ -1605,7 +1616,7 @@ def test_prepare_never_leaves_a_table_without_its_sidecar(self): def test_no_selection_writes_a_fresh_report_the_reader_checks(self): cfg = self.rd / 'tinyusb.json' - cfg.write_text(json.dumps({'boards': [{'name': 'b'}]})) + cfg.write_text(json.dumps({'boards': [{'name': 'b'}]}), encoding='utf-8') hil_report.write_report(self.rd, {'rows': [{'board': 'b', 'cells': {'t': 'pass'}, 'duration': '1s'}], 'scope': '', 'caveat': ''}) reason = '_Skipped by PR selection: no affected boards on this rig._' @@ -1614,7 +1625,7 @@ def test_no_selection_writes_a_fresh_report_the_reader_checks(self): reading = hil_report.core().read_document(self.rd / hil_report.REPORT_JSON, hil_report.schema()) self.assertEqual((reading.outcome, reading.body['caveat'], reading.meta['termination'], reading.meta['selection']), ('empty', reason + '\n', 'no-boards', [])) - self.assertEqual((self.rd / hil_report.REPORT_MD).read_text(), f'{reason}\n\nNo tests were run.\n') + self.assertEqual((self.rd / hil_report.REPORT_MD).read_text(encoding='utf-8'), f'{reason}\n\nNo tests were run.\n') self.assertEqual(len(list((self.rd / hil_report.REPORT_ATTEMPTS).iterdir())), 1, 'the earlier report is kept') verdict = json.loads(_cli(str(cfg), '--report-dir', str(self.rd)).stdout) self.assertFalse(verdict['pass']) @@ -1626,7 +1637,7 @@ def test_without_agentrc_the_commands_refuse_and_board_variants_still_imports(se for rel in ('test/hil/helper/hil_report.py', 'test/hil/helper/agentrc.py', 'test/hil/agentrc.json'): (clean / rel).parent.mkdir(parents=True, exist_ok=True) (clean / rel).write_bytes((REPO / rel).read_bytes()) - (clean / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'b'}]})) + (clean / 'cfg.json').write_text(json.dumps({'boards': [{'name': 'b'}]}), encoding='utf-8') script = str(clean / 'test/hil/helper/hil_report.py') (clean / hil_report.REPORT_MD).write_text('| b | ✅ |\n', encoding='utf-8') for ci, remedy in ((None, agentrc.INSTALL), ('true', agentrc.FETCH)): @@ -1669,7 +1680,7 @@ def prepare(): env=scratch_env(broken, PYTHONSAFEPATH='1')) if os.name != 'nt': import fcntl - with open(clean / '.hil_report.lock', 'a') as held: + with open(clean / '.hil_report.lock', 'a', encoding='utf-8') as held: fcntl.flock(held, fcntl.LOCK_EX) r = prepare() self.assertEqual(r.returncode, 1, r.stdout + r.stderr) @@ -1689,7 +1700,7 @@ def prepare(): def test_the_cli_runs_as_a_script_under_pythonsafepath(self): cfg = self.rd / 'cfg.json' - cfg.write_text(json.dumps({'boards': [{'name': 'b'}]})) + cfg.write_text(json.dumps({'boards': [{'name': 'b'}]}), encoding='utf-8') hil_report.write_report(self.rd, {'rows': [{'board': 'b', 'cells': {'t': 'pass'}, 'duration': '1s'}], 'scope': '', 'caveat': ''}) r = _cli(str(cfg), '--report-dir', str(self.rd), env={**os.environ, 'PYTHONSAFEPATH': '1'}) @@ -1707,7 +1718,7 @@ def setUp(self): self.addCleanup(td.cleanup) self.rd = Path(td.name) self.cfg = self.rd / 'cfg.json' - self.cfg.write_text(json.dumps({'boards': [{'name': 'a'}]})) + self.cfg.write_text(json.dumps({'boards': [{'name': 'a'}]}), encoding='utf-8') self.attempt(False, '✅') self.first = self.meta()['attempt_id'] self.second = hil_report.start_attempt(self.rd, True, self.cfg, ['a']).attempt_id @@ -1718,7 +1729,7 @@ def attempt(self, accumulate, cell): hil_report.accumulate_report([('a', 0, 0, [('a', {'t': cell}, '1s')], 0)], self.rd, not accumulate) def meta(self): - return json.loads((self.rd / hil_report.REPORT_JSON).read_text())['_hil'] + return json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['_hil'] def reading(self): return self.c.read_document(self.rd / hil_report.REPORT_JSON, hil_report.schema()) @@ -1746,7 +1757,7 @@ def test_the_next_attempt_links_the_withdrawal_and_merges_the_report_it_withdrew self.abort(publish=self.refuse) self.attempt(True, '✅') self.assertEqual(self.meta()['previous_attempt'], f'withdrawn-{self.second}') - self.assertEqual([r['board'] for r in json.loads((self.rd / hil_report.REPORT_JSON).read_text())['rows']], ['a']) + self.assertEqual([r['board'] for r in json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows']], ['a']) seq = hil_report.sequence(self.cfg, ['a'], self.rd)['sequence'] self.assertEqual(seq['rows'][0]['verdict'], 'blocker', 'an attempt without a report blocks the sequence') @@ -1755,12 +1766,12 @@ def test_a_json_published_before_its_markdown_failed_is_kept(self): def markdown_fails(report_dir, schema, body, meta): jpath, mpath = real(report_dir, schema, body, meta) - mpath.write_text('a stale table') + mpath.write_text('a stale table', encoding='utf-8') raise self.c.ReportError('markdown', 'not replaced', published=(str(jpath),)) self.abort(publish=markdown_fails) meta = self.meta() self.assertEqual((meta['attempt_id'], meta['termination']), (self.second, 'timeout')) - doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual((self.rd / hil_report.REPORT_MD).read_text(encoding='utf-8'), hil_report.render_report(doc) + '\n') @@ -1770,7 +1781,7 @@ def test_a_one_shot_write_whose_markdown_failed_is_repaired_as_its_own_attempt(s def markdown_fails(report_dir, schema, body, meta): jpath, mpath = real(report_dir, schema, body, meta) - mpath.write_text('a stale table') + mpath.write_text('a stale table', encoding='utf-8') failed.append(self.c.ReportError('markdown', 'not replaced', published=(str(jpath),))) raise failed[-1] hil_report._runs.clear() @@ -1779,7 +1790,7 @@ def markdown_fails(report_dir, schema, body, meta): self.assertEqual(out.getvalue().count('warning:'), 1, 'only the failed write itself: ' + out.getvalue()) one_shot = self.meta()['attempt_id'] self.assertNotIn(one_shot, (self.first, self.second)) - doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text()) + doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) self.assertEqual((self.rd / hil_report.REPORT_MD).read_text(encoding='utf-8'), hil_report.render_report(doc) + '\n') self.assertEqual(hil_report._runs, {}, 'a one-shot attempt is not this dir\'s run') @@ -1795,7 +1806,7 @@ def test_two_withdrawals_then_a_subset_retry_keep_every_row(self): self.abort(publish=self.refuse) self.assertEqual(self.reading().outcome, 'withdrawn') self.attempt(True, '✅') - self.assertEqual(sorted(r['board'] for r in json.loads((self.rd / hil_report.REPORT_JSON).read_text())['rows']), + self.assertEqual(sorted(r['board'] for r in json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['rows']), ['a', 'b'], 'the second withdrawal names the first, a sibling archive') def test_a_cycle_of_withdrawal_references_ends(self): @@ -1804,7 +1815,7 @@ def test_a_cycle_of_withdrawal_references_ends(self): (root / aid).mkdir(parents=True) (root / aid / hil_report.REPORT_JSON).write_text(json.dumps({hil_report.core().WITHDRAWN: { 'version': 1, 'body_schema': hil_report.SCHEMA_NAME, 'attempt_id': aid[len('withdrawn-'):], - 'archived': other, 'reason': 'r'}})) + 'archived': other, 'reason': 'r'}}), encoding='utf-8') (self.rd / hil_report.REPORT_JSON).write_bytes((root / 'withdrawn-x' / hil_report.REPORT_JSON).read_bytes()) self.assertEqual(hil_report._load(self.rd)[0]['rows'], []) @@ -1813,13 +1824,13 @@ def test_a_malformed_withdrawal_is_never_read_as_rows(self): 'rows': [{'board': 'a', 'cells': {'t': 'pass'}}], 'scope': '', 'caveat': ''}) archived = self.rd / hil_report.REPORT_ATTEMPTS / 'withdrawn-x' archived.mkdir(parents=True) - (archived / hil_report.REPORT_JSON).write_text(malformed) + (archived / hil_report.REPORT_JSON).write_text(malformed, encoding='utf-8') through = json.dumps({hil_report.core().WITHDRAWN: { 'version': 1, 'body_schema': hil_report.SCHEMA_NAME, 'attempt_id': 'y', 'archived': 'withdrawn-x', 'reason': 'r'}}) for name, current in (('current', malformed), ('through an archive', through)): with self.subTest(name): - (self.rd / hil_report.REPORT_JSON).write_text(current) + (self.rd / hil_report.REPORT_JSON).write_text(current, encoding='utf-8') self.assertEqual(hil_report._load(self.rd), ({'rows': [], 'scope': '', 'caveat': ''}, False)) def test_a_failed_no_selection_write_withdraws_before_the_upload_prepares(self): @@ -1853,7 +1864,7 @@ def setUp(self): self.rd = Path(td.name) self.cfg = self.rd / 'cfg.json' self.cfg.write_text(json.dumps({'boards': [{'name': 'a', 'variant': [{'name': 'a-fs'}, {'name': 'a-hs'}]}, - {'name': 'b'}, {'name': 'c'}]})) + {'name': 'b'}, {'name': 'c'}]}), encoding='utf-8') def attempt(self, accumulate, results): """results {board: {row: cells}}: one hil_test.py run selecting those boards.""" @@ -1863,7 +1874,7 @@ def attempt(self, accumulate, results): return self.meta()['attempt_id'] def meta(self): - return json.loads((self.rd / hil_report.REPORT_JSON).read_text())['_hil'] + return json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8'))['_hil'] def verdicts(self, boards=('a', 'b', 'c')): seq = hil_report.sequence(self.cfg, list(boards), self.rd)['sequence'] @@ -1889,7 +1900,7 @@ def test_a_retry_after_a_locked_peer_is_not_eligible(self): def test_a_recovery_blocker_stands_and_bars_the_retry(self): self.attempt(False, {'a': self.FAIL_A, 'b': {'b': self.BLOCKED}}) - cfg = json.loads(self.cfg.read_text()) + cfg = json.loads(self.cfg.read_text(encoding='utf-8')) doc = hil_report._load(self.rd)[0] self.assertEqual([r['verdict'] for r in hil_report.normalised_rows(cfg, ['b'], doc)], ['blocker']) self.attempt(True, {'a': self.PASS_A}) @@ -1925,13 +1936,13 @@ def test_a_missing_record_denies_every_pass(self): def test_another_roster_is_never_read_as_this_ones(self): self.attempt(False, {'b': {'b': {'t': '✅'}}}) - self.cfg.write_text(self.cfg.read_text() + ' ') + self.cfg.write_text(self.cfg.read_text(encoding='utf-8') + ' ', encoding='utf-8') seq = hil_report.sequence(self.cfg, ['b'], self.rd)['sequence'] self.assertEqual((seq['verdict'], [x['attempt_id'] for x in seq['broken']]), ('not-run', [self.meta()['attempt_id']]), 'its rows are not read, so never a pass') def test_the_rows_fold_to_the_shared_verdicts(self): - cfg = json.loads(self.cfg.read_text()) + cfg = json.loads(self.cfg.read_text(encoding='utf-8')) doc = {'rows': [{'board': 'a-fs', 'cells': {hil_report.WEDGED_CELL: '❌'}, 'duration': None}, {'board': 'b', 'cells': {hil_report.RUN_ABORTED_CELL: hil_report.BUILD_REFUSED}, 'duration': None}, {'board': 'c', 'cells': {'t': '⚪ no binary'}, 'duration': None}], 'scope': '', 'caveat': ''} @@ -1987,8 +1998,8 @@ def test_a_retry_of_only_the_failed_cells_clears_the_fail(self): def test_a_row_without_a_test_cell_never_passes_the_sequence(self): self.attempt(False, {'b': {'b': {}}}) self.assertEqual(self.verdicts(['b'])['b'], ('not-run', False)) - doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text()) - self.assertTrue(hil_report.summarize(json.loads(self.cfg.read_text()), ['b'], doc)['pass'], + doc = json.loads((self.rd / hil_report.REPORT_JSON).read_text(encoding='utf-8')) + self.assertTrue(hil_report.summarize(json.loads(self.cfg.read_text(encoding='utf-8')), ['b'], doc)['pass'], 'the snapshot verdict keeps its old reading') def test_the_cli_prints_the_sequence_and_the_snapshot_is_unchanged(self): @@ -2038,7 +2049,7 @@ def sandbox(self): shim = root / 'bin' / 'python3' shim.parent.mkdir() shim.write_text(f'#!/bin/sh\n[ "$1 $2" = "test/hil/helper/agentrc.py fetch" ] && exit 0\n' - f'exec {sys.executable} "$@"\n') + f'exec {sys.executable} "$@"\n', encoding='utf-8') shim.chmod(0o755) return root @@ -2052,7 +2063,7 @@ def run_step(self, root, script, env, rc=0): @staticmethod def output_dir(root): - outputs = dict(line.split('=', 1) for line in (root / 'github_output').read_text().splitlines()) + outputs = dict(line.split('=', 1) for line in (root / 'github_output').read_text(encoding='utf-8').splitlines()) return Path(root, outputs['dir']) def upload(self, step, src, artifact): diff --git a/test/hil/test/test_hil_reset_order.py b/test/hil/test/test_hil_reset_order.py index 1708f53cd2..44abb98e96 100644 --- a/test/hil/test/test_hil_reset_order.py +++ b/test/hil/test/test_hil_reset_order.py @@ -11,6 +11,9 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if sys.platform == 'win32': + raise unittest.SkipTest("hil_test imports agentrc's rig harness (fcntl locks, process groups): POSIX only") + import usbtest_harness # noqa: E402 - stubs pyserial before hil_test imports it import hil_flash # noqa: E402 import hil_test # noqa: E402 diff --git a/test/hil/test/test_hil_rtt.py b/test/hil/test/test_hil_rtt.py index 3dd0ddb8e3..e26d6d4268 100644 --- a/test/hil/test/test_hil_rtt.py +++ b/test/hil/test/test_hil_rtt.py @@ -13,6 +13,9 @@ HIL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPO = os.path.dirname(os.path.dirname(HIL_DIR)) sys.path.insert(0, HIL_DIR) +if sys.platform == 'win32': + raise unittest.SkipTest("hil_test imports agentrc's rig harness (fcntl locks, process groups): POSIX only") + import usbtest_harness # noqa: E402,F401 - stubs pyserial before hil_test imports it import hil_test # noqa: E402 from helper import agentrc # noqa: E402 diff --git a/test/hil/test/test_hil_stale_setup.py b/test/hil/test/test_hil_stale_setup.py index 667ab62971..c0a41fb928 100644 --- a/test/hil/test/test_hil_stale_setup.py +++ b/test/hil/test/test_hil_stale_setup.py @@ -10,6 +10,9 @@ import unittest import unittest.mock +if sys.platform == 'win32': + raise unittest.SkipTest("it imports usbtest, which loads agentrc's hil_usbtest (fcntl, Linux usbfs): POSIX only") + import usb.core sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/test/hil/test/test_hil_sysview_capture.py b/test/hil/test/test_hil_sysview_capture.py index 3664503667..2d3a89f45c 100644 --- a/test/hil/test/test_hil_sysview_capture.py +++ b/test/hil/test/test_hil_sysview_capture.py @@ -15,6 +15,9 @@ HIL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, HIL_DIR) +if sys.platform == 'win32': + raise unittest.SkipTest("hil_test imports agentrc's rig harness (fcntl locks, process groups): POSIX only") + from usbtest_harness import patch # noqa: E402 - stubs pyserial before hil_test imports it import hil_flash # noqa: E402 import hil_test # noqa: E402 diff --git a/test/hil/test/test_hil_sysview_ci.py b/test/hil/test/test_hil_sysview_ci.py index 6ed82384df..352caf112e 100644 --- a/test/hil/test/test_hil_sysview_ci.py +++ b/test/hil/test/test_hil_sysview_ci.py @@ -32,7 +32,7 @@ def board_json(board="stm32f407disco", err=None, workload_ok=None, **mk): def write_set(d, *objs): os.makedirs(d, exist_ok=True) for o in objs: - with open(os.path.join(d, f"sysview-{o['board']}.json"), 'w') as f: + with open(os.path.join(d, f"sysview-{o['board']}.json"), 'w', encoding="utf-8") as f: json.dump(o, f) F = lambda name, n, p50: {"name": name, "n": n, "p50_us": p50, "p99_us": p50*2, "max_us": p50*99} @@ -408,7 +408,7 @@ def setUp(self): self.rtt = agentrc.load_file("skills/rtt/scripts/rtt.py") # a serial "node" that already exists, so the enumeration wait does not sleep node = os.path.join(self.tmp, "node") - open(node, "w").close() + open(node, "w", encoding="utf-8").close() for obj, attr, value in ( (hil_util, "get_serial_dev", lambda *a, **k: node), (hil_flash, "flash_openocd", lambda board, fw: subprocess.CompletedProcess([], 0)), @@ -446,6 +446,7 @@ def no_symbol(elf): cfg="-f interface/x.cfg -f target/y.cfg", addr=0x20000000, channel=sysview_ci.SYSVIEW_CHANNEL, serial_no="P", vid_pid=None, reset_before_attach=True) + @unittest.skipIf(sys.platform == "win32", "the flash output goes through agentrc's hil_process (fcntl): POSIX only") def test_failed_flash_runs_the_dap_rescue(self): import hil_flash rescued = [] @@ -511,7 +512,7 @@ class EventMap(unittest.TestCase): def test_matches_the_header_enum(self): root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - with open(os.path.join(root, "src", "common", "tusb_debug.h")) as f: + with open(os.path.join(root, "src", "common", "tusb_debug.h"), encoding="utf-8") as f: header = f.read() base = int(re.search(r"#define TU_SV_EVENT_BASE (\d+)", header).group(1)) expected = { @@ -528,12 +529,12 @@ def test_matches_the_header_enum(self): enum = re.sub(r"//[^\n]*|/\*.*?\*/", "", enum, flags=re.S) members = [member.strip() for member in enum.split(",") if member.strip()] self.assertEqual(members, ["TU_SV_ID_TUD_TASK = 0", *list(expected)[1:], "TU_SV_ID_COUNT"]) - with open(sysview_ci.EVENT_MAP) as f: + with open(sysview_ci.EVENT_MAP, encoding="utf-8") as f: names = json.load(f) self.assertEqual(names, {str(base + i): label for i, label in enumerate(expected.values())}) def test_workload_anchor_is_tud_cdc_read(self): - with open(sysview_ci.EVENT_MAP) as f: + with open(sysview_ci.EVENT_MAP, encoding="utf-8") as f: names = json.load(f) self.assertEqual(names[str(sysview_ci.workload_event())], "tud_cdc_read") @@ -734,12 +735,12 @@ def test_only_a_captured_record_is_decoded(self): "workload_ok": True})) failed = sysview_ci.capture_result({**b, "name": "f"}, "c", error="skipped: --skip-flash") sysview_ci.write_result(self.dir, failed) - open(os.path.join(self.dir, "f-capture.SVDat"), "w").close() # a leftover raw file is not a capture + open(os.path.join(self.dir, "f-capture.SVDat"), "w", encoding="utf-8").close() # a leftover raw file is not a capture decoded, out = self.main() self.assertEqual(decoded, [("b-capture.SVDat", self.dir, None)]) - with open(os.path.join(self.dir, "sysview-b.json")) as f: + with open(os.path.join(self.dir, "sysview-b.json"), encoding="utf-8") as f: self.assertEqual(json.load(f)["metrics"], {"overflow": 0}) - with open(os.path.join(self.dir, "sysview-f.json")) as f: + with open(os.path.join(self.dir, "sysview-f.json"), encoding="utf-8") as f: self.assertEqual(json.load(f), failed) self.assertEqual(out, "b: ok\nf: skipped: --skip-flash\n") @@ -759,21 +760,21 @@ def test_downloaded_artifacts_decode_in_their_own_dirs(self): old = os.path.join(self.dir, "sysview-old") os.makedirs(old) older = board_json(board="o") # decoded on the rig before records carried a status - with open(os.path.join(old, "sysview-o.json"), "w") as f: + with open(os.path.join(old, "sysview-o.json"), "w", encoding="utf-8") as f: json.dump(older, f) decoded_before = {**board_json(board="d"), "capture": {**board_json()["capture"], "status": "captured", "raw": "d-capture.SVDat"}} - with open(os.path.join(old, "sysview-d.json"), "w") as f: # no raw beside it: never decoded again + with open(os.path.join(old, "sysview-d.json"), "w", encoding="utf-8") as f: # no raw beside it: never decoded again json.dump(decoded_before, f) - with open(os.path.join(old, "sysview-bad.json"), "w") as f: + with open(os.path.join(old, "sysview-bad.json"), "w", encoding="utf-8") as f: f.write("{") decoded, out = self.main() self.assertEqual(decoded, [("b-capture.SVDat", nested, None)]) - with open(os.path.join(nested, "sysview-b.json")) as f: + with open(os.path.join(nested, "sysview-b.json"), encoding="utf-8") as f: self.assertEqual(json.load(f)["metrics"], {"overflow": 0}) - with open(os.path.join(old, "sysview-o.json")) as f: + with open(os.path.join(old, "sysview-o.json"), encoding="utf-8") as f: self.assertEqual(json.load(f), older) - with open(os.path.join(old, "sysview-d.json")) as f: + with open(os.path.join(old, "sysview-d.json"), encoding="utf-8") as f: self.assertEqual(json.load(f), decoded_before) self.assertIn(f"{os.path.join(old, 'sysview-bad.json')}: not decoded: JSONDecodeError", out) self.assertIn("b: ok", out) @@ -802,7 +803,7 @@ def step(returncode=1, stdout="", stderr="boom", communicate=None): def decode(self, deadline, *steps): popen = unittest.mock.Mock(side_effect=list(steps)) with unittest.mock.patch("subprocess.Popen", popen), \ - unittest.mock.patch("os.killpg") as killpg, \ + unittest.mock.patch("os.killpg", create=True) as killpg, \ unittest.mock.patch("helper.agentrc.path_of", lambda p: p): err = sysview_ci.decode_one(self.RESULT, self.tmp, deadline)["error"] return err, popen, killpg @@ -846,6 +847,7 @@ def test_a_spent_budget_skips_the_decode(self): self.assertIn("decode skipped", err) popen.assert_not_called() + @unittest.skipIf(sys.platform == "win32", "no SIGKILL or process groups: the group kill is POSIX only") def test_a_cut_decoder_gets_sigterm_then_its_group_sigkill(self): # SIGKILL alone would skip sysview_record.py's handler, which records the failure cut = subprocess.TimeoutExpired("sysview_record.py", 30) @@ -880,6 +882,7 @@ def test_a_decoder_that_exits_on_sigterm_is_not_sigkilled(self): killpg.assert_not_called() +@unittest.skipIf(sys.platform == "win32", "a SIGTERM handler and a process-group SIGKILL are POSIX only") class DecodeStopsARealChild(unittest.TestCase): """A real decoder stand-in cut by the deadline: SIGTERM lets it clean up, SIGKILL takes its group.""" RESULT = DecodeDeadline.RESULT @@ -890,7 +893,7 @@ def run_child(self, setup): tmp = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, tmp, True) script, ready = os.path.join(tmp, "decoder.py"), os.path.join(tmp, "ready") - with open(script, "w") as f: + with open(script, "w", encoding="utf-8") as f: f.write("import os, signal, subprocess, sys, time\n" f"TMP = {tmp!r}\n" + setup + f"open({ready!r}, 'w').close()\n" @@ -928,11 +931,11 @@ def test_a_decoder_ignoring_sigterm_is_killed_with_its_group(self): "open(os.path.join(TMP, 'child'), 'w').write(str(c.pid))\n") self.assertIn("decode timed out", err) self.assertLess(took, 10) - with open(os.path.join(tmp, "child")) as f: + with open(os.path.join(tmp, "child"), encoding="utf-8") as f: child = int(f.read()) for _ in range(100): try: - with open(f"/proc/{child}/stat") as f: + with open(f"/proc/{child}/stat", encoding="utf-8") as f: if f.read().rsplit(")", 1)[1].split()[0] == "Z": break except (FileNotFoundError, ProcessLookupError): @@ -970,7 +973,7 @@ def _find(self, board, images, head=HEAD, **manifest): "commit": self.HEAD, "dirty": False, "elfSha256": hashlib.sha256(rel.encode()).hexdigest(), "eventMask": "none", "bufferSize": "65536", **manifest} - with open(stem + ".sysview.json", "w") as f: + with open(stem + ".sysview.json", "w", encoding="utf-8") as f: json.dump(m, f) with unittest.mock.patch.object(hil_util, "TINYUSB_ROOT", type(hil_util.TINYUSB_ROOT)(root)), \ unittest.mock.patch.object(hil_flash, "build_dir", "cmake-build"): @@ -986,7 +989,7 @@ def test_the_sysview_leg_image_with_the_flasher_extension(self): fw, manifest, err = self._find(self._board(), {f"{self.LEG}.elf": True, f"{self.LEG}.bin": False, "cmake-build-stm32f407disco/device/cdc_msc/cdc_msc.elf": True}) self.assertIsNone(err) - self.assertTrue(fw.endswith(f"{self.LEG}.elf"), fw) + self.assertTrue(fw.replace(os.sep, "/").endswith(f"{self.LEG}.elf"), fw) self.assertEqual(manifest["bufferSize"], "65536") def test_the_buffer_is_the_rosters_or_the_family_default(self): @@ -1051,7 +1054,7 @@ def test_the_variant_flags_name_the_leg(self): def test_the_keys_build_py_sets_itself_are_the_ones_ignored(self): # a -D tools/build.py adds on its own would otherwise read as a traced build configured differently from helper import hil_util - src = (hil_util.TINYUSB_ROOT / "tools" / "build.py").read_text() + src = (hil_util.TINYUSB_ROOT / "tools" / "build.py").read_text(encoding="utf-8") keys = set(re.findall(r"""['"]-D([A-Z_]+)=""", src)) self.assertEqual(keys - sysview_ci._BUILD_PY_KEYS, {"CMAKE_BUILD_TYPE"}) self.assertIn("-DCMAKE_BUILD_TYPE=MinSizeRel", src) @@ -1082,12 +1085,13 @@ def _run(self, *cmd, cwd=None): def _configure(self, *defines, before_manifest=""): src = os.path.join(self.dir, "src") os.makedirs(src, exist_ok=True) - with open(os.path.join(src, "main.c"), "w") as f: + with open(os.path.join(src, "main.c"), "w", encoding="utf-8") as f: f.write("int main(void) { return 0; }\n") - with open(os.path.join(src, "CMakeLists.txt"), "w") as f: - # declared after the record, as RTOS and CMAKE_C_FLAGS are in family_support.cmake and project() + with open(os.path.join(src, "CMakeLists.txt"), "w", encoding="utf-8") as f: + # declared after the record, as RTOS and CMAKE_C_FLAGS are in family_support.cmake and project(); + # include() takes a CMake string, where a Windows backslash is an escape f.write(f"""cmake_minimum_required(VERSION 3.20) -include({self.SCRIPT}) +include({self.SCRIPT.replace(os.sep, '/')}) sysview_record_command_line() project(t C) option(OPT_DECLARED "x" OFF) @@ -1097,7 +1101,7 @@ def _configure(self, *defines, before_manifest=""): sysview_manifest(app board1 device/ex) """) self._run("cmake", "-S", "src", "-B", "b", *defines) - with open(os.path.join(self.dir, "b", "app.sysview.in.json")) as f: + with open(os.path.join(self.dir, "b", "app.sysview.in.json"), encoding="utf-8") as f: return json.load(f) def test_every_define_any_configure_was_given_at_its_current_value(self): @@ -1127,12 +1131,12 @@ def test_an_untouched_declared_option_is_no_command_line_setting(self): def _complete(self, top): import hashlib cfg, elf, out = (os.path.join(self.dir, n) for n in ("in.json", "app.elf", "out.json")) - with open(cfg, "w") as f: + with open(cfg, "w", encoding="utf-8") as f: json.dump({"board": "board1"}, f) with open(elf, "wb") as f: f.write(b"image") self._run("cmake", f"-DCONFIG={cfg}", f"-DELF={elf}", f"-DOUT={out}", f"-DTOP={top}", "-P", self.SCRIPT) - with open(out) as f: + with open(out, encoding="utf-8") as f: m = json.load(f) self.assertEqual((m["board"], m["elfSha256"]), ("board1", hashlib.sha256(b"image").hexdigest())) return m @@ -1140,7 +1144,7 @@ def _complete(self, top): def test_the_commit_and_whether_the_sources_differ_from_it(self): top = os.path.join(self.dir, "repo") os.makedirs(os.path.join(top, "src")) - with open(os.path.join(top, "src", "a.c"), "w") as f: + with open(os.path.join(top, "src", "a.c"), "w", encoding="utf-8") as f: f.write("a\n") self._run("git", "init", "-q", cwd=top) self._run("git", "add", ".", cwd=top) @@ -1150,12 +1154,12 @@ def test_the_commit_and_whether_the_sources_differ_from_it(self): self.assertEqual((self._complete(top)["commit"], self._complete(top)["dirty"]), (head, False)) # CI's own fetches sit untracked at the top, outside the build's source dirs os.makedirs(os.path.join(top, "pico-sdk")) - open(os.path.join(top, "pico-sdk", "x"), "w").close() + open(os.path.join(top, "pico-sdk", "x"), "w", encoding="utf-8").close() self.assertFalse(self._complete(top)["dirty"]) - open(os.path.join(top, "src", "new.c"), "w").close() + open(os.path.join(top, "src", "new.c"), "w", encoding="utf-8").close() self.assertTrue(self._complete(top)["dirty"]) os.remove(os.path.join(top, "src", "new.c")) - with open(os.path.join(top, "src", "a.c"), "w") as f: + with open(os.path.join(top, "src", "a.c"), "w", encoding="utf-8") as f: f.write("b\n") self.assertTrue(self._complete(top)["dirty"]) @@ -1219,7 +1223,7 @@ def test_records_that_do_not_name_one_valid_base_commit(self): info = self.baseline() self.assertEqual((info["sha"], info["note"]), (None, "the PR captures record no single base commit")) - with open(os.path.join(self.tmp, "a0", "sysview-bad.json"), "w") as f: + with open(os.path.join(self.tmp, "a0", "sysview-bad.json"), "w", encoding="utf-8") as f: f.write("{") self.assertIsNone(self.baseline()["sha"]) @@ -1241,7 +1245,7 @@ def boom(path, paginate=False): "--current", self.tmp, "--info", out]), \ contextlib.redirect_stdout(io.StringIO()): sysview_ci.main() - with open(out) as f: + with open(out, encoding="utf-8") as f: info = json.load(f) self.assertEqual((info["sha"], info["note"]), (None, "the baseline lookup failed: RuntimeError: HTTP 502")) @@ -1289,13 +1293,13 @@ def setUp(self): self.rosters, self.reports = os.path.join(self.tmp, "rosters"), os.path.join(self.tmp, "reports") self.base, self.pr = os.path.join(self.tmp, "b"), os.path.join(self.tmp, "p") os.makedirs(self.rosters) - with open(os.path.join(self.rosters, "rig.json"), "w") as f: + with open(os.path.join(self.rosters, "rig.json"), "w", encoding="utf-8") as f: json.dump(self.ROSTER, f) def hil_report(self, rows, rig="rig.json"): d = os.path.join(self.reports, f"hil-report-{rig}") os.makedirs(d, exist_ok=True) - with open(os.path.join(d, "hil_report.json"), "w") as f: + with open(os.path.join(d, "hil_report.json"), "w", encoding="utf-8") as f: json.dump({"rows": [{"board": b, "cells": c} for b, c in rows.items()]}, f) def unrecorded(self): @@ -1320,13 +1324,13 @@ def test_a_board_whose_capture_could_not_run_is_not_expected(self): self.hil_report({"f407": {"device/cdc_msc": "✅"}}, rig="unknown.json") self.assertEqual(self.unrecorded(), []) d = os.path.join(self.reports, "hil-report-rig.json") - with open(os.path.join(d, "hil_report.json"), "w") as f: + with open(os.path.join(d, "hil_report.json"), "w", encoding="utf-8") as f: json.dump({"rows": [{"board": "f407", "retired": True, "cells": {"device/cdc_msc": "✅"}}, "junk"]}, f) self.assertEqual(self.unrecorded(), []) def test_an_unreadable_report_expects_nothing(self): self.hil_report({}) - with open(os.path.join(self.reports, "hil-report-rig.json", "hil_report.json"), "w") as f: + with open(os.path.join(self.reports, "hil-report-rig.json", "hil_report.json"), "w", encoding="utf-8") as f: f.write("[1, 2") self.assertEqual(self.unrecorded(), []) self.assertEqual(sysview_ci.unrecorded_line([]), "") @@ -1345,7 +1349,7 @@ def test_the_cli_names_a_missing_board_only_when_there_is_one(self): self.assertIn("record for `pico`, which", self.main("unrecorded", self.reports, self.pr)) out = os.path.join(self.tmp, "report.md") self.main("report", self.base, self.pr, "--hil-reports", self.reports, "-o", out) - with open(out) as f: + with open(out, encoding="utf-8") as f: self.assertIn("record for `pico`, which", f.read()) write_set(self.pr, board_json(board="pico")) self.assertEqual(self.main("unrecorded", self.reports, self.pr), "") @@ -1365,7 +1369,7 @@ def render(self, *pr, base=(), info=None): for side, objs in (("b", base), ("p", pr)): os.makedirs(os.path.join(self.tmp, side)) for i, o in enumerate(objs): # a board name is no file name here - with open(os.path.join(self.tmp, side, f"sysview-{i}.json"), "w") as f: + with open(os.path.join(self.tmp, side, f"sysview-{i}.json"), "w", encoding="utf-8") as f: json.dump(o, f) return sysview_ci.report(os.path.join(self.tmp, "b"), os.path.join(self.tmp, "p"), info) diff --git a/test/hil/test/test_hil_teardown_stress.py b/test/hil/test/test_hil_teardown_stress.py index 53447fb9b0..b7f549cf1e 100644 --- a/test/hil/test/test_hil_teardown_stress.py +++ b/test/hil/test/test_hil_teardown_stress.py @@ -11,6 +11,9 @@ import unittest import unittest.mock +if sys.platform == 'win32': + raise unittest.SkipTest("it imports usbtest, which loads agentrc's hil_usbtest (fcntl, Linux usbfs): POSIX only") + import usb.core sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/test/hil/test/test_hil_tty_raw.py b/test/hil/test/test_hil_tty_raw.py index 902287b98a..836d9cc826 100644 --- a/test/hil/test/test_hil_tty_raw.py +++ b/test/hil/test/test_hil_tty_raw.py @@ -7,9 +7,13 @@ # python3 test/hil/test/test_hil_tty_raw.py import os import sys -import termios import unittest +if sys.platform == 'win32': + raise unittest.SkipTest('termios: the raw tty mode is POSIX only') + +import termios + TEST_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(TEST_DIR)) diff --git a/test/hil/test/test_hil_usbtest_id.py b/test/hil/test/test_hil_usbtest_id.py index bb4efda295..db175e453c 100644 --- a/test/hil/test/test_hil_usbtest_id.py +++ b/test/hil/test/test_hil_usbtest_id.py @@ -16,6 +16,9 @@ TEST_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(TEST_DIR)) +if sys.platform == 'win32': + raise unittest.SkipTest("usbtest imports agentrc's hil_usbtest (fcntl, Linux usbfs): POSIX only") + import usbtest_harness # noqa: E402 - stubs serial before hil_test is imported import usbtest # noqa: E402 diff --git a/test/hil/test/test_hil_usbtest_tt.py b/test/hil/test/test_hil_usbtest_tt.py index 8c724e3b64..038345c3de 100644 --- a/test/hil/test/test_hil_usbtest_tt.py +++ b/test/hil/test/test_hil_usbtest_tt.py @@ -17,6 +17,9 @@ TEST_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(TEST_DIR)) +if sys.platform == 'win32': + raise unittest.SkipTest("hil_test imports agentrc's rig harness (fcntl locks, process groups): POSIX only") + import usbtest_harness # noqa: E402 - stubs serial before hil_test is imported import hil_flash # noqa: E402 import hil_test # noqa: E402 diff --git a/test/hil/test/test_hil_usbtest_verdict.py b/test/hil/test/test_hil_usbtest_verdict.py index 011e15fc6e..448da01884 100644 --- a/test/hil/test/test_hil_usbtest_verdict.py +++ b/test/hil/test/test_hil_usbtest_verdict.py @@ -16,6 +16,9 @@ TEST_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(TEST_DIR)) +if sys.platform == 'win32': + raise unittest.SkipTest("usbtest imports agentrc's hil_usbtest (fcntl, Linux usbfs): POSIX only") + import usbtest_harness # noqa: E402 - stubs serial before hil_test is imported import usbtest # noqa: E402 diff --git a/test/hil/test/test_hil_util.py b/test/hil/test/test_hil_util.py index 3c167c59e0..3a9ba10224 100644 --- a/test/hil/test/test_hil_util.py +++ b/test/hil/test/test_hil_util.py @@ -18,6 +18,7 @@ from helper import hil_util +@unittest.skipIf(sys.platform == 'win32', "the runner is agentrc's hil_process (fcntl, process groups): POSIX only") class Wrappers(unittest.TestCase): """run_cmd and run_alongside are agentrc's (hil_process; its tests hold the runner's contract) with this harness's CMD_TIMEOUT, REAP_GRACE and verbose read on every call: @@ -119,7 +120,7 @@ def test_bare_runner_modules_stay_stdlib_only(self): '../../tools/get_deps', '../../tools/family_json', # the selection contract CI calls: check_build.py --select-only '../../.claude/skills/build/scripts/check_build', 'helper/hil_report'): - tree = ast.parse((hil_dir / f'{mod}.py').read_text()) + tree = ast.parse((hil_dir / f'{mod}.py').read_text(encoding='utf-8')) # module level only: a deferred import inside a function cannot break # importability for node in tree.body: @@ -133,6 +134,7 @@ def test_bare_runner_modules_stay_stdlib_only(self): f'{mod}.py imports {root}, not stdlib/local - breaks the bare CI runner') +@unittest.skipIf(sys.platform == 'win32', "the runner is agentrc's hil_process (fcntl, process groups): POSIX only") class RunAlongsideKeepsStderrOffThePayload(unittest.TestCase): """test_device_printer_to_cdc byte-compares run_alongside's stdout against the payload it wrote. Merging stderr into that stream turns any stray child stderr byte -- a @@ -185,7 +187,7 @@ def test_the_hfp_rig_gets_its_own_roster(self): def test_a_cwd_file_does_not_shadow_the_host_roster(self): with tempfile.TemporaryDirectory() as d: - Path(d, 'hfp.json').write_text('{}') + Path(d, 'hfp.json').write_text('{}', encoding='utf-8') cwd = os.getcwd() os.chdir(d) try: @@ -203,7 +205,7 @@ def test_local_fallback_takes_local_json_only_when_it_exists(self): hil.mkdir(parents=True) with self.assertRaises(LookupError): hil_util.roster_path('htpc', local_fallback=True) - (hil / 'local.json').write_text('{}') + (hil / 'local.json').write_text('{}', encoding='utf-8') self.assertEqual(hil_util.roster_path('htpc', local_fallback=True), hil / 'local.json') self.assertEqual(hil_util.roster_path('ci', local_fallback=True), hil / 'tinyusb.json') with self.assertRaises(LookupError): # the farm check's call: no fallback diff --git a/test/hil/test/test_hil_wedge_sweep.py b/test/hil/test/test_hil_wedge_sweep.py index e12d8a20a7..e61dff98cd 100644 --- a/test/hil/test/test_hil_wedge_sweep.py +++ b/test/hil/test/test_hil_wedge_sweep.py @@ -14,6 +14,9 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if sys.platform == 'win32': + raise unittest.SkipTest("hil_test imports agentrc's rig harness (fcntl locks, process groups): POSIX only") + import usbtest_harness # noqa: E402 - stubs pyserial before hil_test imports it import hil_flash # noqa: E402 import hil_test # noqa: E402 diff --git a/test/hil/test/test_setup_toolchain.py b/test/hil/test/test_setup_toolchain.py index d19303a353..0f943f2122 100644 --- a/test/hil/test/test_setup_toolchain.py +++ b/test/hil/test/test_setup_toolchain.py @@ -5,14 +5,17 @@ import os import shutil import subprocess +import sys import tempfile import unittest +from pathlib import Path REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) ACTION = os.path.join(REPO, '.github', 'actions', 'setup_toolchain') FETCH = os.path.join(ACTION, 'download', 'fetch.sh') PAYLOAD = 'toolchain bytes\n' DIGEST = hashlib.sha256(PAYLOAD.encode()).hexdigest() +BASH = shutil.which('bash') # writes CURL_PAYLOAD to the -o file and logs the call, or fails like curl -f on a 404 when CURL_FAIL is set CURL_STUB = '''#!/bin/sh @@ -44,6 +47,7 @@ def test_esp_idf_pins_a_release_image(self): self.assertRegex(self.manifest['esp-idf']['image'], r'^espressif/idf:v\d+(\.\d+)*$') +@unittest.skipUnless(BASH and shutil.which('jq'), 'fetch.sh needs bash and jq') class Fetch(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() @@ -59,7 +63,7 @@ def setUp(self): def _write_exe(self, name, text): path = os.path.join(self.bin, name) - with open(path, 'w') as f: + with open(path, 'w', newline='\n') as f: # a CRLF shebang line does not run f.write(text) os.chmod(path, 0o755) @@ -68,8 +72,13 @@ def _manifest(self, **entries): json.dump(entries, f) def fetch(self, name='tc', path=None, **env): - env = {'PATH': path or f'{self.bin}:{os.environ["PATH"]}', 'CURL_LOG': self.curl_log, 'CURL_PAYLOAD': PAYLOAD, **env} - return subprocess.run([self.fetch_sh, name, self.out], env=env, capture_output=True, text=True) + env = {'PATH': path or os.pathsep.join((self.bin, os.environ['PATH'])), + 'CURL_LOG': self.curl_log, 'CURL_PAYLOAD': PAYLOAD, **env} + argv = [self.fetch_sh, name, self.out] + if sys.platform == 'win32': # Windows runs no shebang: hand the script to bash, which needs SYSTEMROOT + env['SYSTEMROOT'] = os.environ['SYSTEMROOT'] + argv.insert(0, BASH) + return subprocess.run(argv, env=env, capture_output=True, text=True) def curl_called(self): return os.path.exists(self.curl_log) @@ -114,7 +123,8 @@ def test_falls_back_to_shasum_without_sha256sum(self): def test_native_windows_jq_line_endings(self): # native jq.exe turns every LF it prints into CRLF; command substitution keeps the CR - self._write_exe('jq', f'#!/bin/bash\nset -o pipefail\n{shutil.which("jq")} "$@" | perl -pe "s/\\n/\\r\\n/"\n') + jq = Path(shutil.which('jq')).as_posix() # bash would eat a Windows path's backslashes + self._write_exe('jq', f'#!/bin/bash\nset -o pipefail\n"{jq}" "$@" | perl -pe "s/\\n/\\r\\n/"\n') self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz', 'sha256': DIGEST}) r = self.fetch() self.assertEqual(r.returncode, 0, r.stderr) diff --git a/test/unit-test/test/host/dwc2/test_dwc2_split.c b/test/unit-test/test/host/dwc2/test_dwc2_split.c index 9bed2d9b3d..1f0cb5ddec 100644 --- a/test/unit-test/test/host/dwc2/test_dwc2_split.c +++ b/test/unit-test/test/host/dwc2/test_dwc2_split.c @@ -3,11 +3,10 @@ * SPDX-License-Identifier: MIT */ -#include #include -#include #include "unity.h" +#include "test_watchdog.h" #include "mock_dwc2_test.h" // Compile the driver itself so these tests exercise its admission and retry paths. @@ -43,11 +42,7 @@ static void spin_watchdog(int sig) { static int test_variant = -1; void setUp(void) { - // SA_NODEFER: Unity leaves the handler by longjmp, which would keep SIGALRM blocked. - struct sigaction watchdog = {.sa_handler = spin_watchdog, .sa_flags = SA_NODEFER}; - TEST_ASSERT_EQUAL(0, sigemptyset(&watchdog.sa_mask)); - TEST_ASSERT_EQUAL(0, sigaction(SIGALRM, &watchdog, NULL)); - alarm(5); + test_watchdog_arm(spin_watchdog, 5); memset(&test_regs, 0, sizeof(test_regs)); memset(&_hcd_data, 0, sizeof(_hcd_data)); _hcd_csplit_behind = _hcd_csplit_pass = _hcd_csplit_late_held = _hcd_split_kicked = false; @@ -68,7 +63,7 @@ void setUp(void) { } void tearDown(void) { - alarm(0); + test_watchdog_disarm(); if (Unity.CurrentTestFailed && test_variant >= 0) { UnityPrint(" (variant "); UnityPrintNumberUnsigned((UNITY_UINT) test_variant); diff --git a/test/unit-test/test/support/test_watchdog.h b/test/unit-test/test/support/test_watchdog.h new file mode 100644 index 0000000000..d192d9df78 --- /dev/null +++ b/test/unit-test/test/support/test_watchdog.h @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 TinyUSB contributors + * SPDX-License-Identifier: MIT + */ +#ifndef TUSB_TEST_WATCHDOG_H_ +#define TUSB_TEST_WATCHDOG_H_ + +// Fails the running test from a SIGALRM handler instead of hanging the binary on a spin the host model never ends. +// Windows has no SIGALRM: there the watchdog is off and such a test hangs instead of failing. + +#ifndef _WIN32 + #include + #include +#endif + +#include "unity.h" + +static inline void test_watchdog_arm(void (*handler)(int), unsigned seconds) { +#ifndef _WIN32 + // SA_NODEFER: Unity leaves the handler by longjmp, which would keep SIGALRM blocked. + struct sigaction watchdog = {.sa_handler = handler, .sa_flags = SA_NODEFER}; + TEST_ASSERT_EQUAL(0, sigemptyset(&watchdog.sa_mask)); + TEST_ASSERT_EQUAL(0, sigaction(SIGALRM, &watchdog, NULL)); + alarm(seconds); +#else + (void) handler; + (void) seconds; +#endif +} + +static inline void test_watchdog_disarm(void) { +#ifndef _WIN32 + alarm(0); +#endif +} + +#endif diff --git a/test/unit-test/test/support/video_device_test.h b/test/unit-test/test/support/video_device_test.h index 5684ca9aee..af0a7261fb 100644 --- a/test/unit-test/test/support/video_device_test.h +++ b/test/unit-test/test/support/video_device_test.h @@ -6,8 +6,7 @@ #define TUSB_VIDEO_DEVICE_TEST_H_ #ifdef VIDEO_DEVICE_TEST_FIXTURE -#include -#include +#include "test_watchdog.h" #endif #include "device/usbd.h" @@ -310,11 +309,7 @@ static void lock_watchdog(int sig) { } static inline void video_test_setup(void) { - // SA_NODEFER: Unity leaves the handler by longjmp, which would keep SIGALRM blocked. - struct sigaction watchdog = {.sa_handler = lock_watchdog, .sa_flags = SA_NODEFER}; - TEST_ASSERT_EQUAL(0, sigemptyset(&watchdog.sa_mask)); - TEST_ASSERT_EQUAL(0, sigaction(SIGALRM, &watchdog, NULL)); - alarm(5); + test_watchdog_arm(lock_watchdog, 5); claimed = false; accept_commit = true; in_commit = stalled = refuse_claim = refuse_xfer = false; @@ -337,7 +332,7 @@ static inline void video_test_teardown(void) { if (!Unity.CurrentTestFailed) { TEST_ASSERT_FALSE(tud_video_n_frame_xfer(0, 0, frame, 0)); } - alarm(0); + test_watchdog_disarm(); videod_reset(0); } diff --git a/tools/codespell/ignore-words.txt b/tools/codespell/ignore-words.txt index 6b301d27b8..b38ba356df 100644 --- a/tools/codespell/ignore-words.txt +++ b/tools/codespell/ignore-words.txt @@ -8,6 +8,7 @@ inout linke mot ore +pres pris ptd ser From 2de577d74c8088c323eeb299d0d341593222b050 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Thu, 12 Nov 2026 13:04:15 +0100 Subject: [PATCH 2/4] Run the pre-commit hooks without a POSIX shell Windows has no sh, so the four `sh -c` entries (unit-test, family-json, change-impact-test, drivers-coverage) could not run from PowerShell or cmd. tools/hook_steps.py runs a hook's steps in order, in a given directory, stopping at the first failure; python steps use the hook's own interpreter, so a language: python hook keeps its venv. test_ci_metrics runs the workflows' bash steps through run_bash(), which skips the test where no bash runs (on Windows `bash` is WSL's launcher). --- .pre-commit-config.yaml | 14 +++++++--- test/hil/test/test_ci_metrics.py | 33 ++++++++++++++++++----- tools/hook_steps.py | 46 ++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 tools/hook_steps.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 73b25593f6..19175735d5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,6 +39,7 @@ repos: - repo: local hooks: + # A multi-step or cd-ing entry goes through tools/hook_steps.py: Windows has no sh to run `sh -c`. - id: unique-example-pids name: unique example USB PIDs files: usb_descriptors\.c$ @@ -56,7 +57,8 @@ repos: - id: unit-test name: unit-test files: ^(src/|test/unit-test/) - entry: sh -c "cd test/unit-test && ceedling test:all" + entry: python3 tools/hook_steps.py + args: ['@test/unit-test ceedling test:all'] pass_filenames: false types_or: [c, header] language: system @@ -92,14 +94,17 @@ repos: additional_dependencies: [pyusb, pyserial] - id: family-json name: family-json - entry: sh -c "python3 tools/family_json.py fix && python3 test/hil/test/test_family_json.py" + entry: python3 tools/hook_steps.py + args: ['python3 tools/family_json.py fix', 'python3 test/hil/test/test_family_json.py'] always_run: true pass_filenames: false language: system - id: change-impact-test name: change-impact-test files: ^(hw/bsp/|hw/mcu/|src/|examples/|test/hil/|tools/(change_impact|build|build_utils|family_json|get_deps)\.py$|\.github/(scripts|workflows)/|\.circleci/) - entry: sh -c "python3 test/hil/test/test_change_impact.py && python3 test/hil/test/test_ci_metrics.py && cd test/hil/test && python3 -m unittest -q test_hil_util.BottomLayer" + entry: python3 tools/hook_steps.py + args: ['python3 test/hil/test/test_change_impact.py', 'python3 test/hil/test/test_ci_metrics.py', + '@test/hil/test python3 -m unittest -q test_hil_util.BottomLayer'] pass_filenames: false language: system - id: setup-toolchain-test @@ -130,7 +135,8 @@ repos: - id: drivers-coverage name: drivers-coverage files: ^(\.github/ci-pinned-boards\.json$|\.github/scripts/ci_set_matrix\.py$|tools/drivers_coverage_check\.py$|tools/change_impact\.py$|tools/build_utils\.py$|hw/bsp/|src/portable/|examples/|test/hil/test/test_drivers_coverage\.py$|tools/build\.py$|test/hil/test/test_ci_boards\.py$|tools/code_size\.py$|tools/code_size_profile\.py$|test/hil/agentrc\.json$|test/hil/helper/agentrc\.py$|test/hil/test/test_code_size\.py$|\.github/scripts/code_size_ci\.py$|test/hil/test/test_code_size_ci\.py$|tools/membrowse_cli\.py$|test/hil/test/test_membrowse_cli\.py$|test/hil/tinyusb\.json$|test/hil/hfp\.json$) - entry: sh -c "cd test/hil/test && python -m unittest -q test_drivers_coverage test_ci_boards test_code_size test_code_size_ci test_membrowse_cli" + entry: python tools/hook_steps.py + args: ['@test/hil/test python -m unittest -q test_drivers_coverage test_ci_boards test_code_size test_code_size_ci test_membrowse_cli'] pass_filenames: false language: python additional_dependencies: [pyyaml, pyelftools] diff --git a/test/hil/test/test_ci_metrics.py b/test/hil/test/test_ci_metrics.py index 2833cec041..d5c9eafcf1 100644 --- a/test/hil/test/test_ci_metrics.py +++ b/test/hil/test/test_ci_metrics.py @@ -3,6 +3,7 @@ # Unit tests for the CircleCI/GitHub Actions selection hand-off contracts. # Stdlib only; no builds. # python3 test/hil/test/test_ci_metrics.py +import functools import json import os import re @@ -26,6 +27,24 @@ if sys.platform == 'win32' and not shutil.which('jq') else '') +@functools.cache +def _no_bash(): + """Why no bash runs here, else '': on Windows `bash` may be absent, or WSL's launcher with no WSL behind it.""" + try: + if subprocess.run([BASH, '-c', 'exit 0'], capture_output=True, timeout=60).returncode == 0: + return '' + except (OSError, subprocess.SubprocessError): + pass + return f'{BASH} does not run, and the CI steps under test are bash scripts' + + +def run_bash(args, **kwargs): + """subprocess.run of bash with `args`; skips the calling test where no bash runs.""" + if _no_bash(): + raise unittest.SkipTest(_no_bash()) + return subprocess.run([BASH, *args], **kwargs) + + def _read(path): # a fixed encoding: the workflows hold non-ASCII text a non-UTF-8 locale cannot decode with open(path, encoding='utf-8') as f: @@ -126,7 +145,7 @@ def write(path, text, mode=0o644): STUB_MATRIX=json.dumps(matrix), STUB_MAP=json.dumps(example_map)) if fork: env['CIRCLE_PR_USERNAME'] = 'contributor' - r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, env=env, + r = run_bash(['-e', '-o', 'pipefail', '-c', block], cwd=d, env=env, capture_output=True, text=True) self.assertEqual(r.returncode, 0, r.stdout + r.stderr) with open(os.path.join(d, '.circleci', 'config2.yml'), encoding='utf-8') as fh: @@ -248,7 +267,7 @@ def test_membrowse_identical_uploads_every_leg_past_a_failure(self): with open(os.path.join(d, 'python3'), 'w', encoding='utf-8', newline='\n') as f: f.write('#!/bin/sh\necho "$*" >> log\ncase "$*" in *bad*) exit 1 ;; esac\n') os.chmod(os.path.join(d, 'python3'), 0o755) - r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, text=True, + r = run_bash(['-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, text=True, env={**os.environ, 'PATH': d + os.pathsep + os.environ['PATH'], 'LEGS': json.dumps(['-b bad', '-b x --build-name x-DMA --cflag=-DY=1'])}) self.assertNotEqual(r.returncode, 0) @@ -365,7 +384,7 @@ def test_the_code_size_scope_lists_every_cmake_leg_and_esp_leg(self): def scope(changed, owner='hathach'): with tempfile.TemporaryDirectory() as d: - subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, check=True, + run_bash(['-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, check=True, env={**os.environ, 'TOOLCHAINS': m.group(1), 'PINNED_JSON': json.dumps(pinned), 'EXAMPLE_MAP': '{"stm32f4": ["device/cdc_msc"]}', 'CODE_CHANGED': changed, 'HIL_JSON': json.dumps(hil), 'OWNER': owner}) @@ -403,14 +422,14 @@ def step_script(name): def run(arg, toolchain='esp-idf'): open(env['LOG'], 'w').close() - r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, + r = run_bash(['-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, text=True, env={**env, 'ARG': arg, 'TOOLCHAIN': toolchain}) self.assertEqual(r.returncode, 0, r.stderr) with open(env['LOG'], encoding='utf-8') as f: return f.read().splitlines() log = run('-b espressif_s3_devkitm $(touch pwned) ;id') # the mount is bash's $PWD: d itself on POSIX, its /c/... spelling under Git Bash - pwd = subprocess.run([BASH, '-c', 'pwd'], cwd=d, capture_output=True, text=True).stdout.strip() + pwd = run_bash(['-c', 'pwd'], cwd=d, capture_output=True, text=True).stdout.strip() self.assertFalse(os.path.exists(os.path.join(d, 'pwned'))) # the engine is agentrc's at the pin, fetched on the runner before anything sizes fetch = 'python [test/hil/helper/agentrc.py] [fetch]' @@ -458,7 +477,7 @@ def step_script(name): json.dump({'schema': 1, 'boards': [], 'examples': None, 'build_outcome': 'success', 'sha': 'a' * 40, 'base_sha': 'a' * 40, 'head_sha': 'a' * 40}, f) for name in ('Find the baseline', 'Compare against the baseline'): - r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', step_script(name)], cwd=REPO, + r = run_bash(['-e', '-o', 'pipefail', '-c', step_script(name)], cwd=REPO, capture_output=True, text=True, env=env) self.assertEqual(r.returncode, 0, f'{name}: {r.stderr}') with open(os.path.join(d, 'out'), encoding='utf-8') as f: @@ -485,7 +504,7 @@ def _run_block_in(self, d, block, sel, setup=None): out = os.path.join(d, 'gh_output') open(out, 'w').close() env = {**os.environ, **((setup and setup(d)) or {}), 'GITHUB_OUTPUT': out} - r = subprocess.run([BASH, '-e', '-o', 'pipefail', '-c', block], cwd=d, + r = run_bash(['-e', '-o', 'pipefail', '-c', block], cwd=d, capture_output=True, text=True, env=env) self.assertEqual(r.returncode, 0, r.stderr) with open(out, encoding='utf-8') as fh: diff --git a/tools/hook_steps.py b/tools/hook_steps.py new file mode 100644 index 0000000000..37e575cd60 --- /dev/null +++ b/tools/hook_steps.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Run a pre-commit hook's steps in order, stopping at the first failure, with no shell: Windows has no `sh`. + + python3 tools/hook_steps.py 'python3 tools/family_json.py fix' '@test/unit-test ceedling test:all' + +Each argument is one step, split like a POSIX shell word list. A leading `@DIR` runs it in DIR, relative to the repo +root. A step whose command is `python` or `python3` runs with this interpreter, so a `language: python` hook's steps +get its venv's packages. Any other command is found on PATH (a .bat or .cmd one too, on Windows). +""" +import os +import shlex +import shutil +import subprocess +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def run(step): + argv = shlex.split(step) + cwd = ROOT + if argv and argv[0].startswith('@'): + cwd = os.path.join(ROOT, argv.pop(0)[1:]) + if not argv: + sys.exit(f'hook_steps: empty step {step!r}') + if argv[0] in ('python', 'python3'): + argv[0] = sys.executable + else: + argv[0] = shutil.which(argv[0]) or argv[0] + try: + return subprocess.run(argv, cwd=cwd).returncode + except OSError as e: + print(f'hook_steps: {step}: {e}', file=sys.stderr) + return 127 + + +def main(steps): + for step in steps: + rc = run(step) + if rc: + return rc + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) From c1c44b04f18b7ce878e47ba55c4e7e7ab8c9b674 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Thu, 12 Nov 2026 14:49:14 +0100 Subject: [PATCH 3/4] Address review: venv python for every hook, test hook_steps.py - Python hooks are language: python with a `python` entry: a python.org Windows install has no python3.exe, and pre-commit's venv supplies `python` on every OS. - tools/hook_steps.py gets its own suite (stop at the first failure, @DIR, python -> the hook's interpreter, missing command -> 127) and hook, listed in test_change_impact's carve-out of non-rig test files. - test_ci_metrics skips before a setup that symlinks when no bash runs. - test_hil_report starts hil_report.py with sys.executable. - test_setup_toolchain reads and writes its files as UTF-8. --- .pre-commit-config.yaml | 44 ++++---- test/hil/test/test_change_impact.py | 1 + test/hil/test/test_ci_metrics.py | 3 + test/hil/test/test_hil_report.py | 4 +- test/hil/test/test_hook_steps.py | 141 ++++++++++++++++++++++++++ test/hil/test/test_setup_toolchain.py | 14 +-- tools/hook_steps.py | 2 +- 7 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 test/hil/test/test_hook_steps.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 19175735d5..8e80b1b00c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,28 +40,31 @@ repos: - repo: local hooks: # A multi-step or cd-ing entry goes through tools/hook_steps.py: Windows has no sh to run `sh -c`. + # A Python hook is language: python with a `python` entry, not language: system with + # `python3`: a python.org Windows install has no python3.exe, while pre-commit's venv + # supplies `python` on every OS. Non-Python commands (ceedling, cmake) still come from PATH. - id: unique-example-pids name: unique example USB PIDs files: usb_descriptors\.c$ - entry: python3 tools/check_example_pids.py + entry: python tools/check_example_pids.py pass_filenames: false - language: system + language: python - id: gen-doc name: regenerate reference docs files: ^(hw/bsp/.*/(board\.h|family\.c)|tools/get_deps\.py|test/hil/(tinyusb|hfp)\.json|\.claude/skills/build-doc/scripts/gen_doc\.py)$ - entry: python3 .claude/skills/build-doc/scripts/gen_doc.py + entry: python .claude/skills/build-doc/scripts/gen_doc.py pass_filenames: false - language: system + language: python - id: unit-test name: unit-test files: ^(src/|test/unit-test/) - entry: python3 tools/hook_steps.py + entry: python tools/hook_steps.py args: ['@test/unit-test ceedling test:all'] pass_filenames: false types_or: [c, header] - language: system + language: python # Two hooks, split by what each suite RUNS, not by what it reads: discovery is # disjoint (test_hil*.py vs the two named suites) so nothing runs twice, but the @@ -94,25 +97,32 @@ repos: additional_dependencies: [pyusb, pyserial] - id: family-json name: family-json - entry: python3 tools/hook_steps.py - args: ['python3 tools/family_json.py fix', 'python3 test/hil/test/test_family_json.py'] + entry: python tools/hook_steps.py + args: ['python tools/family_json.py fix', 'python test/hil/test/test_family_json.py'] always_run: true pass_filenames: false - language: system + language: python - id: change-impact-test name: change-impact-test files: ^(hw/bsp/|hw/mcu/|src/|examples/|test/hil/|tools/(change_impact|build|build_utils|family_json|get_deps)\.py$|\.github/(scripts|workflows)/|\.circleci/) - entry: python3 tools/hook_steps.py - args: ['python3 test/hil/test/test_change_impact.py', 'python3 test/hil/test/test_ci_metrics.py', - '@test/hil/test python3 -m unittest -q test_hil_util.BottomLayer'] + entry: python tools/hook_steps.py + args: ['python test/hil/test/test_change_impact.py', 'python test/hil/test/test_ci_metrics.py', + '@test/hil/test python -m unittest -q test_hil_util.BottomLayer'] + pass_filenames: false + language: python + # the runner of the multi-step hooks above: stop at the first failure, @DIR, python -> venv + - id: hook-steps-test + name: hook-steps-test + files: ^(tools/hook_steps\.py|test/hil/test/test_hook_steps\.py)$ + entry: python test/hil/test/test_hook_steps.py pass_filenames: false - language: system + language: python - id: setup-toolchain-test name: setup-toolchain-test files: ^(\.github/actions/setup_toolchain/|test/hil/test/test_setup_toolchain\.py$) - entry: python3 test/hil/test/test_setup_toolchain.py + entry: python test/hil/test/test_setup_toolchain.py pass_filenames: false - language: system + language: python # a second copy lets GHA and CircleCI build on different IDF versions after a bump - id: esp-idf-image-pin name: esp-idf image pinned only in toolchain.json @@ -145,8 +155,8 @@ repos: name: membrowse-targets # build.py's toolchain-free target list vs a real configure: only the files that list reads files: ^(tools/(build|build_utils|membrowse_cli)\.py|hw/bsp/.*\.(cmake|mk)|examples/.*/(skip|only)\.txt|examples/.*CMakeLists\.txt|test/hil/test/test_membrowse_targets\.py)$ - entry: python3 test/hil/test/test_membrowse_targets.py - language: system + entry: python test/hil/test/test_membrowse_targets.py + language: python pass_filenames: false # - id: build-fuzzer diff --git a/test/hil/test/test_change_impact.py b/test/hil/test/test_change_impact.py index e0f19c2e81..9c63f2c664 100644 --- a/test/hil/test/test_change_impact.py +++ b/test/hil/test/test_change_impact.py @@ -1104,6 +1104,7 @@ def test_nothing_rig_affecting_has_moved_into_the_carve_out(self): 'test/hil/test/test_hil_usbtest_verdict.py', 'test/hil/test/test_hil_util.py', 'test/hil/test/test_hil_wedge_sweep.py', + 'test/hil/test/test_hook_steps.py', 'test/hil/test/test_membrowse_cli.py', 'test/hil/test/test_membrowse_targets.py', 'test/hil/test/test_setup_toolchain.py', diff --git a/test/hil/test/test_ci_metrics.py b/test/hil/test/test_ci_metrics.py index d5c9eafcf1..bcfb5ac4a1 100644 --- a/test/hil/test/test_ci_metrics.py +++ b/test/hil/test/test_ci_metrics.py @@ -499,6 +499,9 @@ def _run_block(self, block, sel, setup=None): def _run_block_in(self, d, block, sel, setup=None): import subprocess + # skip before setup(): its os.symlink needs a privilege Windows withholds by default + if _no_bash(): + raise unittest.SkipTest(_no_bash()) with open(os.path.join(d, 'selection.json'), 'w', encoding='utf-8', newline='\n') as fh: json.dump(sel, fh) out = os.path.join(d, 'gh_output') diff --git a/test/hil/test/test_hil_report.py b/test/hil/test/test_hil_report.py index 96d4349fea..1974ff46d7 100644 --- a/test/hil/test/test_hil_report.py +++ b/test/hil/test/test_hil_report.py @@ -436,7 +436,7 @@ def _sum(self, boards, rows, cfg_boards=None): cfg = d / 'cfg.json' cfg.write_text(json.dumps({'boards': cfg_boards or [{'name': b} for b in boards]}), encoding='utf-8') args = [a for b in boards for a in ('-b', b)] - r = subprocess.run(['python3', str(Path(TEST_DIR).parents[0] / 'helper' / 'hil_report.py'), + r = subprocess.run([sys.executable, str(Path(TEST_DIR).parents[0] / 'helper' / 'hil_report.py'), str(cfg), *args, '--report-dir', str(d)], capture_output=True, text=True, timeout=60) self.assertEqual(r.returncode, 0, r.stderr) @@ -661,7 +661,7 @@ def test_the_caveat_reaches_the_agents_verdict(self): cfg = d / 'cfg.json' cfg.write_text(json.dumps({'boards': [{'name': 'boardA'}]}), encoding='utf-8') r = subprocess.run( - ['python3', str(Path(TEST_DIR).parents[0] / 'helper' / 'hil_report.py'), + [sys.executable, str(Path(TEST_DIR).parents[0] / 'helper' / 'hil_report.py'), str(cfg), '-b', 'boardA', '--report-dir', str(d)], capture_output=True, text=True, timeout=60) self.assertEqual(r.returncode, 0, r.stderr) diff --git a/test/hil/test/test_hook_steps.py b/test/hil/test/test_hook_steps.py new file mode 100644 index 0000000000..8d105cb131 --- /dev/null +++ b/test/hil/test/test_hook_steps.py @@ -0,0 +1,141 @@ +"""tools/hook_steps.py: the shell-free runner of the multi-step pre-commit hooks. + +Steps run in order and the first failure stops the hook with its exit code, a leading +`@DIR` runs a step in ROOT/DIR, `python`/`python3` run as the hook's own interpreter +(a language: python hook's venv), and a command that cannot start is 127. +Stdlib only: the module is imported by path, its steps are real child processes +except where subprocess.run is mocked to read the argv it would get.""" +import contextlib +import importlib.util +import io +import os +import pathlib +import shlex +import sys +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[3] +_spec = importlib.util.spec_from_file_location('hook_steps', ROOT / 'tools' / 'hook_steps.py') +hs = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(hs) + +# Writes argv[1] (cwd when argv[2] is 'cwd', else empty) and exits with int(argv[2]) or 0. +MARK = ("import os, sys\n" + "cwd = sys.argv[2] == 'cwd'\n" + "with open(sys.argv[1], 'w', encoding='utf-8') as f:\n" + " f.write(os.getcwd() if cwd else '')\n" + "sys.exit(0 if cwd else int(sys.argv[2]))\n") + + +def step(path, arg, interp='python', at=None): + """One hook step running MARK; forward slashes so shlex keeps a Windows path whole.""" + words = [interp, '-c', MARK, str(path).replace('\\', '/'), str(arg)] + return (f'@{at} ' if at else '') + shlex.join(words) + + +def same(a, b): + return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b)) + + +class StopsAtFirstFailure(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.marks = [pathlib.Path(self.tmp.name, f'm{i}') for i in range(3)] + + def test_returns_first_nonzero_rc_and_skips_later_steps(self): + rc = hs.main([step(self.marks[0], 0), step(self.marks[1], 3), step(self.marks[2], 0)]) + self.assertEqual(rc, 3) + self.assertTrue(self.marks[0].exists(), 'the step before the failure ran') + self.assertTrue(self.marks[1].exists(), 'the failing step ran') + self.assertFalse(self.marks[2].exists(), 'a step after the failure ran') + + def test_all_steps_pass_returns_zero(self): + rc = hs.main([step(m, 0) for m in self.marks]) + self.assertEqual(rc, 0) + for m in self.marks: + self.assertTrue(m.exists(), f'{m.name} did not run') + + def test_no_steps_is_success(self): + self.assertEqual(hs.main([]), 0) + + +class WorkingDirectory(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.out = pathlib.Path(self.tmp.name, 'cwd') + + def test_at_dir_runs_in_root_dir(self): + self.assertEqual(hs.main([step(self.out, 'cwd', at='test/hil')]), 0) + self.assertTrue(same(self.out.read_text(encoding='utf-8'), ROOT / 'test' / 'hil')) + + def test_no_at_dir_runs_in_root(self): + self.assertEqual(hs.main([step(self.out, 'cwd')]), 0) + self.assertTrue(same(self.out.read_text(encoding='utf-8'), ROOT)) + + def test_root_is_the_repo_root(self): + self.assertTrue(same(hs.ROOT, ROOT)) + + +class CommandResolution(unittest.TestCase): + def argv_of(self, s): + with mock.patch.object(hs.subprocess, 'run') as run: + run.return_value.returncode = 0 + self.assertEqual(hs.run(s), 0) + run.assert_called_once() + return run.call_args + + def test_python_and_python3_become_this_interpreter(self): + for interp in ('python', 'python3'): + with self.subTest(interp=interp): + call = self.argv_of(f'{interp} -m unittest -q x') + self.assertEqual(call.args[0], [sys.executable, '-m', 'unittest', '-q', 'x']) + self.assertTrue(same(call.kwargs['cwd'], ROOT)) + + def test_python_after_at_dir_becomes_this_interpreter(self): + call = self.argv_of('@test/hil/test python3 -m unittest -q test_hil_util.BottomLayer') + self.assertEqual(call.args[0][0], sys.executable) + self.assertTrue(same(call.kwargs['cwd'], ROOT / 'test' / 'hil' / 'test')) + + def test_only_the_command_word_is_substituted(self): + call = self.argv_of('ceedling python3') + self.assertEqual(call.args[0][1], 'python3') + + def test_other_commands_resolve_on_path(self): + with mock.patch.object(hs.shutil, 'which', return_value='/opt/bin/ceedling') as which: + call = self.argv_of('@test/unit-test ceedling test:all') + which.assert_called_once_with('ceedling') + self.assertEqual(call.args[0], ['/opt/bin/ceedling', 'test:all']) + self.assertTrue(same(call.kwargs['cwd'], ROOT / 'test' / 'unit-test')) + + +class Errors(unittest.TestCase): + def test_missing_command_is_127(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = hs.main(['hook-steps-no-such-command-4550907096 --flag']) + self.assertEqual(rc, 127) + self.assertIn('hook-steps-no-such-command-4550907096', err.getvalue()) + + def test_missing_command_stops_later_steps(self): + with tempfile.TemporaryDirectory() as tmp: + mark = pathlib.Path(tmp, 'm') + with contextlib.redirect_stderr(io.StringIO()): + rc = hs.main(['hook-steps-no-such-command-4550907096', step(mark, 0)]) + self.assertEqual(rc, 127) + self.assertFalse(mark.exists()) + + def test_empty_step_exits(self): + for s in ('', ' ', '@test/hil'): + with self.subTest(step=s), mock.patch.object(hs.subprocess, 'run') as run: + with self.assertRaises(SystemExit) as cm: + hs.main([s]) + run.assert_not_called() + self.assertIn('empty step', str(cm.exception.code)) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/hil/test/test_setup_toolchain.py b/test/hil/test/test_setup_toolchain.py index 0f943f2122..3d54716a58 100644 --- a/test/hil/test/test_setup_toolchain.py +++ b/test/hil/test/test_setup_toolchain.py @@ -30,7 +30,7 @@ class Manifest(unittest.TestCase): def setUp(self): - with open(os.path.join(ACTION, 'toolchain.json')) as f: + with open(os.path.join(ACTION, 'toolchain.json'), encoding='utf-8') as f: self.manifest = json.load(f) def test_every_archive_entry_pins_an_https_url_and_sha256(self): @@ -63,12 +63,12 @@ def setUp(self): def _write_exe(self, name, text): path = os.path.join(self.bin, name) - with open(path, 'w', newline='\n') as f: # a CRLF shebang line does not run + with open(path, 'w', encoding='utf-8', newline='\n') as f: # a CRLF shebang line does not run f.write(text) os.chmod(path, 0o755) def _manifest(self, **entries): - with open(os.path.join(self.tmp, 'toolchain.json'), 'w') as f: + with open(os.path.join(self.tmp, 'toolchain.json'), 'w', encoding='utf-8') as f: json.dump(entries, f) def fetch(self, name='tc', path=None, **env): @@ -87,9 +87,9 @@ def test_matching_digest_succeeds(self): self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz', 'sha256': DIGEST}) r = self.fetch() self.assertEqual(r.returncode, 0, r.stderr) - with open(self.out) as f: + with open(self.out, encoding='utf-8') as f: self.assertEqual(f.read(), PAYLOAD) - with open(self.curl_log) as f: + with open(self.curl_log, encoding='utf-8') as f: self.assertIn('https://example.invalid/tc.tar.xz', f.read()) def test_mismatched_digest_fails(self): @@ -128,12 +128,12 @@ def test_native_windows_jq_line_endings(self): self._manifest(tc={'url': 'https://example.invalid/tc.tar.xz', 'sha256': DIGEST}) r = self.fetch() self.assertEqual(r.returncode, 0, r.stderr) - with open(self.curl_log) as f: + with open(self.curl_log, encoding='utf-8') as f: self.assertIn('https://example.invalid/tc.tar.xz -o', f.read()) def test_curl_restricts_to_https_including_redirects(self): # a behavioural check needs a TLS server; pin the flags that enforce it instead - with open(FETCH) as f: + with open(FETCH, encoding='utf-8') as f: script = f.read() self.assertIn("--proto '=https'", script) self.assertIn("--proto-redir '=https'", script) diff --git a/tools/hook_steps.py b/tools/hook_steps.py index 37e575cd60..75b386172b 100644 --- a/tools/hook_steps.py +++ b/tools/hook_steps.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Run a pre-commit hook's steps in order, stopping at the first failure, with no shell: Windows has no `sh`. - python3 tools/hook_steps.py 'python3 tools/family_json.py fix' '@test/unit-test ceedling test:all' + python tools/hook_steps.py 'python tools/family_json.py fix' '@test/unit-test ceedling test:all' Each argument is one step, split like a POSIX shell word list. A leading `@DIR` runs it in DIR, relative to the repo root. A step whose command is `python` or `python3` runs with this interpreter, so a `language: python` hook's steps From f132421cd9054a2052735e38d61dac02659162d7 Mon Sep 17 00:00:00 2001 From: HiFiPhile Date: Thu, 12 Nov 2026 15:33:53 +0100 Subject: [PATCH 4/4] Address review: probe that bash runs fetch.sh before the Fetch tests On Windows, bash may be WSL's launcher with no distribution behind it, so presence alone does not make the Fetch tests runnable. Skip them unless a bash can read fetch.sh by the path the tests pass it, as test_ci_metrics.py does. --- test/hil/test/test_setup_toolchain.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/test/hil/test/test_setup_toolchain.py b/test/hil/test/test_setup_toolchain.py index 3d54716a58..4d3925e274 100644 --- a/test/hil/test/test_setup_toolchain.py +++ b/test/hil/test/test_setup_toolchain.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Tests for .github/actions/setup_toolchain: the toolchain manifest and fetch.sh, with curl stubbed.""" +import functools import hashlib import json import os @@ -28,6 +29,23 @@ exit 2 ''' + +@functools.cache +def _bash_runs_fetch_sh(): + """Whether BASH can read fetch.sh by the path the tests hand it. + + On Windows `bash` may be WSL's launcher: with no distribution it does not run, and with one it + cannot open a Windows path, so presence alone does not make the Fetch tests runnable. + """ + if not BASH: + return False + try: + return subprocess.run([BASH, '-c', 'test -r "$1"', '_', FETCH], + capture_output=True, timeout=60).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + class Manifest(unittest.TestCase): def setUp(self): with open(os.path.join(ACTION, 'toolchain.json'), encoding='utf-8') as f: @@ -47,7 +65,8 @@ def test_esp_idf_pins_a_release_image(self): self.assertRegex(self.manifest['esp-idf']['image'], r'^espressif/idf:v\d+(\.\d+)*$') -@unittest.skipUnless(BASH and shutil.which('jq'), 'fetch.sh needs bash and jq') +@unittest.skipUnless(shutil.which('jq') and _bash_runs_fetch_sh(), + 'fetch.sh needs jq and a bash that runs it by this path') class Fetch(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp()