mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
150042fe98
Put published highlights and optional screenshot comparisons in the post-update dialog, with grouped details available on demand. Reuse inspected images from merged pull requests or capture fresh release views, then publish versioned PNG sidecars. Serve screenshots through the authenticated release endpoint to preserve the existing image policy.
261 lines
11 KiB
Python
261 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import importlib.util
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
MODULE_PATH = Path(__file__).with_name("release_note_visuals.py")
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
|
SPEC = importlib.util.spec_from_file_location("release_note_visuals", MODULE_PATH)
|
|
assert SPEC and SPEC.loader
|
|
visuals = importlib.util.module_from_spec(SPEC)
|
|
SPEC.loader.exec_module(visuals)
|
|
|
|
RENDERER_PATH = Path(__file__).with_name("render_release_body.py")
|
|
RENDERER_SPEC = importlib.util.spec_from_file_location("render_release_body", RENDERER_PATH)
|
|
assert RENDERER_SPEC and RENDERER_SPEC.loader
|
|
renderer = importlib.util.module_from_spec(RENDERER_SPEC)
|
|
RENDERER_SPEC.loader.exec_module(renderer)
|
|
|
|
|
|
def valid_plan():
|
|
return {
|
|
"schema_version": 1,
|
|
"decision": "This settings comparison makes the responsive redesign immediately visible.",
|
|
"captures": [
|
|
{
|
|
"id": "responsive-settings",
|
|
"title": "Settings fit smaller screens",
|
|
"description": "Controls remain readable without horizontal scrolling.",
|
|
"viewport": {"width": 390, "height": 844},
|
|
"before": {
|
|
"route": "/settings/general",
|
|
"steps": [],
|
|
"ready": {
|
|
"kind": "role",
|
|
"role": "heading",
|
|
"name": "General",
|
|
"exact": True,
|
|
"nth": 0,
|
|
},
|
|
},
|
|
"after": {
|
|
"route": "/settings/general",
|
|
"steps": [
|
|
{
|
|
"action": "wait",
|
|
"locator": {
|
|
"kind": "text",
|
|
"value": "Appearance",
|
|
"exact": True,
|
|
"nth": 0,
|
|
},
|
|
}
|
|
],
|
|
"ready": {
|
|
"kind": "text",
|
|
"value": "Appearance",
|
|
"exact": True,
|
|
"nth": 0,
|
|
},
|
|
},
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
class ReleaseNoteVisualPlanTest(unittest.TestCase):
|
|
def test_normalizes_a_safe_accessible_capture_plan(self):
|
|
plan = visuals.validate_plan(valid_plan())
|
|
self.assertEqual(plan["captures"][0]["viewport"], {"width": 390, "height": 844})
|
|
self.assertEqual(
|
|
visuals.asset_names(plan),
|
|
[
|
|
"release-note-responsive-settings-before.png",
|
|
"release-note-responsive-settings-now.png",
|
|
],
|
|
)
|
|
|
|
def test_structured_output_schema_carries_public_and_capture_bounds(self):
|
|
schema = visuals.json_schema()
|
|
self.assertEqual(schema["properties"]["schema_version"]["type"], "integer")
|
|
self.assertIn("decision", schema["required"])
|
|
captures = schema["properties"]["captures"]
|
|
capture = captures["items"]["properties"]
|
|
self.assertEqual(captures["maxItems"], visuals.MAX_CAPTURES)
|
|
self.assertEqual(capture["description"]["maxLength"], 240)
|
|
self.assertEqual(
|
|
capture["after"]["anyOf"][0]["properties"]["steps"]["maxItems"],
|
|
visuals.MAX_STEPS,
|
|
)
|
|
|
|
def test_empty_capture_plan_requires_an_explicit_model_decision(self):
|
|
with self.assertRaisesRegex(visuals.PlanError, "decision"):
|
|
visuals.validate_plan({"schema_version": 1, "captures": []})
|
|
plan = visuals.validate_plan(
|
|
{
|
|
"schema_version": 1,
|
|
"decision": "The investigated changes have no meaningful static visual state.",
|
|
"captures": [],
|
|
}
|
|
)
|
|
self.assertEqual(plan["captures"], [])
|
|
|
|
def test_current_only_capture_has_one_asset(self):
|
|
raw = valid_plan()
|
|
raw["captures"][0]["before"] = None
|
|
plan = visuals.validate_plan(raw)
|
|
self.assertEqual(
|
|
sum(capture["before"] is not None for capture in plan["captures"]),
|
|
0,
|
|
)
|
|
self.assertEqual(
|
|
visuals.asset_names(plan),
|
|
["release-note-responsive-settings-now.png"],
|
|
)
|
|
|
|
def test_reuses_pr_images_without_building_either_runtime(self):
|
|
raw = valid_plan()
|
|
source = {
|
|
"image_url": "https://github.com/user-attachments/assets/f3e1870e-7f12-4ab6-8961-cab68e1f1725",
|
|
"pull_request_url": "https://github.com/rcourtman/Pulse/pull/1194",
|
|
}
|
|
raw["captures"][0]["before"] = source.copy()
|
|
raw["captures"][0]["after"] = source.copy()
|
|
plan = visuals.validate_plan(raw)
|
|
self.assertEqual(visuals.runtime_count(plan, "before"), 0)
|
|
self.assertEqual(visuals.runtime_count(plan, "after"), 0)
|
|
self.assertEqual(len(visuals.asset_names(plan)), 2)
|
|
markdown = visuals.render_markdown(plan, "rcourtman/Pulse", "v6.6.0")
|
|
self.assertIn("releases/download/v6.6.0/release-note-responsive-settings-now.png", markdown)
|
|
self.assertNotIn("user-attachments", markdown)
|
|
|
|
def test_can_mix_a_reused_before_image_and_a_fresh_current_capture(self):
|
|
raw = valid_plan()
|
|
raw["captures"][0]["before"] = {
|
|
"image_url": "https://github.com/user-attachments/assets/f3e1870e-7f12-4ab6-8961-cab68e1f1725",
|
|
"pull_request_url": "https://github.com/rcourtman/Pulse/pull/1194",
|
|
}
|
|
plan = visuals.validate_plan(raw)
|
|
self.assertEqual(visuals.runtime_count(plan, "before"), 0)
|
|
self.assertEqual(visuals.runtime_count(plan, "after"), 1)
|
|
|
|
def test_reuses_a_screenshot_at_an_immutable_repository_revision(self):
|
|
raw = valid_plan()
|
|
raw["captures"][0]["after"] = {
|
|
"image_url": "https://raw.githubusercontent.com/rcourtman/Pulse/" + "a" * 40 + "/frontend-modern/browser-verification/screenshots/release-highlights-desktop.png",
|
|
"pull_request_url": "https://github.com/rcourtman/Pulse/pull/1194",
|
|
}
|
|
plan = visuals.validate_plan(raw)
|
|
self.assertEqual(visuals.runtime_count(plan, "after"), 0)
|
|
raw["captures"][0]["after"]["image_url"] = raw["captures"][0]["after"]["image_url"].replace("a" * 40, "main")
|
|
with self.assertRaises(visuals.PlanError):
|
|
visuals.validate_plan(raw)
|
|
|
|
def test_reused_images_require_public_attachments_and_pulse_pr_provenance(self):
|
|
for image_url, pr_url in [
|
|
("https://example.com/screenshot.png", "https://github.com/rcourtman/Pulse/pull/1194"),
|
|
("https://github.com/user-attachments/assets/f3e1870e-7f12-4ab6-8961-cab68e1f1725?token=private", "https://github.com/rcourtman/Pulse/pull/1194"),
|
|
("https://github.com/user-attachments/assets/f3e1870e-7f12-4ab6-8961-cab68e1f1725", "https://github.com/other/repo/pull/1194"),
|
|
]:
|
|
with self.subTest(image_url=image_url):
|
|
raw = valid_plan()
|
|
raw["captures"][0]["after"] = {"image_url": image_url, "pull_request_url": pr_url}
|
|
with self.assertRaises(visuals.PlanError):
|
|
visuals.validate_plan(raw)
|
|
def test_rejects_external_routes_and_arbitrary_selectors(self):
|
|
raw = valid_plan()
|
|
raw["captures"][0]["after"]["route"] = "https://example.com/"
|
|
with self.assertRaisesRegex(visuals.PlanError, "same-origin"):
|
|
visuals.validate_plan(raw)
|
|
|
|
raw = valid_plan()
|
|
raw["captures"][0]["after"]["route"] = "/\\\\example.com/"
|
|
with self.assertRaisesRegex(visuals.PlanError, "same-origin"):
|
|
visuals.validate_plan(raw)
|
|
|
|
raw = valid_plan()
|
|
raw["captures"][0]["after"]["steps"][0]["locator"]["kind"] = "css"
|
|
with self.assertRaisesRegex(visuals.PlanError, "kind must be"):
|
|
visuals.validate_plan(raw)
|
|
|
|
def test_requires_visible_content_for_every_capture_state(self):
|
|
raw = valid_plan()
|
|
del raw["captures"][0]["after"]["ready"]
|
|
with self.assertRaisesRegex(visuals.PlanError, "visible content"):
|
|
visuals.validate_plan(raw)
|
|
|
|
def test_rejects_public_punctuation_disallowed_by_release_notes(self):
|
|
raw = valid_plan()
|
|
raw["captures"][0]["description"] = "Before; now"
|
|
with self.assertRaisesRegex(visuals.PlanError, "semicolons"):
|
|
visuals.validate_plan(raw)
|
|
|
|
def test_renders_release_asset_links_as_before_and_now(self):
|
|
plan = visuals.validate_plan(valid_plan())
|
|
markdown = visuals.render_markdown(plan, "rcourtman/Pulse", "v6.4.0")
|
|
self.assertIn("## See the difference", markdown)
|
|
self.assertIn("| Before | Now |", markdown)
|
|
self.assertIn(
|
|
"https://github.com/rcourtman/Pulse/releases/download/v6.4.0/"
|
|
"release-note-responsive-settings-before.png",
|
|
markdown,
|
|
)
|
|
self.assertNotIn(";", markdown)
|
|
self.assertNotIn("\u2014", markdown)
|
|
|
|
def test_release_body_accepts_visuals_between_notes_and_installation(self):
|
|
notes = "\n".join(
|
|
[
|
|
"# Pulse v6.4.0 Release Notes",
|
|
"",
|
|
"Pulse is easier to use across the devices you already carry.",
|
|
"",
|
|
"## What's improved",
|
|
"",
|
|
"- **Responsive settings** - Controls now fit smaller screens cleanly.",
|
|
]
|
|
)
|
|
visual_markdown = visuals.render_markdown(
|
|
visuals.validate_plan(valid_plan()), "rcourtman/Pulse", "v6.4.0"
|
|
).strip()
|
|
rollback = renderer.build_rollback_section(
|
|
type(
|
|
"Args",
|
|
(),
|
|
{
|
|
"version": "6.4.0",
|
|
"rollback_target": "v6.3.2",
|
|
"rollback_command": "sudo /bin/update --version v6.3.2",
|
|
},
|
|
)()
|
|
)
|
|
body = "\n\n".join(
|
|
[
|
|
notes,
|
|
visual_markdown,
|
|
renderer.build_installation_section("6.4.0"),
|
|
rollback,
|
|
]
|
|
) + "\n"
|
|
self.assertEqual(renderer.validate_release_body_shape(body, "6.4.0"), body)
|
|
|
|
def test_release_pipeline_captures_uploads_and_verifies_selected_visuals(self):
|
|
workflow = (REPOSITORY_ROOT / ".github/workflows/create-release.yml").read_text()
|
|
self.assertIn("release_screenshot_plan:", workflow)
|
|
self.assertIn("release_note_visuals:", workflow)
|
|
self.assertIn("scripts/capture-release-note-visuals.sh", workflow)
|
|
self.assertIn('--release-visuals-file "$VISUAL_MARKDOWN_FILE"', workflow)
|
|
self.assertIn('release_upload_with_retry "$TAG" "release-note-visuals/${asset_name}"', workflow)
|
|
self.assertIn(r'"release-note-\(.id)-now.png"', workflow)
|
|
|
|
for trigger_name in ("trigger-release.sh", "trigger-stable-patch.sh"):
|
|
trigger = (REPOSITORY_ROOT / "scripts" / trigger_name).read_text()
|
|
self.assertIn("--rawfile release_screenshot_plan", trigger)
|
|
self.assertIn("release_screenshot_plan: $release_screenshot_plan", trigger)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|