#!/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"]["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_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", (), { "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()