diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3a8405c..ed6bbc3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,5 +1,16 @@ name: Test -on: [push] +on: + push: + branches: [main] + tags: ["[0-9]+.[0-9]+.[0-9]+"] + pull_request: {} + +permissions: + contents: read + +concurrency: + group: test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: release-site-tests: @@ -14,6 +25,8 @@ jobs: python-version: '3.13' - run: python3 -m unittest discover -s tools -p release_site_test.py - run: python3 -m unittest discover -s tools -p release_notes_test.py + - run: python3 -m unittest discover -s tools -p site_artwork_test.py + - run: python3 -m unittest discover -s tools -p infrastructure_test.py - name: Verify configured documentation and generated links env: GH_TOKEN: ${{ github.token }} @@ -47,6 +60,7 @@ jobs: - uses: actions/checkout@v7 - uses: bazel-contrib/setup-bazel@0.19.0 with: + cache-save: ${{ github.ref == 'refs/heads/main' }} bazelisk-cache: true # Store build cache per workflow. disk-cache: ${{ github.workflow }} @@ -57,6 +71,9 @@ jobs: bazel test //... done: + permissions: + contents: read + actions: read needs: [release-site-tests, pre-commit, test] if: always() runs-on: ubuntu-latest @@ -82,6 +99,25 @@ jobs: exit 1 fi echo "done covers all $(grep -c . <<<"${declared}") workflow jobs." + - name: Report repository cache usage + if: always() && github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if gh cache list --limit 10000 --json key,ref,sizeInBytes >cache-inventory.json; then + python3 - <<'PYTHON' >>"${GITHUB_STEP_SUMMARY}" + import json + caches = json.load(open("cache-inventory.json", encoding="utf-8")) + size = sum(entry["sizeInBytes"] for entry in caches) + print(f"Actions caches: {len(caches)} entries, {size:,} compressed bytes.") + print("\nLargest entries:") + for entry in sorted(caches, key=lambda item: item["sizeInBytes"], reverse=True)[:10]: + print(f"- {entry['key']}: {entry['sizeInBytes']:,} bytes ({entry['ref']})") + PYTHON + else + echo "Cache inventory unavailable." >>"${GITHUB_STEP_SUMMARY}" + fi - name: Fail if any dependency did not succeed if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 3bd9185..2956d92 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -127,6 +127,7 @@ jobs: fi mkdir -p public rsync --archive --exclude='.git' site/ public/ + python3 source/tools/site_artwork.py source/docs/assets public - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v5 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31603d4..78a2c5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,20 +2,24 @@ name: Release on: push: tags: - - "*.*.*" + - "[0-9]+.[0-9]+.[0-9]+" jobs: release: - uses: bazel-contrib/.github/.github/workflows/release_ruleset.yaml@v7 + uses: bazel-contrib/.github/.github/workflows/release_ruleset.yaml@1d798ff015ed0696433e01e2c3ccbb2abefadad7 # v7.7.0 permissions: attestations: write contents: write id-token: write with: release_files: bashtest-*.tar.gz - # Keep the GitHub release provisional until the secondary BCR - # publication process has completed. - prerelease: true + # action-gh-release v3 stages standard releases as drafts until all assets + # are uploaded, then publishes once. Immutable releases cannot be promoted + # from prerelease after publication. + prerelease: false + # The upstream release workflow cannot restrict saves to main. Keep its + # separate release build from populating tag-scoped caches. + mount_bazel_caches: false # Mirror the release to the Bazel Central Registry (replaces the retired # publish-to-bcr GitHub App). See .github/workflows/publish.yaml. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a3b564..c33d73c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,6 +33,12 @@ repos: - repo: local hooks: + - id: infrastructure-tests + name: Test publishing and CI policy + language: system + entry: python3 -m unittest discover -s tools -p '*_test.py' + files: ^(tools/|docs/assets/|release-site\.json$|\.github/workflows/) + pass_filenames: false - id: uncomment-bazelmod-includes name: uncomment-bazelmod-includes description: | diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..da8e6ce --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# Agent and contributor rules - bashtest + +These rules apply to human and automated contributors. `RULES.md` owns code layout and API +compatibility; `STYLE_SH.md` owns shell conventions; `GIT_RULES.md` owns branch and PR operations. + +Run `bazel test //...`, `python3 -m unittest discover -s tools -p '*_test.py'`, and +`pre-commit run --all-files` before proposing repository-wide changes. Add regression tests for +behavior changes. Preserve the supported operating systems in CI and keep direct Bazel dependencies +explicit. Keep developer-only dependencies separate from the published module where supported. + +Never weaken lint rules or required checks to make CI pass. Do not commit Bazel outputs, local rc +files, caches, or generated release artifacts. Format Starlark with the pinned Buildifier hooks. +Use the existing shell test framework and its diagnostics. Keep public behavior documented in the +same change. Keep Markdown tables vertically aligned; generated docs retain their source of truth. + +Versions in `MODULE.bazel` and `CHANGELOG.md` must agree. Use `tools/trigger_release.sh` for numeric +semantic-version release tags. Preserve the existing immutable GitHub release and BCR publication +flow. [Infrastructure guidance](docs/infrastructure.md) explains CI, caching, and site publishing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd943ee..e6a07c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,12 @@ All contributions are generally welcome as long as they fit in with the concepts # Code Rules -All code must adhere to the [RULES.md](RULES.md) and mostly follows the [Google style](https://google.github.io/styleguide/). Where it diverges, clang-tidy rules are in effect as much as possible. +All code must adhere to the [RULES.md](RULES.md) and mostly follows the [Google style](https://google.github.io/styleguide/). Where it diverges, the checked-in pre-commit rules are authoritative. + +# Workflow and language rules + +Follow [AGENTS.md](AGENTS.md), [GIT_RULES.md](GIT_RULES.md), and [STYLE_SH.md](STYLE_SH.md). +Run `bazel test //...` and keep behavior changes covered by regression tests. # Run pre-commit diff --git a/GIT_RULES.md b/GIT_RULES.md new file mode 100644 index 0000000..aaa1d71 --- /dev/null +++ b/GIT_RULES.md @@ -0,0 +1,105 @@ +# Git and pull-request rules + +These rules are the source of truth for branch, pull-request, CI, and merge operations in this +repository. They apply to human and automated contributors. Repository protections are constraints +to satisfy, never obstacles to bypass. + +## State-changing operations + +Before pushing, rebasing, retargeting, merging, closing, reopening, or otherwise changing GitHub +state: + +1. Fetch the current base branch and pull-request head. +2. Read the pull request's current head SHA, base, mergeability, review state, and required checks + from GitHub. +3. Confirm that the operation advances the stated goal and does not invalidate a ready pull request + or another change that depends on it. +4. Identify the exact branch, pull request, or run being changed. Never rely on a stale local or + remembered state. + +Keep local and remote state distinct. A local branch containing current `main` does not prove that +the remote pull-request head is current. Push synchronization commits before treating later local +validation as authoritative. + +Do not force-push, rebase, retarget, merge another branch into a pull request, cancel runs, or alter +a ready pull request speculatively. Such changes are appropriate only to resolve a demonstrated +failure, conflict, or dependency, and require validation of the resulting head. + +## Pull-request descriptions + +Every pull-request description has two layers, in this order: + +1. A short human-readable explanation of the outcome and why it matters. +2. An `## AG;DR` section containing the implementation details, reasoning, validation, portability + notes, dependencies, and known limitations needed by reviewers or a future contributor. + +Update the detailed section whenever a commit changes the implementation or validation. Keep the +human summary stable unless the outcome or motivation changes. + +Descriptions must identify dependencies on other pull requests and any required merge order. Do +not claim that a check passed unless it ran against the current pushed head in the applicable +context. + +## Review readiness and merging + +A pull request is ready to merge only when all of the following are true: + +- its intended base and head are current; +- it is approved and mergeable; +- all required checks for its current head have completed successfully; +- its description reflects the complete change; +- no unresolved dependency requires another pull request to merge first. + +When a pull request is ready, merge it without changing its head, base, commits, or branch. Do not +restart, duplicate, bypass, transplant, or substitute required checks. Checks belong to their exact +pull-request context even when another commit has the same tree. + +Enable auto-merge only after confirming the correct base and head, approval, mergeability, and +successful required checks. Auto-merge is a convenience, not a substitute for verifying readiness. + +## Dependency ordering + +Prefer small, independently reviewable pull requests. For a sequential tracked effort, keep one +pull request as the active merge gate and start the next item from updated `main` only after that +gate merges. + +When changes must be stacked, record both ancestry and semantic dependencies. If a ready base has +one child, merge the base first; after GitHub retargets the child, require the child's checks to run +again against its new base. Do not delay a ready base for a child's now-obsolete pre-retarget run. + +For multiple open pull requests, inspect more than their declared bases. Account for shared files, +APIs, build configuration, workflows, generated artifacts, and validation behavior. Merge independent +roots without invalidating ready work; serialize changes that overlap or alter one another's CI. +After every merge, push, retarget, or new commit, refresh GitHub state and recompute the order. + +## CI and conflict recovery + +Treat a failure as evidence to investigate, not a reason to weaken or bypass policy. Inspect the +failing job and reproduce it locally where practical. Fix the cause or document a narrowly scoped +exception permitted by repository policy, then rerun validation on the resulting pushed head. + +For a conflicted pull request: + +1. Verify that it is not already ready to merge. +2. Refresh its base and identify the exact conflicting changes. +3. Resolve the conflict on that pull request's branch, preserving compatible behavior. +4. Run validation appropriate to the combined change and push the resolution. +5. Update the description and treat all previous checks for the old head as obsolete. + +Cancel only runs that are demonstrably obsolete. A slow, queued, or temporarily failing run does +not by itself justify replacing the head or starting duplicate runs. + +## Completion checks + +After a pull request merges: + +1. Confirm GitHub reports it as merged and record the merge commit. +2. Verify that `main` contains the intended change. +3. Inspect the resulting `main` CI and address any regression before declaring the work complete or + advancing a dependent change. +4. Refresh remaining pull requests because bases, conflicts, and required checks may have changed. +5. Remove or leave remote branches according to repository settings; never delete unmerged work + without explicit authorization. + +Work is complete only when the intended change is present on `main`, required post-merge automation +is healthy, and no promised dependent action remains. diff --git a/README.md b/README.md index 3b2ebdf..c9bd167 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# bashtest.sh - A Bazel shell test runner. +# bashtest.sh - A Bazel shell test runner. MBO Works -[Release website](https://mboworks.github.io/bashtest/) +[Release website](https://mboworks.github.io/bashtest/) · [Infrastructure and publishing](docs/infrastructure.md) This shell test library provides Bazel macro rules to simplify shell testing. diff --git a/RULES.md b/RULES.md index a43d319..6163943 100644 --- a/RULES.md +++ b/RULES.md @@ -11,3 +11,6 @@ Some rules for the code layout and its development. * have documentation. * API changes that are not backwards compatible should not occur in minor version changes. * Undocumented and private/internal APIs may be changed in any way at any time. + +Shared contributor workflow is in [AGENTS.md](AGENTS.md), branch and PR policy in +[GIT_RULES.md](GIT_RULES.md), and shell conventions in [STYLE_SH.md](STYLE_SH.md). diff --git a/STYLE_SH.md b/STYLE_SH.md new file mode 100644 index 0000000..1ddbe81 --- /dev/null +++ b/STYLE_SH.md @@ -0,0 +1,62 @@ +# Shell style + +Follow the [Google Shell Style Guide](https://google.github.io/styleguide/shellguide.html) unless +this document or repository tooling says otherwise. + +## Tooling + +- Use Bash. Executable scripts start with `#!/usr/bin/env bash`, followed by the licence header. +- Format with the pinned `beautysh` pre-commit hook. +- Run ShellCheck and explain necessary local suppressions next to the affected code. +- Use `set -euo pipefail` in scripts unless a documented compatibility constraint prevents it. +- Quote expansions unless word splitting is intentional and documented. +- Make function variables `local` and use lower-case names for locals and functions. + +The formatter and linter versions are pinned in [`.pre-commit-config.yaml`](.pre-commit-config.yaml). + +## Functions and state + +- A function returns data on standard output and diagnostics on standard error. +- Do not return results by mutating caller variables, global counters, the working directory, shell + options, or traps. +- A function whose stated purpose is an external effect may perform that effect; keep its scope + narrow and explicit. +- Avoid `eval`, `printf -v`, and string-built command lines. Use arrays for commands and arguments. +- Prefer small functions with one clear responsibility. + +```sh +make_tree() { + local root + root="$(mktemp -d)" + mkdir -p "${root}/src" + echo "${root}" +} + +tree="$(make_tree)" +``` + +## Conditions and loops + +- Prefer `[[ ... ]]` for string and file tests and `(( ... ))` for arithmetic. +- Use `case` for multi-way string matching. +- Read lines with `while IFS= read -r line`; do not lose backslashes or surrounding whitespace. +- Do not parse structured data with fragile `grep`/`sed` pipelines when `jq`, `yq`, or a small + checked-in Python tool provides a reliable parser. + +## Temporary files and cleanup + +- Tests put temporary files below `${BASHTEST_TMPDIR}` and use `test_tmpdir name` for retained fixtures. + The framework owns their cleanup; test bodies must not add cleanup traps or recursive deletion. +- Quote and validate deletion targets. Never recursively delete an unresolved variable, a broad + workspace path, or a user directory. +- Framework/standalone scripts may use a cleanup trap only for resources they own, after the path is known. +- Bazel tests use `${TEST_TMPDIR}` for target-owned temporary output and resolve runfiles through + `${TEST_SRCDIR}` and `${TEST_WORKSPACE}`. + +## Portability + +- Support the Bash versions available on the macOS and Linux runners in the CI matrix. +- Do not assume GNU-only flags in scripts that run on macOS; branch on capabilities when necessary. +- Avoid `mapfile` where a script must run under the system Bash shipped with macOS. +- Preserve upper-case names for environment and Bazel runfile variables. +- Check required external programs early and fail with a useful message. diff --git a/docs/assets/apple-touch-icon.png b/docs/assets/apple-touch-icon.png new file mode 100644 index 0000000..fd78e1c Binary files /dev/null and b/docs/assets/apple-touch-icon.png differ diff --git a/docs/assets/favicon.ico b/docs/assets/favicon.ico new file mode 100644 index 0000000..4ebcd44 Binary files /dev/null and b/docs/assets/favicon.ico differ diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png new file mode 100644 index 0000000..db79b35 Binary files /dev/null and b/docs/assets/favicon.png differ diff --git a/docs/assets/mboworks-logo.png b/docs/assets/mboworks-logo.png new file mode 100644 index 0000000..40aa155 Binary files /dev/null and b/docs/assets/mboworks-logo.png differ diff --git a/docs/infrastructure.md b/docs/infrastructure.md new file mode 100644 index 0000000..0953b4e --- /dev/null +++ b/docs/infrastructure.md @@ -0,0 +1,50 @@ +# Infrastructure and publishing + +Adapted from [proto PR 100](https://github.com/mboworks/proto/pull/100) and xff PRs 835–848, +excluding 841. This repository keeps its Starlark/shell test pipeline and native setup-bazel +cache support. C++ clang-tidy orchestration, sanitizer caches, and coverage history ordering do +not apply to its current jobs. + +## CI and caching + +Main and numeric release tags run push validation; branches run in pull-request context, +including forks, without duplicating the complete matrix on each push. Only superseded PR runs +are cancelled. The final gate retains release-site tests, pre-commit, and every test-matrix cell. + +The pinned setup-bazel action retains native platform-specific cache paths, including Windows +where supported. In the Test workflow, PRs and release tags restore caches; only main saves them. +The separate upstream Release build disables cache mounting because it cannot restrict writes +to main. Bazelisk and +repository downloads remain cached because these small projects do not download the C++ LLVM +payload that motivated compiled-output-only caching in proto and xff. The action's existing +OS/architecture and build/dependency-hash keys remain intact. Exact cache hits are immutable; +source-only edits need not refresh these small build-configuration caches. + +The final main job reports compressed repository cache usage and the ten largest entries. +Use these measurements before introducing stricter budgets or custom eviction. A missing +inventory is reported without replacing test failures. This inventory includes all refs and +may briefly lag post-job cache uploads; verify later runs before claiming savings. + +## Publishing + +The release publisher uses trusted main tooling and the tagged documentation snapshot. +The shared 64-pixel README logo and favicons match MBO Works' other repositories. +Favicons are inserted after staging the complete deployment copy; retained release snapshots +stay unchanged. Decoration is idempotent and resolves links from nested pages. + +The shared release workflow is pinned to the same v7.7.0 commit as proto. Standard releases +are staged as drafts until archives and attestations are uploaded, then published once. Published +immutable prereleases cannot later be promoted. BCR publication follows release success and can +be retried independently; its failure does not require recreating a release or moving its tag. +Release helpers, numeric tags, and version agreement keep their existing contracts. No module +dependency versions change in this rollout. + +## Contributor rules and verification + +`AGENTS.md`, `GIT_RULES.md`, and `STYLE_SH.md` synchronize applicable shared rules. `RULES.md` +retains this repository's layout and API promises. Beautysh remains the shell formatter; no C++ +style guide or competing shell formatter is introduced. + +Run `bazel test //...`, `python3 -m unittest discover -s tools -p '*_test.py'`, and +`pre-commit run --all-files`. CI also builds the configured release documentation to validate +links and mappings before publication. diff --git a/release-site.json b/release-site.json index db569c0..109558b 100644 --- a/release-site.json +++ b/release-site.json @@ -6,7 +6,11 @@ "bashtest/README.md": "bashtest/README.html", "CODE_OF_CONDUCT.md": "CODE_OF_CONDUCT.html", "RULES.md": "RULES.html", - "docs/release-website.md": "docs/release-website.html" + "docs/release-website.md": "docs/release-website.html", + "AGENTS.md": "AGENTS.html", + "GIT_RULES.md": "GIT_RULES.html", + "STYLE_SH.md": "STYLE_SH.html", + "docs/infrastructure.md": "docs/infrastructure.html" }, "links": [] } diff --git a/tools/infrastructure_test.py b/tools/infrastructure_test.py new file mode 100644 index 0000000..cfcb4be --- /dev/null +++ b/tools/infrastructure_test.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Regression checks for PR cache isolation and complete validation gates.""" + +from pathlib import Path +import unittest + + +class InfrastructureTest(unittest.TestCase): + def test_prs_restore_but_only_main_writes_caches(self): + workflow = (Path(__file__).parents[1] / '.github/workflows/main.yml').read_text() + self.assertIn('pull_request: {}', workflow) + self.assertIn("cache-save: ${{ github.ref == 'refs/heads/main' }}", workflow) + self.assertIn("cancel-in-progress: ${{ github.event_name == 'pull_request' }}", workflow) + self.assertIn('repository-cache: true', workflow) + self.assertIn('disk-cache: ${{ github.workflow }}', workflow) + self.assertIn('needs: [release-site-tests, pre-commit, test]', workflow) + self.assertIn('os: [ubuntu-latest, macos-latest]', workflow) + + def test_release_uploads_before_immutable_publication(self): + workflow = (Path(__file__).parents[1] / '.github/workflows/release.yml').read_text() + self.assertIn('release_ruleset.yaml@1d798ff015ed0696433e01e2c3ccbb2abefadad7', workflow) + self.assertIn('prerelease: false', workflow) + self.assertIn('mount_bazel_caches: false', workflow) + self.assertIn('needs: release', workflow) + self.assertNotIn('prerelease: true', workflow) + + def test_documented_rules_are_published(self): + import json + root = Path(__file__).parents[1] + config = json.loads((root / 'release-site.json').read_text()) + for name in ('AGENTS.md', 'GIT_RULES.md', 'STYLE_SH.md', 'docs/infrastructure.md'): + self.assertTrue((root / name).is_file()) + self.assertIn(name, config['pages']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/site_artwork.py b/tools/site_artwork.py new file mode 100644 index 0000000..0ef9e89 --- /dev/null +++ b/tools/site_artwork.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Add shared favicons to the staged Pages tree, leaving retained releases unchanged.""" + +import argparse +import os +from pathlib import Path +import re +import shutil + + +ICONS = ("favicon.ico", "favicon.png", "apple-touch-icon.png") +MARKER = '' + + +def decorate(assets: Path, output: Path) -> None: + for name in ICONS: + shutil.copyfile(assets / name, output / name) + for path in output.rglob("*.html"): + text = path.read_text(encoding="utf-8") + if MARKER in text: + continue + opening = re.search(r"]*>", text, re.IGNORECASE) + if opening is None: + opening = re.search(r"]*>", text, re.IGNORECASE) + if opening is None: + continue + prefix = Path(os.path.relpath(output, path.parent)).as_posix() + links = (f'\n{MARKER}\n' + f'\n' + f'\n' + f'\n') + path.write_text(text[:opening.end()] + links + text[opening.end():], encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("assets", type=Path) + parser.add_argument("output", type=Path) + args = parser.parse_args() + decorate(args.assets, args.output) + + +if __name__ == "__main__": + main() diff --git a/tools/site_artwork_test.py b/tools/site_artwork_test.py new file mode 100644 index 0000000..fc2da3c --- /dev/null +++ b/tools/site_artwork_test.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Verify favicons across nested release, reference, and coverage pages.""" + +from pathlib import Path +import tempfile +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).parent)) +import site_artwork + + +class SiteArtworkTest(unittest.TestCase): + def test_publisher_decorates_only_the_complete_deployment_copy(self): + root = Path(__file__).resolve().parent.parent + for name in ("pages",): + workflow = (root / f".github/workflows/{name}.yml").read_text() + stage = workflow.index("rsync --archive --exclude='.git' site/ public/") + decorate = workflow.index("python3 source/tools/site_artwork.py source/docs/assets public") + upload = workflow.index("uses: actions/upload-pages-artifact@") + self.assertLess(stage, decorate) + self.assertLess(decorate, upload) + + def test_nested_pages_and_idempotence(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + assets = Path(__file__).resolve().parent.parent / "docs/assets" + documents = { + "index.html": 'Home', + "site/tag/1.2.5/README.html": 'HelpHelp', + "coverage/pr/835/index.html": 'CoverageReport', + "fragment.html": '

A fragment

', + } + for name, text in documents.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + site_artwork.decorate(assets, root) + for name, original in documents.items(): + path = root / name + decorated = path.read_text() + if name == "fragment.html": + self.assertEqual(decorated, original) + continue + prefix = "." if name == "index.html" else "../../.." + for icon in site_artwork.ICONS: + self.assertIn(f'href="{prefix}/{icon}"', decorated) + self.assertEqual((path.parent / prefix / icon).read_bytes(), + (assets / icon).read_bytes()) + self.assertIn('sizes="32x32"', decorated) + self.assertIn('sizes="180x180"', decorated) + self.assertEqual(decorated.count(site_artwork.MARKER), 1) + before = {name: (root / name).read_bytes() for name in documents} + site_artwork.decorate(assets, root) + self.assertEqual(before, {name: (root / name).read_bytes() for name in documents}) + + +if __name__ == "__main__": + unittest.main()