From 65a5dfecc7756254ebc657b8a029ed06842c06ad Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:46:07 +0100 Subject: [PATCH] feat: show the installer demo on readme and site The README shows the GIF under the commands. The home page and Get your files show the video through InstallDemo.astro: nothing is fetched until it is on screen, reduced motion and Save-Data keep the poster with a play button, and a description is read to screen readers. --- README.md | 4 +- scripts/generate_readme.py | 18 +++ scripts/generate_site.py | 22 +++ scripts/sitestarlight.py | 1 + tests/test_site_demo.py | 165 +++++++++++++++++++++++ website/src/components/InstallDemo.astro | 115 ++++++++++++++++ wiki/testing-guide.md | 2 +- 7 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 tests/test_site_demo.py create mode 100644 website/src/components/InstallDemo.astro diff --git a/README.md b/README.md index e554dfb12..b2f1ce61b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform retroarch &([scriptblock]::Create((irm https://abdess.github.io/retrobios/i.ps1))) --check ``` +![Recording of the one-line command in a terminal. The installer finds an ArkOS SD card with 6 games, plans 43 BIOS files (15.1 MB) for them, installs them and shows the summary.](docs_assets/demo/install.gif) + The script opens a full-screen installer, driven with the arrow keys and Enter, or with a controller on a handheld. It finds your setup and its BIOS folder, downloads only the missing files and checks each one. It can also copy the BIOS files from your old SD card. Every option, and what the installer detects and writes, is on the [installer page](https://abdess.github.io/retrobios/wiki/installer/#passing-options). @@ -206,4 +208,4 @@ The scripts and tooling are released under the [MIT License](LICENSE). The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed. The reasoning, and where it is weakest, is in [the project's legal position](https://abdess.github.io/retrobios/wiki/legal/). -*Auto-generated on 2026-11-06T10:21:06Z* +*Auto-generated on 2026-11-06T19:12:52Z* diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index e845201a0..bbc1179c5 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -440,6 +440,9 @@ def format_size(size: int) -> str: SITE_URL = "https://abdess.github.io/retrobios/" +# The installer demo (scripts/make_demo.py): the recording and what it shows. +DEMO_DIR = Path(__file__).resolve().parent.parent / "docs_assets" / "demo" +DEMO_GIF = "docs_assets/demo/install.gif" REPO_PATH = "../.." REPO = "Abdess/retrobios" # GitHub's "Add file > Upload files" page for bios/: a browser upload by @@ -447,6 +450,19 @@ REPO = "Abdess/retrobios" UPLOAD_URL = f"https://github.com/{REPO}/upload/main/bios" +def demo_facts() -> dict: + """What the installer demo shows, as make_demo.py measured it.""" + return json.loads((DEMO_DIR / "demo.json").read_text(encoding="utf-8")) + + +def demo_description(facts: dict | None = None) -> str: + """The demo in words, for a reader who cannot see it.""" + facts = facts or demo_facts() + return (f"Recording of the one-line command in a terminal. The installer finds an " + f"{facts['setup']} SD card with {facts['games']} games, plans {facts['files']} " + f"BIOS files ({facts['size']}) for them, installs them and shows the summary.") + + def _existing_contributor_block(readme_path: str = "README.md") -> list[str]: """The contributors already published, read back from the file. @@ -593,6 +609,8 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json" install_command("powershell", "--check"), "```", "", + f"![{demo_description()}]({DEMO_GIF})", + "", "The script opens a full-screen installer, driven with the arrow keys and Enter, or with a" " controller on a handheld. It finds your setup and its BIOS folder, downloads only the missing" " files and checks each one. It can also copy the BIOS files from your old SD card.", diff --git a/scripts/generate_site.py b/scripts/generate_site.py index d31250d8b..fd837205f 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -82,6 +82,8 @@ from generate_readme import ( NO_PACK, asset_links, contributing_text, + demo_description, + demo_facts, download_links, extract_notes, extract_targets, @@ -555,6 +557,7 @@ def generate_home( "**Run one command.** Some setups have a ZIP.", "**Start a game.**", ]), + *install_demo(), "## Pick your device", "", *setup_picker(registry, display), @@ -4188,6 +4191,20 @@ def first_screen_words() -> list[str]: f"and **{installer.TUI_FOLDER_TITLE}**), then offers **{installer.TUI_INSTALL_ROW}**."] +# The demo files the site serves (docs_assets/demo/, scripts/make_demo.py): +# the GIF is the README's, the tape and demo.json are inputs. +DEMO_ASSETS = ("install.webm", "install.mp4", "install.png") + + +def install_demo() -> list[str]: + """The installer demo: a video that loads once it is on screen, its + description for screen readers.""" + facts = demo_facts() + text = html.escape(demo_description(facts), quote=True) + return [f'', ""] + + def check_pass_line() -> str: """The pass line of "How to check": the words `--check` prints only when no file is missing or wrong (install.py EVERYTHING_IN_PLACE), read from @@ -12117,6 +12134,7 @@ def generate_which_pack() -> str: "", *first_screen_words(), "", + *install_demo(), *trust_box(""), "**Check your folder later.** Add `--check`. It changes nothing.", "", @@ -12476,6 +12494,10 @@ def _generate(args: argparse.Namespace) -> None: source = Path("docs_assets") / folder if source.is_dir(): shutil.copytree(source, public / "assets" / dest, dirs_exist_ok=True) + demo_dest = public / "assets" / "demo" + demo_dest.mkdir(parents=True, exist_ok=True) + for name in DEMO_ASSETS: + shutil.copy2(Path("docs_assets") / "demo" / name, demo_dest / name) assets_src = Path(".github") / "assets" for name, dest_name in [("logo.png", "logo.png"), ("favicon.png", "favicon.png")]: src = assets_src / name diff --git a/scripts/sitestarlight.py b/scripts/sitestarlight.py index 4f39e20ce..95eb42bcf 100644 --- a/scripts/sitestarlight.py +++ b/scripts/sitestarlight.py @@ -29,6 +29,7 @@ COMPONENTS = { "TabItem": "@astrojs/starlight/components", "FilePicker": "components/FilePicker.astro", "MeasurementsExplorer": "components/MeasurementsExplorer.astro", + "InstallDemo": "components/InstallDemo.astro", } # Pages of file cards: level 3 headings by the hundred, so the table of diff --git a/tests/test_site_demo.py b/tests/test_site_demo.py new file mode 100644 index 000000000..3d653e213 --- /dev/null +++ b/tests/test_site_demo.py @@ -0,0 +1,165 @@ +"""The installer demo of the README and the site (scripts/make_demo.py). + +The home page and Get your files score 98 to 100 on a phone: the video must +cost nothing before it is on screen, keep still under reduced motion, and say +in words what it shows. The files are committed, so their sizes are held +here, and the words come from what the recording measured. +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import json +import re +import struct +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + +import generate_readme # noqa: E402 +import make_demo # noqa: E402 +import sitestarlight # noqa: E402 + +DEMO = ROOT / "docs_assets" / "demo" +COMPONENT = ROOT / "website" / "src" / "components" / "InstallDemo.astro" +DIST = ROOT / "website" / "dist" + + +def _facts() -> dict: + return json.loads((DEMO / "demo.json").read_text(encoding="utf-8")) + + +class TheFilesAreThereAndSmall(unittest.TestCase): + def test_each_output_is_under_its_target(self): + for name, limit in make_demo.LIMITS.items(): + with self.subTest(name=name): + self.assertLessEqual((DEMO / name).stat().st_size, limit) + + def test_the_gif_and_the_poster_have_the_recorded_size(self): + facts = _facts() + head = (DEMO / "install.gif").read_bytes()[:10] + self.assertEqual(head[:6], b"GIF89a") + self.assertEqual(struct.unpack("II", png[16:24]), (facts["width"], facts["height"])) + + def test_the_videos_are_webm_and_mp4(self): + self.assertEqual((DEMO / "install.webm").read_bytes()[:4], b"\x1a\x45\xdf\xa3") + self.assertEqual((DEMO / "install.mp4").read_bytes()[4:8], b"ftyp") + + +class TheTapeAndTheDriverAgree(unittest.TestCase): + def setUp(self): + self.tape = make_demo.TAPE.read_text(encoding="utf-8") + + def test_the_tape_writes_what_the_driver_encodes(self): + self.assertIn(f"Output {make_demo.RECORDED.relative_to(ROOT).as_posix()}\n", self.tape) + self.assertIn(f"Screenshot {make_demo.POSTER_SHOT.relative_to(ROOT).as_posix()}\n", + self.tape) + + def test_the_size_in_demo_json_is_the_tapes(self): + facts = _facts() + self.assertIn(f"Set Width {facts['width']}\n", self.tape) + self.assertIn(f"Set Height {facts['height']}\n", self.tape) + + def test_the_command_typed_is_the_one_the_pages_give(self): + from siteguides import install_command # noqa: PLC0415 + + self.assertIn(f'Type "{install_command("sh", "--for-games")}"\n', self.tape) + + def test_the_shell_answers_only_the_site_address(self): + typed = re.search(r'Type "curl -fsSL (\S+) \|', self.tape) + self.assertIsNotNone(typed) + driver = Path(make_demo.__file__).read_text(encoding="utf-8") + self.assertIn(f'"$*" = "-fsSL {typed.group(1)}"', driver) + + +class TheReadmeShowsTheGif(unittest.TestCase): + def test_the_gif_sits_under_the_commands_with_its_description(self): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + line = f"![{generate_readme.demo_description()}]({generate_readme.DEMO_GIF})" + self.assertIn(line, readme) + self.assertLess(readme.index("## Quick install"), readme.index(line)) + self.assertLess(readme.index(line), readme.index("## Download BIOS packs")) + + def test_the_description_names_what_the_recording_measured(self): + facts = _facts() + words = generate_readme.demo_description(facts) + for value in (facts["setup"], f"{facts['games']} games", f"{facts['files']} BIOS files", + facts["size"]): + self.assertIn(str(value), words) + + +class TheVideoLoadsOnlyOnScreen(unittest.TestCase): + def setUp(self): + self.source = COMPONENT.read_text(encoding="utf-8") + self.markup = self.source.split(" + + diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 42f2b8dea..41052281d 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -67,7 +67,7 @@ Every module in `tests/`, by the area it guards. | Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` | | Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | -| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_demo`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` | | Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` | ## Test architecture