diff --git a/devices.json b/devices.json index a0f0cf8..fb8a782 100644 --- a/devices.json +++ b/devices.json @@ -239,6 +239,11 @@ "stable": { "WLED 16.0.1 (Rev6 only)": "ApolloAutomation/WLED-M1" } + }, + "installers": { + "stable": { + "WLED 16.0.1 (Rev6 only)": null + } } } ] diff --git a/js/views/device.js b/js/views/device.js index 4a46de8..f976e76 100644 --- a/js/views/device.js +++ b/js/views/device.js @@ -30,6 +30,11 @@ function repoFor(device, channel, variant) { return (device.repos && device.repos[channel] && device.repos[channel][variant]) || device.repo; } +function classicInstallerFor(device, channel, variant) { + const map = device.installers && device.installers[channel]; + return map && variant in map ? map[variant] : device.githubPagesInstaller; +} + function segHtml(id, label, keys, active, dataAttr) { if (keys.length < 2) return ''; return ` @@ -56,11 +61,7 @@ export function renderDevice(el, device) {

${device.name}

${device.description}

- +
@@ -100,6 +101,18 @@ export function renderDevice(el, device) { const variantSlot = el.querySelector('#variant-slot'); const installSlot = el.querySelector('#install-slot'); + const linksSlot = el.querySelector('#links-slot'); + + function renderLinks() { + const repo = repoFor(device, channel, variant); + const classic = classicInstallerFor(device, channel, variant); + const parts = [ + `Setup guide`, + `GitHub`, + ]; + if (classic) parts.push(`Classic installer`); + linksSlot.innerHTML = parts.join(' · '); + } function renderVariantSeg() { variantSlot.innerHTML = segHtml('variant-seg', 'Variant', Object.keys(device.firmware[channel]), variant, 'variant'); @@ -117,6 +130,7 @@ export function renderDevice(el, device) { renderInstall(); renderConfig(); renderReleaseNotes(); + renderLinks(); }); } @@ -124,6 +138,7 @@ export function renderDevice(el, device) { const myEpoch = epoch; const manifest = selectedManifest(device, channel, variant); const repo = repoFor(device, channel, variant); + const classic = classicInstallerFor(device, channel, variant); if (hasSerial) { installSlot.innerHTML = ` @@ -140,7 +155,7 @@ export function renderDevice(el, device) { `; // Pin the list element before the fetch: if the user navigates to another @@ -241,10 +256,12 @@ export function renderDevice(el, device) { renderInstall(); renderReleaseNotes(); renderConfig(); + renderLinks(); }); renderVariantSeg(); renderInstall(); renderReleaseNotes(); renderConfig(); + renderLinks(); } diff --git a/scripts/test_validate_registry.py b/scripts/test_validate_registry.py index 7354705..775f606 100644 --- a/scripts/test_validate_registry.py +++ b/scripts/test_validate_registry.py @@ -120,5 +120,36 @@ def test_variant_not_in_firmware_errors(self): self.assertTrue(any("no such firmware variant" in e for e in errs), errs) +class InstallersShapeChecks(unittest.TestCase): + FW = {"stable": {"v16": "https://x/m.json", "v14": "https://y/m.json"}} + + def test_absent_ok(self): + self.assertEqual(vr.check_installers_shape(None, self.FW, "dev"), []) + + def test_null_hide_ok(self): + self.assertEqual(vr.check_installers_shape({"stable": {"v16": None}}, self.FW, "dev"), []) + + def test_url_override_ok(self): + ins = {"stable": {"v16": "https://apolloautomation.github.io/WLED-M1/"}} + self.assertEqual(vr.check_installers_shape(ins, self.FW, "dev"), []) + + def test_not_dict_errors(self): + errs = vr.check_installers_shape([], self.FW, "dev") + self.assertTrue(any("installers" in e for e in errs), errs) + + def test_channel_not_dict_errors(self): + errs = vr.check_installers_shape({"stable": "x"}, self.FW, "dev") + self.assertTrue(any("stable" in e for e in errs), errs) + + def test_non_https_non_null_errors(self): + for bad in ("http://x", "ftp://x", "not-a-url", 5): + errs = vr.check_installers_shape({"stable": {"v16": bad}}, self.FW, "dev") + self.assertTrue(any("https URL or null" in e for e in errs), (bad, errs)) + + def test_variant_not_in_firmware_errors(self): + errs = vr.check_installers_shape({"stable": {"ghost": None}}, 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 c462381..760d2ec 100644 --- a/scripts/validate_registry.py +++ b/scripts/validate_registry.py @@ -92,6 +92,31 @@ def check_repos_shape(repos, firmware, dev_id): errs.append(f"{dev_id} repos {channel}/{variant}: no such firmware variant") return errs +def check_installers_shape(installers, firmware, dev_id): + """Validate the optional `installers` map (channel -> variant -> https URL or null). + + Network-free. `installers` absent (None) is valid. A variant value of null + means "this variant has no classic installer, hide the link"; any other + value must be an https URL. Every variant key must exist in + `firmware[channel]`. Returns a list of error strings. + """ + errs = [] + if installers is None: + return errs + if not isinstance(installers, dict): + errs.append(f"{dev_id} installers: not an object") + return errs + for channel, variants in installers.items(): + if not isinstance(variants, dict): + errs.append(f"{dev_id} installers {channel}: not an object") + continue + for variant, url in variants.items(): + if url is not None and (not isinstance(url, str) or not url.startswith("https://")): + errs.append(f"{dev_id} installers {channel}/{variant}: not an https URL or null") + if variant not in firmware.get(channel, {}): + errs.append(f"{dev_id} installers {channel}/{variant}: no such firmware variant") + return errs + def check_manifest(dev_id, channel, variant, murl): where = f"{dev_id} {channel}/{variant}" try: @@ -123,6 +148,7 @@ def main(): 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_installers_shape(dev.get("installers"), 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 21f991e..106e85e 100644 --- a/tests/installer.spec.js +++ b/tests/installer.spec.js @@ -206,6 +206,34 @@ test('release notes follow the selected variant repo (per-variant repos override .toHaveAttribute('href', `https://github.com/${overrideRepo}/releases`); }); +test('header GitHub link and classic-installer links follow the selected variant', async ({ page }) => { + const d = registry.devices.find((x) => x.installers && x.installers.stable + && Object.values(x.installers.stable).some((v) => v === null)); + test.skip(!d, 'no device that hides a classic installer for a variant'); + const hiddenVariant = Object.keys(d.installers.stable).find((k) => d.installers.stable[k] === null); + const defaultVariant = Object.keys(d.firmware.stable)[0]; + test.skip(hiddenVariant === defaultVariant, 'hidden installer is on the default variant'); + const overrideRepo = d.repos && d.repos.stable && d.repos.stable[hiddenVariant]; + + await page.route('https://api.github.com/**', (route) => route.fulfill({ status: 403 })); + await page.goto(`/#/${d.id}`); + + // Default variant: header GitHub = device repo, Classic installer link present. + await expect(page.locator('.links a', { hasText: 'GitHub' })) + .toHaveAttribute('href', `https://github.com/${d.repo}`); + await expect(page.locator('.links a', { hasText: 'Classic installer' })).toHaveCount(1); + + // Select the variant whose installer is null. + await page.locator(`#variant-seg button[data-variant="${hiddenVariant}"]`).click(); + + // Header GitHub link now points at the override repo (if set); Classic installer link is gone. + if (overrideRepo) { + await expect(page.locator('.links a', { hasText: 'GitHub' })) + .toHaveAttribute('href', `https://github.com/${overrideRepo}`); + } + await expect(page.locator('.links a', { hasText: 'Classic installer' })).toHaveCount(0); +}); + test('step 3 shows the Home Assistant hand-off', async ({ page }) => { const d = registry.devices[0]; await page.goto(`/#/${d.id}`);