From 2861567d15aaf7dd6060ddb11a7ad35b46b85e75 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:00:03 +1100 Subject: [PATCH 1/2] Android store: app pages open again instead of a bare line (#68) Clicking any app in the Android tab opened an empty dialog, which shows as a single horizontal line (its 1px border). The store's renderDetail was replaced by the Devices page's renderDetail, declared further down the same script: a later function declaration silently wins. The store's call then rendered "Pick a headset." into the Devices panel and left #detailBody empty. Every platform and both 0.4.x releases were affected. The store's renderer is now renderAppDetail, and a render failure shows a clear message with a Close button rather than an empty dialog. The same clash hid the live view's loadPanels behind the Tools panel switcher's; that one is now loadPanelSwitcher. Both names match the open store PR (#56). Tests: a guard that no top-level name is declared twice in the page's shared script scope, and tests/page/store_detail.mjs, which runs the real store functions for a good listing and a broken one. Co-Authored-By: Claude Opus 5.5 --- tests/page/store_detail.mjs | 69 +++++++++++++++++++++++++++++++++++++ tests/test_server.py | 34 ++++++++++++++++++ ui/index.html | 26 +++++++++----- 3 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/page/store_detail.mjs diff --git a/tests/page/store_detail.mjs b/tests/page/store_detail.mjs new file mode 100644 index 0000000..b9df74c --- /dev/null +++ b/tests/page/store_detail.mjs @@ -0,0 +1,69 @@ +// The Android store's app page, run in node against the real functions from ui/index.html. +// Issue #68: clicking an app opened an empty dialog (a bare horizontal line), because the +// Devices page's renderDetail replaced the store's. The store's is renderAppDetail now. +import { readFileSync } from "fs"; +const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8"); +const lines = src.split("\n"); +const fail = msg => { console.log("FAIL " + msg); process.exit(1); }; + +// A top-level declaration, from its first line to where its braces balance. +function grab(name) { + const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l)); + if (start < 0) fail(`no top-level ${name} in index.html`); + let depth = 0; + for (let i = start; i < lines.length; i++) { + for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0; + if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n"; + } + fail(`unbalanced ${name}`); +} +const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage", + "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody", + "updateInstall"]; +const code = names.map(grab).join(""); + +function page() { + const els = {}; + const $ = id => id === "detailInstall" ? null : (els[id] ||= { innerHTML: "", textContent: "" }); + const sourceState = { detail: null, jobs: new Map() }; + const document = { activeElement: null, querySelector: () => null }; + const console = { error() {} }; + const fns = new Function("$", "sourceState", "document", "console", code + "; return { renderAppDetail };")($, sourceState, document, console); + return { els, sourceState, ...fns }; +} + +const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid", name: "Open Brush", summary: "Paint in VR", + description: "Paint in 3D.", version: "2.1", version_code: 21, package: "org.example.brush", + artwork: { icon: "/source-image/abc", screenshots: [] }, downloadable: true, verdict: { tone: "works", label: "Works" } }; + +// An app from the catalogue: its page renders, with its name, install and sources. +{ + const p = page(); + p.sourceState.detail = { app: { name: "Open Brush", offers: [{ ...offer }] }, choice: 0 }; + p.renderAppDetail(); + const html = p.els.detailBody.innerHTML; + if (!html.includes('id="detailTitle">Open Brush<')) fail("app page has no title: " + html.slice(0, 200)); + if (!html.includes('id="detailInstall"') || !html.includes("Get it from")) fail("app page has no install panel"); + if (html.includes('role="alert"')) fail("a good listing showed the error state"); +} + +// A listing that can't render (no offer to show) gets a clear message, not an empty dialog. +{ + const p = page(); + p.sourceState.detail = { app: { name: "Broken ", offers: [] }, choice: 0 }; + p.renderAppDetail(); + const html = p.els.detailBody.innerHTML; + if (!html.includes('role="alert"') || !html.includes("We couldn't show Broken <App>")) fail("no error state: " + html); + if (!html.includes('data-close="appDetail"')) fail("error state has no way to close"); +} + +// No other top-level function in the page's scripts may take a store function's name: +// a later function declaration silently replaces an earlier one. +{ + const scripts = [...src.matchAll(/", page)) + for src in re.findall(r'", html) + parts += [(ROOT / "ui" / src).read_text(encoding="utf-8") for src in re.findall(r'" + across = "

x

" + same_line = "" + const_and_function = "" + for html in (dup, across, same_line, const_and_function): + rc, err = self.module_check(self.page_scripts(html)) + self.assertNotEqual(rc, 0, html) + self.assertIn("has already been declared", err, html) + nested = ("") + self.assertEqual(self.module_check(self.page_scripts(nested)), (0, "")) def test_no_top_level_name_is_declared_twice(self): - import re - for name in ("index.html", "assistant.html", "mac-view.html"): - page = (ROOT / "ui" / name).read_text(encoding="utf-8") - scripts = "\n".join(re.findall(r"", page)) - for src in re.findall(r'