diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 07969383..2fbe6f63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -198,7 +198,7 @@ jobs: # The pull-library negative control must not consume the primary artifact: # on disaggregation branches that artifact now contains the real transport. build-linux-monolith-control: - name: build explicit pull runtime control + name: build monolith controls (pull; push without MG_Remote) runs-on: ubuntu-latest env: BUILD_DIR: build-monolith-control @@ -262,10 +262,40 @@ jobs: - name: Prepare packaged test metadata run: python3 scripts/ci/prepare_test_runtime.py "${BUILD_DIR}" - - name: Package pull control + # P13 W1: THE PUSH MONOLITH WITHOUT MG_Remote - the shape FCL embeds once the pull build is + # gone (PLAN-P13 §4.1, §4.3; ID-P13-1). Two jobs in one: it is the transport-negative control + # that replaces the pull library in retrace-split (run side by side with it until W2 drops the + # pull build), and its runtime_mode_proof is the zero-MG_Remote-symbol check, one of G1's + # successors (ID-P13-4): a push library that links any MG_Remote symbol is red here. + - name: Configure push control without MG_Remote + run: | + cmake -S . -B build-push-control -G Ninja \ + -DCMAKE_C_COMPILER="${MOBILEGL_CI_CC}" -DCMAKE_CXX_COMPILER="${MOBILEGL_CI_CXX}" \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=OFF -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=OFF -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_BUILD_DISAGGREGATED=OFF -DMOBILEGL_BUILD_DISAGGREGATED_INPROC=OFF \ + -DMOBILEGL_PIPE_PUSH=ON -DMOBILEGL_PIPE_VERIFY=OFF \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + - name: Build push control and prove it has zero MG_Remote symbols + run: | + cmake --build build-push-control --target MobileGL --parallel "$(nproc)" + python3 scripts/ci/runtime_mode_proof.py build --build-dir build-push-control \ + --mode monolith --output build-push-control/runtime-mode.json + python3 - build-push-control/runtime-mode.json <<'PY' + import json, sys + rows = json.load(open(sys.argv[1]))['production_compilation'] + if not rows or any(row['definitions'].get('MOBILEGL_PIPE_PUSH') != '1' for row in rows): + sys.exit('::error::the push control was not compiled with MOBILEGL_PIPE_PUSH=1 everywhere') + print(f'push control: {len(rows)} production sources compiled with MOBILEGL_PIPE_PUSH=1, zero MG_Remote symbols') + PY + + - name: Package monolith controls run: | tar -czf mobilegl-linux-monolith-control.tgz \ - "${BUILD_DIR}/.ci-source-root" "${BUILD_DIR}/libMobileGL.so" "${BUILD_DIR}/runtime-mode.json" + "${BUILD_DIR}/.ci-source-root" "${BUILD_DIR}/libMobileGL.so" "${BUILD_DIR}/runtime-mode.json" \ + build-push-control/libMobileGL.so build-push-control/runtime-mode.json - name: Upload pull control uses: actions/upload-artifact@v7 with: @@ -316,11 +346,20 @@ jobs: ulimit -c unlimited if [ "$MOBILEGL_CI_NATIVE_GPU" != 1 ] && [ ! -f /.dockerenv ]; then sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'; fi if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then - ctest -V -L unit --no-tests=error + ctest -V -L unit --no-tests=error --output-junit "${RUNNER_TEMP}/unit.xml" else - ctest --output-on-failure -L unit --no-tests=error + ctest --output-on-failure -L unit --no-tests=error --output-junit "${RUNNER_TEMP}/unit.xml" fi + # P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py). + - name: Upload JUnit for the skip census + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: junit-test + path: ${{ runner.temp }}/*.xml + if-no-files-found: warn + - name: Upload core dumps if: failure() uses: actions/upload-artifact@v7 @@ -451,6 +490,15 @@ jobs: build-linux/MobileGL/MG_IntegrationTest/p5f-rsp-*.log if-no-files-found: warn + # P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py). + - name: Upload JUnit for the skip census + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: junit-integration-${{ matrix.lane }}-${{ matrix.transport }} + path: ${{ runner.temp }}/*.xml + if-no-files-found: warn + - name: Upload core dumps if: failure() uses: actions/upload-artifact@v7 @@ -708,9 +756,9 @@ jobs: ulimit -c unlimited if [ "$MOBILEGL_CI_NATIVE_GPU" != 1 ] && [ ! -f /.dockerenv ]; then sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'; fi if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then - ctest -V -L integration-verify --no-tests=error + ctest -V -L integration-verify --no-tests=error --output-junit "${RUNNER_TEMP}/verify.xml" else - ctest --output-on-failure -L integration-verify --no-tests=error + ctest --output-on-failure -L integration-verify --no-tests=error --output-junit "${RUNNER_TEMP}/verify.xml" fi # The push-only unit tests, on the verify runtime. @@ -731,7 +779,7 @@ jobs: # unit binaries would otherwise report a green run of nothing. - name: Unit tests on the verify runtime (G6, G10) working-directory: build-verify - run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" --output-junit "${RUNNER_TEMP}/unit-verify.xml" # The always-on P2 and P3a negative controls (G8, G10, G12), which are labelled # integration-gpu and not integration-verify - they are about the handle key, the subsystem @@ -893,6 +941,15 @@ jobs: path: build-verify/MobileGL/MG_IntegrationTest/pipe-*.log* if-no-files-found: warn + # P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py). + - name: Upload JUnit for the skip census + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: junit-integration-verify + path: ${{ runner.temp }}/*.xml + if-no-files-found: warn + - name: Upload core dumps if: failure() uses: actions/upload-artifact@v7 @@ -1138,6 +1195,15 @@ jobs: build-verify-split/MobileGL/MG_IntegrationTest/pipe-*split*.log* if-no-files-found: warn + # P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py). + - name: Upload JUnit for the skip census + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: junit-integration-verify-split + path: ${{ runner.temp }}/*.xml + if-no-files-found: warn + - name: Upload core dumps if: failure() uses: actions/upload-artifact@v7 @@ -1625,6 +1691,15 @@ jobs: ${{ runner.temp }}/arm-monolith.xml if-no-files-found: warn + # P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py). + - name: Upload JUnit for the skip census + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: junit-integration-split + path: ${{ runner.temp }}/*.xml + if-no-files-found: warn + - name: Upload core dumps if: failure() uses: actions/upload-artifact@v7 @@ -1742,7 +1817,8 @@ jobs: PY rc=0 - ctest -L integration-split --no-tests=error -j 4 > "${RUNNER_TEMP}/strict-split.txt" 2>&1 || rc=$? + ctest -L integration-split --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/strict-split.xml" \ + > "${RUNNER_TEMP}/strict-split.txt" 2>&1 || rc=$? tail -5 "${RUNNER_TEMP}/strict-split.txt" echo "strict run: rc=${rc}" @@ -2216,6 +2292,15 @@ jobs: if: failure() run: python3 scripts/ci/junit_annotate.py "${RUNNER_TEMP}" + # P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py). + - name: Upload JUnit for the skip census + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: junit-integration-split-strict + path: ${{ runner.temp }}/*.xml + if-no-files-found: warn + - name: Upload strict lane logs if: ${{ always() }} uses: actions/upload-artifact@v7 @@ -3060,9 +3145,12 @@ jobs: - build-retrace - trace-cases - trace-fixtures - # The separate OFF/pull artifact is for the negative control ONLY: its library is what the control - # swaps in to prove the transport-resolution assertion is load-bearing. - if: ${{ always() && needs.build-linux-monolith-control.result == 'success' && needs.build-linux-split.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' }} + # The monolith-control artifact is for the negative controls ONLY: its libraries are what the + # controls swap in to prove the transport-resolution assertion is load-bearing. P13 W1: the + # legs no longer require that build to have SUCCEEDED - gating on it skipped the whole split + # matrix (grey, not red) whenever the control build failed (PLAN-P13 §0-7). The control steps + # below download its artifact, and a missing one is a red step on every leg instead. + if: ${{ always() && needs.build-linux-split.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' }} strategy: fail-fast: false max-parallel: 4 @@ -3130,6 +3218,7 @@ jobs: run: | tar -xzf pull-runtime/mobilegl-linux-monolith-control.tgz -C pull-runtime test -f pull-runtime/build-monolith-control/libMobileGL.so + test -f pull-runtime/build-push-control/libMobileGL.so - name: Prepare downloaded test metadata run: | @@ -3276,6 +3365,22 @@ jobs: bash "${GITHUB_WORKSPACE}/scripts/ci/retrace_pull_library_control.sh" '${{ matrix.case }}' '${{ matrix.backend }}' + # P13 W1: THE SAME CONTROL WITH THE PUSH MONOLITH WITHOUT MG_Remote - the library FCL embeds + # after W2, and the successor of the pull library above once W2 drops the pull build. Same + # script and the same evidence sentence: what makes either library red here is that it has no + # transport, not that it pulls. + - name: Negative control (push library without MG_Remote) + working-directory: build-retrace/tools/trace_replay + env: + CONTROL_TMPDIR: ${{ runner.temp }} + PULL_LIBRARY: ${{ github.workspace }}/pull-runtime/build-push-control/libMobileGL.so + FROZEN_LIBRARY: ${{ github.workspace }}/build-linux/libMobileGL.so + SPLIT_TRANSPORT: ${{ matrix.transport }} + MOBILEGL_IPC_SERVER_PATH: ${{ github.workspace }}/build-linux/libMobileGLServer.so + run: >- + bash "${GITHUB_WORKSPACE}/scripts/ci/retrace_pull_library_control.sh" + '${{ matrix.case }}' '${{ matrix.backend }}' + # EXIT GATE E2's OTHER HALF, and the one BRIEF-P5 §7 E2 actually names: "drop an emission # and the SSIM must fall below the threshold". The control above proves the lane runs a # SPLIT library; it cannot prove the PICTURE came through the wire, because OpenRA scores @@ -3729,3 +3834,42 @@ jobs: name: mobilegl-linux-runtime-verify-split path: ci-artifacts/mobilegl-linux-runtime-verify-split.tgz if-no-files-found: error + + # P13 W1 (PLAN-P13 §4.2): THE SKIP CENSUS. Deleting a compile-time macro before the GTEST_SKIP + # or pull stub that guards on it turns tests "green by skipping"; a per-lane pass/fail count + # cannot see that. Every gtest lane uploads its JUnit as junit-*; this job keys each case by + # lane and name and holds the run against scripts/data/skip_census_baseline.json: a new skip + # (pass -> skip included) or a vanished name is red, a skip that turned into a pass is a note. + # A deliberate retirement edits the baseline in the same commit and names the cases. + skip-census: + name: skip census + runs-on: ubuntu-latest + needs: + - test + - integration + - integration-verify + - integration-verify-split + - integration-split + - integration-split-strict + if: ${{ always() }} + steps: + - name: Checkout repo + uses: actions/checkout@v6 + - name: Skip census self-test + run: python3 scripts/ci/skip_census.py --self-test + - name: Download lane JUnit + uses: actions/download-artifact@v8 + with: + pattern: junit-* + path: junit + - name: Skip census + run: | + python3 scripts/ci/skip_census.py check junit \ + --baseline scripts/data/skip_census_baseline.json --report skip-census.json + - name: Upload census + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: skip-census + path: skip-census.json + if-no-files-found: warn diff --git a/scripts/ci/runtime_mode_proof.py b/scripts/ci/runtime_mode_proof.py index 237a103b..f10fba53 100644 --- a/scripts/ci/runtime_mode_proof.py +++ b/scripts/ci/runtime_mode_proof.py @@ -29,9 +29,10 @@ def build_proof(build, mode): argv = row.get('arguments') or shlex.split(row['command']) flags = {arg[2:].split('=', 1)[0]: arg[2:].split('=', 1)[1] if '=' in arg else '1' for arg in argv if arg.startswith('-D')} + # P13: "monolith" means the transport is absent (no MG_Remote), not that the pipe is + # pulled - the monolith build pushes too, so MGPipeApply symbols are expected in both. expected = mode == 'disaggregated' - if any((flags.get(name) == '1') != expected - for name in ('MOBILEGL_BUILD_DISAGGREGATED', 'MOBILEGL_PIPE_PUSH')): + if (flags.get('MOBILEGL_BUILD_DISAGGREGATED') == '1') != expected: raise RuntimeError(f'production macros disagree with {mode}: {flags}') definitions.append({'source': row['file'], 'definitions': flags}) symbols = subprocess.check_output(['nm', '--defined-only', str(library)], text=True) @@ -39,7 +40,7 @@ def build_proof(build, mode): raise RuntimeError('library appears stripped; symbol absence would prove nothing') remote = sum('MG_Remote' in line for line in symbols.splitlines()) applier = sum('MGPipeApply' in line for line in symbols.splitlines()) - if (mode == 'disaggregated' and (not remote or not applier)) or (mode == 'monolith' and (remote or applier)): + if (mode == 'disaggregated' and (not remote or not applier)) or (mode == 'monolith' and remote): raise RuntimeError(f'{mode} symbol mismatch: MG_Remote={remote}, MGPipeApply={applier}') return {'mode': mode, 'source_sha': subprocess.check_output(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'], text=True).strip(), 'library': str(library), 'sha256': hashlib.sha256(library.read_bytes()).hexdigest(), @@ -122,13 +123,18 @@ def self_test(): raise AssertionError('missing server log passed the runtime log proof') (build / 'libMobileGL.so').write_bytes(b'checker fixture, not a real runtime') normal = ''.join(f'00000000 T fixture{i}\n' for i in range(1000)) - remote = normal + '00000000 T MG_Remote_fixture\n00000000 T MGPipeApply_fixture\n' + pushed = normal + '00000000 T MGPipeApply_fixture\n' + remote = pushed + '00000000 T MG_Remote_fixture\n' for mode, flags, symbols, succeeds in ( ('monolith', '', normal, True), + ('monolith', '-DMOBILEGL_PIPE_PUSH=1', pushed, True), ('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1 -DMOBILEGL_PIPE_PUSH=1', remote, True), - ('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1', remote, False), + ('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1', remote, True), + ('disaggregated', '-DMOBILEGL_PIPE_PUSH=1', remote, False), ('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1 -DMOBILEGL_PIPE_PUSH=1', normal, False), + ('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1', normal + '00000000 T MG_Remote_fixture\n', False), ('monolith', '', remote, False), + ('monolith', '-DMOBILEGL_BUILD_DISAGGREGATED=1', pushed, False), ('monolith', '', '00000000 T stripped\n', False), ): (build / 'compile_commands.json').write_text(json.dumps([ diff --git a/scripts/ci/skip_census.py b/scripts/ci/skip_census.py new file mode 100644 index 00000000..a8092e02 --- /dev/null +++ b/scripts/ci/skip_census.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""The skip census: across every gtest lane of the Test workflow, skips may only shrink. + +P13 deletes compile-time macros (MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS) that dozens of +tests guard with GTEST_SKIP or a pull stub. Deleting a macro before its guard turns those tests +"green by skipping", and nothing in a per-lane pass/fail count notices. This gate reads every +lane's ctest JUnit, keys each case by lane and name, and compares the run with a baseline checked +into the tree (scripts/data/skip_census_baseline.json): + + * a case skipped now that the baseline does not list as skipped is RED - including the + pass -> skip flip, which is the trap the gate exists for; + * a case the baseline knows that is gone from its lane is RED (names only grow, G14), unless + the lane is missing entirely, which is reported on its own line; + * a skip that turned into a pass is reported, never red - rewrite the baseline in the commit + that earns it so the improvement is locked in; + * new lanes and new names are reported, never red. + +A deliberate retirement edits the baseline in the same commit and names the cases in the commit +message (PLAN-P13 §4.2). + +Usage: + skip_census.py check --baseline [--report ] + skip_census.py write --baseline + skip_census.py --self-test + holds one sub-directory per lane (actions/download-artifact with a pattern lays +them out like that); every *.xml under a lane directory counts toward that lane, keyed +/. +""" +import argparse +import json +import pathlib +import sys +import tempfile +import xml.etree.ElementTree as ET + + +def case_status(case): + if case.find('failure') is not None or case.find('error') is not None: + return 'fail' + if case.find('skipped') is not None or case.get('status') in ('notrun', 'disabled'): + return 'skip' + return 'pass' + + +def collect(root): + """{lane: {name: status}} from //**/*.xml.""" + root = pathlib.Path(root) + lanes = {} + for xml in sorted(root.rglob('*.xml')): + rel = xml.relative_to(root) + lane = f'{rel.parts[0]}/{xml.stem}' if len(rel.parts) > 1 else xml.stem + try: + cases = ET.parse(xml).getroot().iter('testcase') + except ET.ParseError as error: + raise SystemExit(f'skip census: {xml} is not valid JUnit ({error})') + entries = lanes.setdefault(lane, {}) + for case in cases: + name = case.get('name') + if not name: + continue + status = case_status(case) + # A name reported twice in one lane (a rerun) keeps its worst outcome. + order = {'pass': 0, 'skip': 1, 'fail': 2} + if name not in entries or order[status] > order[entries[name]]: + entries[name] = status + return lanes + + +def to_baseline(lanes): + return {lane: {'names': sorted(entries), 'skipped': sorted(n for n, s in entries.items() if s == 'skip')} + for lane, entries in sorted(lanes.items())} + + +def compare(lanes, baseline): + red, info = [], [] + for lane, known in sorted(baseline.items()): + current = lanes.get(lane) + if current is None: + red.append(f'lane {lane}: no JUnit at all in this run (the job did not run or did not upload)') + continue + allowed = set(known['skipped']) + for name in sorted(n for n, s in current.items() if s == 'skip' and n not in allowed): + was = 'new' if name not in set(known['names']) else 'pass' + red.append(f'lane {lane}: {name} SKIPPED (baseline: {was})') + missing = sorted(set(known['names']) - set(current)) + for name in missing: + red.append(f'lane {lane}: {name} disappeared (names only grow; retire it in the baseline by name)') + for name in sorted(n for n in allowed if current.get(n) == 'pass'): + info.append(f'lane {lane}: {name} now PASSES (was skipped) - tighten the baseline') + added = sorted(set(current) - set(known['names'])) + if added: + info.append(f'lane {lane}: {len(added)} new case(s), e.g. {added[0]}') + for lane in sorted(set(lanes) - set(baseline)): + info.append(f'lane {lane}: new lane ({len(lanes[lane])} cases) - add it to the baseline') + return red, info + + +def summary(lanes): + total = sum(len(e) for e in lanes.values()) + skipped = sum(1 for e in lanes.values() for s in e.values() if s == 'skip') + return f'{len(lanes)} lane(s), {total} case(s), {skipped} skipped' + + +def run(argv): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('command', choices=('check', 'write')) + parser.add_argument('junit_dir') + parser.add_argument('--baseline', required=True, type=pathlib.Path) + parser.add_argument('--report', type=pathlib.Path) + args = parser.parse_args(argv) + lanes = collect(args.junit_dir) + if not lanes: + print(f'::error::skip census: no JUnit under {args.junit_dir}') + return 1 + print(f'skip census: {summary(lanes)}') + if args.report: + args.report.write_text(json.dumps(to_baseline(lanes), indent=1) + '\n') + if args.command == 'write': + args.baseline.parent.mkdir(parents=True, exist_ok=True) + args.baseline.write_text(json.dumps(to_baseline(lanes), indent=1) + '\n') + print(f'wrote {args.baseline}') + return 0 + if not args.baseline.exists(): + print(f'::error::skip census: no baseline at {args.baseline}; write one from a full run') + return 1 + red, info = compare(lanes, json.loads(args.baseline.read_text())) + for line in info: + print(f'note: {line}') + for line in red: + print(f'::error::skip census: {line}') + print(f'skip census: {"RED" if red else "green"} ({len(red)} finding(s))') + return 1 if red else 0 + + +def self_test(): + def junit(path, cases): + path.parent.mkdir(parents=True, exist_ok=True) + body = ''.join( + f'' + ('' if s == 'skip' else '' if s == 'fail' else '') + + '' for n, s in cases) + path.write_text(f'{body}') + + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + base = tmp / 'base.json' + good = tmp / 'good' + junit(good / 'junit-unit' / 'unit.xml', [('A', 'pass'), ('B', 'skip'), ('C', 'pass')]) + assert run(['write', str(good), '--baseline', str(base)]) == 0 + assert run(['check', str(good), '--baseline', str(base)]) == 0, 'identical run must be green' + cases = { + 'pass -> skip': ([('A', 'skip'), ('B', 'skip'), ('C', 'pass')], 1), + 'new skipped case': ([('A', 'pass'), ('B', 'skip'), ('C', 'pass'), ('D', 'skip')], 1), + 'case disappeared': ([('A', 'pass'), ('B', 'skip')], 1), + 'skip -> pass': ([('A', 'pass'), ('B', 'pass'), ('C', 'pass')], 0), + 'new passing case': ([('A', 'pass'), ('B', 'skip'), ('C', 'pass'), ('D', 'pass')], 0), + 'failure is not a skip': ([('A', 'fail'), ('B', 'skip'), ('C', 'pass')], 0), + } + for label, (run_cases, expected) in cases.items(): + d = tmp / label.replace(' ', '_').replace('>', '') + junit(d / 'junit-unit' / 'unit.xml', run_cases) + got = run(['check', str(d), '--baseline', str(base)]) + assert got == expected, f'{label}: exit {got}, expected {expected}' + lost = tmp / 'lost' + junit(lost / 'junit-other' / 'other.xml', [('A', 'pass')]) + assert run(['check', str(lost), '--baseline', str(base)]) == 1, 'a lane that vanished must be red' + assert run(['check', str(tmp / 'empty'), '--baseline', str(base)]) == 1, 'no JUnit must be red' + assert run(['check', str(good), '--baseline', str(tmp / 'none.json')]) == 1, 'no baseline must be red' + print('skip_census self-test: ok') + return 0 + + +if __name__ == '__main__': + if sys.argv[1:] == ['--self-test']: + sys.exit(self_test()) + sys.exit(run(sys.argv[1:]))