Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions .github/workflows/coverage-report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
name: Coverage report

on:
workflow_run:
workflows: [Build]
types: [completed]

permissions:
contents: read

jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
pull-requests: write
steps:
- name: Find the current pull request
id: pr
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
number=$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" --paginate |
jq -sr --arg sha "$HEAD_SHA" --arg repo "$HEAD_REPOSITORY" \
'[.[][] | select(.head.sha == $sha and .head.repo.full_name == $repo) | .number] | first // empty')
Comment on lines +27 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disambiguate PRs that share a head commit

🤖 This comment was written by Codex.

When two open PRs share the same commit—such as separate branches pointing at one SHA or one head targeting different accepted base branches—this query matches both and arbitrarily selects first. Both completed runs can consequently update the same sticky comment while the other PR receives none. Resolve from workflow_run.pull_requests when available and make the fork fallback reject or disambiguate multiple matches.

Useful? React with 👍 / 👎.

if [ -n "$number" ]; then
echo "number=$number" >> "$GITHUB_OUTPUT"
fi

- name: Download bounded coverage archives
if: steps.pr.outputs.number != ''
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
run: |
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts?per_page=100" > "$RUNNER_TEMP/artifacts.json"
mkdir -p "$RUNNER_TEMP/coverage"
for suite in backend-unit backend-integration frontend; do
id=$(jq -r --arg name "coverage-$suite" \
'[.artifacts[] | select(.name == $name and .expired == false and
.size_in_bytes > 0 and .size_in_bytes <= 10000000) | .id] | first // empty' \
"$RUNNER_TEMP/artifacts.json")
if [[ "$id" =~ ^[0-9]+$ ]]; then
archive="$RUNNER_TEMP/coverage/coverage-$suite.zip"
if ! gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip" > "$archive"; then
rm -f "$archive"
elif [ "$(stat -c %s "$archive")" -gt 10000000 ]; then
rm -f "$archive"
fi
fi
done

- name: Build coverage comment
if: steps.pr.outputs.number != ''
id: summary
env:
RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
python3 - "$RUNNER_TEMP/coverage" "$RUNNER_TEMP/coverage-comment.md" "$RUN_URL" <<'PY'
"""Build a PR coverage comment from reports produced by the Build workflow."""

import json
import math
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from zipfile import BadZipFile, ZipFile


def percentage(value: object, scale: int = 1) -> str:
number = float(value) * scale
if not math.isfinite(number) or not 0 <= number <= 100:
raise ValueError("Invalid coverage percentage")
return f"{number:.1f}%"


def read_member(archive: Path, member: str, allowed: set[str]) -> bytes:
with ZipFile(archive) as zipped:
infos = zipped.infolist()
if len(infos) > len(allowed) or any(
info.filename not in allowed or info.is_dir() for info in infos
):
raise ValueError("Unexpected coverage archive entries")
if sum(info.file_size for info in infos) > len(allowed) * 10_000_000:
raise ValueError("Coverage archive expands beyond its limit")
matches = [info for info in infos if info.filename == member]
if len(matches) != 1 or matches[0].file_size > 10_000_000:
raise ValueError("Missing, duplicate, or oversized coverage report")
with zipped.open(matches[0]) as source:
document = source.read(10_000_001)
if len(document) != matches[0].file_size:
raise ValueError("Coverage report exceeds its declared size")
return document


def cobertura_metrics(document: bytes) -> tuple[str, str]:
if b"<!DOCTYPE" in document.upper() or b"<!ENTITY" in document.upper():
raise ValueError("XML declarations are not allowed in coverage reports")
root = ET.fromstring(document)
if root.tag != "coverage":
raise ValueError("Expected a Cobertura coverage document")
return percentage(root.attrib["line-rate"], 100), percentage(
root.attrib["branch-rate"], 100
)


def vitest_metrics(document: bytes) -> tuple[str, str]:
if len(document) > 5_000_000:
raise ValueError("Vitest summary is too large")
summary = json.loads(document)
return percentage(summary["total"]["lines"]["pct"]), percentage(
summary["total"]["branches"]["pct"]
)


