diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fb54641..c012926 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,3 +8,4 @@ - [ ] Any new runtime file/dir is added to the `cp` list in `.github/workflows/pages.yml` - [ ] No new build step, framework, or runtime CDN dependency - [ ] If `devices.json` changed: `python scripts/validate_registry.py` passes +- [ ] If a firmware or config URL changed: it is the build the product repo's classic installer flashes, `config` points at the YAML that builds it, and the first (default) variant is the one most people need diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..aa5ce79 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +# Keep the SHA-pinned workflow actions current, like the product repos. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 3533fde..332af81 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -10,9 +10,10 @@ permissions: pages: write id-token: write +# Let an in-flight production deploy finish; a newer push queues behind it. concurrency: group: pages - cancel-in-progress: true + cancel-in-progress: false jobs: deploy: @@ -21,13 +22,23 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + # Network-free gate: a malformed registry (duplicate id, missing image, + # manifest outside its repo's Pages site) never reaches production, even + # when a PR merged with red or stale checks. + - name: Check registry (offline) + run: | + python scripts/test_validate_registry.py + python scripts/validate_registry.py --offline - name: Stage site files run: | mkdir _site cp -r index.html css js images assets vendor devices.json _site/ - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: _site - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 389532a..69336ed 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,38 +2,76 @@ name: Validate on: pull_request: + push: + branches: [main] # test the merged result, not just the PR head workflow_dispatch: schedule: - cron: "17 6 * * *" # nightly registry check +permissions: + contents: read + jobs: registry: runs-on: ubuntu-latest + permissions: + contents: read + issues: write # the nightly opens an issue when upstream firmware breaks steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Validator unit tests (offline) run: python scripts/test_validate_registry.py - name: Image script unit tests (offline) run: python scripts/test_fetch_images.py - - run: python scripts/validate_registry.py + - name: Registry checks (offline) + run: python scripts/validate_registry.py --offline + - name: Registry checks (live manifests, configs and installers) + shell: bash + run: python scripts/validate_registry.py 2>&1 | tee validate.log + # Nightly failures otherwise only email whoever last edited the cron line. + - name: Report nightly failure + if: failure() && github.event_name == 'schedule' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + title="Nightly registry check failed" + log=$(tail -n 40 validate.log 2>/dev/null) + body=$(printf 'The nightly registry check failed: %s\n\n```\n%s\n```\n' "$RUN_URL" "${log:-No validator output; an earlier step failed. See the run.}") + num=$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number // empty') + if [ -n "$num" ]; then + gh issue comment "$num" --body "$body" + else + gh issue create --title "$title" --body "$body" + fi ui-tests: - if: github.event_name == 'pull_request' + if: github.event_name != 'schedule' runs-on: ubuntu-latest defaults: run: working-directory: tests steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" + cache: npm + cache-dependency-path: tests/package-lock.json - run: npm ci - run: npx playwright install chromium --with-deps - run: npx playwright test + - name: Keep traces of failed tests + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: playwright-results + path: tests/test-results/ + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index d4394e7..939379c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,11 +3,13 @@ Static site, **no build step** — do not add bundlers, frameworks, or npm deps outside `tests/`. - Serve: `python -m http.server 8123` from repo root. -- Test: `cd tests && npx playwright test` (fails confusingly if run from repo root). Registry-driven suite. -- Validate registry: `python scripts/validate_registry.py` (network, ~60s). +- Test: `cd tests && npx playwright test` (fails confusingly if run from repo root). Registry-driven suite; it stubs GitHub, manifests and configs, so it runs offline. +- Python unit tests: `python scripts/test_validate_registry.py` and `python scripts/test_fetch_images.py` (offline). +- Validate registry: `python scripts/validate_registry.py` (network, ~15s); `--offline` for the shape checks alone. - `devices.json` is the single source of truth; views, scripts, and tests all read it. Device ids are lowercase-kebab and become URL hashes (`#/air-1`) and image filenames. -- Per-variant overrides in `devices.json` all follow one shape: `channel -> variant -> value`, listing only the variants that differ from the device-level field. `repos` overrides `repo`, `installers` overrides `githubPagesInstaller` (`null` hides the link), `platforms` overrides `platform` (`esphome` default, or `wled`, which selects the step-3 wording). `variantNotes` is the same shape but adds rather than overrides: a sentence or two under the variant toggle saying when to pick that variant. The device-level field stays the fallback, so a device that ships one ecosystem today needs no map. +- Every device needs `id`, `name`, `category` (hub filter pill), `description`, `image`, `wiki`, `repo`, and `firmware` (`channel -> variant -> manifest URL`), and normally `imageSource` (input to `fetch_images.py`) and `githubPagesInstaller`. `stable` is required and preferred; the first variant key is the default selection. Manifests must live on the variant's own repo's GitHub Pages site and be one of the builds that repo's classic installer page offers (the validator checks both, and that no two variants share a manifest; it can't catch two of the page's builds swapped between variants, so check the mapping by hand). Optional `config` has the same shape as `firmware` and points at the raw YAML that builds each manifest. +- Per-variant overrides in `devices.json` all follow one shape: `channel -> variant -> value`, listing only the variants that differ from the device-level field. `repos` overrides `repo`, `installers` overrides `githubPagesInstaller` (`null` hides the link), `platforms` overrides `platform`, which selects the step-3 wording: `esphome` (default), `wled`, or `standalone` (no network at all, e.g. the ornaments' Basic firmware: step 3 says there is nothing to add). `variantNotes` is the same shape but adds rather than overrides: a sentence or two under the variant toggle saying when to pick that variant. The device-level field stays the fallback, so a device that ships one ecosystem today needs no map. - **Deploy gotcha:** `.github/workflows/pages.yml` copies an explicit file list into `_site/`. Any new runtime file or directory must be added there or it 404s in production while working locally. - `vendor/esp-web-tools/` is a pinned upstream copy — never hand-edit. -- Security stance: registry fields are trusted repo content; runtime-fetched content (GitHub releases, manifest-derived filenames) must go through the existing escaping (`esc()`, `encodeURI`) in `js/views/device.js`. -- Async renders in `js/views/device.js` guard against stale writes (variant/device changed mid-fetch). If you add an async fetch there, follow the same pattern — a wrong-firmware link is this site's worst failure mode. +- Security stance: registry fields are trusted repo content (`description` and `variantNotes` are rendered as HTML); runtime-fetched content (GitHub releases, manifest-derived URLs and filenames, the URL hash) must be escaped with `esc()` or set via `textContent`, and manifest part URLs are parsed with `URL` and limited to https (see `js/views/device.js`). +- Async renders in `js/views/device.js` guard against stale writes (variant/device changed mid-fetch). If you add an async fetch there, follow the same pattern — a wrong-firmware link is this site's worst failure mode. Release notes are shown only when the release matches the manifest's version, for the same reason. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ae70ba2..d26f52a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ python -m http.server 8123 ``` -from the repo root, then open http://localhost:8123. No build step — there deliberately isn't one. localhost is a secure context, so real USB flashing works in Chrome/Edge. +from the repo root, then open http://localhost:8123. No build step — there deliberately isn't one. localhost is a secure context, so real USB flashing works in Chrome, Edge, or Firefox. ## Tests @@ -16,11 +16,25 @@ npm ci npx playwright test ``` -Run from `tests/`, not the repo root. PRs run this suite plus the registry validator (`python scripts/validate_registry.py`) in CI. +Run from `tests/`, not the repo root. The suite stubs GitHub, manifests and configs, so it runs offline and never spends GitHub API quota. + +From the repo root: + +``` +python scripts/test_validate_registry.py # validator unit tests (offline) +python scripts/test_fetch_images.py # image script unit tests (offline) +python scripts/validate_registry.py --offline # registry shape only, no network +python scripts/validate_registry.py # plus live manifests, configs, wiki and classic installers (~15s) +``` + +CI runs all of these on every PR and again on `main` after merge. The deploy re-runs the offline checks, so a malformed registry never reaches production. ## Adding a device -One entry in `devices.json` (id must be lowercase-kebab; `firmware.stable` pointing at the product repo's GitHub Pages manifest) + one image in `images/` (`python scripts/fetch_images.py` downloads it from the `imageSource` URL). The tests are registry-driven and cover new devices automatically. +1. **Pick the firmware.** Use the manifest(s) the product repo's own classic installer page flashes. When a repo publishes several builds, that is usually the improv-enabled `firmware-factory/` build, not `firmware/`: without improv the installer can't offer Wi-Fi setup after flashing. The validator fails if the registry points at a build the classic page doesn't offer, but it can't tell which of the page's builds belongs to which variant, so check that by hand. +2. **Add one entry to `devices.json`.** The id is lowercase-kebab; it becomes the URL hash (`#/air-1`) and the image filename. Set `config` to the YAML that builds that same manifest. The first variant listed is the one selected on load, so put support-only or legacy builds after it. For the optional per-variant fields (`variantNotes`, `platform`/`platforms`, `repos`, `installers`) see `AGENTS.md`. +3. **Add the image.** Run `python scripts/fetch_images.py `. Shopify sources are downscaled automatically; for any other source, save a ~600px JPEG as `images/.jpg` yourself and the script keeps it. +4. **Check it.** Run `python scripts/validate_registry.py` and the Playwright suite. The tests are registry-driven and visit every device and variant automatically. ## Rules that will save you a red X @@ -29,4 +43,4 @@ One entry in `devices.json` (id must be lowercase-kebab; `firmware.stable` point - Registry content (`devices.json`) is trusted; anything fetched at runtime (GitHub API, manifests) must be escaped/sanitized before touching the DOM — see `esc()` in `js/views/device.js`. - This site never builds or hosts firmware. Firmware changes belong in the product repos. -PRs target `main`. Merging deploys to https://apolloautomation.github.io/installer/. +PRs target `main`. Merging deploys to https://install.apolloautomation.com. diff --git a/README.md b/README.md index 90597bf..b4673d2 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Central browser installer for Apollo devices — https://install.apolloautomation.com - Hub of every flashable Apollo device → guided 3-step install wizard. -- Static site, no build step. Serve locally: `python -m http.server 8123` then open http://localhost:8123 (localhost is a secure context, so real flashing works in Chrome/Edge). +- Static site, no build step. Serve locally: `python -m http.server 8123` then open http://localhost:8123 (localhost is a secure context, so real flashing works in Chrome, Edge, or Firefox). - All device knowledge lives in `devices.json`. Adding a device = one JSON entry + one image. - Firmware comes from each product repo's existing GitHub Pages manifests — this repo never builds or hosts firmware. - Existing per-repo installer pages stay standalone; this site is the promoted front door, they are the fallback. -Design spec: `docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md` +How to add a device and run the checks: `CONTRIBUTING.md`. Original design (historical): `docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md` diff --git a/css/style.css b/css/style.css index 910e5bd..e8e1151 100644 --- a/css/style.css +++ b/css/style.css @@ -1,6 +1,7 @@ :root { --blue: #417AAB; - --blue-dark: #35638b; + --link: #35638b; /* text links: >= 4.5:1 on the ground and the tinted boxes */ + --fill: #417AAB; /* filled controls with white text */ --green: #9ABD32; --ink: #22303c; --dim: #5c6b7a; @@ -12,10 +13,12 @@ --bg-green-tint: #f5f9ec; --bg-error: #faf0ed; --border-error: #c0563c; + color-scheme: light; } [data-theme="dark"] { --blue: #5b9fd4; - --blue-dark: #4a87bb; + --link: #5b9fd4; + --fill: #2f6fa3; /* #5b9fd4 is only 2.9:1 under white text */ --green: #a8cf35; --ink: #e4edf5; --dim: #8fa0b3; @@ -27,13 +30,14 @@ --bg-green-tint: #0c1a06; --bg-error: #1e0e0a; --border-error: #d06550; + color-scheme: dark; } * { box-sizing: border-box; } body { margin: 0; background: var(--ground); color: var(--ink); font-family: "Segoe UI", system-ui, -apple-system, sans-serif; line-height: 1.55; } -a { color: var(--blue); } +a { color: var(--link); } /* Branded keyboard-focus ring (only on keyboard focus, not mouse clicks). Outline follows each control's own border-radius, so pills stay round. */ a:focus-visible, @@ -42,12 +46,14 @@ summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } +/* Headings get programmatic focus on route changes; no ring for those. */ +[tabindex="-1"]:focus { outline: none; } .site-header { - display: flex; justify-content: space-between; align-items: center; + display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px 16px; padding: 14px 24px; background: var(--panel); border-bottom: 1px solid var(--line); } -.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; text-decoration: none; color: var(--blue); font-size: 1.05rem; } -.site-header nav { display: flex; gap: 18px; align-items: center; } +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; text-decoration: none; color: var(--blue); font-size: 1.05rem; white-space: nowrap; } +.site-header nav { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; } .site-header nav a { color: var(--dim); text-decoration: none; } .theme-btn { position: relative; width: 30px; height: 30px; border: none; padding: 0; cursor: pointer; @@ -67,8 +73,9 @@ summary:focus-visible { backing it already has on the (white) light-mode header so it stays legible. */ [data-theme="dark"] .theme-btn { background-color: #fff; border-radius: 6px; } main { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; } -.site-footer { text-align: center; color: var(--dim); font-size: .85rem; padding: 24px; border-top: 1px solid var(--line); } .loading { color: var(--dim); } +.hint { color: var(--dim); font-size: .85rem; } +.notice { border-left: 3px solid var(--blue); background: var(--bg-blue-tint); border-radius: 0 8px 8px 0; padding: 10px 14px; margin: 0 0 18px; } /* Hub */ .hero h1 { font-size: 1.9rem; margin: 0 0 6px; letter-spacing: -.01em; } @@ -78,14 +85,15 @@ main { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; } border: 1px solid var(--line); background: var(--panel); color: var(--dim); border-radius: 999px; padding: 5px 14px; font-size: .85rem; cursor: pointer; } -.filters button.active { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; } +.filters button.active { background: var(--fill); border-color: var(--fill); color: #fff; font-weight: 600; } .device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; } .device-card { display: block; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; text-decoration: none; color: inherit; transition: border-color .15s, box-shadow .15s; } .device-card:hover { border-color: var(--blue); box-shadow: 0 4px 14px -8px rgba(65,122,171,.5); } -.device-card img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; background: var(--img-bg); } +/* contain, not cover: a few product shots aren't square and carry spec text at the edges. */ +.device-card img { width: 100%; aspect-ratio: 1; object-fit: contain; border-radius: 6px; background: var(--img-bg); } .device-card h3 { margin: 10px 0 2px; font-size: 1rem; } .device-card p { margin: 0; color: var(--dim); font-size: .82rem; } .device-card .go { color: var(--blue); font-weight: 600; font-size: .85rem; margin-top: 8px; display: inline-block; } @@ -93,24 +101,28 @@ main { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; } /* Device wizard */ .device-page .back { display: inline-block; margin-bottom: 16px; text-decoration: none; color: var(--dim); } .device-head { display: flex; gap: 20px; align-items: center; margin-bottom: 20px; } -.device-head img { width: 110px; height: 110px; object-fit: contain; background: var(--img-bg); border: 1px solid var(--line); border-radius: 10px; } +.device-head img { width: 110px; height: 110px; flex: none; object-fit: contain; background: var(--img-bg); border: 1px solid var(--line); border-radius: 10px; } .device-head h1 { margin: 0; font-size: 1.6rem; } .device-head p { margin: 2px 0 6px; color: var(--dim); } .device-head .links { font-size: .85rem; } .step { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; margin-bottom: 14px; } .step h2 { margin: 0 0 10px; font-size: 1.05rem; display: flex; align-items: center; gap: 10px; } .step h2 .num { - width: 26px; height: 26px; border-radius: 50%; background: var(--blue); color: #fff; + width: 26px; height: 26px; border-radius: 50%; background: var(--fill); color: #fff; display: inline-grid; place-items: center; font-size: .85rem; flex: none; } .picker { display: flex; gap: 24px; flex-wrap: wrap; } -.picker .group label { display: block; font-size: .78rem; color: var(--dim); margin-bottom: 5px; text-transform: uppercase; letter-spacing: .04em; } +.picker .seg-label { display: block; font-size: .78rem; color: var(--dim); margin-bottom: 5px; text-transform: uppercase; letter-spacing: .04em; } .seg { display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } .seg button { border: 0; background: var(--panel); color: var(--dim); padding: 7px 16px; font-size: .88rem; cursor: pointer; } -.seg button.active { background: var(--blue); color: #fff; font-weight: 600; } +.seg button.active { background: var(--fill); color: #fff; font-weight: 600; } +/* .seg clips overflow for its rounded corners, so draw the focus ring inside the button. */ +.seg button:focus-visible { outline-offset: -4px; outline-color: currentColor; } .variant-note { color: var(--dim); font-size: .88rem; margin: 8px 0 4px; max-width: 60ch; } +.single-note { margin: 0 0 4px; font-size: inherit; } +.install-hint { margin: 10px 0 0; } esp-web-install-button button, .install-btn { - background: var(--blue); color: #fff; border: 0; border-radius: 8px; + background: var(--fill); color: #fff; border: 0; border-radius: 8px; padding: 10px 22px; font-size: .95rem; font-weight: 700; cursor: pointer; } .fallback { border-left: 3px solid var(--blue); background: var(--bg-blue-tint); border-radius: 0 8px 8px 0; padding: 12px 16px; } @@ -120,7 +132,7 @@ esp-web-install-button button, .install-btn { .release-notes pre { white-space: pre-wrap; font-family: inherit; margin: 8px 0; } .done-check { color: var(--green); font-weight: 700; } .customize { margin: 4px 0 14px; font-size: .9rem; } -.customize summary { cursor: pointer; color: var(--blue); font-weight: 600; } +.customize summary { cursor: pointer; color: var(--link); font-weight: 600; } .customize p { margin: 8px 0 0; color: var(--dim); } .customize code { background: var(--bg-blue-tint); padding: 1px 5px; border-radius: 4px; } .error-box { border-left: 3px solid var(--border-error); background: var(--bg-error); border-radius: 0 8px 8px 0; padding: 12px 16px; } @@ -133,6 +145,20 @@ esp-web-install-button button, .install-btn { .config-yaml { margin: 0; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; overflow: auto; max-height: 340px; } .config-yaml code { font-family: ui-monospace, Consolas, monospace; font-size: .8rem; line-height: 1.5; white-space: pre; color: var(--ink); } .config-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 12px; } -.config-download { background: var(--blue); color: #fff; border: 0; border-radius: 8px; padding: 9px 18px; font-size: .9rem; font-weight: 700; cursor: pointer; } +.config-download { background: var(--fill); color: #fff; border: 0; border-radius: 8px; padding: 9px 18px; font-size: .9rem; font-weight: 700; cursor: pointer; } .config-download:disabled { opacity: .55; cursor: default; } -.config-github { font-size: .86rem; font-weight: 600; } +.config-github, .config-folder { font-size: .86rem; font-weight: 600; } + +/* Phones: tighter gutters, two device cards per row, and a header that wraps + instead of pushing the page sideways. */ +@media (max-width: 480px) { + .site-header { padding: 10px 16px; } + .site-header nav { gap: 14px; font-size: .92rem; } + main { padding: 24px 16px 48px; } + .hero h1 { font-size: 1.6rem; } + .device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; } + .device-card { padding: 10px; } + .device-head { gap: 14px; } + .device-head img { width: 84px; height: 84px; } + .step { padding: 16px; } +} diff --git a/devices.json b/devices.json index f690574..25f6e5c 100644 --- a/devices.json +++ b/devices.json @@ -84,6 +84,12 @@ "WiFi": "https://raw.githubusercontent.com/ApolloAutomation/R_PRO-1/main/Integrations/ESPHome/R_PRO-1_W.yaml", "Ethernet": "https://raw.githubusercontent.com/ApolloAutomation/R_PRO-1/main/Integrations/ESPHome/R_PRO-1_ETH.yaml" } + }, + "variantNotes": { + "stable": { + "WiFi": "Connects over Wi-Fi. After installing, the installer asks for your Wi-Fi network.", + "Ethernet": "For a wired or PoE connection, with no Wi-Fi. Connect the Ethernet cable; the installer won't ask for a Wi-Fi network." + } } }, { @@ -272,6 +278,13 @@ "stable": { "WLED 16.0.1 (Rev6 only)": null } + }, + "variantNotes": { + "stable": { + "WLED 16.0.1 (Rev6 only)": "For Rev6 controllers. The revision is printed on the back of the board; on a Rev4, pick WLED-MM 14.5.1 (Rev4) instead.", + "WLED-MM 14.5.1 (Rev6)": "The earlier WLED-MM firmware, for Rev6 controllers.", + "WLED-MM 14.5.1 (Rev4)": "For Rev4 controllers, which can't run WLED 16.0.1." + } } }, { @@ -296,7 +309,7 @@ }, "variantNotes": { "stable": { - "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects, run automations, use it as a Bluetooth proxy. Sleeps between button presses to save battery." + "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects and run automations. It stays awake by default; turn off Prevent Sleep in Home Assistant to save battery between button presses." } } }, @@ -324,8 +337,13 @@ }, "variantNotes": { "stable": { - "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects, run automations, use it as a Bluetooth proxy. Sleeps between button presses to save battery.", - "Basic (No Wi-Fi)": "The firmware the ornament ships with. No Wi-Fi or Home Assistant; the buttons play the built-in tunes. Install this to put the ornament back to stock." + "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects and run automations. After installing, restart it (remove power for a moment), then join the open Apollo H2 Hotspot Wi-Fi network from your phone to pick your Wi-Fi. It stays awake by default; turn off Prevent Sleep in Home Assistant to save battery between button presses.", + "Basic (No Wi-Fi)": "The firmware the ornament ships with. No Wi-Fi or Home Assistant: the buttons play the built-in tunes, and it sleeps between presses to save battery. Install this to put the ornament back to stock." + } + }, + "platforms": { + "stable": { + "Basic (No Wi-Fi)": "standalone" } } }, @@ -353,8 +371,13 @@ }, "variantNotes": { "stable": { - "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects, run automations, use it as a Bluetooth proxy. Sleeps between button presses to save battery.", - "Basic (No Wi-Fi)": "The firmware the ornament ships with. No Wi-Fi or Home Assistant; the buttons play the built-in tunes. Install this to put the ornament back to stock." + "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects, run automations, and turn on its Bluetooth Proxy switch to use it as a Bluetooth proxy. It stays awake by default; turn off Prevent Sleep in Home Assistant to save battery between button presses.", + "Basic (No Wi-Fi)": "The firmware the ornament ships with. No Wi-Fi or Home Assistant: the buttons play the built-in tunes, and it sleeps between presses to save battery. Install this to put the ornament back to stock." + } + }, + "platforms": { + "stable": { + "Basic (No Wi-Fi)": "standalone" } } } diff --git a/docs/m1-wled-migration.md b/docs/m1-wled-migration.md index 6725296..2e7d679 100644 --- a/docs/m1-wled-migration.md +++ b/docs/m1-wled-migration.md @@ -10,15 +10,17 @@ The M-1 offers two WLED firmwares in the installer, both served from GitHub Page WLED 16.0.1 is the default. It is a single merged full-install image (bootloader, partition table, otadata, application, and LittleFS factory config) built from ApolloAutomation/WLED-M1. -Its manifest sets new_install_prompt_erase, so esp-web-tools prompts a full erase, which is what -guarantees a factory-fresh 64x64 boot. It is offered for Rev6 only, so Rev4 owners must switch -to the WLED-MM 14.5.1 (Rev4) variant. +The full image is what gives a factory-fresh 64x64 boot: it covers flash up to 0xff0000, leaving +only the coredump partition. Its manifest sets new_install_prompt_erase, which makes esp-web-tools +*offer* a full erase as an unticked checkbox rather than always erasing. It is offered for Rev6 +only, so Rev4 owners must switch to the WLED-MM 14.5.1 (Rev4) variant; the `variantNotes` under the +toggle say where the revision is printed. The M-1 entry sets `"platform": "wled"`, so step 3 of the wizard gives the WLED onboarding (open Apollo M-1 hotspot at 4.3.2.1, discovered WLED integration, WLED web UI for effects and -manual OTA) instead -of the ESPHome Dashboard "Take control" flow. The hardware can run ESPHome, we just do not offer -an ESPHome build yet. When we do, add that manifest as another variant and mark it in a +manual OTA) instead of the ESPHome Device Builder "Take control" flow. Step 3 does not name an OTA +file, because the only `_ota.bin` published (WLED-M1's M-1_ota.bin) is a Rev6 image. The hardware +can run ESPHome, we just do not offer an ESPHome build yet. When we do, add that manifest as another variant and mark it in a `platforms` map (`"platforms": { "stable": { "": "esphome" } }`); step 3 then switches back to the ESPHome instructions whenever that variant is selected, and stays WLED for the rest. @@ -26,8 +28,14 @@ Because the default variant has no classic installer page of its own, the M-1 pa the "Classic installer" link hidden and the header GitHub link pointing at WLED-M1; both come back when a 14.5.1 variant is selected. -Customers already on WLED-MM 14.5.1 can OTA to 16.0.1 with the M-1_ota.bin app-only image -(same partition table preserves settings); the installer only serves the first-flash full image. +Rev6 owners already on WLED-MM 14.5.1 can OTA to 16.0.1 with the M-1_ota.bin app-only image +(same partition table preserves settings). Rev4 owners must not: M-1_ota.bin uses the Rev6 +(MOONHUB) pinout. The installer only serves the first-flash full image. + +Release notes: WLED-MM-M1's only GitHub release (25.7.14.1) predates both builds its manifests +serve (25.8.19.1 for Rev4, 25.10.9.1 for Rev6), so the installer shows "Installs firmware version +..." for those variants instead of that release's notes. Publishing releases for those versions +brings the notes back with no installer change. Hosting: WLED-MM-M1 serves the 14.5.1 manifests from committed files via legacy branch Pages. WLED-M1 serves the 16.0.1 manifest through an Actions Pages workflow @@ -36,9 +44,11 @@ from the latest GitHub release, so the 16MB image never enters git history. ## Setup hotspot (verified against the shipped images, not WLED defaults) -Both the 16.0.1 and 14.5.1 Apollo builds broadcast an **open** hotspot, not the stock WLED -`WLED-AP` / `wled1234` pair. The string `wled1234` is absent from `M-1_full_install.bin` and from +Both the 16.0.1 and 14.5.1 Apollo builds broadcast an **open** hotspot named `Apollo M-1`, not +the stock WLED `WLED-AP` / `wled1234` pair. The string `wled1234` is absent from `M-1_full_install.bin` and from `Apollo_M-1_Rev6_14.5.1.bin`, while the sibling default `wledota` is present in both, so `apPass` is empty. The m1-b8 release notes state it directly: "STEP 1: join the open Apollo M-1 WiFi", and list "cleaner hotspot name: the setup WiFi is now just Apollo M-1 (no serial-number suffix)" as new -in that build, which is why step 3 says older builds carry a suffix. Setup address is 4.3.2.1. +in that build. That compares b8 with the earlier 16.0.1 bundles (b1-b7); the WLED-MM 14.5.1 build +never added a suffix (its env sets `WLED_AP_SSID` without `WLED_AP_SSID_UNIQUE`), so step 3 names +plain `Apollo M-1`. Setup address is 4.3.2.1. diff --git a/docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md b/docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md index 83aa18c..fc3903d 100644 --- a/docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md +++ b/docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md @@ -1,5 +1,11 @@ # Apollo Web Installer — Design +> **Historical.** This is the original design. For current behavior see AGENTS.md, CONTRIBUTING.md +> and the code. Since then: Firefox flashes too (it ships WebSerial), TEMP_PRO-1 is in the +> registry, the first-install build is whichever one the product repo's classic installer flashes +> (often `firmware-factory/`, not `firmware/`), and `apolloautomation.github.io/installer/` now +> redirects to production, so it is no longer a staging URL. + Date: 2026-07-08 Status: Approved by Brandon (direction: A+B hybrid — Fleet Grid hub + per-device mini-wizard) diff --git a/docs/superpowers/specs/2026-07-09-yaml-view-download-design.md b/docs/superpowers/specs/2026-07-09-yaml-view-download-design.md index 25df585..2f92394 100644 --- a/docs/superpowers/specs/2026-07-09-yaml-view-download-design.md +++ b/docs/superpowers/specs/2026-07-09-yaml-view-download-design.md @@ -1,5 +1,9 @@ # YAML View / Download on Device Pages — Design +> **Historical.** Every product config `!include`s sibling files (`Core.yaml` and others), so a +> downloaded single file can't be built on its own. The page now says to build from the whole +> folder and links it. + Date: 2026-07-09 Status: Approved by Brandon (2026-07-09) Issue: #3 diff --git a/js/app.js b/js/app.js index a6e4c39..16592d9 100644 --- a/js/app.js +++ b/js/app.js @@ -4,28 +4,44 @@ import { renderDevice } from './views/device.js'; import { initThemeToggle } from './theme.js'; const app = document.getElementById('app'); +const siteTitle = document.title; -async function route() { +function hashId() { + const raw = location.hash.replace(/^#\/?/, '').replace(/\/$/, ''); + try { return decodeURIComponent(raw).toLowerCase(); } catch { return raw.toLowerCase(); } +} + +async function route(e) { let registry; try { registry = await loadRegistry(); } catch (err) { app.innerHTML = `
-

