diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6840d67..dff60ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,15 @@ jobs: - 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 "*_test.py" + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Test retained coverage in Chromium + working-directory: tools/coverage_web + run: | + npm ci + npx playwright install --with-deps chromium + npm test - name: Verify configured documentation and generated links env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index a4bf50a..0f15d9f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -95,8 +95,10 @@ jobs: working-directory: source run: | set -euo pipefail - git fetch origin - if git show-ref --verify --quiet refs/remotes/origin/coverage-pages; then + retained_ref="$(git ls-remote --heads origin refs/heads/coverage-pages)" + if [[ -n "${retained_ref}" ]]; then + git fetch --no-tags --depth=1 origin \ + +refs/heads/coverage-pages:refs/remotes/origin/coverage-pages git worktree add ../site -B coverage-pages origin/coverage-pages else git worktree add --detach ../site @@ -149,6 +151,7 @@ jobs: RELEASE_TAG: ${{ steps.release.outputs.tag }} run: | set -euo pipefail + python3 source/tools/compact_site.py site git -C site config user.name 'github-actions[bot]' git -C site config user.email '41898282+github-actions[bot]@users.noreply.github.com' git -C site add --all @@ -159,6 +162,7 @@ jobs: mkdir -p public rsync --archive --exclude='.git' site/ public/ python3 source/tools/site_artwork.py source/docs/assets public + python3 source/tools/compact_site.py public - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v5 with: @@ -191,8 +195,10 @@ jobs: working-directory: source run: | set -euo pipefail - git fetch origin - if git show-ref --verify --quiet refs/remotes/origin/coverage-pages; then + retained_ref="$(git ls-remote --heads origin refs/heads/coverage-pages)" + if [[ -n "${retained_ref}" ]]; then + git fetch --no-tags --depth=1 origin \ + +refs/heads/coverage-pages:refs/remotes/origin/coverage-pages git worktree add ../site -B coverage-pages origin/coverage-pages else git worktree add --detach ../site @@ -300,9 +306,10 @@ jobs: gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/pulls?state=all&per_page=100" > pulls.json python3 source/tools/coverage_index.py history site/coverage pulls.json python3 source/tools/coverage_index.py regenerate site/coverage + python3 source/tools/compact_site.py site git -C site config user.name 'github-actions[bot]' git -C site config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git -C site add coverage + git -C site add --all if ! git -C site diff --cached --quiet; then git -C site commit -m "coverage: publish reports and refresh PR metadata" git -C site push origin HEAD:coverage-pages @@ -310,6 +317,7 @@ jobs: mkdir -p public rsync --archive --exclude='.git' site/ public/ python3 source/tools/site_artwork.py source/docs/assets public + python3 source/tools/compact_site.py public - uses: actions/configure-pages@v6 - uses: actions/upload-pages-artifact@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a4561..e6a53f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ versions follow [SemVer](https://semver.org/spec/v2.0.0.html). ### Changed +- Preserve coverage aggregates indefinitely, compress report history, expire source HTML after + seven days, and measure complete publication size against a 250 MB advisory budget. + - Allow serialized coverage backfill from a completed CI run without rerunning tests. - Publish successful coverage despite unrelated CI failures, and put post-merge before pre-merge diff --git a/docs/infrastructure.md b/docs/infrastructure.md index 27a2f66..618b986 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -38,8 +38,9 @@ The overview uses the same report table columns as the C++ repositories: Report, Completed, Commit, Workflow, Lines, Branches, and Functions. Coderef's Python generator computes rates from summed LCOV counters, links each immutable report and its LCOV/metadata downloads, and displays completion times in UTC. Zero or unavailable metric totals display `n/a`, including -branch coverage when Rust instrumentation produces no branch measurements. Existing archives -are read without modification. The overview shows one result per PR, ordered by merge time, +branch coverage when Rust instrumentation produces no branch measurements. Aggregate measurements +and original run metadata are preserved through storage compaction. The overview shows one result +per PR, ordered by merge time, then open PRs. Pre-merge coverage is replaced only when a main report tests that PR's exact merge commit; unrelated main runs are omitted. Aggregation PR coverage is never attributed to its constituent PRs. PRs without pre-merge reports can still show post-merge coverage. @@ -47,7 +48,8 @@ its constituent PRs. PRs without pre-merge reports can still show post-merge cov selects the newest run within each phase, with numeric attempt ordering for retries. Late pre-merge reports cannot displace post-merge results. Closed unmerged PRs are omitted from both views and reappear when reopened. All immutable run URLs remain available, including omitted -runs and retries. A minimal `pull-requests.json` registry records merge SHAs for exact attribution. +runs and retries; expired source views link to the permanent aggregate summary. A minimal +`pull-requests.json` registry records merge SHAs for exact attribution. The coverage figures describe Rust, not the Python or TypeScript scripts. PR closure (including merge) and reopening trigger a metadata-only refresh using trusted `main` publisher code. This updates overview ordering and visibility without downloading artifacts or @@ -101,3 +103,10 @@ Run `python3 -m unittest discover -s tools -p '*_test.py'`, `pre-commit run --al existing Cargo and extension checks appropriate to a change. Publishing orchestration is tested with mocked GitHub commands, including corrupt/missing assets, existing drafts, immutable retries, and failed remote verification. Live publication remains a tag-triggered external integration. + +## Published-site storage + +[Storage and retention](site-storage.md) describes permanent per-attempt summaries, compressed +LLVM HTML, seven-day detail retention, original LCOV gzip downloads, and the 250 MB advisory. +Both publication paths compact before committing and after deployment-only artwork, and fetch +only a shallow publication snapshot. Neither Git history nor measured run identities are rewritten. diff --git a/docs/site-storage.md b/docs/site-storage.md new file mode 100644 index 0000000..8f0bd49 --- /dev/null +++ b/docs/site-storage.md @@ -0,0 +1,77 @@ +# Published site storage + +Coverage, release documentation, and shared assets use one complete-site storage budget. +`storage-report.json` records byte totals and file counts by top-level directory. Publication warns +at **250 MB** so growth can be reviewed early. The final payload guard is **9 GB**, allowing some TAR +packaging headroom below the uploader's absolute limit. GitHub's documented supported published-site +limit remains [1 GB](https://docs.github.com/en/pages/getting-started-with-github-pages/github-pages-limits); +the emergency guard is not a supported operating budget. + +This adapts [mbo PR #549](https://github.com/mboworks/mbo/pull/549), based on +[xff PR #955](https://github.com/mboworks/xff/pull/955), merged as +`91064c283fa90445db48ec598e4e052fb9d37261`. + +## Retention and representation + +- Coverage summaries, original run metadata, + and pre/post-merge attribution are retained indefinitely. No report-count limit evicts history. +- Detailed source, function, and branch pages expire **seven days** after the report's original + completion timestamp. Replays do not renew that timestamp. Undated legacy reports have no + recent-detail window. Their summaries and metadata remain available. +- Older LCOV-only directories without structured summaries or metadata are preserved intact. + They cannot enter the retention scheme until their aggregate data and identity can be recovered; + the migration does not invent either or discard the only historical report. Their bytes still + count toward the site budget. +- Recent details use compressed manifests and shared source-page bases. Changed pages store edits + against one base; there are no dependency chains through earlier reports. LLVM named source + anchors are preserved for direct links and scrolling. Collection removes only detail archives + and bases without a retained reference. +- Aggregate counters are captured from LCOV into a permanent per-attempt summary before any + source HTML expires. Original LCOV is retained byte-for-byte in `lcov.info.gz`; history reads + the permanent summary. Summary JSON is gzip-compressed without changing its original bytes. + Report landing pages keep their URLs and load compressed HTML through a shared loader. Old LLVM + HTML source URLs route through + the site's 404 document to the viewer, preserving report identity and line anchors. Expired + details explain the policy and link their aggregate report. +- The browser uses built-in `DecompressionStream`; packed views require JavaScript. Raw + `coverage-summary.json.gz` and uncompressed `metadata.json` remain downloadable without it. +- Release documentation, coverage thresholds, and measurement identities are not + changed by compaction. + +## Publication + +Both coverage and release-site publishers compact the retained `coverage-pages` tree **before +committing**. Each then stages the complete site, installs deployment-only artwork, compacts again, +and measures the final payload before upload. Every publisher uses the existing serialized queue. +The existing attempt-specific `Rust coverage` gate remains authoritative. + +Publication checkouts fetch only the current retained snapshot. Both release and coverage +restoration explicitly fetch only the shallow publication branch. Compaction does not rewrite +Git history: earlier committed source details remain in old commits. Deployed bytes, Git object storage, Actions artifacts, and build +caches are separate budgets. + +## Validation and future growth + +Python tests cover summary bytes, metadata, expiry boundaries, shared-base collection, missing +bases, replay behavior, repeated compaction, complete-site size accounting, and both publishers. +Chromium tests exercise packed summary tables, compressed JSON, source navigation, CSS, line +anchors, old deep links, expired reports, and the download fallback without JavaScript: + +```sh +python3 -m unittest discover -s tools -p '*_test.py' +cd tools/coverage_web +npm ci +npx playwright install chromium +npm test +``` + +The same browser suite runs in the required release-site CI job. To use an installed Chromium +browser locally, set `CHROME_PATH` to its executable when running `npm test`. + +At 250 MB, inspect the section totals and consider shared data-driven summary tables and a more +compact source representation. Preserve aggregate measurements and provenance. Moving historical +data to other storage or rewriting publication Git history requires a separate decision. + +LLVM-generated JavaScript navigation controls are removed from packed source views; the viewer +serves static source, coverage tables, CSS, page links, and line anchors in a sandboxed frame. +Original report scripts are never executed. Runtime site code has no third-party dependencies. diff --git a/release-site.json b/release-site.json index 6650c60..38d0185 100644 --- a/release-site.json +++ b/release-site.json @@ -15,7 +15,8 @@ "docs/release-website.md": "docs/release-website.html", "GIT_RULES.md": "GIT_RULES.html", "STYLE_SH.md": "STYLE_SH.html", - "docs/infrastructure.md": "docs/infrastructure.html" + "docs/infrastructure.md": "docs/infrastructure.html", + "docs/site-storage.md": "docs/site-storage.html" }, "links": [ { diff --git a/tools/compact_site.py b/tools/compact_site.py new file mode 100644 index 0000000..3cdf164 --- /dev/null +++ b/tools/compact_site.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Compact the retained publication tree before committing or deploying it.""" + +import argparse +import datetime +import json +from pathlib import Path +import shutil + +import coverage_storage +import site_storage + + +def sizes(root): + result = {} + for path in root.rglob('*'): + relative = path.relative_to(root) + if '.git' in relative.parts or relative.as_posix() == 'storage-report.json' or not path.is_file(): + continue + section = relative.parts[0] if len(relative.parts) > 1 else 'root' + stats = result.setdefault(section, {'files': 0, 'bytes': 0}) + stats['files'] += 1 + stats['bytes'] += path.stat().st_size + return result + + +def compact(root, now): + site_storage.prepare_summaries(root / 'coverage') + coverage_storage.compact(root / 'coverage', now) + site_storage.compact_summaries(root / 'coverage') + source = Path(__file__).parent + for name in ('site_storage', 'site_page'): + shutil.copyfile(source / (name + '.js'), root / (name.replace('_', '-') + '.js')) + if (root / 'coverage').is_dir(): + shutil.copyfile(source / 'coverage_viewer.js', root / 'coverage/viewer.js') + (root / '404.html').write_text(coverage_storage.legacy_router()) + result = sizes(root) + total = sum(entry['bytes'] for entry in result.values()) + report = {'schema': 1, 'total_bytes': total, 'sections': result, + 'coverage_detail_days': 7, 'review_bytes': 250_000_000} + (root / 'storage-report.json').write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2), flush=True) + if total >= report['review_bytes']: + print('::warning::Published site reached 250 MB; review storage-report.json and docs/site-storage.md.') + if total > 9_000_000_000: + raise ValueError('Published site exceeds the Pages deployment ceiling') + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', type=Path) + args = parser.parse_args() + compact(args.root, datetime.datetime.now(datetime.timezone.utc)) + + +if __name__ == '__main__': + main() diff --git a/tools/compact_site_test.py b/tools/compact_site_test.py new file mode 100644 index 0000000..57cc5f7 --- /dev/null +++ b/tools/compact_site_test.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Both publishers compact history and measure the complete deployment.""" + +import contextlib +import datetime +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import compact_site + + +class CompactSiteTest(unittest.TestCase): + def test_counts_entire_payload_without_changing_release_or_benchmark_data(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + retained = {'site/tag/1.2.3/index.html': b'release documentation', + 'benchmarks/report.json': b'{"observations":[1,2,3]}', + 'coverage/pr/1/index.html': b'legacy aggregate report', + 'coverage/pr/1/source.cc.gcov.html': b'legacy source without metadata'} + for name, data in {**retained, '.git/objects/private': b'not deployed'}.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + with contextlib.redirect_stdout(io.StringIO()): + report = compact_site.compact(root, datetime.datetime.now(datetime.timezone.utc)) + self.assertEqual(report['sections']['site'], {'files': 1, 'bytes': 21}) + self.assertNotIn('.git', report['sections']) + self.assertEqual(report['total_bytes'], sum(p.stat().st_size for p in root.rglob('*') + if p.is_file() and '.git' not in p.parts and p.name != 'storage-report.json')) + self.assertEqual(json.loads((root / 'storage-report.json').read_text()), report) + for name, data in retained.items(): + self.assertEqual((root / name).read_bytes(), data) + self.assertFalse((root / 'coverage/pr/1/coverage-meta.json').exists()) + + def test_advisory_budget_and_packaging_ceiling(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + now = datetime.datetime.now(datetime.timezone.utc) + for size, warns, fails in ((249_999_999, False, False), (250_000_000, True, False), + (9_000_000_000, True, False), (9_000_000_001, True, True)): + with self.subTest(size=size), mock.patch.object( + compact_site, 'sizes', return_value={'site': {'files': 1, 'bytes': size}}): + output = io.StringIO() + with contextlib.redirect_stdout(output): + if fails: + with self.assertRaisesRegex(ValueError, 'deployment ceiling'): + compact_site.compact(root, now) + else: + compact_site.compact(root, now) + self.assertEqual('::warning::' in output.getvalue(), warns) + + def test_publishers_compact_before_commit_and_after_deployment_artwork(self): + repository = Path(__file__).resolve().parent.parent + workflow = (repository / '.github/workflows/pages.yml').read_text() + for source in workflow.split('\n coverage:\n'): + self.assertLess(source.index('compact_site.py site'), source.index('git -C site commit')) + self.assertLess(source.index('site_artwork.py'), source.index('compact_site.py public')) + self.assertLess(source.index('compact_site.py public'), source.index('uses: actions/upload-pages-artifact@')) + self.assertIn('git -C site add --all', source) + self.assertIn('git fetch --no-tags --depth=1 origin', source) + self.assertNotIn('git fetch origin\n', source) + + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/coverage_index.py b/tools/coverage_index.py index 74e6844..f6c9a92 100644 --- a/tools/coverage_index.py +++ b/tools/coverage_index.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import gzip import json from html import escape import shutil @@ -19,8 +20,11 @@ def _coverage(path: Path) -> dict: """Sum llvm-cov's per-file LCOV counters, without averaging percentages.""" totals = {field: 0 for fields in _METRICS.values() for field in fields} - if path.exists(): - for line in path.read_text(encoding="utf-8").splitlines(): + compressed = path.with_suffix(path.suffix + ".gz") + if path.exists() or compressed.exists(): + text = (path.read_text(encoding="utf-8") if path.exists() + else gzip.decompress(compressed.read_bytes()).decode("utf-8")) + for line in text.splitlines(): field, _, value = line.partition(":") if field in totals: totals[field] += int(value) @@ -31,6 +35,40 @@ def _coverage(path: Path) -> dict: } +def report_coverage(directory: Path) -> dict: + """Prefer permanent aggregate measurements after source-detail expiration.""" + summary = directory / "coverage-summary.json.gz" + if summary.exists(): + return json.loads(gzip.decompress(summary.read_bytes()))["coverage"] + summary = directory / "coverage-summary.json" + if summary.exists(): + return _read(summary)["coverage"] + return _coverage(directory / "lcov.info") + + +def render_summary(metadata: dict, metrics: dict) -> str: + """Render a permanent aggregate landing page for each immutable attempt.""" + rows = [] + for name in _METRICS: + value = metrics[name] + rate = "n/a" if value["percent"] is None else f'{value["percent"]:.2f}%' + rows.append(f'
Commit: {sha}; completed: {completed}
' + '| Metric | Covered | ' + 'Total | Rate |
|---|
Browse detailed source coverage | ' + 'Summary JSON | ' + 'LCOV | Metadata
') + + def _read(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) @@ -77,7 +115,7 @@ def _reports(root: Path) -> list[dict]: for metadata_path in sorted((root / "runs").glob("*/*/metadata.json")): metadata = _read(metadata_path) metadata["path"] = metadata_path.parent.relative_to(root).as_posix() - metadata["coverage"] = _coverage(metadata_path.parent / "lcov.info") + metadata["coverage"] = report_coverage(metadata_path.parent) reports.append(metadata) return reports @@ -192,14 +230,15 @@ def _render(root: Path, reports: list[dict], title: str, description: str, navig attempt = report.get("run_attempt", 1) if int(attempt) > 1: workflow += f" (attempt {escape(str(attempt))})" + lcov = "lcov.info.gz" if (root / path / "lcov.info.gz").exists() else "lcov.info" details = ( _link(f"{path}/html/index.html", label), - _link(f"{path}/lcov.info", "LCOV") + " · " + _link(f"{path}/metadata.json", "Metadata"), + _link(f"{path}/{lcov}", "LCOV") + " · " + _link(f"{path}/metadata.json", "Metadata"), source, timestamp, commit, workflow, ) cells = [f"Report summary | Coverage history
' + 'Loading coverage details...
' + '' + '\n') + + +def legacy_router(): + return r'''The requested page is unavailable.
+ +''' diff --git a/tools/coverage_storage_test.py b/tools/coverage_storage_test.py new file mode 100644 index 0000000..17af73f --- /dev/null +++ b/tools/coverage_storage_test.py @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Coverage retention expires source details, never aggregate measurements.""" + +import datetime +import gzip +import json +from pathlib import Path +import tempfile +import unittest +import shutil + +import coverage_storage as storage + + +class CoverageStorageTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / 'coverage' + self.now = datetime.datetime(2026, 10, 3, tzinfo=datetime.timezone.utc) + + def report(self, target, days): + path = self.root / target + path.joinpath('html').mkdir(parents=True) + path.joinpath('metadata.json').write_text(json.dumps({ + 'completed_at': (self.now - datetime.timedelta(days=days)).isoformat(), 'head_sha': 'actual-commit'})) + path.joinpath('coverage-summary.json').write_text('{"aggregate":42,"patch":17}\n') + path.joinpath('index.html').write_text('original aggregate table') + path.joinpath('html/index.html').write_text('Source\nfile') + path.joinpath('html/file.cc.gcov.html').write_text('Source\nreturn 42;') + return path + + def test_seven_day_boundary_alias_deduplication_and_expiration_preserve_all_summaries(self): + recent = self.report('runs/12/1', 7) + alias = self.report('runs/13/1', 7) + expired = self.report('runs/11/1', 7.01) + summaries = {p: p.read_bytes() for p in self.root.rglob('*') if p.is_file() and 'html' not in p.parts} + storage.compact(self.root, self.now) + first = json.loads(recent.joinpath('details.json').read_text()) + self.assertEqual(first, json.loads(alias.joinpath('details.json').read_text())) + archive = self.root / first['archive'] + self.assertTrue(archive.exists()) + self.assertNotIn('archive', json.loads(expired.joinpath('details.json').read_text())) + self.assertEqual(list(recent.joinpath('html').iterdir()), [recent / 'html/index.html']) + for path, data in summaries.items(): + self.assertEqual(path.read_bytes(), data) + storage.compact(self.root, self.now + datetime.timedelta(days=1)) + self.assertFalse(archive.exists()) + self.assertEqual(list(self.root.glob('assets/reports/bases/*.json.gz')), []) + for path, data in summaries.items(): + self.assertEqual(path.read_bytes(), data) + + def test_shared_base_and_edits_reconstruct_every_source_byte(self): + assets = self.root / 'assets/reports' + bases = {} + initial = {'file.cc.gcov.html': ['text/html', 'line 1\nline 2\nline 3\n']} + original = storage.pack_report(initial, assets, bases) + changed = {'file.cc.gcov.html': ['text/html', 'first\nline 2\nline 3\nlast without newline']} + packed = storage.pack_report(changed, assets, bases) + record = packed['files']['file.cc.gcov.html'][1] + self.assertEqual(record['base'], original['files']['file.cc.gcov.html'][1]['base']) + base = json.loads(gzip.decompress((assets / 'bases' / (record['base'] + '.json.gz')).read_bytes())) + lines = base.splitlines(keepends=True) + actual = ''.join(edit if isinstance(edit, str) else ''.join(lines[edit[0]:edit[1]]) for edit in record['edits']) + self.assertEqual(actual, changed['file.cc.gcov.html'][1]) + + def test_alias_keeps_shared_bases_until_the_last_recent_reference_expires(self): + expired_first = self.report('runs/11/1', 7) + recent = self.report('runs/13/1', 1) + storage.compact(self.root, self.now) + original = json.loads(recent.joinpath('details.json').read_text()) + storage.compact(self.root, self.now + datetime.timedelta(days=1)) + self.assertNotIn('archive', json.loads(expired_first.joinpath('details.json').read_text())) + self.assertEqual(json.loads(recent.joinpath('details.json').read_text()), original) + self.assertTrue((self.root / original['archive']).is_file()) + self.assertTrue(list(self.root.glob('assets/reports/bases/*.json.gz'))) + + def test_undated_legacy_and_old_replayed_reports_do_not_renew_details(self): + report = self.report('runs/11/1', 8) + legacy = self.report('runs/14/1', 1) + legacy.joinpath('metadata.json').write_text('{}') + storage.compact(self.root, self.now) + # Replaying the original artifact restores its HTML but keeps its original timestamp. + report.joinpath('html/file.cc.gcov.html').write_text('old source uploaded again') + storage.compact(self.root, self.now) + for path in (report, legacy): + self.assertNotIn('archive', json.loads(path.joinpath('details.json').read_text())) + self.assertTrue(path.joinpath('coverage-summary.json').is_file()) + self.assertFalse(path.joinpath('html/file.cc.gcov.html').exists()) + + def test_missing_base_fails_without_deleting_remaining_report_sources(self): + report = self.report('runs/12/1', 1) + storage.compact(self.root, self.now) + shutil.rmtree(self.root / 'assets/reports/bases') + report.joinpath('html/file.cc.gcov.html').write_text('recoverable source') + with self.assertRaisesRegex(ValueError, 'missing coverage base'): + storage.compact(self.root, self.now) + self.assertEqual(report.joinpath('html/file.cc.gcov.html').read_text(), 'recoverable source') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/coverage_viewer.js b/tools/coverage_viewer.js new file mode 100644 index 0000000..607ab6f --- /dev/null +++ b/tools/coverage_viewer.js @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +// SPDX-License-Identifier: Apache-2.0 +(async () => { + const status = document.getElementById("status"); + try { + const query = new URLSearchParams(location.search); + const report = query.get("report"); + if ( + !/^(main|pr\/\d+|tag\/[^/.]+\.[^/.]+\.[^/.]+|runs\/\d+\/\d+)$/.test( + report, + ) + ) + throw new Error("Invalid report path"); + document.getElementById("summary").href = report + "/"; + const response = await fetch(report + "/details.json"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const details = await response.json(); + if (!details.archive) { + status.textContent = details.reason; + return; + } + if (!/^assets\/reports\/[a-f0-9]{64}\.json\.gz$/.test(details.archive)) + throw new Error("Invalid archive path"); + const archive = await window.MboSiteStorage.gzip(details.archive); + const cache = new Map(); + async function content(entry) { + const record = entry[1]; + if (!/^[a-f0-9]{64}$/.test(record.base)) + throw new Error("Invalid source base"); + if (!cache.has(record.base)) + cache.set( + record.base, + window.MboSiteStorage.gzip( + `assets/reports/bases/${record.base}.json.gz`, + ), + ); + const base = await cache.get(record.base); + const lines = base.match(/[^\n]*\n|[^\n]+$/g) || []; + let text = record.edits + ? record.edits + .map((edit) => + typeof edit === "string" ? edit : lines.slice(...edit).join(""), + ) + .join("") + : base; + if (record.numbered) { + let number = 0; + text = text.replace( + /@LINE@<\/span>/g, + () => + `${String(number).padStart(8)}`, + ); + } + return text; + } + const file = query.get("file") || "index.html"; + const entry = archive.files[file]; + if (!entry || entry[0] !== "text/html") + throw new Error("Source page not available in this report"); + const page = new DOMParser().parseFromString( + await content(entry), + "text/html", + ); + page.querySelectorAll("script,base,.control").forEach((element) => element.remove()); + // LLVM uses named anchors; expose them as IDs for fragment scrolling. + page.querySelectorAll("a[name]").forEach((element) => { + if (!element.id) element.id = element.getAttribute("name"); + }); + const original = new URL(file, "https://coverage.invalid/"); + for (const element of page.querySelectorAll("[src],[href]")) { + const attribute = element.hasAttribute("src") ? "src" : "href"; + const url = new URL(element.getAttribute(attribute), original); + const path = decodeURIComponent(url.pathname.slice(1)); + const resource = archive.files[path]; + if (url.origin === original.origin && resource) { + if (resource[0] === "text/html") { + const target = new URL("view.html", location.href); + target.search = new URLSearchParams({ report, file: path }); + target.hash = url.hash; + element.href = target; + element.target = "_top"; + } else { + const text = await content(resource); + const bytes = resource[0].startsWith("image/") + ? Uint8Array.from(atob(text), (character) => + character.charCodeAt(0), + ) + : text; + element.setAttribute( + attribute, + URL.createObjectURL(new Blob([bytes], { type: resource[0] })), + ); + } + } else if (url.origin === original.origin) { + // Navigation out of the archive goes to the retained summary, never a missing source tree. + if (attribute === "href") { + element.href = new URL(report + "/", location.href); + element.target = "_top"; + } else element.removeAttribute(attribute); + } else if (!["https:", "http:"].includes(url.protocol)) { + element.removeAttribute(attribute); + } else if (attribute === "href") element.target = "_top"; + } + const frame = document.querySelector("iframe"); + frame.addEventListener( + "load", + () => { + const anchor = decodeURIComponent(location.hash.slice(1)); + if (anchor) + frame.contentDocument.getElementById(anchor)?.scrollIntoView(); + }, + { once: true }, + ); + frame.srcdoc = page.documentElement.outerHTML; + frame.hidden = false; + status.hidden = true; + } catch (error) { + status.textContent = "Coverage details unavailable: " + error.message; + } +})(); diff --git a/tools/coverage_web/.gitignore b/tools/coverage_web/.gitignore new file mode 100644 index 0000000..2ccbe46 --- /dev/null +++ b/tools/coverage_web/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/tools/coverage_web/coverage.test.mjs b/tools/coverage_web/coverage.test.mjs new file mode 100644 index 0000000..9684be2 --- /dev/null +++ b/tools/coverage_web/coverage.test.mjs @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +// SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; +import { chromium } from "playwright"; + +test("retained coverage: packed summaries, source navigation, old URLs and expiry", async () => { + const root = mkdtempSync(join(tmpdir(), "coderef-coverage-web-")); + const options = { headless: true }; + if (process.env.CHROME_PATH) options.executablePath = process.env.CHROME_PATH; + let server, browser; + try { + execFileSync(process.env.PYTHON || "python3", [ + fileURLToPath(new URL("fixture.py", import.meta.url)), + root, + ]); + server = createServer((request, response) => { + const path = new URL(request.url, "http://localhost").pathname; + try { + assert.ok(path.startsWith("/coderef/")); + const relative = decodeURIComponent(path.slice("/coderef/".length)); + const file = join( + root, + relative.endsWith("/") ? relative + "index.html" : relative, + ); + response.setHeader( + "Content-Type", + path.endsWith(".js") + ? "text/javascript" + : path.endsWith(".gz") + ? "application/octet-stream" + : path.endsWith(".json") + ? "application/json" + : "text/html", + ); + response.end(readFileSync(file)); + } catch { + // GitHub Pages serves the site's 404 document at the original deep URL. + response.writeHead(404, { "Content-Type": "text/html" }); + response.end(readFileSync(join(root, "404.html"))); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const base = `http://127.0.0.1:${server.address().port}/coderef`; + browser = await chromium.launch(options); + const page = await browser.newPage(); + page.setDefaultTimeout(10000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(base + "/coverage/runs/2/1/"); + await page.waitForSelector("table.coverageTable"); + const raw = await page.request.get( + base + "/coverage/runs/2/1/coverage-summary.json.gz", + ); + assert.equal(raw.ok(), true); + const data = JSON.parse(gunzipSync(await raw.body())); + assert.deepEqual(data.coverage.lines, { + covered: 9, + total: 10, + percent: 90, + }); + await page + .getByText("Browse detailed source coverage", { exact: true }) + .click(); + await page + .frameLocator("iframe") + .getByText("Source", { exact: true }) + .click(); + await page.waitForURL(/file=coverage%2Fsrc%2Flib.rs.html#L100/); + const frame = page.frameLocator("iframe"); + assert.equal( + await frame.locator("#L100").textContent(), + "100", + ); + assert.equal( + await frame + .locator("body") + .evaluate((element) => getComputedStyle(element).color), + "rgb(1, 2, 3)", + ); + await page.waitForFunction( + () => document.querySelector("iframe").contentWindow.scrollY > 0, + ); + await frame.getByText("Source index", { exact: true }).click(); + await page + .frameLocator("iframe") + .getByText("Source", { exact: true }) + .waitFor(); + // Existing direct links retain the report identity and source-line fragment. + await page.goto(base + "/coverage/runs/3/1/html/coverage/src/lib.rs.html#L100"); + await page.waitForURL( + /view.html\?report=runs%2F3%2F1&file=coverage%2Fsrc%2Flib.rs.html#L100/, + ); + await page.frameLocator("iframe").locator("#L100").waitFor(); + await page.goto(base + "/coverage/runs/1/1/html/coverage/src/lib.rs.html#L100"); + await page.waitForFunction(() => + document.getElementById("status")?.textContent.includes("seven days"), + ); + assert.equal(await page.locator("iframe").isVisible(), false); + await page.locator("#summary").click(); + await page.waitForSelector("table.coverageTable"); + assert.deepEqual(errors, []); + const noScript = await browser.newPage({ javaScriptEnabled: false }); + await noScript.goto(base + "/coverage/runs/1/1/"); + assert.equal( + await noScript.locator("noscript a").getAttribute("href"), + "coverage-summary.json.gz", + ); + } finally { + if (browser) await browser.close(); + if (server) await new Promise((resolve) => server.close(resolve)); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tools/coverage_web/fixture.py b/tools/coverage_web/fixture.py new file mode 100644 index 0000000..6c2a2c3 --- /dev/null +++ b/tools/coverage_web/fixture.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Retained Rust coverage with LLVM-style named source anchors.""" + +import datetime +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from compact_site import compact +from coverage_index import history, regenerate + + +def build(root): + now = datetime.datetime.now(datetime.timezone.utc) + for run, age in ((2, 0), (3, 0), (1, 8)): + report = root / f'coverage/runs/{run}/1' + (report / 'html/coverage/src').mkdir(parents=True) + (report / 'metadata.json').write_text(json.dumps({ + 'run_id': run, 'run_attempt': 1, 'target': 'pr/12', + 'completed_at': (now - datetime.timedelta(days=age)).isoformat(), 'head_sha': 'actual-commit'})) + (report / 'lcov.info').write_text('SF:src/lib.rs\nLF:10\nLH:9\nFNF:10\nFNH:9\nBRF:10\nBRH:9\nend_of_record\n') + (report / 'html/index.html').write_text( + 'Source') + lines = ''.join(f'{line}return {line};Loading report...
' + f'' + f'\n') + + +def prepare_summaries(root): + """Capture aggregates before expiring any original LLVM HTML source pages.""" + for metadata in sorted(root.glob('runs/*/*/metadata.json')): + report = metadata.parent + summary = report / 'coverage-summary.json' + if not summary.exists() and not summary.with_suffix('.json.gz').exists(): + if not any((report / name).is_file() for name in ('lcov.info', 'lcov.info.gz')): + raise ValueError(f'missing aggregate measurements: {report}') + summary.write_text(json.dumps({'schema': 1, 'coverage': report_coverage(report)}) + '\n') + if not (report / 'index.html').exists(): + (report / 'index.html').write_text(render_summary(json.loads(metadata.read_text()), + report_coverage(report))) + + +def compact_summaries(root): + for name in ('coverage-summary.json', 'lcov.info'): + for path in root.rglob(name): + compact_json(path) + for page in root.rglob('*.html'): + if 'html' in page.relative_to(root).parts[:-1]: + continue + text = page.read_text().replace('lcov.info"', 'lcov.info.gz"') + page.write_text(text) + if page.name == 'index.html' and page.with_name('metadata.json').exists(): + compact_page(page, root.parent, 'coverage-summary.json.gz') diff --git a/tools/site_storage_test.py b/tools/site_storage_test.py new file mode 100644 index 0000000..e6e4a7d --- /dev/null +++ b/tools/site_storage_test.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: Copyright (c) M. Boerger, the MBO Works authors +# SPDX-License-Identifier: Apache-2.0 +"""Rust aggregate history survives source expiry and original LCOV compaction.""" + +import contextlib +import datetime +import gzip +import io +import json +from pathlib import Path +import tempfile +import unittest + +import compact_site +import coverage_index +import site_artwork + + +class SiteStorageTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.coverage = self.root / 'coverage' + self.now = datetime.datetime(2026, 10, 4, tzinfo=datetime.timezone.utc) + + def report(self, run, days): + report = self.coverage / f'runs/{run}/1' + (report / 'html').mkdir(parents=True) + (report / 'html/index.html').write_text('LLVM coverage') + (report / 'html/lib.rs.html').write_text('source') + metadata = {'run_id': run, 'run_attempt': 1, 'target': 'pr/12', 'head_sha': 'original', + 'completed_at': (self.now - datetime.timedelta(days=days)).isoformat()} + (report / 'metadata.json').write_text(json.dumps(metadata) + '\n') + (report / 'lcov.info').write_text('SF:a.rs\nLF:10\nLH:10\nFNF:2\nFNH:1\nend_of_record\n' + 'SF:b.rs\nLF:90\nLH:0\nFNF:8\nFNH:1\nend_of_record\n') + return report + + def compact(self): + with contextlib.redirect_stdout(io.StringIO()): + compact_site.compact(self.root, self.now) + + def test_expired_reports_keep_weighted_aggregates_original_metadata_and_lcov(self): + report = self.report(12, 8) + recent = self.report(13, 0) + metadata = (report / 'metadata.json').read_bytes() + lcov = (report / 'lcov.info').read_bytes() + self.compact() + self.assertEqual((report / 'metadata.json').read_bytes(), metadata) + self.assertEqual(gzip.decompress((report / 'lcov.info.gz').read_bytes()), lcov) + self.assertFalse((report / 'html/lib.rs.html').exists()) + self.assertIn('archive', json.loads((recent / 'details.json').read_text())) + metrics = coverage_index.report_coverage(report) + self.assertEqual(metrics['lines'], {'covered': 10, 'total': 100, 'percent': 10.0}) + self.assertEqual(metrics['functions']['percent'], 20.0) + self.assertIsNone(metrics['branches']['percent']) + # Rebuilding history uses the permanent summary even without the original artifact data. + (report / 'lcov.info.gz').unlink() + history = coverage_index.history(self.coverage) + self.assertEqual(next(item['coverage'] for item in history if item['run_id'] == 12), metrics) + coverage_index.regenerate(self.coverage) + self.assertIn('10.00%', (self.coverage / 'index.html').read_text()) + self.compact() + before = {p: p.read_bytes() for p in self.root.rglob('*') if p.is_file()} + self.compact() + self.assertEqual(before, {p: p.read_bytes() for p in self.root.rglob('*') if p.is_file()}) + + def test_missing_measurements_abort_before_source_expiration(self): + report = self.report(12, 8) + (report / 'lcov.info').unlink() + with self.assertRaisesRegex(ValueError, 'missing aggregate measurements'): + self.compact() + self.assertTrue((report / 'html/lib.rs.html').exists()) + + def test_gzip_lcov_fallback_preserves_legacy_counter_totals(self): + report = self.report(12, 8) + path = report / 'lcov.info' + expected = coverage_index._coverage(path) + path.with_suffix('.info.gz').write_bytes(gzip.compress(path.read_bytes(), mtime=0)) + path.unlink() + self.compact() + self.assertEqual(coverage_index.report_coverage(report), expected) + + def test_second_compaction_preserves_deployment_only_compressed_artwork(self): + report = self.report(12, 8) + self.compact() + original = (report / 'page.html.gz').read_bytes() + site_artwork.decorate(Path(__file__).resolve().parent.parent / 'docs/assets', self.root) + decorated = (report / 'page.html.gz').read_bytes() + self.assertNotEqual(original, decorated) + self.compact() + self.assertEqual((report / 'page.html.gz').read_bytes(), decorated) + self.assertIn(site_artwork.MARKER.encode(), gzip.decompress(decorated)) + + +if __name__ == '__main__': + unittest.main()