def main() -> None:
artifact_dir = Path(sys.argv[1])
output_path = Path(sys.argv[2])
run_url = sys.argv[3]
reports = (
(
"MenuApi.Tests",
"backend-unit",
"MenuApi.Tests.cobertura.xml",
cobertura_metrics,
),
(
"MenuDB.Tests",
"backend-unit",
"MenuDB.Tests.cobertura.xml",
cobertura_metrics,
),
(
"MenuApi.Integration.Tests",
"backend-integration",
"MenuApi.Integration.Tests.cobertura.xml",
cobertura_metrics,
),
(
"Frontend",
"frontend",
"coverage-summary.json",
vitest_metrics,
),
)
allowed = {
"backend-unit": {"MenuApi.Tests.cobertura.xml", "MenuDB.Tests.cobertura.xml"},
"backend-integration": {"MenuApi.Integration.Tests.cobertura.xml"},
"frontend": {"coverage-summary.json"},
}

rows = []
for label, suite, member, read_metrics in reports:
archive = artifact_dir / f"coverage-{suite}.zip"
if not archive.is_file():
continue
try:
document = read_member(archive, member, allowed[suite])
lines, branches = read_metrics(document)
except (BadZipFile, ET.ParseError, KeyError, OSError, RuntimeError,
TypeError, ValueError, NotImplementedError) as error:
print(f"Skipping invalid {label} coverage report: {error}", file=sys.stderr)
continue
rows.append(f"| {label} | {lines} | {branches} |")

content = (
["| Suite | Lines | Branches |", "| --- | ---: | ---: |", *rows]
if rows
else [
"No coverage report was produced for this run. "
"Tests may have been skipped or stopped early."
]
)
comment = "\n".join(
[
"<!-- menu-ci-coverage -->",
"### Test coverage",
"",
*content,
"",
f"[View the test run]({run_url})",
"",
]
)
output_path.write_text(comment, encoding="utf-8")


if __name__ == "__main__":
main()
PY
if [ -s "$RUNNER_TEMP/coverage-comment.md" ]; then
echo 'available=true' >> "$GITHUB_OUTPUT"
fi

- name: Comment coverage on pull request
if: steps.summary.outputs.available == 'true'
uses: mshick/add-pr-comment@ec328af66588ab8f77cdeb2c264f14aba45bbf59 # v3
continue-on-error: true
with:
issue: ${{ steps.pr.outputs.number }}
message-id: menu-ci-coverage
message-path: ${{ runner.temp }}/coverage-comment.md
85 changes: 80 additions & 5 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,43 @@ jobs:
run: dotnet build MenuApi.sln --configuration Release --no-restore
working-directory: ./backend

- name: Test with the dotnet CLI
- name: Test with Cobertura coverage
run: |
$coverageDir = Join-Path $env:GITHUB_WORKSPACE 'coverage'
New-Item -ItemType Directory -Path $coverageDir -Force | Out-Null
$failed = $false
Get-ChildItem -Recurse -Filter "*.Tests.csproj" | Where-Object { $_.FullName -notlike "*Integration*" } | ForEach-Object {
dotnet test $_.FullName --configuration Release --no-build
$report = Join-Path $coverageDir "$($_.BaseName).cobertura.xml"
dotnet test --project $_.FullName --configuration Release --no-build --coverage --coverage-output-format cobertura --coverage-output $report
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $report)) { $failed = $true }
}
if ($failed) { exit 1 }
shell: pwsh
working-directory: ./backend

