Skip to content

Commit 9d3fbc5

Browse files
committed
Apply pytest improvements to storybook workflow
- Create test-storybook-runner.yml as a simple reusable runner (analogous to test-py-pytest.yml) - Add parallel_workers input with cgroup-aware auto-detection - Add smart caching for target branch results with pending marker pattern to avoid duplicate test runs across concurrent PRs - Refactor test-storybook.yml to use the new runner for source branch testing while maintaining inline target branch testing with caching - Add consistent outputs for regression analysis compatibility - Update usage.mmd to reflect new architecture
1 parent 77b09cc commit 9d3fbc5

3 files changed

Lines changed: 921 additions & 150 deletions

File tree

Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
name: Reusable Storybook Runner
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
ref:
7+
description: "Git ref to checkout and test. Leave empty for default checkout."
8+
required: false
9+
type: string
10+
default: ""
11+
node-version:
12+
description: "Node.js version to use for Storybook tests."
13+
required: false
14+
type: string
15+
default: "18.x"
16+
runs_on:
17+
description: "Runner label for the test job."
18+
required: false
19+
type: string
20+
default: '["self-hosted", "multithreaded"]'
21+
artifact_name:
22+
description: "Name for the test results artifact."
23+
required: true
24+
type: string
25+
parallel_workers:
26+
description: "Number of parallel workers (shards) for Storybook tests. Leave empty for runner default (6 for multithreaded, 1 for singlethreaded). Use 'auto' for cgroup-aware CPU count, or a number."
27+
required: false
28+
type: string
29+
default: ""
30+
storybook_port:
31+
description: "Port for Storybook server."
32+
required: false
33+
type: string
34+
default: "6006"
35+
storybook_build_command:
36+
description: "Command to build Storybook. Leave empty to use dev server instead."
37+
required: false
38+
type: string
39+
default: ""
40+
storybook_start_command:
41+
description: "Command to start Storybook server. Uses 'npm run storybook' by default."
42+
required: false
43+
type: string
44+
default: "npm run storybook"
45+
storybook_test_command:
46+
description: "Command to run Storybook tests. Uses 'npm run storybook-test' by default."
47+
required: false
48+
type: string
49+
default: "npm run storybook-test"
50+
outputs:
51+
total:
52+
description: "Total number of tests"
53+
value: ${{ jobs.test.outputs.total }}
54+
passed:
55+
description: "Number of passing tests"
56+
value: ${{ jobs.test.outputs.passed }}
57+
percentage:
58+
description: "Pass percentage"
59+
value: ${{ jobs.test.outputs.percentage }}
60+
collection_errors:
61+
description: "Whether collection errors occurred"
62+
value: ${{ jobs.test.outputs.collection_errors }}
63+
no_tests_found:
64+
description: "Whether no tests were found"
65+
value: ${{ jobs.test.outputs.no_tests_found }}
66+
has_errors:
67+
description: "Whether any errors occurred"
68+
value: ${{ jobs.test.outputs.has_errors }}
69+
error_type:
70+
description: "Type of error if any"
71+
value: ${{ jobs.test.outputs.error_type }}
72+
failing_count:
73+
description: "Number of failing tests"
74+
value: ${{ jobs.test.outputs.failing_count }}
75+
error_count:
76+
description: "Number of errored tests"
77+
value: ${{ jobs.test.outputs.error_count }}
78+
skipped_count:
79+
description: "Number of skipped tests"
80+
value: ${{ jobs.test.outputs.skipped_count }}
81+
xfailed_count:
82+
description: "Number of xfailed tests"
83+
value: ${{ jobs.test.outputs.xfailed_count }}
84+
85+
jobs:
86+
test:
87+
runs-on: ${{ fromJSON(inputs.runs_on) }}
88+
outputs:
89+
total: ${{ steps.extract-results.outputs.total }}
90+
passed: ${{ steps.extract-results.outputs.passed }}
91+
percentage: ${{ steps.extract-results.outputs.percentage }}
92+
collection_errors: ${{ steps.check-stories.outputs.has_collection_errors }}
93+
no_tests_found: ${{ steps.check-stories.outputs.no_tests_found }}
94+
has_errors: ${{ steps.check-stories.outputs.has_errors }}
95+
error_type: ${{ steps.check-stories.outputs.error_type }}
96+
failing_count: ${{ steps.extract-results.outputs.failing_count }}
97+
error_count: ${{ steps.extract-results.outputs.error_count }}
98+
skipped_count: ${{ steps.extract-results.outputs.skipped_count }}
99+
xfailed_count: ${{ steps.extract-results.outputs.xfailed_count }}
100+
101+
steps:
102+
- name: Checkout
103+
uses: actions/checkout@v4.2.2
104+
with:
105+
submodules: "recursive"
106+
ref: ${{ inputs.ref || github.ref }}
107+
108+
- name: Use Node.js ${{ inputs.node-version }}
109+
uses: actions/setup-node@v4
110+
with:
111+
node-version: ${{ inputs.node-version }}
112+
cache: "npm"
113+
114+
- name: Install dependencies
115+
run: npm ci
116+
117+
- name: Install Playwright browsers
118+
run: npx playwright install --with-deps
119+
120+
- name: Check for story collection errors
121+
id: check-stories
122+
run: |
123+
echo "Checking for Storybook stories..."
124+
HAS_COLLECTION_ERRORS="false"
125+
NO_TESTS_FOUND="false"
126+
ERROR_TYPE="none"
127+
128+
# Check if storybook config exists
129+
if ! ls .storybook/main.* 2>/dev/null; then
130+
echo "::error::No Storybook configuration found"
131+
HAS_COLLECTION_ERRORS="true"
132+
ERROR_TYPE="NoStorybookConfig"
133+
else
134+
# Try to find stories by checking for story files
135+
STORY_COUNT=$(find . -name "*.stories.*" -o -name "*.story.*" 2>/dev/null | grep -v node_modules | wc -l || echo "0")
136+
if [[ "$STORY_COUNT" == "0" ]]; then
137+
echo "::warning::No story files were found"
138+
NO_TESTS_FOUND="true"
139+
ERROR_TYPE="NoStoriesFound"
140+
else
141+
echo "Found $STORY_COUNT story files"
142+
fi
143+
fi
144+
145+
echo "has_collection_errors=$HAS_COLLECTION_ERRORS" >> "$GITHUB_OUTPUT"
146+
echo "no_tests_found=$NO_TESTS_FOUND" >> "$GITHUB_OUTPUT"
147+
echo "error_type=$ERROR_TYPE" >> "$GITHUB_OUTPUT"
148+
if [[ "$HAS_COLLECTION_ERRORS" == "true" || "$NO_TESTS_FOUND" == "true" ]]; then
149+
echo "has_errors=true" >> "$GITHUB_OUTPUT"
150+
else
151+
echo "has_errors=false" >> "$GITHUB_OUTPUT"
152+
fi
153+
154+
- name: Build Storybook
155+
if: ${{ inputs.storybook_build_command != '' && steps.check-stories.outputs.has_collection_errors != 'true' }}
156+
run: ${{ inputs.storybook_build_command }}
157+
158+
- name: Start Storybook server
159+
if: steps.check-stories.outputs.has_collection_errors != 'true'
160+
run: |
161+
${{ inputs.storybook_start_command }} -- --port ${{ inputs.storybook_port }} &
162+
echo "STORYBOOK_PID=$!" >> $GITHUB_ENV
163+
164+
- name: Wait for Storybook
165+
if: steps.check-stories.outputs.has_collection_errors != 'true'
166+
run: |
167+
echo "Waiting for Storybook to start on port ${{ inputs.storybook_port }}..."
168+
timeout=120
169+
counter=0
170+
until curl --output /dev/null --silent --head --fail http://localhost:${{ inputs.storybook_port }}; do
171+
if [ $counter -ge $timeout ]; then
172+
echo "::error::Timed out waiting for Storybook to start"
173+
exit 1
174+
fi
175+
echo "Waiting for Storybook... ($counter seconds so far)"
176+
sleep 5
177+
counter=$((counter + 5))
178+
done
179+
echo "Storybook is up and running on port ${{ inputs.storybook_port }}!"
180+
181+
- name: Run Storybook tests
182+
id: run-tests
183+
continue-on-error: true
184+
if: steps.check-stories.outputs.has_collection_errors != 'true'
185+
run: |
186+
set -euo pipefail
187+
188+
cgroup_auto_workers() {
189+
local n=""
190+
191+
# cgroup v2: /sys/fs/cgroup/cpu.max => "<quota> <period>" or "max <period>"
192+
if [ -f /sys/fs/cgroup/cpu.max ]; then
193+
local quota period
194+
quota="$(awk '{print $1}' /sys/fs/cgroup/cpu.max)"
195+
period="$(awk '{print $2}' /sys/fs/cgroup/cpu.max)"
196+
if [ -n "$quota" ] && [ -n "$period" ] && [ "$quota" != "max" ] && [ "$period" != "0" ]; then
197+
n="$(awk -v q="$quota" -v p="$period" 'BEGIN{print int((q+p-1)/p)}')"
198+
fi
199+
fi
200+
201+
# cgroup v1: cpu.cfs_quota_us / cpu.cfs_period_us
202+
if [ -z "$n" ] && [ -f /sys/fs/cgroup/cpu/cpu.cfs_quota_us ] && [ -f /sys/fs/cgroup/cpu/cpu.cfs_period_us ]; then
203+
local quota period
204+
quota="$(cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us)"
205+
period="$(cat /sys/fs/cgroup/cpu/cpu.cfs_period_us)"
206+
if [ "$quota" -gt 0 ] && [ "$period" -gt 0 ]; then
207+
n="$(awk -v q="$quota" -v p="$period" 'BEGIN{print int((q+p-1)/p)}')"
208+
fi
209+
fi
210+
211+
# cpuset fallback (v2: /sys/fs/cgroup/cpuset.cpus ; v1: /sys/fs/cgroup/cpuset/cpuset.cpus)
212+
if [ -z "$n" ]; then
213+
local f=""
214+
if [ -f /sys/fs/cgroup/cpuset.cpus ]; then
215+
f="/sys/fs/cgroup/cpuset.cpus"
216+
elif [ -f /sys/fs/cgroup/cpuset/cpuset.cpus ]; then
217+
f="/sys/fs/cgroup/cpuset/cpuset.cpus"
218+
fi
219+
220+
if [ -n "$f" ]; then
221+
local spec
222+
spec="$(cat "$f" | tr -d '[:space:]')"
223+
if [ -n "$spec" ]; then
224+
local count=0
225+
IFS=',' read -r -a parts <<< "$spec"
226+
for p in "${parts[@]}"; do
227+
if [[ "$p" == *-* ]]; then
228+
local a="${p%%-*}"
229+
local b="${p##*-}"
230+
if [[ "$a" =~ ^[0-9]+$ && "$b" =~ ^[0-9]+$ && "$b" -ge "$a" ]]; then
231+
count=$((count + b - a + 1))
232+
fi
233+
elif [[ "$p" =~ ^[0-9]+$ ]]; then
234+
count=$((count + 1))
235+
fi
236+
done
237+
if [ "$count" -gt 0 ]; then
238+
n="$count"
239+
fi
240+
fi
241+
fi
242+
fi
243+
244+
if [ -z "$n" ] || [ "$n" -lt 1 ] 2>/dev/null; then
245+
n="1"
246+
fi
247+
248+
echo "$n"
249+
}
250+
251+
WORKERS="${{ inputs.parallel_workers }}"
252+
if [ -z "$WORKERS" ]; then
253+
if echo '${{ inputs.runs_on }}' | grep -q "multithreaded"; then
254+
WORKERS="6"
255+
else
256+
WORKERS="1"
257+
fi
258+
elif [ "$WORKERS" = "auto" ]; then
259+
WORKERS="$(cgroup_auto_workers)"
260+
fi
261+
262+
echo "Running Storybook tests with $WORKERS workers..."
263+
264+
# Build the shard flag if workers > 1
265+
SHARD_FLAGS=""
266+
if [ "$WORKERS" != "1" ]; then
267+
# Storybook test-runner supports --shard for parallelization
268+
# We run all shards in sequence here, or you can use matrix strategy
269+
SHARD_FLAGS="--maxWorkers=$WORKERS"
270+
fi
271+
272+
set +e
273+
${{ inputs.storybook_test_command }} -- --url http://localhost:${{ inputs.storybook_port }} $SHARD_FLAGS --json --outputFile=results.json 2>&1 | tee test_output.txt
274+
TEST_EXIT=$?
275+
set -e
276+
277+
echo "test_exit_code=$TEST_EXIT" >> "$GITHUB_OUTPUT"
278+
279+
if [ $TEST_EXIT -eq 137 ]; then
280+
echo "::warning::Tests were killed (exit 137) - likely OOM. Partial results may be available."
281+
fi
282+
283+
if [ -f results.json ]; then
284+
echo "Test execution completed (exit code: $TEST_EXIT)"
285+
else
286+
echo "No results.json - creating empty results file"
287+
echo '{"testResults": [], "numTotalTests": 0, "numPassedTests": 0, "numFailedTests": 0}' > results.json
288+
fi
289+
290+
- name: Extract test results
291+
id: extract-results
292+
run: |
293+
# Use the storybook normalizer script if available, otherwise inline
294+
if [ -f "$GITHUB_WORKSPACE/.github/scripts/storybook_results_to_standard_json.py" ]; then
295+
python3 "$GITHUB_WORKSPACE/.github/scripts/storybook_results_to_standard_json.py" \
296+
results.json \
297+
test_data.json \
298+
--github-output "$GITHUB_OUTPUT"
299+
else
300+
# Inline extraction for repositories without the script
301+
python3 -c "
302+
import json
303+
import os
304+
305+
total = passed = 0
306+
percentage = 0.0
307+
passing_tests = []
308+
failing_tests = []
309+
error_tests = []
310+
skipped_tests = []
311+
xfailed_tests = []
312+
all_tests = []
313+
314+
try:
315+
with open('results.json') as f:
316+
results = json.load(f)
317+
318+
# Handle Jest-style output from storybook test-runner
319+
if 'testResults' in results:
320+
for suite in results.get('testResults', []):
321+
for assertion in suite.get('assertionResults', []):
322+
name = assertion.get('fullName') or assertion.get('title', '')
323+
if not name:
324+
continue
325+
all_tests.append(name)
326+
status = assertion.get('status', '').lower()
327+
if status == 'passed':
328+
passing_tests.append(name)
329+
elif status == 'failed':
330+
failing_tests.append(name)
331+
elif status == 'skipped' or status == 'pending':
332+
skipped_tests.append(name)
333+
334+
total = len(all_tests)
335+
passed = len(passing_tests)
336+
percentage = (passed / total * 100) if total > 0 else 0
337+
except FileNotFoundError:
338+
print('Results file not found')
339+
except Exception as e:
340+
print(f'Error: {e}')
341+
342+
# Save artifact data
343+
with open('test_data.json', 'w') as f:
344+
json.dump({
345+
'passing_tests': passing_tests,
346+
'failing_tests': failing_tests,
347+
'error_tests': error_tests,
348+
'skipped_tests': skipped_tests,
349+
'xfailed_tests': xfailed_tests,
350+
'all_tests': all_tests,
351+
'warnings': []
352+
}, f, indent=2)
353+
354+
print(f'Results: {passed}/{total} ({percentage:.1f}%)')
355+
356+
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
357+
f.write(f'total={total}\n')
358+
f.write(f'passed={passed}\n')
359+
f.write(f'percentage={percentage:.2f}\n')
360+
f.write(f'failing_count={len(failing_tests)}\n')
361+
f.write(f'error_count={len(error_tests)}\n')
362+
f.write(f'skipped_count={len(skipped_tests)}\n')
363+
f.write(f'xfailed_count={len(xfailed_tests)}\n')
364+
f.write(f'no_tests_found={\"true\" if total == 0 else \"false\"}\n')
365+
f.write(f'collection_errors=false\n')
366+
f.write(f'has_failures={\"true\" if failing_tests else \"false\"}\n')
367+
"
368+
fi
369+
370+
- name: Stop Storybook server
371+
if: always()
372+
run: |
373+
if [ -n "${STORYBOOK_PID:-}" ]; then
374+
kill $STORYBOOK_PID 2>/dev/null || true
375+
fi
376+
# Also kill any stray node processes running storybook
377+
pkill -f "storybook" 2>/dev/null || true
378+
379+
- name: Upload test artifacts
380+
if: always()
381+
uses: actions/upload-artifact@v4
382+
with:
383+
name: ${{ inputs.artifact_name }}
384+
path: |
385+
test_data.json
386+
test_output.txt
387+
results.json
388+
retention-days: 3
389+
if-no-files-found: ignore

0 commit comments

Comments
 (0)