diff --git a/tests/test_page_scripts.py b/tests/test_page_scripts.py index 35ba048..f04468c 100644 --- a/tests/test_page_scripts.py +++ b/tests/test_page_scripts.py @@ -1,21 +1,50 @@ """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 collections +import json import pathlib import re +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. +CHECK = 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); } +''' + +def check(scripts): + r = subprocess.run(['node', '-e', CHECK], input=json.dumps(scripts), capture_output=True, text=True) + return r.stdout.strip() or r.stderr.strip() + + +@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts') class PageScripts(unittest.TestCase): def test_no_top_level_name_is_declared_twice(self): page = (ROOT / 'ui/index.html').read_text(encoding='utf-8') - names = collections.Counter() - for js in re.findall(r'', page, re.S): - for m in re.finditer(r'^(?:async\s+)?function\s+(\w+)|^(?:const|let|var)\s+(\w+)\s*=', js, re.M): - names[m.group(1) or m.group(2)] += 1 - self.assertEqual({k: n for k, n in names.items() if n > 1}, {}) + self.assertEqual(check(re.findall(r'', page, re.S)), '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') if __name__ == '__main__': diff --git a/ui/index.html b/ui/index.html index 69e61ed..5404c6c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -575,7 +575,6 @@ .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; } @@ -619,10 +618,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; } @@ -3189,7 +3186,7 @@ document.body.addEventListener("click", async e => { loadOwned(); // ---- Discover apps ---- -const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() }; +const sourceState = { vr: null, installable: false, apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() }; const storeName = s => String(s || "").replace(/ \(demo\)$/i, ""); const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360; const storeInitial = name => [...String(name || "?")][0].toUpperCase(); // the small icon's stand-in @@ -3230,7 +3227,7 @@ async function loadSources() { } catch (e) { $("sourceError").textContent = e.message; } } // Will it run? Short enough for a card; the full sentence is in its tooltip and the details. -const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["Won't run", "blocked"] }; +const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"] }; function storeRuns(o) { const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown; return `${text}`; @@ -3245,7 +3242,7 @@ function storeEmpty(message) { return `
${message ? esc(message) : "Try another name, or clear the filters."}