diff --git a/docs/sidequest.md b/docs/sidequest.md index eba0f02..23311d0 100644 --- a/docs/sidequest.md +++ b/docs/sidequest.md @@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here. - `search()` raises a user-readable `SourceError` with the browse URL (zero limit returns no rows). It does not invent app results or report a false “no matching games”. The aggregate search UI should surface this source error. + In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest + with a link to its website instead of an on/off switch. - `details()` accepts a numeric listing id and returns its canonical page link, `downloadable: False`, empty versions/tags/headsets and the `images` shape `{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id; diff --git a/tests/page/store_detail.mjs b/tests/page/store_detail.mjs index 2696b2d..bf740b3 100644 --- a/tests/page/store_detail.mjs +++ b/tests/page/store_detail.mjs @@ -19,8 +19,8 @@ function grab(name) { fail(`unbalanced ${name}`); } const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage", - "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody", - "updateInstall", "openStoreApp", "loadOfferDetails"]; + "GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody", + "updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"]; const code = names.map(grab).join(""); // A fake page: elements by id. The install panel's elements exist only while @@ -34,8 +34,10 @@ function page(api = async () => ({})) { 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); }); + const fns = new Function("$", "sourceState", "document", "console", "api", "runJob", "loadAndroid", "act", "window", code + "; return { renderAppDetail, openStoreApp };")( + $, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, + async (title, key, work) => work(), () => {}, async (title, work) => work(), + { open() { fail("an in-app installation opened an external page"); } }); return { els, sourceState, calls, ...fns }; } @@ -68,7 +70,7 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid { const p = page(async () => { throw new Error("offline"); }); p.sourceState.apps = [{ name: "Broken", offers: [] }]; - await p.openStoreApp(0); + await p.openStoreApp(0, true); 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"); } @@ -90,4 +92,18 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid 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"); } +// The featured Install starts a new or failed job, while a job already running or +// installed opens its details without another install or a launch command. +for (const [job, expected] of [[null, 1], [{ done: false }, 0], + [{ done: true, result: { package: offer.package } }, 0], [{ done: true, error: "offline" }, 1]]) { + const p = page(async path => path === "/api/sources/install" ? { package: offer.package } : {}); + p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }]; + if (job) p.sourceState.jobs.set(`${offer.source}:${offer.id}`, job); + await p.openStoreApp(0, true); + if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('id="detailInstall"')) fail("hero installation didn't open details"); + const installs = p.calls.filter(([path]) => path === "/api/sources/install"); + if (installs.length !== expected) fail(`expected ${expected} installation requests, got ${installs.length}`); + if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer"); + if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app"); +} console.log("ok"); diff --git a/tests/test_page_scripts.py b/tests/test_page_scripts.py new file mode 100644 index 0000000..8ce35bd --- /dev/null +++ b/tests/test_page_scripts.py @@ -0,0 +1,141 @@ +"""The page's inline scripts share one global scope, so a second top-level function or +variable with a name already used replaces the first everywhere, silently.""" +import html.parser +import json +import pathlib +import shutil +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + +# Compiles the scripts as one strict-mode block. There, functions are block scoped like let +# and const, so V8 itself rejects a name declared twice in the shared scope (however it's +# indented or declared: function, class, let, const, destructuring), while helpers with the +# same name inside different functions stay legal. var is the exception (declaring one twice +# is allowed), so the page doesn't use var at all: Node's own copy of the acorn parser finds +# real var declarations, not the word in comments, strings or CSS var(). +DUPLICATES = r''' +const vm = require('vm'); +const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8')); +try { new vm.Script('"use strict"; {\n' + scripts.join('\n;\n') + '\n}'); console.log('ok'); } +catch (e) { console.log(e.message); } +''' +VARS = r''' +const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8')); +let acorn, walk; +try { + acorn = require('internal/deps/acorn/acorn/dist/acorn'); + walk = require('internal/deps/acorn/acorn-walk/dist/walk'); +} catch { console.log('no parser'); process.exit(0); } +for (const code of scripts) { + let found = null; + try { + walk.simple(acorn.parse(code, { ecmaVersion: 'latest' }), { + VariableDeclaration(n) { if (n.kind === 'var' && !found) found = code.slice(n.start, n.end).slice(0, 40); }, + }); + } catch (e) { console.log(e.message); process.exit(0); } + if (found) { console.log('declares with var: ' + found); process.exit(0); } +} +console.log('ok'); +''' + + +def node(script, scripts, *flags): + r = subprocess.run(['node', *flags, '-e', script], input=json.dumps(scripts), capture_output=True, text=True) + return r.stdout.strip() or r.stderr.strip() + + +def check(scripts): + """'ok', or V8's message for the first name declared twice.""" + return node(DUPLICATES, scripts) + + +def check_vars(scripts): + """'ok', or the first var declaration. Skips where Node doesn't include acorn.""" + out = node(VARS, scripts, '--expose-internals') + if out == 'no parser': + raise unittest.SkipTest("this Node doesn't include acorn") + return out + + +class ClassicScripts(html.parser.HTMLParser): + """The page's inline classic scripts: no src, and no type other than a JavaScript one (a + module has its own scope, and JSON data isn't code). The types a browser runs as script: + https://mimesniff.spec.whatwg.org/#javascript-mime-type""" + JS = {'', 'application/ecmascript', 'application/javascript', 'application/x-ecmascript', + 'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0', + 'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4', + 'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript'} + + def __init__(self): + super().__init__() + self.scripts, self.current = [], None + + def handle_starttag(self, tag, attrs): + if tag == 'script': + a = dict(attrs) + inline = 'src' not in a and (a.get('type') or '').strip().lower() in self.JS + self.current = [] if inline else None + + def handle_data(self, data): + if self.current is not None: + self.current.append(data) + + def handle_endtag(self, tag): + if tag == 'script' and self.current is not None: + self.scripts.append(''.join(self.current)) + if tag == 'script': + self.current = None + + +def classic_scripts(page): + p = ClassicScripts() + p.feed(page) + return p.scripts + + +@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts') +class PageScripts(unittest.TestCase): + def test_no_top_level_name_is_declared_twice(self): + scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8')) + self.assertGreaterEqual(len(scripts), 2) + self.assertEqual(check(scripts), 'ok') + + def test_the_page_declares_nothing_with_var(self): + scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8')) + self.assertEqual(check_vars(scripts), 'ok') + + def test_the_check_finds_what_it_should(self): + twice = { + 'indented function': ['function loadPanels() {}', ' async function loadPanels() {}'], + 'class': ['class Panel {}', 'class Panel {}'], + 'destructured': ['const { a, b } = {};', 'let [b] = [];'], + 'later declarator': ['let x = 1;', 'const y = 2, x = 3;'], + 'function and const': ['function f() {}', 'const f = 1;'], + } + for what, scripts in twice.items(): + with self.subTest(what): + self.assertIn('already been declared', check(scripts)) + helpers = ['function a() { function help() {} }', 'function b() { const help = 1; }'] + self.assertEqual(check(helpers), 'ok') + + def test_the_var_check_finds_what_it_should(self): + # var: legal to declare twice, so not allowed at all; however it's written. + for code in ['var x = 1;', 'var/*c*/x = 1;', 'var {x} = {x: 1};', 'function f() { var y; }']: + with self.subTest(code): + self.assertIn('declares with var', check_vars([code])) + # The word var elsewhere is fine. + text = ['// var x\nconst a = "var y", b = `var ${a}`, c = "color: var(--blue)", d = {}.var;'] + self.assertEqual(check_vars(text), 'ok') + + def test_only_inline_classic_scripts_are_checked(self): + page = ('' + '' + '' + '') + self.assertEqual(classic_scripts(page), ['let a;', 'let c;', 'let d;', 'let e;']) + + +if __name__ == '__main__': + unittest.main() diff --git a/ui/index.html b/ui/index.html index 7c2006c..7642d6d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -600,8 +600,9 @@ .store-chips { display:flex; flex-wrap:wrap; gap:7px; } .store-chips button { border-radius:20px; font-size:12px; height:34px; padding:0 14px; background:#ffffff07; color:#b5c0cb; border:1px solid #ffffff0c; } .store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; } - .store-source-select select { max-width:180px; padding:8px 26px 8px 10px; font:inherit; font-size:12px; border:0; border-radius:5px; color:var(--text); background:#1d2b3b; } .store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; } + .store-heading a, .cross-link a { color:var(--link); } + .cross-link { margin:-6px 0 12px; } body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); } body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; } .store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; } @@ -609,14 +610,24 @@ .store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; } .store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); } .store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; } - .art-initial { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:70px; font-weight:700; color:#ffffff45; letter-spacing:-4px; } + .store-art > img.art-wash { inset:-24px; width:calc(100% + 48px); height:calc(100% + 48px); z-index:0; filter:blur(26px) saturate(1.4); opacity:.55; } + .store-art > img.art-icon { inset:0; margin:auto; width:64px; height:64px; border-radius:15px; z-index:0; box-shadow:0 10px 28px #0007; } + .detail-banner > img.art-icon, .store-hero .store-art > img.art-icon { width:88px; height:88px; border-radius:20px; } + .store-art .art-glyph { position:absolute; inset:0; display:grid; place-items:center; color:hsl(var(--hue) 45% 72% / .38); } + .store-art .art-glyph svg { width:34%; max-width:84px; height:auto; } + .runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; } + .runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; } + .runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; } .store-hero > .store-art { position:absolute; inset:0; } .store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; } .hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; } .hero-copy .store-eyebrow { color:#c2e7ff; } .hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; } - .hero-copy > span:not(.store-eyebrow):not(.hero-cta) { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; } - .hero-cta { color:white; display:inline-flex; align-items:center; background:#ffffff20; border:1px solid #ffffff40; border-radius:6px; padding:10px 18px; font-size:13px; margin-top:6px; } + .hero-copy .hero-sum { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; } + .hero-actions { display:flex; gap:10px; margin-top:6px; } + .hero-actions button { height:38px; border-radius:6px; padding:0 18px; } + .hero-details { background:#ffffff20; color:#fff; border:1px solid #ffffff40; } + .store-hero { cursor:pointer; } .store-row { margin-bottom:34px; } .store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; } .store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; } @@ -632,10 +643,8 @@ .store-card-title strong { font-size:15px; color:var(--bright); text-overflow:ellipsis; overflow:hidden; white-space:nowrap; } .store-summary { display:block; color:#a8b5c2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; } - .store-card-foot b { color:#dbeecf; font-weight:500; } .store-badges { display:flex; flex-wrap:wrap; gap:7px; } .store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; } - .store-badge.works { color:#b8e2ab; background:#70bb5418; } .store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; } .store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; } .store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; } @@ -692,6 +701,7 @@ .source-setting strong { display:block; font-size:14px; color:white; } .source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; } .source-setting small { color:var(--muted); font-size:10px; } + .source-web { font-size:12px; white-space:nowrap; } .source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; } .source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; } .source-toggle:checked { background:#208dca; } @@ -717,7 +727,7 @@ .store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; } .store-hero { height:285px; border-radius:9px; margin-bottom:26px; } .hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; } - .hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; } + .hero-copy .hero-sum { font-size:13px; max-width:260px; } .store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; } .store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; } .store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; } @@ -746,7 +756,7 @@