diff --git a/tests/page/desk_panels.mjs b/tests/page/desk_panels.mjs index 3cf19fc..5ddf353 100644 --- a/tests/page/desk_panels.mjs +++ b/tests/page/desk_panels.mjs @@ -8,6 +8,12 @@ const grab = name => { if (!m) throw new Error("not found: " + name); return m[0]; }; +// The page's scripts share one global scope, so a second loadPanels would silently replace +// the Desktop view's (what broke #73 until #84): the one grabbed here must be the only one. +for (const name of ["loadPanels", "noPanelWhy", "deskPanel", "ctrlTarget"]) { + const count = (src.match(new RegExp(`\\n(?:async )?function ${name}\\(`, "g")) || []).length; + if (count !== 1) { console.log(`FAIL ${name} is defined ${count} times`); process.exit(1); } +} const code = ["desk", "panelKey", "noPanelWhy", "deskPanel", "loadPanels", "ctrlTarget"].map(grab).join(""); const fail = msg => { console.log("FAIL " + msg); process.exit(1); }; @@ -59,4 +65,18 @@ function page(answer) { if (!t.why || t.why.includes("Couldn't list")) fail("after recovery: " + JSON.stringify(t)); if (p.desk.panels.length !== 1) fail("panels after recovery"); } +// Waiting for the Frame: shown in Control, not logged every poll. An answer about the +// previous headset (stale) is ignored. +{ + let err; + const p = page(() => { throw err; }); + err = Object.assign(new Error("Can't reach the Frame"), { offline: true }); + await p.loadPanels(); await p.loadPanels(); + if (p.logged.length) fail("offline logged: " + p.logged); + if (p.ctrlTarget().why !== "Couldn't list the headset's panels: Can't reach the Frame") fail("offline: " + p.ctrlTarget().why); + const q = page(() => { throw Object.assign(new Error("old headset"), { offline: true, stale: true }); }); + await q.loadPanels(); + if (q.logged.length || q.desk.error) fail("stale answer used: " + q.desk.error); + if (q.ctrlTarget().why !== "Finding the headset's panels…") fail("stale: " + q.ctrlTarget().why); +} console.log("ok"); diff --git a/tests/page/store_detail.mjs b/tests/page/store_detail.mjs new file mode 100644 index 0000000..2696b2d --- /dev/null +++ b/tests/page/store_detail.mjs @@ -0,0 +1,93 @@ +// 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. +// (tests/test_server.py PageScripts checks no top-level name is declared twice.) +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", "openStoreApp", "loadOfferDetails"]; +const code = names.map(grab).join(""); + +// A fake page: elements by id. The install panel's elements exist only while +// #detailBody holds the real app page, as in the browser. +function page(api = async () => ({})) { + const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } }; + const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id); + const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null + : (els[id] ||= { innerHTML: "", textContent: "", hidden: false, value: 0, removeAttribute() {}, querySelectorAll: () => [] }); + const sourceState = { apps: [], detail: null, detailRequest: 0, jobs: new Map() }; + const document = { activeElement: null, querySelector: () => null }; + const console = { error() {} }; + const calls = []; + const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")( + $, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }); + return { els, sourceState, calls, ...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 }; + if (p.renderAppDetail() !== true) fail("a good listing reported a render failure"); + 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 }; + if (p.renderAppDetail() !== false) fail("a broken listing reported success"); + 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"); +} + +// Opening a listing that can't render: the error stays up, no details request, nothing thrown. +{ + const p = page(async () => { throw new Error("offline"); }); + p.sourceState.apps = [{ name: "Broken", offers: [] }]; + await p.openStoreApp(0); + if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error"); + if (p.calls.length) fail("asked for details of a listing that couldn't render"); +} + +// Opening a good listing: the dialog opens with its page, and fresher details fill it in. +{ + const p = page(async () => ({ description: "Fresh description." })); + p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }]; + await p.openStoreApp(0); + if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes("Fresh description.")) fail("details didn't fill in the page"); + if (!String(p.calls[0]?.[0]).startsWith("/api/sources/details?")) fail("no details request: " + JSON.stringify(p.calls)); +} + +// A good listing whose details request fails keeps its page, with a note. +{ + const p = page(async () => { throw new Error("offline"); }); + p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }]; + await p.openStoreApp(0); + if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request"); + if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request"); +} +console.log("ok"); diff --git a/tests/test_server.py b/tests/test_server.py index 7dab96c..97e9f80 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -411,6 +411,59 @@ class ArtworkSettings(unittest.TestCase): self.assertLess(page.index("async function api("), page.index('", 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): + for name in self.PAGES: + source = self.page_scripts((ROOT / "ui" / name).read_text(encoding="utf-8")) + self.assertIn("function", source, name) + rc, err = self.module_check(source) + self.assertEqual(rc, 0, f"{name}: {err}") + + def test_store_app_page_renders(self): + """tests/page/store_detail.mjs runs the real store functions from index.html.""" + r = subprocess.run(["node", str(ROOT / "tests" / "page" / "store_detail.mjs")], capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + + @unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script") class LocalMode(unittest.TestCase): """FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key diff --git a/ui/index.html b/ui/index.html index 0326317..0046457 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2127,7 +2127,9 @@ async function loadPanels() { if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); } ctrlShow(); } catch (e) { - if (e.message !== desk.error) log("Couldn't list the headset's panels: " + e.message, "e"); + if (e.stale) return; // an answer about the previous headset says nothing about this one + // Polled every few seconds: waiting for the Frame, or the same failure again, isn't news. + if (!e.offline && e.message !== desk.error) log("Couldn't list the headset's panels: " + e.message, "e"); desk.error = e.message; ctrlShow(); } @@ -3180,7 +3182,19 @@ async function searchSources(quiet) { } function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; } function detailBadges(o) { return `${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}${o.verdict?.tone==='works' ? '✓ Works on Frame' : ''}Free${storeOpenSource(o) ? 'Open source' : ''}`; } -function renderDetail() { +// The store's app page. (Not renderDetail: the Devices page declares that further down, +// and a later function declaration silently replaces an earlier one; issue #68.) +// True when the page rendered; false when it shows the error instead. +function renderAppDetail() { + try { renderAppDetailBody(); return true; } + catch(e) { + console.error("App details failed to render", e); + const name=sourceState.detail?.app?.name; + $("detailBody").innerHTML=``; + return false; + } +} +function renderAppDetailBody() { const restoreOfferFocus=document.activeElement?.name==='storeOffer'; const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page); $("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`
${storeIcon(o)}

