diff --git a/devices.json b/devices.json index 4aaaa41..a0f0cf8 100644 --- a/devices.json +++ b/devices.json @@ -234,6 +234,11 @@ "WLED-MM 14.5.1 (Rev4)": "https://apolloautomation.github.io/WLED-MM-M1/14.5.1/manifest.json", "WLED 16.0.1 (Rev6 only)": "https://apolloautomation.github.io/WLED-M1/manifest.json" } + }, + "repos": { + "stable": { + "WLED 16.0.1 (Rev6 only)": "ApolloAutomation/WLED-M1" + } } } ] diff --git a/js/views/device.js b/js/views/device.js index c6f5c6b..4a46de8 100644 --- a/js/views/device.js +++ b/js/views/device.js @@ -26,6 +26,10 @@ function selectedManifest(device, channel, variant) { return device.firmware[channel][variant]; } +function repoFor(device, channel, variant) { + return (device.repos && device.repos[channel] && device.repos[channel][variant]) || device.repo; +} + function segHtml(id, label, keys, active, dataAttr) { if (keys.length < 2) return ''; return ` @@ -112,12 +116,14 @@ export function renderDevice(el, device) { }); renderInstall(); renderConfig(); + renderReleaseNotes(); }); } async function renderInstall() { const myEpoch = epoch; const manifest = selectedManifest(device, channel, variant); + const repo = repoFor(device, channel, variant); if (hasSerial) { installSlot.innerHTML = ` @@ -153,7 +159,7 @@ export function renderDevice(el, device) { if (epoch !== myEpoch) return; // selection changed mid-fetch filesEl.innerHTML = `
  • Couldn't load the file list — download firmware from the - latest release.
  • `; + latest release.`; } } } @@ -162,10 +168,11 @@ export function renderDevice(el, device) { const slot = el.querySelector('#release-slot'); slot.innerHTML = ''; const myEpoch = epoch; + const repo = repoFor(device, channel, variant); try { - const rel = await fetchReleaseNotes(device.repo, channel); + 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/${device.repo}/releases`; + const url = /^https:\/\/github\.com\//.test(rel.url) ? rel.url : `https://github.com/${repo}/releases`; slot.innerHTML = `
    @@ -178,7 +185,7 @@ export function renderDevice(el, device) { if (epoch !== myEpoch) return; // selection changed mid-fetch slot.innerHTML = `
    - See recent releases + See recent releases for what's new.
    `; } diff --git a/scripts/test_validate_registry.py b/scripts/test_validate_registry.py index 656581a..7354705 100644 --- a/scripts/test_validate_registry.py +++ b/scripts/test_validate_registry.py @@ -92,5 +92,33 @@ def test_non_dict_config_errors_without_crashing(self): self.assertTrue(any("config" in e for e in errs), (bad, errs)) +class ReposShapeChecks(unittest.TestCase): + FW = {"stable": {"v16": "https://x/m.json", "v14": "https://y/m.json"}} + + def test_absent_repos_ok(self): + self.assertEqual(vr.check_repos_shape(None, self.FW, "dev"), []) + + def test_valid_override_ok(self): + repos = {"stable": {"v16": "Owner/Repo"}} + self.assertEqual(vr.check_repos_shape(repos, self.FW, "dev"), []) + + def test_repos_not_dict_errors(self): + errs = vr.check_repos_shape([], self.FW, "dev") + self.assertTrue(any("repos" in e for e in errs), errs) + + def test_channel_not_dict_errors(self): + errs = vr.check_repos_shape({"stable": "x"}, self.FW, "dev") + self.assertTrue(any("stable" in e for e in errs), errs) + + def test_bad_owner_name_errors(self): + for bad in ("OwnerRepo", "a/b/c", "own er/repo", "", "/repo", "owner/"): + errs = vr.check_repos_shape({"stable": {"v16": bad}}, self.FW, "dev") + self.assertTrue(any("owner/name" in e for e in errs), (bad, errs)) + + def test_variant_not_in_firmware_errors(self): + errs = vr.check_repos_shape({"stable": {"ghost": "Owner/Repo"}}, self.FW, "dev") + self.assertTrue(any("no such firmware variant" in e for e in errs), errs) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/validate_registry.py b/scripts/validate_registry.py index 16eb409..c462381 100644 --- a/scripts/validate_registry.py +++ b/scripts/validate_registry.py @@ -67,6 +67,31 @@ def check_config_shape(config, dev_id): errs.append(f"{dev_id} config {channel}/{variant}: not an https URL") return errs +def check_repos_shape(repos, firmware, dev_id): + """Validate the optional `repos` map (channel -> variant -> "owner/name"). + + Network-free. `repos` absent (None) is valid. Every variant key must exist + in `firmware[channel]`, so a mistyped key that would silently fall back to + the device repo is caught instead. Returns a list of error strings. + """ + errs = [] + if repos is None: + return errs + if not isinstance(repos, dict): + errs.append(f"{dev_id} repos: not an object") + return errs + for channel, variants in repos.items(): + if not isinstance(variants, dict): + 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("/"))): + 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") + return errs + def check_manifest(dev_id, channel, variant, murl): where = f"{dev_id} {channel}/{variant}" try: @@ -97,6 +122,7 @@ def main(): check_manifest(dev["id"], channel, variant, murl) 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"])) if not isinstance(config, dict): continue for channel, variants in config.items(): diff --git a/tests/installer.spec.js b/tests/installer.spec.js index 50f976e..21f991e 100644 --- a/tests/installer.spec.js +++ b/tests/installer.spec.js @@ -183,6 +183,29 @@ test('release notes ignore an off-allowlist API url and use the safe releases hr await expect(full).toHaveAttribute('href', `https://github.com/${d.repo}/releases`); }); +test('release notes follow the selected variant repo (per-variant repos override)', async ({ page }) => { + const d = registry.devices.find((x) => x.repos && x.repos.stable); + test.skip(!d, 'no device with a per-variant repos override'); + const overrideVariant = Object.keys(d.repos.stable)[0]; + const overrideRepo = d.repos.stable[overrideVariant]; + const defaultVariant = Object.keys(d.firmware.stable)[0]; + test.skip(overrideVariant === defaultVariant, 'override is on the default variant'); + + // Force the API-failure path so the deterministic .fail-link (built from the + // resolved repo) is what we assert on. + await page.route('https://api.github.com/**', (route) => route.fulfill({ status: 403 })); + await page.goto(`/#/${d.id}`); + + // Default variant resolves to the device-level repo. + await expect(page.locator('.release-notes .fail-link')) + .toHaveAttribute('href', `https://github.com/${d.repo}/releases`); + + // Selecting the override variant must re-render release notes against the override repo. + await page.locator(`#variant-seg button[data-variant="${overrideVariant}"]`).click(); + await expect(page.locator('.release-notes .fail-link')) + .toHaveAttribute('href', `https://github.com/${overrideRepo}/releases`); +}); + test('step 3 shows the Home Assistant hand-off', async ({ page }) => { const d = registry.devices[0]; await page.goto(`/#/${d.id}`);