Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions devices.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@
"stable": {
"WLED 16.0.1 (Rev6 only)": "ApolloAutomation/WLED-M1"
}
},
"installers": {
"stable": {
"WLED 16.0.1 (Rev6 only)": null
}
}
}
]
Expand Down
29 changes: 23 additions & 6 deletions js/views/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand All @@ -56,11 +61,7 @@ export function renderDevice(el, device) {
<div>
<h1>${device.name}</h1>
<p>${device.description}</p>
<p class="links">
<a href="${device.wiki}">Setup guide</a> ·
<a href="https://github.com/${device.repo}">GitHub</a> ·
<a href="${device.githubPagesInstaller}">Classic installer</a>
</p>
<p class="links" id="links-slot"></p>
</div>
</div>

Expand Down Expand Up @@ -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 = [
`<a href="${device.wiki}">Setup guide</a>`,
`<a href="https://github.com/${repo}">GitHub</a>`,
];
if (classic) parts.push(`<a href="${classic}">Classic installer</a>`);
linksSlot.innerHTML = parts.join(' · ');
}

function renderVariantSeg() {
variantSlot.innerHTML = segHtml('variant-seg', 'Variant', Object.keys(device.firmware[channel]), variant, 'variant');
Expand All @@ -117,13 +130,15 @@ export function renderDevice(el, device) {
renderInstall();
renderConfig();
renderReleaseNotes();
renderLinks();
});
}

async function renderInstall() {
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 = `
<esp-web-install-button manifest="${manifest}">
Expand All @@ -140,7 +155,7 @@ export function renderDevice(el, device) {
<ul>
<li>Flash it with <a href="https://web.esphome.io">ESPHome Web</a> (open it in Chrome, Edge, or Firefox) or
<code>esptool write-flash --port &lt;port&gt; 0x0 &lt;file&gt;</code>.</li>
<li>Or use the <a href="${device.githubPagesInstaller}">classic installer page</a> in Chrome, Edge, or Firefox.</li>
${classic ? `<li>Or use the <a href="${classic}">classic installer page</a> in Chrome, Edge, or Firefox.</li>` : ''}
</ul>
</div>`;
// Pin the list element before the fetch: if the user navigates to another
Expand Down Expand Up @@ -241,10 +256,12 @@ export function renderDevice(el, device) {
renderInstall();
renderReleaseNotes();
renderConfig();
renderLinks();
});

renderVariantSeg();
renderInstall();
renderReleaseNotes();
renderConfig();
renderLinks();
}
31 changes: 31 additions & 0 deletions scripts/test_validate_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
26 changes: 26 additions & 0 deletions scripts/validate_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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():
Expand Down
28 changes: 28 additions & 0 deletions tests/installer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Loading