Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 5a6dfbfc9a fix(web): sample both sides of DST when comparing zones
CodeRabbit caught this and it is right: comparing the wall clock at one
instant treats zones that merely coincide right now as the same one.
America/New_York and America/Lima hold the same offset all winter, so a
panel set to the wrong one of the two ticked the step in January and then
ran an hour off from March -- a silent false pass, which is the failure the
whole check exists to prevent. Same shape as the dateStyle problem in the
previous commit: a comparison coarser than it looks.

Three instants now, all of which must agree: now, and mid-January and
mid-July of the current year. Those sit either side of DST in both
hemispheres, so only zones that agree year-round match. Toronto still
matches New York, which is correct -- either renders the same times.

Two tests. A static one asserts the comparison samples more than the
current instant, since reverting to `[now]` looks like a simplification.
And a table pinning which pairs must count as the same zone: aliases and
same-rule zones equal, seasonal coincidences (New York/Lima,
Phoenix/Los_Angeles, Sydney/Guadalcanal) not. That table mirrors the
algorithm rather than executing the shipped JS -- there is no JS runtime
here and the repo has no JS test infra -- so it records the verdicts the
browser code has to reach, and the static guard keeps the two aligned.

Mutation-checked: reverting to a single instant fails the static guard.

Also documented what the city test compares. CodeRabbit read it as always
failing, on the grounds that the label differs between Tampa and Seattle.
It does, but timezone_step() returns the opening tag only, so the
comparison is over data-done and data-tz and the label is not in it. The
assertion is left as an equality over the whole tag, which is stronger than
checking the two attributes by name; the docstring now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-17 15:53:43 -04:00
ChuckBuildsandClaude Opus 5 dcba6120f9 fix(web): compare zones on fields Intl has always had
dateStyle/timeStyle are late additions to Intl -- Firefox shipped them in
91 -- and an implementation that does not know them ignores them and
formats the date alone. The comparison would then read New York, Chicago
and Madrid as the same zone and tick the step for a timezone that is
plainly wrong, which is the failure the check exists to catch. Silent, and
only on older browsers.

Explicit numeric fields (year/month/day/hour/minute) have been in Intl
since ECMA-402 v1, so there is nothing left to degrade to.

The options look like a stylistic choice, so a test pins them: it reads the
comparison with comments stripped -- the comment names dateStyle to explain
why it is not used -- and fails if either style option comes back or a
time field is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-17 15:43:38 -04:00
ChuckBuildsandClaude Opus 5 2f64dbc48c fix(web): verify the onboarding timezone step, don't compare it to the default
The Getting Started card's timezone step ticked when the saved timezone
differed from the value config.template.json ships (America/New_York),
OR-ed with the saved city differing from Tampa. Both halves were wrong.

"Differs from the default" answers "did somebody edit this?", but what the
checklist needs to know is whether the value is right. A user genuinely in
America/New_York could never satisfy it, so the card nagged forever with
four of five steps done -- the case that prompted this, on a panel whose
timezone was correct all along.

The city half was worse than useless: the saved city says nothing about
whether the timezone is set, and because the two were OR-ed, saving a city
ticked the step off with the timezone still wrong. That is the direction
that actually breaks displays, since event times then render in the wrong
zone.

The browser already knows its own zone, so compare against that. No new
persisted state, no network, and it catches the reverse case the old test
got backwards: a panel still set to the old zone after a move now stays
unticked, where before it ticked the moment the value stopped being the
default. Zones are compared by the wall-clock time they produce for one
instant rather than by identifier, so aliases (Asia/Calcutta vs
Asia/Kolkata, Europe/Kiev vs Europe/Kyiv) don't read as a mismatch. When
they genuinely differ the step names the browser's zone, so an unticked box
says why. Configs with no timezone, an unparseable zone, or a browser
without Intl leave the step open for the existing manual tick.

The step still deep-links to the General tab, and the location value stays
visible in its label -- it just no longer votes on whether the timezone is
configured.