Something went wrong loading the installer. ${err.message}

+

Something went wrong loading the installer.

Try reloading. If it keeps happening, every device also has a standalone installer linked from its GitHub repository.

`; + app.querySelector('.error-detail').textContent = err.message; return; } - const id = location.hash.replace(/^#\/?/, '').replace(/\/$/, ''); + // Ids are lowercase-kebab; #/AIR-1 and #/air-1/ reach the same page. + const id = hashId(); const device = registry.devices.find((d) => d.id === id); if (device) { renderDevice(app, device); + document.title = `${device.name} · ${siteTitle}`; } else { - renderHub(app, registry); + renderHub(app, registry, id); + document.title = siteTitle; } window.scrollTo(0, 0); + // On in-app navigation, move focus to the new page's heading so keyboard and + // screen-reader users start at what changed instead of a detached link. + if (e) { + const h1 = app.querySelector('h1'); + if (h1) { h1.tabIndex = -1; h1.focus({ preventScroll: true }); } + } } window.addEventListener('hashchange', route); diff --git a/js/registry.js b/js/registry.js index 85e4e11..e8e187a 100644 --- a/js/registry.js +++ b/js/registry.js @@ -2,9 +2,16 @@ let cache = null; export async function loadRegistry() { if (cache) return cache; - const res = await fetch('devices.json'); + // no-cache revalidates (cheap, ETag) instead of trusting Pages' 10-minute + // max-age, so a registry fix reaches the next page load. + const res = await fetch('devices.json', { cache: 'no-cache' }); if (!res.ok) throw new Error(`Could not load the device list (HTTP ${res.status}).`); - const reg = await res.json(); + let reg; + try { + reg = await res.json(); + } catch { + throw new Error('The device list could not be read.'); + } if (!Array.isArray(reg.devices) || reg.devices.length === 0) { throw new Error('The device list is empty or malformed.'); } diff --git a/js/release-notes.js b/js/release-notes.js index 5fc7381..53ef5b5 100644 --- a/js/release-notes.js +++ b/js/release-notes.js @@ -1,4 +1,19 @@ -export async function fetchReleaseNotes(repo, channel) { +// One GitHub API lookup per repo+channel for the page session: variants that share +// a repo reuse it, which keeps well under the unauthenticated 60 requests/hour/IP. +// A failed lookup is evicted so a later render can retry. +const memo = new Map(); + +export function fetchReleaseNotes(repo, channel) { + const key = `${repo}|${channel}`; + if (!memo.has(key)) { + const p = load(repo, channel); + p.catch(() => memo.delete(key)); + memo.set(key, p); + } + return memo.get(key); +} + +async function load(repo, channel) { const url = channel === 'stable' ? `https://api.github.com/repos/${repo}/releases/latest` : `https://api.github.com/repos/${repo}/releases?per_page=15`; @@ -7,5 +22,5 @@ export async function fetchReleaseNotes(repo, channel) { const data = await res.json(); const rel = channel === 'stable' ? data : data.find((r) => r.prerelease); if (!rel) throw new Error('no release found for channel'); - return { name: rel.name || rel.tag_name, body: rel.body || '', url: rel.html_url }; + return { name: rel.name || rel.tag_name, tag: rel.tag_name, body: rel.body || '', url: rel.html_url }; } diff --git a/js/theme.js b/js/theme.js index 267c5cb..0539dc2 100644 --- a/js/theme.js +++ b/js/theme.js @@ -38,7 +38,8 @@ export function initThemeToggle() { function updateBtn(pref) { // The dog is the CSS background; the small badge shows the current mode. if (badge) badge.textContent = ICONS[pref]; - btn.setAttribute('aria-label', LABELS[pref]); + // Name the current mode and what a click does; the title alone is not announced. + btn.setAttribute('aria-label', `${LABELS[pref]}. ${NEXT_LABEL[pref]}`); btn.title = NEXT_LABEL[pref]; } diff --git a/js/views/device.js b/js/views/device.js index a5c915c..d9f87c3 100644 --- a/js/views/device.js +++ b/js/views/device.js @@ -10,6 +10,12 @@ function rawToBlob(raw) { const m = raw.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/); return m ? `https://github.com/${m[1]}/${m[2]}/blob/${m[3]}/${m[4]}` : raw; } +// The folder a raw config lives in, as a GitHub tree link. Configs !include their +// sibling files (Core.yaml and friends), so a rebuild needs the whole folder. +function rawToTree(raw) { + const m = raw.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)\/[^/]+$/); + return m ? `https://github.com/${m[1]}/${m[2]}/tree/${m[3]}/${m[4]}` : rawToBlob(raw); +} function configBasename(url) { return (url.split('/').pop() || 'config.yaml').replace(/[^\w.\-]/g, '_'); } @@ -21,6 +27,40 @@ function downloadText(text, filename) { document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } +function fileName(url) { + const last = url.pathname.split('/').pop(); + try { return decodeURIComponent(last); } catch { return last; } +} + +// Manifests are read by the release-notes check and by the no-WebSerial file +// list. One request per URL for the page session; a failure is evicted so a +// later render can retry. +const manifests = new Map(); +function fetchManifest(url) { + if (!manifests.has(url)) { + const p = fetch(url).then((res) => { + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }); + p.catch(() => manifests.delete(url)); + manifests.set(url, p); + } + return manifests.get(url); +} + +// Release notes describe the flashed build only if the release is tagged with the +// manifest's version or names it (WLED-M1 tags bundles like m1-b8 but its notes +// say "v16.0.1"). Otherwise "What's new" would show some other build's notes. +// URLs are ignored: GitHub's "Full Changelog" compare link names the previous +// version, and 16.0.1 must not match 16.0.10 or 16.0.1-beta. +function releaseMatches(rel, version) { + if (!version) return true; + const v = String(version); + if (rel.tag === v || rel.tag === `v${v}`) return true; + const e = v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`(^|[^\\w.])v?${e}(?![.-]?\\w|\\.\\.)`); + return re.test(`${rel.name}\n${rel.body}`.replace(/https?:\/\/\S+/g, '')); +} function selectedManifest(device, channel, variant) { return device.firmware[channel][variant]; @@ -35,10 +75,6 @@ function classicInstallerFor(device, channel, variant) { return map && variant in map ? map[variant] : device.githubPagesInstaller; } -// Firmware ecosystem for a variant. Mirrors `repos`/`installers`: an optional -// per-variant `platforms` map wins, then the device-level `platform`, then -// esphome. A device offering both WLED and ESPHome builds (the M-1 could) marks -// only the variants that differ from its device-level default. // Optional per-variant explanation shown under the variant toggle, same // `channel -> variant -> text` shape as `repos`/`installers`. Registry content, // so it is trusted HTML like `description`. @@ -47,37 +83,51 @@ function variantNoteFor(device, channel, variant) { return (map && map[variant]) || ''; } +// Firmware ecosystem for a variant. Mirrors `repos`/`installers`: an optional +// per-variant `platforms` map wins, then the device-level `platform`, then +// esphome. A device offering both WLED and ESPHome builds (the M-1 could) marks +// only the variants that differ from its device-level default. function platformFor(device, channel, variant) { const map = device.platforms && device.platforms[channel]; if (map && variant in map) return map[variant]; return device.platform || 'esphome'; } -// Step 3 differs by ecosystem: ESPHome devices are adopted through the ESPHome -// integration and Device Builder, WLED devices through the WLED integration and the -// WLED web UI. +// Step 3 differs by what the firmware does once installed: ESPHome devices are +// adopted through the ESPHome integration and Device Builder, WLED devices through +// the WLED integration and the WLED web UI, and standalone builds (no network at +// all, like the ornaments' stock firmware) have nothing to add. function stepThreeHtml(device, platform) { - if (platform === 'wled') { + if (platform === 'standalone') { return ` +

