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..8e80b1b00c 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,32 +33,38 @@ repos: exclude: | (?x)^( lib/| - hw/bsp/mcx/sdk/ + hw/bsp/mcx/sdk/| + tools/codespell/ ) - 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: sh -c "cd test/unit-test && ceedling test:all" + 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 @@ -75,32 +86,43 @@ 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] - id: family-json name: family-json - entry: sh -c "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: 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: 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: system + 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: 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 @@ -115,7 +137,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 +145,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 && python3 -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] @@ -132,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/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..9c63f2c664 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: @@ -1103,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', @@ -1510,7 +1512,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 +1632,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 +1698,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 +2151,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 +2216,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 +2442,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 +2463,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 +2580,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 +2593,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 +2637,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..bcfb5ac4a1 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 @@ -19,6 +20,35 @@ 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 '') + + +@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: + return f.read() class TestCircleCiSentinelContract(unittest.TestCase): @@ -28,8 +58,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 +105,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 +114,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 +124,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 +145,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 = 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 +198,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 +226,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 +258,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 = 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 +320,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 +362,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 +384,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, + 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 +400,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 +413,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 +422,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 = 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 = 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 +455,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 +466,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 = 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 +499,18 @@ 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: + # 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') 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) 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 +531,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 +591,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 +605,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 +712,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..1974ff46d7 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,11 +432,11 @@ 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'), + 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) @@ -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,11 +657,11 @@ 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'), + [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) @@ -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_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 d19303a353..4d3925e274 100644 --- a/test/hil/test/test_setup_toolchain.py +++ b/test/hil/test/test_setup_toolchain.py @@ -1,18 +1,22 @@ #!/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 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 @@ -25,9 +29,26 @@ 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')) 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): @@ -44,6 +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(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() @@ -59,17 +82,22 @@ 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', 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): - 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) @@ -78,9 +106,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): @@ -114,16 +142,17 @@ 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) - 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/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 diff --git a/tools/hook_steps.py b/tools/hook_steps.py new file mode 100644 index 0000000000..75b386172b --- /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`. + + 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 +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:]))