summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/hil/helper/hil_pool_check.py4
-rwxr-xr-xtest/hil/helper/hil_select.py524
-rw-r--r--test/hil/helper/hil_util.py2
-rw-r--r--test/hil/hil_ci.sh1
-rwxr-xr-xtest/hil/hil_flash.py2
-rwxr-xr-xtest/hil/hil_test.py14
-rw-r--r--test/hil/test/test_ci_metrics.py463
-rw-r--r--test/hil/test/test_ci_select.py2202
-rw-r--r--test/hil/test/test_hil_select.py689
-rw-r--r--test/hil/test/test_hil_util.py17
-rw-r--r--test/hil/tinyusb.json13
11 files changed, 2689 insertions, 1242 deletions
diff --git a/test/hil/helper/hil_pool_check.py b/test/hil/helper/hil_pool_check.py
index d926bbe3d..179a417ed 100644
--- a/test/hil/helper/hil_pool_check.py
+++ b/test/hil/helper/hil_pool_check.py
@@ -433,7 +433,7 @@ def build_example(board: dict, variant: str, example: str) -> int:
cmd = ['idf.py', '-C', f'examples/{example}',
'-B', f'cmake-build/cmake-build-{vcfg["name"]}/{example}',
'-G', 'Ninja', f'-DBOARD={name}', 'build']
- for d in board.get('build', {}).get('args', []) + vcfg.get('defines', []):
+ for d in vcfg.get('defines', []):
cmd.insert(-1, f'-D{d}')
if vcfg.get('flags'):
cmd.insert(-1, f'-DCFLAGS_CLI={vcfg["flags"]}')
@@ -446,8 +446,6 @@ def build_example(board: dict, variant: str, example: str) -> int:
cmd = [sys.executable, str(hil_util.TINYUSB_ROOT / 'tools' / 'build.py'),
'-b', name, '-T', Path(example).name,
'-j', str(max(1, (os.cpu_count() or _jobs) // _jobs))]
- for d in board.get('build', {}).get('args', []):
- cmd += ['-D', d]
if vcfg['name'] != name:
cmd += ['--build-name', vcfg['name']]
for d in vcfg.get('defines', []):
diff --git a/test/hil/helper/hil_select.py b/test/hil/helper/hil_select.py
deleted file mode 100755
index f0d4f0b9f..000000000
--- a/test/hil/helper/hil_select.py
+++ /dev/null
@@ -1,524 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MIT
-"""PR-diff -> HIL selection: which rig boards and which tests a change can affect.
-
-Stdlib-only (runs on bare CI runners; imports hil_util for the example rosters,
-never hil_test/pyserial — test_hil_util.BottomLayer enforces the stdlib closure).
-Fail-open: any file no rule classifies forces the full matrix. See
-docs/superpowers/specs/2026-07-29-hil-pr-scoped-selection-design.md.
-
-JSON: full, boards (name -> 'all' | [tests]), families (bsp families the diff
-touches, including ones with no rig board - build-only consumers such as /pre-pr
-sample from these), args (hil_test.py args per config) and args_flasher (the same
-args split by each board's flasher, for CI legs that split one rig by flasher).
-"""
-import argparse
-import functools
-import glob
-import json
-import os
-import re
-import subprocess
-import sys
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # helper/ scripts import via the test/hil root
-from helper.hil_util import device_tests, dual_tests, host_test
-
-ALL_TESTS = {'device': device_tests, 'dual': dual_tests, 'host': host_test}
-
-# class dir -> config macro suffix exceptions (rule 3); dfu is per-file, handled inline
-NET_MACROS = ('ECM_RNDIS', 'NCM')
-
-_NONCODE_RE = re.compile(
- r'^(docs/|\.claude/|.*\.(md|rst)$|LICENSE)')
-_FULL_RE = re.compile(
- r'^(src/common/|src/osal/|src/tusb\.c$|src/tusb\.h$|src/tusb_option\.h$|'
- r'test/hil/|\.github/workflows/build.*\.yml$|\.github/actions/|\.github/scripts/|'
- r'tools/build\.py$|tools/get_deps\.py$|tools/cmake/|hw/mcu/|lib/|'
- r'hw/bsp/(family_support\.cmake|board_api\.h|board\.c|ansi_escape\.h)$|'
- r'examples/build_system/|examples/CMakeLists\.txt$|'
- # board_test is HIL infrastructure, not a test: hil_test.py flashes it to park
- # every board (variant boundary + end-of-board teardown), so every board depends on it
- r'examples/device/board_test/)')
-
-# --no-renames: with rename detection git reports only a rename's destination, so code
-# moved out of an HIL-relevant path would be classified by its new path alone
-GIT_DIFF_ARGV = ['git', 'diff', '--no-renames', '--name-only']
-
-
-def test_role(test: str) -> str:
- return test.split('/', 1)[0] # 'device' | 'dual' | 'host'
-
-
-def board_roles(board: dict) -> set:
- t = board.get('tests', {})
- roles = set()
- if t.get('device'):
- roles.add('device')
- if t.get('host'):
- roles.add('host')
- if t.get('dual'):
- roles.update(('device', 'host'))
- for only in t.get('only', []):
- r = test_role(only)
- roles.update(('device', 'host') if r == 'dual' else (r,))
- return roles
-
-
-def board_tests(board: dict) -> list:
- """Every test this board would run today (mirrors hil_test.test_board's default)."""
- t = board.get('tests', {})
- if 'only' in t:
- run = list(t['only'])
- else:
- run = []
- if t.get('device'):
- run += device_tests
- if t.get('dual'):
- run += dual_tests
- if t.get('host'):
- run += host_test
- return [x for x in run if x not in t.get('skip', [])]
-
-
-# cached: called per changed file x roster board, and the tree doesn't change mid-run
[email protected]_cache(maxsize=None)
-def board_family(board_name: str, repo_root: str):
- hits = glob.glob(os.path.join(repo_root, 'hw/bsp/*/boards', board_name))
- return os.path.basename(os.path.dirname(os.path.dirname(hits[0]))) if hits else None
-
-
-# `if (OPTION STREQUAL "1")` guards in family_support.cmake, and the option tokens
-# a roster entry passes to the build (NAME=VALUE / -DNAME=VALUE)
-_CM_IF_RE = re.compile(r'if\s*\(')
-_CM_ELSE_RE = re.compile(r'else(if)?\s*\(')
-_CM_ENDIF_RE = re.compile(r'endif\s*\(')
-_CM_OPT_RE = re.compile(r'if\s*\(\s*\$?\{?([A-Za-z_]\w*)\}?\s+STREQUAL\s+"?1"?\s*\)')
-_CM_PORT_RE = re.compile(r'src/portable/((?:[^/\s]+/)?[^/\s]+)/')
-_FALSY = ('', '0', 'off', 'false', 'no')
-
-
[email protected]_cache(maxsize=None)
-def port_option_gates(repo_root: str) -> dict:
- """port dir -> build options that compile it regardless of the board's family
- file, e.g. {'analog/max3421': {'MAX3421_HOST'}} from family_support.cmake."""
- gates = {}
- try:
- text = open(os.path.join(repo_root, 'hw/bsp/family_support.cmake')).read()
- except OSError:
- return gates
- stack = [] # one entry per open if(): its option, or None
- for line in text.splitlines():
- line = line.strip()
- if _CM_IF_RE.match(line):
- m = _CM_OPT_RE.match(line)
- stack.append(m.group(1) if m else None)
- elif _CM_ELSE_RE.match(line):
- if stack:
- stack[-1] = None # the guard doesn't hold in this branch
- elif _CM_ENDIF_RE.match(line):
- if stack:
- stack.pop()
- opts = {o for o in stack if o}
- m = _CM_PORT_RE.search(line)
- if opts and m:
- gates.setdefault(m.group(1), set()).update(opts)
- return gates
-
-
-_CM_SET_RE = re.compile(r'set\s*\(\s*([A-Za-z_]\w*)\s+([^)\s]+)\s*\)')
-
-
-# cached: called per changed portable file x roster board
[email protected]_cache(maxsize=None)
-def bsp_board_options(board_name: str, repo_root: str) -> frozenset:
- """Build options a board turns on in its own BSP: `set(<OPT> <value>)` in
- hw/bsp/<family>/boards/<board>/board.cmake, e.g. MAX3421_HOST on the espressif
- and rp2040 max3421 boards. CMake only - HIL CI builds nothing with Make, so a
- board.mk-only option (e.g. nrf5340dk's MAX3421_HOST) compiles no port here."""
- fam = board_family(board_name, repo_root)
- if not fam:
- return frozenset()
- path = os.path.join(repo_root, 'hw/bsp', fam, 'boards', board_name, 'board.cmake')
- try:
- text = open(path).read()
- except OSError:
- return frozenset()
- out = set()
- for line in text.splitlines():
- line = line.strip()
- if line.startswith('#'):
- continue
- m = _CM_SET_RE.match(line)
- if m and m.group(2).strip('"').lower() not in _FALSY:
- out.add(m.group(1))
- return frozenset(out)
-
-
-def board_options(board: dict, repo_root: str) -> set:
- """Build options a board has truthy: the roster entry's build.args plus each
- variant's defines (NAME=VALUE) and raw CFLAGS (-DNAME=VALUE), plus whatever its
- own board.cmake sets (a board can enable a gated port without the roster saying so)."""
- toks = list(board.get('build', {}).get('args', []))
- for v in board.get('variant', []):
- toks += list(v.get('defines', []))
- toks += v.get('flags', '').split()
- out = set(bsp_board_options(board['name'], repo_root))
- for t in toks:
- name, _, val = (t[2:] if t.startswith('-D') else t).partition('=')
- if name and val.strip().strip('"').lower() not in _FALSY:
- out.add(name.strip())
- return out
-
-
[email protected]_cache(maxsize=None)
-def port_families(port_dir: str, repo_root: str) -> set:
- """Board families that compile this src/portable dir. CMake only: HIL CI builds
- every board with CMake, so a port wired up in family.mk alone is compiled for no
- HIL board and must not select one. family.cmake lists portable sources directly
- for most families; espressif instead references them from a nested component
- CMakeLists.txt (hw/bsp/espressif/components/tinyusb_src/CMakeLists.txt)."""
- fams = set()
- bsp_root = os.path.join(repo_root, 'hw/bsp')
- # trailing '/' so a port dir is not a prefix of a sibling: bare 'microchip/pic'
- # would otherwise match '.../microchip/pic32mz/...' and inherit its families
- needle = port_dir + '/'
- for f in glob.glob(os.path.join(bsp_root, '*/family.cmake')) + \
- glob.glob(os.path.join(bsp_root, '*/components/*/CMakeLists.txt')):
- try:
- if needle in open(f).read():
- fam = os.path.relpath(f, bsp_root).split(os.sep, 1)[0]
- fams.add(fam)
- except OSError:
- pass
- return fams
-
-
-_CLS_INC_RE = re.compile(r'#\s*include\s*[<"]class/([^/"<>]+)/([^"<>]+)[">]')
-
-
[email protected]_cache(maxsize=None)
-def class_include_edges(repo_root: str) -> dict:
- """'<class>/<header>' -> the other class dirs that include it. A class header
- pulled in by a second class ships in every firmware enabling that second class:
- src/class/midi/midi{,2}_{device,host}.h include class/audio/audio.h, and
- net_device.h includes class/cdc/cdc.h. The class rule derives macros from the
- directory name alone, so without this edge a change to the included header
- selects only its own class's examples - and on a board that skips those (e.g.
- metro_m4_express skips audio_test_freertos), nothing at all.
-
- Derived from the actual #include lines rather than a hand-written table so it
- cannot rot when a class picks up or drops a cross-class include."""
- edges = {}
- for f in sorted(glob.glob(os.path.join(repo_root, 'src/class/*/*.[ch]'))):
- cls = os.path.basename(os.path.dirname(f))
- try:
- text = open(f).read()
- except OSError:
- continue
- for inc_cls, inc_hdr in _CLS_INC_RE.findall(text):
- if inc_cls != cls:
- edges.setdefault(f'{inc_cls}/{inc_hdr}', set()).add(cls)
- return edges
-
-
-def class_macros(cls: str, base: str, prefix: str) -> list:
- """Config macros that compile a class dir's code, for role prefix TUD/TUH.
- `base` refines dfu only (it splits DFU from DFU_RUNTIME per file); pass '' for
- a class reached through an include edge, where the widest set is correct."""
- if cls == 'net':
- return [f'CFG_{prefix}_{m}' for m in NET_MACROS]
- if cls == 'dfu':
- if base.startswith('dfu_rt'):
- return [f'CFG_{prefix}_DFU_RUNTIME']
- if base.startswith('dfu_device') or base.startswith('dfu_host'):
- return [f'CFG_{prefix}_DFU']
- return [f'CFG_{prefix}_DFU', f'CFG_{prefix}_DFU_RUNTIME']
- return [f'CFG_{prefix}_{cls.upper()}']
-
-
-def _config_enables(cfg_path: str, macros) -> bool:
- try:
- text = open(cfg_path).read()
- except OSError:
- return False
- return any(re.search(rf'#define\s+{m}\s+\(?\s*0*[1-9]', text) for m in macros)
-
-
-def roster_only_tests(all_boards) -> set:
- """Test paths that only appear in a roster board's tests.only list (e.g.
- espressif boards), not in the shared device/dual/host_test lists."""
- out = set()
- for b in all_boards:
- out.update(b.get('tests', {}).get('only', []))
- return out
-
-
-def class_examples(macros, role: str, repo_root: str, extra_tests: set) -> set:
- """Tests (from role's + dual lists, plus roster-only-list tests of that role)
- whose example config enables any macro."""
- pool = role_tests({role}, extra_tests)
- out = set()
- for test in pool:
- cfg = os.path.join(repo_root, 'examples', test, 'src', 'tusb_config.h')
- if _config_enables(cfg, macros):
- out.add(test)
- return out
-
-
-def role_tests(roles: set, extras: set) -> set:
- """Every test for the given role(s): each role's own list + dual tests,
- plus roster-only-list tests (extras) matching those roles or 'dual'."""
- pool = set(dual_tests)
- for r in roles:
- pool |= set(ALL_TESTS[r])
- pool |= {t for t in extras if test_role(t) in roles or test_role(t) == 'dual'}
- return pool
-
-
-class _Sel:
- """Accumulates contributions. board->set(tests) plus 'all-board' markers."""
- def __init__(self):
- self.full = False
- self.by_board = {} # name -> set of tests, or 'all'
- self.roles = set() # roles touched by any contribution
- self.families = set() # bsp families touched (incl. off-rig ones: build-only consumers)
- self.reasons = []
-
- def add(self, boards, tests, reason):
- """tests: 'all' or iterable of test paths."""
- self.reasons.append(reason)
- for b in boards:
- cur = self.by_board.get(b)
- if tests == 'all' or cur == 'all':
- self.by_board[b] = 'all'
- else:
- self.by_board[b] = (cur or set()) | set(tests)
-
- def force_full(self, reason):
- self.full = True
- self.reasons.append(reason)
-
-
-def _classify_one(path, repo_root, roster_boards, extras: set, s: _Sel):
- base = os.path.basename(path)
- if _NONCODE_RE.match(path):
- s.reasons.append(f'{path}: non-code, no contribution')
- return
- if _FULL_RE.match(path):
- s.force_full(f'{path}: core/infra -> full matrix')
- return
-
- m = re.match(r'src/portable/((?:[^/]+/)?[^/]+)/', path)
- if m:
- port = m.group(1)
- if re.match(r'(dcd_|.*_device)', base):
- roles = {'device'}
- elif re.match(r'(hcd_|.*_host)', base):
- roles = {'host'}
- else:
- roles = {'device', 'host'}
- fams = port_families(port, repo_root)
- if not fams:
- # no family references this port: either a new/renamed port dir or a
- # family.cmake layout the scan misses - widen instead of contributing nothing
- s.force_full(f'{path}: port {port} maps to no board family -> full matrix')
- return
- s.families.update(fams)
- # a board can also pull the port in through a build option (e.g. MAX3421_HOST=1
- # from the roster on metro_m4_express, or from its own board.cmake), which its
- # family file never names
- gates = port_option_gates(repo_root).get(port, set())
- boards = [b['name'] for b in roster_boards
- if (board_family(b['name'], repo_root) in fams or
- (gates and board_options(b, repo_root) & gates)) and (board_roles(b) & roles)]
- tests = role_tests(roles, extras)
- s.roles.update(roles)
- why = f'{path}: port {port} -> families {sorted(fams)}'
- if gates:
- why += f' + option {sorted(gates)}'
- s.add(boards, tests, f'{why} -> boards {boards} ({"/".join(sorted(roles))})')
- return
-
- m = re.match(r'src/class/([^/]+)/', path)
- if m:
- cls = m.group(1)
- if re.search(r'_device\.[ch]$', base):
- roles = {'device'}
- elif re.search(r'_host\.[ch]$', base):
- roles = {'host'}
- else:
- roles = {'device', 'host'}
- # this file's own class, plus any class whose headers include it
- via = sorted(class_include_edges(repo_root).get(f'{cls}/{base}', ()))
-
- def macros(prefix):
- return (class_macros(cls, base, prefix) +
- [m2 for c in via for m2 in class_macros(c, '', prefix)])
- tests = set()
- if 'device' in roles:
- tests |= class_examples(macros('TUD'), 'device', repo_root, extras)
- if 'host' in roles:
- tests |= class_examples(macros('TUH'), 'host', repo_root, extras)
- boards = [b['name'] for b in roster_boards if board_roles(b) & roles]
- s.roles.update(roles)
- why = f'{path}: class {cls}' + (f' (+ included by {via})' if via else '')
- s.add(boards, tests, f'{why} -> {sorted(tests)} ({"/".join(sorted(roles))})')
- return
-
- m = re.match(r'src/(device|host)/', path)
- if m:
- role = m.group(1)
- boards = [b['name'] for b in roster_boards if role in board_roles(b)]
- s.roles.add(role)
- s.add(boards, role_tests({role}, extras), f'{path}: core {role} stack -> all {role} tests')
- return
-
- m = re.match(r'hw/bsp/([^/]+)/(?:boards/([^/]+)/)?', path)
- if m:
- fam, brd = m.group(1), m.group(2)
- s.families.add(fam)
- if brd:
- boards = [b['name'] for b in roster_boards if b['name'] == brd]
- why = f'{path}: bsp board {brd}'
- else:
- boards = [b['name'] for b in roster_boards
- if board_family(b['name'], repo_root) == fam]
- why = f'{path}: bsp family {fam}'
- s.roles.update(('device', 'host'))
- s.add(boards, 'all', f'{why} -> boards {boards}')
- return
-
- m = re.match(r'examples/(device|host|dual)/([^/]+)/', path)
- if m:
- test = f'{m.group(1)}/{m.group(2)}'
- known = any(test in pool for pool in ALL_TESTS.values()) or test in extras
- if known:
- boards = [b['name'] for b in roster_boards]
- role = test_role(test)
- s.roles.update(('device', 'host') if role == 'dual' else (role,))
- s.add(boards, [test], f'{path}: example -> {test} on all boards')
- else:
- s.reasons.append(f'{path}: example not in HIL lists, no contribution')
- return
-
- s.force_full(f'{path}: unclassified -> full matrix')
-
-
-def classify(changed_files, repo_root, rosters):
- all_boards = []
- seen = set()
- for _, boards in rosters:
- for b in boards:
- if b['name'] not in seen:
- seen.add(b['name'])
- all_boards.append(b)
-
- extras = roster_only_tests(all_boards)
- s = _Sel()
- # no early exit once full: keep classifying so `families` still reports every
- # family the diff touches (build-only consumers need it). Nothing after the first
- # force_full can change full/boards/args - the full branch below ignores by_board.
- for path in changed_files:
- _classify_one(path, repo_root, all_boards, extras, s)
-
- if s.full:
- return {'full': True, 'boards': {b['name']: 'all' for b in all_boards},
- 'families': sorted(s.families), 'reasons': s.reasons}
-
- # role pruning: single-role selections drop the other role's tests and boards
- by_name = {b['name']: b for b in all_boards}
- out = {}
- for name, tests in s.by_board.items():
- allowed = board_tests(by_name[name])
- if tests == 'all':
- kept = list(allowed)
- else:
- kept = [t for t in allowed if t in tests]
- if s.roles and s.roles != {'device', 'host'}:
- role = next(iter(s.roles))
- kept = [t for t in kept if test_role(t) in (role, 'dual')]
- if kept:
- out[name] = 'all' if set(kept) == set(allowed) else sorted(kept)
- return {'full': False, 'boards': out, 'families': sorted(s.families),
- 'reasons': s.reasons}
-
-
-def _board_args(name, chosen) -> list:
- parts = [f'-b {name}']
- if chosen != 'all':
- parts.append(f'-bt {name}:{",".join(chosen)}')
- return parts
-
-
-def selection_args(sel, rosters):
- """hil_test.py args per config. Empty means either 'full matrix' or 'nothing
- selected' - callers must read sel['full'] to tell them apart."""
- args = {}
- for cfg_path, boards in rosters:
- parts = []
- if not sel['full']:
- for b in boards:
- chosen = sel['boards'].get(b['name'])
- if chosen is not None:
- parts += _board_args(b['name'], chosen)
- args[os.path.basename(cfg_path)] = ' '.join(parts)
- return args
-
-
-def selection_args_by_flasher(sel, rosters):
- """{config: {flasher name: args}}. CI runs one rig as several jobs split by
- flasher (esptool vs the rest); each must gate on its own subset, otherwise the
- other leg runs a filter matching zero boards and reports a vacuous green."""
- out = {}
- for cfg_path, boards in rosters:
- per = {}
- if not sel['full']:
- for b in boards:
- chosen = sel['boards'].get(b['name'])
- if chosen is None:
- continue
- per.setdefault(b.get('flasher', {}).get('name', ''), []).extend(
- _board_args(b['name'], chosen))
- out[os.path.basename(cfg_path)] = {f: ' '.join(p) for f, p in per.items()}
- return out
-
-
-def changed_files_from_git(base, repo_root):
- mb = subprocess.run(['git', 'merge-base', 'HEAD', base], cwd=repo_root,
- capture_output=True, text=True, check=True).stdout.strip()
- diff = subprocess.run(GIT_DIFF_ARGV + [f'{mb}..HEAD'], cwd=repo_root,
- capture_output=True, text=True, check=True).stdout
- return [l for l in diff.splitlines() if l.strip()]
-
-
-def main():
- ap = argparse.ArgumentParser(description=__doc__)
- g = ap.add_mutually_exclusive_group(required=True)
- g.add_argument('--base', help='git ref to diff against (merge-base..HEAD)')
- g.add_argument('--diff-file', help='newline-separated changed-file list')
- ap.add_argument('configs', nargs='+', help='rig roster JSON file(s)')
- a = ap.parse_args()
-
- # test/hil/helper/ -> repo root is FOUR levels up; three left this at <repo>/test
- # after the helper/ move and every repo-relative glob silently matched nothing
- repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
- rosters = []
- for c in a.configs:
- with open(c) as f:
- rosters.append((c, json.load(f)['boards']))
-
- files = (open(a.diff_file).read().splitlines() if a.diff_file
- else changed_files_from_git(a.base, repo_root))
- files = [f for f in files if f.strip()]
-
- s = classify(files, repo_root, rosters)
- s['args'] = selection_args(s, rosters)
- s['args_flasher'] = selection_args_by_flasher(s, rosters)
- for r in s['reasons']:
- print(f'hil_select: {r}', file=sys.stderr)
- print(json.dumps(s))
-
-
-if __name__ == '__main__':
- main()
diff --git a/test/hil/helper/hil_util.py b/test/hil/helper/hil_util.py
index 54984d20f..0a2a13fca 100644
--- a/test/hil/helper/hil_util.py
+++ b/test/hil/helper/hil_util.py
@@ -18,7 +18,7 @@ from typing import Any
# -------------------------------------------------------------
-# HIL example test lists, shared by hil_test.py (runner) and hil_select.py (PR-diff
+# HIL example test lists, shared by hil_test.py (runner) and ci_select.py (PR-diff
# selector). Run order is shuffled per board (see test_board); every example carries a
# unique hardcoded idProduct (see its usb_descriptors.c).
# -------------------------------------------------------------
diff --git a/test/hil/hil_ci.sh b/test/hil/hil_ci.sh
index 66f4e48d4..514b0f174 100644
--- a/test/hil/hil_ci.sh
+++ b/test/hil/hil_ci.sh
@@ -224,7 +224,6 @@ scp -q "$ROOT_DIR/test/hil/helper/__init__.py" \
"$ROOT_DIR/test/hil/helper/hil_health.py" \
"$ROOT_DIR/test/hil/helper/hil_lock.py" \
"$ROOT_DIR/test/hil/helper/hil_summary.py" \
- "$ROOT_DIR/test/hil/helper/hil_select.py" \
"$REMOTE:$REMOTE_DIR/test/hil/helper/"
# Copy only firmware binaries (elf/bin/hex) plus esptool metadata
diff --git a/test/hil/hil_flash.py b/test/hil/hil_flash.py
index f4bed45a6..c4d4e6552 100755
--- a/test/hil/hil_flash.py
+++ b/test/hil/hil_flash.py
@@ -294,7 +294,7 @@ reset_lm4flash.no_op = True
# The one place a flasher's firmware extension is decided. A flasher with no entry falls
-# back to .elf-or-.bin and can be handed the wrong file — test_hil_select's
+# back to .elf-or-.bin and can be handed the wrong file — test_ci_select's
# TestRosterFlashersDispatch fails if a roster names one.
FLASHER_SUFFIX = {
'esptool': '.bin',
diff --git a/test/hil/hil_test.py b/test/hil/hil_test.py
index 174251343..fcd7c7e6f 100755
--- a/test/hil/hil_test.py
+++ b/test/hil/hil_test.py
@@ -194,10 +194,6 @@ class TestsCfg(TypedDict, total=False):
dev_attached: list[AttachedDevCfg]
-class BuildCfg(TypedDict, total=False):
- args: list[str]
-
-
class VariantCfg(TypedDict, total=False):
name: str # build dir (cmake-build-<name>) and HIL report row
flags: str # raw CFLAGS, e.g. "-DCFG_TUD_DWC2_DMA_ENABLE=1"
@@ -209,7 +205,9 @@ class Board(TypedDict):
uid: str
tests: TestsCfg
flasher: FlasherCfg
- build: NotRequired[BuildCfg]
+ # every build knob lives here, including a board's always-on defines: a board that
+ # needs one carries a single variant named after itself (metro_m4_express /
+ # MAX3421_HOST=1), which is exactly what the `or [...]` default below synthesises
variant: NotRequired[list[VariantCfg]]
toolchain: NotRequired[str] # CI build bucket override, e.g. "riscv-gcc" (consumed by hil_ci_set_matrix.py)
@@ -1670,21 +1668,17 @@ def test_example(board: Board, variant: str, example: str) -> tuple[int, str, st
def build_board(board: Board) -> tuple[str, int]:
"""Build firmware for this board via tools/build.py.
- Honors board config's variant list and build.args defines.
+ Honors board config's variant list (name, defines, flags).
Output goes to cmake-build/cmake-build-<variant>/ (tools/build.py layout).
Unbounded on purpose: --build is a local convenience (no CI workflow passes it), so
the developer watching the build is the timeout."""
name = board['name']
- bcfg = cast(BuildCfg, board.get('build', {}))
- extra_defs = bcfg.get('args', [])
variants = board.get('variant') or [{'name': name, 'flags': ''}]
failed = 0
for v in variants:
cmd = [sys.executable, str(hil_util.TINYUSB_ROOT / 'tools' / 'build.py'), '-b', name]
- for d in extra_defs:
- cmd += ['-D', d]
if v['name'] != name:
cmd += ['--build-name', v['name']]
for d in v.get('defines', []):
diff --git a/test/hil/test/test_ci_metrics.py b/test/hil/test/test_ci_metrics.py
new file mode 100644
index 000000000..a76b6e3a0
--- /dev/null
+++ b/test/hil/test/test_ci_metrics.py
@@ -0,0 +1,463 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for the by-example half of tools/metrics.py and the (family, example)
+# pair-compare script. Stdlib only; synthetic map.json fixtures, no builds.
+# python3 test/hil/test/test_ci_metrics.py
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.dirname(
+ os.path.dirname(os.path.abspath(__file__)))))
+METRICS = os.path.join(REPO, 'tools', 'metrics.py')
+
+
+def fake_map(path, files):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, 'w') as f:
+ json.dump({'files': files}, f)
+
+
+def entry(name, size, path_prefix='tinyusb/src'):
+ return {'file': name, 'path': f'{path_prefix}/{name}', 'size': size,
+ 'symbols': [{'name': f'{name}_fn', 'size': size}], 'sections': {'.text': size}}
+
+
+class TestByExample(unittest.TestCase):
+ def build_tree(self, td):
+ fake_map(os.path.join(td, 'device', 'cdc_msc', 'cdc_msc.map.json'),
+ [entry('usbd.c', 100), entry('cdc_device.c', 50)])
+ fake_map(os.path.join(td, 'host', 'bare_api', 'bare_api.map.json'),
+ [entry('usbh.c', 200)])
+
+ def test_by_example_output(self):
+ with tempfile.TemporaryDirectory() as td:
+ self.build_tree(td)
+ out = os.path.join(td, 'metrics')
+ r = subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j',
+ '--by-example', '-o', out,
+ os.path.join(td, '*', '*', '*.map.json')],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ by_ex = json.load(open(out + '_by_example.json'))
+ self.assertEqual(set(by_ex), {'device/cdc_msc', 'host/bare_api'})
+ self.assertEqual({f['file'] for f in by_ex['device/cdc_msc']['files']},
+ {'usbd.c', 'cdc_device.c'})
+ # the plain averaged output is unchanged by the extra flag
+ avg = json.load(open(out + '.json'))
+ self.assertIn('files', avg)
+
+ def test_by_example_json_roundtrips_as_combine_input(self):
+ with tempfile.TemporaryDirectory() as td:
+ self.build_tree(td)
+ out = os.path.join(td, 'metrics')
+ subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j', '--by-example',
+ '-o', out, os.path.join(td, '*', '*', '*.map.json')], check=True)
+ out2 = os.path.join(td, 'sub')
+ r = subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j',
+ '-o', out2, out + '_by_example.json'],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ sub = json.load(open(out2 + '.json'))
+ names = {f['file'] for f in sub['files']}
+ # one data entry per example, not one blob: reading it as an ordinary
+ # metrics.json would double-count every file
+ self.assertIn('usbd.c', names)
+ self.assertIn('cdc_device.c', names)
+ self.assertNotIn('TOTAL', {n.upper() for n in names})
+
+ def test_by_example_expansion_is_keyed_on_the_filename(self):
+ # the '_by_example.json' suffix IS the contract (write_by_example, the CMake
+ # rule and metrics_pair_compare all spell it). A shape-sniff would reroute
+ # any coincidentally-shaped JSON into the per-example branch instead.
+ with tempfile.TemporaryDirectory() as td:
+ look_alike = os.path.join(td, 'metrics.json')
+ with open(look_alike, 'w') as f:
+ json.dump({'device/cdc_msc': {'files': [entry('usbd.c', 100)]}}, f)
+ out = os.path.join(td, 'combined')
+ r = subprocess.run([sys.executable, METRICS, 'combine', '-q', '-j',
+ '-o', out, look_alike],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ combined = json.load(open(out + '.json'))
+ self.assertNotIn('usbd.c', {f['file'] for f in combined.get('files', [])})
+
+
+PAIR_COMPARE = os.path.join(REPO, '.github/scripts/metrics_pair_compare.py')
+
+
+def fake_by_example(root, board, data):
+ d = os.path.join(root, f'cmake-build-{board}')
+ os.makedirs(d, exist_ok=True)
+ with open(os.path.join(d, 'metrics_by_example.json'), 'w') as f:
+ json.dump(data, f)
+
+
+class TestPairCompare(unittest.TestCase):
+ def test_intersection_compare(self):
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ # real board names so board->family resolution works against hw/bsp
+ fake_by_example(base, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 100)]},
+ 'device/dfu': {'files': [entry('dfu_device.c', 10)]}})
+ fake_by_example(new, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 120)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ self.assertIn('usbd.c', md)
+ self.assertNotIn('dfu_device.c', md) # not on both sides
+ self.assertIn('raspberry_pi_pico', md) # scope footer names the board
+ self.assertIn('device/dfu', md) # named as dropped
+
+ def test_a_different_board_of_the_same_family_is_not_compared(self):
+ """--one-first returns all_boards[0], so adding a board can shift which one a
+ family builds. Keyed on the family, the base run's sizes and the PR run's sizes
+ would land under one key and the difference between two unrelated MCUs would be
+ published as this PR's code-size impact."""
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ # both rp2040, both device/cdc_msc - only the board differs
+ fake_by_example(base, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 100)]}})
+ fake_by_example(new, 'adafruit_fruit_jam',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 900)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ self.assertIn('skipped', md)
+ self.assertNotIn('+800', md)
+
+ def test_empty_intersection_writes_note(self):
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ fake_by_example(base, 'raspberry_pi_pico', {'device/dfu': {'files': [entry('a.c', 1)]}})
+ fake_by_example(new, 'stm32f407disco', {'device/cdc_msc': {'files': [entry('b.c', 1)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn('skipped', open(out + '.md').read())
+
+ def test_malformed_files_are_skipped_with_stderr_note(self):
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ # good pair on both sides -- must survive the malformed siblings below
+ fake_by_example(base, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 100)]}})
+ fake_by_example(new, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 120)]}})
+ # well-formed JSON, wrong shape (a list, not a {example: {files: [...]}} dict)
+ wrong_shape = os.path.join(base, 'cmake-build-stm32f407disco', 'metrics_by_example.json')
+ os.makedirs(os.path.dirname(wrong_shape), exist_ok=True)
+ with open(wrong_shape, 'w') as f:
+ json.dump(['not', 'a', 'dict'], f)
+ # metrics_by_example.json not under a cmake-build-<board> dir
+ misplaced = os.path.join(base, 'not_a_board_dir', 'metrics_by_example.json')
+ os.makedirs(os.path.dirname(misplaced), exist_ok=True)
+ with open(misplaced, 'w') as f:
+ json.dump({'device/dfu': {'files': [entry('dfu_device.c', 10)]}}, f)
+
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr) # fail-open: never crash the job
+ md = open(out + '.md').read()
+ self.assertIn('usbd.c', md) # good pair still compared
+ self.assertIn(wrong_shape, r.stderr)
+ self.assertIn(misplaced, r.stderr)
+ self.assertIn('skipping', r.stderr)
+
+
+ def test_missing_base_baseline_gets_its_own_note(self):
+ # interim state right after this feature merges: master has not uploaded a
+ # per-example baseline yet, so the BASE side collects nothing. The generic
+ # "no pair on both sides" note misattributes that to the PR's own scoping.
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ os.makedirs(base)
+ fake_by_example(new, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('usbd.c', 100)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ self.assertIn('No per-example baseline from the base branch yet', md)
+ self.assertIn('next push', md)
+ self.assertNotIn('comparison skipped', md)
+
+ def test_a_partially_malformed_file_contributes_nothing(self):
+ """A file that blows up half way through must drop WHOLE. Entries parsed
+ before the malformation used to stay in the comparison while stderr claimed
+ the file had been skipped - a silently truncated table published as the
+ code-size verdict. A non-list 'files' (TypeError) also has to be caught."""
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ # good entry FIRST, malformed second: the leak is order-dependent
+ fake_by_example(base, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('leaked.c', 100)]},
+ 'device/dfu': {'files': 42}})
+ fake_by_example(new, 'raspberry_pi_pico',
+ {'device/cdc_msc': {'files': [entry('leaked.c', 120)]}})
+ # a sibling file that is fine on both sides must still be compared
+ fake_by_example(base, 'stm32f407disco',
+ {'device/cdc_msc': {'files': [entry('good.c', 10)]}})
+ fake_by_example(new, 'stm32f407disco',
+ {'device/cdc_msc': {'files': [entry('good.c', 12)]}})
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ self.assertIn('good.c', md)
+ self.assertNotIn('leaked.c', md)
+ self.assertIn('skipping', r.stderr)
+ self.assertIn(os.path.join(base, 'cmake-build-raspberry_pi_pico'), r.stderr)
+
+ def test_dropped_footer_is_summarised_not_dumped(self):
+ """The sticky PR comment is capped at 65,536 chars by GitHub; a broad scoped
+ PR drops hundreds of (family, example) pairs and the full list alone ran to
+ tens of KB, pushing the comment past the cap and reddening code-metrics."""
+ with tempfile.TemporaryDirectory() as td:
+ base, new = os.path.join(td, 'base'), os.path.join(td, 'new')
+ common = {'device/cdc_msc': {'files': [entry('usbd.c', 100)]}}
+ extra = {f'device/example_{i:03d}': {'files': [entry(f'f{i}.c', i + 1)]}
+ for i in range(30)}
+ fake_by_example(base, 'raspberry_pi_pico', dict(common, **extra))
+ fake_by_example(new, 'raspberry_pi_pico', common)
+ out = os.path.join(td, 'cmp')
+ r = subprocess.run([sys.executable, PAIR_COMPARE, '--base-dir', base,
+ '--new-dir', new, '--out', out],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ md = open(out + '.md').read()
+ footer = md[md.index('_Scoped compare:'):]
+ self.assertLess(len(footer), 2048, footer)
+ self.assertIn('30', footer) # the count is still reported
+ self.assertIn('more', footer) # truncation marker
+ self.assertIn('device/example_029', r.stderr) # full list on stderr
+
+
+CIRCLECI = os.path.join(REPO, '.circleci')
+SENTINELS = ('example-map-default', 'build-filtered-default')
+
+
+class TestCircleCiSentinelContract(unittest.TestCase):
+ """config.yml's set-matrix rewrites config2.yml's parameter defaults by matching
+ a sentinel comment line — the only way past /pipeline/continue's 512-char
+ parameter cap. Renaming or reformatting either side is a silent full-build
+ 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()
+
+ def test_each_sentinel_appears_once_on_a_default_line(self):
+ for tag in SENTINELS:
+ marker = f'# {tag}: rewritten in-place by config.yml set-matrix'
+ hits = [l for l in self.config2.splitlines() if l.strip().endswith(marker)]
+ self.assertEqual(len(hits), 1, f'{tag}: {len(hits)} sentinel lines in config2.yml')
+ self.assertIn('default:', hits[0], f'{tag}: sentinel is not on a default: line')
+
+ def test_the_selection_travels_as_a_file(self):
+ # a mass-sweep selection runs to hundreds of KB: handed to ci_set_matrix as one
+ # argv it E2BIGs the step before the `||` fallback can fire, and EXAMPLE_MAP /
+ # BUILD_FILTERED (derived with jq, no argv limit) would then label a FULL build
+ # scoped -- the build and its label disagreeing is worse than either alone
+ self.assertIn('--select-file', self.config)
+ self.assertNotIn('--select "', self.config)
+
+ def test_the_rewriter_names_the_same_sentinels(self):
+ for tag in SENTINELS:
+ self.assertIn(f"'{tag}'", self.config,
+ f'{tag}: config.yml rewrite block does not name this sentinel')
+ self.assertIn("# {tag}: rewritten in-place by config.yml set-matrix", self.config,
+ 'config.yml no longer builds the sentinel comment it matches on')
+
+ def test_the_rewrite_precedes_the_scoped_entries(self):
+ # the scoping is all-or-nothing: config2's checked-in defaults are {} / false =
+ # unfiltered, so a rewrite that fails AFTER the family entries were generated
+ # leaves a subset of families built and code-metrics told it was a full build.
+ # Rewrite first, and on failure drop the scoping (back to the full matrix).
+ rewrite = self.config.index("p = '.circleci/config2.yml'")
+ entries = self.config.index('gen_build_entry() {')
+ self.assertLess(rewrite, entries,
+ 'the sentinel rewrite must run before any build entry is generated')
+ tail = self.config[rewrite:entries]
+ self.assertIn('MATRIX_JSON="$FULL_MATRIX_JSON"', tail,
+ 'a failed rewrite must fall back to the FULL matrix, not keep the '
+ 'scoped one')
+ # and that fallback must be a plain assignment: a second `python ...` here is an
+ # unguarded command under CircleCI's `set -e`, inside the one branch whose whole
+ # job is to keep the pipeline green
+ self.assertNotIn('ci_set_matrix.py)', tail)
+
+ def test_the_selector_gate_runs_both_suites(self):
+ # test_ci_select.py owns the rules; this file owns the sentinel contract the
+ # very same job rewrites. Gating on one of the two leaves the other unguarded.
+ for suite in ('test_ci_select.py', 'test_ci_metrics.py'):
+ self.assertIn(suite, self.config, f'{suite} does not gate the CircleCI selector')
+
+
+class TestWorkflowSelectionHandOff(unittest.TestCase):
+ """build.yml's counterpart of the CircleCI contract above: same E2BIG limit, same
+ consequence (the scoping silently turns itself off on exactly the PRs where it
+ saves most), plus the GITHUB_ENV lines that carry PR-derived values."""
+
+ 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()
+
+ def test_no_step_execs_with_the_selection_in_its_environment(self):
+ # SELECT_JSON="$SELECT_JSON" python3 -c ... E2BIGs at ~128KiB: measured 261KB
+ # for a `git ls-files hw/bsp/**` sweep. Every reader takes the file instead.
+ self.assertNotIn('SELECT_JSON="$SELECT_JSON"', self.build)
+ self.assertIn('json.load(open("ci_select_out.json"))', self.build)
+
+ def test_the_file_is_written_before_its_first_reader(self):
+ self.assertLess(self.build.index("printf '%s' \"$SELECT_JSON\" > ci_select_out.json"),
+ self.build.index('json.load(open("ci_select_out.json"))'),
+ 'the selection file must exist before the step that reads it')
+
+ def test_pr_derived_env_values_are_character_guarded(self):
+ # values reach GITHUB_ENV/GITHUB_OUTPUT as bare NAME=VALUE lines; a newline in
+ # one (git allows it in a path, and both the example map and the roster are
+ # PR-editable) writes extra variables into every later step of a job that runs
+ # with secrets - and for run_*, flips which rig jobs execute
+ for name in ('EX_ARGS', 'ARTIFACT_TAG'):
+ self.assertIn(f'echo "{name}=', self.util)
+ # per guard, not a sum: `count(a) + count(b) == 2` stays green when one guard is
+ # deleted and the other duplicated
+ for guard in ('case "$EX_ARGS" in', 'case "$TAG" in'):
+ 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()
+ 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)
+ self.assertIn('unexpected characters in the " + key', self.build,
+ 'the args_*/run_* emitter must screen each board filter')
+
+ def test_the_guards_accept_what_the_selector_actually_emits(self):
+ """A guard that rejects a NORMAL value is worse than no guard: build.yml throws
+ the whole selection away, warns, and both axes fall back to full - silently
+ turning the feature off. So run the real character classes over real selections
+ rather than only asserting that the guard text is present.
+
+ The one that got away: `[-A-Za-z0-9_/ .=+]` has no ':' or ',', and every partial
+ board filter is `-bt <board>:<test>,<test>`."""
+ import re, subprocess, sys, tempfile, json
+ repo = os.path.dirname(CIRCLECI)
+ # the character classes, lifted from the three places they are written
+ classes = {}
+ m = re.search(r're\.fullmatch\(r"\[([^"]+)\]\*"', self.build)
+ self.assertTrue(m, 'args_*/run_* guard not found in build.yml')
+ classes['args'] = m.group(1)
+ for name, text in (('BUILD_ARGS', self.build), ('EX_ARGS', self.util),
+ ('TAG', self.util)):
+ m = re.search(r'case "\$%s" in\s*\n\s*\*\[!([^\]]+)\]\*\)' % name, text)
+ self.assertTrue(m, f'{name} guard not found')
+ classes[name] = m.group(1).replace('\\', '')
+
+ def ok(cls, value):
+ return re.fullmatch('[%s]*' % cls.replace('!', ''), value) is not None
+
+ with tempfile.TemporaryDirectory() as d:
+ for path in ('src/class/cdc/cdc_device.c', 'src/device/usbd.c',
+ 'src/portable/synopsys/dwc2/dcd_dwc2.c',
+ 'examples/device/cdc_msc/src/main.c',
+ 'hw/bsp/stm32f4/family.cmake'):
+ f = os.path.join(d, 'diff.txt')
+ with open(f, 'w') as fh:
+ fh.write(path + '\n')
+ r = subprocess.run([sys.executable, os.path.join(repo, 'tools/ci_select.py'),
+ '--diff-file', f,
+ os.path.join(repo, 'test/hil/tinyusb.json'),
+ os.path.join(repo, 'test/hil/hfp.json')],
+ capture_output=True, text=True, cwd=repo)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ s = json.loads(r.stdout)
+ for flasher, a in s.get('args_flasher', {}).get('tinyusb.json', {}).items():
+ self.assertTrue(ok(classes['args'], a),
+ f'{path}/{flasher}: the args guard rejects {a!r}')
+ hfp = s.get('args', {}).get('hfp.json', '')
+ self.assertTrue(ok(classes['args'], hfp), f'{path}: hfp {hfp!r}')
+ # BUILD_ARGS is the hfp job's `-b <board> [-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:
+ fh.write(r.stdout)
+ hm = subprocess.run(
+ [sys.executable, os.path.join(repo, '.github/scripts/hil_ci_set_matrix.py'),
+ '--select-file', os.path.join(d, 'sel.json'),
+ os.path.join(repo, 'test/hil/hfp.json')],
+ capture_output=True, text=True, cwd=repo)
+ self.assertEqual(hm.returncode, 0, hm.stderr)
+ build_args = ' '.join(json.loads(hm.stdout)['arm-gcc'])
+ self.assertTrue(ok(classes['BUILD_ARGS'], build_args),
+ f'{path}: the BUILD_ARGS guard rejects {build_args!r}')
+ for entry in json.loads(hm.stdout)['arm-gcc']:
+ tag = re.sub(r' -e [^ ]+', '', entry)
+ self.assertTrue(ok(classes['TAG'], tag),
+ f'{path}: the artifact-name guard rejects {tag!r}')
+ for fam, exs in (s.get('build', {}).get('family_examples') or {}).items():
+ ex_args = ' '.join('-e ' + e for e in exs)
+ self.assertTrue(ok(classes['EX_ARGS'], ex_args),
+ f'{path}/{fam}: the EX_ARGS guard rejects {ex_args!r}')
+
+ def test_an_unusable_selection_is_unusable_for_both_matrices(self):
+ # hil_ci_set_matrix reads "full false with no boards map" as unusable and falls
+ # open to the whole roster; if this emitter instead computed run_*=false, the
+ # rig jobs would skip while all 37 build legs ran - a full build and still zero
+ # hardware coverage, which is the outcome the guard exists to prevent
+ self.assertIn('isinstance(s.get("boards"), dict)', self.build)
+
+ def test_the_build_extras_drop_when_the_matrix_falls_open(self):
+ # ci_set_matrix falls open with rc 0, so the example map and family regex must
+ # follow it or a nominally full build is filtered and labelled as a scoped one
+ 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()
+ # 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.
+ # Adjacent string literals are joined first, since these messages wrap.
+ import re as _re
+ flat = _re.sub(r"['\"]\s*\n\s*f?['\"]", '', matrix)
+ hits = [m.start() for m in _re.finditer('emitting the full ', flat)]
+ self.assertGreaterEqual(len(hits), 2, 'fall-open messages not found')
+ for i in hits:
+ self.assertIn('UNSCOPED', flat[max(0, i - 200):i],
+ 'a fall-open path without the marker build.yml greps for')
+
+ def test_membrowse_upload_sees_the_same_board_as_the_build(self):
+ # $EX_ARGS is passed for the BOARD it selects: --one-first picks a board that can
+ # build the -e set, so without it membrowse configures a different, empty build
+ # dir and uploads --identical for a board that was never compiled. It does NOT
+ # scope the targets - `examples-membrowse-upload` is not `all`, so it passes
+ # through as the aggregate, which has no DEPENDS and still records every example.
+ line = [l for l in self.util.splitlines()
+ if '--target examples-membrowse-upload' in l][0]
+ self.assertIn('$EX_ARGS', line)
+ self.assertNotIn('-e ', line.replace('$EX_ARGS', ''))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/test/hil/test/test_ci_select.py b/test/hil/test/test_ci_select.py
new file mode 100644
index 000000000..8f1841531
--- /dev/null
+++ b/test/hil/test/test_ci_select.py
@@ -0,0 +1,2202 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: MIT
+# Unit tests for ci_select.py — pure logic, no hardware, no git. Run directly:
+# python3 test/hil/test/test_ci_select.py
+#
+# Imports stay stdlib + ci_select/hil_util/hil_flash ONLY: the pre-commit hil-test
+# hook runs this suite, on GitHub's bare runner in the pre-commit workflow as well as
+# locally, and that runner has no pyserial/pymtp. hil_flash is admissible because it
+# is stdlib + hil_util only (test_hil_util.BottomLayer enforces the stdlib closure of
+# both) and the roster-dispatch tests need its flash_* table; never import hil_test,
+# which pulls pyserial.
+import contextlib
+import glob
+import io
+import json
+import os
+import pathlib
+import re
+import subprocess
+import sys
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.dirname(
+ os.path.dirname(os.path.abspath(__file__)))))
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # test/hil, for hil_flash/helper
+sys.path.insert(0, os.path.join(REPO, 'tools'))
+import hil_flash
+import ci_select
+from helper.hil_util import device_tests, dual_tests
+
+
+def real_rosters():
+ """The actual rig rosters, for regression tests that need real-world data
+ (a specific board/family/only-list) rather than the synthetic ROSTER above."""
+ rosters = []
+ for name in ('tinyusb.json', 'hfp.json'):
+ path = os.path.join(REPO, 'test/hil', name)
+ with open(path) as f:
+ rosters.append((f'test/hil/{name}', json.load(f)['boards']))
+ return rosters
+
+
+def roster_flashers():
+ """(roster path, board) for every board in the live rosters, `boards-skip`
+ included: a parked board's flasher name must still dispatch, so that unparking it
+ is not what discovers the name went stale."""
+ for name in ('tinyusb.json', 'hfp.json'):
+ path = os.path.join(REPO, 'test/hil', name)
+ with open(path) as f:
+ cfg = json.load(f)
+ for key in ('boards', 'boards-skip'):
+ for b in cfg.get(key, []):
+ yield f'test/hil/{name}', b
+
+
+def on_roster(tc, *names):
+ """The subset of `names` currently in the live rig rosters, skipping the test
+ when none are, because parking/unparking a board is routine rig maintenance.
+
+ That skip now matters MORE than it used to, not less: this suite is a blocking
+ pre-commit hook AND build.yml's selector steps gate on it (a failing suite falls
+ open to the full matrix), so an assertion that depends on a specific board being
+ present goes red on every PR -- including src/-only ones that never touched the
+ rig -- until someone fixes the roster. Keep roster-dependent assertions behind
+ on_roster."""
+ have = {b['name'] for _, boards in real_rosters() for b in boards}
+ got = [n for n in names if n in have]
+ if not got:
+ tc.skipTest(f'not in the rig roster: {", ".join(names)}')
+ return got
+
+
+ROSTER = [
+ # device-only, rp2040 family
+ {'name': 'raspberry_pi_pico', 'uid': 'u1', 'flasher': {'name': 'openocd'},
+ 'tests': {'device': True, 'host': True, 'dual': True}},
+ # device-only, stm32f4 family
+ {'name': 'stm32f407disco', 'uid': 'u2', 'flasher': {'name': 'jlink'},
+ 'tests': {'device': True, 'host': False, 'dual': False}},
+ # host-only board
+ {'name': 'raspberry_pi_pico2', 'uid': 'u3', 'flasher': {'name': 'openocd'},
+ 'tests': {'device': False, 'host': True, 'dual': False}},
+ # only-list board (espressif-style), flashed by the CI leg that splits on esptool
+ {'name': 'espressif_s3_devkitm', 'uid': 'u4', 'flasher': {'name': 'esptool'},
+ 'tests': {'only': ['device/cdc_msc_freertos', 'host/device_info']}},
+]
+ROSTERS = [('test/hil/tinyusb.json', ROSTER)]
+
+
+def sel(files):
+ return ci_select.classify(files, REPO, ROSTERS)
+
+
+class TestPortRule(unittest.TestCase):
+ def test_dcd_rp2040_selects_pico_family_only(self):
+ s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico', s['boards'])
+ self.assertNotIn('stm32f407disco', s['boards'])
+ self.assertNotIn('espressif_s3_devkitm', s['boards'])
+ # device role: no host tests in pico's list
+ self.assertTrue(all(not t.startswith('host/') for t in s['boards']['raspberry_pi_pico']))
+ # host-only boards drop out entirely on a device-role change
+ self.assertNotIn('raspberry_pi_pico2', s['boards'])
+
+ def test_shared_port_file_is_both_roles(self):
+ s = sel(['src/portable/synopsys/dwc2/dwc2_common.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('raspberry_pi_pico', s['boards']) # rp2040 is not a dwc2 family
+ self.assertIn('stm32f407disco', s['boards']) # stm32f4 is
+
+
+class TestCoreRoleRule(unittest.TestCase):
+ def test_usbd_selects_all_device_tests_everywhere(self):
+ s = sel(['src/device/usbd.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('raspberry_pi_pico2', s['boards']) # host-only board dropped
+ pico = s['boards']['raspberry_pi_pico']
+ self.assertTrue(set(device_tests).issubset(set(pico)))
+ self.assertTrue(set(dual_tests).issubset(set(pico))) # dual survives device role
+ self.assertTrue(all(not t.startswith('host/') for t in pico))
+ # only-list board: selection intersects its only-list
+ esp = s['boards']['espressif_s3_devkitm']
+ self.assertEqual(esp, ['device/cdc_msc_freertos'])
+
+ def test_host_change_drops_device(self):
+ s = sel(['src/host/usbh.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico2', s['boards'])
+ self.assertNotIn('stm32f407disco', s['boards']) # device-only board dropped
+
+
+class TestClassRule(unittest.TestCase):
+ def test_cdc_device_selects_cdc_examples_only(self):
+ s = sel(['src/class/cdc/cdc_device.c'])
+ self.assertFalse(s['full'])
+ pico = s['boards']['raspberry_pi_pico']
+ self.assertIn('device/cdc_msc', pico)
+ self.assertIn('device/cdc_dual_ports', pico)
+ self.assertNotIn('device/msc_dual_lun', pico) # CFG_TUD_CDC 0 there
+ self.assertNotIn('device/usbtest', pico) # CFG_TUD_CDC 0 there
+ self.assertTrue(all(not t.startswith('host/') for t in pico))
+
+ def test_msc_host_selects_host_side(self):
+ s = sel(['src/class/msc/msc_host.c'])
+ self.assertFalse(s['full'])
+ self.assertNotIn('stm32f407disco', s['boards']) # device-only board
+ pico2 = s['boards']['raspberry_pi_pico2']
+ self.assertIn('host/msc_file_explorer', pico2)
+ self.assertTrue(all(not t.startswith('device/') for t in pico2))
+
+
+class TestClassIncludeEdges(unittest.TestCase):
+ """A class header another class includes reaches that class's examples too.
+ src/class/midi/midi{,2}_{device,host}.h include class/audio/audio.h, so
+ midi_test's firmware contains audio.h - but the class rule derives macros from
+ the directory name alone, so an audio.h change used to select only
+ device/audio_test_freertos. On boards that skip that example the per-board
+ intersection emptied and an audio.h-only PR ran ZERO HIL on them."""
+ def test_edges_derived_from_includes(self):
+ edges = ci_select.class_include_edges(REPO)
+ self.assertEqual(edges.get('audio/audio.h'), {'midi'})
+ self.assertEqual(edges.get('cdc/cdc.h'), {'net'})
+
+ def test_audio_header_selects_midi_example(self):
+ s = ci_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ # every board that runs device/midi_test at all must run it here (boards with
+ # a tests.only list, e.g. espressif, run the freertos examples instead)
+ by_name = {b['name']: b for _, bs in real_rosters() for b in bs}
+ checked = 0
+ for name, tests in s['boards'].items():
+ if 'device/midi_test' in ci_select.board_tests(by_name[name]):
+ self.assertIn('device/midi_test', tests, name)
+ checked += 1
+ self.assertTrue(checked)
+
+ def test_audio_header_reaches_boards_that_skip_audio(self):
+ # both skip device/audio_test_freertos: without the midi edge their
+ # intersection is empty and they drop out of the selection entirely
+ boards = on_roster(self, 'metro_m4_express', 'nrf54lm20dk')
+ s = ci_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
+ for board in boards:
+ self.assertEqual(s['boards'].get(board), ['device/midi_test'], board)
+
+ def test_edge_is_per_header_not_per_class(self):
+ # midi includes audio.h, not audio_device.h: an audio_device change must
+ # not drag midi's examples in
+ s = ci_select.classify(['src/class/audio/audio_device.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for tests in s['boards'].values():
+ if tests != 'all':
+ self.assertNotIn('device/midi_test', tests)
+
+
+class TestFallbackRules(unittest.TestCase):
+ def test_unknown_tool_is_full(self):
+ s = sel(['tools/random_new_script.py'])
+ self.assertTrue(s['full'])
+
+ def test_docs_only_is_empty_not_full(self):
+ s = sel(['docs/info/contributing.rst', 'README.rst'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_bsp_family_selects_family_boards(self):
+ s = sel(['hw/bsp/rp2040/family.cmake'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico', s['boards'])
+ self.assertEqual(s['boards']['raspberry_pi_pico'], 'all')
+ self.assertNotIn('stm32f407disco', s['boards'])
+
+ def test_bsp_board_narrows_to_board(self):
+ s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
+ self.assertFalse(s['full'])
+ self.assertEqual(list(s['boards'].keys()), ['raspberry_pi_pico'])
+
+ def test_example_change_selects_that_example(self):
+ s = sel(['examples/device/cdc_msc/src/main.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards']['raspberry_pi_pico'], ['device/cdc_msc'])
+
+ def test_core_common_is_full(self):
+ for f in ['src/tusb.c', 'src/common/tusb_fifo.c', 'src/osal/osal_freertos.h']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_board_test_example_is_full(self):
+ # board_test is the park/teardown firmware hil_test.py flashes on every board,
+ # not an unlisted example: a regression there must not skip the whole rig
+ for f in ['examples/device/board_test/src/main.c',
+ 'examples/device/board_test/CMakeLists.txt']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_harness_is_full(self):
+ # hw/mcu/ is no longer here: it resolves to families/boards via mcu_families()
+ # instead of forcing full - see TestMcuHilRule
+ for f in ['test/hil/hil_test.py', '.github/workflows/build.yml']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_mixed_roles_no_pruning(self):
+ s = sel(['src/device/usbd.c', 'src/host/usbh.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('raspberry_pi_pico2', s['boards'])
+ self.assertIn('stm32f407disco', s['boards'])
+
+ def test_cmakelists_and_requirements_are_full(self):
+ for f in ['src/CMakeLists.txt', 'examples/CMakeLists.txt',
+ 'examples/device/CMakeLists.txt', 'test/hil/requirements.txt']:
+ self.assertTrue(sel([f])['full'], f)
+
+ def test_docs_txt_is_noncode(self):
+ s = sel(['docs/info/changelog.txt'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+
+class TestArgsEmission(unittest.TestCase):
+ def test_args_for_scoped_selection(self):
+ s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ args = ci_select.selection_args(s, ROSTERS)
+ a = args['tinyusb.json']
+ self.assertIn('-b raspberry_pi_pico', a)
+ self.assertNotIn('stm32f407disco', a)
+ self.assertIn('-bt raspberry_pi_pico:', a) # device-only subset of a device+host board
+
+ def test_args_full_is_empty(self):
+ s = sel(['tools/random_new_script.py'])
+ self.assertEqual(ci_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
+
+ def test_args_all_board_gets_bare_b(self):
+ s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
+ a = ci_select.selection_args(s, ROSTERS)['tinyusb.json']
+ self.assertIn('-b raspberry_pi_pico', a)
+ self.assertNotIn('-bt', a)
+
+ def test_args_by_flasher_splits_esp_from_the_rest(self):
+ s = sel(['src/device/usbd.c'])
+ per = ci_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
+ self.assertIn('espressif_s3_devkitm', per['esptool'])
+ self.assertIn('raspberry_pi_pico', per['openocd'])
+ self.assertNotIn('espressif_s3_devkitm', per.get('openocd', '') + per.get('jlink', ''))
+
+ def test_args_by_flasher_omits_a_flasher_with_no_selected_board(self):
+ # the esp CI leg must see no args at all here, not a filter matching zero boards
+ s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
+ per = ci_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
+ self.assertEqual(per, {'openocd': '-b raspberry_pi_pico'})
+
+ def test_args_by_flasher_full_is_empty(self):
+ s = sel(['tools/random_new_script.py'])
+ self.assertEqual(ci_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})
+
+ def test_cli_diff_file(self):
+ import subprocess, tempfile, json as j
+ with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
+ f.write('src/class/cdc/cdc_device.c\n')
+ path = f.name
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/ci_select.py'),
+ '--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ out = j.loads(r.stdout)
+ self.assertFalse(out['full'])
+ self.assertIn('tinyusb.json', out['args'])
+ self.assertTrue(any('cdc_device' in line for line in out['reasons']))
+ # A core-class diff must select boards THROUGH THE CLI: the in-process tests
+ # inject their own repo root, so only this subprocess path catches a broken
+ # repo_root derivation -- which once made every repo-relative glob match
+ # nothing and turned this exact diff into a silent full-HIL skip.
+ self.assertTrue(out['boards'],
+ 'CLI selected zero boards for a src/class change: repo_root broken?')
+ os.unlink(path)
+
+
+class TestRealRosterPortFamilies(unittest.TestCase):
+ """Regression for port_families() missing espressif's dwc2 reference, which
+ lives in a component CMakeLists.txt rather than family.cmake/family.mk."""
+ def test_dwc2_change_selects_espressif_boards(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ self.assertIn(board, s['boards'])
+
+
+class TestOptionGatedPort(unittest.TestCase):
+ """Regression: family_support.cmake compiles some ports from a build option
+ (MAX3421_HOST=1 -> hcd_max3421.c), so a board's family file never names them."""
+ # host-side option board (max3421 as host controller), off any max3421 family
+ OPT_ROSTER = [('test/hil/opt.json', [
+ {'name': 'fake_dual_board', 'uid': 'o1', 'flasher': {'name': 'jlink'},
+ 'variant': [{'name': 'fake_dual_board', 'defines': ['MAX3421_HOST=1']}],
+ 'tests': {'device': True, 'host': False, 'dual': True}},
+ {'name': 'fake_host_board', 'uid': 'o2', 'flasher': {'name': 'jlink'},
+ 'variant': [{'name': 'fake_host_board', 'flags': '-DMAX3421_HOST=1'}],
+ 'tests': {'device': False, 'host': True, 'dual': False}},
+ {'name': 'fake_off_board', 'uid': 'o3', 'flasher': {'name': 'jlink'},
+ 'variant': [{'name': 'fake_off_board', 'defines': ['MAX3421_HOST=0']}],
+ 'tests': {'device': True, 'host': True, 'dual': True}},
+ ])]
+
+ def test_real_roster_max3421_selects_option_board(self):
+ boards = on_roster(self, 'metro_m4_express')
+ s = ci_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ self.assertIn(board, s['boards'])
+
+ def test_option_selects_via_defines_and_flags(self):
+ s = ci_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, self.OPT_ROSTER)
+ self.assertFalse(s['full'])
+ self.assertIn('fake_dual_board', s['boards']) # variant defines
+ self.assertIn('fake_host_board', s['boards']) # variant flags
+ self.assertNotIn('fake_off_board', s['boards']) # variant defines, but =0
+
+ def test_device_role_port_does_not_pull_host_only_option_board(self):
+ s = ci_select.classify(['src/portable/analog/max3421/dcd_max3421.c'], REPO, self.OPT_ROSTER)
+ self.assertFalse(s['full'])
+ self.assertNotIn('fake_host_board', s['boards']) # host-only board, device change
+ self.assertIn('fake_dual_board', s['boards']) # device-capable option board
+
+ def test_gates_parsed_from_family_support(self):
+ self.assertEqual(ci_select.port_option_gates(REPO).get('analog/max3421'),
+ {'MAX3421_HOST'})
+
+ def test_board_cmake_option_counts(self):
+ """A board can enable a gated port in its own BSP rather than via the roster
+ (hw/bsp/espressif/boards/*/board.cmake -> set(MAX3421_HOST 1)); board_options()
+ must see those too, or such a board joining the roster is silently dropped."""
+ self.assertIn('MAX3421_HOST',
+ ci_select.bsp_board_options('adafruit_feather_esp32s3', REPO))
+ self.assertIn('CFG_TUH_RPI_PIO_USB',
+ ci_select.bsp_board_options('adafruit_fruit_jam', REPO))
+ # commented-out `# set(MAX3421_HOST 1)` must not count
+ self.assertNotIn('MAX3421_HOST',
+ ci_select.bsp_board_options('feather_nrf52840_express', REPO))
+
+ def test_board_cmake_option_selects_off_family_board(self):
+ # adafruit_feather_esp32s3 is not on any rig roster; stand it in as one to
+ # prove the BSP-sourced option alone pulls a max3421 change onto the board
+ roster = [('test/hil/opt.json', [
+ {'name': 'adafruit_feather_esp32s3', 'uid': 'o1', 'flasher': {'name': 'esptool'},
+ 'tests': {'device': False, 'host': True, 'dual': False}}])]
+ s = ci_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
+ self.assertFalse(s['full'])
+ self.assertIn('adafruit_feather_esp32s3', s['boards'])
+
+ def test_board_mk_option_is_ignored(self):
+ """Make-only options must not select: HIL CI builds with CMake exclusively, so
+ hw/bsp/nrf/boards/nrf5340dk/board.mk's MAX3421_HOST compiles nothing here."""
+ roster = [('test/hil/opt.json', [
+ {'name': 'nrf5340dk', 'uid': 'o1', 'flasher': {'name': 'jlink'},
+ 'tests': {'device': False, 'host': True, 'dual': False}}])]
+ s = ci_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+
+class TestPortFamiliesCmakeOnly(unittest.TestCase):
+ """port_families() is CMake-only (HIL CI never builds with Make) and matches on
+ 'port_dir/' so a port dir is not a prefix of a sibling."""
+ def test_make_only_family_is_not_a_family(self):
+ # hw/bsp/pic32mz has family.mk but no family.cmake
+ self.assertEqual(ci_select.port_families('microchip/pic32mz', REPO), set())
+
+ def test_prefix_port_does_not_inherit_sibling_families(self):
+ # bare-substring matching let 'microchip/pic' match '.../microchip/pic32mz/...'
+ self.assertEqual(ci_select.port_families('microchip/pic', REPO), set())
+
+ def test_make_only_port_contributes_nothing(self):
+ s = sel(['src/portable/microchip/pic32mz/dcd_pic32mz.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+ self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
+
+ def test_cmake_families_still_found(self):
+ self.assertEqual(ci_select.port_families('raspberrypi/rp2040', REPO), {'rp2040'})
+ self.assertIn('stm32f4', ci_select.port_families('synopsys/dwc2', REPO))
+
+
+class TestPortFamiliesCoverage(unittest.TestCase):
+ """Systematic guard: every real dcd_*/hcd_* port directory should map to at
+ least one board family, so a future family.cmake/CMakeLists.txt layout that
+ port_families() doesn't scan fails loudly instead of silently dropping boards
+ (as espressif's dwc2 reference did - see TestRealRosterPortFamilies)."""
+ # Ports with no board family: not a bug, just not wired into any rig board.
+ # Add here (with a reason) only if port_families() legitimately can't find one.
+ # A port listed here contributes NOTHING on either axis (empty means empty), so
+ # this list is the tripwire: a port that stops resolving must show up as a test
+ # failure, not as a PR that quietly builds and tests nothing.
+ NO_FAMILY = {
+ 'template', # reference/example port, not built by any board
+ # hw/bsp/pic32mz has family.mk only (no family.cmake), and port_families()
+ # is CMake-only because HIL CI builds every board with CMake - so this port
+ # is compiled for no HIL board.
+ 'microchip/pic32mz',
+ 'microchip/pic', # same: only ever referenced from pic32mz's family.mk
+ }
+
+ @staticmethod
+ def _dcd_hcd_ports():
+ portable_root = os.path.join(REPO, 'src/portable')
+ ports = []
+ for entry in sorted(os.listdir(portable_root)):
+ d = os.path.join(portable_root, entry)
+ if not os.path.isdir(d):
+ continue
+ if glob.glob(os.path.join(d, 'dcd_*.c')) or glob.glob(os.path.join(d, 'hcd_*.c')):
+ ports.append(entry)
+ continue
+ for sub in sorted(os.listdir(d)):
+ sd = os.path.join(d, sub)
+ if os.path.isdir(sd) and (glob.glob(os.path.join(sd, 'dcd_*.c')) or
+ glob.glob(os.path.join(sd, 'hcd_*.c'))):
+ ports.append(f'{entry}/{sub}')
+ return ports
+
+ def test_every_port_maps_to_a_family(self):
+ ports = self._dcd_hcd_ports()
+ self.assertTrue(ports) # sanity: the scan itself found something
+ for port in ports:
+ if port in self.NO_FAMILY:
+ continue
+ fams = ci_select.port_families(port, REPO)
+ self.assertTrue(fams, f'{port}: no family references this port '
+ f'(port_families() scan gap, or add to NO_FAMILY)')
+
+
+class TestRealRosterOnlyListTests(unittest.TestCase):
+ """Regression for roster-only-list tests (e.g. espressif's hid_composite_freertos)
+ being invisible to the selector because it only knew the shared hil_util lists."""
+ def test_only_list_example_change_selects_it(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['examples/device/hid_composite_freertos/src/main.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ self.assertEqual(s['boards'][board], ['device/hid_composite_freertos'])
+
+ def test_class_change_includes_only_list_boards(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['src/class/hid/hid_device.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ self.assertIn(board, s['boards'])
+
+
+class TestPortAndCoreRoleUseExtras(unittest.TestCase):
+ """Regression: the port rule and core-role rule must thread the roster-only
+ test universe (extras) the same way the class rule already does, so a DCD
+ or device-stack change doesn't silently drop espressif's only-list tests
+ (e.g. hid_composite_freertos) that aren't in the shared device_tests list."""
+ def test_dcd_change_includes_only_list_test(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ tests = s['boards'][board]
+ self.assertIn('device/hid_composite_freertos', tests)
+ self.assertIn('device/cdc_msc_freertos', tests)
+ self.assertIn('device/audio_test_freertos', tests)
+ self.assertIn('device/usbtest', tests)
+
+ def test_core_device_change_includes_only_list_test(self):
+ boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
+ s = ci_select.classify(['src/device/usbd.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board in boards:
+ tests = s['boards'][board]
+ self.assertIn('device/hid_composite_freertos', tests)
+ self.assertIn('device/cdc_msc_freertos', tests)
+ self.assertIn('device/audio_test_freertos', tests)
+ self.assertIn('device/usbtest', tests)
+
+ def test_host_change_does_not_leak_device_only_list_test(self):
+ s = ci_select.classify(['src/host/usbh.c'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for board, tests in s['boards'].items():
+ if tests == 'all':
+ continue
+ self.assertNotIn('device/hid_composite_freertos', tests, board)
+
+
+class TestFamilies(unittest.TestCase):
+ """`families` exists for consumers that build (not just test) the diff: most
+ families have no rig board, so `boards` alone would compile nothing for them."""
+ def test_off_rig_port_still_reports_family(self):
+ s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {}) # no same7x board on the rig
+ self.assertEqual(s['families'], ['same7x'])
+
+ def test_port_families_are_reported(self):
+ s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ self.assertIn('rp2040', s['families'])
+
+ def test_bsp_family_and_board_report_family(self):
+ self.assertEqual(sel(['hw/bsp/rp2040/family.cmake'])['families'], ['rp2040'])
+ self.assertEqual(sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])['families'],
+ ['rp2040'])
+
+ def test_docs_only_has_no_families(self):
+ self.assertEqual(sel(['docs/info/contributing.rst'])['families'], [])
+
+ def test_full_selection_still_reports_families(self):
+ """A full-matrix file must not hide the families of the other changed files:
+ consumers that build from `families` (e.g. /pre-pr) ignore `boards` when full."""
+ s = sel(['src/common/tusb_fifo.c', 'src/portable/microchip/samx7x/dcd_samx7x.c'])
+ self.assertTrue(s['full'])
+ self.assertIn('same7x', s['families'])
+ # full stays full: every roster board, and no args to narrow the run
+ self.assertEqual(set(s['boards']), {b['name'] for b in ROSTER})
+ self.assertTrue(all(v == 'all' for v in s['boards'].values()))
+ self.assertEqual(ci_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
+ self.assertEqual(ci_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})
+
+ def test_family_order_does_not_matter(self):
+ # same as above with the full-matrix file last (was the only order that worked)
+ s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c', 'src/common/tusb_fifo.c'])
+ self.assertTrue(s['full'])
+ self.assertIn('same7x', s['families'])
+
+
+class TestGitDiffArgv(unittest.TestCase):
+ def test_diff_disables_rename_detection(self):
+ """Without --no-renames git reports only a rename's destination, so moving an
+ HIL-relevant file to a non-code path would be classified as non-code only."""
+ self.assertIn('--no-renames', ci_select.GIT_DIFF_ARGV)
+
+
+class TestPortWithoutFamilyContributesNothing(unittest.TestCase):
+ """A port dir no family file references contributes nothing on BOTH axes (the
+ maintainer's empty-means-empty ruling): nothing compiles the file, so there is
+ nothing to run. Forcing the full 30-board rig here bought no coverage - the build
+ walk answered the identical condition with zero families for the same path."""
+ def test_unreferenced_port_contributes_nothing(self):
+ orig = ci_select.port_families
+ ci_select.port_families = lambda port_dir, repo_root: set()
+ try:
+ s = sel(['src/portable/vendor/newip/dcd_newip.c'])
+ b = ci_select.classify_build(['src/portable/vendor/newip/dcd_newip.c'], REPO)
+ finally:
+ ci_select.port_families = orig
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+ self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
+ self.assertFalse(b['full'])
+ self.assertEqual(b['families'], [])
+
+
+class TestOpenocdVidPid(unittest.TestCase):
+ """The roster's optional flasher `vid_pid` field (openocd-verbatim, e.g.
+ "0x1a86 0x8010", more pairs appended) pins openocd's probe discovery so it
+ never opens foreign usbfs nodes. It must be emitted BEFORE the args: the
+ rescue cfgs run `init` internally (rp2350-rescue.cfg errors on any
+ config-stage command after its init; rp2040.cfg under RESCUE scans before a
+ trailing flag is even parsed), and no rig cfg sets a competing list
+ (the 2026-08-10 convoy mechanism)."""
+
+ def test_vid_pid_flag_precedes_args(self):
+ cmd = hil_flash._openocd_cmd_base(
+ {'uid': 'S1', 'args': '-f target/wch-riscv.cfg', 'vid_pid': '0x1a86 0x8010'})
+ self.assertIn('-c "adapter usb vid_pid 0x1a86 0x8010" -f target/wch-riscv.cfg', cmd)
+ self.assertTrue(cmd.endswith('-f target/wch-riscv.cfg'), cmd)
+
+ def test_rescue_cfg_command_keeps_vid_pid_before_init(self):
+ """rescue_openocd swaps the target cfg for one that runs `init` internally;
+ a vid_pid flag after the args would error there (rp2350) or be skipped
+ (rp2040) -- in exactly the wedged-rig scenario the pin exists for."""
+ flasher = {'name': 'openocd', 'uid': 'S1', 'vid_pid': '0x2e8a 0x000c',
+ 'args': '-c "set RESCUE 1" -f target/rp2040.cfg'}
+ cmd = hil_flash._openocd_cmd_base(flasher)
+ self.assertLess(cmd.index('adapter usb vid_pid'), cmd.index('-f target/'), cmd)
+
+ def test_vid_pid_multiple_pairs(self):
+ cmd = hil_flash._openocd_cmd_base(
+ {'uid': 'S1', 'args': '-f i.cfg', 'vid_pid': '0x2e8a 0x000c 0x2e8a 0x000d'})
+ self.assertIn('-c "adapter usb vid_pid 0x2e8a 0x000c 0x2e8a 0x000d"', cmd)
+
+ def test_no_field_no_flag_but_warns(self):
+ # the roster lint only covers the committed rosters; a dev PC's local.json entry
+ # without the field must at least say what it is giving up -- on STDERR, since
+ # hil_test captures stdout per test and would swallow it on a passing run
+ import io
+ from contextlib import redirect_stderr
+ hil_flash._VID_PID_WARNED.discard('S-warn')
+ cap = io.StringIO()
+ with redirect_stderr(cap):
+ cmd = hil_flash._openocd_cmd_base({'uid': 'S-warn', 'args': '-f i.cfg'})
+ self.assertNotIn('vid_pid', cmd)
+ self.assertIn('vid_pid', cap.getvalue())
+
+ def test_roster_openocd_entries_all_pin_vid_pid(self):
+ # every openocd probe on the rig has a known VID/PID; a new entry without the
+ # pin silently reintroduces open-everything discovery
+ for path, board in roster_flashers():
+ f = board['flasher']
+ # tinyusb.json only: hfp.json is the hifiphile rig owner's file, and a
+ # blocking repo-wide lint over someone else's roster would red every PR the
+ # moment they add an openocd board (hil_flash treats the field as optional)
+ if f['name'] == 'openocd' and path.endswith('tinyusb.json'):
+ self.assertIn('vid_pid', f,
+ f"{path}: {board['name']} openocd flasher lacks vid_pid")
+ self.assertNotIn('vid_pid', f.get('args', ''),
+ f"{path}: {board['name']} packs vid_pid into args; use the field")
+
+
+class TestRosterFlashersDispatch(unittest.TestCase):
+ """hil_test and hil_pool_check resolve a board's flasher with a bare
+ getattr(hil_flash, f'flash_{name}'), and hil_test does it inside a redirect_stdout —
+ so a renamed or typo'd roster name raises an AttributeError whose output is swallowed,
+ with nothing pointing at the roster as the thing to edit. Renaming a flash_*/reset_*
+ pair without updating every roster must fail here instead."""
+
+ def test_flash_and_reset_exist_for_every_roster_flasher(self):
+ for path, board in roster_flashers():
+ name = board['flasher']['name'].lower()
+ for fn in (f'flash_{name}', f'reset_{name}'):
+ self.assertTrue(callable(getattr(hil_flash, fn, None)),
+ f'{path}: {board["name"]} uses flasher "{name}" '
+ f'but hil_flash.{fn} does not exist')
+
+ def test_firmware_suffix_known_for_every_roster_flasher(self):
+ """find_firmware falls back to accepting .elf-or-.bin when a flasher is missing
+ from FLASHER_SUFFIX, silently restoring the mismatch that map exists to catch."""
+ for path, board in roster_flashers():
+ name = board['flasher']['name'].lower()
+ self.assertIn(name, hil_flash.FLASHER_SUFFIX,
+ f'{path}: {board["name"]} uses flasher "{name}" '
+ f'with no hil_flash.FLASHER_SUFFIX entry')
+
+
+class FlasherRecoverEntry(unittest.TestCase):
+ """Optional roster key: a SECOND flasher used only to deliver recovery while a usbfs
+ node is poisoned. Boards whose primary flasher cannot get past a convoy (jlink,
+ stlink, lm4flash) name an openocd entry here instead of changing how they are
+ normally flashed."""
+
+ def test_recover_flasher_prefers_the_optional_entry(self):
+ prim = {'name': 'jlink', 'uid': 'X', 'args': '-device MIMXRT1064xxx6A'}
+ rec = {'name': 'openocd', 'uid': 'X', 'args': '-f interface/jlink.cfg -f target/foo.cfg'}
+ self.assertEqual(hil_flash.recover_flasher({'flasher': prim, 'flasher_recover': rec}), rec)
+ self.assertEqual(hil_flash.recover_flasher({'flasher': prim}), prim)
+
+ def test_openocd_over_jlink_is_convoy_safe_without_a_pin(self):
+ """libjaylink discovery returns early unless idVendor == 0x1366 (SEGGER) and the PID
+ is in its table, and only THEN calls libusb_open (discovery_usb.c) -- it never opens
+ a foreign node. `adapter usb vid_pid` is a no-op for this driver: jlink.c reads
+ adapter_serial / usb address / usb location, never the vid/pid."""
+ self.assertTrue(hil_flash.convoy_safe(
+ {'name': 'openocd', 'args': '-f interface/jlink.cfg -f target/stm32f4x.cfg'}))
+
+ def test_openocd_with_neither_a_pin_nor_jlink_is_not_safe(self):
+ self.assertFalse(hil_flash.convoy_safe(
+ {'name': 'openocd', 'args': '-f interface/stlink.cfg -f target/stm32h7x.cfg'}))
+
+ def test_the_existing_rules_are_unchanged(self):
+ self.assertTrue(hil_flash.convoy_safe(
+ {'name': 'openocd', 'vid_pid': '0x2e8a 0x000c', 'args': '-f interface/cmsis-dap.cfg'}))
+ self.assertFalse(hil_flash.convoy_safe({'name': 'jlink', 'uid': 'X'}))
+ self.assertTrue(hil_flash.convoy_safe({'name': 'esptool'}))
+
+
+class TestModuleMove(unittest.TestCase):
+ def test_repo_root_guard(self):
+ # __file__-derived root: moving the module without re-deriving the parent
+ # count re-points every scan at the wrong tree (it happened once already)
+ self.assertTrue(os.path.isdir(os.path.join(ci_select._REPO_ROOT, 'src')))
+ self.assertTrue(os.path.isdir(os.path.join(ci_select._REPO_ROOT, 'hw', 'bsp')))
+ self.assertEqual(os.path.realpath(ci_select._REPO_ROOT), os.path.realpath(REPO))
+
+
+class TestPathFamilies(unittest.TestCase):
+ def test_port_wrapper_unchanged(self):
+ self.assertEqual(ci_select.port_families('raspberrypi/rp2040', REPO), {'rp2040'})
+ self.assertIn('stm32f4', ci_select.port_families('synopsys/dwc2', REPO))
+
+ def test_boundary_without_trailing_slash(self):
+ # hw/bsp/nrf/family.cmake writes `${TOP}/hw/mcu/nordic/nrfx` — no trailing
+ # slash; the match must accept a directory-boundary end-of-token
+ self.assertEqual(ci_select.path_families('hw/mcu/nordic/nrfx', REPO), {'nrf'})
+
+ def test_boundary_rejects_prefix_sibling(self):
+ # 'microchip/pic' must not inherit pic32mz's references (and pic32mz itself
+ # is family.mk-only, which the CMake-only scan never reads)
+ self.assertEqual(ci_select.port_families('microchip/pic', REPO), set())
+ self.assertEqual(ci_select.port_families('microchip/pic32mz', REPO), set())
+
+ def test_mcu_families_prefix_walk(self):
+ self.assertEqual(ci_select.mcu_families('hw/mcu/nordic/nrf5x/nrf_clock.h', REPO), {'nrf'})
+ self.assertEqual(ci_select.mcu_families('hw/mcu/dialog/da1469x/x.h', REPO), {'da1469x'})
+ self.assertEqual(ci_select.mcu_families('hw/mcu/no_such_vendor/x.c', REPO), set())
+
+
+class TestMcuHilRule(unittest.TestCase):
+ def test_mcu_no_longer_forces_full(self):
+ s = ci_select.classify(['hw/mcu/nordic/nrf5x/nrf_clock.h'], REPO, ROSTERS)
+ self.assertFalse(s['full'])
+ self.assertIn('nrf', s['families']) # recorded even with no nrf rig board
+
+ def test_mcu_selects_family_boards(self):
+ got = on_roster(self, 'feather_nrf52840_express', 'pca10056', 'pca10095')
+ s = ci_select.classify(['hw/mcu/nordic/nrf5x/nrf_clock.h'], REPO, real_rosters())
+ self.assertFalse(s['full'])
+ for b in got:
+ self.assertIn(b, s['boards'])
+
+ def test_unresolved_mcu_path_selects_nothing(self):
+ # empty means empty (maintainer ruling): if no family's build references the
+ # path, no build consumes the change - there is nothing to compile or run.
+ # test_tracked_mcu_vendors_resolve is the drift guard for a real vendor dir
+ s = ci_select.classify(['hw/mcu/no_such_vendor/x.c'], REPO, ROSTERS)
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+ self.assertEqual(s['families'], [])
+
+
+class TestOrphanInvariant(unittest.TestCase):
+ ALLOW = {'microchip/pic', 'microchip/pic32mz'} # spec: known orphans, CMake builds neither
+
+ def test_every_port_resolves_to_a_family(self):
+ for d in sorted(glob.glob(os.path.join(REPO, 'src/portable/*/*'))):
+ if not os.path.isdir(d):
+ continue
+ port = os.path.relpath(d, os.path.join(REPO, 'src/portable')).replace(os.sep, '/')
+ fams = ci_select.port_families(port, REPO)
+ if port in self.ALLOW:
+ self.assertEqual(fams, set(), f'{port}: no longer an orphan - drop it from ALLOW')
+ else:
+ self.assertTrue(fams, f'{port}: no family.cmake references it - wire it up or allowlist it')
+
+ def test_tracked_mcu_vendors_resolve(self):
+ import subprocess as sp
+ r = sp.run(['git', 'ls-files', 'hw/mcu'], cwd=REPO, capture_output=True, text=True)
+ if r.returncode != 0:
+ self.skipTest('not a git checkout')
+ vendors = sorted({'/'.join(p.split('/')[:3]) for p in r.stdout.split()})
+ for v in vendors:
+ self.assertTrue(ci_select.mcu_families(v + '/x.c', REPO), f'{v}: resolves to no family')
+
+ # hw/bsp families ci_set_matrix's family_list does not map to any toolchain. Master
+ # gave a PR touching one of these no compile coverage either - none of the other 64
+ # families compiles same7x's board.h - so this is not new. What IS new is that the
+ # gap used to be masked by a full matrix and is now the whole answer, which is why
+ # ci_set_matrix treats a selection that intersects family_list to NOTHING as
+ # unusable (UNSCOPED -> full matrix) rather than emitting an all-empty one.
+ # espressif is here because hil-build-esp builds its boards by name rather than by
+ # family - though only on hathach/tinyusb: that job is gated on repository_owner,
+ # so on a fork an espressif-only PR builds nowhere.
+ UNBUILT_FAMILIES = {'cxd56', 'efm32', 'espressif', 'f1c100s', 'pic32mz', 'py32f0',
+ 'same7x'}
+
+ def test_every_bsp_family_is_in_the_ci_matrix(self):
+ sys.path.insert(0, os.path.join(REPO, '.github/scripts'))
+ import ci_set_matrix
+ fams = set(ci_select.all_bsp_families(REPO))
+ self.assertEqual(fams - set(ci_set_matrix.family_list), self.UNBUILT_FAMILIES,
+ 'a hw/bsp family that no toolchain in ci_set_matrix.family_list '
+ 'builds: a PR touching only it now selects zero build legs. Wire '
+ 'it into family_list, or add it here with a reason.')
+
+ def test_every_get_deps_family_token_resolves_or_is_a_known_alias(self):
+ """Same drift guard, dep side. A token naming no hw/bsp dir makes the entry
+ unreachable for its family in get_deps.py itself (`f in entry[2].split()`), and
+ makes a bump of it select nothing here. The four known ones are pinned; a fifth
+ appearing is a real bug in get_deps.py, not something to swallow."""
+ sys.path.insert(0, os.path.join(REPO, 'tools'))
+ import get_deps
+ fams = set(ci_select.all_bsp_families(REPO))
+ stale = {}
+ for name, d in (('deps_mandatory', get_deps.deps_mandatory),
+ ('deps_optional', get_deps.deps_optional)):
+ for path, entry in d.items():
+ for tok in str(entry[2]).split():
+ if tok != 'all' and tok not in fams:
+ stale.setdefault(tok, []).append(f'{name}[{path}]')
+ # subset, not equality: correcting a token in get_deps.py (fc100s -> f1c100s)
+ # should be a one-file change, while a NEW unmappable token - which force-fulls
+ # every get_deps edit that touches its entry - has to be a deliberate act
+ self.assertFalse(set(stale) - set(ci_select._DEPS_ALIAS_TOKENS),
+ f'get_deps family tokens naming no hw/bsp dir: '
+ f'{ {k: v for k, v in stale.items() if k not in ci_select._DEPS_ALIAS_TOKENS} }')
+
+
+class TestRostersDoNotOverlap(unittest.TestCase):
+ """sel['boards'] is one map across every roster, so a board listed in TWO rosters
+ with different test lists would get the union - and hil_test.py on the rig that
+ only runs half of them would be handed a -t it has no fixture for. No overlap
+ exists today; this is the tripwire for the day one is added."""
+
+ def test_no_board_name_is_in_two_rosters(self):
+ seen = {}
+ for name in ('tinyusb.json', 'hfp.json'):
+ cfg = json.load(open(os.path.join(REPO, 'test/hil', name)))
+ for b in cfg['boards']:
+ if b['name'] in seen:
+ self.assertEqual(
+ seen[b['name']], b.get('tests'),
+ f"{b['name']}: on two rosters with different test lists - "
+ f"selection_args must then filter per roster, not from the union")
+ seen[b['name']] = b.get('tests')
+
+
+class TestLibRule(unittest.TestCase):
+ """lib/** is not a full-matrix path: only the examples that build the lib need it."""
+
+ def b(self, files):
+ return ci_select.classify_build(files, REPO)
+
+ def test_lib_examples_ground_truth(self):
+ self.assertEqual(ci_select.lib_examples('embedded-cli', REPO),
+ {'host/msc_file_explorer', 'host/msc_file_explorer_freertos'})
+ self.assertEqual(ci_select.lib_examples('networking', REPO),
+ {'device/net_lwip_webserver'})
+ # only family_support.cmake's LOGGER=rtt plumbing names it, and no CI example
+ # build turns that on - the scan is per-example on purpose
+ self.assertEqual(ci_select.lib_examples('SEGGER_RTT', REPO), set())
+ self.assertEqual(ci_select.lib_examples('rt-thread', REPO), set())
+
+ def test_lib_examples_matches_at_a_directory_boundary(self):
+ # 'lib/net' must not inherit lib/networking's example
+ self.assertEqual(ci_select.lib_examples('net', REPO), set())
+
+ def test_build_lib_selects_only_the_using_examples(self):
+ s = self.b(['lib/embedded-cli/embedded_cli.h'])
+ self.assertFalse(s['full'])
+ self.assertTrue(s['families'])
+ want = {'host/msc_file_explorer', 'host/msc_file_explorer_freertos'}
+ mapped = set()
+ for fam, exs in s['family_examples'].items():
+ self.assertTrue(set(exs) <= want, f'{fam}: {exs}')
+ mapped |= set(exs)
+ self.assertEqual(mapped, want)
+
+ def test_build_lib_nobody_builds_selects_nothing(self):
+ s = self.b(['lib/SEGGER_RTT/RTT/SEGGER_RTT.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertEqual(s['family_examples'], {})
+
+ def test_hil_lib_selects_the_using_tests(self):
+ s = sel(['lib/embedded-cli/embedded_cli.h'])
+ self.assertFalse(s['full'])
+ want = {'host/msc_file_explorer', 'host/msc_file_explorer_freertos'}
+ self.assertEqual(set(s['boards']['raspberry_pi_pico']), want)
+ self.assertEqual(set(s['boards']['raspberry_pi_pico2']), want)
+ # device-only board and the only-list board run neither test
+ self.assertNotIn('stm32f407disco', s['boards'])
+ self.assertNotIn('espressif_s3_devkitm', s['boards'])
+
+ def test_hil_lib_used_only_by_a_disabled_test_selects_nothing(self):
+ # device/net_lwip_webserver is commented out of hil_util.device_tests, so the
+ # intersection with the HIL universe is empty
+ s = sel(['lib/networking/dhserver.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_hil_lib_nobody_builds_selects_nothing(self):
+ s = sel(['lib/SEGGER_RTT/RTT/SEGGER_RTT.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+
+# A miniature get_deps.py: the module shape the parser must cope with (imports,
+# both dep dicts, the derived deps_all, a function) without the real 300-entry file.
+_GD_BASE = """#!/usr/bin/env python3
+import argparse
+
+deps_mandatory = {
+ 'lib/fatfs': ['https://github.com/abbrev/fatfs.git', 'aaa', 'all'],
+}
+
+deps_optional = {
+ 'hw/mcu/st/cmsis_device_f4': ['https://github.com/x/f4.git', 'bbb', 'stm32f4 stm32f7'],
+ 'hw/mcu/nordic/nrfx': ['https://github.com/x/nrfx.git', 'ccc', 'nrf'],
+}
+
+deps_all = {**deps_mandatory, **deps_optional}
+
+
+def main():
+ return 1
+"""
+
+
+class TestGetDepsChangedFamilies(unittest.TestCase):
+ """Pure text-in, families-out: no git, no exec of the parsed module."""
+
+ def f(self, head, base=_GD_BASE):
+ return ci_select.get_deps_changed_families(base, head, REPO)
+
+ def test_no_change_selects_nothing(self):
+ self.assertEqual(self.f(_GD_BASE), set())
+
+ def test_comment_only_change_selects_nothing(self):
+ self.assertEqual(self.f(_GD_BASE.replace('import argparse',
+ 'import argparse # noqa')), set())
+
+ def test_optional_commit_bump_selects_its_families(self):
+ self.assertEqual(self.f(_GD_BASE.replace("'bbb'", "'bbb2'")),
+ {'stm32f4', 'stm32f7'})
+
+ def test_mandatory_all_entry_is_full(self):
+ self.assertIsNone(self.f(_GD_BASE.replace("'aaa'", "'aaa2'")))
+
+ def test_logic_change_is_full(self):
+ self.assertIsNone(self.f(_GD_BASE.replace('return 1', 'return 2')))
+
+ def test_unparseable_text_is_full(self):
+ self.assertIsNone(self.f('def broken(:\n'))
+
+ def test_unresolvable_token_is_full(self):
+ # a changed entry we cannot map to a family is NOT "nothing changed": reading it
+ # that way empties the whole build matrix for a dep bump. Fall open instead -
+ # even when a sibling token does resolve, because the unmapped one may be the
+ # family that actually needed the new revision
+ base = _GD_BASE.replace("'ccc', 'nrf'", "'ccc', 'zz_gone samd5x_e5x'")
+ self.assertIsNone(self.f(base.replace("'ccc'", "'ccc2'"), base))
+ base = _GD_BASE.replace("'ccc', 'nrf'", "'ccc', 'zz_gone'")
+ self.assertIsNone(self.f(base.replace("'ccc'", "'ccc2'"), base))
+
+ def test_family_token_change_unions_both_sides(self):
+ # the family list itself edited: both sides contribute
+ head = _GD_BASE.replace("'ccc', 'nrf'", "'ccc', 'rp2040 samd5x_e5x'")
+ self.assertEqual(self.f(head), {'nrf', 'rp2040', 'samd5x_e5x'})
+
+ def test_known_alias_tokens_select_nothing(self):
+ # the tokens in _DEPS_ALIAS_TOKENS name no hw/bsp dir: either a pre-rename
+ # spelling sitting beside the current name in the same entry, or a family with
+ # no boards in the tree. Changing one selects nothing rather than force-fulling
+ # every get_deps edit that touches its entry.
+ base = _GD_BASE.replace("'ccc', 'nrf'", "'ccc', 'stm32l5'")
+ self.assertEqual(self.f(base.replace("'ccc'", "'ccc2'"), base), set())
+
+ def test_moving_an_entry_between_the_two_dicts_is_seen(self):
+ # value untouched, dict changed: mandatory deps are fetched for every family, so
+ # demoting one stops families fetching it. Merging the dicts before diffing (or
+ # comparing the ast dump of deps_all) hides this completely.
+ head = _GD_BASE.replace(
+ " 'hw/mcu/nordic/nrfx': ['https://github.com/x/nrfx.git', 'ccc', 'nrf'],\n", '')
+ head = head.replace(
+ "deps_mandatory = {\n",
+ "deps_mandatory = {\n 'hw/mcu/nordic/nrfx': ['https://github.com/x/nrfx.git', 'ccc', 'nrf'],\n")
+ self.assertEqual(self.f(head), {'nrf'})
+
+ def test_added_entry_selects_its_families(self):
+ head = _GD_BASE.replace(
+ "deps_optional = {\n",
+ "deps_optional = {\n 'hw/mcu/x': ['https://github.com/x/x.git', 'ddd', 'rp2040'],\n")
+ self.assertEqual(self.f(head), {'rp2040'})
+
+ def test_removed_entry_selects_its_base_side_families(self):
+ head = _GD_BASE.replace(
+ " 'hw/mcu/nordic/nrfx': ['https://github.com/x/nrfx.git', 'ccc', 'nrf'],\n", '')
+ self.assertEqual(self.f(head), {'nrf'})
+
+ def test_family_list_change_unions_both_sides(self):
+ head = _GD_BASE.replace("'stm32f4 stm32f7'", "'stm32f4 stm32h7'")
+ self.assertEqual(self.f(head), {'stm32f4', 'stm32f7', 'stm32h7'})
+
+ def test_real_get_deps_parses(self):
+ with open(os.path.join(REPO, 'tools/get_deps.py')) as f:
+ real = f.read()
+ self.assertEqual(ci_select.get_deps_changed_families(real, real, REPO), set())
+ # a real optional entry bumped resolves to that entry's real family. The commit
+ # is read out of get_deps.py rather than pinned here - a routine dep bump must
+ # not fail this suite, and pinning a hash tests the tree, not the code
+ sys.path.insert(0, os.path.join(REPO, 'tools'))
+ import get_deps
+ commit, tokens = get_deps.deps_optional['hw/mcu/nordic/nrfx'][1:3]
+ bumped = real.replace(commit, '0' * len(commit))
+ self.assertNotEqual(bumped, real)
+ self.assertEqual(ci_select.get_deps_changed_families(real, bumped, REPO),
+ set(tokens.split()))
+
+
+class TestGetDepsRule(unittest.TestCase):
+ """tools/get_deps.py: the changed dep entries' families, or full when unknowable."""
+
+ def test_build_selects_the_changed_families(self):
+ s = ci_select.classify_build(['tools/get_deps.py'], REPO,
+ get_deps_families={'stm32f4'})
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], ['stm32f4'])
+ self.assertNotIn('stm32f4', s['family_examples']) # every example it builds
+
+ def test_build_without_a_base_is_full(self):
+ # --diff-file mode has no git and so no base content: fail open
+ self.assertTrue(ci_select.classify_build(['tools/get_deps.py'], REPO)['full'])
+
+ def test_build_no_dep_entry_changed_selects_nothing(self):
+ s = ci_select.classify_build(['tools/get_deps.py'], REPO, get_deps_families=set())
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+
+ def test_hil_selects_the_changed_families_boards(self):
+ s = ci_select.classify(['tools/get_deps.py'], REPO, ROSTERS,
+ get_deps_families={'stm32f4'})
+ self.assertFalse(s['full'])
+ self.assertEqual(list(s['boards']), ['stm32f407disco'])
+ self.assertEqual(s['families'], ['stm32f4'])
+
+ def test_hil_without_a_base_is_full(self):
+ self.assertTrue(ci_select.classify(['tools/get_deps.py'], REPO, ROSTERS)['full'])
+
+ def test_hil_no_dep_entry_changed_selects_nothing(self):
+ s = ci_select.classify(['tools/get_deps.py'], REPO, ROSTERS, get_deps_families=set())
+ self.assertFalse(s['full'])
+ self.assertEqual(s['boards'], {})
+
+ def test_cli_diff_file_mode_is_full(self):
+ import tempfile, json as j
+ with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
+ f.write('tools/get_deps.py\n')
+ path = f.name
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/ci_select.py'),
+ '--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ os.unlink(path)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ out = j.loads(r.stdout)
+ self.assertTrue(out['full'])
+ self.assertTrue(out['build']['full'])
+
+
+class TestGetDepsGitPlumbing(unittest.TestCase):
+ """--base mode: merge-base, the diff, and both blobs come from git, and only
+ tools/get_deps.py in the diff triggers the blob reads."""
+
+ HEAD = _GD_BASE.replace("'bbb'", "'bbb2'")
+
+ def run_main(self, diff):
+ from unittest import mock
+ calls = []
+
+ def fake_run(argv, **kw):
+ calls.append(argv)
+ if argv[:2] == ['git', 'merge-base']:
+ out = 'MB123\n'
+ elif argv[:3] == ci_select.GIT_DIFF_ARGV[:3]:
+ out = diff
+ elif argv[:2] == ['git', 'show']:
+ out = _GD_BASE if argv[2].startswith('MB123:') else self.HEAD
+ else:
+ raise AssertionError(f'unexpected git call: {argv}')
+ return subprocess.CompletedProcess(argv, 0, stdout=out, stderr='')
+
+ buf = io.StringIO()
+ argv = [sys.executable, '--base', 'origin/master']
+ with mock.patch.object(ci_select.subprocess, 'run', fake_run), \
+ mock.patch.object(sys, 'argv', argv), \
+ contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
+ ci_select.main()
+ return json.loads(buf.getvalue()), calls
+
+ def test_base_mode_reads_the_merge_base_blob(self):
+ out, calls = self.run_main('tools/get_deps.py\n')
+ self.assertIn(['git', 'show', 'MB123:tools/get_deps.py'], calls)
+ self.assertIn(['git', 'show', 'HEAD:tools/get_deps.py'], calls)
+ self.assertFalse(out['build']['full'])
+ self.assertEqual(out['build']['families'], ['stm32f4', 'stm32f7'])
+
+ def test_no_get_deps_in_the_diff_reads_no_blob(self):
+ out, calls = self.run_main('src/class/cdc/cdc_device.c\n')
+ self.assertFalse(any(c[:2] == ['git', 'show'] for c in calls))
+ self.assertFalse(out['build']['full'])
+
+ def test_git_failure_falls_open(self):
+ from unittest import mock
+
+ def fake_run(argv, **kw):
+ if argv[:2] == ['git', 'show']:
+ raise subprocess.CalledProcessError(128, argv)
+ out = 'MB123\n' if argv[:2] == ['git', 'merge-base'] else 'tools/get_deps.py\n'
+ return subprocess.CompletedProcess(argv, 0, stdout=out, stderr='')
+
+ buf = io.StringIO()
+ with mock.patch.object(ci_select.subprocess, 'run', fake_run), \
+ mock.patch.object(sys, 'argv', [sys.executable, '--base', 'origin/master']), \
+ contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
+ ci_select.main()
+ self.assertTrue(json.loads(buf.getvalue())['build']['full'])
+
+
+class TestBuildClassifier(unittest.TestCase):
+ def b(self, files):
+ return ci_select.classify_build(files, REPO)
+
+ def test_noncode_and_test_hil_contribute_nothing(self): # rules 1, 2
+ s = self.b(['docs/info/index.rst', 'README.rst', 'test/hil/hil_test.py', '.claude/skills/hil/SKILL.md'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertEqual(s['family_examples'], {})
+
+ def test_port_device_rule(self): # rule 3
+ s = self.b(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], ['rp2040'])
+ exs = s['family_examples']['rp2040']
+ self.assertIn('device/cdc_msc', exs)
+ self.assertFalse(any(e.startswith(('host/', 'typec/')) for e in exs))
+ # dual inclusion asserted on the pure role helper: whether a dual example
+ # survives Task 4's buildability pruning depends on the environment-gated
+ # CI board pick, so the classifier-output assertion must not rely on it
+ self.assertIn('dual/host_info_to_device_cdc',
+ ci_select.role_examples(REPO, ('device', 'dual')))
+ self.assertNotIn('host/bare_api', ci_select.role_examples(REPO, ('device', 'dual')))
+
+ def test_port_host_rule(self): # rule 4
+ s = self.b(['src/portable/analog/max3421/hcd_max3421.c'])
+ self.assertFalse(s['full'])
+ # rp2040's family.cmake unconditionally lists hcd_max3421.c as a source of its
+ # tinyusb_host_max3421 INTERFACE lib (linked only when MAX3421_HOST=1, e.g. the
+ # real feather_rp2040_max3421 board) and espressif's component CMakeLists also
+ # references it — so the raw (unpruned) scan legitimately finds both; Task 4's
+ # buildability post-filter is what may later prune either away
+ # non-empty FIRST: a subset assertion is satisfied by set(), and since ports are
+ # now empty-means-empty (fail-closed) an unnoticed regression to zero families
+ # would select no build leg at all and merge an uncompiled HCD
+ self.assertTrue(s['families'], 'a host-port change must select some family')
+ self.assertLessEqual(set(s['families']), {'espressif', 'rp2040'})
+ self.assertTrue(s['family_examples'], 'and must name the examples for them')
+ for exs in s['family_examples'].values():
+ self.assertTrue(exs)
+ self.assertFalse(any(e.startswith(('device/', 'typec/')) for e in exs))
+
+ def test_port_shared_file_selects_all_examples(self): # rule 5
+ s = self.b(['src/portable/synopsys/dwc2/dwc2_common.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('stm32f4', s['families'])
+ self.assertNotIn('rp2040', s['families'])
+ self.assertNotIn('stm32f4', s['family_examples']) # 'all' => no map key
+
+ def test_bsp_family_rule(self): # rule 6
+ s = self.b(['hw/bsp/stm32f4/boards/stm32f407disco/board.h'])
+ self.assertEqual(s['families'], ['stm32f4'])
+ self.assertNotIn('stm32f4', s['family_examples'])
+
+ def test_bsp_top_level_file_is_full(self): # rule 16
+ self.assertTrue(self.b(['hw/bsp/board.c'])['full'])
+ self.assertTrue(self.b(['hw/bsp/family_support.cmake'])['full'])
+
+ def test_mcu_rule(self): # rule 7
+ s = self.b(['hw/mcu/nordic/nrf5x/nrf_clock.h'])
+ self.assertEqual(s['families'], ['nrf'])
+ # empty means empty: no family's build references the path, so no build
+ # compiles it - nothing to select
+ s = self.b(['hw/mcu/no_such_vendor/x.c'])
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertEqual(s['family_examples'], {})
+
+ def test_class_device_rule(self): # rule 8
+ s = self.b(['src/class/cdc/cdc_device.c'])
+ self.assertFalse(s['full'])
+ # near-all families (Task 4's pruning may drop a few); never equality
+ # against all_bsp_families — that's a tuple, and pruning shrinks the list
+ self.assertIn('stm32f4', s['families'])
+ self.assertGreater(len(s['families']), 50)
+ exs = s['family_examples']['stm32f4']
+ self.assertIn('device/cdc_msc', exs)
+ self.assertNotIn('device/hid_composite', exs)
+ self.assertNotIn('host/cdc_msc_hid', exs) # TUH_CDC examples are rule 9's
+
+ def test_class_host_rule(self): # rule 9
+ s = self.b(['src/class/msc/msc_host.c'])
+ exs = s['family_examples']['stm32f4']
+ self.assertIn('host/msc_file_explorer', exs)
+ self.assertNotIn('device/cdc_msc', exs)
+
+ def test_class_shared_header_and_include_edge(self): # rule 10
+ s = self.b(['src/class/audio/audio.h'])
+ exs = s['family_examples']['stm32f4']
+ self.assertIn('device/audio_test', exs)
+ self.assertIn('device/midi_test', exs) # midi headers include audio.h
+
+ def test_core_device_rule(self): # rule 11
+ s = self.b(['src/device/usbd.c'])
+ exs = s['family_examples']['stm32f4']
+ self.assertIn('device/cdc_msc', exs)
+ # no dual In-assertion: dual examples are only.txt-gated to max3421/pio-usb
+ # boards, so pruning legitimately drops them on a plain stm32f4 board
+ self.assertFalse(any(e.startswith(('host/', 'typec/')) for e in exs))
+
+ def test_core_host_rule(self): # rule 12
+ s = self.b(['src/host/usbh.c'])
+ exs = s['family_examples']['stm32f4']
+ self.assertFalse(any(e.startswith(('device/', 'typec/')) for e in exs))
+
+ def test_example_rule(self): # rules 13, 14
+ s = self.b(['examples/device/cdc_msc/src/main.c'])
+ self.assertEqual(s['family_examples']['stm32f4'], ['device/cdc_msc'])
+ s = self.b(['examples/device/board_test/src/main.c'])
+ self.assertEqual(s['family_examples']['stm32f4'], ['device/board_test'])
+ s = self.b(['examples/device/no_such_example/src/main.c']) # deleted example: nothing
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+
+ def test_full_paths(self): # rules 15-17
+ for p in ('src/common/tusb_fifo.c', 'src/osal/osal.h', 'src/tusb.c',
+ 'src/tusb_option.h',
+ 'tools/build.py', 'tools/cmake/cpu/cortex-m4.cmake',
+ 'examples/CMakeLists.txt', 'examples/device/CMakeLists.txt',
+ 'examples/build_system/cmake/cpu.cmake', '.github/workflows/build.yml',
+ 'sonar-project.properties', 'some/unknown/path.c'):
+ self.assertTrue(self.b([p])['full'], p)
+
+ def test_mixed_diff_unions_per_family(self):
+ s = self.b(['src/portable/raspberrypi/rp2040/dcd_rp2040.c', 'src/class/cdc/cdc_device.c'])
+ self.assertFalse(s['full'])
+ self.assertIn('stm32f4', s['families'])
+ self.assertGreater(len(s['families']), 50)
+ self.assertIn('device/hid_composite', s['family_examples']['rp2040']) # from the dcd rule
+ self.assertNotIn('device/hid_composite', s['family_examples']['stm32f4']) # cdc-only there
+
+ def test_example_names_are_real_dirs(self):
+ for ex in ci_select.all_examples(REPO):
+ role, name = ex.split('/')
+ self.assertTrue(os.path.isdir(os.path.join(REPO, 'examples', role, name)), ex)
+ self.assertRegex(ex, r'^(device|dual|host|typec)/[A-Za-z0-9_]+$')
+
+
+class TestBuildPostFilter(unittest.TestCase):
+ def test_kept_examples_are_buildable(self):
+ import build_utils, build as build_py
+ s = ci_select.classify_build(['src/class/msc/msc_host.c'], REPO)
+ self.assertFalse(s['full'])
+ # families that cannot build a single TUH_MSC example drop out entirely
+ self.assertNotIn('msp430', s['families'])
+ old = os.getcwd()
+ os.chdir(REPO)
+ try:
+ # buildable on SOME board of the family - CircleCI builds them all
+ for fam, exs in s['family_examples'].items():
+ boards = build_py.get_family_boards(fam, False, False)
+ for e in exs:
+ self.assertTrue(any(not build_utils.skip_example(e, b) for b in boards),
+ f'{fam}: {e}')
+ finally:
+ os.chdir(old)
+
+ def test_unfiltered_family_has_no_map_key(self):
+ s = ci_select.classify_build(['hw/bsp/stm32f4/family.c'], REPO)
+ self.assertEqual(s['families'], ['stm32f4'])
+ self.assertEqual(s['family_examples'], {})
+
+ def test_espressif_prunes_to_what_its_build_path_can_build(self):
+ # build.py's espressif branch builds get_examples('espressif') only (the
+ # *_freertos examples plus a short extra list), so keeping espressif for a
+ # device/mtp diff spins CircleCI's most expensive leg up to skip everything
+ s = ci_select.classify_build(['examples/device/mtp/src/main.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertNotIn('espressif', s['families'])
+
+ def test_espressif_survives_an_example_it_does_build(self):
+ s = ci_select.classify_build(['examples/device/cdc_msc_freertos/src/main.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertIn('espressif', s['families'])
+
+ def test_ra_survives_the_dual_example_prune(self):
+ # ra's only buildable dual example is gated on only.txt's mcu:ra6m5, which
+ # exists only if the ${MCU_VARIANT} token in FAMILY_MCUS resolves
+ s = ci_select.classify_build(
+ ['examples/dual/host_info_to_device_cdc/src/main.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertIn('ra', s['families'], s['families'])
+
+ def test_deleted_family_dir_does_not_crash(self):
+ # rule 6 extracts a family from the path; a PR that deletes or renames
+ # hw/bsp/<fam> used to traceback in get_family_boards' scandir
+ s = ci_select.classify_build(['hw/bsp/no_such_family_xyz/family.cmake'], REPO)
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertTrue(any('gone from tree' in r for r in s['reasons']), s['reasons'])
+
+ def test_class_source_selecting_nothing_selects_nothing(self):
+ # synthetic class-with-no-enabling-config case (vendor_host.c was the live
+ # instance until its removal): no config enables CFG_TUH_VENDOR, so
+ # nothing exercises it and nothing builds - empty means empty (maintainer
+ # decision; the file is still parsed by every full master-push build, which is
+ # the accepted net for a break outside its #if guard)
+ s = ci_select.classify_build(['src/class/vendor/vendor_host.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertEqual(s['families'], [])
+ self.assertTrue(any('no contribution' in r for r in s['reasons']), s['reasons'])
+
+ def test_class_source_with_examples_still_scopes(self):
+ s = ci_select.classify_build(['src/class/cdc/cdc_device.c'], REPO)
+ self.assertFalse(s['full'])
+
+ def test_no_stdout_pollution(self):
+ # get_family_boards prints on odd families; the selector's stdout is JSON
+ import io, contextlib
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ ci_select.classify_build(['src/class/msc/msc_host.c'], REPO)
+ self.assertEqual(buf.getvalue(), '')
+
+
+class TestNoContributionPaths(unittest.TestCase):
+ """Paths that are inside build.yml's code filter but cannot change a compiled byte.
+ Unclassified means FULL on both axes, so a metrics-only PR would otherwise cost the
+ whole build matrix plus an exclusive full-rig sweep - where master ran nothing."""
+
+ def test_metrics_scripts_run_on_no_board_but_still_build(self):
+ # HIL axis only. tools/metrics.py IS executed by a build - examples/CMakeLists.txt
+ # makes it the `tinyusb_metrics` target and build_util.yml adds
+ # `--target tinyusb_metrics` - so the build axis must keep exercising it, or a
+ # break merges green and reds the next master push. Nothing on the rig runs it.
+ for p in ('tools/metrics.py', '.github/scripts/metrics_pair_compare.py'):
+ h = sel([p])
+ self.assertFalse(h['full'], p)
+ self.assertEqual(h['boards'], {}, p)
+ self.assertTrue(ci_select.classify_build([p], REPO)['full'], p)
+
+ def test_typec_example_builds_but_runs_nothing(self):
+ # examples/typec is compiled by the build matrix and run by no rig board; the
+ # HIL walk used to not recognise the role at all -> unclassified -> full rig
+ p = 'examples/typec/power_delivery/src/main.c'
+ h = sel([p])
+ self.assertFalse(h['full'])
+ self.assertEqual(h['boards'], {})
+ b = ci_select.classify_build([p], REPO)
+ self.assertFalse(b['full'])
+ self.assertTrue(b['families'], 'typec still has to be compiled somewhere')
+
+
+class TestHilExamples(unittest.TestCase):
+ def test_board_test_always_present_and_full_emits(self):
+ s = ci_select.classify(['src/common/tusb_fifo.c'], REPO, ROSTERS) # full
+ he = ci_select.hil_examples(s, ROSTERS)
+ self.assertEqual(set(he), {b['name'] for b in ROSTER})
+ for name, exs in he.items():
+ self.assertIn('device/board_test', exs)
+
+ def test_narrowed_board_gets_chosen_tests_only(self):
+ s = ci_select.classify(['examples/device/cdc_msc/src/main.c'], REPO, ROSTERS)
+ he = ci_select.hil_examples(s, ROSTERS)
+ self.assertEqual(he['stm32f407disco'], ['device/board_test', 'device/cdc_msc'])
+
+ def test_full_board_gets_its_whole_test_list(self):
+ s = ci_select.classify(['hw/bsp/stm32f4/boards/stm32f407disco/board.h'], REPO, ROSTERS)
+ he = ci_select.hil_examples(s, ROSTERS)
+ want = set(ci_select.board_tests(ROSTER[1])) | {'device/board_test'}
+ self.assertEqual(set(he['stm32f407disco']), want)
+ self.assertNotIn('raspberry_pi_pico', he) # deselected board: no firmware needed
+
+
+class TestHilExamplesDuplicateRosters(unittest.TestCase):
+ """Rosters are disjoint today, but a board moved between rigs (or listed on both
+ during a migration) must get the UNION of its test lists: superset firmware is
+ harmless, a missing image fails the run on whichever rig lost the coin toss."""
+
+ ROSTERS = [
+ ('test/hil/a.json', [{'name': 'dup_board', 'uid': 'd1', 'flasher': {'name': 'jlink'},
+ 'tests': {'only': ['device/cdc_msc']}}]),
+ ('test/hil/b.json', [{'name': 'dup_board', 'uid': 'd1', 'flasher': {'name': 'jlink'},
+ 'tests': {'only': ['device/hid_boot_interface']}}]),
+ ]
+
+ def test_duplicate_board_unions_the_test_lists(self):
+ he = ci_select.hil_examples({'full': True, 'boards': {}}, self.ROSTERS)
+ self.assertEqual(he['dup_board'],
+ ['device/board_test', 'device/cdc_msc',
+ 'device/hid_boot_interface'])
+
+
+class TestCliJson(unittest.TestCase):
+ def test_build_key_without_rosters(self):
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/ci_select.py'),
+ '--diff-file', '/dev/null'], capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ j = json.loads(r.stdout)
+ self.assertIn('build', j)
+ self.assertNotIn('hil_examples', j) # rosters not given
+
+ def test_build_and_hil_keys_with_rosters(self):
+ import tempfile
+ with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
+ f.write('src/portable/raspberrypi/rp2040/dcd_rp2040.c\n')
+ df = f.name
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools/ci_select.py'),
+ '--diff-file', df, os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ os.unlink(df)
+ j = json.loads(r.stdout)
+ self.assertEqual(j['build']['families'], ['rp2040'])
+ self.assertIn('hil_examples', j)
+ for exs in j['hil_examples'].values():
+ self.assertIn('device/board_test', exs)
+
+
+SET_MATRIX = os.path.join(REPO, '.github/scripts/ci_set_matrix.py')
+
+
+class TestCiSetMatrix(unittest.TestCase):
+ def run_matrix(self, *args):
+ return subprocess.run([sys.executable, SET_MATRIX, *args],
+ capture_output=True, text=True)
+
+ def test_no_flags_is_todays_output(self):
+ r = self.run_matrix()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.baseline = json.loads(r.stdout)
+ self.assertIn('stm32f4', self.baseline['arm-gcc'])
+
+ def test_select_full_is_identical(self):
+ base = json.loads(self.run_matrix().stdout)
+ sel = json.dumps({'build': {'full': True, 'families': [], 'family_examples': {}}})
+ self.assertEqual(json.loads(self.run_matrix('--select', sel).stdout), base)
+
+ def test_select_narrow_is_a_subset(self):
+ sel = json.dumps({'build': {'full': False, 'families': ['rp2040', 'stm32f4'],
+ 'family_examples': {}}})
+ m = json.loads(self.run_matrix('--select', sel).stdout)
+ self.assertEqual(m['arm-gcc'], ['rp2040', 'stm32f4'])
+ self.assertEqual(m['riscv-gcc'], [])
+ self.assertEqual(set(m), set(json.loads(self.run_matrix().stdout))) # all keys kept
+
+ def test_malformed_select_falls_open(self):
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--select', 'not json {')
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr) # build.yml greps this
+
+ def test_wrong_shaped_select_falls_open_too(self):
+ # valid JSON, wrong types: the matrix is built AFTER main()'s try/except, so an
+ # AttributeError here reds the step - the very outcome that handler exists to
+ # prevent (GHA and CircleCI only survive it through their own shell `||`)
+ base = json.loads(self.run_matrix().stdout)
+ for bad in ('{"build": ["stm32f4"]}', '{"build": {"full": false}}',
+ '{"build": {"full": false, "families": "stm32f4"}}', '["stm32f4"]'):
+ r = self.run_matrix('--select', bad)
+ self.assertEqual(r.returncode, 0, f'{bad}: {r.stderr}')
+ self.assertEqual(json.loads(r.stdout), base, bad)
+
+ def test_base_flag_with_empty_diff_selects_nothing(self):
+ # --base HEAD => empty diff => build.families [] => every toolchain scopes to []
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--base', 'HEAD')
+ self.assertEqual(r.returncode, 0, r.stderr)
+ m = json.loads(r.stdout)
+ self.assertEqual(set(m), set(base))
+ self.assertTrue(all(v == [] for v in m.values()), m)
+
+ def test_select_file_matches_select(self):
+ # build.yml hands the selection over as a FILE: a ~128KiB step env var makes
+ # the step's own exec fail with E2BIG before any fallback can run
+ import tempfile
+ sel = json.dumps({'build': {'full': False, 'families': ['rp2040'],
+ 'family_examples': {}}})
+ with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as f:
+ f.write(sel)
+ path = f.name
+ try:
+ self.assertEqual(self.run_matrix('--select-file', path).stdout,
+ self.run_matrix('--select', sel).stdout)
+ finally:
+ os.unlink(path)
+
+ def test_absent_families_key_falls_open(self):
+ # `{"build": {"full": false}}` with no families key is an unusable selection,
+ # not "nothing selected": scoping every toolchain to [] would report a
+ # vacuous green with zero families built
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--select', json.dumps({'build': {'full': False}}))
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr) # build.yml greps this
+
+ def test_families_no_toolchain_builds_falls_open(self):
+ # hw/bsp/same7x is real but in no toolchain's list, so scoping to it emits an
+ # all-empty matrix: every leg skips and the PR goes green from a build job that
+ # ran no compiler. Unusable, not "nothing selected" - and the marker matters,
+ # because that is what build.yml and CircleCI grep to drop the build extras too.
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--select',
+ json.dumps({'build': {'full': False, 'families': ['same7x']}}))
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr)
+
+ def test_a_partial_toolchain_miss_still_scopes(self):
+ # one buildable family is real coverage: scope to it and just note the other
+ r = self.run_matrix('--select', json.dumps(
+ {'build': {'full': False, 'families': ['stm32f4', 'same7x']}}))
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertEqual(json.loads(r.stdout)['arm-gcc'], ['stm32f4'])
+ self.assertNotIn('UNSCOPED', r.stderr)
+ self.assertIn('same7x', r.stderr)
+
+ def test_explicit_empty_families_selects_nothing(self):
+ # an explicit [] IS a legitimate answer (a diff that builds nothing)
+ r = self.run_matrix('--select',
+ json.dumps({'build': {'full': False, 'families': []}}))
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(set().union(*json.loads(r.stdout).values()), set())
+
+ def test_missing_select_file_falls_open(self):
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--select-file', '/no/such/selection.json')
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr) # build.yml greps this
+
+ def test_base_flag_bad_ref_falls_open(self):
+ base = json.loads(self.run_matrix().stdout)
+ r = self.run_matrix('--base', 'no-such-ref-xyz')
+ self.assertEqual(r.returncode, 0)
+ self.assertEqual(json.loads(r.stdout), base)
+ self.assertIn('ci_set_matrix: UNSCOPED', r.stderr) # build.yml greps this
+
+
+HIL_SET_MATRIX = os.path.join(REPO, '.github/scripts/hil_ci_set_matrix.py')
+
+
+class TestHilCiSetMatrixExamples(unittest.TestCase):
+ def run_matrix(self, *args):
+ r = subprocess.run([sys.executable, HIL_SET_MATRIX, *args,
+ os.path.join(REPO, 'test/hil/tinyusb.json')],
+ capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ return r.stdout
+
+ def test_no_hil_examples_is_byte_identical(self):
+ plain = self.run_matrix()
+ sel = json.dumps({'full': True, 'boards': {}})
+ self.assertEqual(self.run_matrix('--select', sel), plain)
+
+ def test_absent_boards_key_falls_open_to_the_full_roster(self):
+ # the mirror of ci_set_matrix's families guard: reading an ABSENT boards key as
+ # "nothing selected" filters every board out, so every hil-build leg skips and
+ # both rig jobs skip through needs: - an all-green PR with zero hardware
+ # coverage. An explicit boards: {} stays a legitimate nothing-selected.
+ plain = self.run_matrix()
+ for bad in ('{"full": false, "hil_examples": {}}', '{"full": false, "boards": []}',
+ 'not json {', '["a board"]',
+ # the whole selection is unusable, hil_examples included: keeping the
+ # -e lists builds a few examples per board while the rig, unfiltered,
+ # runs that board's whole test list
+ '{"full": false, "hil_examples": {"frdm_k64f": ["device/cdc_msc"]}}'):
+ self.assertEqual(self.run_matrix('--select', bad), plain, bad)
+ self.assertNotEqual(self.run_matrix('--select', '{"full": false, "boards": {}}'),
+ plain, 'an explicit empty boards map still means nothing')
+
+ def test_select_file_matches_select(self):
+ # hil-hfp-iar passes the whole selection; as one argv it can exceed
+ # MAX_ARG_STRLEN on a big diff, so the file form must be equivalent
+ import tempfile
+ board = on_roster(self, 'stm32f407disco')[0]
+ sel = json.dumps({'full': False, 'boards': {board: 'all'},
+ 'hil_examples': {board: ['device/board_test']}})
+ with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as f:
+ f.write(sel)
+ path = f.name
+ try:
+ self.assertEqual(self.run_matrix('--select-file', path),
+ self.run_matrix('--select', sel))
+ finally:
+ os.unlink(path)
+
+ def test_examples_appended_per_board(self):
+ board = on_roster(self, 'stm32f407disco')[0]
+ sel = json.dumps({'full': False, 'boards': {board: 'all'},
+ 'hil_examples': {board: ['device/board_test', 'device/cdc_msc']}})
+ m = json.loads(self.run_matrix('--select', sel))
+ entries = [e for entries in m.values() for e in entries]
+ self.assertTrue(entries)
+ for e in entries:
+ self.assertIn(f'-b {board}', e)
+ self.assertIn('-e device/board_test', e)
+ self.assertIn('-e device/cdc_msc', e)
+
+
+class TestBuildPyExampleFilter(unittest.TestCase):
+ def setUp(self):
+ import build as build_py
+ self.build = build_py
+ self.old = os.getcwd()
+ os.chdir(REPO) # skip_example uses repo-relative paths
+
+ def tearDown(self):
+ os.chdir(self.old)
+
+ def test_all_maps_to_example_names(self):
+ # ONE group: the examples of a '--target all' build go into a single
+ # `cmake --build --target a b c`, so they build in parallel
+ t = self.build.resolve_example_target_groups(['all'], ['device/cdc_msc', 'device/dfu'],
+ 'stm32f407disco')
+ self.assertEqual(t, [['cdc_msc', 'dfu']])
+
+ def test_other_targets_pass_through_in_their_own_group(self):
+ # a target that is not 'all' keeps its own invocation, so ordering against the
+ # examples is preserved (tinyusb_metrics runs after them, as it did unfiltered)
+ t = self.build.resolve_example_target_groups(['all', 'tinyusb_metrics'],
+ ['device/cdc_msc'], 'stm32f407disco')
+ self.assertEqual(t, [['cdc_msc'], ['tinyusb_metrics']])
+
+ def test_unbuildable_examples_drop_and_empty_is_none(self):
+ # typec/power_delivery only builds on stm32g4-class parts, never on f4
+ t = self.build.resolve_example_target_groups(['all'],
+ ['typec/power_delivery', 'device/cdc_msc'],
+ 'stm32f407disco')
+ self.assertEqual(t, [['cdc_msc']])
+ self.assertIsNone(self.build.resolve_example_target_groups(['all'],
+ ['typec/power_delivery'],
+ 'stm32f407disco'))
+
+ def test_espressif_empty_intersection_skips_without_building(self):
+ # cmake_board's espressif branch must short-circuit on an empty -e
+ # intersection the same way the generic cmake/make branches do, and
+ # must do so before touching idf.py (no real esp-idf build here).
+ calls = []
+ real_run_cmd = self.build.run_cmd # `del` here would drop the real one
+ self.build.run_cmd = lambda cmd: calls.append(cmd) # would only run for a real build
+ try:
+ r = self.build.cmake_board('espressif_s3_devkitc', [], None, [], ['all'],
+ examples=['nonexistent/example'])
+ finally:
+ self.build.run_cmd = real_run_cmd
+ self.assertEqual(r, [0, 0, 1])
+ self.assertEqual(calls, [])
+
+ def test_make_one_example_uses_make_semantics(self):
+ # F1 end to end: the make path must ask skip_example with build_system='make',
+ # or lpc54's cmake-only FAMILY_MCUS un-skips a host example whose make build
+ # compiles no HCD source and fails to link
+ calls = []
+ real_run_cmd = self.build.run_cmd
+ self.build.run_cmd = lambda cmd: calls.append(cmd)
+ try:
+ r = self.build.make_one_example('host/msc_file_explorer_freertos',
+ 'lpcxpresso54628', '', ['all'])
+ finally:
+ self.build.run_cmd = real_run_cmd
+ self.assertEqual(r, [0, 0, 1]) # skipped, nothing handed to make
+ self.assertEqual(calls, [])
+
+ def test_example_flag_rejects_a_bare_name(self):
+ # `-e cdc_msc` (no role) used to IndexError inside the target resolver;
+ # argparse rejects the shape now, with a message that names it
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools', 'build.py'),
+ '-b', 'stm32f407disco', '-e', 'cdc_msc'],
+ capture_output=True, text=True, cwd=REPO)
+ self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
+ self.assertIn('role/name', r.stderr)
+
+ def test_no_example_basename_is_reused_across_roles(self):
+ # -e maps role/name onto the BARE cmake target name, so device/foo and host/foo
+ # would collapse into one `--target foo`: one of them would never build while
+ # the post-configure check still reports both as covered. No collision today,
+ # and the -e lists are machine-generated, so nothing else would notice one.
+ seen = {}
+ for ex in ci_select.all_examples(REPO):
+ role, name = ex.split('/', 1)
+ self.assertNotIn(name, seen,
+ f'{ex} and {seen.get(name)}/{name} share a cmake target name; '
+ f'build.py -e cannot tell them apart')
+ seen[name] = role
+
+ def test_example_flag_rejects_a_name_no_example_dir_answers_to(self):
+ # right shape, no such dir: every board would report Skipped and the run would
+ # still exit 0 (main returns the FAILED count), so an entirely stale -e list -
+ # from the example map or from a roster test name - reads as a green build
+ r = subprocess.run([sys.executable, os.path.join(REPO, 'tools', 'build.py'),
+ '-b', 'stm32f407disco', '-e', 'device/no_such_example'],
+ capture_output=True, text=True, cwd=REPO)
+ self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
+ self.assertIn('no such example directory', r.stderr)
+
+ def test_pr_filter_answers_before_configuring(self):
+ # nothing the -e list names is buildable here: the skip.txt mirror needs no
+ # configure output, so the whole cmake run must be skipped, not just its build
+ calls = []
+ real_run_cmd = self.build.run_cmd
+ self.build.run_cmd = lambda cmd: calls.append(cmd)
+ try:
+ r = self.build.cmake_board('stm32f407disco', [], None, [], ['all'],
+ examples=['typec/power_delivery'])
+ finally:
+ self.build.run_cmd = real_run_cmd
+ self.assertEqual(r, [0, 0, 1])
+ self.assertEqual(calls, [])
+
+ def _cmake_board_with_targets(self, registered, examples):
+ """cmake_board with the configure/build stubbed and CMake's registered-target
+ list forced. Returns (result, target names handed to `cmake --build`)."""
+ class Ok:
+ returncode = 0
+ calls = []
+
+ def fake_run(cmd):
+ calls.append(cmd)
+ return Ok()
+ real_run_cmd = self.build.run_cmd
+ real_targets = self.build.cmake_registered_targets
+ self.build.run_cmd = fake_run
+ self.build.cmake_registered_targets = lambda d: registered
+ try:
+ r = self.build.cmake_board('stm32f407disco', [], None, [], ['all'],
+ examples=examples)
+ finally:
+ self.build.run_cmd = real_run_cmd
+ self.build.cmake_registered_targets = real_targets
+ # everything after --target: one invocation carries the whole group
+ built = [c[c.index('--target') + 1:] for c in calls if '--target' in c]
+ return r, built
+
+ def test_example_without_a_cmake_target_is_dropped(self):
+ # an example dir CMake never registered (absent from the role CMakeLists, or
+ # a stale roster name) must not reach `cmake --build --target <it>`: that is a
+ # hard red, and skip.txt cannot see it
+ r, built = self._cmake_board_with_targets({'cdc_msc'},
+ ['device/cdc_msc', 'device/dfu'])
+ self.assertEqual(built, [['cdc_msc']])
+ self.assertEqual(r, [1, 0, 0])
+
+ def test_the_selected_examples_build_in_one_invocation(self):
+ # one `cmake --build --target a b c`, not one invocation per example: the
+ # per-example loop serialised every scoped leg, and hil-build gets an -e list
+ # on EVERY PR (~14 examples per board), so it is on the critical path to the rig
+ r, built = self._cmake_board_with_targets({'cdc_msc', 'dfu', 'hid_generic_inout'},
+ ['device/cdc_msc', 'device/dfu',
+ 'device/hid_generic_inout'])
+ self.assertEqual(built, [['cdc_msc', 'dfu', 'hid_generic_inout']])
+
+ def test_no_registered_target_at_all_skips_the_build(self):
+ r, built = self._cmake_board_with_targets({'cdc_msc'}, ['device/dfu'])
+ self.assertEqual(built, [])
+ self.assertEqual(r, [0, 0, 1])
+
+ def test_unparseable_target_help_keeps_the_skip_txt_answer(self):
+ # ground truth unavailable (a non-Ninja generator, an old cmake): fall back
+ # to the mirror rather than dropping every example
+ r, built = self._cmake_board_with_targets(None, ['device/cdc_msc'])
+ self.assertEqual(built, [['cdc_msc']])
+
+ def test_target_help_parse(self):
+ text = ('[1/1] All primary targets available:\n'
+ 'tinyusb_metrics: phony\n'
+ 'cdc_msc: phony\n'
+ 'cdc_msc-membrowse-upload: phony\n'
+ 'device/edit_cache: phony\n'
+ '/abs/build/device/cdc_msc/CMakeFiles/cdc_msc-jlink: CUSTOM_COMMAND\n')
+ self.assertEqual(self.build.parse_target_help(text),
+ {'tinyusb_metrics', 'cdc_msc', 'cdc_msc-membrowse-upload'})
+
+ def test_build_defines_reach_the_example_filter(self):
+ # metro_m4_express gets MAX3421_HOST=1 from its roster variant, never
+ # from its BSP: without threading them through, -e drops the rig's only
+ # MAX3421 dual firmware that --target all used to build
+ self.assertIsNone(self.build.resolve_example_target_groups(
+ ['all'], ['dual/host_info_to_device_cdc'], 'metro_m4_express'))
+ self.assertEqual(self.build.resolve_example_target_groups(
+ ['all'], ['dual/host_info_to_device_cdc'], 'metro_m4_express',
+ extra_defines=('MAX3421_HOST=1',)), [['host_info_to_device_cdc']])
+
+
+
+class TestFamilyMcusFallback(unittest.TestCase):
+ """A family whose family.cmake sets FAMILY_MCUS only inside if() blocks gets its
+ whole MCU answer from _board_mcu's CFG_TUSB_MCU scrape (build_utils._family_mcus
+ does not evaluate cmake conditionals). For mcx that answer is load-bearing - six
+ examples' skip.txt name mcu:MCXA15 - and it comes out right only because every
+ mcx board still carries the token in a make-only board.mk the scrape falls
+ through to. A board.cmake-only board (MCU_VARIANT, no CFG_TUSB_MCU) would scrape
+ 'NONE' and silently skip EVERY example on it, in CI as well as in -e."""
+
+ @staticmethod
+ def conditional_only_families():
+ """hw/bsp/<family> dirs whose family.cmake has no unconditional
+ set(FAMILY_MCUS ...) - computed, not listed, so a family that grows or loses
+ one moves in and out of this guard on its own."""
+ import build_utils
+ out = []
+ for fc in sorted(glob.glob(os.path.join(REPO, 'hw/bsp/*/family.cmake'))):
+ depth, uncond = 0, False
+ for line in open(fc).read().splitlines():
+ line = line.strip()
+ if build_utils._FAMILY_MCUS_RE.match(line) and depth == 0:
+ uncond = True
+ if re.match(r'if\s*\(', line):
+ depth += 1
+ elif re.match(r'endif\s*\(', line):
+ depth = max(0, depth - 1)
+ if not uncond:
+ out.append(os.path.dirname(fc))
+ return out
+
+ def test_every_board_of_such_a_family_scrapes_an_mcu(self):
+ import build_utils
+ fams = self.conditional_only_families()
+ self.assertTrue(fams, 'no family sets FAMILY_MCUS conditionally any more')
+ for fam_dir in fams:
+ fam = os.path.basename(fam_dir)
+ for bd in sorted(glob.glob(os.path.join(fam_dir, 'boards', '*'))):
+ if not os.path.isdir(bd):
+ continue
+ mcu, _ = build_utils._board_mcu(bd, fam_dir, fam)
+ self.assertNotEqual(
+ mcu, 'NONE',
+ f'{fam}/{os.path.basename(bd)}: nothing to scrape a CFG_TUSB_MCU '
+ f'token from, and {fam}/family.cmake sets FAMILY_MCUS only inside '
+ f'if() - skip_example would skip every example on this board. Fix '
+ f'by evaluating the if(MCU_VARIANT STREQUAL ...) branches.')
+
+
+class TestMcuTokensResolve(unittest.TestCase):
+ """The cmake-side MCU mirror must never answer with an unexpanded ${VAR} or with
+ nothing at all: both make every `mcu:` token miss, which reads as 'skip' for any
+ example carrying an only.txt and silently drops compile coverage."""
+
+ @staticmethod
+ def _every_board():
+ import build as build_py
+ old = os.getcwd()
+ os.chdir(REPO)
+ try:
+ for fam in sorted(os.path.basename(os.path.dirname(f))
+ for f in glob.glob(os.path.join(REPO, 'hw/bsp/*/boards'))):
+ for b in build_py.get_family_boards(fam, False, False):
+ yield fam, b
+ finally:
+ os.chdir(old)
+
+ def test_no_board_answers_with_an_unexpanded_variable(self):
+ import build_utils
+ for fam, board in self._every_board():
+ fam_dir, board_dir = f'{REPO}/hw/bsp/{fam}', f'{REPO}/hw/bsp/{fam}/boards/{board}'
+ mcus = set(build_utils._family_mcus(fam_dir, board_dir))
+ mcus.add(build_utils._board_mcu(board_dir, fam_dir, fam)[0])
+ self.assertFalse([m for m in mcus if '${' in m],
+ f'{fam}/{board}: unexpanded cmake variable in {sorted(mcus)} - '
+ f'teach build_utils._cmake_expand the construct that produces it')
+ self.assertTrue(mcus - {'NONE'},
+ f'{fam}/{board}: no MCU name resolved at all')
+
+ # skip.txt/only.txt tokens no board in the tree answers to: stale spellings left
+ # behind by a family rename. Each one silently changes what CI builds, so this list
+ # must only ever SHRINK - a new entry means either a live token the mirror cannot
+ # produce, or a rename nobody followed through. `family:samd21` was one of these
+ # until the nine examples/host/*/only.txt files were corrected to samd2x_l2x.
+ #
+ # The `mcu:` entries are NOT all harmless. MIMXRT10XX/MIMXRT11XX and LPC177X_8X sit
+ # beside a live token in the same file, so they gate nothing either way. MKL25ZXX
+ # (device/msc_dual_lun) and SAME5X (device/audio_test) do not: those skips are dead,
+ # and both examples are built today on the boards their skip file meant to exclude -
+ # successfully, which is why nobody noticed. Correcting them REMOVES working build
+ # coverage, so it is a maintainer call, not a drive-by fix.
+ UNREACHABLE_TOKENS = {
+ 'mcu': {'LPC177X_8X', 'MIMXRT10XX', 'MIMXRT11XX', 'MKL25ZXX', 'SAME5X', 'STM32U3'},
+ 'family': set(),
+ 'board': set(),
+ }
+
+ def test_every_skip_only_token_is_reachable(self):
+ import build_utils
+ wanted = {ns: set() for ns in self.UNREACHABLE_TOKENS}
+ for f in glob.glob(os.path.join(REPO, 'examples/*/*/*.txt')):
+ if os.path.basename(f) in ('skip.txt', 'only.txt'):
+ for tok in open(f).read().split():
+ ns, _, name = tok.partition(':')
+ if ns in wanted and name:
+ wanted[ns].add(name)
+ have = {ns: set() for ns in wanted}
+ have['mcu'].add('MAX3421') # synthetic, from family_support.cmake:940
+ for fam, board in self._every_board():
+ fam_dir, board_dir = f'{REPO}/hw/bsp/{fam}', f'{REPO}/hw/bsp/{fam}/boards/{board}'
+ have['family'].add(fam)
+ have['board'].add(board)
+ have['mcu'] |= set(build_utils._family_mcus(fam_dir, board_dir))
+ have['mcu'].add(build_utils._board_mcu(board_dir, fam_dir, fam)[0])
+ have['mcu'].add(build_utils._scrape_mcu(pathlib.Path(fam_dir),
+ pathlib.Path(board_dir), fam)[0]) # make
+ for ns in wanted:
+ self.assertEqual(
+ wanted[ns] - have[ns], self.UNREACHABLE_TOKENS[ns] & wanted[ns],
+ f'a skip.txt/only.txt {ns}: token nothing in hw/bsp answers to. Either '
+ f'the token is stale (a rename just changed what CI builds), or the '
+ f'mirror cannot produce it - both silently skip that example everywhere.')
+
+ def test_the_mcx_skip_tokens_are_still_live(self):
+ # the reason the mcx scrape is load-bearing rather than academic
+ named = [os.path.dirname(f) for f in glob.glob(os.path.join(REPO, 'examples/*/*/skip.txt'))
+ if 'mcu:MCXA15' in open(f).read().split()]
+ self.assertTrue(named, 'no skip.txt names mcu:MCXA15 any more')
+
+
+class TestSkipExampleMirrorsFamilyFilter(unittest.TestCase):
+ """build_utils.skip_example is the python mirror of CMake's family_filter
+ (hw/bsp/family_support.cmake:171-207). family_filter loops over the whole
+ FAMILY_MCUS list; a per-board CFG_TUSB_MCU scrape alone lets -e ask for a
+ target CMake never created, and `cmake --build --target <it>` hard-fails."""
+
+ def setUp(self):
+ import build_utils
+ self.build_utils = build_utils
+ self.old = os.getcwd()
+ os.chdir(REPO) # skip_example uses repo-relative paths
+
+ def tearDown(self):
+ os.chdir(self.old)
+
+ def test_any_family_mcu_can_skip(self):
+ # broadcom_64bit: set(FAMILY_MCUS BCM2711 BCM2835); raspberrypi_cm4 is
+ # BCM2711, and examples/device/dfu/skip.txt lists mcu:BCM2835
+ self.assertTrue(self.build_utils.skip_example('device/dfu', 'raspberrypi_cm4'))
+
+ def test_any_family_mcu_can_satisfy_only(self):
+ # lpc55: family.mk says LPC55XX, family.cmake sets FAMILY_MCUS LPC55, and
+ # host/cdc_msc_hid/only.txt lists mcu:LPC55 - CMake builds it
+ self.assertFalse(self.build_utils.skip_example('host/cdc_msc_hid', 'lpcxpresso55s69'))
+
+ def test_existing_decisions_are_unchanged(self):
+ self.assertFalse(self.build_utils.skip_example('device/cdc_msc', 'stm32f407disco'))
+ self.assertTrue(self.build_utils.skip_example('typec/power_delivery', 'stm32f407disco'))
+
+ def test_build_define_enables_max3421_only_list(self):
+ # family_support.cmake:940 appends MAX3421 to FAMILY_MCUS when
+ # MAX3421_HOST=1; on metro_m4_express that define comes from the roster
+ # variant defines, so skip_example has to be told about it
+ ex = 'dual/host_info_to_device_cdc'
+ self.assertTrue(self.build_utils.skip_example(ex, 'metro_m4_express'))
+ self.assertFalse(self.build_utils.skip_example(ex, 'metro_m4_express',
+ extra_defines=('MAX3421_HOST=1',)))
+
+ def test_family_mcus_variable_token_resolves(self):
+ """hw/bsp/ra/family.cmake: `set(FAMILY_MCUS RAXXX ${MCU_VARIANT})`, and
+ ra6m5_ek/board.cmake sets MCU_VARIANT ra6m5 — which is exactly the token
+ dual/host_info_to_device_cdc/only.txt spells (mcu:ra6m5). Dropping the
+ ${...} token silently removed ra from every scoped dual-example build."""
+ self.assertFalse(self.build_utils.skip_example(
+ 'dual/host_info_to_device_cdc', 'ra6m5_ek'))
+
+ def test_board_cmake_max3421_counts(self):
+ """feather_rp2040_max3421/board.cmake sets MAX3421_HOST 1 while the MCU
+ token comes from rp2040's family.cmake; scanning only the file the token
+ came from misses it, and only.txt's mcu:MAX3421 never matches."""
+ self.assertFalse(self.build_utils.skip_example(
+ 'host/cdc_msc_hid_freertos', 'feather_rp2040_max3421'))
+
+
+class TestSkipExampleMakeSemantics(unittest.TestCase):
+ """FAMILY_MCUS is a CMAKE fact. hw/bsp/lpc54/family.cmake sets it to LPC54 and
+ wires the ohci host sources; family.mk builds OPT_MCU_LPC54XXX and compiles no
+ HCD source at all — so applying the cmake MCU union to a Make build un-skips
+ the 9 host examples only.txt gates on mcu:LPC54 and they fail to link
+ (undefined reference to hcd_init). Make keeps master's exact algorithm."""
+
+ def setUp(self):
+ import build_utils
+ self.build_utils = build_utils
+ self.old = os.getcwd()
+ os.chdir(REPO) # skip_example uses repo-relative paths
+
+ def tearDown(self):
+ os.chdir(self.old)
+
+ def test_make_keeps_cmake_only_family_mcus_out(self):
+ self.assertTrue(self.build_utils.skip_example(
+ 'host/msc_file_explorer_freertos', 'lpcxpresso54628', build_system='make'))
+
+ def test_make_does_not_skip_on_a_sibling_family_mcu(self):
+ # broadcom_64bit sets FAMILY_MCUS "BCM2711 BCM2835"; raspberrypi_cm4 is the
+ # BCM2711 one and device/dfu/skip.txt names mcu:BCM2835. The aarch64 make leg
+ # built device/dfu before the union and must keep building it.
+ for ex in ('device/dfu', 'device/usbtmc'):
+ self.assertFalse(self.build_utils.skip_example(
+ ex, 'raspberrypi_cm4', build_system='make'), ex)
+
+ def test_cmake_is_the_default_and_still_unions(self):
+ self.assertTrue(self.build_utils.skip_example('device/dfu', 'raspberrypi_cm4'))
+ self.assertEqual(
+ self.build_utils.skip_example('device/dfu', 'raspberrypi_cm4'),
+ self.build_utils.skip_example('device/dfu', 'raspberrypi_cm4',
+ build_system='cmake'))
+
+ def test_build_system_is_part_of_the_cache_key(self):
+ # one lru_cache shared by both semantics would answer the second caller
+ # with the first caller's verdict
+ ex, board = 'host/msc_file_explorer_freertos', 'lpcxpresso54628'
+ self.assertFalse(self.build_utils.skip_example(ex, board, build_system='cmake'))
+ self.assertTrue(self.build_utils.skip_example(ex, board, build_system='make'))
+ self.assertFalse(self.build_utils.skip_example(ex, board, build_system='cmake'))
+
+
+class TestConfigEnables(unittest.TestCase):
+ """_config_enables decides which examples a class change selects, on BOTH the
+ build and the HIL axis. A define it cannot evaluate must read as ON: reading
+ it as OFF is fail-closed, and lets a compile break merge green."""
+
+ def test_identifier_value_is_enabled(self):
+ # examples/host/midi_rx: `#define CFG_TUH_MIDI CFG_TUH_DEVICE_MAX`
+ cfg = os.path.join(REPO, 'examples/host/midi_rx/src/tusb_config.h')
+ self.assertTrue(ci_select._config_enables(cfg, ['CFG_TUH_MIDI']))
+
+ def test_literal_zero_is_disabled(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ cfg = os.path.join(td, 'tusb_config.h')
+ with open(cfg, 'w') as f:
+ f.write('#define CFG_TUD_CDC 0\n'
+ '#define CFG_TUD_MSC (0)\n'
+ '#define CFG_TUD_HID 00\n'
+ '#define CFG_TUH_HID 0 // typical keyboard + mouse\n'
+ '#define CFG_TUD_MIDI 01\n'
+ '#define CFG_TUD_DFU (1)\n')
+ for m in ('CFG_TUD_CDC', 'CFG_TUD_MSC', 'CFG_TUD_HID', 'CFG_TUH_HID'):
+ self.assertFalse(ci_select._config_enables(cfg, [m]), m)
+ for m in ('CFG_TUD_MIDI', 'CFG_TUD_DFU'):
+ self.assertTrue(ci_select._config_enables(cfg, [m]), m)
+ self.assertFalse(ci_select._config_enables(cfg, ['CFG_TUD_VIDEO']))
+
+ def test_two_branch_define_reads_on(self):
+ # examples/device/uac2_speaker_fb defines CFG_TUD_HID 1 under
+ # `#if CFG_AUDIO_DEBUG` and 0 in the #else. The default build (CFG_AUDIO_DEBUG
+ # defaults to 1) compiles the HID class in, so a CFG_TUD_HID change must keep
+ # this example on both axes - the #else's zero must not decide it.
+ cfg = os.path.join(REPO, 'examples/device/uac2_speaker_fb/src/tusb_config.h')
+ self.assertTrue(ci_select._config_enables(cfg, ['CFG_TUD_HID']))
+
+ def test_any_nonzero_define_wins_over_a_zero_one(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as td:
+ cfg = os.path.join(td, 'tusb_config.h')
+ with open(cfg, 'w') as f:
+ f.write('#if FOO\n#define CFG_TUD_MSC 1\n#else\n'
+ '#define CFG_TUD_MSC 0\n#endif\n'
+ '#if BAR\n#define CFG_TUD_CDC 0\n#else\n'
+ '#define CFG_TUD_CDC (0)\n#endif\n')
+ self.assertTrue(ci_select._config_enables(cfg, ['CFG_TUD_MSC']))
+ self.assertFalse(ci_select._config_enables(cfg, ['CFG_TUD_CDC']))
+
+ def test_midi_host_change_selects_midi_rx(self):
+ s = ci_select.classify_build(['src/class/midi/midi_host.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertTrue(s['families'], 'a TUH_MIDI change must select some family')
+ self.assertTrue(any('host/midi_rx' in exs
+ for exs in s['family_examples'].values()),
+ s['family_examples'])
+
+
+class TestPruneUsesEveryFamilyBoard(unittest.TestCase):
+ """CircleCI's cmake legs build EVERY board of a family, so an example gated to
+ one board (only.txt board:mimxrt1060_evk) must keep its family even though the
+ family's one-first board cannot build it."""
+
+ def test_board_gated_example_keeps_its_family(self):
+ s = ci_select.classify_build(
+ ['examples/dual/host_hid_to_device_cdc/src/main.c'], REPO)
+ self.assertFalse(s['full'])
+ self.assertIn('imxrt', s['families'], s['families'])
+ self.assertEqual(s['family_examples'].get('imxrt'),
+ ['dual/host_hid_to_device_cdc'])
+
+ def test_either_build_system_keeps_the_family(self):
+ """This one family list gates CircleCI's MAKE legs too, and the two build
+ systems answer skip.txt differently. device/dfu carries mcu:BCM2835, which the
+ cmake FAMILY_MCUS union (BCM2711 BCM2835) applies to every broadcom_64bit board
+ and the make scrape applies to none - asking cmake alone drops the only
+ aarch64-gcc family in the matrix, so build-make-aarch64-gcc silently stops
+ compiling dfu at all."""
+ import build_utils
+ old = os.getcwd()
+ os.chdir(REPO)
+ try:
+ self.assertTrue(build_utils.skip_example('device/dfu', 'raspberrypi_cm4'))
+ self.assertFalse(build_utils.skip_example('device/dfu', 'raspberrypi_cm4',
+ (), 'make'))
+ finally:
+ os.chdir(old)
+ s = ci_select.classify_build(['examples/device/dfu/src/main.c'], REPO)
+ self.assertIn('broadcom_64bit', s['families'], s['families'])
+
+
+class TestPrunePoolIsBuildPys(unittest.TestCase):
+ """_prune_buildable asks build.py what each family's build path can see, the same
+ way for every family - the espressif carve-out lives in build.py.get_examples and
+ needs no second copy here. Measured identical on all 82 families."""
+
+ def setUp(self):
+ import build as build_py
+ self.build_py = build_py
+ self.old = os.getcwd()
+ os.chdir(REPO) # get_examples scans relative paths
+
+ def tearDown(self):
+ os.chdir(self.old)
+
+ def test_only_espressif_narrows_the_pool(self):
+ allex = list(ci_select.all_examples(REPO))
+ for fam in ci_select.all_bsp_families(REPO):
+ pool = [e for e in allex if e in set(self.build_py.get_examples(fam))]
+ if fam == 'espressif':
+ self.assertNotEqual(pool, allex) # the carve-out is real
+ else:
+ self.assertEqual(pool, allex, f'{fam}: build.py narrows this family')
+
+ def test_selections_are_what_the_espressif_only_rule_gave(self):
+ # espressif's own list is the one value that ever differed from the unfiltered
+ # example set. Recomputed from build.py rather than pinned as literals: a new
+ # board, family or example moves the counts, and a suite that fails for that
+ # teaches people to edit the numbers instead of reading the diff. What is pinned
+ # is the RELATION - espressif gets exactly the rule's answer narrowed to its own
+ # pool, every other family gets the answer unnarrowed.
+ pool = set(self.build_py.get_examples('espressif'))
+ # the third diff names an example espressif DOES build, so there is nothing for
+ # the carve-out to remove - it pins that the narrowing does not over-reach
+ for files, carve in ((['src/portable/synopsys/dwc2/dcd_dwc2.c'], True),
+ (['src/class/msc/msc_host.c'], True),
+ (['examples/device/cdc_msc_freertos/src/main.c'], False)):
+ s = ci_select.classify_build(files, REPO)
+ self.assertFalse(s['full'], files)
+ self.assertIn('espressif', s['families'], files)
+ esp = set(s['family_examples'].get('espressif') or [])
+ self.assertTrue(esp, f'{files}: espressif selected nothing')
+ # the pool narrowing is what _prune_buildable adds here, so it must hold...
+ self.assertTrue(esp <= pool, f'{files}: {sorted(esp - pool)} is outside the pool')
+ # ...and it must actually bite: some other family was given an example that
+ # espressif's build path cannot see, and espressif did not get it
+ other = set().union(*(set(v) for f, v in s['family_examples'].items()
+ if f != 'espressif'), set())
+ self.assertEqual(bool(other - pool), carve,
+ f'{files}: carve-out expected={carve}, other-side extras '
+ f'{sorted(other - pool)}')
+ self.assertFalse(esp & (other - pool), files)
+
+
+class TestGetDepsExampleShim(unittest.TestCase):
+ """hil_ci_set_matrix emits `-b <board> -e role/name` entries that .github/actions/
+ get_deps and build.yml's hfp job hand verbatim to get_deps.py. argparse must not
+ reject -e there (exit 2 = every PR's Get Dependencies step red)."""
+
+ # get_deps.main() with its process pool stubbed out: argparse runs for real,
+ # nothing is cloned (this suite also runs on GitHub's bare pre-commit runner)
+ CODE = ('import sys\n'
+ 'import get_deps\n'
+ 'class P:\n'
+ ' def __enter__(self): return self\n'
+ ' def __exit__(self, *a): return False\n'
+ ' def map(self, fn, items): return [0] * len(items)\n'
+ 'get_deps.Pool = P\n'
+ "sys.argv = ['get_deps.py'] + sys.argv[1:]\n"
+ 'sys.exit(get_deps.main())\n')
+
+ def run_get_deps(self, *args):
+ env = dict(os.environ, PYTHONPATH=os.path.join(REPO, 'tools'))
+ return subprocess.run([sys.executable, '-c', self.CODE, *args],
+ capture_output=True, text=True, cwd=REPO, env=env)
+
+ def test_example_flag_is_accepted(self):
+ r = self.run_get_deps('-b', 'stm32f407disco', '-e', 'device/cdc_msc')
+ self.assertNotIn('unrecognized arguments', r.stderr)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_plain_board_still_works(self):
+ r = self.run_get_deps('-b', 'stm32f407disco')
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+
+if __name__ == '__main__':
+ unittest.main(verbosity=1)
diff --git a/test/hil/test/test_hil_select.py b/test/hil/test/test_hil_select.py
deleted file mode 100644
index 9a1261878..000000000
--- a/test/hil/test/test_hil_select.py
+++ /dev/null
@@ -1,689 +0,0 @@
-#!/usr/bin/env python3
-# SPDX-License-Identifier: MIT
-# Unit tests for hil_select.py — pure logic, no hardware, no git. Run directly:
-# python3 test/hil/test/test_hil_select.py
-#
-# Imports stay stdlib + hil_select/hil_util/hil_flash ONLY: the pre-commit hil-test
-# hook runs this suite, on GitHub's bare runner in the pre-commit workflow as well as
-# locally, and that runner has no pyserial/pymtp. hil_flash is admissible because it
-# is stdlib + hil_util only (test_hil_util.BottomLayer enforces the stdlib closure of
-# both) and the roster-dispatch tests need its flash_* table; never import hil_test,
-# which pulls pyserial.
-import glob
-import json
-import os
-import sys
-import unittest
-
-# the modules under test live in the parent dir (test/hil), not here
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-import hil_flash
-from helper import hil_select
-from helper.hil_util import device_tests, dual_tests
-
-REPO = os.path.dirname(os.path.dirname(os.path.dirname(
- os.path.dirname(os.path.abspath(__file__)))))
-
-
-def real_rosters():
- """The actual rig rosters, for regression tests that need real-world data
- (a specific board/family/only-list) rather than the synthetic ROSTER above."""
- rosters = []
- for name in ('tinyusb.json', 'hfp.json'):
- path = os.path.join(REPO, 'test/hil', name)
- with open(path) as f:
- rosters.append((f'test/hil/{name}', json.load(f)['boards']))
- return rosters
-
-
-def roster_flashers():
- """(roster path, board) for every board in the live rosters, `boards-skip`
- included: a parked board's flasher name must still dispatch, so that unparking it
- is not what discovers the name went stale."""
- for name in ('tinyusb.json', 'hfp.json'):
- path = os.path.join(REPO, 'test/hil', name)
- with open(path) as f:
- cfg = json.load(f)
- for key in ('boards', 'boards-skip'):
- for b in cfg.get(key, []):
- yield f'test/hil/{name}', b
-
-
-def on_roster(tc, *names):
- """The subset of `names` currently in the live rig rosters, skipping the test
- when none are, because parking/unparking a board is routine rig maintenance.
-
- That skip now matters MORE than it used to, not less: this suite is a blocking
- pre-commit hook AND build.yml's selector steps gate on it (a failing suite falls
- open to the full matrix), so an assertion that depends on a specific board being
- present goes red on every PR -- including src/-only ones that never touched the
- rig -- until someone fixes the roster. Keep roster-dependent assertions behind
- on_roster."""
- have = {b['name'] for _, boards in real_rosters() for b in boards}
- got = [n for n in names if n in have]
- if not got:
- tc.skipTest(f'not in the rig roster: {", ".join(names)}')
- return got
-
-
-ROSTER = [
- # device-only, rp2040 family
- {'name': 'raspberry_pi_pico', 'uid': 'u1', 'flasher': {'name': 'openocd'},
- 'tests': {'device': True, 'host': True, 'dual': True}},
- # device-only, stm32f4 family
- {'name': 'stm32f407disco', 'uid': 'u2', 'flasher': {'name': 'jlink'},
- 'tests': {'device': True, 'host': False, 'dual': False}},
- # host-only board
- {'name': 'raspberry_pi_pico2', 'uid': 'u3', 'flasher': {'name': 'openocd'},
- 'tests': {'device': False, 'host': True, 'dual': False}},
- # only-list board (espressif-style), flashed by the CI leg that splits on esptool
- {'name': 'espressif_s3_devkitm', 'uid': 'u4', 'flasher': {'name': 'esptool'},
- 'tests': {'only': ['device/cdc_msc_freertos', 'host/device_info']}},
-]
-ROSTERS = [('test/hil/tinyusb.json', ROSTER)]
-
-
-def sel(files):
- return hil_select.classify(files, REPO, ROSTERS)
-
-
-class TestPortRule(unittest.TestCase):
- def test_dcd_rp2040_selects_pico_family_only(self):
- s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
- self.assertFalse(s['full'])
- self.assertIn('raspberry_pi_pico', s['boards'])
- self.assertNotIn('stm32f407disco', s['boards'])
- self.assertNotIn('espressif_s3_devkitm', s['boards'])
- # device role: no host tests in pico's list
- self.assertTrue(all(not t.startswith('host/') for t in s['boards']['raspberry_pi_pico']))
- # host-only boards drop out entirely on a device-role change
- self.assertNotIn('raspberry_pi_pico2', s['boards'])
-
- def test_shared_port_file_is_both_roles(self):
- s = sel(['src/portable/synopsys/dwc2/dwc2_common.c'])
- self.assertFalse(s['full'])
- self.assertNotIn('raspberry_pi_pico', s['boards']) # rp2040 is not a dwc2 family
- self.assertIn('stm32f407disco', s['boards']) # stm32f4 is
-
-
-class TestCoreRoleRule(unittest.TestCase):
- def test_usbd_selects_all_device_tests_everywhere(self):
- s = sel(['src/device/usbd.c'])
- self.assertFalse(s['full'])
- self.assertNotIn('raspberry_pi_pico2', s['boards']) # host-only board dropped
- pico = s['boards']['raspberry_pi_pico']
- self.assertTrue(set(device_tests).issubset(set(pico)))
- self.assertTrue(set(dual_tests).issubset(set(pico))) # dual survives device role
- self.assertTrue(all(not t.startswith('host/') for t in pico))
- # only-list board: selection intersects its only-list
- esp = s['boards']['espressif_s3_devkitm']
- self.assertEqual(esp, ['device/cdc_msc_freertos'])
-
- def test_host_change_drops_device(self):
- s = sel(['src/host/usbh.c'])
- self.assertFalse(s['full'])
- self.assertIn('raspberry_pi_pico2', s['boards'])
- self.assertNotIn('stm32f407disco', s['boards']) # device-only board dropped
-
-
-class TestClassRule(unittest.TestCase):
- def test_cdc_device_selects_cdc_examples_only(self):
- s = sel(['src/class/cdc/cdc_device.c'])
- self.assertFalse(s['full'])
- pico = s['boards']['raspberry_pi_pico']
- self.assertIn('device/cdc_msc', pico)
- self.assertIn('device/cdc_dual_ports', pico)
- self.assertNotIn('device/msc_dual_lun', pico) # CFG_TUD_CDC 0 there
- self.assertNotIn('device/usbtest', pico) # CFG_TUD_CDC 0 there
- self.assertTrue(all(not t.startswith('host/') for t in pico))
-
- def test_msc_host_selects_host_side(self):
- s = sel(['src/class/msc/msc_host.c'])
- self.assertFalse(s['full'])
- self.assertNotIn('stm32f407disco', s['boards']) # device-only board
- pico2 = s['boards']['raspberry_pi_pico2']
- self.assertIn('host/msc_file_explorer', pico2)
- self.assertTrue(all(not t.startswith('device/') for t in pico2))
-
-
-class TestClassIncludeEdges(unittest.TestCase):
- """A class header another class includes reaches that class's examples too.
- src/class/midi/midi{,2}_{device,host}.h include class/audio/audio.h, so
- midi_test's firmware contains audio.h - but the class rule derives macros from
- the directory name alone, so an audio.h change used to select only
- device/audio_test_freertos. On boards that skip that example the per-board
- intersection emptied and an audio.h-only PR ran ZERO HIL on them."""
- def test_edges_derived_from_includes(self):
- edges = hil_select.class_include_edges(REPO)
- self.assertEqual(edges.get('audio/audio.h'), {'midi'})
- self.assertEqual(edges.get('cdc/cdc.h'), {'net'})
-
- def test_audio_header_selects_midi_example(self):
- s = hil_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
- self.assertFalse(s['full'])
- # every board that runs device/midi_test at all must run it here (boards with
- # a tests.only list, e.g. espressif, run the freertos examples instead)
- by_name = {b['name']: b for _, bs in real_rosters() for b in bs}
- checked = 0
- for name, tests in s['boards'].items():
- if 'device/midi_test' in hil_select.board_tests(by_name[name]):
- self.assertIn('device/midi_test', tests, name)
- checked += 1
- self.assertTrue(checked)
-
- def test_audio_header_reaches_boards_that_skip_audio(self):
- # both skip device/audio_test_freertos: without the midi edge their
- # intersection is empty and they drop out of the selection entirely
- boards = on_roster(self, 'metro_m4_express', 'nrf54lm20dk')
- s = hil_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
- for board in boards:
- self.assertEqual(s['boards'].get(board), ['device/midi_test'], board)
-
- def test_edge_is_per_header_not_per_class(self):
- # midi includes audio.h, not audio_device.h: an audio_device change must
- # not drag midi's examples in
- s = hil_select.classify(['src/class/audio/audio_device.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for tests in s['boards'].values():
- if tests != 'all':
- self.assertNotIn('device/midi_test', tests)
-
-
-class TestFallbackRules(unittest.TestCase):
- def test_unknown_tool_is_full(self):
- s = sel(['tools/random_new_script.py'])
- self.assertTrue(s['full'])
-
- def test_docs_only_is_empty_not_full(self):
- s = sel(['docs/info/contributing.rst', 'README.rst'])
- self.assertFalse(s['full'])
- self.assertEqual(s['boards'], {})
-
- def test_bsp_family_selects_family_boards(self):
- s = sel(['hw/bsp/rp2040/family.cmake'])
- self.assertFalse(s['full'])
- self.assertIn('raspberry_pi_pico', s['boards'])
- self.assertEqual(s['boards']['raspberry_pi_pico'], 'all')
- self.assertNotIn('stm32f407disco', s['boards'])
-
- def test_bsp_board_narrows_to_board(self):
- s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
- self.assertFalse(s['full'])
- self.assertEqual(list(s['boards'].keys()), ['raspberry_pi_pico'])
-
- def test_example_change_selects_that_example(self):
- s = sel(['examples/device/cdc_msc/src/main.c'])
- self.assertFalse(s['full'])
- self.assertEqual(s['boards']['raspberry_pi_pico'], ['device/cdc_msc'])
-
- def test_core_common_is_full(self):
- for f in ['src/tusb.c', 'src/common/tusb_fifo.c', 'src/osal/osal_freertos.h']:
- self.assertTrue(sel([f])['full'], f)
-
- def test_board_test_example_is_full(self):
- # board_test is the park/teardown firmware hil_test.py flashes on every board,
- # not an unlisted example: a regression there must not skip the whole rig
- for f in ['examples/device/board_test/src/main.c',
- 'examples/device/board_test/CMakeLists.txt']:
- self.assertTrue(sel([f])['full'], f)
-
- def test_harness_is_full(self):
- for f in ['test/hil/hil_test.py', '.github/workflows/build.yml', 'hw/mcu/nxp/x.c', 'lib/foo/x.c']:
- self.assertTrue(sel([f])['full'], f)
-
- def test_mixed_roles_no_pruning(self):
- s = sel(['src/device/usbd.c', 'src/host/usbh.c'])
- self.assertFalse(s['full'])
- self.assertIn('raspberry_pi_pico2', s['boards'])
- self.assertIn('stm32f407disco', s['boards'])
-
- def test_cmakelists_and_requirements_are_full(self):
- for f in ['src/CMakeLists.txt', 'examples/CMakeLists.txt',
- 'examples/device/CMakeLists.txt', 'test/hil/requirements.txt']:
- self.assertTrue(sel([f])['full'], f)
-
- def test_docs_txt_is_noncode(self):
- s = sel(['docs/info/changelog.txt'])
- self.assertFalse(s['full'])
- self.assertEqual(s['boards'], {})
-
-
-class TestArgsEmission(unittest.TestCase):
- def test_args_for_scoped_selection(self):
- s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
- args = hil_select.selection_args(s, ROSTERS)
- a = args['tinyusb.json']
- self.assertIn('-b raspberry_pi_pico', a)
- self.assertNotIn('stm32f407disco', a)
- self.assertIn('-bt raspberry_pi_pico:', a) # device-only subset of a device+host board
-
- def test_args_full_is_empty(self):
- s = sel(['tools/random_new_script.py'])
- self.assertEqual(hil_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
-
- def test_args_all_board_gets_bare_b(self):
- s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
- a = hil_select.selection_args(s, ROSTERS)['tinyusb.json']
- self.assertIn('-b raspberry_pi_pico', a)
- self.assertNotIn('-bt', a)
-
- def test_args_by_flasher_splits_esp_from_the_rest(self):
- s = sel(['src/device/usbd.c'])
- per = hil_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
- self.assertIn('espressif_s3_devkitm', per['esptool'])
- self.assertIn('raspberry_pi_pico', per['openocd'])
- self.assertNotIn('espressif_s3_devkitm', per.get('openocd', '') + per.get('jlink', ''))
-
- def test_args_by_flasher_omits_a_flasher_with_no_selected_board(self):
- # the esp CI leg must see no args at all here, not a filter matching zero boards
- s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
- per = hil_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
- self.assertEqual(per, {'openocd': '-b raspberry_pi_pico'})
-
- def test_args_by_flasher_full_is_empty(self):
- s = sel(['tools/random_new_script.py'])
- self.assertEqual(hil_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})
-
- def test_cli_diff_file(self):
- import subprocess, tempfile, json as j
- with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
- f.write('src/class/cdc/cdc_device.c\n')
- path = f.name
- r = subprocess.run([sys.executable, os.path.join(REPO, 'test/hil/helper/hil_select.py'),
- '--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
- capture_output=True, text=True)
- self.assertEqual(r.returncode, 0, r.stderr)
- out = j.loads(r.stdout)
- self.assertFalse(out['full'])
- self.assertIn('tinyusb.json', out['args'])
- self.assertTrue(any('cdc_device' in line for line in out['reasons']))
- # A core-class diff must select boards THROUGH THE CLI: the in-process tests
- # inject their own repo root, so only this subprocess path catches a broken
- # repo_root derivation -- which once made every repo-relative glob match
- # nothing and turned this exact diff into a silent full-HIL skip.
- self.assertTrue(out['boards'],
- 'CLI selected zero boards for a src/class change: repo_root broken?')
- os.unlink(path)
-
-
-class TestRealRosterPortFamilies(unittest.TestCase):
- """Regression for port_families() missing espressif's dwc2 reference, which
- lives in a component CMakeLists.txt rather than family.cmake/family.mk."""
- def test_dwc2_change_selects_espressif_boards(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- self.assertIn(board, s['boards'])
-
-
-class TestOptionGatedPort(unittest.TestCase):
- """Regression: family_support.cmake compiles some ports from a build option
- (MAX3421_HOST=1 -> hcd_max3421.c), so a board's family file never names them."""
- # host-side option board (max3421 as host controller), off any max3421 family
- OPT_ROSTER = [('test/hil/opt.json', [
- {'name': 'fake_dual_board', 'uid': 'o1', 'flasher': {'name': 'jlink'},
- 'build': {'args': ['MAX3421_HOST=1']},
- 'tests': {'device': True, 'host': False, 'dual': True}},
- {'name': 'fake_host_board', 'uid': 'o2', 'flasher': {'name': 'jlink'},
- 'variant': [{'name': 'fake_host_board', 'flags': '-DMAX3421_HOST=1'}],
- 'tests': {'device': False, 'host': True, 'dual': False}},
- {'name': 'fake_off_board', 'uid': 'o3', 'flasher': {'name': 'jlink'},
- 'variant': [{'name': 'fake_off_board', 'defines': ['MAX3421_HOST=0']}],
- 'tests': {'device': True, 'host': True, 'dual': True}},
- ])]
-
- def test_real_roster_max3421_selects_option_board(self):
- boards = on_roster(self, 'metro_m4_express')
- s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- self.assertIn(board, s['boards'])
-
- def test_option_selects_via_args_defines_and_flags(self):
- s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, self.OPT_ROSTER)
- self.assertFalse(s['full'])
- self.assertIn('fake_dual_board', s['boards']) # build.args
- self.assertIn('fake_host_board', s['boards']) # variant flags
- self.assertNotIn('fake_off_board', s['boards']) # variant defines, but =0
-
- def test_device_role_port_does_not_pull_host_only_option_board(self):
- s = hil_select.classify(['src/portable/analog/max3421/dcd_max3421.c'], REPO, self.OPT_ROSTER)
- self.assertFalse(s['full'])
- self.assertNotIn('fake_host_board', s['boards']) # host-only board, device change
- self.assertIn('fake_dual_board', s['boards']) # device-capable option board
-
- def test_gates_parsed_from_family_support(self):
- self.assertEqual(hil_select.port_option_gates(REPO).get('analog/max3421'),
- {'MAX3421_HOST'})
-
- def test_board_cmake_option_counts(self):
- """A board can enable a gated port in its own BSP rather than via the roster
- (hw/bsp/espressif/boards/*/board.cmake -> set(MAX3421_HOST 1)); board_options()
- must see those too, or such a board joining the roster is silently dropped."""
- self.assertIn('MAX3421_HOST',
- hil_select.bsp_board_options('adafruit_feather_esp32s3', REPO))
- self.assertIn('CFG_TUH_RPI_PIO_USB',
- hil_select.bsp_board_options('adafruit_fruit_jam', REPO))
- # commented-out `# set(MAX3421_HOST 1)` must not count
- self.assertNotIn('MAX3421_HOST',
- hil_select.bsp_board_options('feather_nrf52840_express', REPO))
-
- def test_board_cmake_option_selects_off_family_board(self):
- # adafruit_feather_esp32s3 is not on any rig roster; stand it in as one to
- # prove the BSP-sourced option alone pulls a max3421 change onto the board
- roster = [('test/hil/opt.json', [
- {'name': 'adafruit_feather_esp32s3', 'uid': 'o1', 'flasher': {'name': 'esptool'},
- 'tests': {'device': False, 'host': True, 'dual': False}}])]
- s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
- self.assertFalse(s['full'])
- self.assertIn('adafruit_feather_esp32s3', s['boards'])
-
- def test_board_mk_option_is_ignored(self):
- """Make-only options must not select: HIL CI builds with CMake exclusively, so
- hw/bsp/nrf/boards/nrf5340dk/board.mk's MAX3421_HOST compiles nothing here."""
- roster = [('test/hil/opt.json', [
- {'name': 'nrf5340dk', 'uid': 'o1', 'flasher': {'name': 'jlink'},
- 'tests': {'device': False, 'host': True, 'dual': False}}])]
- s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
- self.assertFalse(s['full'])
- self.assertEqual(s['boards'], {})
-
-
-class TestPortFamiliesCmakeOnly(unittest.TestCase):
- """port_families() is CMake-only (HIL CI never builds with Make) and matches on
- 'port_dir/' so a port dir is not a prefix of a sibling."""
- def test_make_only_family_is_not_a_family(self):
- # hw/bsp/pic32mz has family.mk but no family.cmake
- self.assertEqual(hil_select.port_families('microchip/pic32mz', REPO), set())
-
- def test_prefix_port_does_not_inherit_sibling_families(self):
- # bare-substring matching let 'microchip/pic' match '.../microchip/pic32mz/...'
- self.assertEqual(hil_select.port_families('microchip/pic', REPO), set())
-
- def test_make_only_port_forces_full(self):
- s = sel(['src/portable/microchip/pic32mz/dcd_pic32mz.c'])
- self.assertTrue(s['full'])
- self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
-
- def test_cmake_families_still_found(self):
- self.assertEqual(hil_select.port_families('raspberrypi/rp2040', REPO), {'rp2040'})
- self.assertIn('stm32f4', hil_select.port_families('synopsys/dwc2', REPO))
-
-
-class TestPortFamiliesCoverage(unittest.TestCase):
- """Systematic guard: every real dcd_*/hcd_* port directory should map to at
- least one board family, so a future family.cmake/CMakeLists.txt layout that
- port_families() doesn't scan fails loudly instead of silently dropping boards
- (as espressif's dwc2 reference did - see TestRealRosterPortFamilies)."""
- # Ports with no board family: not a bug, just not wired into any rig board.
- # Add here (with a reason) only if port_families() legitimately can't find one.
- # A port listed here force-fulls (fail-open), so it is never under-selected.
- NO_FAMILY = {
- 'template', # reference/example port, not built by any board
- # hw/bsp/pic32mz has family.mk only (no family.cmake), and port_families()
- # is CMake-only because HIL CI builds every board with CMake - so this port
- # is compiled for no HIL board.
- 'microchip/pic32mz',
- 'microchip/pic', # same: only ever referenced from pic32mz's family.mk
- }
-
- @staticmethod
- def _dcd_hcd_ports():
- portable_root = os.path.join(REPO, 'src/portable')
- ports = []
- for entry in sorted(os.listdir(portable_root)):
- d = os.path.join(portable_root, entry)
- if not os.path.isdir(d):
- continue
- if glob.glob(os.path.join(d, 'dcd_*.c')) or glob.glob(os.path.join(d, 'hcd_*.c')):
- ports.append(entry)
- continue
- for sub in sorted(os.listdir(d)):
- sd = os.path.join(d, sub)
- if os.path.isdir(sd) and (glob.glob(os.path.join(sd, 'dcd_*.c')) or
- glob.glob(os.path.join(sd, 'hcd_*.c'))):
- ports.append(f'{entry}/{sub}')
- return ports
-
- def test_every_port_maps_to_a_family(self):
- ports = self._dcd_hcd_ports()
- self.assertTrue(ports) # sanity: the scan itself found something
- for port in ports:
- if port in self.NO_FAMILY:
- continue
- fams = hil_select.port_families(port, REPO)
- self.assertTrue(fams, f'{port}: no family references this port '
- f'(port_families() scan gap, or add to NO_FAMILY)')
-
-
-class TestRealRosterOnlyListTests(unittest.TestCase):
- """Regression for roster-only-list tests (e.g. espressif's hid_composite_freertos)
- being invisible to the selector because it only knew the shared hil_util lists."""
- def test_only_list_example_change_selects_it(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['examples/device/hid_composite_freertos/src/main.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- self.assertEqual(s['boards'][board], ['device/hid_composite_freertos'])
-
- def test_class_change_includes_only_list_boards(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['src/class/hid/hid_device.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- self.assertIn(board, s['boards'])
-
-
-class TestPortAndCoreRoleUseExtras(unittest.TestCase):
- """Regression: the port rule and core-role rule must thread the roster-only
- test universe (extras) the same way the class rule already does, so a DCD
- or device-stack change doesn't silently drop espressif's only-list tests
- (e.g. hid_composite_freertos) that aren't in the shared device_tests list."""
- def test_dcd_change_includes_only_list_test(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- tests = s['boards'][board]
- self.assertIn('device/hid_composite_freertos', tests)
- self.assertIn('device/cdc_msc_freertos', tests)
- self.assertIn('device/audio_test_freertos', tests)
- self.assertIn('device/usbtest', tests)
-
- def test_core_device_change_includes_only_list_test(self):
- boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
- s = hil_select.classify(['src/device/usbd.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board in boards:
- tests = s['boards'][board]
- self.assertIn('device/hid_composite_freertos', tests)
- self.assertIn('device/cdc_msc_freertos', tests)
- self.assertIn('device/audio_test_freertos', tests)
- self.assertIn('device/usbtest', tests)
-
- def test_host_change_does_not_leak_device_only_list_test(self):
- s = hil_select.classify(['src/host/usbh.c'], REPO, real_rosters())
- self.assertFalse(s['full'])
- for board, tests in s['boards'].items():
- if tests == 'all':
- continue
- self.assertNotIn('device/hid_composite_freertos', tests, board)
-
-
-class TestFamilies(unittest.TestCase):
- """`families` exists for consumers that build (not just test) the diff: most
- families have no rig board, so `boards` alone would compile nothing for them."""
- def test_off_rig_port_still_reports_family(self):
- s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c'])
- self.assertFalse(s['full'])
- self.assertEqual(s['boards'], {}) # no same7x board on the rig
- self.assertEqual(s['families'], ['same7x'])
-
- def test_port_families_are_reported(self):
- s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
- self.assertIn('rp2040', s['families'])
-
- def test_bsp_family_and_board_report_family(self):
- self.assertEqual(sel(['hw/bsp/rp2040/family.cmake'])['families'], ['rp2040'])
- self.assertEqual(sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])['families'],
- ['rp2040'])
-
- def test_docs_only_has_no_families(self):
- self.assertEqual(sel(['docs/info/contributing.rst'])['families'], [])
-
- def test_full_selection_still_reports_families(self):
- """A full-matrix file must not hide the families of the other changed files:
- consumers that build from `families` (e.g. /pre-pr) ignore `boards` when full."""
- s = sel(['src/common/tusb_fifo.c', 'src/portable/microchip/samx7x/dcd_samx7x.c'])
- self.assertTrue(s['full'])
- self.assertIn('same7x', s['families'])
- # full stays full: every roster board, and no args to narrow the run
- self.assertEqual(set(s['boards']), {b['name'] for b in ROSTER})
- self.assertTrue(all(v == 'all' for v in s['boards'].values()))
- self.assertEqual(hil_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
- self.assertEqual(hil_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})
-
- def test_family_order_does_not_matter(self):
- # same as above with the full-matrix file last (was the only order that worked)
- s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c', 'src/common/tusb_fifo.c'])
- self.assertTrue(s['full'])
- self.assertIn('same7x', s['families'])
-
-
-class TestGitDiffArgv(unittest.TestCase):
- def test_diff_disables_rename_detection(self):
- """Without --no-renames git reports only a rename's destination, so moving an
- HIL-relevant file to a non-code path would be classified as non-code only."""
- self.assertIn('--no-renames', hil_select.GIT_DIFF_ARGV)
-
-
-class TestPortWithoutFamilyIsFull(unittest.TestCase):
- """A port dir no family file references must widen (full matrix), not silently
- contribute zero boards — the fail-open contract."""
- def test_unreferenced_port_forces_full(self):
- orig = hil_select.port_families
- hil_select.port_families = lambda port_dir, repo_root: set()
- try:
- s = sel(['src/portable/vendor/newip/dcd_newip.c'])
- finally:
- hil_select.port_families = orig
- self.assertTrue(s['full'])
- self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])
-
-
-class TestOpenocdVidPid(unittest.TestCase):
- """The roster's optional flasher `vid_pid` field (openocd-verbatim, e.g.
- "0x1a86 0x8010", more pairs appended) pins openocd's probe discovery so it
- never opens foreign usbfs nodes. It must be emitted BEFORE the args: the
- rescue cfgs run `init` internally (rp2350-rescue.cfg errors on any
- config-stage command after its init; rp2040.cfg under RESCUE scans before a
- trailing flag is even parsed), and no rig cfg sets a competing list
- (the 2026-08-10 convoy mechanism)."""
-
- def test_vid_pid_flag_precedes_args(self):
- cmd = hil_flash._openocd_cmd_base(
- {'uid': 'S1', 'args': '-f target/wch-riscv.cfg', 'vid_pid': '0x1a86 0x8010'})
- self.assertIn('-c "adapter usb vid_pid 0x1a86 0x8010" -f target/wch-riscv.cfg', cmd)
- self.assertTrue(cmd.endswith('-f target/wch-riscv.cfg'), cmd)
-
- def test_rescue_cfg_command_keeps_vid_pid_before_init(self):
- """rescue_openocd swaps the target cfg for one that runs `init` internally;
- a vid_pid flag after the args would error there (rp2350) or be skipped
- (rp2040) -- in exactly the wedged-rig scenario the pin exists for."""
- flasher = {'name': 'openocd', 'uid': 'S1', 'vid_pid': '0x2e8a 0x000c',
- 'args': '-c "set RESCUE 1" -f target/rp2040.cfg'}
- cmd = hil_flash._openocd_cmd_base(flasher)
- self.assertLess(cmd.index('adapter usb vid_pid'), cmd.index('-f target/'), cmd)
-
- def test_vid_pid_multiple_pairs(self):
- cmd = hil_flash._openocd_cmd_base(
- {'uid': 'S1', 'args': '-f i.cfg', 'vid_pid': '0x2e8a 0x000c 0x2e8a 0x000d'})
- self.assertIn('-c "adapter usb vid_pid 0x2e8a 0x000c 0x2e8a 0x000d"', cmd)
-
- def test_no_field_no_flag_but_warns(self):
- # the roster lint only covers the committed rosters; a dev PC's local.json entry
- # without the field must at least say what it is giving up -- on STDERR, since
- # hil_test captures stdout per test and would swallow it on a passing run
- import io
- from contextlib import redirect_stderr
- hil_flash._VID_PID_WARNED.discard('S-warn')
- cap = io.StringIO()
- with redirect_stderr(cap):
- cmd = hil_flash._openocd_cmd_base({'uid': 'S-warn', 'args': '-f i.cfg'})
- self.assertNotIn('vid_pid', cmd)
- self.assertIn('vid_pid', cap.getvalue())
-
- def test_roster_openocd_entries_all_pin_vid_pid(self):
- # every openocd probe on the rig has a known VID/PID; a new entry without the
- # pin silently reintroduces open-everything discovery
- for path, board in roster_flashers():
- f = board['flasher']
- # tinyusb.json only: hfp.json is the hifiphile rig owner's file, and a
- # blocking repo-wide lint over someone else's roster would red every PR the
- # moment they add an openocd board (hil_flash treats the field as optional)
- if f['name'] == 'openocd' and path.endswith('tinyusb.json'):
- self.assertIn('vid_pid', f,
- f"{path}: {board['name']} openocd flasher lacks vid_pid")
- self.assertNotIn('vid_pid', f.get('args', ''),
- f"{path}: {board['name']} packs vid_pid into args; use the field")
-
-
-class TestRosterFlashersDispatch(unittest.TestCase):
- """hil_test and hil_pool_check resolve a board's flasher with a bare
- getattr(hil_flash, f'flash_{name}'), and hil_test does it inside a redirect_stdout —
- so a renamed or typo'd roster name raises an AttributeError whose output is swallowed,
- with nothing pointing at the roster as the thing to edit. Renaming a flash_*/reset_*
- pair without updating every roster must fail here instead."""
-
- def test_flash_and_reset_exist_for_every_roster_flasher(self):
- for path, board in roster_flashers():
- name = board['flasher']['name'].lower()
- for fn in (f'flash_{name}', f'reset_{name}'):
- self.assertTrue(callable(getattr(hil_flash, fn, None)),
- f'{path}: {board["name"]} uses flasher "{name}" '
- f'but hil_flash.{fn} does not exist')
-
- def test_firmware_suffix_known_for_every_roster_flasher(self):
- """find_firmware falls back to accepting .elf-or-.bin when a flasher is missing
- from FLASHER_SUFFIX, silently restoring the mismatch that map exists to catch."""
- for path, board in roster_flashers():
- name = board['flasher']['name'].lower()
- self.assertIn(name, hil_flash.FLASHER_SUFFIX,
- f'{path}: {board["name"]} uses flasher "{name}" '
- f'with no hil_flash.FLASHER_SUFFIX entry')
-
-
-class FlasherRecoverEntry(unittest.TestCase):
- """Optional roster key: a SECOND flasher used only to deliver recovery while a usbfs
- node is poisoned. Boards whose primary flasher cannot get past a convoy (jlink,
- stlink, lm4flash) name an openocd entry here instead of changing how they are
- normally flashed."""
-
- def test_recover_flasher_prefers_the_optional_entry(self):
- prim = {'name': 'jlink', 'uid': 'X', 'args': '-device MIMXRT1064xxx6A'}
- rec = {'name': 'openocd', 'uid': 'X', 'args': '-f interface/jlink.cfg -f target/foo.cfg'}
- self.assertEqual(hil_flash.recover_flasher({'flasher': prim, 'flasher_recover': rec}), rec)
- self.assertEqual(hil_flash.recover_flasher({'flasher': prim}), prim)
-
- def test_openocd_over_jlink_is_convoy_safe_without_a_pin(self):
- """libjaylink discovery returns early unless idVendor == 0x1366 (SEGGER) and the PID
- is in its table, and only THEN calls libusb_open (discovery_usb.c) -- it never opens
- a foreign node. `adapter usb vid_pid` is a no-op for this driver: jlink.c reads
- adapter_serial / usb address / usb location, never the vid/pid."""
- self.assertTrue(hil_flash.convoy_safe(
- {'name': 'openocd', 'args': '-f interface/jlink.cfg -f target/stm32f4x.cfg'}))
-
- def test_openocd_with_neither_a_pin_nor_jlink_is_not_safe(self):
- self.assertFalse(hil_flash.convoy_safe(
- {'name': 'openocd', 'args': '-f interface/stlink.cfg -f target/stm32h7x.cfg'}))
-
- def test_the_existing_rules_are_unchanged(self):
- self.assertTrue(hil_flash.convoy_safe(
- {'name': 'openocd', 'vid_pid': '0x2e8a 0x000c', 'args': '-f interface/cmsis-dap.cfg'}))
- self.assertFalse(hil_flash.convoy_safe({'name': 'jlink', 'uid': 'X'}))
- self.assertTrue(hil_flash.convoy_safe({'name': 'esptool'}))
-
-
-if __name__ == '__main__':
- unittest.main(verbosity=1)
diff --git a/test/hil/test/test_hil_util.py b/test/hil/test/test_hil_util.py
index 9c3d5edef..c95e20b6d 100644
--- a/test/hil/test/test_hil_util.py
+++ b/test/hil/test/test_hil_util.py
@@ -98,7 +98,7 @@ class RunCmdModes(unittest.TestCase):
class BottomLayer(unittest.TestCase):
def test_bad_timeout_env_falls_back(self):
- # hil_select (the PR-diff selector) imports hil_util for the example rosters;
+ # ci_select (the PR-diff selector) imports hil_util for the example rosters;
# a malformed HIL_CMD_TIMEOUT must not crash the selector at import and knock
# CI back to the full-matrix fallback
import subprocess
@@ -108,7 +108,7 @@ class BottomLayer(unittest.TestCase):
env={**os.environ, 'HIL_CMD_TIMEOUT': 'bogus'},
capture_output=True, text=True, timeout=30)
self.assertEqual(r.returncode, 0, r.stderr)
- # the warning must NOT be on stdout: hil_select's stdout is machine-read JSON
+ # the warning must NOT be on stdout: ci_select's stdout is machine-read JSON
self.assertEqual(r.stdout.strip(), '180')
self.assertIn('warning', r.stderr) # but a silent fallback hides the misconfiguration
@@ -132,22 +132,23 @@ class BottomLayer(unittest.TestCase):
# hil_examples.py used to make this structural (a list of strings cannot grow a
# dependency); with the rosters folded into hil_util the invariant needs teeth:
# everything the bare GitHub runner imports (selector + this suite) must stay
- # stdlib + local. Adding pyserial/pymtp here breaks hil_select on CI.
+ # stdlib + local. Adding pyserial/pymtp here breaks ci_select on CI.
import ast
hil_dir = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ONLY the modules the bare runner can import -- not every stem in the tree.
# Globbing the directory allowed `import pymtp` (and hil_test, usbtest,
# mtp_test) through, so the pymtp case this test names could never fail: that
# module runs ctypes.CDLL(find_library('mtp')) at import and raises where there
- # is no libmtp, taking hil_select down with it.
- local = {'helper', 'hil_util', 'hil_select', 'hil_flash',
- 'hil_health', 'hil_lock', 'hil_pool_check'}
+ # is no libmtp, taking ci_select down with it.
+ local = {'helper', 'hil_util', 'ci_select', 'hil_flash',
+ 'hil_health', 'hil_lock', 'hil_pool_check', 'build', 'build_utils'}
allowed = set(sys.stdlib_module_names) | local
# hil_pool_check included: test_hil_util_is_a_single_module_instance imports it
# on the bare runner, and its `import serial` is function-local for exactly
# this reason -- hoisting it must fail HERE, not on every PR's pre-commit CI
- for mod in ('helper/hil_util', 'hil_flash', 'helper/hil_select',
- 'helper/hil_health', 'helper/hil_lock', 'helper/hil_pool_check'):
+ for mod in ('helper/hil_util', 'hil_flash', '../../tools/ci_select',
+ 'helper/hil_health', 'helper/hil_lock', 'helper/hil_pool_check',
+ '../../tools/build', '../../tools/build_utils'):
tree = ast.parse((hil_dir / f'{mod}.py').read_text())
# module level only: a deferred import inside a function cannot break
# importability (hil_pool_check keeps `import serial` function-local
diff --git a/test/hil/tinyusb.json b/test/hil/tinyusb.json
index 6f552f126..8fd4683a4 100644
--- a/test/hil/tinyusb.json
+++ b/test/hil/tinyusb.json
@@ -157,11 +157,14 @@
{
"name": "metro_m4_express",
"uid": "9995AD485337433231202020FF100A34",
- "build": {
- "args": [
- "MAX3421_HOST=1"
- ]
- },
+ "variant": [
+ {
+ "name": "metro_m4_express",
+ "defines": [
+ "MAX3421_HOST=1"
+ ]
+ }
+ ],
"tests": {
"device": true,
"host": false,