Skip to content

Commit 17d136d

Browse files
authored
GitHub actions improvements with code coverage (#100)
* Refactor GA workflows and add Visible Test Coverage * Add example frontend/ui unit tests * Add MONOREPO env variable to coverage aggregate script
1 parent 64ad399 commit 17d136d

File tree

17 files changed

+1231
-127
lines changed

17 files changed

+1231
-127
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
name: 🗳️ Check Licenses
2+
description: 'Check package licenses for compliance'
3+
4+
runs:
5+
using: 'composite'
6+
steps:
7+
- name: 🗳️ Check licenses
8+
run: pnpm check-licenses
9+
shell: bash

.github/actions/code-analysis/action.yml

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,3 @@ runs:
1111
- name: 🫣 Lint
1212
run: pnpm lint
1313
shell: bash
14-
15-
- name: 🗳️ Check licenses
16-
run: pnpm check-licenses
17-
shell: bash
18-
19-
- name: 🧪 Test
20-
run: pnpm test:coverage
21-
shell: bash
22-
23-
- name: 📊 Aggregate coverage results
24-
run: node scripts/aggregate-coverage-results.js
25-
shell: bash
Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
name: 💻 Node.js setup
22
description: 'Setup Node.js environment and install dependencies'
33

4+
inputs:
5+
install-dependencies:
6+
description: 'Set to false to skip pnpm install (for tooling-only jobs)'
7+
default: 'true'
8+
49
runs:
510
using: 'composite'
611
steps:
@@ -9,37 +14,22 @@ runs:
914
shell: bash
1015

1116
- name: 🎰 Setup Node
12-
uses: actions/setup-node@v4
17+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
1318
with:
1419
node-version-file: 'package.json'
15-
16-
- name: 🎈 Install pNPM
17-
uses: pnpm/action-setup@v3
18-
with:
19-
run_install: false
20-
21-
- name: 📀 Get pnpm store directory
22-
run: |
23-
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
24-
shell: bash
25-
26-
- name: 💾 Setup pnpm cache
27-
uses: actions/cache@v4
28-
with:
29-
path: ${{ env.STORE_PATH }}
30-
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
31-
restore-keys: |
32-
${{ runner.os }}-pnpm-store-
20+
cache: 'pnpm'
21+
cache-dependency-path: 'pnpm-lock.yaml'
3322

3423
- name: 📥 Install dependencies
24+
if: inputs.install-dependencies == 'true'
3525
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
3626
shell: bash
3727

3828
- name: 💾 Cache turbo build setup
39-
uses: actions/cache@v4
29+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
4030
with:
4131
path: .turbo
42-
key: ${{ runner.os }}-turbo-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
32+
key: ${{ runner.os }}-turbo-${{ github.head_ref || github.ref_name }}-${{ hashFiles('pnpm-lock.yaml') }}
4333
restore-keys: |
4434
${{ runner.os }}-turbo-${{ github.head_ref || github.ref_name }}-
4535
${{ runner.os }}-turbo-

.github/workflows/ci.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: 🧪 CI
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read # required so checkout/actions using GITHUB_TOKEN can read repo
10+
11+
concurrency:
12+
group: ci-${{ github.ref }}
13+
cancel-in-progress: true
14+
15+
jobs:
16+
lint:
17+
runs-on: ubuntu-24.04
18+
steps:
19+
- name: 📥 Checkout Repository
20+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
21+
22+
- name: 💻 Node setup
23+
uses: ./.github/actions/node-setup
24+
25+
- name: 👁️‍🗨️ Code Analysis
26+
uses: ./.github/actions/code-analysis
27+
28+
licenses:
29+
runs-on: ubuntu-24.04
30+
steps:
31+
- name: 📥 Checkout Repository
32+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
33+
34+
- name: 💻 Node setup
35+
uses: ./.github/actions/node-setup
36+
37+
- name: 🗳️ Check Licenses
38+
uses: ./.github/actions/check-licenses
39+
40+
build:
41+
runs-on: ubuntu-24.04
42+
steps:
43+
- name: 📥 Checkout Repository
44+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
45+
46+
- name: 💻 Node setup
47+
uses: ./.github/actions/node-setup
48+
49+
- name: 🏗️ Build packages
50+
run: pnpm build
51+
shell: bash

.github/workflows/code-analysis.yml

Lines changed: 0 additions & 25 deletions
This file was deleted.

.github/workflows/coverage.yml

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
name: 📊 Tests & Coverage
2+
3+
# Configuration
4+
# Set to 'true' for monorepo, 'false' for single repository
5+
env:
6+
MONOREPO: 'true'
7+
BASE_BRANCH: 'main'
8+
9+
on:
10+
push:
11+
branches:
12+
- main
13+
pull_request:
14+
types: [opened, synchronize, reopened]
15+
16+
concurrency:
17+
group: coverage-${{ github.ref }}
18+
cancel-in-progress: true
19+
20+
permissions:
21+
contents: read # required so checkout/actions using GITHUB_TOKEN can read repo
22+
actions: read # required to download base coverage artifact via workflow run APIs
23+
pull-requests: write # required to post/update coverage diff comments on PRs
24+
25+
jobs:
26+
coverage:
27+
runs-on: ubuntu-24.04
28+
permissions:
29+
actions: read # download base coverage artifact via workflow run APIs
30+
contents: read # checkout
31+
pull-requests: write # comment coverage diff on PRs
32+
env:
33+
EVENT_TYPE: ${{ github.event_name == 'push' && 'push' || 'pr' }}
34+
# Workflow-level env variables (MONOREPO, BASE_BRANCH) are automatically inherited by jobs
35+
steps:
36+
- name: 📥 Checkout Repository
37+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
38+
39+
- name: 💻 Node setup
40+
uses: ./.github/actions/node-setup
41+
42+
- name: 🧪 Test
43+
run: pnpm test:coverage
44+
shell: bash
45+
# Note: If tests fail, the workflow will fail and coverage steps won't run.
46+
# This ensures we only report coverage for passing tests.
47+
48+
- name: 📊 Aggregate coverage results and create summary
49+
# MONOREPO is inherited from workflow-level env
50+
run: node scripts/aggregate-coverage-results.js
51+
shell: bash
52+
53+
- name: 🔍 Locate base coverage workflow run
54+
id: locate-base-coverage
55+
if: env.EVENT_TYPE == 'pr'
56+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
57+
env:
58+
BASE_WORKFLOW: coverage.yml
59+
# BASE_BRANCH is inherited from workflow-level env
60+
with:
61+
script: |
62+
const workflowId = process.env.BASE_WORKFLOW;
63+
const baseBranch = process.env.BASE_BRANCH || 'main';
64+
65+
// Get the latest successful workflow run from the base branch
66+
const { data } = await github.rest.actions.listWorkflowRuns({
67+
owner: context.repo.owner,
68+
repo: context.repo.repo,
69+
workflow_id: workflowId,
70+
branch: baseBranch,
71+
per_page: 20,
72+
});
73+
74+
const run = data.workflow_runs.find((run) => run.conclusion === 'success');
75+
if (run) {
76+
core.info(`Found base coverage run ${run.id} from ${baseBranch} branch`);
77+
core.setOutput('run-id', String(run.id));
78+
} else {
79+
core.warning(`No successful base coverage run found for ${baseBranch} branch`);
80+
core.setOutput('run-id', '');
81+
}
82+
83+
- name: 📥 Download base branch coverage artifact
84+
if: env.EVENT_TYPE == 'pr' && steps.locate-base-coverage.outputs.run-id != ''
85+
continue-on-error: true
86+
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
87+
with:
88+
name: base-coverage
89+
path: base-coverage
90+
github-token: ${{ secrets.GITHUB_TOKEN }}
91+
run-id: ${{ steps.locate-base-coverage.outputs.run-id }}
92+
93+
- name: 💬 Comment coverage diff
94+
if: env.EVENT_TYPE == 'pr'
95+
env:
96+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
97+
PR_NUMBER: ${{ github.event.pull_request.number }}
98+
BASE_COVERAGE_ROOT: base-coverage
99+
# MONOREPO and BASE_BRANCH are inherited from workflow-level env
100+
run: node scripts/comment-coverage-diff.js
101+
shell: bash
102+
103+
- name: ♻️ Collect coverage summaries
104+
if: env.EVENT_TYPE == 'push' && github.ref_name == 'main'
105+
# MONOREPO is inherited from workflow-level env
106+
run: |
107+
rm -rf base-coverage
108+
if [ "$MONOREPO" = "true" ]; then
109+
rsync -a \
110+
--include '*/' \
111+
--include 'coverage-summary.json' \
112+
--exclude '*' \
113+
coverage/ base-coverage/
114+
else
115+
mkdir -p base-coverage
116+
cp coverage/coverage-summary.json base-coverage/coverage-summary.json
117+
fi
118+
shell: bash
119+
120+
- name: 📤 Upload coverage artifact
121+
if: env.EVENT_TYPE == 'push' && github.ref_name == 'main'
122+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
123+
with:
124+
name: base-coverage
125+
path: base-coverage
126+
retention-days: 14 # Artifacts expire after 14 days. Adjust if needed, but note that PRs opened after expiration won't have base coverage to compare against.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ next-env.d.ts
2020

2121
# Testing
2222
/coverage
23+
/base-coverage
24+
.actrc
2325

2426
# Cache
2527
.turbo
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { safeImportNamespace } from '../utils';
2+
3+
// Mock dynamic imports
4+
jest.mock('../locales/en/common.json', () => ({ default: { hello: 'Hello' } }), { virtual: true });
5+
jest.mock('../locales/hr/common.json', () => ({ default: { hello: 'Zdravo' } }), { virtual: true });
6+
7+
describe('safeImportNamespace', () => {
8+
it('successfully imports existing namespace', async () => {
9+
// Note: This test may need adjustment based on how Jest handles dynamic imports
10+
// The actual implementation uses dynamic imports which can be tricky to test
11+
const result = await safeImportNamespace('en', 'common');
12+
expect(result).toBeDefined();
13+
});
14+
15+
it('throws error for missing namespace', async () => {
16+
await expect(safeImportNamespace('en', 'nonexistent')).rejects.toThrow('Missing translation namespace');
17+
});
18+
19+
it('throws error for missing locale', async () => {
20+
await expect(safeImportNamespace('nonexistent', 'common')).rejects.toThrow('Missing translation namespace');
21+
});
22+
23+
it('rethrows non-MODULE_NOT_FOUND errors', async () => {
24+
// This would require mocking the import to throw a different error
25+
// For now, we'll test the error handling logic exists
26+
await expect(safeImportNamespace('invalid', 'common')).rejects.toThrow();
27+
});
28+
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"test": "turbo run test --parallel --log-order=grouped --continue",
2929
"test:watch": "turbo run test:watch --ui=tui --continue",
3030
"test:coverage": "turbo run test:coverage --parallel --log-order=grouped --continue",
31+
"test:coverage:aggregate": "pnpm test:coverage && MONOREPO=true node scripts/aggregate-coverage-results.js",
3132
"test:coverage:watch": "turbo run test:coverage:watch --ui=tui --continue"
3233
},
3334
"devDependencies": {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../card';
4+
import { Button } from '../button';
5+
6+
describe('Card component suite', () => {
7+
it('renders all structural slots with custom classNames', () => {
8+
render(
9+
<Card className="test-card" data-testid="card-root">
10+
<CardHeader className="header-slot">
11+
<CardTitle>Plan</CardTitle>
12+
<CardDescription>Choose the perfect plan</CardDescription>
13+
<CardAction>
14+
<Button>Primary action</Button>
15+
</CardAction>
16+
</CardHeader>
17+
<CardContent className="content-slot">Card body content</CardContent>
18+
<CardFooter className="footer-slot">Footer CTA</CardFooter>
19+
</Card>
20+
);
21+
22+
expect(screen.getByTestId('card-root')).toHaveClass('test-card');
23+
expect(screen.getByText('Plan')).toHaveAttribute('data-slot', 'card-title');
24+
expect(screen.getByText('Choose the perfect plan')).toHaveAttribute('data-slot', 'card-description');
25+
expect(screen.getByRole('button', { name: /primary action/i })).toBeInTheDocument();
26+
expect(screen.getByText('Card body content')).toHaveClass('content-slot');
27+
expect(screen.getByText('Footer CTA')).toHaveClass('footer-slot');
28+
});
29+
30+
it('forwards arbitrary props down to the DOM nodes', () => {
31+
render(
32+
<Card id="pricing-card" aria-label="Pricing overview">
33+
<CardContent>Details</CardContent>
34+
</Card>
35+
);
36+
37+
const card = screen.getByLabelText('Pricing overview');
38+
expect(card).toHaveAttribute('id', 'pricing-card');
39+
});
40+
});

0 commit comments

Comments
 (0)