mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-12 13:01:10 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b365a85665 | ||
|
|
d17e911c5b | ||
|
|
6746a8220d | ||
|
|
2861567d15 | ||
|
|
b5c8d2f19d | ||
|
|
4a489e4606 | ||
|
|
0304575e5b |
No files matched your search
@@ -16,8 +16,12 @@ permissions:
|
||||
jobs:
|
||||
# One job makes the draft, before the builds: three matrix jobs each running
|
||||
# "view || create" could race and make duplicate drafts. The draft is tied to
|
||||
# the pushed tag (--verify-tag, then tag_name set explicitly), so
|
||||
# scripts/publish-release.sh and `gh release upload` find it by tag.
|
||||
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
|
||||
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
|
||||
# by tag. It is created with the REST API so the id comes straight back: the
|
||||
# release list can lag a fresh draft (v0.4.2's first run found nothing and
|
||||
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
|
||||
# this step against tests/fakegh.
|
||||
draft:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,11 +32,16 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
# A rerun finds the draft an earlier attempt made.
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
|
||||
if [ -z "$id" ]; then
|
||||
gh release create "$tag" --draft --verify-tag --title "Frame Control ${tag#v}" --notes ""
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\") | .id" | head -n 1)
|
||||
# Given a missing tag, GitHub would create it on the default branch.
|
||||
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|
||||
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
|
||||
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
|
||||
-F draft=true -f body="" --jq .id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
|
||||
|
||||
build:
|
||||
|
||||
+43
-11
@@ -1,11 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py. Serves
|
||||
the releases in $FAKEGH_RELEASES (a JSON list, drafts included) and the tags in
|
||||
$FAKEGH_TAGS (space-separated); an upload's body is written to $FAKEGH_UPLOAD.
|
||||
Every call is appended to $FAKEGH_LOG as a JSON line. Anything else fails, so the
|
||||
script under test can't fall back to `gh release ...` (GraphQL) unnoticed."""
|
||||
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
|
||||
tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
|
||||
drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
|
||||
written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
|
||||
Anything else fails, so the script under test can't fall back to `gh release ...`
|
||||
(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
|
||||
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
|
||||
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
|
||||
(raw strings, compact JSON)."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
@@ -14,7 +20,7 @@ with open(os.environ["FAKEGH_LOG"], "a") as f:
|
||||
if not args or args[0] != "api":
|
||||
sys.exit("fakegh: only `gh api` is supported: %r" % args)
|
||||
|
||||
method, endpoint, fields, inp, i = "GET", None, {}, None, 1
|
||||
method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "-X":
|
||||
@@ -23,7 +29,9 @@ while i < len(args):
|
||||
k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2
|
||||
elif a == "--input":
|
||||
inp = args[i + 1]; i += 2
|
||||
elif a in ("-H", "--jq"):
|
||||
elif a == "--jq":
|
||||
jq = args[i + 1]; i += 2
|
||||
elif a == "-H":
|
||||
i += 2
|
||||
elif a.startswith("-"):
|
||||
i += 1
|
||||
@@ -34,14 +42,34 @@ while i < len(args):
|
||||
|
||||
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
|
||||
repo = "repos/saphid/frame-control/"
|
||||
|
||||
|
||||
def answer(response, fallback):
|
||||
"""Print response through --jq when jq is here, else the fixed fallback text."""
|
||||
if jq and shutil.which("jq"):
|
||||
out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
||||
sys.stdout.write(out.stdout)
|
||||
if out.returncode:
|
||||
sys.exit("fakegh: jq failed: " + out.stderr)
|
||||
else:
|
||||
print(fallback)
|
||||
|
||||
|
||||
if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
|
||||
tag = endpoint.rsplit("/", 1)[1]
|
||||
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
print(json.dumps({"ref": "refs/tags/" + tag}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "releases?"):
|
||||
for r in releases: # what --jq '.[]' prints
|
||||
print(json.dumps(r))
|
||||
# The fallback is what --jq '.[]' prints.
|
||||
answer(releases, "\n".join(json.dumps(r) for r in releases))
|
||||
elif method == "POST" and endpoint == repo + "releases":
|
||||
new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
|
||||
"name": fields.get("name"), "draft": fields.get("draft") == "true",
|
||||
"body": fields.get("body"), "assets": [],
|
||||
"html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
|
||||
answer(new, json.dumps(new))
|
||||
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
|
||||
pass
|
||||
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
|
||||
@@ -49,7 +77,11 @@ elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + re
|
||||
dst.write(src.read())
|
||||
print("{}")
|
||||
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
|
||||
print("published %s at https://github.com/saphid/frame-control/releases/tag/%s"
|
||||
% (fields.get("tag_name"), fields.get("tag_name")))
|
||||
rid = endpoint[len(repo + "releases/"):]
|
||||
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
|
||||
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
|
||||
"published %s at %s" % (fields.get("tag_name"), page))
|
||||
else:
|
||||
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
|
||||
@@ -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 <App>", 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");
|
||||
@@ -263,11 +263,14 @@ class ArtworkTests(unittest.TestCase):
|
||||
def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self):
|
||||
import frame_steamgriddb as sgdb
|
||||
from apk_sources import _images
|
||||
deadline = time.monotonic() + 5
|
||||
with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get:
|
||||
self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1])
|
||||
self.assertEqual(sgdb._get('/search/x', 'secret', deadline), [1])
|
||||
self.assertEqual(get.call_args.kwargs['redirects'], 0)
|
||||
self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret')
|
||||
self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5)
|
||||
# The caller's deadline is passed on, never extended. (Comparing against a fresh
|
||||
# time.monotonic() flaked on Windows, whose clock ticks coarsely.)
|
||||
self.assertLessEqual(get.call_args.kwargs['deadline'], deadline)
|
||||
|
||||
def test_supplied_jpeg(self):
|
||||
data = (FIXTURES / 'icon.jpg').read_bytes()
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""The "Create the draft release" step of .github/workflows/release.yml, taken out of
|
||||
the workflow and run with bash (as Actions runs it) against a stand-in gh
|
||||
(tests/fakegh/gh): a new draft's id comes straight from the POST even when the
|
||||
release list doesn't show it yet (v0.4.2's first run), a rerun reuses the draft
|
||||
found by tag_name, and a tag that isn't on GitHub stops it. Nothing here talks to
|
||||
GitHub.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
|
||||
FAKEGH = ROOT / "tests" / "fakegh"
|
||||
STEP = "- name: Create the draft release"
|
||||
|
||||
|
||||
def step_script():
|
||||
"""The step's `run: |` block, dedented."""
|
||||
lines = WORKFLOW.read_text().splitlines()
|
||||
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
|
||||
while lines[i].strip() != "run: |":
|
||||
i += 1
|
||||
indent = len(lines[i]) - len(lines[i].lstrip())
|
||||
body = []
|
||||
for line in lines[i + 1:]:
|
||||
if line.strip() and len(line) - len(line.lstrip()) <= indent:
|
||||
break
|
||||
body.append(line)
|
||||
return textwrap.dedent("\n".join(body)).strip() + "\n"
|
||||
|
||||
|
||||
def release(rid, tag_name, name="Frame Control 9.8.7", draft=True):
|
||||
return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []}
|
||||
|
||||
|
||||
# The step only runs on ubuntu-latest. On Windows `bash` is often the WSL launcher
|
||||
# (with no distribution on GitHub's runners), not a bash that can run tests/fakegh.
|
||||
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
|
||||
"needs a POSIX bash and jq (for gh --jq); the step runs on ubuntu only")
|
||||
class DraftStep(unittest.TestCase):
|
||||
def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "step.sh").write_text(step_script())
|
||||
(d / "releases.json").write_text(json.dumps(releases))
|
||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
||||
FAKEGH_LOG=str(d / "log"), GITHUB_REF_NAME=tag, GH_REPO="saphid/frame-control")
|
||||
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
|
||||
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||
return p, log
|
||||
|
||||
def posts(self, log):
|
||||
return [c for c in log if c[1:4] == ["-X", "POST", "repos/saphid/frame-control/releases"]]
|
||||
|
||||
def patches(self, log):
|
||||
return [c for c in log if c[1:3] == ["-X", "PATCH"]]
|
||||
|
||||
def test_new_draft_uses_the_id_the_post_returns(self):
|
||||
# The list doesn't show the fresh draft (it never does in the fake): v0.4.2's case.
|
||||
p, log = self.run_step([])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
[post] = self.posts(log)
|
||||
for f in ("tag_name=v9.8.7", "name=Frame Control 9.8.7", "draft=true"):
|
||||
self.assertIn(f, post)
|
||||
self.assertIn(["api", "repos/saphid/frame-control/git/ref/tags/v9.8.7"], log)
|
||||
[patch] = self.patches(log)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/9001")
|
||||
self.assertIn("tag_name=v9.8.7", patch)
|
||||
self.assertIn("release 9001 tag_name v9.8.7", p.stdout)
|
||||
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...`
|
||||
|
||||
def test_a_rerun_reuses_the_draft_with_the_tag(self):
|
||||
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6"), release(42, "v9.8.7")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(self.posts(log), [])
|
||||
[patch] = self.patches(log)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
||||
|
||||
def test_other_releases_alone_mean_a_new_draft(self):
|
||||
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
|
||||
release(8, "v9.8.70", "Frame Control 9.8.70")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(len(self.posts(log)), 1)
|
||||
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/9001")
|
||||
|
||||
def test_stops_when_the_tag_is_not_on_github(self):
|
||||
p, log = self.run_step([], tags="")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("tag v9.8.7 isn't on GitHub", p.stderr)
|
||||
self.assertEqual(self.posts(log), [])
|
||||
self.assertEqual(self.patches(log), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -411,6 +411,59 @@ class ArtworkSettings(unittest.TestCase):
|
||||
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "needs node")
|
||||
class PageScripts(unittest.TestCase):
|
||||
"""A page's scripts share one global scope, where a later function declaration
|
||||
silently replaces an earlier one of the same name. Issue #68: the Devices page's
|
||||
renderDetail replaced the Android store's, so every app opened as an empty
|
||||
dialog (a bare horizontal line). Compiled together as one module, any top-level
|
||||
name declared twice is a SyntaxError; node's parser handles nested scopes,
|
||||
comments and strings."""
|
||||
|
||||
PAGES = ("index.html", "assistant.html", "mac-view.html")
|
||||
|
||||
@staticmethod
|
||||
def page_scripts(html):
|
||||
"""A page's inline scripts, then its /*.js scripts, in one source."""
|
||||
import re
|
||||
parts = re.findall(r"<script>([\s\S]*?)</script>", html)
|
||||
parts += [(ROOT / "ui" / src).read_text(encoding="utf-8") for src in re.findall(r'<script src="/([\w.-]+\.js)">', html)]
|
||||
return "\n;\n".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def module_check(source):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "page.mjs"
|
||||
path.write_text(source, encoding="utf-8")
|
||||
r = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
|
||||
return r.returncode, r.stderr
|
||||
|
||||
def test_the_check_catches_top_level_duplicates_only(self):
|
||||
dup = "<script>function a() {}\nfunction a() {}</script>"
|
||||
across = "<script>function a() {}</script><p>x</p><script> async function a() {}</script>"
|
||||
same_line = "<script>function a() {} function a() {}</script>"
|
||||
const_and_function = "<script>const a = 1, b = 2;\nfunction b() {}</script>"
|
||||
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 = ("<script>function a() {}\nfunction f() { function a() {} if (1) { const a = 2; } }\n"
|
||||
"// function a() {}\nconst s = `function a() {}`;</script>")
|
||||
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
|
||||
|
||||
+24
-11
@@ -2120,7 +2120,8 @@ async function loadPanels() {
|
||||
if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); }
|
||||
ctrlShow();
|
||||
} catch (e) {
|
||||
log("Couldn't list the headset's panels: " + e.message, "e");
|
||||
// Polled every few seconds: waiting for the Frame, or an answer about the previous headset, isn't news.
|
||||
if (!e.offline && !e.stale) log("Couldn't list the headset's panels: " + e.message, "e");
|
||||
}
|
||||
}
|
||||
function watchPanels() {
|
||||
@@ -3172,7 +3173,19 @@ async function searchSources(quiet) {
|
||||
}
|
||||
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
|
||||
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
|
||||
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=`<div class="store-empty detail-error" role="alert"><h2 id="detailTitle">${name ? `We couldn't show ${esc(name)}` : "We couldn't show this app"}</h2><p>Something went wrong opening its page. Close this and try again, or pick another app.</p><button class="action" data-close="appDetail">Close</button></div>`;
|
||||
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)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||||
@@ -3192,8 +3205,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;
|
||||
@@ -3201,8 +3214,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);
|
||||
@@ -3232,8 +3245,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});};
|
||||
@@ -3785,7 +3798,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"});
|
||||
@@ -3796,7 +3809,7 @@ async function loadPanels() {
|
||||
: '<div class="sub">No open panels.</div>';
|
||||
} 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 => {
|
||||
@@ -3894,7 +3907,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).
|
||||
|
||||
Reference in new issue
Block a user