diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57deacc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,160 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_call: + +permissions: + contents: read + +jobs: + quality: + name: Quality + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install project and development tooling + run: | + python -m pip install --upgrade pip + python -m pip install ".[dev]" + + - name: Lint shipped Python and tests + run: flake8 micro src scripts tests + + - name: Run tests with branch coverage + run: coverage run --branch --source=micro,src,scripts -m pytest + env: + PYTHONPATH: src:. + + - name: Report and enforce per-file coverage + run: | + coverage report -m + coverage json --pretty-print -o coverage.json + python scripts/check_coverage.py coverage.json + + - name: Build and validate release OTA candidate + run: | + version="$(python -c 'import json; print(json.load(open("docs/release-validation.json"))["candidate_version"])')" + python scripts/build_ota_bundle.py \ + --version "$version" \ + --output candidate-assets/bambutton-ota.json + python - <<'PY' + import json + from pathlib import Path + + from micro import ota_manager + + path = Path("candidate-assets/bambutton-ota.json") + raw = path.read_bytes() + max_bytes = getattr(ota_manager, "MAX_BUNDLE_BODY_BYTES", 96 * 1024) + if len(raw) > max_bytes: + raise SystemExit("OTA bundle is {} bytes; limit is {}".format(len(raw), max_bytes)) + ota_manager.validate_bundle(json.loads(raw)) + print("Validated OTA bundle: {} bytes".format(len(raw))) + PY + + - name: Upload OTA release candidate + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: release-candidate-ota + path: candidate-assets/bambutton-ota.json + + - name: Build wheel and source distribution + run: python -m build + + - name: Smoke-test installed wheel resources + run: | + python -m pip install --force-reinstall --no-deps dist/*.whl + python scripts/smoke_installed_package.py + + - name: Smoke-test installed source distribution resources + run: | + python -m pip install --no-build-isolation --force-reinstall --no-deps dist/*.tar.gz + python scripts/smoke_installed_package.py + + - name: Upload per-file coverage report + uses: actions/upload-artifact@v4 + with: + name: python-coverage + path: coverage.json + + desktop-smoke: + name: Desktop smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install project and desktop build tooling + run: | + python -m pip install --upgrade pip + python -m pip install ".[dev]" + + - name: Build and install wheel + run: | + python -m build --wheel + python -c "import glob, subprocess, sys; wheels = glob.glob('dist/*.whl'); assert len(wheels) == 1, wheels; subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--force-reinstall', '--no-deps', wheels[0]])" + + - name: Smoke-test installed setup tool + run: python -m bambutton --self-test + + - name: Build desktop application + run: python scripts/build_gui.py + + - name: Smoke-test Windows application + if: runner.os == 'Windows' + shell: pwsh + run: .\dist\Bambutton.exe --self-test + + - name: Package Windows release candidate + if: runner.os == 'Windows' + shell: pwsh + run: Compress-Archive -Path dist/Bambutton.exe -DestinationPath dist/Bambutton-windows.zip -Force + + - name: Upload Windows release candidate + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: release-candidate-windows + path: dist/Bambutton-windows.zip + + - name: Smoke-test macOS application + if: runner.os == 'macOS' + run: dist/Bambutton.app/Contents/MacOS/Bambutton --self-test + + - name: Package macOS release candidate + if: runner.os == 'macOS' + run: ditto -c -k --keepParent dist/Bambutton.app dist/Bambutton-macos.zip + + - name: Upload macOS release candidate + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: release-candidate-macos + path: dist/Bambutton-macos.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b54017..da8bcbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,74 +6,87 @@ on: - "v[0-9]*.[0-9]*.[0-9]*" permissions: + actions: read contents: write jobs: - python-dist: - name: Build Python package + verify-release-tag: + name: Verify tag is current main runs-on: ubuntu-latest steps: - - name: Check out repository + - name: Check out repository history uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install build tooling - run: python -m pip install --upgrade pip build - - - name: Build package - run: python -m build - - - name: Upload Python package artifacts - uses: actions/upload-artifact@v4 - with: - name: python-dist - path: | - dist/*.whl - dist/*.tar.gz - - windows-exe: - name: Build Windows executable - runs-on: windows-latest - + - name: Require tag to point at current main + run: | + git fetch origin main + tag_commit="$(git rev-parse "${GITHUB_REF_NAME}^{commit}")" + main_commit="$(git rev-parse origin/main)" + if [ "$tag_commit" != "$main_commit" ]; then + echo "Release tag $GITHUB_REF_NAME points at $tag_commit, not main $main_commit" + exit 1 + fi + + quality-gate: + name: Run current CI on release commit + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + verify-release-record: + name: Require hardware release rehearsal + runs-on: ubuntu-latest + outputs: + candidate_run_id: ${{ steps.find-candidate.outputs.run_id }} steps: - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install project and build tooling + - name: Require a passing hardware record for this tag run: | - python -m pip install --upgrade pip - python -m pip install ".[dev]" - - - name: Build executable - run: python scripts/build_gui.py - - - name: Package executable - shell: pwsh - run: Compress-Archive -Path dist/Bambutton.exe -DestinationPath dist/Bambutton-windows.zip -Force - - - name: Upload Windows artifact - uses: actions/upload-artifact@v4 + tested_commit="$(git rev-parse "${GITHUB_SHA}^1")" + changed_files="$(git diff --name-only "$tested_commit" "${GITHUB_SHA}")" + if [ "$changed_files" != "docs/release-validation.json" ]; then + echo "Release record commit must change only docs/release-validation.json" + exit 1 + fi + python scripts/verify_release_readiness.py "${GITHUB_REF_NAME}" \ + --expected-commit "$tested_commit" + + - name: Find the successful CI run for the tested candidate + id: find-candidate + uses: actions/github-script@v7 with: - name: windows-exe - path: dist/Bambutton-windows.zip + script: | + const { execFileSync } = require('node:child_process'); + const testedCommit = execFileSync('git', ['rev-parse', 'HEAD^1'], { encoding: 'utf8' }).trim(); + const result = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'ci.yml', + head_sha: testedCommit, + per_page: 100, + }); + const candidate = result.data.workflow_runs.find(run => + run.event === 'push' && + run.head_branch === 'main' && + run.conclusion === 'success' + ); + if (!candidate) { + throw new Error(`No successful main CI run found for tested commit ${testedCommit}`); + } + core.setOutput('run_id', String(candidate.id)); - macos-app: - name: Build macOS app - runs-on: macos-latest + python-dist: + name: Build Python package + runs-on: ubuntu-latest + needs: + - verify-release-tag + - quality-gate steps: - name: Check out repository @@ -86,29 +99,31 @@ jobs: with: python-version: "3.11" - - name: Install project and build tooling - run: | - python -m pip install --upgrade pip - python -m pip install ".[dev]" - - - name: Build app - run: python scripts/build_gui.py + - name: Install build tooling + run: python -m pip install --upgrade pip build - - name: Package app bundle - run: ditto -c -k --keepParent dist/Bambutton.app dist/Bambutton-macos.zip + - name: Build package + run: python -m build - - name: Upload macOS artifact + - name: Upload Python package artifacts uses: actions/upload-artifact@v4 with: - name: macos-app - path: dist/Bambutton-macos.zip + name: python-dist + path: | + dist/*.whl + dist/*.tar.gz - ota-bundle: - name: Build MicroPython OTA bundle + release: + name: Publish GitHub release runs-on: ubuntu-latest + needs: + - verify-release-tag + - quality-gate + - verify-release-record + - python-dist steps: - - name: Check out repository + - name: Check out release record and verification script uses: actions/checkout@v4 - name: Set up Python @@ -116,33 +131,41 @@ jobs: with: python-version: "3.11" - - name: Build OTA bundle - run: | - python scripts/build_ota_bundle.py \ - --version "${GITHUB_REF_NAME#v}" \ - --output dist/bambutton-ota.json + - name: Download hardware-tested Windows candidate + uses: actions/download-artifact@v4 + with: + name: release-candidate-windows + path: release-assets + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ needs.verify-release-record.outputs.candidate_run_id }} - - name: Upload OTA artifact - uses: actions/upload-artifact@v4 + - name: Download hardware-tested macOS candidate + uses: actions/download-artifact@v4 with: - name: ota-bundle - path: dist/bambutton-ota.json + name: release-candidate-macos + path: release-assets + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ needs.verify-release-record.outputs.candidate_run_id }} - release: - name: Publish GitHub release - runs-on: ubuntu-latest - needs: - - python-dist - - windows-exe - - macos-app - - ota-bundle + - name: Download hardware-tested OTA candidate + uses: actions/download-artifact@v4 + with: + name: release-candidate-ota + path: release-assets + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ needs.verify-release-record.outputs.candidate_run_id }} - steps: - - name: Download artifacts + - name: Verify release candidate checksums + run: python scripts/verify_release_artifacts.py docs/release-validation.json release-assets + + - name: Download Python package artifacts uses: actions/download-artifact@v4 with: + name: python-dist path: release-assets - merge-multiple: true - name: Publish release uses: softprops/action-gh-release@v2 diff --git a/docs/release-checklist.md b/docs/release-checklist.md new file mode 100644 index 0000000..297fd71 --- /dev/null +++ b/docs/release-checklist.md @@ -0,0 +1,50 @@ +# v1.0.0 release qualification + +The release workflow runs CI against the tagged commit and refuses to publish +unless the tag points at the current `main` commit and +[`release-validation.json`](release-validation.json) records a passing hardware +rehearsal for its immediate parent commit. Run the rehearsal against a candidate +commit on `main`. Download the three `release-candidate-*` artifacts from that +commit's successful CI run, test those exact files, and record their SHA-256 +digests in `release-validation.json`. The release workflow publishes those same +artifacts only if the recorded digests match. Then make a follow-up commit that +changes only `docs/release-validation.json` to record the result. Tag that +record-only commit; the workflow verifies the recorded parent and single-file +change. + +## Automated checks + +- The required `CI / Quality` check passes on the pull request and the final + `main` commit. +- The `CI / Desktop smoke (windows-latest)` and + `CI / Desktop smoke (macos-latest)` checks pass. +- CI reports per-file coverage for every Python source file in `micro/`, + `src/`, and `scripts/`; each file has at least 25% coverage and total coverage + is at least 70%. +- The wheel and source distribution install and contain the firmware and + MicroPython runtime resources. +- The exact OTA JSON asset built for the tag passes the device validator. +- The Windows setup tool, macOS setup tool, and OTA bundle tested on hardware + are byte-for-byte the files the release workflow publishes. + +## ESP32-C3 rehearsal + +Run these checks on the named board with a data-capable USB cable. Record the +board model/revision, MicroPython firmware image/version, test date, commit, +and result in `release-validation.json`. + +- Erase the board and perform a fresh USB install with the release tool. +- Upgrade over USB from the last published release while preserving settings. +- Save settings, interrupt power during the save, and verify the board boots + with either the old or new complete settings. +- Request a plate clear with the API unavailable, restore Bambuddy/Wi-Fi, and + verify the queued press resolves once without losing the LED indication. +- Install an OTA update from the web UI, including a web UI file larger than + the former 32 KiB limit; verify boot confirmation and active-slot contents. +- Interrupt an OTA update and verify the prior application remains bootable. +- Use the released Windows and macOS setup tools with the board and confirm + that each completes a USB install and starts the configured application. + +`release-validation.json` intentionally starts in `pending` state. A software +CI pass cannot substitute for these checks on the ESP32-C3 and desktop +platforms. diff --git a/docs/release-validation.json b/docs/release-validation.json new file mode 100644 index 0000000..9ff47a4 --- /dev/null +++ b/docs/release-validation.json @@ -0,0 +1,25 @@ +{ + "candidate_version": "1.0.0", + "release_tag": null, + "tested_at_utc": null, + "tested_commit": null, + "board": null, + "micropython_firmware": null, + "result": "pending", + "artifacts": { + "windows_setup_tool_sha256": null, + "macos_setup_tool_sha256": null, + "ota_bundle_sha256": null + }, + "checks": { + "fresh_usb_install": false, + "usb_upgrade_from_previous_release": false, + "settings_save_power_interruption_recovery": false, + "plate_clear_api_outage_recovery": false, + "web_ota_install": false, + "larger_ota_install_and_rollback": false, + "interrupted_ota_rollback": false, + "windows_setup_tool_used": false, + "macos_setup_tool_used": false + } +} diff --git a/pyproject.toml b/pyproject.toml index b538758..ecfec95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ bambutton = "bambutton.gui:main" [project.optional-dependencies] dev = [ "build", + "coverage>=7.6", "flake8", "hatch-vcs", "pyinstaller", diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py new file mode 100644 index 0000000..bcdfd51 --- /dev/null +++ b/scripts/check_coverage.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Fail when shipped Python files lack meaningful automated coverage.""" + +import argparse +import json +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SOURCE_DIRECTORIES = ("micro", "scripts", "src") +MIN_FILE_COVERAGE = 25.0 +MIN_TOTAL_COVERAGE = 70.0 + + +def shipped_python_files(project_root=PROJECT_ROOT): + files = set() + for directory in SOURCE_DIRECTORIES: + files.update((project_root / directory).rglob("*.py")) + return sorted(path for path in files if "__pycache__" not in path.parts) + + +def audit_coverage(report_path, project_root=PROJECT_ROOT): + report = json.loads(Path(report_path).read_text(encoding="utf-8")) + measured_files = {} + for filename, details in report.get("files", {}).items(): + path = Path(filename) + if not path.is_absolute(): + path = project_root / path + measured_files[path.resolve()] = details + + errors = [] + for path in shipped_python_files(project_root): + details = measured_files.get(path.resolve()) + percent = ( + details.get("summary", {}).get("percent_covered", 0.0) + if details + else 0.0 + ) + if percent < MIN_FILE_COVERAGE: + errors.append( + "{} has {:.1f}% coverage; requires at least {:.0f}%".format( + path.relative_to(project_root), percent, MIN_FILE_COVERAGE + ) + ) + + total = report.get("totals", {}).get("percent_covered", 0.0) + if total < MIN_TOTAL_COVERAGE: + errors.append( + "total coverage is {:.1f}%; requires at least {:.0f}%".format( + total, MIN_TOTAL_COVERAGE + ) + ) + return errors, total + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report", type=Path, nargs="?", default=Path("coverage.json")) + parser.add_argument("--root", type=Path, default=PROJECT_ROOT) + args = parser.parse_args() + + try: + errors, total = audit_coverage(args.report, args.root.resolve()) + except (OSError, ValueError) as exc: + parser.error("could not read coverage report: {}".format(exc)) + + print("Total Python source coverage: {:.1f}%".format(total)) + if errors: + for error in errors: + print("Coverage gate failed:", error) + raise SystemExit(1) + print("Every shipped Python module meets the coverage floor.") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_installed_package.py b/scripts/smoke_installed_package.py new file mode 100644 index 0000000..4ebd89c --- /dev/null +++ b/scripts/smoke_installed_package.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Smoke-test runtime resources in an installed wheel or source distribution.""" + +from pathlib import Path + + +def check_installed_package(): + import bambutton + + package_root = Path(bambutton.__file__).resolve().parent + required_files = ( + "micro/app_main.py", + "micro/boot.py", + "micro/config_example.json", + "micro/ota_manager.py", + "micro/web_config.py", + "firmware/ESP32_GENERIC_C3-20260406-v1.28.0.bin", + ) + missing = [name for name in required_files if not (package_root / name).is_file()] + if missing: + raise RuntimeError( + "Installed package is missing: {}".format(", ".join(missing)) + ) + return package_root + + +def main(): + package_root = check_installed_package() + print("Installed Bambutton package is ready:", package_root) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_release_artifacts.py b/scripts/verify_release_artifacts.py new file mode 100644 index 0000000..5bb21e1 --- /dev/null +++ b/scripts/verify_release_artifacts.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Verify that downloaded release assets match the hardware-tested digests.""" + +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from micro import ota_manager # noqa: E402 + + +ARTIFACTS = { + "windows_setup_tool_sha256": "Bambutton-windows.zip", + "macos_setup_tool_sha256": "Bambutton-macos.zip", + "ota_bundle_sha256": "bambutton-ota.json", +} + + +def release_artifact_errors(record, artifact_dir): + expected_hashes = record.get("artifacts") if isinstance(record, dict) else None + if not isinstance(expected_hashes, dict): + return ["artifacts must be an object containing tested SHA-256 digests"] + + errors = [] + artifact_dir = Path(artifact_dir) + for field, filename in ARTIFACTS.items(): + expected = expected_hashes.get(field) + if not isinstance(expected, str) or len(expected) != 64: + errors.append("artifact {} must record a SHA-256 digest".format(field)) + continue + path = artifact_dir / filename + try: + actual = hashlib.sha256(path.read_bytes()).hexdigest() + except OSError: + errors.append("release candidate artifact is missing: {}".format(filename)) + continue + if actual.lower() != expected.lower(): + errors.append("release candidate artifact digest does not match: {}".format(filename)) + + ota_path = artifact_dir / ARTIFACTS["ota_bundle_sha256"] + try: + ota_body = ota_path.read_bytes() + except OSError: + ota_body = None + if ota_body is not None: + max_bytes = getattr(ota_manager, "MAX_BUNDLE_BODY_BYTES", 96 * 1024) + if len(ota_body) > max_bytes: + errors.append( + "OTA bundle exceeds the {} byte device payload limit".format(max_bytes) + ) + else: + try: + bundle = json.loads(ota_body.decode("utf-8")) + ota_manager.validate_bundle(bundle) + except (UnicodeError, ValueError, ota_manager.OTAError) as exc: + errors.append("OTA bundle is invalid for the device: {}".format(exc)) + return errors + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("record", type=Path) + parser.add_argument("artifact_dir", type=Path) + args = parser.parse_args() + + try: + record = json.loads(args.record.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + parser.error("could not read release validation record: {}".format(exc)) + + errors = release_artifact_errors(record, args.artifact_dir) + if errors: + for error in errors: + print("Release artifact verification failed:", error) + raise SystemExit(1) + print("Release artifacts match the hardware-tested candidate digests.") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_release_readiness.py b/scripts/verify_release_readiness.py new file mode 100644 index 0000000..d917da0 --- /dev/null +++ b/scripts/verify_release_readiness.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Require a completed hardware rehearsal record before publishing a tag.""" + +import argparse +import json +import re +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +RECORD_PATH = PROJECT_ROOT / "docs" / "release-validation.json" +REQUIRED_CHECKS = ( + "fresh_usb_install", + "usb_upgrade_from_previous_release", + "settings_save_power_interruption_recovery", + "plate_clear_api_outage_recovery", + "web_ota_install", + "larger_ota_install_and_rollback", + "interrupted_ota_rollback", + "windows_setup_tool_used", + "macos_setup_tool_used", +) +REQUIRED_ARTIFACT_HASHES = ( + "windows_setup_tool_sha256", + "macos_setup_tool_sha256", + "ota_bundle_sha256", +) + + +def release_readiness_errors(record, tag, expected_commit): + errors = [] + if not isinstance(record, dict): + return ["release validation record must be a JSON object"] + + if record.get("release_tag") != tag: + errors.append("release_tag must match {}".format(tag)) + candidate_version = record.get("candidate_version") + if not isinstance(candidate_version, str) or tag != "v" + candidate_version: + errors.append("candidate_version must match {}".format(tag)) + for field in ("tested_at_utc", "tested_commit", "board", "micropython_firmware"): + if not isinstance(record.get(field), str) or not record[field].strip(): + errors.append("{} must be recorded".format(field)) + if record.get("tested_commit") != expected_commit: + errors.append("tested_commit must match the release commit") + if record.get("result") != "passed": + errors.append("result must be passed") + + artifacts = record.get("artifacts") + if not isinstance(artifacts, dict): + errors.append("artifacts must be an object containing tested SHA-256 digests") + else: + for name in REQUIRED_ARTIFACT_HASHES: + value = artifacts.get(name) + if not isinstance(value, str) or re.fullmatch(r"[0-9a-fA-F]{64}", value) is None: + errors.append("artifact {} must record a SHA-256 digest".format(name)) + + checks = record.get("checks") + if not isinstance(checks, dict): + errors.append("checks must be an object") + else: + for name in REQUIRED_CHECKS: + if checks.get(name) is not True: + errors.append("check {} is incomplete".format(name)) + return errors + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tag") + parser.add_argument("--expected-commit", required=True) + parser.add_argument("--record", type=Path, default=RECORD_PATH) + args = parser.parse_args() + + try: + record = json.loads(args.record.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + parser.error("could not read release validation record: {}".format(exc)) + + errors = release_readiness_errors(record, args.tag, args.expected_commit) + if errors: + for error in errors: + print("Release readiness failed:", error) + raise SystemExit(1) + print("Release validation record passed for {}".format(args.tag)) + + +if __name__ == "__main__": + main() diff --git a/src/bambutton/gui.py b/src/bambutton/gui.py index d3e8fde..ea8ada4 100755 --- a/src/bambutton/gui.py +++ b/src/bambutton/gui.py @@ -174,6 +174,10 @@ def write_install_marker(): def main(): + if "--self-test" in sys.argv[1:]: + run_installation_self_test() + return + window = build_window() try: @@ -194,6 +198,30 @@ def main(): window.close() +def run_installation_self_test(): + required_files = ( + "api.py", + "app_main.py", + "bambuddy_api.py", + "boot.py", + "config_loader.py", + "gpio_button.py", + "led_flasher.py", + "main.py", + "ota_manager.py", + "periodic_timer.py", + "web_config.py", + "wifi.py", + "config_example.json", + ) + missing = [name for name in required_files if not (MICRO_DIR / name).is_file()] + if missing: + raise RuntimeError( + "Bambutton installation is missing: {}".format(", ".join(missing)) + ) + validate_firmware(first_firmware_file()) + + def build_window(): background_color = "#181C23" text_color = "#F3F4F6" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..508984b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""Keep unit tests runnable on hosts whose Python lacks Tk support.""" + +import sys +from types import ModuleType + + +try: + import _tkinter # noqa: F401 +except ImportError: + class HeadlessFreeSimpleGUI(ModuleType): + WIN_CLOSED = "WIN_CLOSED" + BUTTON_TYPE_BROWSE_FILE = "browse-file" + + def __getattr__(self, name): + def widget_or_callback(*args, **kwargs): + return None + + return widget_or_callback + + sys.modules.setdefault( + "FreeSimpleGUI", HeadlessFreeSimpleGUI("FreeSimpleGUI") + ) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..cdc3048 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,76 @@ +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + + +API_PATH = Path(__file__).parents[1] / "micro" / "api.py" + + +def load_api_module(monkeypatch, requests): + monkeypatch.setitem(sys.modules, "urequests", requests) + spec = importlib.util.spec_from_file_location("test_micro_api", API_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class Response: + def __init__(self, status_code=200, text="ok"): + self.status_code = status_code + self.text = text + self.closed = False + + def close(self): + self.closed = True + + +def test_api_get_uses_headers_timeout_and_closes_response(monkeypatch): + response = Response(202, '{"ready": true}') + calls = [] + requests = SimpleNamespace( + get=lambda *args, **kwargs: calls.append((args, kwargs)) or response + ) + api_module = load_api_module(monkeypatch, requests) + api = api_module.API("secret", "http://bambuddy/api/v1/", 5) + + result = api.api_get("printers/7/status", {"X-Test": "yes"}) + + assert result == (202, '{"ready": true}') + assert calls == [(("http://bambuddy/api/v1/printers/7/status",), { + "headers": {"X-API-Key": "secret", "X-Test": "yes"}, + "timeout": 5, + })] + assert response.closed is True + + +def test_api_post_sends_json_and_closes_response(monkeypatch): + response = Response(201, "created") + calls = [] + requests = SimpleNamespace( + post=lambda *args, **kwargs: calls.append((args, kwargs)) or response + ) + api_module = load_api_module(monkeypatch, requests) + api = api_module.API("secret", "https://bambuddy/api/v1") + payload = {"printer_id": 7} + + result = api.api_post("printers/7/clear-plate", payload) + + assert result == (201, "created") + assert calls[0][0] == ("https://bambuddy/api/v1/printers/7/clear-plate",) + assert calls[0][1]["json"] == payload + assert calls[0][1]["headers"] == { + "X-API-Key": "secret", + "Content-Type": "application/json", + } + assert response.closed is True + + +def test_api_urls_preserve_absolute_links_and_normalize_relative_paths(monkeypatch): + api_module = load_api_module(monkeypatch, SimpleNamespace()) + api = api_module.API("key", "http://bambuddy/api/v1/") + + assert api.api_url("/printers") == "http://bambuddy/api/v1/printers" + assert api.api_url("https://other.example/status") == ( + "https://other.example/status" + ) diff --git a/tests/test_application_runtime.py b/tests/test_application_runtime.py new file mode 100644 index 0000000..07a5e5a --- /dev/null +++ b/tests/test_application_runtime.py @@ -0,0 +1,184 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + + +APP_MAIN = Path(__file__).parents[1] / "micro" / "app_main.py" + + +class StopMainLoop(BaseException): + pass + + +class FakeAPI: + def __init__(self): + self.clears = 0 + self.status = {"awaiting_plate_clear": False, "chamber_light": False} + + def clear_plate(self, printer_id): + self.clears += 1 + + def clear_plate_with_reconciliation(self, printer_id): + self.clears += 1 + return {"resolved": True, "status": None, "request_error": None} + + def get_printer_status(self, printer_id): + return self.status + + +def load_app_main(monkeypatch, api): + button_handlers = [] + clock = {"ticks": 100} + + class FakeNetwork: + ap_mode = True + + def connect_with_fallback(self, watchdog_feed=None): + return self + + def is_ap_mode(self): + return self.ap_mode + + def is_connected(self): + return not self.ap_mode + + def ensure_connected(self, watchdog_feed=None, fallback_to_ap=False): + return self + + def ifconfig(self): + return ("192.168.4.1", "255.255.255.0", "192.168.4.1", "192.168.4.1") + + def mode(self): + return "access point" if self.ap_mode else "station" + + network = FakeNetwork() + config = { + "wifi": {"ssid": "shop", "password": "password", "hostname": "button", + "timeout_seconds": 1}, + "api": {"key": "key", "base_url": "http://bambuddy/api/v1", + "request_timeout_seconds": 1}, + "printer": {"id": 7, "poll_interval_seconds": 5}, + "led": {"pin": 3, "flash_interval_ms": 250}, + "button": {"pin": 4, "debounce_ms": 100, "pull": "down", + "trigger": "rising"}, + } + + class FakeButton: + def __init__(self, on_press, **kwargs): + button_handlers.append(on_press) + + def start(self): + return None + + class FakeFlasher: + def __init__(self, **kwargs): + pass + + def start(self): + return None + + def on(self): + return None + + class FakeWDT: + def __init__(self, timeout): + self.timeout = timeout + + def feed(self): + return None + + class FakeOTA: + def __init__(self, reset=None): + pass + + def status(self): + return {"current_version": "test"} + + def confirm_boot(self): + return True + + class FakeWebServer: + def __init__(self, **kwargs): + pass + + def poll(self): + return False + + modules = { + "machine": SimpleNamespace(WDT=FakeWDT, reset=lambda: None), + "time": SimpleNamespace( + ticks_ms=lambda: clock["ticks"], + ticks_diff=lambda left, right: left - right, + ticks_add=lambda value, delta: value + delta, + sleep_ms=lambda delay: (_ for _ in ()).throw(StopMainLoop()), + ), + "bambuddy_api": SimpleNamespace(BambuddyAPI=lambda *args: api), + "config_loader": SimpleNamespace( + load_config=lambda: config, + is_runtime_config_ready=lambda loaded: True, + ), + "gpio_button": SimpleNamespace(GPIOButton=FakeButton), + "led_flasher": SimpleNamespace(LedFlasher=FakeFlasher), + "ota_manager": SimpleNamespace(OTAUpdateManager=FakeOTA), + "wifi": SimpleNamespace( + WiFi=lambda **kwargs: network, + DEFAULT_HOSTNAME="bambutton", + DEFAULT_AP_PASSWORD="bambutton", + AP_RETRY_INTERVAL_SECONDS=30, + ), + "periodic_timer": SimpleNamespace( + PeriodicTimer=lambda **kwargs: SimpleNamespace(start=lambda: None) + ), + "web_config": SimpleNamespace(WebConfigServer=FakeWebServer), + } + for name, module in modules.items(): + monkeypatch.setitem(sys.modules, name, module) + + namespace = {"__name__": "app_main_coverage_test"} + try: + exec(compile(APP_MAIN.read_text(), str(APP_MAIN), "exec"), namespace) + except StopMainLoop: + pass + return namespace, network, button_handlers[0] + + +def test_press_interrupt_queues_a_single_pending_request(monkeypatch): + namespace, _, press = load_app_main(monkeypatch, FakeAPI()) + namespace["PRINTER_AWAITING_PLATE_CLEAR"] = True + + press(None) + press(None) + + assert namespace["PENDING_BUTTON_PRESS"] is True + assert namespace["PRINTER_AWAITING_PLATE_CLEAR"] is False + + +def test_pending_request_sends_clear_and_updates_poll_state(monkeypatch): + api = FakeAPI() + namespace, network, press = load_app_main(monkeypatch, api) + namespace["PRINTER_AWAITING_PLATE_CLEAR"] = True + press(None) + network.ap_mode = False + + namespace["handle_pending_button_press"]() + + assert api.clears == 1 + assert namespace["PENDING_BUTTON_PRESS"] is False + assert namespace["PRINTER_STATUS_UPDATE_REQUIRED"] is True + + +def test_printer_status_updates_led_and_debug_information(monkeypatch): + api = FakeAPI() + api.status = {"awaiting_plate_clear": True, "chamber_light": True} + namespace, network, _ = load_app_main(monkeypatch, api) + network.ap_mode = False + + namespace["handle_printer_status_update"]() + status = namespace["debug_status"]() + + assert namespace["should_flash_plate_clear"]() is True + assert status["Network mode"] == "station" + assert status["Awaiting plate clear"] is True + assert status["Chamber light on"] is True + assert status["Application version"] == "test" + assert namespace["PRINTER_STATUS_UPDATE_REQUIRED"] is False diff --git a/tests/test_bambuddy_client.py b/tests/test_bambuddy_client.py new file mode 100644 index 0000000..d4920cc --- /dev/null +++ b/tests/test_bambuddy_client.py @@ -0,0 +1,90 @@ +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +BAMBUDDY_API_PATH = Path(__file__).parents[1] / "micro" / "bambuddy_api.py" + + +def load_bambuddy_api(monkeypatch): + class API: + def __init__(self, *args, **kwargs): + pass + + def api_get(self, path): + raise NotImplementedError + + def api_post(self, path, payload=None): + raise NotImplementedError + + monkeypatch.setitem(sys.modules, "api", SimpleNamespace(API=API)) + spec = importlib.util.spec_from_file_location( + "test_bambuddy_client", BAMBUDDY_API_PATH + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_public_methods_use_printer_routes_and_status_fields(monkeypatch): + module = load_bambuddy_api(monkeypatch) + api = module.BambuddyAPI() + get_calls = [] + post_calls = [] + monkeypatch.setattr( + api, "_get", lambda path: get_calls.append(path) or { + "awaiting_plate_clear": True, "chamber_light": True, + } + ) + monkeypatch.setattr( + api, "_post", lambda path, payload=None: post_calls.append((path, payload)) + ) + + assert api.get_printers()["awaiting_plate_clear"] is True + assert api.get_printer(7)["chamber_light"] is True + assert api.get_printer_status(7)["awaiting_plate_clear"] is True + assert api.printer_is_awaiting_plate_clear(7) is True + assert api.chamber_light_is_lit(7) is True + api.clear_plate(7) + + assert get_calls == [ + "printers/", "printers/7", "printers/7/status", + "printers/7/status", "printers/7/status", + ] + assert post_calls == [("printers/7/clear-plate", None)] + + +def test_api_errors_preserve_status_and_server_message(monkeypatch): + module = load_bambuddy_api(monkeypatch) + api = module.BambuddyAPI() + monkeypatch.setattr(api, "api_get", lambda path: (409, '{"detail":"not awaiting"}')) + + with pytest.raises(module.BambuddyAPIError, match="not awaiting") as error: + api.get_printer_status(7) + + assert error.value.status_code == 409 + assert error.value.body == {"detail": "not awaiting"} + + +def test_invalid_json_response_is_returned_as_text(monkeypatch): + module = load_bambuddy_api(monkeypatch) + api = module.BambuddyAPI() + monkeypatch.setattr(api, "api_get", lambda path: (200, "plain response")) + + assert api.get_printer(7) == "plain response" + + +def test_transport_exception_is_wrapped_as_api_error(monkeypatch): + module = load_bambuddy_api(monkeypatch) + api = module.BambuddyAPI() + + def fail_request(path): + raise OSError("offline") + + monkeypatch.setattr(api, "api_post", fail_request) + + with pytest.raises(module.BambuddyAPIError, match="API POST request failed"): + api.clear_plate(7) diff --git a/tests/test_boot.py b/tests/test_boot.py new file mode 100644 index 0000000..373bcd2 --- /dev/null +++ b/tests/test_boot.py @@ -0,0 +1,48 @@ +import runpy +from pathlib import Path + + +BOOT_PATH = Path(__file__).parents[1] / "micro" / "boot.py" + + +def test_boot_recovery_moves_old_main_and_installs_staged_loader(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + marker = Path(".bambutton/bootstrap-install.marker") + marker.parent.mkdir(parents=True) + marker.write_text("bambutton-bootstrap-v1\n") + staged_main = Path(".bambutton/bootstrap/main.py") + staged_main.parent.mkdir(parents=True) + staged_main.write_text("new loader") + Path("main.py").write_text("old application") + + runpy.run_path(str(BOOT_PATH)) + + assert Path("main.py").read_text() == "new loader" + assert Path(".bambutton/legacy_main.py").read_text() == "old application" + + +def test_boot_recovery_restores_legacy_main_if_staging_was_lost( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + marker = Path(".bambutton/bootstrap-install.marker") + marker.parent.mkdir(parents=True) + marker.write_text("bambutton-bootstrap-v1\n") + legacy_main = Path(".bambutton/legacy_main.py") + legacy_main.write_text("old application") + + runpy.run_path(str(BOOT_PATH)) + + assert Path("main.py").read_text() == "old application" + + +def test_boot_ignores_unknown_install_marker(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + marker = Path(".bambutton/bootstrap-install.marker") + marker.parent.mkdir(parents=True) + marker.write_text("unrecognized marker") + Path("main.py").write_text("current application") + + runpy.run_path(str(BOOT_PATH)) + + assert Path("main.py").read_text() == "current application" diff --git a/tests/test_build_ota_bundle.py b/tests/test_build_ota_bundle.py index ed36ee3..c8f963a 100644 --- a/tests/test_build_ota_bundle.py +++ b/tests/test_build_ota_bundle.py @@ -1,5 +1,7 @@ import base64 import hashlib +import json +import sys from scripts import build_ota_bundle @@ -20,3 +22,23 @@ def test_build_bundle_contains_hashable_allowlisted_application_files(monkeypatc content = base64.b64decode(info["content"]) assert info["size"] == len(content) assert info["sha256"] == hashlib.sha256(content).hexdigest() + + +def test_build_bundle_cli_writes_the_requested_artifact(monkeypatch, tmp_path): + source = tmp_path / "micro" + source.mkdir() + for filename in build_ota_bundle.APP_FILES: + (source / filename).write_bytes((filename + "\n").encode()) + output = tmp_path / "release" / "bundle.json" + monkeypatch.setattr(build_ota_bundle, "MICRO_DIR", source) + monkeypatch.setattr( + sys, + "argv", + ["build_ota_bundle.py", "--version", "1.2.3", "--output", str(output)], + ) + + build_ota_bundle.main() + + artifact = json.loads(output.read_text()) + assert artifact["version"] == "1.2.3" + assert set(artifact["files"]) == set(build_ota_bundle.APP_FILES) diff --git a/tests/test_check_coverage.py b/tests/test_check_coverage.py new file mode 100644 index 0000000..fe9067f --- /dev/null +++ b/tests/test_check_coverage.py @@ -0,0 +1,43 @@ +import json + +from scripts import check_coverage + + +def test_coverage_gate_requires_each_shipped_file_and_total_to_meet_floor( + tmp_path, monkeypatch +): + source = tmp_path / "micro" + source.mkdir() + module = source / "runtime.py" + module.write_text("print('runtime')\n") + report = tmp_path / "coverage.json" + report.write_text(json.dumps({ + "files": { + "micro/runtime.py": { + "summary": {"percent_covered": 30.0}, + }, + }, + "totals": {"percent_covered": 71.0}, + })) + monkeypatch.setattr(check_coverage, "SOURCE_DIRECTORIES", ("micro",)) + + errors, total = check_coverage.audit_coverage(report, tmp_path) + + assert errors == [] + assert total == 71.0 + + +def test_coverage_gate_reports_uncovered_module_and_low_total( + tmp_path, monkeypatch +): + source = tmp_path / "scripts" + source.mkdir() + (source / "uncovered.py").write_text("main()\n") + report = tmp_path / "coverage.json" + report.write_text(json.dumps({"files": {}, "totals": {"percent_covered": 0}})) + monkeypatch.setattr(check_coverage, "SOURCE_DIRECTORIES", ("scripts",)) + + errors, _ = check_coverage.audit_coverage(report, tmp_path) + + assert any("uncovered.py has 0.0%" in error for error in errors) + assert any("total coverage is 0.0%" in error for error in errors) diff --git a/tests/test_gpio_button.py b/tests/test_gpio_button.py new file mode 100644 index 0000000..b4d400c --- /dev/null +++ b/tests/test_gpio_button.py @@ -0,0 +1,86 @@ +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +GPIO_BUTTON_PATH = Path(__file__).parents[1] / "micro" / "gpio_button.py" + + +def load_gpio_button(monkeypatch, clock): + class FakePin: + IN = "in" + PULL_DOWN = "down" + PULL_UP = "up" + IRQ_FALLING = 1 + IRQ_RISING = 2 + instances = [] + + def __init__(self, *args): + self.args = args + self.handler = None + self.trigger = None + self.instances.append(self) + + def irq(self, trigger=None, handler=None): + self.trigger = trigger + self.handler = handler + + monkeypatch.setitem(sys.modules, "machine", SimpleNamespace(Pin=FakePin)) + monkeypatch.setitem( + sys.modules, + "time", + SimpleNamespace( + ticks_ms=lambda: clock[0], + ticks_diff=lambda current, previous: current - previous, + ), + ) + spec = importlib.util.spec_from_file_location("test_gpio_button", GPIO_BUTTON_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module, FakePin + + +def test_button_uses_configured_pull_trigger_and_debounce(monkeypatch): + clock = [100] + module, pin_type = load_gpio_button(monkeypatch, clock) + presses = [] + button = module.GPIOButton( + 4, presses.append, debounce_ms=100, pull="up", trigger="both" + ) + + button.start() + button.pin.handler(button.pin) + clock[0] = 150 + button.pin.handler(button.pin) + clock[0] = 200 + button.pin.handler(button.pin) + + assert button.pin.args == (4, pin_type.IN, pin_type.PULL_UP) + assert button.pin.trigger == pin_type.IRQ_FALLING | pin_type.IRQ_RISING + assert presses == [button.pin, button.pin] + + button.stop() + assert button.pin.handler is None + + +def test_button_can_disable_internal_pull(monkeypatch): + module, _ = load_gpio_button(monkeypatch, [100]) + + button = module.GPIOButton(4, lambda pin: None, pull="none") + + assert button.pin.args == (4, module.Pin.IN) + + +@pytest.mark.parametrize( + "field,value", + [("pull", "sideways"), ("trigger", "invalid")], +) +def test_button_rejects_unknown_pull_or_trigger(monkeypatch, field, value): + module, _ = load_gpio_button(monkeypatch, [100]) + kwargs = {field: value} + + with pytest.raises(ValueError, match="Unsupported button"): + module.GPIOButton(4, lambda pin: None, **kwargs) diff --git a/tests/test_gui.py b/tests/test_gui.py index c0b2cce..f03f2ff 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -241,6 +241,47 @@ def test_config_setup_requires_a_json_object(tmp_path): assert gui.config_path_for_mode({"-CONFIG-": True, "-CONFIG_PATH-": str(config_path)}) == config_path +def test_installed_setup_self_test_checks_micro_files_and_firmware( + tmp_path, monkeypatch +): + micro_dir = tmp_path / "micro" + firmware_dir = tmp_path / "firmware" + micro_dir.mkdir() + firmware_dir.mkdir() + for name in ( + "api.py", "app_main.py", "bambuddy_api.py", "boot.py", + "config_loader.py", "gpio_button.py", "led_flasher.py", "main.py", + "ota_manager.py", "periodic_timer.py", "web_config.py", "wifi.py", + "config_example.json", + ): + (micro_dir / name).write_text(name) + (firmware_dir / "esp32.bin").write_bytes(b"firmware") + monkeypatch.setattr(gui, "MICRO_DIR", micro_dir) + monkeypatch.setattr(gui, "DEFAULT_FIRMWARE_DIR", firmware_dir) + + gui.run_installation_self_test() + + +def test_installed_setup_self_test_reports_missing_resources(tmp_path, monkeypatch): + micro_dir = tmp_path / "micro" + micro_dir.mkdir() + monkeypatch.setattr(gui, "MICRO_DIR", micro_dir) + + with pytest.raises(RuntimeError, match="installation is missing"): + gui.run_installation_self_test() + + +def test_gui_main_runs_self_test_without_opening_window(monkeypatch): + calls = [] + monkeypatch.setattr(sys, "argv", ["Bambutton", "--self-test"]) + monkeypatch.setattr(gui, "run_installation_self_test", lambda: calls.append(True)) + monkeypatch.setattr(gui, "build_window", lambda: pytest.fail("window was opened")) + + gui.main() + + assert calls == [True] + + @pytest.mark.parametrize( "contents,error", [ diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..543d2d8 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,58 @@ +import runpy +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +MAIN_PATH = Path(__file__).parents[1] / "micro" / "main.py" + + +def test_stable_main_entrypoint_launches_ota_manager(monkeypatch): + events = [] + + class Manager: + def __init__(self, reset): + events.append(("manager", reset)) + + def launch(self): + events.append(("launch",)) + + def reset(): + events.append(("reset",)) + + monkeypatch.setitem(sys.modules, "machine", SimpleNamespace(reset=reset)) + monkeypatch.setitem( + sys.modules, + "ota_manager", + SimpleNamespace(OTAUpdateManager=Manager, OTAError=RuntimeError), + ) + + runpy.run_path(str(MAIN_PATH)) + + assert events == [("manager", reset), ("launch",)] + + +def test_stable_main_logs_and_reraises_boot_failure(monkeypatch, capsys): + class OTAError(Exception): + pass + + class Manager: + def __init__(self, reset): + pass + + def launch(self): + raise OTAError("rollback requested") + + monkeypatch.setitem(sys.modules, "machine", SimpleNamespace(reset=lambda: None)) + monkeypatch.setitem( + sys.modules, + "ota_manager", + SimpleNamespace(OTAUpdateManager=Manager, OTAError=OTAError), + ) + + with pytest.raises(OTAError, match="rollback"): + runpy.run_path(str(MAIN_PATH)) + + assert "Bambutton boot error: rollback requested" in capsys.readouterr().out diff --git a/tests/test_package_smoke.py b/tests/test_package_smoke.py new file mode 100644 index 0000000..323ac9d --- /dev/null +++ b/tests/test_package_smoke.py @@ -0,0 +1,51 @@ +import runpy +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +SMOKE_PATH = Path(__file__).parents[1] / "scripts" / "smoke_installed_package.py" +REQUIRED_FILES = ( + "micro/app_main.py", + "micro/boot.py", + "micro/config_example.json", + "micro/ota_manager.py", + "micro/web_config.py", + "firmware/ESP32_GENERIC_C3-20260406-v1.28.0.bin", +) + + +def fake_package(tmp_path, include_all=True): + package_root = tmp_path / "site-packages" / "bambutton" + for filename in REQUIRED_FILES if include_all else REQUIRED_FILES[:-1]: + path = package_root / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("resource") + return SimpleNamespace(__file__=str(package_root / "__init__.py")) + + +def test_installed_package_smoke_finds_wheel_resources(tmp_path, monkeypatch): + package = fake_package(tmp_path) + monkeypatch.setitem(sys.modules, "bambutton", package) + module = runpy.run_path(str(SMOKE_PATH), run_name="installed_package_smoke") + + assert module["check_installed_package"]() == Path(package.__file__).parent + + +def test_installed_package_smoke_rejects_missing_resource(tmp_path, monkeypatch): + monkeypatch.setitem(sys.modules, "bambutton", fake_package(tmp_path, False)) + module = runpy.run_path(str(SMOKE_PATH), run_name="installed_package_smoke") + + with pytest.raises(RuntimeError, match="Installed package is missing"): + module["check_installed_package"]() + + +def test_smoke_script_entrypoint_prints_success(tmp_path, monkeypatch, capsys): + package = fake_package(tmp_path) + monkeypatch.setitem(sys.modules, "bambutton", package) + + runpy.run_path(str(SMOKE_PATH), run_name="__main__") + + assert "Installed Bambutton package is ready" in capsys.readouterr().out diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 3ebd21c..7018885 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,5 +1,6 @@ import runpy from pathlib import Path +import tomllib def test_release_gui_includes_all_micro_python_modules(): @@ -11,3 +12,29 @@ def test_release_gui_includes_all_micro_python_modules(): assert "app_main.py" in build_script["MICRO_FILES"] assert "ota_manager.py" in build_script["MICRO_FILES"] assert "config_example.json" in build_script["MICRO_FILES"] + + +def test_desktop_and_python_packages_include_all_runtime_files(): + project_root = Path(__file__).parents[1] + build_script = runpy.run_path(str(project_root / "scripts" / "build_gui.py")) + pyproject = tomllib.loads((project_root / "pyproject.toml").read_text()) + runtime_files = { + path.name for path in (project_root / "micro").iterdir() + if path.suffix == ".py" + } | {"config_example.json"} + + assert set(build_script["MICRO_FILES"]) == runtime_files + + wheel_files = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ + "force-include" + ] + wheel_micro_files = { + Path(source).name for source in wheel_files + if source.startswith("micro/") + } + assert wheel_micro_files == runtime_files + + sdist_files = set(pyproject["tool"]["hatch"]["build"]["targets"]["sdist"][ + "include" + ]) + assert {"/micro/" + filename for filename in runtime_files} <= sdist_files diff --git a/tests/test_periodic_timer.py b/tests/test_periodic_timer.py new file mode 100644 index 0000000..5f867af --- /dev/null +++ b/tests/test_periodic_timer.py @@ -0,0 +1,67 @@ +import importlib.util +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +TIMER_PATH = Path(__file__).parents[1] / "micro" / "periodic_timer.py" + + +def load_periodic_timer(monkeypatch): + class FakeTimer: + PERIODIC = "periodic" + instances = [] + + def __init__(self, timer_id): + self.timer_id = timer_id + self.init_args = None + self.deinit_count = 0 + self.instances.append(self) + + def init(self, **kwargs): + self.init_args = kwargs + + def deinit(self): + self.deinit_count += 1 + + monkeypatch.setitem(sys.modules, "machine", SimpleNamespace(Timer=FakeTimer)) + spec = importlib.util.spec_from_file_location("test_periodic_timer", TIMER_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module, FakeTimer + + +def test_periodic_timer_starts_once_stops_and_releases_id(monkeypatch): + module, fake_timer = load_periodic_timer(monkeypatch) + ticks = [] + timer = module.PeriodicTimer(500, lambda: ticks.append("tick")) + + timer.start() + timer.start() + assert timer.timer.init_args == { + "period": 500, + "mode": fake_timer.PERIODIC, + "callback": timer._tick, + } + timer.timer.init_args["callback"](timer.timer) + assert ticks == ["tick"] + + timer.stop() + timer.stop() + assert timer.timer.deinit_count == 1 + timer.close() + assert timer.running is False + assert module.PeriodicTimer._available_timer_ids == [0, 1, 2, 3] + + +def test_periodic_timer_rejects_exhausted_timer_ids(monkeypatch): + module, _ = load_periodic_timer(monkeypatch) + timers = [module.PeriodicTimer(100, lambda: None) for _ in range(4)] + + with pytest.raises(RuntimeError, match="No available timer ids"): + module.PeriodicTimer(100, lambda: None) + + for timer in timers: + timer.close() diff --git a/tests/test_release_readiness.py b/tests/test_release_readiness.py new file mode 100644 index 0000000..353470f --- /dev/null +++ b/tests/test_release_readiness.py @@ -0,0 +1,204 @@ +import base64 +import hashlib +import json +import runpy +import sys +from pathlib import Path + +import pytest + +from scripts import verify_release_artifacts, verify_release_readiness + + +RECORD_PATH = Path(__file__).parents[1] / "docs" / "release-validation.json" +SCRIPT_PATH = Path(__file__).parents[1] / "scripts" / "verify_release_readiness.py" + + +def passing_record(): + return { + "candidate_version": "1.0.0", + "release_tag": "v1.0.0", + "tested_at_utc": "2026-09-24T12:00:00Z", + "tested_commit": "a" * 40, + "board": "ESP32-C3 Super Mini revision A", + "micropython_firmware": "ESP32_GENERIC_C3 v1.28.0", + "result": "passed", + "artifacts": { + name: "a" * 64 + for name in verify_release_readiness.REQUIRED_ARTIFACT_HASHES + }, + "checks": {name: True for name in verify_release_readiness.REQUIRED_CHECKS}, + } + + +def valid_ota_bundle_bytes(): + from micro import ota_manager + + files = {} + for filename in ota_manager.APP_FILES: + content = (filename + "\n").encode("utf-8") + files[filename] = { + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + "content": base64.b64encode(content).decode("ascii"), + } + return json.dumps({ + "format": ota_manager.UPDATE_FORMAT, + "version": "1.0.0", + "minimum_bootloader": ota_manager.BOOTLOADER_VERSION, + "files": files, + }).encode("utf-8") + + +def test_release_record_must_match_tag_and_pass_every_check(): + record = passing_record() + expected_commit = record["tested_commit"] + + assert verify_release_readiness.release_readiness_errors( + record, "v1.0.0", expected_commit + ) == [] + record["checks"]["web_ota_install"] = False + + errors = verify_release_readiness.release_readiness_errors( + record, "v1.0.0", expected_commit + ) + + assert errors == ["check web_ota_install is incomplete"] + assert verify_release_readiness.release_readiness_errors( + passing_record(), "v1.0.1", expected_commit + ) == [ + "release_tag must match v1.0.1", + "candidate_version must match v1.0.1", + ] + assert verify_release_readiness.release_readiness_errors( + passing_record(), "v1.0.0", "b" * 40 + ) == ["tested_commit must match the release commit"] + record = passing_record() + record["candidate_version"] = "1.0.1" + assert verify_release_readiness.release_readiness_errors( + record, "v1.0.0", record["tested_commit"] + ) == ["candidate_version must match v1.0.0"] + + +def test_pending_template_blocks_release_until_hardware_results_are_recorded(): + record = json.loads(RECORD_PATH.read_text()) + + errors = verify_release_readiness.release_readiness_errors( + record, "v1.0.0", "a" * 40 + ) + + assert "result must be passed" in errors + assert "check fresh_usb_install is incomplete" in errors + assert "board must be recorded" in errors + assert "artifact windows_setup_tool_sha256 must record a SHA-256 digest" in errors + + +def test_release_gate_cli_accepts_passing_record(tmp_path, monkeypatch, capsys): + record_path = tmp_path / "release-validation.json" + record_path.write_text(json.dumps(passing_record())) + monkeypatch.setattr( + sys, + "argv", + [ + "verify_release_readiness.py", + "v1.0.0", + "--expected-commit", + "a" * 40, + "--record", + str(record_path), + ], + ) + + runpy.run_path(str(SCRIPT_PATH), run_name="__main__") + + assert "passed for v1.0.0" in capsys.readouterr().out + + +def test_release_gate_cli_rejects_pending_record(tmp_path, monkeypatch): + record_path = tmp_path / "release-validation.json" + record_path.write_text(json.dumps({"result": "pending"})) + monkeypatch.setattr( + sys, + "argv", + [ + "verify_release_readiness.py", + "v1.0.0", + "--expected-commit", + "a" * 40, + "--record", + str(record_path), + ], + ) + + with pytest.raises(SystemExit) as error: + runpy.run_path(str(SCRIPT_PATH), run_name="__main__") + + assert error.value.code == 1 + + +def test_release_artifacts_must_match_tested_sha256_digests(tmp_path): + artifacts = tmp_path / "artifacts" + artifacts.mkdir() + contents = { + "Bambutton-windows.zip": b"windows candidate", + "Bambutton-macos.zip": b"macOS candidate", + "bambutton-ota.json": valid_ota_bundle_bytes(), + } + record = {"artifacts": {}} + for name, content in contents.items(): + (artifacts / name).write_bytes(content) + for field, filename in verify_release_artifacts.ARTIFACTS.items(): + record["artifacts"][field] = hashlib.sha256(contents[filename]).hexdigest() + + assert verify_release_artifacts.release_artifact_errors(record, artifacts) == [] + (artifacts / "Bambutton-windows.zip").write_bytes(b"changed after test") + errors = verify_release_artifacts.release_artifact_errors(record, artifacts) + + assert errors == [ + "release candidate artifact digest does not match: Bambutton-windows.zip" + ] + + +def test_release_artifact_verifier_reports_missing_assets_and_hashes(tmp_path): + errors = verify_release_artifacts.release_artifact_errors( + {"artifacts": {}}, tmp_path + ) + + assert len(errors) == len(verify_release_artifacts.ARTIFACTS) + assert all("must record a SHA-256 digest" in error for error in errors) + + +def test_release_artifact_verifier_reports_missing_candidate_file(tmp_path): + record = { + "artifacts": { + name: "a" * 64 + for name in verify_release_artifacts.ARTIFACTS + }, + } + + errors = verify_release_artifacts.release_artifact_errors(record, tmp_path) + + assert len(errors) == len(verify_release_artifacts.ARTIFACTS) + assert all("release candidate artifact is missing" in error for error in errors) + + +def test_release_artifact_verifier_validates_ota_bundle_even_with_matching_digest( + tmp_path, +): + artifacts = tmp_path / "artifacts" + artifacts.mkdir() + (artifacts / "Bambutton-windows.zip").write_bytes(b"windows candidate") + (artifacts / "Bambutton-macos.zip").write_bytes(b"macOS candidate") + (artifacts / "bambutton-ota.json").write_bytes(b"not an OTA bundle") + record = { + "artifacts": { + "windows_setup_tool_sha256": hashlib.sha256(b"windows candidate").hexdigest(), + "macos_setup_tool_sha256": hashlib.sha256(b"macOS candidate").hexdigest(), + "ota_bundle_sha256": hashlib.sha256(b"not an OTA bundle").hexdigest(), + }, + } + + errors = verify_release_artifacts.release_artifact_errors(record, artifacts) + + assert len(errors) == 1 + assert errors[0].startswith("OTA bundle is invalid for the device:") diff --git a/tests/test_script_entrypoints.py b/tests/test_script_entrypoints.py new file mode 100644 index 0000000..e7c506e --- /dev/null +++ b/tests/test_script_entrypoints.py @@ -0,0 +1,78 @@ +import runpy +import sys +from pathlib import Path + +from bambutton import gui +from scripts import build_gui, run_main + + +def test_build_gui_main_invokes_pyinstaller_with_bundled_resources( + tmp_path, monkeypatch +): + entrypoint = tmp_path / "src" / "bambutton" / "gui.py" + entrypoint.parent.mkdir(parents=True) + entrypoint.write_text("# setup GUI") + micro_dir = tmp_path / "micro" + micro_dir.mkdir() + firmware_dir = tmp_path / "firmware" + firmware_dir.mkdir() + calls = [] + + monkeypatch.setattr(build_gui, "PROJECT_ROOT", tmp_path) + monkeypatch.setattr(build_gui, "ENTRYPOINT", entrypoint) + monkeypatch.setattr(build_gui, "MICRO_DIR", micro_dir) + monkeypatch.setattr(build_gui, "FIRMWARE_DIR", firmware_dir) + monkeypatch.setattr(build_gui.subprocess, "run", lambda *args, **kwargs: calls.append(args)) + + build_gui.main() + + command = calls[0][0] + assert command[:3] == [sys.executable, "-m", "PyInstaller"] + assert "--windowed" in command + assert str(entrypoint) == command[-1] + assert any("bambutton/micro" in item for item in command) + assert any("bambutton/firmware" in item for item in command) + + +def test_run_main_passes_selected_device_to_mpremote(tmp_path, monkeypatch): + main_file = tmp_path / "main.py" + main_file.write_text("# entrypoint") + calls = [] + monkeypatch.setattr(run_main, "MAIN_FILE", main_file) + monkeypatch.setattr(run_main.subprocess, "run", lambda command, check: calls.append(command)) + monkeypatch.setattr(sys, "argv", ["run_main.py", "--device", "/dev/test"]) + + run_main.main() + + assert calls == [["mpremote", "connect", "/dev/test", "run", str(main_file)]] + + +def test_run_main_reports_missing_entrypoint(tmp_path, monkeypatch): + monkeypatch.setattr(run_main, "MAIN_FILE", tmp_path / "missing.py") + monkeypatch.setattr(sys, "argv", ["run_main.py"]) + + try: + run_main.main() + except SystemExit as exc: + assert "main.py not found" in str(exc) + else: + raise AssertionError("Missing entrypoint should stop the command") + + +def test_package_entrypoint_calls_gui_main(monkeypatch): + calls = [] + monkeypatch.setattr(gui, "main", lambda: calls.append("package")) + + runpy.run_module("bambutton", run_name="__main__") + + assert calls == ["package"] + + +def test_legacy_config_gui_entrypoint_calls_gui_main(monkeypatch): + calls = [] + monkeypatch.setattr(gui, "main", lambda: calls.append("legacy")) + shim = Path(__file__).parents[1] / "src" / "bambutton_config_gui.py" + + runpy.run_path(str(shim), run_name="__main__") + + assert calls == ["legacy"]