${esc(o.name)}

${esc(o.developer || o.author || 'From '+storeName(o.source_name))}

${detailBadges(o)}
${images.length ? `` : ''}

About this app

${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}
${page ? `

Meet the creators ↗

` : ''}
Technical details
Package
${esc(o.package||"Unknown")}
Version
${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})
Android API
${esc(o.min_sdk??"Unknown")}
Architecture
${esc(o.abis?.join(', ')||"Unknown / no native code")}
Updated
${esc(o.updated||"Unknown")}
Compatibility
${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}
`; @@ -3200,8 +3214,8 @@ function updateInstall() { async function openStoreApp(index) { const app=sourceState.apps[index];if(!app)return; sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0}; - renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0; - await loadOfferDetails(); + const shown=renderAppDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0; + if(shown)await loadOfferDetails(); } async function loadOfferDetails() { const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest; @@ -3209,8 +3223,8 @@ async function loadOfferDetails() { const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id})); if(request!==sourceState.detailRequest || d!==sourceState.detail)return; const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]}; - d.app.offers[d.choice]={...o,...detail,artwork};renderDetail(); - } catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; } + d.app.offers[d.choice]={...o,...detail,artwork};renderAppDetail(); + } catch(e) { const note=$("detailInstallNote");if(d===sourceState.detail && note)note.textContent="Some app details couldn't be refreshed. You can still view this listing."; } } async function installStoreApp() { const o=currentOffer(), key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key); @@ -3240,8 +3254,8 @@ $("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer) $("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();}; $("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();}; $("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}}; -$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});}; -$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}}; +$("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDetail").close();if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});}; +$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}}; $("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();}; $("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});}; $("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});}; @@ -3793,7 +3807,7 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden && on // ---- Panel switcher: our UI over SteamVR's panel API ---- let panelSeq = 0; -async function loadPanels() { +async function loadPanelSwitcher() { const seq = ++panelSeq; try { const s = await api("/api/panels", {action: "list"}); @@ -3804,7 +3818,7 @@ async function loadPanels() { : '
No open panels.
'; } catch (e) { if (seq === panelSeq) failed($("panelList"), e); } } -$("panelsRefresh").onclick = loadPanels; +$("panelsRefresh").onclick = loadPanelSwitcher; $("panelsOpen").onclick = e => act("Open panel switcher in headset", () => api("/api/panels", {action: "open"}), e.currentTarget); $("panelList").onclick = e => { @@ -3902,7 +3916,7 @@ function showPage() { document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`; const section = !PAGES.includes(id) && id && $(id); if (section) section.scrollIntoView(); else window.scrollTo(0, 0); - if (page === "tools") { loadMacView(); loadPanels(); } + if (page === "tools") { loadMacView(); loadPanelSwitcher(); } } window.addEventListener("hashchange", showPage); // While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).