Tests render the partial across configured zones and both cities: the step
never pre-ticks server-side, carries the configured zone for the client to
check, is unmoved by the city, and the panel-size step still resolves
server-side. Reverting the template fails 9 of the 11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
2026-08-17 11:36:08 -04:00
6 changed files with 355 additions and 452 deletions
+5 -43
View File
@@ -24,29 +24,9 @@ echo "========================================"
# Auto-detect latest version if needed
if [ "$PIXLET_VERSION" = "latest" ]; then
echo "Detecting latest version..."
# When this response arrives on a single line -- as it did on the device
# where Starlark apps were failing -- `grep '"tag_name"'` matches the whole
# document and a greedy `sed 's/.*"([^"]+)".*/\1/'` captures the LAST
# quoted token in it rather than the tag. That resolved to
# "mentions_count", which built a download URL for a release that does not
# exist. (The API is pretty-printed by default, which is why the old
# command looks correct when you try it by hand -- but the formatting is
# not something to depend on.) Match the field itself and take the value
# after it, which is right for either shape.
PIXLET_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -n1 \
| sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')
# A wrong-but-non-empty value is what made the old bug silent, so check the
# shape rather than just that something came back. Anchored at both ends: a
# partial match would accept "v0.53garbage" or "0.53" and build a URL for a
# release that cannot exist, which is the failure this check is here to
# stop. Every tronbyt/pixlet release to date is vX.Y.Z; the optional suffix
# leaves room for a future -rc.1 or +build tag.
if ! printf '%s' "$PIXLET_VERSION" \
| grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
echo "Could not detect the latest version (got: '${PIXLET_VERSION:-<empty>}'), using fallback"
PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$PIXLET_VERSION" ]; then
echo "Failed to detect latest version, using fallback"
PIXLET_VERSION="v0.50.2"
fi
fi
@@ -87,26 +67,8 @@ download_binary() {
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
local temp_file="$temp_dir/$archive_name"
# -f so an HTTP error is a failure. Without it curl writes the 404 body
# to the file and exits 0, and the first sign of trouble is tar saying
# "not in gzip format" about what is actually a page of HTML.
if ! curl -fL -o "$temp_file" "$url" 2>/dev/null; then
echo "✗ Failed to download $arch from $url"
rm -rf "$temp_dir"
return 1
fi
# Belt and braces: a mirror or proxy can return 200 with an error page.
if ! gzip -t "$temp_file" 2>/dev/null; then
echo "✗ Downloaded file is not a gzip archive: $url"
# These bytes come from whatever answered the request, so strip
# everything non-printable before echoing them: an error page carrying
# terminal escapes would otherwise be able to rewrite this output or
# bury it in a CI log. Printable characters are kept rather than
# hex-encoding the lot, because "<!DOCTYPE html>" is the diagnostic.
local first_bytes
first_bytes=$(head -c 60 "$temp_file" | tr -cd '[:print:]')
printf ' (first bytes: %s)\n' "$first_bytes"
if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then
echo "✗ Failed to download $arch"
rm -rf "$temp_dir"
return 1
fi
+18 -41
View File
@@ -125,14 +125,6 @@ class PluginManager:
self._plugin_locks: Dict[str, threading.Lock] = {}
self._plugin_locks_guard = threading.Lock()
self._update_worker: Optional[threading.Thread] = None
# Plugin ids whose update() has finished since the last time anyone
# asked. Updates are dispatched to a worker thread, so a caller that
# wants to know "whose data just changed" cannot learn it by diffing
# plugin_last_update around run_scheduled_updates() -- that call only
# enqueues, and the timestamp is stamped later, on the worker. See
# run_scheduled_updates_with_changes().
self._completed_updates: set = set()
self._completed_updates_lock = threading.Lock()
self._synchronous_updates = False
if self.config_manager is not None:
try:
@@ -1033,7 +1025,6 @@ class PluginManager:
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = scheduled_time
self._note_update_completed(plugin_id)
self.state_manager.record_update(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
if self.health_tracker:
@@ -1098,41 +1089,28 @@ class PluginManager:
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
"""
Like run_scheduled_updates(), but also reports which plugins have
fresh data -- the ids whose update() has finished since the last
call, not necessarily the ones enqueued by this one.
Like run_scheduled_updates(), but also returns the plugin_ids whose
plugin_last_update timestamp actually advanced during this call.
That distinction is the whole point. This used to snapshot
plugin_last_update, call run_scheduled_updates(), and diff. But
run_scheduled_updates() only *enqueues*: the work runs on the
update worker and the timestamp is stamped there, after this method
has already returned. The two snapshots were therefore always
identical and the result was always empty, so Vegas never learned
that any plugin's data had changed and kept scrolling whatever a
segment was first built from -- last night's live game still drawn
as live the next morning. The only path that ever worked was the
synchronous kill-switch, where update() runs inline.
Reporting completions instead of enqueues costs a poll's worth of
latency (the Vegas tick runs every ~4s) and is correct regardless of
which side of the queue the work lands on.
The before/after snapshots and the update pass itself are each
individually lock-protected against concurrent plugin_last_update
mutation (Vegas mode calls this from its own background
update-tick thread, racing the main render loop's plugin updates),
so callers get an atomic "who got fresh data" answer without
reaching into plugin_last_update themselves. The lock is not held
across the update pass so slow/blocking plugin update() calls don't
serialize against other plugin_last_update readers.
"""
with self._plugin_last_update_lock:
old_times = dict(self.plugin_last_update)
self.run_scheduled_updates(current_time)
return self.drain_completed_updates()
def _note_update_completed(self, plugin_id: str) -> None:
"""Record that a plugin's update() finished, for the next poll."""
with self._completed_updates_lock:
self._completed_updates.add(plugin_id)
def drain_completed_updates(self) -> List[str]:
"""Return and clear the plugin ids whose update() has since finished."""
with self._completed_updates_lock:
if not self._completed_updates:
return []
done = sorted(self._completed_updates)
self._completed_updates.clear()
return done
with self._plugin_last_update_lock:
return [
plugin_id for plugin_id, new_time in self.plugin_last_update.items()
if new_time > old_times.get(plugin_id, 0.0)
]
def update_all_plugins(self) -> None:
"""
@@ -1157,7 +1135,6 @@ class PluginManager:
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = time.time()
self._note_update_completed(plugin_id)
self.state_manager.record_update(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
else:
+241
View File
@@ -0,0 +1,241 @@
"""
Getting Started checklist: what the server decides, and what it must not.
The timezone step used to tick server-side when the saved timezone differed
from the shipped default, OR-ed with the saved city. That made the step
unsatisfiable for anyone genuinely in the default zone (the card nagged
forever), and let a saved city tick it off while the timezone was still wrong.
The step is now verified in the browser against its own zone, so the server's
only job is to hand over the configured value and stay out of the decision.
These tests pin that contract: the panel-size step still reflects config, the
timezone step never pre-ticks, it carries the configured zone, and the city
has no influence on it.
"""
import copy
import re
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
BASE_CONFIG = {
"timezone": "America/New_York",
"location": {"city": "Tampa", "state": "Florida", "country": "US"},
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1},
"runtime": {},
"double_sided": {"enabled": False},
"vegas_scroll": {"plugin_order": [], "excluded_plugins": []},
"plugin_rotation_order": [],
},
"plugin_system": {},
"schedule": {},
"dim_schedule": {},
"sync": {},
}
def render(config):
"""Render the overview partial against one config, as app.py would."""
base = PROJECT_ROOT / "web_interface"
app = Flask(
__name__,
template_folder=str(base / "templates"),
static_folder=str(base / "static"),
)
app.config["TESTING"] = True
from web_interface.blueprints import pages_v3 as pv
# pages_v3 is a module-level singleton shared across the test process;
# restore whatever the previous test left on it.
original_cm = getattr(pv.pages_v3, "config_manager", None)
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
mock_cm = MagicMock()
mock_cm.load_config.return_value = config
mock_cm.get_raw_file_content.return_value = config
pv.pages_v3.config_manager = mock_cm
mock_pm = MagicMock()
mock_pm.plugins = {}
mock_pm.get_all_plugin_info.return_value = []
mock_pm.get_plugin_display_modes.side_effect = lambda pid: []
pv.pages_v3.plugin_manager = mock_pm
app.register_blueprint(pv.pages_v3, url_prefix="")
try:
resp = app.test_client().get("/partials/overview")
assert resp.status_code == 200, resp.status_code
return resp.get_data(as_text=True)
finally:
pv.pages_v3.config_manager = original_cm
pv.pages_v3.plugin_manager = original_pm
def timezone_step(body):
"""The checklist <button> for the timezone step."""
match = re.search(r"<button[^>]*data-check=\"timezone\"[^>]*>", body)
assert match, "timezone step not found in the rendered checklist"
return match.group(0)
def config_with(**overrides):
config = copy.deepcopy(BASE_CONFIG)
for key, value in overrides.items():
config[key] = value
return config
@pytest.mark.parametrize(
"timezone",
["America/New_York", "America/Los_Angeles", "Europe/Madrid", "Asia/Kolkata"],
)
def test_timezone_step_never_pre_ticks_server_side(timezone):
"""The browser owns this decision; the server must not pre-empt it.
The default zone is in the list deliberately: that is the case the old
default-comparison could never tick.
"""
step = timezone_step(render(config_with(timezone=timezone)))
assert 'data-done="0"' in step, step
@pytest.mark.parametrize(
"timezone",
["America/New_York", "Europe/Madrid", "Pacific/Auckland"],
)
def test_timezone_step_carries_the_configured_zone(timezone):
"""JS compares data-tz against the browser, so it has to be the real value."""
assert f'data-tz="{timezone}"' in timezone_step(render(config_with(timezone=timezone)))
def test_city_does_not_influence_the_timezone_step():
"""The coupling this change removes: city said nothing about the timezone,
and OR-ing it let a saved city tick the step off with the zone still wrong.
timezone_step() returns the opening tag only, so this compares the state
the step is in -- data-done and data-tz -- and not the label, which does
still show the configured city as context and so differs between the two.
"""
tampa = timezone_step(render(config_with(
location={"city": "Tampa", "state": "Florida", "country": "US"})))
seattle = timezone_step(render(config_with(
location={"city": "Seattle", "state": "Washington", "country": "US"})))
assert tampa == seattle
def test_missing_timezone_leaves_the_step_open():
"""Nothing saved means nothing to verify: the step stays unticked and the
JS bails on the empty value rather than comparing against ''."""
step = timezone_step(render(config_with(timezone="")))
assert 'data-tz=""' in step
assert 'data-done="0"' in step
def test_zone_comparison_asks_for_the_time_of_day():
"""Guard on the Intl options, which look like a stylistic choice.
dateStyle/timeStyle are late additions (Firefox shipped them in 91). An
implementation that does not know them ignores them and formats the date
alone -- which compares New York, Chicago and Madrid as equal and ticks
the step for a timezone that is plainly wrong. Explicit numeric fields
have been in Intl since ECMA-402 v1.
"""
template = (PROJECT_ROOT / "web_interface" / "templates" / "v3"
/ "partials" / "overview.html").read_text()
body = template[template.index("function sameZone"):]
body = body[:body.index("}())")]
# The comment above the options names dateStyle/timeStyle to explain why
# they are not used, so match on code only.
body = "\n".join(line for line in body.splitlines()
if not line.lstrip().startswith("//"))
assert "dateStyle" not in body and "timeStyle" not in body, (
"zone comparison must not depend on dateStyle/timeStyle")
for field in ("hour:", "minute:", "year:", "month:", "day:"):
assert field in body, f"zone comparison dropped {field!r}"
def test_zone_comparison_samples_both_sides_of_dst():
"""One instant is not enough, and the shortfall is invisible for months.
America/New_York and America/Lima hold the same offset all winter, so a
check against now alone ticks the step in January for a panel that runs an
hour off from March. The comparison has to sample instants either side of
DST -- mid-January and mid-July, which covers both hemispheres.
"""
template = (PROJECT_ROOT / "web_interface" / "templates" / "v3"
/ "partials" / "overview.html").read_text()
body = template[template.index("function sameZone"):]
body = body[:body.index("}())")]
code = "\n".join(line for line in body.splitlines()
if not line.lstrip().startswith("//"))
assert "Date.UTC" in code, (
"zone comparison samples only the current instant, so zones that "
"coincide seasonally would read as equal")
assert code.count("Date.UTC") >= 2, "expected an instant either side of DST"
def _stamp(zone, instant):
"""The JS comparison's algorithm, for pinning what it must decide.
There is no JS runtime here (and the repo has no JS test infra), so this
mirrors sameZone rather than executing it: same instants, same wall-clock
equality. It records the verdicts the shipped code has to reach.
"""
from zoneinfo import ZoneInfo
return instant.astimezone(ZoneInfo(zone)).strftime("%m/%d/%Y %H:%M")
@pytest.mark.parametrize(
"left,right,equivalent",
[
# Aliases: one zone under two names.
("Asia/Calcutta", "Asia/Kolkata", True),
("Europe/Kiev", "Europe/Kyiv", True),
# Same rules year-round: either renders the same times, so a panel set
# to one and browsed from the other is correctly configured.
("America/New_York", "America/Toronto", True),
# Coincide in winter only -- the case a single-instant check gets wrong.
("America/New_York", "America/Lima", False),
("America/Phoenix", "America/Los_Angeles", False),
("Australia/Sydney", "Pacific/Guadalcanal", False),
# Plainly different.
("America/New_York", "America/Chicago", False),
("America/New_York", "Europe/Madrid", False),
],
)
def test_which_zone_pairs_must_count_as_the_same(left, right, equivalent):
from datetime import datetime
from zoneinfo import ZoneInfo
year = 2026
instants = [datetime(year, 1, 15, 12, tzinfo=ZoneInfo("UTC")),
datetime(year, 7, 15, 12, tzinfo=ZoneInfo("UTC"))]
matched = all(_stamp(left, at) == _stamp(right, at) for at in instants)
assert matched is equivalent, (
f"{left} vs {right}: sampling both seasons gave {matched}")
@pytest.mark.parametrize(
"hardware,expected",
[
({"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1}, "1"),
({"rows": 0, "cols": 0, "chain_length": 0, "parallel": 1}, "0"),
],
)
def test_panel_size_step_still_reflects_config(hardware, expected):
"""Regression guard: the hardware step is still decided server-side."""
config = config_with()
config["display"]["hardware"] = hardware
body = render(config)
match = re.search(r"<button[^>]*data-tab=\"display\"[^>]*>", body)
assert match, "panel-size step not found"
assert f'data-done="{expected}"' in match.group(0), match.group(0)
-180
View File
@@ -1,180 +0,0 @@
"""
Tests for scripts/download_pixlet.sh -- release-tag resolution and download guards.
Background: Starlark apps render through the pixlet binary, and the installer
that fetches it failed silently. It resolved the release tag by grepping the
GitHub API response for '"tag_name"' and taking the last quoted token on the
match with a greedy sed. When the response arrives on one line that token is
"mentions_count", not the tag, so the script built a URL for a release that
cannot exist -- and `curl -L -o` without -f wrote the 404 body to the file and
exited 0, so the first sign of trouble was tar reporting "not in gzip format"
about a page of HTML.
The API is pretty-printed by default, which is exactly why this needs a test:
by hand the old command looks correct, and the failure only appears when the
formatting changes. These drive the real script with a stubbed curl on PATH, so
both response shapes are covered without touching the network.
"""
import re
import shutil
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "download_pixlet.sh"
PRETTY = """{
"url": "https://api.github.com/repos/tronbyt/pixlet/releases/12345",
"id": 12345,
"tag_name": "v0.53.1",
"name": "v0.53.1",
"draft": false,
"prerelease": false,
"mentions_count": 3
}
"""
# The shape that broke it: one line, and the last quoted token is not the tag.
MINIFIED = (
'{"url":"https://api.github.com/repos/tronbyt/pixlet/releases/12345",'
'"id":12345,"tag_name":"v0.53.1","name":"v0.53.1","draft":false,'
'"prerelease":false,"mentions_count":3}'
)
def run_script(tmp_path, api_body, download=None):
"""Run the real script against a stubbed curl.
Args:
api_body: what the stub returns for the api.github.com request.
download: bytes to write for a release-asset request, or None to make
that request fail the way `curl -f` does on an HTTP error.
"""
root = tmp_path / "project"
(root / "scripts").mkdir(parents=True)
shutil.copy(SCRIPT, root / "scripts" / "download_pixlet.sh")
api_file = tmp_path / "api.json"
api_file.write_text(api_body)
stub_dir = tmp_path / "stub"
stub_dir.mkdir()
asset_file = tmp_path / "asset.bin"
if download is not None:
asset_file.write_bytes(download)
# Stands in for curl, including the -f semantics the fix turns on: without
# -f, real curl writes the error body to the output file and exits 0, which
# is what let a 404 masquerade as a successful download. The stub has to
# honour that or a test of the fix would pass against the old script too.
(stub_dir / "curl").write_text(f"""#!/bin/bash
out=""
url=""
fail_on_error=0
while [ $# -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
-*f*) fail_on_error=1; shift ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
if [[ "$url" == *api.github.com* ]]; then
cat {api_file}
exit 0
fi
if [ -f "{asset_file}" ]; then
cp "{asset_file}" "$out"
exit 0
fi
# No asset: stand in for an HTTP 404.
if [ "$fail_on_error" = "1" ]; then
exit 22
fi
printf '<!DOCTYPE html><html>404 Not Found</html>' > "$out"
exit 0
""")
(stub_dir / "curl").chmod(0o755)
return subprocess.run(
["bash", str(root / "scripts" / "download_pixlet.sh")],
capture_output=True, text=True,
env={"PATH": f"{stub_dir}:/usr/bin:/bin:/usr/sbin:/sbin",
"PIXLET_VERSION": "latest"},
)
def resolved_version(result):
match = re.search(r"^Version: (.+)$", result.stdout, re.M)
assert match, f"no version line in output:\n{result.stdout}"
return match.group(1).strip()
def test_script_is_syntactically_valid():
result = subprocess.run(["bash", "-n", str(SCRIPT)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
@pytest.mark.parametrize("body,label", [(PRETTY, "pretty"), (MINIFIED, "minified")])
def test_tag_is_resolved_from_either_response_shape(tmp_path, body, label):
"""The minified case is the regression: the last quoted token there is
"mentions_count", which is what the old greedy sed captured."""
result = run_script(tmp_path, body)
assert resolved_version(result) == "v0.53.1", f"{label}: {result.stdout}"
assert "mentions_count" not in result.stdout
@pytest.mark.parametrize(
"tag",
["mentions_count", "v0.53garbage", "0.53", "v0.5", "", "v0.53.1 ; echo pwned"],
)
def test_a_tag_that_is_not_a_release_falls_back(tmp_path, tag):
"""A wrong-but-non-empty value is what made the original bug silent, so the
check is on the shape. Partial matches must not pass: "v0.53garbage" and
"0.53" would build a URL for a release that cannot exist."""
result = run_script(tmp_path, '{"tag_name": "%s"}' % tag)
assert resolved_version(result) == "v0.50.2", result.stdout
assert "using fallback" in result.stdout
@pytest.mark.parametrize("tag", ["v0.53.1", "v1.0.0", "v0.54.0-rc.1", "v1.2.3+build.4"])
def test_real_release_tag_shapes_are_accepted(tmp_path, tag):
assert resolved_version(run_script(tmp_path, '{"tag_name": "%s"}' % tag)) == tag
def test_an_http_error_is_reported_as_a_failed_download(tmp_path):
"""Without curl -f the 404 body lands in the file and curl exits 0, so the
failure surfaced two steps later as tar complaining about gzip -- about
what was really a page of HTML. It has to be reported where it happened.
Both versions end at 0/1, so asserting only on the count would pass against
the old script; the discriminating part is which layer reports it.
"""
result = run_script(tmp_path, PRETTY, download=None)
assert "Download complete: 0/1 succeeded" in result.stdout
assert "✓ Downloaded" not in result.stdout
assert "Failed to download" in result.stdout
assert "Failed to extract" not in result.stdout, (
"an HTTP error should not surface as an extraction failure")
def test_a_non_archive_response_is_rejected_before_extraction(tmp_path):
result = run_script(tmp_path, PRETTY, download=b"<!DOCTYPE html><html>502 Bad Gateway")
assert "not a gzip archive" in result.stdout
assert "Download complete: 0/1 succeeded" in result.stdout
def test_the_diagnostic_cannot_smuggle_terminal_escapes(tmp_path):
"""Those bytes come from whatever answered the request. An error page
carrying escapes must not be able to rewrite the output or bury it."""
hostile = b"<!DOCTYPE html>\x1b[2J\x1b[31mgone\x1b[0m\rHTTP 200 OK\x08\x08"
result = run_script(tmp_path, PRETTY, download=hostile)
assert "not a gzip archive" in result.stdout
printed = re.search(r"^\s*\(first bytes: (.*)\)$", result.stdout, re.M)
assert printed, f"no diagnostic line:\n{result.stdout}"
assert "DOCTYPE" in printed.group(1), "the useful part of the page was dropped"
for forbidden in ("\x1b", "\r", "\x08", "\x00"):
assert forbidden not in printed.group(1), (
f"control byte {forbidden!r} reached the terminal")
-182
View File
@@ -1,182 +0,0 @@
#!/usr/bin/env python3
"""
Tests that "which plugins have fresh data" survives the async update worker.
Regression under test: run_scheduled_updates_with_changes() snapshotted
plugin_last_update, called run_scheduled_updates(), and diffed the two. But
run_scheduled_updates() only *enqueues* -- the work runs on the update worker
and stamps the timestamp there, after the method has already returned. The
snapshots were therefore always identical and the result always empty.
Vegas depends on that result: it is what calls mark_plugin_updated(), which
drops the cached content for a plugin whose data changed. With it always
empty, a segment kept scrolling whatever it was first built from -- the
"last night's live game still drawn as live the next morning" failure the
coordinator comments describe. Observed on a live rig: zero update ticks in
twenty minutes, with weather, stocks and news all updating.
Run: python -m pytest test/test_update_change_reporting.py -v
"""
import ast
import inspect
import sys
import threading
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
def _manager():
"""A PluginManager with only the update-reporting state initialised."""
manager = PluginManager.__new__(PluginManager)
manager._completed_updates = set()
manager._completed_updates_lock = threading.Lock()
return manager
class DrainCompletedUpdates(unittest.TestCase):
def setUp(self):
self.manager = _manager()
def test_nothing_completed_reports_nothing(self):
self.assertEqual(self.manager.drain_completed_updates(), [])
def test_a_completed_update_is_reported(self):
self.manager._note_update_completed("news")
self.assertEqual(self.manager.drain_completed_updates(), ["news"])
def test_draining_clears_so_the_next_poll_is_empty(self):
self.manager._note_update_completed("news")
self.manager.drain_completed_updates()
self.assertEqual(
self.manager.drain_completed_updates(), [],
"a plugin must be reported once per update, not on every poll, "
"or Vegas would drop its cached content every few seconds")
def test_repeated_completions_between_polls_collapse(self):
for _ in range(5):
self.manager._note_update_completed("weather")
self.assertEqual(self.manager.drain_completed_updates(), ["weather"])
def test_multiple_plugins_are_all_reported(self):
for plugin_id in ("news", "weather", "ledmatrix-stocks"):
self.manager._note_update_completed(plugin_id)
self.assertEqual(self.manager.drain_completed_updates(),
["ledmatrix-stocks", "news", "weather"])
class CompletionReportingIsAsyncSafe(unittest.TestCase):
"""The point of the change: completion may land after the call returns."""
def setUp(self):
self.manager = _manager()
def test_an_update_completing_after_the_call_is_still_reported(self):
"""The exact shape of the bug.
The enqueueing call sees nothing, because the worker has not run yet.
The next poll must report it -- under the old diff it was lost, since
the second snapshot was taken before the worker ever stamped.
"""
first = self.manager.drain_completed_updates()
self.assertEqual(first, [], "nothing has finished yet")
# The worker finishes some time later, on its own thread.
worker = threading.Thread(
target=self.manager._note_update_completed, args=("news",))
worker.start()
worker.join()
self.assertEqual(
self.manager.drain_completed_updates(), ["news"],
"an update that finishes between polls must still be reported")
def test_concurrent_completions_are_not_lost(self):
ids = ["plugin-%02d" % i for i in range(40)]
threads = [threading.Thread(target=self.manager._note_update_completed,
args=(pid,)) for pid in ids]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(self.manager.drain_completed_updates(), sorted(ids))
def test_a_completion_during_a_drain_is_not_swallowed(self):
"""A drain must not clear an entry it did not report."""
self.manager._note_update_completed("news")
reported = self.manager.drain_completed_updates()
# ...worker finishes another one immediately afterwards
self.manager._note_update_completed("weather")
self.assertEqual(reported, ["news"])
self.assertEqual(self.manager.drain_completed_updates(), ["weather"])
class EveryStampRecordsACompletion(unittest.TestCase):
"""The ledger is only correct if the production paths actually fill it.
Asserting on the mechanics alone passes even when nothing calls
_note_update_completed -- verified by deleting the call sites, which the
behavioural tests above did not notice. This checks the invariant at the
source: wherever a successful update stamps plugin_last_update, it must
also record the completion, or Vegas silently stops being told.
"""
def test_success_paths_record_the_completion(self):
import src.plugin_system.plugin_manager as pm
tree = ast.parse(inspect.getsource(pm))
stamps = []
for node in ast.walk(tree):
if not isinstance(node, ast.With):
continue
# `with self._plugin_last_update_lock:` blocks that stamp a real
# time on success. Two stamps are deliberately excluded: the 0.0
# written at registration, and the failure path, which backs the
# timestamp off to space out retries -- neither means fresh data.
assigns_time = any(
isinstance(stmt, ast.Assign)
and any(isinstance(t, ast.Subscript)
and getattr(t.value, "attr", None) == "plugin_last_update"
for t in stmt.targets)
and not (isinstance(stmt.value, ast.Constant)
and stmt.value.value == 0.0)
and "failure" not in ast.dump(stmt.value)
for stmt in node.body
)
if assigns_time:
stamps.append(node)
self.assertGreaterEqual(
len(stamps), 2,
"expected the worker and inline success paths to stamp the time; "
"if this drops, the search below is looking at the wrong thing")
for stamp in stamps:
enclosing = self._enclosing_function(tree, stamp)
calls = [n for n in ast.walk(enclosing)
if isinstance(n, ast.Call)
and getattr(n.func, "attr", None) == "_note_update_completed"]
self.assertTrue(
calls,
"%s stamps plugin_last_update on success but never calls "
"_note_update_completed, so a plugin's fresh data would never "
"be reported and Vegas would keep its stale cached content"
% enclosing.name)
@staticmethod
def _enclosing_function(tree, target):
best = None
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.lineno <= target.lineno <= (node.end_lineno or node.lineno):
if best is None or node.lineno > best.lineno:
best = node
return best
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -63,13 +63,13 @@
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
auto-check from existing config/endpoints — no new persisted state.
Known heuristic limits (acceptable, disclosed): values left at legitimate
defaults (e.g. a user actually in Tampa) read as "not done". -->
The timezone step is verified against the browser's own zone rather than
compared to the shipped default; see the data-check="timezone" block below
for why. -->
{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %}
{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %}
{% set _loc = main_config.location if main_config and main_config.location else {} %}
{% set _loc_done = (main_config.timezone and main_config.timezone != 'America/New_York')
or (_loc.city and _loc.city != 'Tampa') %}
{% set _tz = (main_config.timezone if main_config else '') or '' %}
<div id="getting-started-card" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4" style="display:none" role="region" aria-label="Getting started checklist">
<div class="flex items-start justify-between">
<div class="flex-1">
@@ -78,8 +78,8 @@
<ul class="space-y-1 text-sm" id="getting-started-items">
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _loc_done else '0' }}" data-tab="general">
<i class="far fa-square mr-2"></i>Set your timezone and location (General tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
<i class="far fa-square mr-2"></i>Set your timezone{% if _tz %} — currently {{ _tz }}{% if _loc.city %}, {{ _loc.city }}{% endif %}{% endif %} (General tab)<span data-gs-tz-note class="text-xs"></span></button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="installed" data-tab="plugins">
<i class="far fa-square mr-2"></i>Install a plugin from the Plugin Store</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="enabled" data-tab="plugins">
@@ -165,6 +165,91 @@
});
maybeAutoHide();
// Timezone: verified against the browser's own zone.
//
// This step used to tick when the saved timezone differed from the value
// config.template.json ships (America/New_York), with the saved city
// OR-ed in. Two things were wrong with that. "Differs from the default"
// answers "did somebody edit this?", but what the checklist needs to know
// is whether the value is RIGHT — so anyone who genuinely lives in the
// default zone could never satisfy it and the card nagged forever. And
// the city has no bearing on whether the timezone is set: because the two
// were OR-ed, saving a city ticked the step off with the timezone still
// wrong, which is the direction that actually breaks displays (event
// times render in the wrong zone).
//
// The browser already knows its zone, so compare against that: no new
// persisted state, no network, and it catches the reverse case too — a
// panel still set to the old zone after a move now stays unticked, where
// the old test ticked it the moment the value stopped being the default.
function sameZone(a, b) {
if (a === b) return true;
// Compare the wall-clock time each zone yields, not the identifiers:
// aliases (Asia/Calcutta vs Asia/Kolkata, Europe/Kiev vs Europe/Kyiv)
// name one zone and must not read as a mismatch.
//
// Sampled at three instants, all of which have to agree. Checking only
// now is not enough: America/New_York and America/Lima hold the same
// offset all winter, so a panel set to the wrong one of those would
// tick in January and then run an hour off from March. Mid-January and
// mid-July sit either side of DST in both hemispheres, so only zones
// that agree year-round match -- while Toronto still matches New York,
// which is right, since either renders the same times.
try {
var now = new Date();
var year = now.getUTCFullYear();
var instants = [now,
new Date(Date.UTC(year, 0, 15, 12)),
new Date(Date.UTC(year, 6, 15, 12))];
var stamp = function (tz, at) {
// Explicit numeric fields rather than dateStyle/timeStyle:
// those are late additions to Intl (Firefox shipped them in
// 91), and an implementation that does not know them ignores
// them and formats the date alone. That would compare
// New York, Chicago and Madrid as equal and tick the step for
// a timezone that is plainly wrong -- the exact failure this
// check exists to catch. These options have been in Intl
// since ECMA-402 v1.
return new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: '2-digit',
day: '2-digit', hour: '2-digit', minute: '2-digit',
hour12: false
}).format(at);
};
for (var i = 0; i < instants.length; i++) {
if (stamp(a, instants[i]) !== stamp(b, instants[i])) {
return false;
}
}
return true;
} catch (e) {
// An unparseable zone in the config is worth surfacing, not hiding.
return false;
}
}
(function () {
var tzBtn = card.querySelector('[data-check="timezone"]');
if (!tzBtn) return;
var configured = tzBtn.dataset.tz || '';
if (!configured) return; // nothing saved yet: leave it open
var local = '';
try {
local = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
} catch (e) {
return; // no Intl: leave it to the manual tick
}
if (!local) return;
if (sameZone(configured, local)) {
markDone(tzBtn);
return;
}
// Unticked on its own says "wrong" without saying why; name the zone
// the browser is in so the step is actionable.
var note = tzBtn.querySelector('[data-gs-tz-note]');
if (note) note.textContent = ' — this browser is in ' + local;
}());
// Plugin-derived states from the existing installed-plugins endpoint.
fetch('/api/v3/plugins/installed')
.then(function (r) { return r.json(); })