- name: Generate backend unit coverage summary
if: ${{ always() && hashFiles('coverage/MenuApi.Tests.cobertura.xml') != '' && hashFiles('coverage/MenuDB.Tests.cobertura.xml') != '' }}
uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5
continue-on-error: true
with:
reports: coverage/*.cobertura.xml
targetdir: coverage-report/backend-unit
reporttypes: MarkdownSummaryGithub
title: Backend unit tests

- name: Add backend unit coverage to step summary
if: ${{ always() && hashFiles('coverage-report/backend-unit/SummaryGithub.md') != '' }}
run: cat coverage-report/backend-unit/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"

- name: Save backend unit coverage
if: ${{ always() && hashFiles('coverage/*.cobertura.xml') != '' }}
uses: actions/upload-artifact@v7
continue-on-error: true
with:
name: coverage-backend-unit
path: coverage/*.cobertura.xml
retention-days: 7

backend-integration-tests:
name: Backend integration tests
needs: changes
Expand Down Expand Up @@ -176,15 +205,41 @@ jobs:
dotnet dev-certs https --trust || true
echo "SSL_CERT_DIR=$HOME/.aspnet/dev-certs/trust:${SSL_CERT_DIR:-/usr/lib/ssl/certs}" >> "$GITHUB_ENV"

- name: Test with the dotnet CLI
run: dotnet test --project MenuApi.Integration.Tests/MenuApi.Integration.Tests.csproj --configuration Release --no-build
- name: Test with Cobertura coverage
run: |
mkdir -p "$GITHUB_WORKSPACE/coverage"
dotnet test --project MenuApi.Integration.Tests/MenuApi.Integration.Tests.csproj --configuration Release --no-build --coverage --coverage-output-format cobertura --coverage-output "$GITHUB_WORKSPACE/coverage/MenuApi.Integration.Tests.cobertura.xml"
test -s "$GITHUB_WORKSPACE/coverage/MenuApi.Integration.Tests.cobertura.xml"
working-directory: ./backend
env:
parameters__Auth0TestClientId: ${{ vars.AUTH0_CLIENT_ID }}
parameters__Auth0TestClientSecret: ${{ secrets.AUTH0_CLIENT_SECRET }}
parameters__Auth0Domain: ${{ vars.AUTH0_DOMAIN }}
parameters__Auth0Audience: http://localhost:65273

- name: Generate backend integration coverage summary
if: ${{ always() && hashFiles('coverage/MenuApi.Integration.Tests.cobertura.xml') != '' }}
uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5
continue-on-error: true
with:
reports: coverage/MenuApi.Integration.Tests.cobertura.xml
targetdir: coverage-report/backend-integration
reporttypes: MarkdownSummaryGithub
title: Backend integration tests

- name: Add backend integration coverage to step summary
if: ${{ always() && hashFiles('coverage-report/backend-integration/SummaryGithub.md') != '' }}
run: cat coverage-report/backend-integration/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"

- name: Save backend integration coverage
if: ${{ always() && hashFiles('coverage/MenuApi.Integration.Tests.cobertura.xml') != '' }}
uses: actions/upload-artifact@v7
continue-on-error: true
with:
name: coverage-backend-integration
path: coverage/MenuApi.Integration.Tests.cobertura.xml
retention-days: 7

frontend:
name: Frontend validation
runs-on: ubuntu-latest
Expand Down Expand Up @@ -273,9 +328,29 @@ jobs:
working-directory: ./ui/menu-website

- name: Run frontend tests
run: pnpm run test
run: |
pnpm run test:coverage
test -s coverage/coverage-summary.json
test -s coverage/lcov.info
working-directory: ./ui/menu-website

- name: Report frontend coverage
if: ${{ always() && hashFiles('ui/menu-website/coverage/coverage-summary.json') != '' }}
uses: davelosert/vitest-coverage-report-action@c4bbc33a89b7ace0e63d35f1f7d4bcee31155a73 # v2
continue-on-error: true
with:
working-directory: ui/menu-website
comment-on: none

- name: Save frontend coverage
if: ${{ always() && hashFiles('ui/menu-website/coverage/coverage-summary.json') != '' }}
uses: actions/upload-artifact@v7
continue-on-error: true
with:
name: coverage-frontend
path: ui/menu-website/coverage/coverage-summary.json
retention-days: 7

frontend-e2e:
name: Frontend E2E (shard ${{ matrix.shard }})
needs: [changes, backend-build]
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Generated\ Files/

# MSTest test Results
[Tt]est[Rr]esult*/
/coverage/
/coverage-report/
[Bb]uild[Ll]og.*

# NUNIT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.5" />
<PackageReference Include="MartinCostello.Logging.XUnit.v3" Version="0.7.1" />
<PackageReference Include="Microsoft.Testing.Platform" Version="2.4.1" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.11.2" />
<PackageReference Include="xunit.v3.mtp-v2" Version="4.0.1" />
</ItemGroup>

Expand Down
1 change: 1 addition & 0 deletions backend/MenuApi.Tests/MenuApi.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageReference Include="Microsoft.Testing.Platform" Version="2.4.1" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.11.2" />
<PackageReference Include="xunit.v3.mtp-v2" Version="4.0.1" />
</ItemGroup>

Expand Down
1 change: 1 addition & 0 deletions backend/MenuDB.Tests/MenuDB.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageReference Include="Microsoft.Testing.Platform" Version="2.4.1" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.11.2" />
<PackageReference Include="xunit.v3.mtp-v2" Version="4.0.1" />
<PackageReference Include="AwesomeAssertions" Version="9.6.0" />
<PackageReference Include="AwesomeAssertions.Analyzers" Version="9.0.8">
Expand Down
Loading
Loading