@plainconceptsplatform/workflows 1.20.2 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/loops/actions/land-pull-request/land-pull-request.sh +6 -0
- package/loops/actions/land-pull-request/side-checks.sh +36 -0
- package/loops/actions/merge-agent-pr/action.yml +6 -0
- package/loops/actions/merge-train/merge-train.sh +11 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +25 -0
- package/loops/templates/ci/app-ci-dotnet-next.yml +15 -8
- package/loops/templates/ci/app-ci-node-monorepo.yml +5 -4
- package/loops/templates/quality-gates/scripts/lib/test-timings.mjs +74 -0
- package/loops/templates/quality-gates/scripts/lib/test-timings.test.mjs +46 -0
- package/loops/templates/quality-gates/scripts/test-timings.mjs +25 -0
- package/loops/templates/rulesets/main-required-checks.json +17 -0
- package/package.json +1 -1
|
@@ -34,6 +34,12 @@ if [ "$current" != "$HEAD_SHA" ]; then
|
|
|
34
34
|
out ""
|
|
35
35
|
exit 0
|
|
36
36
|
fi
|
|
37
|
+
reason=$(bash "$(dirname "${BASH_SOURCE[0]}")/side-checks.sh" "$REPO" "$HEAD_SHA")
|
|
38
|
+
if [ -n "$reason" ]; then
|
|
39
|
+
echo "::warning::Not landing #$PR: $reason."
|
|
40
|
+
out ""
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
37
43
|
base=$(jq -r .baseRefName <<<"$pr_json")
|
|
38
44
|
head_ref=$(jq -r .headRefName <<<"$pr_json")
|
|
39
45
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/land-pull-request/side-checks.sh. Update with workflows update --force; consumer edits may be overwritten.
|
|
3
|
+
#
|
|
4
|
+
# Prints why a commit may not land yet, or nothing when it may. A side workflow
|
|
5
|
+
# (SIDE_CHECK_WORKFLOWS, comma-separated, default "Agentics checks") runs only when its paths change,
|
|
6
|
+
# so no ruleset can require it, and the CI verdict the train and the gate read leaves it out: a
|
|
7
|
+
# release once merged into three apps with its workflow lint red. A side workflow that did not run
|
|
8
|
+
# on the commit is no objection; one that is running, failed or cannot be read is.
|
|
9
|
+
# Usage: side-checks.sh <owner/repo> <sha>
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
repo="$1"
|
|
12
|
+
sha="$2"
|
|
13
|
+
IFS=',' read -ra names <<<"${SIDE_CHECK_WORKFLOWS-Agentics checks}"
|
|
14
|
+
[ "${#names[@]}" -gt 0 ] || exit 0
|
|
15
|
+
|
|
16
|
+
if ! runs=$(gh api "repos/$repo/actions/runs?head_sha=$sha&per_page=100" --jq '.workflow_runs' 2>/dev/null); then
|
|
17
|
+
echo "the workflow runs on ${sha:0:7} could not be read"
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
for name in "${names[@]}"; do
|
|
22
|
+
name=$(sed -E 's/^ +| +$//g' <<<"$name")
|
|
23
|
+
[ -n "$name" ] || continue
|
|
24
|
+
latest=$(jq -r --arg n "$name" '[.[] | select(.name == $n)] | sort_by(.created_at) | last // empty | "\(.status) \(.conclusion // "")"' <<<"$runs")
|
|
25
|
+
[ -n "$latest" ] || continue
|
|
26
|
+
status=${latest%% *}
|
|
27
|
+
conclusion=${latest#* }
|
|
28
|
+
if [ "$status" != completed ]; then
|
|
29
|
+
echo "$name is still running on ${sha:0:7}"
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
case "$conclusion" in
|
|
33
|
+
success | skipped | neutral) ;;
|
|
34
|
+
*) echo "$name ended ${conclusion:-without a conclusion} on ${sha:0:7}"; exit 0 ;;
|
|
35
|
+
esac
|
|
36
|
+
done
|
|
@@ -34,6 +34,12 @@ runs:
|
|
|
34
34
|
|
|
35
35
|
HEAD_REF=$(gh pr view "$PR_NUM" --repo "$REPO" --json headRefName --jq '.headRefName')
|
|
36
36
|
|
|
37
|
+
reason=$(bash ./.github/actions/land-pull-request/side-checks.sh "$REPO" "$HEAD_SHA")
|
|
38
|
+
if [ -n "$reason" ]; then
|
|
39
|
+
echo "::warning::Not merging #$PR_NUM: $reason."
|
|
40
|
+
continue
|
|
41
|
+
fi
|
|
42
|
+
|
|
37
43
|
gh pr merge "$PR_NUM" --repo "$REPO" --squash --match-head-commit "$HEAD_SHA"
|
|
38
44
|
|
|
39
45
|
# Delete the branch, because nothing else ever has: four repositories had accumulated
|
|
@@ -520,6 +520,17 @@ for line in "${candidates[@]}"; do
|
|
|
520
520
|
[ $((now_epoch - $(iso_epoch "$ci_created"))) -lt $((STUCK_HOURS * 3600)) ] || ci_stuck=true
|
|
521
521
|
fi
|
|
522
522
|
|
|
523
|
+
# A green CI run is not the whole verdict: a side workflow (Agentics checks) on the same head may be
|
|
524
|
+
# red or still running, and the gate could then never land it. Held until that changes.
|
|
525
|
+
if [ "$ci_status" = completed ] && [ "$ci_conclusion" = success ] && [ "$park" = none ]; then
|
|
526
|
+
side=$(bash "$(dirname "${BASH_SOURCE[0]}")/../land-pull-request/side-checks.sh" "$REPO" "$head_sha")
|
|
527
|
+
if [ -n "$side" ]; then
|
|
528
|
+
echo "#$pr ($author): hold, $side."
|
|
529
|
+
summary "| #$pr | $author | hold | $side |"
|
|
530
|
+
continue
|
|
531
|
+
fi
|
|
532
|
+
fi
|
|
533
|
+
|
|
523
534
|
mergeable=UNKNOWN
|
|
524
535
|
behind_by=0
|
|
525
536
|
if [ "$park" = none ] && [ "$hold" != true ] && [ "$review_decision" != CHANGES_REQUESTED ]; then
|
|
@@ -3708,6 +3708,31 @@ if [ -f "$RTP_YML" ] && worker_installed release; then
|
|
|
3708
3708
|
if [ "$RTP_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
3709
3709
|
fi
|
|
3710
3710
|
|
|
3711
|
+
echo "── Required checks and side workflows ────────────────────────────────────"
|
|
3712
|
+
|
|
3713
|
+
# The main ruleset requires one check, Full CI, so Full CI must need every blocking job, and the
|
|
3714
|
+
# side workflows no ruleset can require (Agentics checks runs only on its paths) must be checked
|
|
3715
|
+
# by both merge points and held by the train. Dropping any one of these lets a red job merge.
|
|
3716
|
+
RC_OK=1
|
|
3717
|
+
rc() { grep -qE "$1" "$2" || { RC_OK=0; echo "FAIL: required checks: $3" >&2; }; }
|
|
3718
|
+
CI_DOTNET="${HERE}/../../templates/ci/app-ci-dotnet-next.yml"
|
|
3719
|
+
CI_NODE="${HERE}/../../templates/ci/app-ci-node-monorepo.yml"
|
|
3720
|
+
RULESET="${HERE}/../../templates/rulesets/main-required-checks.json"
|
|
3721
|
+
for job in sast-scan secret-scan deps-and-iac-scan platform-conformance engineering-excellence api web; do
|
|
3722
|
+
awk '/^ full-ci:$/{f=1} f&&/needs:/{print; exit}' "$CI_DOTNET" | grep -qE "[[ ]${job}[],]" || { RC_OK=0; echo "FAIL: required checks: the .NET template's Full CI does not need ${job}" >&2; }
|
|
3723
|
+
done
|
|
3724
|
+
for job in lint sast-scan secret-scan deps-and-iac-scan web; do
|
|
3725
|
+
awk '/^ full-ci:$/{f=1} f&&/needs:/{print; exit}' "$CI_NODE" | grep -qE "[[ ]${job}[],]" || { RC_OK=0; echo "FAIL: required checks: the node template's Full CI does not need ${job}" >&2; }
|
|
3726
|
+
done
|
|
3727
|
+
rc '"context": "Full CI"' "$RULESET" 'the ruleset must require Full CI'
|
|
3728
|
+
rc '"include": \["~DEFAULT_BRANCH"\]' "$RULESET" 'the ruleset must target the default branch'
|
|
3729
|
+
rc 'side-checks\.sh' "${HERE}/../land-pull-request/land-pull-request.sh" 'land-pull-request must check the side workflows'
|
|
3730
|
+
rc 'side-checks\.sh' "${HERE}/../merge-agent-pr/action.yml" 'merge-agent-pr must check the side workflows'
|
|
3731
|
+
rc 'land-pull-request/side-checks\.sh' "${HERE}/../merge-train/merge-train.sh" 'the merge train must hold a pull request whose side workflow is red'
|
|
3732
|
+
rc 'test-timings\.mjs' "$CI_DOTNET" 'the .NET template must summarise the test timings'
|
|
3733
|
+
[ "$(grep -c -- '--logger trx' "$CI_DOTNET")" -ge 5 ] || { RC_OK=0; echo "FAIL: required checks: every .NET test step must write a trx report" >&2; }
|
|
3734
|
+
if [ "$RC_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
|
|
3735
|
+
|
|
3711
3736
|
echo "── App error privacy ─────────────────────────────────────────────────────"
|
|
3712
3737
|
|
|
3713
3738
|
# The application error report files into the private repository rather than out of it, so
|
|
@@ -214,19 +214,19 @@ jobs:
|
|
|
214
214
|
- name: Unit tests + coverage
|
|
215
215
|
if: env.UNIT_TEST_PROJECT != ''
|
|
216
216
|
run: dotnet test "$UNIT_TEST_PROJECT" --no-build -c Release
|
|
217
|
-
--collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
|
|
217
|
+
--collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults --logger trx
|
|
218
218
|
- name: Parity tests
|
|
219
219
|
if: env.PARITY_TEST_PROJECT != ''
|
|
220
|
-
run: dotnet test "$PARITY_TEST_PROJECT" --no-build -c Release
|
|
220
|
+
run: dotnet test "$PARITY_TEST_PROJECT" --no-build -c Release --results-directory TestResults --logger trx
|
|
221
221
|
- name: Architecture tests
|
|
222
222
|
if: env.ARCHITECTURE_TEST_PROJECT != ''
|
|
223
|
-
run: dotnet test "$ARCHITECTURE_TEST_PROJECT" --no-build -c Release
|
|
223
|
+
run: dotnet test "$ARCHITECTURE_TEST_PROJECT" --no-build -c Release --results-directory TestResults --logger trx
|
|
224
224
|
# Executable OpenSpec scenarios (Reqnroll). Each scenario is bound to a spec scenario by tag
|
|
225
225
|
# and fails if that scenario disappears from openspec/.
|
|
226
226
|
- name: Acceptance tests (Reqnroll) + coverage
|
|
227
227
|
if: env.ACCEPTANCE_TEST_PROJECT != ''
|
|
228
228
|
run: dotnet test "$ACCEPTANCE_TEST_PROJECT" --no-build -c Release
|
|
229
|
-
--collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
|
|
229
|
+
--collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults --logger trx
|
|
230
230
|
# APP_SQL_SERVER is the connection string without a database; each later step names its own.
|
|
231
231
|
- name: Create test databases
|
|
232
232
|
env:
|
|
@@ -259,11 +259,16 @@ jobs:
|
|
|
259
259
|
export "$INTEGRATION_CONNECTION_ENV=$APP_SQL_SERVER;Database=$PRIMARY_DATABASE"
|
|
260
260
|
for pair in $EXTRA_DATABASES; do export "${pair%%=*}=$APP_SQL_SERVER;Database=${pair#*=}"; done
|
|
261
261
|
dotnet test "$INTEGRATION_TEST_PROJECT" --no-build -c Release \
|
|
262
|
-
--collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults
|
|
262
|
+
--collect:"XPlat Code Coverage" --settings "$COVERAGE_SETTINGS" --results-directory TestResults --logger trx
|
|
263
263
|
# The coverage step runs a small node script; fresh runner VMs ship no node.
|
|
264
264
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
265
265
|
with:
|
|
266
266
|
node-version: ${{ env.NODE_VERSION }}
|
|
267
|
+
# Where the run's time went, by test class: a class is what xUnit runs in parallel and where a
|
|
268
|
+
# fixture's setup is paid, so a slow class shows up the day it is added. Never fails the job.
|
|
269
|
+
- name: Test timings
|
|
270
|
+
if: ${{ !cancelled() }}
|
|
271
|
+
run: node "$GITHUB_WORKSPACE/scripts/test-timings.mjs" TestResults >> "$GITHUB_STEP_SUMMARY" || true
|
|
267
272
|
# pipefail: without it the step reports tee's status and passes however low coverage is.
|
|
268
273
|
- name: Coverage floor (merged)
|
|
269
274
|
run: |
|
|
@@ -854,20 +859,22 @@ jobs:
|
|
|
854
859
|
retention-days: 90
|
|
855
860
|
|
|
856
861
|
# The merge train reads this job by its name to tell a full run from a light one: do not rename it.
|
|
862
|
+
# It is also the check the main ruleset requires (loops/templates/rulesets), so it needs every
|
|
863
|
+
# blocking job, the scans as well as the heavy ones: a red scan must stop a merge too.
|
|
857
864
|
full-ci:
|
|
858
865
|
name: Full CI
|
|
859
|
-
needs: [depth, api, web, e2e, engineering-excellence]
|
|
866
|
+
needs: [depth, api, web, e2e, engineering-excellence, markdown, repo-checks, platform-conformance, secret-scan, deps-and-iac-scan, sast-scan]
|
|
860
867
|
if: ${{ always() && needs.depth.outputs.full == 'true' }}
|
|
861
868
|
runs-on: ${{ vars.CI_RUNNER || 'agents-arc' }}
|
|
862
869
|
timeout-minutes: 5
|
|
863
870
|
steps:
|
|
864
|
-
- name: Every
|
|
871
|
+
- name: Every blocking job passed
|
|
865
872
|
env:
|
|
866
873
|
RESULTS: ${{ join(needs.*.result, ' ') }}
|
|
867
874
|
run: |
|
|
868
875
|
for result in $RESULTS; do
|
|
869
876
|
case "$result" in
|
|
870
|
-
failure | cancelled | timed_out) echo "A
|
|
877
|
+
failure | cancelled | timed_out) echo "A blocking job ended $result."; exit 1 ;;
|
|
871
878
|
esac
|
|
872
879
|
done
|
|
873
880
|
echo "Full CI passed."
|
|
@@ -209,21 +209,22 @@ jobs:
|
|
|
209
209
|
# Same guard as above: a tag release needs contents: write, which this job lacks.
|
|
210
210
|
upload-release-assets: false
|
|
211
211
|
|
|
212
|
-
# The merge queue
|
|
212
|
+
# The merge queue and the main ruleset (loops/templates/rulesets) require this check, so it needs
|
|
213
|
+
# every blocking job, the scans too. On a pull request with MERGE_QUEUE on, the heavy jobs are
|
|
213
214
|
# skipped and the full CI runs on the merge group instead; skipped counts as passed.
|
|
214
215
|
full-ci:
|
|
215
216
|
name: Full CI
|
|
216
|
-
needs: [web, e2e, desktop, mobile]
|
|
217
|
+
needs: [lint, web, e2e, desktop, mobile, secret-scan, deps-and-iac-scan, sast-scan]
|
|
217
218
|
if: always()
|
|
218
219
|
runs-on: agents-arc
|
|
219
220
|
timeout-minutes: 5
|
|
220
221
|
steps:
|
|
221
|
-
- name: Every
|
|
222
|
+
- name: Every blocking job passed
|
|
222
223
|
env:
|
|
223
224
|
RESULTS: ${{ join(needs.*.result, ' ') }}
|
|
224
225
|
run: |
|
|
225
226
|
for result in $RESULTS; do
|
|
226
|
-
case "$result" in failure | cancelled | timed_out) echo "A
|
|
227
|
+
case "$result" in failure | cancelled | timed_out) echo "A blocking job ended $result."; exit 1 ;; esac
|
|
227
228
|
done
|
|
228
229
|
|
|
229
230
|
# Hands this run's verdict to the router's merge train, which decides whether this pull request
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/test-timings.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
|
|
3
|
+
// Reads VSTest .trx reports and says where a test run's time goes: by test class, since a class is
|
|
4
|
+
// what xUnit runs in parallel and where a fixture's setup is paid once.
|
|
5
|
+
|
|
6
|
+
const attr = (tag, name) => tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1];
|
|
7
|
+
|
|
8
|
+
function seconds(duration) {
|
|
9
|
+
const [h, m, s] = (duration ?? '0:0:0').split(':');
|
|
10
|
+
return Number(h) * 3600 + Number(m) * 60 + Number(s);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseTrx(xml) {
|
|
14
|
+
const classes = new Map();
|
|
15
|
+
for (const [, id, body] of xml.matchAll(/<UnitTest\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/UnitTest>/g)) {
|
|
16
|
+
const method = body.match(/<TestMethod\b[^>]*>/)?.[0];
|
|
17
|
+
if (method) classes.set(id, attr(method, 'className'));
|
|
18
|
+
}
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const [tag] of xml.matchAll(/<UnitTestResult\b[^>]*>/g)) {
|
|
21
|
+
const className = classes.get(attr(tag, 'testId')) ?? 'unknown';
|
|
22
|
+
results.push({
|
|
23
|
+
className: className.split('.').pop(),
|
|
24
|
+
name: (attr(tag, 'testName') ?? '').replace(`${className}.`, ''),
|
|
25
|
+
seconds: seconds(attr(tag, 'duration')),
|
|
26
|
+
start: Date.parse(attr(tag, 'startTime') ?? ''),
|
|
27
|
+
end: Date.parse(attr(tag, 'endTime') ?? ''),
|
|
28
|
+
outcome: attr(tag, 'outcome') ?? 'Unknown',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const times = xml.match(/<Times\b[^>]*>/)?.[0] ?? '';
|
|
32
|
+
const wall = (Date.parse(attr(times, 'finish') ?? '') - Date.parse(attr(times, 'start') ?? '')) / 1000;
|
|
33
|
+
const runName = xml.match(/<TestRun\b[^>]*>/)?.[0];
|
|
34
|
+
const storage = xml.match(/<UnitTest\b[^>]*\bstorage="([^"]+)"/)?.[1] ?? attr(runName ?? '', 'name') ?? 'tests';
|
|
35
|
+
return { name: storage.split(/[\\/]/).pop().replace(/\.dll$/i, ''), wall: Number.isFinite(wall) ? wall : 0, results };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function summarize(run, top = 15) {
|
|
39
|
+
const byClass = new Map();
|
|
40
|
+
for (const r of run.results) {
|
|
41
|
+
const c = byClass.get(r.className) ?? { name: r.className, tests: 0, seconds: 0, first: null };
|
|
42
|
+
c.tests += 1;
|
|
43
|
+
c.seconds += r.seconds;
|
|
44
|
+
if (!c.first || r.start < c.first.start) c.first = r;
|
|
45
|
+
byClass.set(r.className, c);
|
|
46
|
+
}
|
|
47
|
+
const total = run.results.reduce((sum, r) => sum + r.seconds, 0);
|
|
48
|
+
const classes = [...byClass.values()].sort((a, b) => b.seconds - a.seconds);
|
|
49
|
+
return {
|
|
50
|
+
name: run.name,
|
|
51
|
+
tests: run.results.length,
|
|
52
|
+
wall: run.wall,
|
|
53
|
+
total,
|
|
54
|
+
concurrency: run.wall > 0 ? total / run.wall : 0,
|
|
55
|
+
classes: classes.slice(0, top).map(({ name, tests, seconds, first }) => ({ name, tests, seconds, firstTest: first?.seconds ?? 0 })),
|
|
56
|
+
slowest: [...run.results].sort((a, b) => b.seconds - a.seconds).slice(0, top).map(({ className, name, seconds }) => ({ className, name, seconds })),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const s = (n) => `${n.toFixed(1)} s`;
|
|
61
|
+
|
|
62
|
+
export function toMarkdown(summaries) {
|
|
63
|
+
const lines = ['### Test timings', ''];
|
|
64
|
+
for (const t of summaries) {
|
|
65
|
+
lines.push(`**${t.name}**: ${t.tests} tests, ${s(t.wall)} wall, ${s(t.total)} of test time, ${t.concurrency.toFixed(1)} running at once on average.`, '');
|
|
66
|
+
if (t.tests < 20) continue;
|
|
67
|
+
lines.push('| Class | Tests | Time | First test |', '|---|--:|--:|--:|');
|
|
68
|
+
for (const c of t.classes) lines.push(`| ${c.name} | ${c.tests} | ${s(c.seconds)} | ${s(c.firstTest)} |`);
|
|
69
|
+
lines.push('', '<details><summary>Slowest tests</summary>', '');
|
|
70
|
+
for (const r of t.slowest) lines.push(`- ${s(r.seconds)} ${r.className}: ${r.name.replace(/\|/g, '\\|')}`);
|
|
71
|
+
lines.push('', '</details>', '');
|
|
72
|
+
}
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/lib/test-timings.test.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
// Run: node --test scripts/lib/test-timings.test.mjs
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { parseTrx, summarize, toMarkdown } from './test-timings.mjs';
|
|
6
|
+
|
|
7
|
+
const unit = (id, cls, name) =>
|
|
8
|
+
`<UnitTest name="${name}" storage="/w/App.IntegrationTests.dll" id="${id}"><Execution id="e${id}" /><TestMethod codeBase="x" className="App.Tests.${cls}" name="${name}" /></UnitTest>`;
|
|
9
|
+
const result = (id, duration, start, end) =>
|
|
10
|
+
`<UnitTestResult executionId="e${id}" testId="${id}" testName="${id} name" duration="${duration}" startTime="${start}" endTime="${end}" outcome="Passed" />`;
|
|
11
|
+
|
|
12
|
+
const trx = `<?xml version="1.0"?><TestRun id="r" name="run">
|
|
13
|
+
<Times creation="2026-01-01T00:00:00Z" start="2026-01-01T00:00:00.000Z" finish="2026-01-01T00:00:10.000Z" />
|
|
14
|
+
<Results>
|
|
15
|
+
${result('a', '00:00:06.0000000', '2026-01-01T00:00:00Z', '2026-01-01T00:00:06Z')}
|
|
16
|
+
${result('b', '00:00:01.0000000', '2026-01-01T00:00:06Z', '2026-01-01T00:00:07Z')}
|
|
17
|
+
${result('c', '00:00:08.0000000', '2026-01-01T00:00:01Z', '2026-01-01T00:00:09Z')}
|
|
18
|
+
</Results>
|
|
19
|
+
<TestDefinitions>${unit('a', 'OrdersTests', 'First')}${unit('b', 'OrdersTests', 'Second')}${unit('c', 'StockTests', 'Only')}</TestDefinitions>
|
|
20
|
+
</TestRun>`;
|
|
21
|
+
|
|
22
|
+
test('parseTrx_resultsAndDefinitions_joinsClassAndDurationAndWallTime', () => {
|
|
23
|
+
const run = parseTrx(trx);
|
|
24
|
+
assert.equal(run.name, 'App.IntegrationTests');
|
|
25
|
+
assert.equal(run.wall, 10);
|
|
26
|
+
assert.deepEqual(run.results.map((r) => [r.className, r.seconds]), [['OrdersTests', 6], ['OrdersTests', 1], ['StockTests', 8]]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('summarize_classesBySummedTime_reportsConcurrencyAndTheFirstTestCost', () => {
|
|
30
|
+
const t = summarize(parseTrx(trx));
|
|
31
|
+
assert.equal(t.total, 15);
|
|
32
|
+
assert.equal(t.concurrency, 1.5);
|
|
33
|
+
assert.deepEqual(t.classes.map((c) => [c.name, c.tests, c.seconds, c.firstTest]), [['StockTests', 1, 8, 8], ['OrdersTests', 2, 7, 6]]);
|
|
34
|
+
assert.equal(t.slowest[0].className, 'StockTests');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('toMarkdown_smallRun_givesTheTotalsWithoutTables', () => {
|
|
38
|
+
const md = toMarkdown([summarize(parseTrx(trx))]);
|
|
39
|
+
assert.match(md, /\*\*App\.IntegrationTests\*\*: 3 tests, 10\.0 s wall, 15\.0 s of test time, 1\.5 running at once on average\./);
|
|
40
|
+
assert.doesNotMatch(md, /\| Class \|/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('parseTrx_fullyQualifiedTestName_dropsTheClassPrefix', () => {
|
|
44
|
+
const xml = `<TestRun><UnitTestResult testId="x" testName="App.Tests.OrdersTests.Saves(total: 1.5)" duration="00:00:01" outcome="Passed" />${unit('x', 'OrdersTests', 'Saves')}</TestRun>`;
|
|
45
|
+
assert.equal(parseTrx(xml).results[0].name, 'Saves(total: 1.5)');
|
|
46
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/templates/quality-gates/scripts/test-timings.mjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
|
+
|
|
4
|
+
// Usage: node scripts/test-timings.mjs <dir-with-trx> [--top 15]
|
|
5
|
+
// Prints where the test run's time went as Markdown, for the job summary. Never fails the job.
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { parseTrx, summarize, toMarkdown } from './lib/test-timings.mjs';
|
|
9
|
+
|
|
10
|
+
const [dir = 'TestResults', ...rest] = process.argv.slice(2);
|
|
11
|
+
const top = Number(rest[rest.indexOf('--top') + 1]) || 15;
|
|
12
|
+
|
|
13
|
+
function trxFiles(root) {
|
|
14
|
+
try {
|
|
15
|
+
return readdirSync(root).flatMap((entry) => {
|
|
16
|
+
const full = join(root, entry);
|
|
17
|
+
return statSync(full).isDirectory() ? trxFiles(full) : entry.endsWith('.trx') ? [full] : [];
|
|
18
|
+
});
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const runs = trxFiles(dir).map((file) => summarize(parseTrx(readFileSync(file, 'utf8')), top)).filter((t) => t.tests > 0);
|
|
25
|
+
console.log(runs.length ? toMarkdown(runs.sort((a, b) => b.wall - a.wall)) : `### Test timings\n\nNo .trx reports under ${dir}.`);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Default branch: Full CI required",
|
|
3
|
+
"target": "branch",
|
|
4
|
+
"enforcement": "active",
|
|
5
|
+
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
|
|
6
|
+
"rules": [
|
|
7
|
+
{
|
|
8
|
+
"type": "required_status_checks",
|
|
9
|
+
"parameters": {
|
|
10
|
+
"strict_required_status_checks_policy": false,
|
|
11
|
+
"do_not_enforce_on_create": false,
|
|
12
|
+
"required_status_checks": [{ "context": "Full CI" }]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"bypass_actors": [{ "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" }]
|
|
17
|
+
}
|