3 You're done

+

This firmware doesn't use Wi-Fi, so there's nothing to add to Home Assistant. + Unplug the ${device.name} from the computer and restart it: if it has batteries in, + take one out for a few seconds. After that it works on its own. To use it with + Home Assistant later, come back and install a Wi-Fi firmware. + ✓

`; + } + const heading = '

3 Add to Home Assistant

'; + if (platform === 'wled') { + return heading + `

If you did not set Wi-Fi during install, join the open - Apollo ${device.name} Wi-Fi hotspot the device broadcasts (builds before - 16.0.1 add a serial-number suffix to the name), then open 4.3.2.1 in a - browser and pick your network. On 16.0.1 the panel itself shows a setup card with that - address and a QR code. Once it is on your Wi-Fi, go to + Apollo ${device.name} Wi-Fi hotspot the device broadcasts, then open + 4.3.2.1 in a browser and pick your network. On 16.0.1 the panel itself + shows a setup card with that address and a QR code. Once it is on your Wi-Fi, go to Settings → Devices & services in Home Assistant, where it appears as a discovered WLED device. Click Configure, and you're done. ✓

Want to customize the firmware?

The ${device.name} runs WLED, so effects and settings are customized in WLED itself. - Open its web UI at the device's IP address (or http://wled.local), - or use the WLED mobile app, to change effects, colors, presets and segments. - Later firmware updates install from that UI under - Config → Security & Updates → Manual OTA Update using the - _ota.bin asset from the latest release.

+ Open its web UI at the device's IP address, or use the WLED mobile app, to change + effects, colors, presets and segments. Firmware updates install from that UI under + Config → Security & Updates → Manual OTA Update. Only use an + update file built for your firmware and board revision; check the setup guide + before updating.

