mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(dashboards): v2.7 widget catalog — file-reading widgets, sparkline, typed status, project-wide watch
Major project-dashboard release. Five new widget types (markdown_file, log_tail, cron_status, image, status_grid), inline sparkline on stat, typed status enum shared by list + status_grid, structured WidgetErrorCard, and a project-wide .scarf/ directory watch that picks up files cron jobs write next to dashboard.json. - ProjectDashboard: extend DashboardWidget with path/lines/jobId/cells/gridColumns/sparkline; add StatusGridCell + ListItemStatus (lenient parse with synonyms) - HermesFileWatcher: watch each project's .scarf/ dir alongside dashboard.json (local FSEvents + remote SSH mtime poll); updateProjectWatches signature now takes dashboardPaths + scarfDirs - New widget views: CronStatus, Image, LogTail, MarkdownFile, StatusGrid, plus WidgetErrorCard for structured failure messaging; legacy "Unknown" placeholder replaced everywhere - WidgetPathResolver: project-root-anchored path resolution that rejects absolute paths + ".." escapes pre and post canonicalization - Stat widget gains optional inline sparkline (pure SwiftUI Path, no Charts dep); list widget rows route through typed status with semantic icons + ScarfColor tints - iOS list widget + unsupported card adopt typed status + warning-toned error card (parity with Mac error styling); new widget types remain Mac-only - Site mirror: widgets.js renders all five new types (file-reading widgets show annotated catalog placeholders), sparkline SVG, status-grid grid; styles.css adds typed-status palette + error-card + sparkline + grid styles - Catalog validator: tools/widget-schema.json is the single source of truth; build-catalog.py loads it and enforces per-type required fields. 8 new test cases in test_build_catalog.py covering schema load, v2.7 additions, and missing-required rejection - Template-author skill (SKILL.md) gains v2.7 Widget Catalog section + canonical status guidance; CONTRIBUTING.md points authors at widget-schema.json; template-author bundle rebuilt - Localizable.xcstrings picks up auto-extracted strings for the previously-shipped OAuth keepalive feature - Release notes drafted at releases/v2.7.0/RELEASE_NOTES.md Backwards compatible — existing dashboard.json renders byte-identically, status synonyms (ok/up/down/active/etc.) keep working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -266,6 +266,174 @@ class ValidationTests(unittest.TestCase):
|
||||
_, errors = self._validate_all()
|
||||
self.assertTrue(any("unknown type" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_widget_missing_required_field(self):
|
||||
# 'progress' requires both title + value; omit value.
|
||||
bad_dashboard = {
|
||||
"version": 1,
|
||||
"title": "Bad",
|
||||
"sections": [{"title": "x", "columns": 1, "widgets": [
|
||||
{"type": "progress", "title": "Loading"},
|
||||
]}],
|
||||
}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/missing-required",
|
||||
"name": "Missing",
|
||||
"version": "1.0.0",
|
||||
"description": "missing required field",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "missing-required",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
_, errors = self._validate_all()
|
||||
self.assertTrue(
|
||||
any("missing required field 'value'" in str(e) for e in errors),
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_widget_schema_loads_and_lists_known_types(self):
|
||||
# Sanity check: schema includes the v2.2 originals so old templates
|
||||
# keep validating.
|
||||
for t in ("stat", "progress", "text", "table", "chart", "list", "webview"):
|
||||
self.assertIn(t, build_catalog.SUPPORTED_WIDGET_TYPES)
|
||||
|
||||
def test_widget_schema_includes_v2_7_additions(self):
|
||||
# v2.7 added markdown_file, log_tail, cron_status, image, status_grid.
|
||||
for t in ("markdown_file", "log_tail", "cron_status", "image", "status_grid"):
|
||||
self.assertIn(t, build_catalog.SUPPORTED_WIDGET_TYPES)
|
||||
|
||||
def test_v2_7_widgets_accept_canonical_minimum_fields(self):
|
||||
# Build one bundle whose dashboard exercises every v2.7 addition with
|
||||
# its canonical required fields populated. If any per-type rule
|
||||
# over-tightens, this test catches it before catalog publishing.
|
||||
ok_dashboard = {
|
||||
"version": 1,
|
||||
"title": "v2.7 sampler",
|
||||
"sections": [{
|
||||
"title": "Sample",
|
||||
"columns": 2,
|
||||
"widgets": [
|
||||
{"type": "stat", "title": "Sites", "value": 4, "sparkline": [1, 2, 3, 2, 4]},
|
||||
{"type": "list", "title": "Status",
|
||||
"items": [{"text": "auth.example.com", "status": "ok"},
|
||||
{"text": "api.example.com", "status": "down"}]},
|
||||
{"type": "markdown_file", "title": "Weekly", "path": "reports/weekly.md"},
|
||||
{"type": "log_tail", "title": "Tail", "path": "reports/run.log", "lines": 30},
|
||||
{"type": "cron_status", "title": "Job", "jobId": "uptime-sweep"},
|
||||
{"type": "image", "title": "Pic", "path": "reports/chart.png"},
|
||||
{"type": "status_grid", "title": "Fleet", "cells": [
|
||||
{"label": "us-east-1", "status": "success"},
|
||||
{"label": "us-west-2", "status": "warning"},
|
||||
{"label": "eu-central-1", "status": "danger"},
|
||||
]},
|
||||
],
|
||||
}],
|
||||
}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/v2_7",
|
||||
"name": "v2.7 sampler",
|
||||
"version": "1.0.0",
|
||||
"description": "exercises every v2.7 widget",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "v2_7",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(ok_dashboard).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
templates, errors = self._validate_all()
|
||||
self.assertEqual(errors, [], f"unexpected errors: {errors}")
|
||||
|
||||
def test_v2_7_widgets_reject_missing_required(self):
|
||||
# Each v2.7 file/cron/grid widget has a required field. A bundle that
|
||||
# omits any of them should be rejected.
|
||||
bad_dashboard = {
|
||||
"version": 1,
|
||||
"title": "Bad sampler",
|
||||
"sections": [{
|
||||
"title": "Bad",
|
||||
"columns": 1,
|
||||
"widgets": [
|
||||
{"type": "markdown_file", "title": "no path"},
|
||||
{"type": "log_tail", "title": "no path"},
|
||||
{"type": "cron_status", "title": "no jobId"},
|
||||
{"type": "status_grid", "title": "no cells"},
|
||||
],
|
||||
}],
|
||||
}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/v2_7_bad",
|
||||
"name": "Bad",
|
||||
"version": "1.0.0",
|
||||
"description": "missing required fields",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "v2_7_bad",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
_, errors = self._validate_all()
|
||||
# Expect at least one missing-required error per offending widget.
|
||||
for required, label in [("path", "markdown_file"), ("path", "log_tail"),
|
||||
("jobId", "cron_status"), ("cells", "status_grid")]:
|
||||
self.assertTrue(
|
||||
any(f"missing required field '{required}'" in str(e) and label in str(e) for e in errors),
|
||||
f"expected missing `{required}` error for {label}; got: {errors}",
|
||||
)
|
||||
|
||||
def test_cron_status_requires_jobId(self):
|
||||
bad_dashboard = {
|
||||
"version": 1,
|
||||
"title": "Bad",
|
||||
"sections": [{"title": "x", "columns": 1, "widgets": [
|
||||
{"type": "cron_status", "title": "Without jobId"},
|
||||
]}],
|
||||
}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/cron-no-id",
|
||||
"name": "Cron",
|
||||
"version": "1.0.0",
|
||||
"description": "missing jobId",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "cron-no-id",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
_, errors = self._validate_all()
|
||||
self.assertTrue(
|
||||
any("missing required field 'jobId'" in str(e) for e in errors),
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_rejects_secret_in_bundle(self):
|
||||
leaky = b"config:\n github_token: ghp_" + b"A" * 40 + b"\n"
|
||||
manifest = {
|
||||
|
||||
Reference in New Issue
Block a user