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 @@ -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"
}
}
}
]
Expand Down
15 changes: 11 additions & 4 deletions js/views/device.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand Down Expand Up @@ -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 = `
<esp-web-install-button manifest="${manifest}">
Expand Down Expand Up @@ -153,7 +159,7 @@ export function renderDevice(el, device) {
if (epoch !== myEpoch) return; // selection changed mid-fetch
filesEl.innerHTML =
`<li>Couldn't load the file list — download firmware from the
<a href="https://github.com/${device.repo}/releases">latest release</a>.</li>`;
<a href="https://github.com/${repo}/releases">latest release</a>.</li>`;
}
}
}
Expand All @@ -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 = `
<div class="release-notes">
<details>
Expand All @@ -178,7 +185,7 @@ export function renderDevice(el, device) {
if (epoch !== myEpoch) return; // selection changed mid-fetch
slot.innerHTML = `
<div class="release-notes">
See <a class="fail-link" href="https://github.com/${device.repo}/releases">recent releases</a>
See <a class="fail-link" href="https://github.com/${repo}/releases">recent releases</a>
for what's new.
</div>`;
}
Expand Down
28 changes: 28 additions & 0 deletions scripts/test_validate_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
26 changes: 26 additions & 0 deletions scripts/validate_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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():
Expand Down
23 changes: 23 additions & 0 deletions tests/installer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Loading