`; } - return ` + return heading + `

After installing, the device broadcasts itself on your network. In Home Assistant go to Settings → Devices & services — it appears as a discovered ESPHome device. Click Configure, and you're done. @@ -87,7 +137,7 @@ function stepThreeHtml(device, platform) {

Apollo firmware ships with dashboard_import, so the device also shows up in the ESPHome Device Builder (or the ESPHome Device Builder app in Home Assistant) under Discovered. Click Take control to pull its configuration - into the Device Builder, then edit it and flash updates over Wi-Fi.

+ into the Device Builder, then edit it and flash updates over the network.

`; } @@ -95,8 +145,8 @@ function segHtml(id, label, keys, active, dataAttr) { if (keys.length < 2) return ''; return `
- - + ${label} + ${keys.map((k) => ``).join('')}
`; @@ -113,7 +163,7 @@ export function renderDevice(el, device) {
← All devices
- ${device.name} +

${device.name}

${device.description}

@@ -129,7 +179,7 @@ export function renderDevice(el, device) {
${channels.length < 2 && Object.keys(device.firmware[channel]).length < 2 - ? '

One firmware for this device — nothing to choose here.

' : ''} + ? '

One firmware for this device — nothing to choose here.

' : ''}
@@ -140,7 +190,6 @@ export function renderDevice(el, device) {
-

3 Add to Home Assistant

Full ${device.name} setup guide on the wiki →

@@ -212,18 +261,19 @@ export function renderDevice(el, device) { -

+

Plug the device into this computer with a USB data cable, click the button, and pick the serial port.

`; } } else { installSlot.innerHTML = `
This browser can't flash over USB. -

Installing from the browser needs Chrome, Edge, or Firefox. You can still install manually:

+

Installing from the browser needs Chrome, Edge, or Firefox on a computer. You can still install manually:

  • Loading firmware file list…
  • Flash it with ESPHome Web (open it in Chrome, Edge, or Firefox) or - esptool write-flash --port <port> 0x0 <file>.
  • + esptool write-flash --port <port> <offset> <file>, using the offset + listed next to each file (only the files for your board's chip, if several are listed). ${classic ? `
  • Or use the classic installer page in Chrome, Edge, or Firefox.
  • ` : ''}
`; @@ -232,13 +282,19 @@ export function renderDevice(el, device) { // instead of the new device's freshly rendered #fallback-files. const filesEl = el.querySelector('#fallback-files'); try { - const res = await fetch(manifest); - if (!res.ok) throw new Error('HTTP ' + res.status); - const m = await res.json(); + const m = await fetchManifest(manifest); if (epoch !== myEpoch) return; // selection changed mid-fetch - const files = m.builds.flatMap((b) => b.parts.map((p) => new URL(p.path, manifest).href)); - filesEl.innerHTML = - files.map((f) => `
  • ${f.split('/').pop().replace(/[<>&"']/g, '')}
  • `).join(''); + // Part paths are runtime data: resolve them against the manifest, keep + // only https links, and escape what goes into the markup. A manifest + // with builds for several chips labels each file with its chip. + const several = m.builds.length > 1; + const parts = m.builds + .flatMap((b) => b.parts.map((p) => ({ + chip: b.chipFamily, url: new URL(p.path, manifest), offset: Number(p.offset) || 0 }))) + .filter((p) => p.url.protocol === 'https:'); + if (!parts.length) throw new Error('no firmware files'); + filesEl.innerHTML = parts.map((p) => `
  • ${several ? `${esc(p.chip)}: ` : ''}${esc(fileName(p.url))} + at 0x${p.offset.toString(16)}
  • `).join(''); } catch { if (epoch !== myEpoch) return; // selection changed mid-fetch filesEl.innerHTML = @@ -252,22 +308,43 @@ export function renderDevice(el, device) { const slot = el.querySelector('#release-slot'); const myEpoch = epoch; const repo = repoFor(device, channel, variant); - try { - const rel = await fetchReleaseNotes(repo, channel); - if (epoch !== myEpoch) return; // selection changed mid-fetch - const url = /^https:\/\/github\.com\//.test(rel.url) ? rel.url : `https://github.com/${repo}/releases`; + const manifestUrl = selectedManifest(device, channel, variant); + // Never leave the previous selection's notes, build number or "Full release" + // link next to this variant's install button while its own load. Both lookups + // are memoized, so a revisited variant replaces this before it is painted. + const key = `${repo}|${channel}|${manifestUrl}`; + if (slot.dataset.key !== key) { + slot.dataset.key = key; + slot.innerHTML = '
    Loading release notes…
    '; + } + const [rel, man] = await Promise.allSettled([ + fetchReleaseNotes(repo, channel), + fetchManifest(manifestUrl), + ]); + if (epoch !== myEpoch) return; // selection changed mid-fetch + const version = man.status === 'fulfilled' ? man.value.version : null; + if (rel.status === 'fulfilled' && releaseMatches(rel.value, version)) { + const r = rel.value; + const url = /^https:\/\/github\.com\//.test(r.url) ? r.url : `https://github.com/${repo}/releases`; slot.innerHTML = `
    - What's new in ${esc(rel.name)} -
    ${esc(rel.body)}
    + What's new in ${esc(r.name)} +
    ${esc(r.body)}
    Full release →
    `; - } catch { - if (epoch !== myEpoch) return; // selection changed mid-fetch + } else if (rel.status === 'fulfilled') { + // The repo's latest release is a different build; don't present its notes + // or assets as this firmware's. + slot.innerHTML = ` +
    + Installs build ${esc(version)}. +
    `; + } else { slot.innerHTML = `
    + ${version ? `Installs build ${esc(version)}.` : ''} See recent releases for what's new.
    `; @@ -283,12 +360,14 @@ export function renderDevice(el, device) {
    Build or reflash this firmware yourself

    The ESPHome config for the ${variant} variant - (${filename}). Rebuilding from this keeps the device's onboarding, so the - ${device.name} wiki setup steps still apply.

    + (${filename}). It pulls in other files from the same folder, so build it + from a copy of the whole folder, not this file alone. The + ${device.name} wiki has the setup steps.

    Loading config…
    `; const codeEl = slot.querySelector('.config-yaml code'); @@ -312,6 +391,7 @@ export function renderDevice(el, device) { if (chanSeg) chanSeg.addEventListener('click', (e) => { const b = e.target.closest('button[data-channel]'); if (!b) return; + if (b.dataset.channel === channel) return; // already selected — keep the chosen variant channel = b.dataset.channel; variant = Object.keys(device.firmware[channel])[0]; epoch++; diff --git a/js/views/hub.js b/js/views/hub.js index f3a4ae9..fad8345 100644 --- a/js/views/hub.js +++ b/js/views/hub.js @@ -1,11 +1,12 @@ // js/views/hub.js — device grid with category filters -export function renderHub(el, registry) { +export function renderHub(el, registry, missingId) { const cats = [...new Set(registry.devices.map((d) => d.category))]; el.innerHTML = `

    Flash your Apollo device from the browser

    Plug in your device via USB, choose your device below, and you'll be running the latest firmware in minutes!

    + ${missingId ? '

    ' : ''}
    ${cats.map((c) => ``).join('')} @@ -13,13 +14,19 @@ export function renderHub(el, registry) {
    ${registry.devices.map((d) => ` - ${d.name} +

    ${d.name}

    ${d.description}

    - Install → + Install
    `).join('')}
    `; + // The hash is user input; textContent keeps it out of the markup. + if (missingId) { + el.querySelector('#hub-notice').textContent = + `There's no device called "${missingId}" here. Pick yours below.`; + } + el.querySelector('.filters').addEventListener('click', (e) => { const btn = e.target.closest('button[data-cat]'); if (!btn) return; diff --git a/scripts/fetch_images.py b/scripts/fetch_images.py index 90039ab..1d64080 100644 --- a/scripts/fetch_images.py +++ b/scripts/fetch_images.py @@ -1,17 +1,23 @@ """Download product images from devices.json imageSource into images/. -Run from repo root: python scripts/fetch_images.py -Re-run any time to refresh; commits stay the source of truth. +Run from repo root: python scripts/fetch_images.py [device-id ...] +With ids, only those devices are fetched (e.g. the one you just added); +without, every device is. Re-run any time to refresh; commits stay the source +of truth. Shopify CDN sources are requested as small progressive-JPEG renditions (width + format=pjpg), which shrinks the ~700-900KB product PNGs to tens of KB. The CDN ignores format=webp on this store, so pjpg is the only conversion that takes effect. When a device's image extension changes (e.g. .png -> .jpg) the old file is removed so it doesn't linger in the repo and the deploy. + +Other sources can't be resized here: save a ~600px JPEG by hand as +images/.jpg and the script keeps it from then on. """ import glob import json import os +import sys import urllib.parse import urllib.request @@ -48,11 +54,43 @@ def remove_stale(dest): os.remove(old) print(f" removed stale {old}") -def main(): +def existing_image(dev, dest): + """The image to keep for a non-Shopify source, if one is already on disk. + + Checks the registry's current `image` first: a hand-made rendition (h-1 is a + 600px JPEG of a PNG source) has a different extension than `dest`, and + re-fetching would replace it with the full-size original. + """ + for path in (dev.get("image"), dest): + if path and os.path.exists(path): + return path + return None + +def download(url, dest): + """Fetch url into dest, replacing it only once a complete image has arrived, + so a failed or non-image response never truncates the committed file.""" + req = urllib.request.Request(url, headers=UA) + with urllib.request.urlopen(req, timeout=60) as r: + ctype = r.headers.get_content_type() + data = r.read() + if not ctype.startswith("image/") or not data: + raise ValueError(f"{url} returned {ctype} ({len(data)} bytes), not an image") + tmp = dest + ".tmp" + with open(tmp, "wb") as out: + out.write(data) + os.replace(tmp, dest) + +def main(argv=None): + only = set(sys.argv[1:] if argv is None else argv) with open(REG, encoding="utf-8") as f: reg = json.load(f) + unknown = only - {dev["id"] for dev in reg["devices"]} + if unknown: + sys.exit(f"No such device id(s): {', '.join(sorted(unknown))}") os.makedirs("images", exist_ok=True) for dev in reg["devices"]: + if only and dev["id"] not in only: + continue src = dev.get("imageSource") if not src: print(f"{dev['id']}: no imageSource, skipped") @@ -60,20 +98,20 @@ def main(): url, ext = rendition_url(src) dest = f"images/{dev['id']}{ext}" # Non-Shopify sources can't be CDN-downscaled; if we already have the - # file (it may be hand-resized, like msr-1), keep it rather than + # file (it may be hand-resized, like msr-1 or h-1), keep it rather than # re-downloading the full-size original and undoing that work. - if "cdn.shopify.com" not in src and os.path.exists(dest): - dev["image"] = dest - print(f"{dev['id']}: kept existing {dest} (non-Shopify source, not re-fetched)") - continue - req = urllib.request.Request(url, headers=UA) - with urllib.request.urlopen(req, timeout=60) as r, open(dest, "wb") as out: - out.write(r.read()) + if "cdn.shopify.com" not in src: + kept = existing_image(dev, dest) + if kept: + dev["image"] = kept + print(f"{dev['id']}: kept existing {kept} (non-Shopify source, not re-fetched)") + continue + download(url, dest) remove_stale(dest) dev["image"] = dest print(f"{dev['id']}: {dest} ({os.path.getsize(dest)} bytes)") with open(REG, "w", encoding="utf-8") as f: - json.dump(reg, f, indent=2) + json.dump(reg, f, indent=2, ensure_ascii=False) f.write("\n") if __name__ == "__main__": diff --git a/scripts/test_fetch_images.py b/scripts/test_fetch_images.py index 5db8d5a..2e0028b 100644 --- a/scripts/test_fetch_images.py +++ b/scripts/test_fetch_images.py @@ -1,13 +1,17 @@ """Offline unit tests for fetch_images.py (stdlib unittest, no deps). Run: python scripts/test_fetch_images.py -Covers the CDN rendition URL building and stale-file cleanup without touching -the network. +Covers the CDN rendition URL building, stale-file cleanup, the download and +main() against a temporary registry, without touching the network. """ +import contextlib +import io +import json import os import sys import tempfile import unittest +from unittest import mock sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import fetch_images as fi # noqa: E402 @@ -77,5 +81,101 @@ def test_does_not_touch_same_base_non_image(self): self.assertTrue(os.path.exists(note)) +class FakeResponse(io.BytesIO): + def __init__(self, body, ctype): + super().__init__(body) + self.headers = mock.Mock(get_content_type=lambda: ctype) + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class InTempRepo(unittest.TestCase): + def setUp(self): + self._cwd = os.getcwd() + self._tmp = tempfile.TemporaryDirectory() + os.chdir(self._tmp.name) + os.makedirs("images") + + def tearDown(self): + os.chdir(self._cwd) + self._tmp.cleanup() + + def write_registry(self, devices): + with open("devices.json", "w", encoding="utf-8") as f: + json.dump({"devices": devices}, f) + + def run_main(self, argv=(), urlopen=None): + urlopen = urlopen or mock.Mock(side_effect=AssertionError("fetched unexpectedly")) + with mock.patch.object(fi.urllib.request, "urlopen", urlopen), \ + contextlib.redirect_stdout(io.StringIO()): + fi.main(list(argv)) + with open("devices.json", encoding="utf-8") as f: + return {d["id"]: d for d in json.load(f)["devices"]} + + +class Main(InTempRepo): + H1 = {"id": "h-1", "name": "H-1", "image": "images/h-1.jpg", + "imageSource": "https://wiki.apolloautomation.com/assets/h-1-usa.png"} + + def test_keeps_hand_made_rendition_of_non_shopify_png(self): + # images/h-1.jpg is a 600px JPEG of a PNG source. A re-run must not + # download the full-size PNG and delete the JPEG. + with open("images/h-1.jpg", "wb") as f: + f.write(b"jpeg") + self.write_registry([self.H1]) + devs = self.run_main() + self.assertTrue(os.path.exists("images/h-1.jpg")) + self.assertFalse(os.path.exists("images/h-1.png")) + self.assertEqual(devs["h-1"]["image"], "images/h-1.jpg") + + def test_only_fetches_the_named_devices(self): + shop = {"id": "air-1", "image": "images/air-1.jpg", "imageSource": SHOPIFY} + self.write_registry([dict(shop), dict(shop, id="msr-2", image="images/msr-2.jpg")]) + urlopen = mock.Mock(return_value=FakeResponse(b"jpeg", "image/jpeg")) + self.run_main(["air-1"], urlopen) + self.assertEqual(urlopen.call_count, 1) + self.assertTrue(os.path.exists("images/air-1.jpg")) + self.assertFalse(os.path.exists("images/msr-2.jpg")) + + def test_unknown_id_exits(self): + self.write_registry([self.H1]) + with self.assertRaises(SystemExit): + self.run_main(["nope"]) + + def test_keeps_non_ascii_text_readable(self): + self.write_registry([dict(self.H1, description="Ornament — 2024")]) + open("images/h-1.jpg", "wb").close() + self.run_main() + with open("devices.json", encoding="utf-8") as f: + self.assertIn("Ornament — 2024", f.read()) + + +class Download(InTempRepo): + def test_non_image_response_leaves_committed_file_alone(self): + with open("images/air-1.jpg", "wb") as f: + f.write(b"committed") + resp = FakeResponse(b"error", "text/html") + with mock.patch.object(fi.urllib.request, "urlopen", return_value=resp): + with self.assertRaises(ValueError): + fi.download("https://x/a.jpg", "images/air-1.jpg") + with open("images/air-1.jpg", "rb") as f: + self.assertEqual(f.read(), b"committed") + + def test_failed_read_leaves_committed_file_alone(self): + with open("images/air-1.jpg", "wb") as f: + f.write(b"committed") + resp = FakeResponse(b"", "image/jpeg") + resp.read = mock.Mock(side_effect=TimeoutError("read timed out")) + with mock.patch.object(fi.urllib.request, "urlopen", return_value=resp): + with self.assertRaises(TimeoutError): + fi.download("https://x/a.jpg", "images/air-1.jpg") + with open("images/air-1.jpg", "rb") as f: + self.assertEqual(f.read(), b"committed") + + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_validate_registry.py b/scripts/test_validate_registry.py index dbc87c3..2426767 100644 --- a/scripts/test_validate_registry.py +++ b/scripts/test_validate_registry.py @@ -1,11 +1,15 @@ """Offline unit tests for validate_registry.py (stdlib unittest, no deps). Run: python scripts/test_validate_registry.py -Covers the manifest-shape checks and head_ok's exception handling without -touching the network. +Covers the shape checks, head_ok's exception handling, the classic-installer +comparison and main() against a temporary registry, without touching the network. """ +import contextlib +import io +import json import os import sys +import tempfile import unittest from unittest import mock @@ -51,6 +55,31 @@ def test_missing_chip_family_errors(self): errs = vr.check_manifest_shape(m, "dev stable/wifi") self.assertTrue(any("chipFamily" in e for e in errs), errs) + def test_unknown_chip_family_errors(self): + # esp-web-tools matches chipFamily exactly; "ESP32C3" would be "unsupported". + m = {"builds": [{"chipFamily": "ESP32C3", "parts": [{"path": "a.bin"}]}]} + errs = vr.check_manifest_shape(m, "dev stable/wifi") + self.assertTrue(any("unknown chipFamily" in e for e in errs), errs) + + def test_non_object_manifest_errors_without_crashing(self): + for bad in ([{"chipFamily": "ESP32"}], "x", None): + errs = vr.check_manifest_shape(bad, "dev stable/wifi") + self.assertTrue(any("not a JSON object" in e for e in errs), (bad, errs)) + + def test_non_object_build_or_part_errors_without_crashing(self): + self.assertTrue(vr.check_manifest_shape({"builds": ["x"]}, "d")) + m = {"builds": [{"chipFamily": "ESP32", "parts": ["a.bin"]}]} + errs = vr.check_manifest_shape(m, "d") + self.assertTrue(any("path" in e for e in errs), errs) + self.assertEqual(list(vr.part_paths(m)), []) + + def test_non_string_part_path_errors_and_is_skipped(self): + # urljoin() would raise on a numeric path in the network loop. + m = {"builds": [{"chipFamily": "ESP32", "parts": [{"path": 123}, {"path": "a.bin"}]}]} + errs = vr.check_manifest_shape(m, "d") + self.assertTrue(any("path" in e for e in errs), errs) + self.assertEqual(list(vr.part_paths(m)), ["a.bin"]) + class HeadOk(unittest.TestCase): def test_url_error_returns_false(self): @@ -65,32 +94,52 @@ def test_read_phase_timeout_returns_false(self): side_effect=TimeoutError("read timed out")): self.assertFalse(vr.head_ok("https://example.com/x.bin")) + def test_dropped_connection_returns_false(self): + # urllib does not wrap a server hang-up in URLError. + for exc in (vr.http.client.RemoteDisconnected("closed"), ConnectionResetError()): + with mock.patch.object(vr.urllib.request, "urlopen", side_effect=exc): + self.assertFalse(vr.head_ok("https://example.com/x.bin"), exc) + + def test_cors_ok(self): + self.assertTrue(vr.cors_ok({"Access-Control-Allow-Origin": "*"})) + self.assertTrue(vr.cors_ok({"Access-Control-Allow-Origin": vr.ORIGIN})) + self.assertFalse(vr.cors_ok({})) + self.assertFalse(vr.cors_ok({"Access-Control-Allow-Origin": "https://evil.example"})) + class ConfigShape(unittest.TestCase): + FW = {"stable": {"WiFi": "https://x/m.json", "Standard": "https://y/m.json"}} + def test_good_config_has_no_errors(self): cfg = {"stable": {"WiFi": "https://raw.githubusercontent.com/o/r/main/a.yaml"}} - self.assertEqual(vr.check_config_shape(cfg, "r-pro-1"), []) + self.assertEqual(vr.check_config_shape(cfg, self.FW, "r-pro-1"), []) def test_non_string_url_errors(self): cfg = {"stable": {"WiFi": 123}} - errs = vr.check_config_shape(cfg, "r-pro-1") + errs = vr.check_config_shape(cfg, self.FW, "r-pro-1") self.assertTrue(any("WiFi" in e for e in errs), errs) def test_non_https_url_errors(self): cfg = {"stable": {"Standard": "http://insecure/a.yaml"}} - errs = vr.check_config_shape(cfg, "x") + errs = vr.check_config_shape(cfg, self.FW, "x") self.assertTrue(any("https" in e for e in errs), errs) def test_channel_not_object_errors(self): cfg = {"stable": "oops"} - errs = vr.check_config_shape(cfg, "x") + errs = vr.check_config_shape(cfg, self.FW, "x") self.assertTrue(any("stable" in e for e in errs), errs) def test_non_dict_config_errors_without_crashing(self): for bad in (None, "oops", []): - errs = vr.check_config_shape(bad, "x") + errs = vr.check_config_shape(bad, self.FW, "x") self.assertTrue(any("config" in e for e in errs), (bad, errs)) + def test_variant_not_in_firmware_errors(self): + # A typo would silently drop the "Build or reflash" section for the variant. + cfg = {"stable": {"Standrd": "https://raw.githubusercontent.com/o/r/main/a.yaml"}} + errs = vr.check_config_shape(cfg, self.FW, "x") + self.assertTrue(any("no such firmware variant" in e for e in errs), errs) + class ReposShapeChecks(unittest.TestCase): FW = {"stable": {"v16": "https://x/m.json", "v14": "https://y/m.json"}} @@ -184,7 +233,7 @@ def test_absent_means_esphome(self): self.assertEqual(vr.check_platform(None, "dev"), []) def test_known_platforms_ok(self): - for p in ("esphome", "wled"): + for p in ("esphome", "wled", "standalone"): self.assertEqual(vr.check_platform(p, "dev"), [], p) def test_unknown_platform_errors(self): @@ -220,5 +269,182 @@ def test_variant_not_in_firmware_errors(self): self.assertTrue(any("no such firmware variant" in e for e in errs), errs) +def device(**over): + d = { + "id": "msr-1", "name": "MSR-1", "category": "mmWave presence", + "description": "Sensor", "image": "images/msr-1.jpg", + "wiki": "https://wiki.apolloautomation.com/products/msr1/introduction/", + "repo": "ApolloAutomation/MSR-1", + "githubPagesInstaller": "https://apolloautomation.github.io/MSR-1/", + "firmware": {"stable": { + "Standard": "https://apolloautomation.github.io/MSR-1/firmware-factory/manifest.json"}}, + } + d.update(over) + return d + + +class InTempRepo(unittest.TestCase): + """Run with cwd in a scratch dir holding images/msr-1.jpg, like the repo root.""" + + def setUp(self): + self._cwd = os.getcwd() + self._tmp = tempfile.TemporaryDirectory() + os.chdir(self._tmp.name) + os.makedirs("images") + open("images/msr-1.jpg", "wb").close() + + def tearDown(self): + os.chdir(self._cwd) + self._tmp.cleanup() + + +class DeviceShape(InTempRepo): + def test_good_device_ok(self): + self.assertEqual(vr.check_device_shape(device(), "msr-1"), []) + + def test_missing_required_field_errors(self): + for key in vr.REQUIRED: + d = device() + del d[key] + errs = vr.check_device_shape(d, "x") + self.assertTrue(any(repr(key) in e for e in errs), (key, errs)) + + def test_id_must_be_lowercase_kebab(self): + for bad in ("MSR-1", "msr_1", "msr 1", "-msr", "msr--1"): + errs = vr.check_device_shape(device(id=bad), "x") + self.assertTrue(any("lowercase-kebab" in e for e in errs), (bad, errs)) + + def test_missing_image_file_errors(self): + errs = vr.check_device_shape(device(image="images/nope.jpg"), "x") + self.assertTrue(any("does not exist" in e for e in errs), errs) + + def test_image_named_after_id(self): + open("images/other.jpg", "wb").close() + errs = vr.check_device_shape(device(image="images/other.jpg"), "x") + self.assertTrue(any("named after the id" in e for e in errs), errs) + + def test_non_https_urls_error(self): + for key in ("wiki", "githubPagesInstaller", "imageSource"): + errs = vr.check_device_shape(device(**{key: "http://x/"}), "x") + self.assertTrue(any(key in e for e in errs), (key, errs)) + + def test_empty_channel_errors(self): + fw = {"stable": device()["firmware"]["stable"], "beta": {}} + errs = vr.check_device_shape(device(firmware=fw), "x") + self.assertTrue(any("beta" in e for e in errs), errs) + + def test_no_stable_channel_errors(self): + errs = vr.check_device_shape(device(firmware={"beta": {"a": "https://x"}}), "x") + self.assertTrue(any("no stable channel" in e for e in errs), errs) + + def test_two_variants_on_one_manifest_errors(self): + # e.g. the Ethernet variant pasted with the WiFi build's URL. + url = "https://apolloautomation.github.io/MSR-1/firmware-w/manifest.json" + errs = vr.check_device_shape(device(firmware={"stable": {"WiFi": url, "Ethernet": url}}), "x") + self.assertTrue(any("same manifest as 'WiFi'" in e for e in errs), errs) + + def test_markup_breaking_variant_key_errors(self): + url = "https://apolloautomation.github.io/MSR-1/firmware/manifest.json" + errs = vr.check_device_shape(device(firmware={"stable": {'Say "hi"': url}}), "x") + self.assertTrue(any("breaks the page markup" in e for e in errs), errs) + + +class ManifestUrl(unittest.TestCase): + def test_own_pages_site_ok(self): + url = "https://apolloautomation.github.io/MSR-1/firmware-factory/manifest.json" + self.assertEqual(vr.check_manifest_url(url, "ApolloAutomation/MSR-1", "w"), []) + + def test_manifest_copied_from_another_device_errors(self): + # The copy-paste mistake that would flash H-2 firmware onto an H-3. + url = "https://apolloautomation.github.io/H-2/firmware/manifest.json" + errs = vr.check_manifest_url(url, "ApolloAutomation/H-3", "w") + self.assertTrue(any("Pages site" in e for e in errs), errs) + + def test_release_asset_host_errors(self): + url = "https://github.com/ApolloAutomation/MSR-2/releases/download/beta-fw/manifest.json" + self.assertTrue(vr.check_manifest_url(url, "ApolloAutomation/MSR-2", "w")) + + def test_non_https_errors(self): + url = "http://apolloautomation.github.io/MSR-1/firmware/manifest.json" + errs = vr.check_manifest_url(url, "ApolloAutomation/MSR-1", "w") + self.assertTrue(any("https" in e for e in errs), errs) + + def test_repos_override_is_used(self): + dev = device(id="m-1", repo="ApolloAutomation/WLED-MM-M1", + repos={"stable": {"v16": "ApolloAutomation/WLED-M1"}}) + self.assertEqual(vr.repo_for(dev, "stable", "v16"), "ApolloAutomation/WLED-M1") + self.assertEqual(vr.repo_for(dev, "stable", "v14"), "ApolloAutomation/WLED-MM-M1") + + +class UniqueIds(unittest.TestCase): + def test_duplicate_id_errors(self): + # Merging PR #32 as it stood would have listed TEMP_PRO-1 twice. + errs = vr.check_unique_ids([device(), device(id="air-1"), device()]) + self.assertEqual(errs, ["msr-1: duplicate id"]) + + +class ClassicInstaller(unittest.TestCase): + PAGE = "https://apolloautomation.github.io/AIR-1/" + HTML = """ + """ + + def test_manifests_on_page_resolves_relative_urls(self): + self.assertEqual(vr.manifests_on_page(self.HTML, self.PAGE), { + "https://apolloautomation.github.io/AIR-1/firmware-factory/manifest.json", + "https://other.example/m.json"}) + + def test_registry_build_the_page_flashes_ok(self): + flashed = vr.manifests_on_page(self.HTML, self.PAGE) + url = self.PAGE + "firmware-factory/manifest.json" + self.assertEqual(vr.check_classic_agrees("w", url, self.PAGE, flashed), []) + + def test_build_the_page_does_not_flash_errors(self): + # AIR-1 before PR #33: the registry pointed at firmware/ (no improv) while + # the classic installer flashed firmware-factory/. + flashed = vr.manifests_on_page(self.HTML, self.PAGE) + errs = vr.check_classic_agrees("w", self.PAGE + "firmware/manifest.json", self.PAGE, flashed) + self.assertTrue(any("doesn't flash" in e for e in errs), errs) + + def test_unreachable_page_errors(self): + errs = vr.check_classic_agrees("w", self.PAGE + "x.json", self.PAGE, None) + self.assertTrue(any("unreachable" in e for e in errs), errs) + + def test_installer_override_wins(self): + dev = device(installers={"stable": {"Standard": None}}) + self.assertIsNone(vr.classic_installer_for(dev, "stable", "Standard")) + self.assertEqual(vr.classic_installer_for(device(), "stable", "Standard"), + device()["githubPagesInstaller"]) + + +class MainOffline(InTempRepo): + def run_main(self, devices): + with open("devices.json", "w", encoding="utf-8") as f: + json.dump({"devices": devices}, f) + vr.errors.clear() + with mock.patch.object(vr.urllib.request, "urlopen", + side_effect=AssertionError("offline run touched the network")), \ + contextlib.redirect_stdout(io.StringIO()): + try: + vr.main(["--offline"]) + except SystemExit as e: + return e.code + return 0 + + def test_good_registry_passes(self): + self.assertEqual(self.run_main([device()]), 0) + self.assertEqual(vr.errors, []) + + def test_null_channel_with_overrides_reports_instead_of_crashing(self): + fw = dict(device()["firmware"], beta=None) + cfg = {"beta": {"Standard": "https://raw.githubusercontent.com/o/r/main/a.yaml"}} + self.assertEqual(self.run_main([device(firmware=fw, config=cfg)]), 1) + self.assertTrue(any("beta" in e for e in vr.errors), vr.errors) + + def test_duplicate_and_bad_entries_fail(self): + self.assertEqual(self.run_main([device(), device(), "junk"]), 1) + self.assertIn("msr-1: duplicate id", vr.errors) + self.assertTrue(any("devices[2]" in e for e in vr.errors), vr.errors) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/validate_registry.py b/scripts/validate_registry.py index adf6ae6..8943a8b 100644 --- a/scripts/validate_registry.py +++ b/scripts/validate_registry.py @@ -1,27 +1,80 @@ -"""Validate every manifest URL in devices.json. +"""Validate devices.json and everything it points at. -Checks: manifest fetches (200) and parses; has builds[] with chipFamily; -every part path resolves and exists (HEAD 200). Exit 1 on any failure. -Run: python scripts/validate_registry.py +Offline checks (always run): each device's shape (required fields, unique +lowercase-kebab id, image file present and named after the id), the firmware, +config and per-variant override maps, and that every manifest URL lives on its +own repo's GitHub Pages site. + +Network checks (skipped with --offline): every manifest fetches (200, readable +cross-origin from the installer) and parses, with builds[] and a known +chipFamily; every part path resolves (HEAD 200); configs and wiki pages are +reachable; and each manifest is one the variant's classic installer page offers. +(That can't tell two of a page's builds apart, so two variants swapped with each +other still pass; a variant duplicating another's manifest is caught offline.) +Exit 1 on any failure. + +Run: python scripts/validate_registry.py [--offline] """ +import http.client import json +import os +import re import sys import urllib.error import urllib.parse import urllib.request UA = {"User-Agent": "apollo-installer-registry-check"} +# esp-web-tools fetches manifests and parts cross-origin from the installer, so +# they must allow it. GitHub Pages and raw.githubusercontent.com send "*"; +# GitHub release assets send nothing. +ORIGIN = "https://install.apolloautomation.com" +# The chipFamily names the vendored esp-web-tools recognizes. +CHIP_FAMILIES = ("ESP32", "ESP32-C2", "ESP32-C3", "ESP32-C5", "ESP32-C6", "ESP32-C61", + "ESP32-H2", "ESP32-P4", "ESP32-S2", "ESP32-S3", "ESP8266") +REQUIRED = ("id", "name", "category", "description", "image", "wiki", "repo") +ID_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +# Channel and variant keys are written into HTML attributes and CSS selectors. +UNSAFE_KEY_CHARS = set('"<>&`\\') +MANIFEST_ATTR = re.compile(r"""\bmanifest\s*=\s*["']([^"']+)["']""") errors = [] -def head_ok(url): - req = urllib.request.Request(url, headers=UA, method="HEAD") +def head(url): + """HEAD url with the installer's Origin; return the headers on a 200, else None. + + Any connection, timeout or protocol failure counts as unreachable rather than + crashing the run. URLError and a read-phase TimeoutError are both OSErrors; + a dropped connection can also surface unwrapped (RemoteDisconnected). + """ + req = urllib.request.Request(url, headers=dict(UA, Origin=ORIGIN), method="HEAD") try: with urllib.request.urlopen(req, timeout=30) as r: - return r.status == 200 - except (urllib.error.URLError, TimeoutError): - # A read-phase timeout raises TimeoutError, not URLError — treat any - # connection/timeout failure as "part unreachable" rather than crashing. - return False + return r.headers if r.status == 200 else None + except (OSError, http.client.HTTPException): + return None + +def head_ok(url): + return head(url) is not None + +def cors_ok(headers): + return headers.get("Access-Control-Allow-Origin") in ("*", ORIGIN) + +def is_owner_name(repo): + return (isinstance(repo, str) and repo.count("/") == 1 + and " " not in repo and all(repo.split("/"))) + +def repo_for(dev, channel, variant): + repos = dev.get("repos") if isinstance(dev.get("repos"), dict) else {} + chan = repos.get(channel) if isinstance(repos.get(channel), dict) else {} + return chan.get(variant) or dev.get("repo") + +def classic_installer_for(dev, channel, variant): + """Mirrors classicInstallerFor() in js/views/device.js.""" + installers = dev.get("installers") if isinstance(dev.get("installers"), dict) else {} + chan = installers.get(channel) + if isinstance(chan, dict) and variant in chan: + return chan[variant] + return dev.get("githubPagesInstaller") def check_manifest_shape(manifest, where): """Validate manifest structure without touching the network. @@ -30,29 +83,133 @@ def check_manifest_shape(manifest, where): with no parts, or a part with no "path", is a hard error rather than a silent pass or a KeyError crash. """ + if not isinstance(manifest, dict): + return [f"{where}: manifest is not a JSON object"] errs = [] builds = manifest.get("builds") - if not builds: + if not builds or not isinstance(builds, list): errs.append(f"{where}: manifest has no builds[]") return errs for b in builds: + if not isinstance(b, dict): + errs.append(f"{where}: build is not an object") + continue if "chipFamily" not in b: errs.append(f"{where}: build missing chipFamily") + elif b["chipFamily"] not in CHIP_FAMILIES: + errs.append(f"{where}: unknown chipFamily {b['chipFamily']!r} " + "(esp-web-tools would report the board as unsupported)") parts = b.get("parts") - if not parts: + if not parts or not isinstance(parts, list): errs.append(f"{where}: build has no parts[]") continue for part in parts: - if not part.get("path"): + if not isinstance(part, dict) or not isinstance(part.get("path"), str) or not part["path"]: # Absent, empty, or null path — matches the network loop's skip # test so a broken path can never slip past both checks. errs.append(f"{where}: firmware part missing 'path'") return errs -def check_config_shape(config, dev_id): +def part_paths(manifest): + """Every usable part path in a manifest; malformed entries are skipped here + because check_manifest_shape already reports them.""" + builds = manifest.get("builds") if isinstance(manifest, dict) else None + for b in builds if isinstance(builds, list) else []: + parts = b.get("parts") if isinstance(b, dict) else None + for part in parts if isinstance(parts, list) else []: + if isinstance(part, dict) and isinstance(part.get("path"), str) and part["path"]: + yield part["path"] + +def check_manifest_url(murl, repo, where): + """Network-free: a manifest must be an https URL on its own repo's GitHub + Pages site (https://.github.io//...). + + `repo` is the variant's resolved repo (the `repos` override, else the + device's). A URL copy-pasted from another device's entry fails here, before + it can flash one board's firmware onto another. + """ + if not isinstance(murl, str) or not murl.startswith("https://"): + return [f"{where}: manifest is not an https URL"] + if not is_owner_name(repo): + return [] # the repo itself is reported by the shape checks + owner, name = repo.split("/") + u = urllib.parse.urlparse(murl) + segments = u.path.split("/") + if u.netloc.lower() != f"{owner.lower()}.github.io" or len(segments) < 3 or segments[1] != name: + return [f"{where}: manifest {murl} is not on {repo}'s Pages site " + f"(https://{owner.lower()}.github.io/{name}/)"] + return [] + +def check_device_shape(dev, where): + """Network-free checks on one device entry. `where` names it in messages. + + Covers required fields, the id format (it becomes the URL hash and the image + filename), the image file, URL fields, and the firmware map itself. + """ + if not isinstance(dev, dict): + return [f"{where}: not an object"] + errs = [] + for key in REQUIRED: + if not isinstance(dev.get(key), str) or not dev[key].strip(): + errs.append(f"{where}: missing {key!r}") + dev_id = dev.get("id") + if isinstance(dev_id, str) and dev_id and not ID_RE.fullmatch(dev_id): + errs.append(f"{where}: id must be lowercase-kebab (it becomes the URL hash and image filename)") + image = dev.get("image") + if isinstance(image, str) and image: + if not os.path.isfile(image): + errs.append(f"{where}: image {image} does not exist") + elif isinstance(dev_id, str) and os.path.splitext(os.path.basename(image))[0] != dev_id: + errs.append(f"{where}: image should be named after the id (images/{dev_id}.)") + if "repo" in dev and not is_owner_name(dev["repo"]): + errs.append(f"{where}: repo is not an 'owner/name' string") + for key in ("wiki", "githubPagesInstaller", "imageSource"): + url = dev.get(key) + if url is not None and (not isinstance(url, str) or not url.startswith("https://")): + errs.append(f"{where}: {key} is not an https URL") + firmware = dev.get("firmware") + if not isinstance(firmware, dict) or not firmware.get("stable"): + errs.append(f"{where}: no stable channel") + if not isinstance(firmware, dict): + return errs + for channel, variants in firmware.items(): + if UNSAFE_KEY_CHARS & set(channel): + errs.append(f"{where}: channel {channel!r} has a character that breaks the page markup") + if not isinstance(variants, dict) or not variants: + errs.append(f"{where} firmware {channel}: not a non-empty object") + continue + seen = {} + for variant, murl in variants.items(): + # Two variants on one manifest is a copy-paste slip: one of them is + # not flashing what its button says. + if isinstance(murl, str): + if murl in seen: + errs.append(f"{where} {channel}/{variant}: same manifest as {seen[murl]!r}") + seen.setdefault(murl, variant) + if UNSAFE_KEY_CHARS & set(variant): + errs.append(f"{where}: variant {variant!r} has a character that breaks the page markup") + errs.extend(check_manifest_url(murl, repo_for(dev, channel, variant), + f"{where} {channel}/{variant}")) + return errs + +def check_unique_ids(devices): + """A duplicate id sends the second card's #/ link to the first entry.""" + seen, errs = set(), [] + for dev in devices: + dev_id = dev.get("id") if isinstance(dev, dict) else None + if not isinstance(dev_id, str): + continue + if dev_id in seen: + errs.append(f"{dev_id}: duplicate id") + seen.add(dev_id) + return errs + +def check_config_shape(config, firmware, dev_id): """Validate the optional `config` map (channel -> variant -> https URL). - Returns a list of error strings (empty when sound). Network-free. + Network-free. Every variant key must exist in `firmware[channel]`, like the + override maps: a mistyped key would silently drop the reflash section. + Returns a list of error strings (empty when sound). """ errs = [] if not isinstance(config, dict): @@ -65,6 +222,8 @@ def check_config_shape(config, dev_id): for variant, url in variants.items(): if not isinstance(url, str) or not url.startswith("https://"): errs.append(f"{dev_id} config {channel}/{variant}: not an https URL") + if variant not in firmware.get(channel, {}): + errs.append(f"{dev_id} config {channel}/{variant}: no such firmware variant") return errs def check_repos_shape(repos, firmware, dev_id): @@ -85,8 +244,7 @@ def check_repos_shape(repos, firmware, dev_id): errs.append(f"{dev_id} repos {channel}: not an object") continue for variant, repo in variants.items(): - if (not isinstance(repo, str) or repo.count("/") != 1 - or " " in repo or not all(repo.split("/"))): + if not is_owner_name(repo): errs.append(f"{dev_id} repos {channel}/{variant}: not an 'owner/name' string") if variant not in firmware.get(channel, {}): errs.append(f"{dev_id} repos {channel}/{variant}: no such firmware variant") @@ -141,7 +299,9 @@ def check_variant_notes_shape(notes, firmware, dev_id): errs.append(f"{dev_id} variantNotes {channel}/{variant}: no such firmware variant") return errs -PLATFORMS = ("esphome", "wled") +# What step 3 of the wizard tells the user after flashing: ESPHome adoption, WLED +# setup, or (standalone) nothing to add, for builds with no network at all. +PLATFORMS = ("esphome", "wled", "standalone") def check_platform(platform, dev_id): """Validate the optional `platform` field (firmware ecosystem). @@ -184,52 +344,114 @@ def check_platforms_shape(platforms, firmware, dev_id): def check_manifest(dev_id, channel, variant, murl): where = f"{dev_id} {channel}/{variant}" try: - req = urllib.request.Request(murl, headers=UA) + req = urllib.request.Request(murl, headers=dict(UA, Origin=ORIGIN)) with urllib.request.urlopen(req, timeout=30) as r: + readable = cors_ok(r.headers) manifest = json.load(r) except Exception as e: errors.append(f"{where}: manifest fetch/parse failed: {e}") return + if not readable: + errors.append(f"{where}: manifest has no Access-Control-Allow-Origin for {ORIGIN}, " + "so the browser can't load it") errors.extend(check_manifest_shape(manifest, where)) - for b in manifest.get("builds", []): - for part in b.get("parts", []): - path = part.get("path") - if not path: - continue # already reported by check_manifest_shape - purl = urllib.parse.urljoin(murl, path) - if not head_ok(purl): - errors.append(f"{where}: firmware part missing: {purl}") - -def main(): + for path in part_paths(manifest): + purl = urllib.parse.urljoin(murl, path) + headers = head(purl) + if headers is None: + errors.append(f"{where}: firmware part missing: {purl}") + elif not cors_ok(headers): + errors.append(f"{where}: firmware part not readable cross-origin: {purl}") + +def manifests_on_page(html, base): + """The manifest URLs an installer page flashes: every manifest= attribute + (esp-web-install-button), resolved against the page URL.""" + return {urllib.parse.urljoin(base, m) for m in MANIFEST_ATTR.findall(html)} + +_classic_pages = {} + +def classic_manifests(page): + """Fetch a classic installer page once; None when it can't be reached.""" + if page not in _classic_pages: + try: + req = urllib.request.Request(page, headers=UA) + with urllib.request.urlopen(req, timeout=30) as r: + html = r.read().decode("utf-8", "replace") + _classic_pages[page] = manifests_on_page(html, r.geturl()) + except (OSError, http.client.HTTPException, ValueError): + _classic_pages[page] = None + return _classic_pages[page] + +def check_classic_agrees(where, murl, page, flashed): + """Each product repo's own classic installer is the reference for which builds + a first install should flash (e.g. the improv-enabled firmware-factory/ when + a repo has one). A registry manifest the page doesn't offer is either a + mapping mistake or a sign the two have drifted apart. This is a membership + test, so it can't catch two of the page's builds swapped between variants.""" + if flashed is None: + return [f"{where}: classic installer unreachable: {page}"] + if murl not in flashed: + offered = ", ".join(sorted(flashed)) or "none" + return [f"{where}: classic installer {page} doesn't flash {murl} (it offers: {offered})"] + return [] + +def main(argv=None): + offline = "--offline" in (sys.argv[1:] if argv is None else argv) with open("devices.json", encoding="utf-8") as f: reg = json.load(f) - for dev in reg["devices"]: - if not dev.get("firmware", {}).get("stable"): - errors.append(f"{dev['id']}: no stable channel") - for channel, variants in dev.get("firmware", {}).items(): - for variant, murl in variants.items(): - check_manifest(dev["id"], channel, variant, murl) + devices = reg.get("devices") if isinstance(reg, dict) else None + if not isinstance(devices, list) or not devices: + errors.append("devices.json: no devices[]") + devices = [] + errors.extend(check_unique_ids(devices)) + for i, dev in enumerate(devices): + dev_id = dev.get("id") if isinstance(dev, dict) and isinstance(dev.get("id"), str) else f"devices[{i}]" + errors.extend(check_device_shape(dev, dev_id)) + if not isinstance(dev, dict): + continue + # Malformed channels are reported by check_device_shape; leave them out + # here so the per-variant checks below can't trip over them. + firmware = dev.get("firmware") if isinstance(dev.get("firmware"), dict) else {} + firmware = {c: v for c, v in firmware.items() if isinstance(v, dict)} config = dev.get("config", {}) - errors.extend(check_config_shape(config, dev["id"])) - errors.extend(check_repos_shape(dev.get("repos"), dev.get("firmware", {}), dev["id"])) - errors.extend(check_variant_notes_shape(dev.get("variantNotes"), dev.get("firmware", {}), dev["id"])) - errors.extend(check_installers_shape(dev.get("installers"), dev.get("firmware", {}), dev["id"])) - errors.extend(check_platform(dev.get("platform"), dev["id"])) - errors.extend(check_platforms_shape(dev.get("platforms"), dev.get("firmware", {}), dev["id"])) - if not isinstance(config, dict): + errors.extend(check_config_shape(config, firmware, dev_id)) + errors.extend(check_repos_shape(dev.get("repos"), firmware, dev_id)) + errors.extend(check_variant_notes_shape(dev.get("variantNotes"), firmware, dev_id)) + errors.extend(check_installers_shape(dev.get("installers"), firmware, dev_id)) + errors.extend(check_platform(dev.get("platform"), dev_id)) + errors.extend(check_platforms_shape(dev.get("platforms"), firmware, dev_id)) + if offline: continue - for channel, variants in config.items(): + for channel, variants in firmware.items(): if not isinstance(variants, dict): continue - for variant, curl in variants.items(): - if isinstance(curl, str) and not head_ok(curl): - errors.append(f"{dev['id']} config {channel}/{variant}: unreachable: {curl}") + for variant, murl in variants.items(): + if not isinstance(murl, str): + continue + check_manifest(dev_id, channel, variant, murl) + page = classic_installer_for(dev, channel, variant) + if isinstance(page, str): + errors.extend(check_classic_agrees(f"{dev_id} {channel}/{variant}", murl, + page, classic_manifests(page))) + if isinstance(config, dict): + for channel, variants in config.items(): + if not isinstance(variants, dict): + continue + for variant, curl in variants.items(): + if isinstance(curl, str) and not head_ok(curl): + errors.append(f"{dev_id} config {channel}/{variant}: unreachable: {curl}") + wiki = dev.get("wiki") + if isinstance(wiki, str) and not head_ok(wiki): + errors.append(f"{dev_id} wiki unreachable: {wiki}") if errors: print(f"FAILED — {len(errors)} problem(s):") for e in errors: print(f" {e}") sys.exit(1) - print("Registry OK: all manifests live and complete.") + if offline: + print("Registry OK (offline checks only).") + else: + print("Registry OK: all manifests live and complete.") if __name__ == "__main__": main() diff --git a/tests/installer.spec.js b/tests/installer.spec.js index cd60ccc..459da8f 100644 --- a/tests/installer.spec.js +++ b/tests/installer.spec.js @@ -4,6 +4,48 @@ const path = require('path'); const registry = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'devices.json'), 'utf8')); +const manifestFor = (file, version = '1.0.0') => JSON.stringify({ + name: 't', version, + builds: [{ chipFamily: 'ESP32', parts: [{ path: file, offset: 0 }] }], +}); + +// Keep the suite hermetic: no live GitHub API calls (60/hour/IP, shared on CI +// runners), manifests or configs unless a test routes its own. Playwright runs +// the most recently registered matching route first, so per-test routes win. +test.beforeEach(async ({ page }) => { + await page.route('https://api.github.com/**', (route) => route.fulfill({ status: 403 })); + await page.route('https://apolloautomation.github.io/**/manifest.json', (route) => + route.fulfill({ contentType: 'application/json', body: manifestFor('firmware.bin') })); + await page.route('https://raw.githubusercontent.com/**', (route) => + route.fulfill({ contentType: 'text/plain', body: 'esphome:\n name: stub\n' })); +}); + +// Serve `body` after `ms` and expose when it was delivered, so stale-write tests +// assert right after the late response lands instead of sleeping past it. +function lateRoute(ms, body, contentType) { + let delivered; + const done = new Promise((r) => { delivered = r; }); + const handler = async (route) => { + await new Promise((r) => setTimeout(r, ms)); + await route.fulfill({ contentType, body }); + delivered(); + }; + return { handler, done }; +} +// Let the page finish handling a response that just arrived. +const settle = (page) => page.evaluate(() => new Promise((r) => + requestAnimationFrame(() => requestAnimationFrame(r)))); + +// Pin the WebSerial path either way so tests don't depend on the browser build. +const forceSerial = (page) => page.addInitScript(() => { + if (!('serial' in navigator)) { + Object.defineProperty(navigator, 'serial', { value: {}, configurable: true }); + } +}); +const noSerial = (page) => page.addInitScript(() => { + Object.defineProperty(Navigator.prototype, 'serial', { get: () => undefined }); +}); + test('hub renders a card for every registry device', async ({ page }) => { await page.goto('/'); for (const d of registry.devices) { @@ -69,6 +111,7 @@ test('channel/variant toggles expose aria-pressed reflecting the selection', asy test('deep link renders the device wizard', async ({ page }) => { const d = registry.devices[0]; + await forceSerial(page); await page.goto(`/#/${d.id}`); await expect(page.locator('.device-head h1')).toHaveText(d.name); await expect(page.locator('esp-web-install-button')).toHaveAttribute( @@ -93,13 +136,7 @@ test('channel/variant toggles rewire the install button', async ({ page }) => { test('changing variant reuses the install button instead of recreating it', async ({ page }) => { const d = registry.devices.find((x) => Object.keys(x.firmware.stable).length > 1); test.skip(!d, 'no multi-variant device in registry'); - // Force the WebSerial path (headless Chromium lacks navigator.serial) so the - // esp-web-install-button renders. - await page.addInitScript(() => { - if (!('serial' in navigator)) { - Object.defineProperty(navigator, 'serial', { value: {}, configurable: true }); - } - }); + await forceSerial(page); await page.goto(`/#/${d.id}`); // Tag the initially-rendered install button so we can tell if it survives. @@ -119,39 +156,31 @@ test('changing variant reuses the install button instead of recreating it', asyn }); test('manual fallback renders when WebSerial is unavailable', async ({ page }) => { - await page.addInitScript(() => { - Object.defineProperty(Navigator.prototype, 'serial', { get: () => undefined }); - }); + await noSerial(page); const d = registry.devices[0]; await page.goto(`/#/${d.id}`); await expect(page.locator('.fallback')).toBeVisible(); await expect(page.locator('esp-web-install-button')).toHaveCount(0); // esptool v5 spells the subcommand `write-flash` and needs an explicit --port. - await expect(page.locator('.fallback code')).toContainText('write-flash'); - await expect(page.locator('.fallback code')).toContainText('--port'); + const esptool = page.locator('.fallback code', { hasText: 'esptool' }); + await expect(esptool).toContainText('write-flash'); + await expect(esptool).toContainText('--port'); }); test('late manifest fetch for a deselected variant does not overwrite the fallback list', async ({ page }) => { const d = registry.devices.find((x) => Object.keys(x.firmware.stable).length > 1); test.skip(!d, 'no multi-variant device in registry'); const variants = Object.keys(d.firmware.stable); - const manifestFor = (file) => JSON.stringify({ - name: 't', version: '1', - builds: [{ chipFamily: 'ESP32', parts: [{ path: file, offset: 0 }] }], - }); - await page.addInitScript(() => { - Object.defineProperty(Navigator.prototype, 'serial', { get: () => undefined }); - }); + await noSerial(page); // First variant's manifest responds slowly, after the user has moved on. - await page.route(d.firmware.stable[variants[0]], async (route) => { - await new Promise((r) => setTimeout(r, 1500)); - await route.fulfill({ contentType: 'application/json', body: manifestFor('wifi.bin') }); - }); + const slow = lateRoute(1500, manifestFor('wifi.bin'), 'application/json'); + await page.route(d.firmware.stable[variants[0]], slow.handler); await page.route(d.firmware.stable[variants[1]], (route) => route.fulfill({ contentType: 'application/json', body: manifestFor('eth.bin') })); await page.goto(`/#/${d.id}`); await page.locator(`#variant-seg button[data-variant="${variants[1]}"]`).click(); - await page.waitForTimeout(2500); + await slow.done; + await settle(page); await expect(page.locator('#fallback-files a[href$="eth.bin"]')).toHaveCount(1); await expect(page.locator('#fallback-files a[href$="wifi.bin"]')).toHaveCount(0); }); @@ -160,25 +189,18 @@ test('late manifest fetch does not leak into another device page after navigatio const old = registry.devices.find((x) => Object.keys(x.firmware.stable).length > 1); test.skip(!old, 'no multi-variant device in registry'); const next = registry.devices.find((x) => x.id !== old.id); - const manifestFor = (file) => JSON.stringify({ - name: 't', version: '1', - builds: [{ chipFamily: 'ESP32', parts: [{ path: file, offset: 0 }] }], - }); - await page.addInitScript(() => { - Object.defineProperty(Navigator.prototype, 'serial', { get: () => undefined }); - }); + await noSerial(page); // Old device's manifest responds slowly — after the user has navigated away. - await page.route(old.firmware.stable[Object.keys(old.firmware.stable)[0]], async (route) => { - await new Promise((r) => setTimeout(r, 1500)); - await route.fulfill({ contentType: 'application/json', body: manifestFor('old-device.bin') }); - }); + const slow = lateRoute(1500, manifestFor('old-device.bin'), 'application/json'); + await page.route(old.firmware.stable[Object.keys(old.firmware.stable)[0]], slow.handler); await page.route(next.firmware.stable[Object.keys(next.firmware.stable)[0]], (route) => route.fulfill({ contentType: 'application/json', body: manifestFor('new-device.bin') })); await page.goto(`/#/${old.id}`); await expect(page.locator('.fallback')).toBeVisible(); await page.evaluate((hash) => { location.hash = hash; }, `#/${next.id}`); await expect(page.locator('.device-head h1')).toHaveText(next.name); - await page.waitForTimeout(2500); + await slow.done; + await settle(page); // The stale response must not overwrite the new device's fallback list. await expect(page.locator('#fallback-files a[href$="old-device.bin"]')).toHaveCount(0); await expect(page.locator('#fallback-files a[href$="new-device.bin"]')).toHaveCount(1); @@ -186,8 +208,10 @@ test('late manifest fetch does not leak into another device page after navigatio test('release notes render from the GitHub API', async ({ page }) => { const d = registry.devices[0]; + await page.route(d.firmware.stable[Object.keys(d.firmware.stable)[0]], (route) => + route.fulfill({ contentType: 'application/json', body: manifestFor('a.bin', '99.9.9.9') })); await page.route('https://api.github.com/**', (route) => - route.fulfill({ json: { name: 'v99.9.9.9', body: '- test note line', html_url: 'https://example.com/rel' } })); + route.fulfill({ json: { name: 'v99.9.9.9', tag_name: '99.9.9.9', body: '- test note line', html_url: 'https://example.com/rel' } })); await page.goto(`/#/${d.id}`); await expect(page.locator('.release-notes summary')).toContainText('v99.9.9.9'); }); @@ -204,8 +228,10 @@ test('release notes ignore an off-allowlist API url and use the safe releases hr const d = registry.devices[0]; // A compromised or unexpected html_url must never become the "Full release" link; // anything not on github.com falls back to the device's releases page. + await page.route(d.firmware.stable[Object.keys(d.firmware.stable)[0]], (route) => + route.fulfill({ contentType: 'application/json', body: manifestFor('a.bin', '1.2.3.4') })); await page.route('https://api.github.com/**', (route) => - route.fulfill({ json: { name: 'v1.2.3.4', body: '- note', html_url: 'https://evil.example.com/pwn' } })); + route.fulfill({ json: { name: 'v1.2.3.4', tag_name: '1.2.3.4', body: '- note', html_url: 'https://evil.example.com/pwn' } })); await page.goto(`/#/${d.id}`); const full = page.locator('.release-notes a', { hasText: 'Full release' }); await expect(full).toHaveAttribute('href', `https://github.com/${d.repo}/releases`); @@ -424,15 +450,14 @@ test('device config: a slow fetch for a deselected variant does not overwrite', const d = registry.devices.find((x) => x.config && x.config.stable && Object.keys(x.config.stable).length > 1); test.skip(!d, 'no multi-variant config device'); const variants = Object.keys(d.config.stable); - await page.route(d.config.stable[variants[0]], async (route) => { - await new Promise((r) => setTimeout(r, 1500)); - await route.fulfill({ contentType: 'text/plain', body: 'esphome:\n name: cfg-slow-0\n' }); - }); + const slow = lateRoute(1500, 'esphome:\n name: cfg-slow-0\n', 'text/plain'); + await page.route(d.config.stable[variants[0]], slow.handler); await page.route(d.config.stable[variants[1]], (route) => route.fulfill({ contentType: 'text/plain', body: 'esphome:\n name: cfg-fast-1\n' })); await page.goto(`/#/${d.id}`); await page.locator(`#variant-seg button[data-variant="${variants[1]}"]`).click(); - await page.waitForTimeout(2500); + await slow.done; + await settle(page); await expect(page.locator('.config-yaml code')).toContainText('cfg-fast-1'); await expect(page.locator('.config-yaml code')).not.toContainText('cfg-slow-0'); }); @@ -475,3 +500,211 @@ test('single-firmware devices show the "nothing to choose" note above the releas }); expect(noteBeforeRelease).toBe(true); }); + +test('every device and variant puts its own manifest on the install button', async ({ page }) => { + // The site's worst failure mode is a wrong-firmware link, so walk the whole + // registry instead of sampling one device. + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + await forceSerial(page); + await page.goto('/'); + for (const d of registry.devices) { + await page.evaluate((hash) => { location.hash = hash; }, `#/${d.id}`); + await expect(page.locator('.device-head h1')).toHaveText(d.name); + await expect(page).toHaveTitle(`${d.name} · Apollo Installer`); + const channels = Object.keys(d.firmware); + for (const channel of channels) { + if (channels.length > 1) await page.locator(`#channel-seg button[data-channel="${channel}"]`).click(); + const variants = Object.keys(d.firmware[channel]); + for (const variant of variants) { + if (variants.length > 1) await page.locator(`#variant-seg button[data-variant="${variant}"]`).click(); + await expect(page.locator('#install-slot esp-web-install-button')) + .toHaveAttribute('manifest', d.firmware[channel][variant]); + } + } + } + expect(errors).toEqual([]); +}); + +test('every hub card image loads', async ({ page }) => { + await page.goto('/'); + const imgs = page.locator('a.device-card img'); + await expect(imgs).toHaveCount(registry.devices.length); + for (const img of await imgs.all()) { + await img.scrollIntoViewIfNeeded(); + await expect.poll(() => img.evaluate((el) => el.complete && el.naturalWidth)).toBeGreaterThan(0); + } +}); + +test('step 3 says there is nothing to add for a standalone (no Wi-Fi) variant', async ({ page }) => { + const d = registry.devices.find((x) => x.platforms && x.platforms.stable + && Object.values(x.platforms.stable).includes('standalone')); + test.skip(!d, 'no standalone variant in registry'); + const standalone = Object.keys(d.platforms.stable).find((v) => d.platforms.stable[v] === 'standalone'); + const networked = Object.keys(d.firmware.stable).find((v) => v !== standalone); + await page.goto(`/#/${d.id}`); + const done = page.locator('#step-done'); + await page.locator(`#variant-seg button[data-variant="${standalone}"]`).click(); + await expect(done.locator('h2')).toContainText("You're done"); + await expect(done).toContainText('nothing to add'); + await expect(done).not.toContainText('ESPHome'); + await expect(done).not.toContainText('Devices & services'); + await page.locator(`#variant-seg button[data-variant="${networked}"]`).click(); + await expect(done.locator('h2')).toContainText('Add to Home Assistant'); +}); + +test('a beta channel swaps manifests, keeps the variant on re-click, and shows prerelease notes', async ({ page }) => { + const src = registry.devices.find((x) => Object.keys(x.firmware.stable).length > 1); + test.skip(!src, 'no multi-variant device in registry'); + // No device ships a beta channel today, so synthesize one. + const d = JSON.parse(JSON.stringify(src)); + const variants = Object.keys(d.firmware.stable); + d.firmware.beta = Object.fromEntries(variants.map((v) => + [v, d.firmware.stable[v].replace('/manifest.json', '-beta/manifest.json')])); + await page.route('**/devices.json', (route) => route.fulfill({ json: { devices: [d] } })); + await page.route('**/*-beta/manifest.json', (route) => + route.fulfill({ contentType: 'application/json', body: manifestFor('b.bin', '2.1.0-beta') })); + await page.route(`https://api.github.com/repos/${d.repo}/releases?per_page=15`, (route) => route.fulfill({ json: [ + { name: 'v2.0.0', tag_name: '2.0.0', prerelease: false, body: 'stable', html_url: 'https://github.com/a/b/releases/tag/2.0.0' }, + { name: 'v2.1.0-beta', tag_name: '2.1.0-beta', prerelease: true, body: 'beta', html_url: 'https://github.com/a/b/releases/tag/2.1.0-beta' }, + ] })); + await forceSerial(page); + await page.goto(`/#/${d.id}`); + const btn = page.locator('#install-slot esp-web-install-button'); + await expect(btn).toHaveAttribute('manifest', d.firmware.stable[variants[0]]); + + await page.locator('#channel-seg button[data-channel="beta"]').click(); + await expect(btn).toHaveAttribute('manifest', d.firmware.beta[variants[0]]); + await page.locator(`#variant-seg button[data-variant="${variants[1]}"]`).click(); + await expect(btn).toHaveAttribute('manifest', d.firmware.beta[variants[1]]); + await expect(page.locator('.release-notes summary')).toContainText('v2.1.0-beta'); + + // Re-clicking the active channel keeps the chosen variant. + await page.locator('#channel-seg button[data-channel="beta"]').click(); + await expect(btn).toHaveAttribute('manifest', d.firmware.beta[variants[1]]); + + await page.locator('#channel-seg button[data-channel="stable"]').click(); + await expect(btn).toHaveAttribute('manifest', d.firmware.stable[variants[0]]); + await expect(page.locator('#channel-seg button[data-channel="stable"]')).toHaveAttribute('aria-pressed', 'true'); +}); + +test('release notes give way to the version when the latest release is another build', async ({ page }) => { + // e.g. WLED-MM-M1's only release predates the builds its manifests serve. + const d = registry.devices[0]; + await page.route(d.firmware.stable[Object.keys(d.firmware.stable)[0]], (route) => + route.fulfill({ contentType: 'application/json', body: manifestFor('a.bin', '2.0.0.1') })); + await page.route('https://api.github.com/**', (route) => route.fulfill({ json: { + name: 'Initial release', tag_name: '1.0.0.1', body: 'old', html_url: 'https://github.com/a/b/releases/tag/1.0.0.1' } })); + await page.goto(`/#/${d.id}`); + await expect(page.locator('.release-notes')).toContainText('Installs build 2.0.0.1'); + await expect(page.locator('.release-notes summary')).toHaveCount(0); + await expect(page.locator('.release-notes a', { hasText: 'Full release' })).toHaveCount(0); +}); + +test("an older build doesn't borrow the latest release's notes via its compare link", async ({ page }) => { + // Every release body ends with a "Full Changelog" link naming the previous version. + const d = registry.devices[0]; + await page.route(d.firmware.stable[Object.keys(d.firmware.stable)[0]], (route) => + route.fulfill({ contentType: 'application/json', body: manifestFor('a.bin', '26.7.14.1') })); + await page.route('https://api.github.com/**', (route) => route.fulfill({ json: { + name: 'Release 26.10.12.1', tag_name: '26.10.12.1', html_url: 'https://github.com/a/b/releases/tag/26.10.12.1', + body: '- fixes\n\n**Full Changelog**: https://github.com/a/b/compare/26.7.14.1...26.10.12.1' } })); + await page.goto(`/#/${d.id}`); + await expect(page.locator('.release-notes.version-only')).toContainText('Installs build 26.7.14.1'); +}); + +test('release notes that name the flashed version still show (bundle-style tags)', async ({ page }) => { + const d = registry.devices[0]; + await page.route(d.firmware.stable[Object.keys(d.firmware.stable)[0]], (route) => + route.fulfill({ contentType: 'application/json', body: manifestFor('a.bin', '16.0.1') })); + await page.route('https://api.github.com/**', (route) => route.fulfill({ json: { + name: 'Firmware bundle b8', tag_name: 'm1-b8', body: 'v16.0.1 with fixes', html_url: 'https://github.com/a/b/releases/tag/m1-b8' } })); + await page.goto(`/#/${d.id}`); + await expect(page.locator('.release-notes summary')).toContainText('Firmware bundle b8'); +}); + +test('fallback file list keeps only https parts and shows each offset', async ({ page }) => { + const d = registry.devices[0]; + await noSerial(page); + await page.route(d.firmware.stable[Object.keys(d.firmware.stable)[0]], (route) => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ version: '1', builds: [{ chipFamily: 'ESP32', parts: [ + { path: 'my file.bin', offset: 0 }, + { path: 'javascript:alert(1)', offset: 0 }, + { path: 'boot.bin', offset: 4096 }, + ] }] }), + })); + await page.goto(`/#/${d.id}`); + const links = page.locator('#fallback-files a'); + await expect(links).toHaveCount(2); + await expect(links.first()).toHaveText('my file.bin'); + await expect(links.first()).toHaveAttribute('href', /\/my%20file\.bin$/); + await expect(page.locator('#fallback-files')).toContainText('0x1000'); +}); + +test('hash routing: unknown ids show the hub with a notice; case and trailing slash are ignored', async ({ page }) => { + await page.goto('/#/no-such-device'); + await expect(page.locator('.device-grid')).toBeVisible(); + await expect(page.locator('#hub-notice')).toContainText('no-such-device'); + // The hash is user input and must stay text. + await page.goto('/#/bold'); + await expect(page.locator('#hub-notice')).toContainText('bold'); + await expect(page.locator('#hub-notice b')).toHaveCount(0); + const d = registry.devices[0]; + await page.goto(`/#/${d.id.toUpperCase()}/`); + await expect(page.locator('.device-head h1')).toHaveText(d.name); + await page.goto('/#/'); + await expect(page.locator('#hub-notice')).toHaveCount(0); + await expect(page).toHaveTitle('Apollo Installer'); +}); + +test('opening a device moves keyboard focus to its heading', async ({ page }) => { + const d = registry.devices[0]; + await page.goto('/'); + await page.locator(`a.device-card[href="#/${d.id}"]`).focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('.device-head h1')).toBeFocused(); +}); + +test('firmware picker is a labelled group', async ({ page }) => { + const d = registry.devices.find((x) => Object.keys(x.firmware.stable).length > 1); + test.skip(!d, 'no multi-variant device in registry'); + await page.goto(`/#/${d.id}`); + await expect(page.getByRole('group', { name: 'Variant' })).toBeVisible(); +}); + +test('theme toggle cycles system, dark, light and remembers the choice', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }); + await page.goto('/'); + const html = page.locator('html'); + const btn = page.locator('#theme-toggle'); + const stored = () => page.evaluate(() => localStorage.getItem('theme')); + await expect(html).not.toHaveAttribute('data-theme', 'dark'); + await expect(btn).toHaveAttribute('aria-label', /System theme/); + + await btn.click(); + await expect(html).toHaveAttribute('data-theme', 'dark'); + expect(await stored()).toBe('dark'); + await page.reload(); + await expect(html).toHaveAttribute('data-theme', 'dark'); + + await btn.click(); + await expect(html).not.toHaveAttribute('data-theme', 'dark'); + expect(await stored()).toBe('light'); + + await btn.click(); + expect(await stored()).toBeNull(); + await expect(btn).toHaveAttribute('aria-label', /System theme/); +}); + +test('every page fits a 360px phone screen without sideways scrolling', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 780 }); + await page.goto('/'); + for (const hash of ['#/', ...registry.devices.map((d) => `#/${d.id}`)]) { + await page.evaluate((h) => { location.hash = h; }, hash); + await expect(page.locator('h1').first()).toBeVisible(); + const [scroll, client] = await page.evaluate(() => + [document.documentElement.scrollWidth, document.documentElement.clientWidth]); + expect(scroll, hash).toBeLessThanOrEqual(client); + } +}); diff --git a/tests/playwright.config.js b/tests/playwright.config.js index 11e3d20..d6baddb 100644 --- a/tests/playwright.config.js +++ b/tests/playwright.config.js @@ -2,10 +2,16 @@ const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ testDir: '.', - use: { baseURL: 'http://localhost:8123' }, + forbidOnly: !!process.env.CI, + use: { + baseURL: 'http://localhost:8123', + trace: 'retain-on-failure', + }, webServer: { command: 'python -m http.server 8123 --directory ..', url: 'http://localhost:8123', - reuseExistingServer: true, + // Locally a server you already run is reused; it must serve this checkout + // (not another worktree), since the spec reads devices.json from disk. + reuseExistingServer: !process.env.CI, }, });