forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest.sh
More file actions
executable file
·354 lines (326 loc) · 14 KB
/
Copy pathtest.sh
File metadata and controls
executable file
·354 lines (326 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
#!/usr/bin/env bash
#
# Run the suites this branch actually affects, the way CI decides it.
#
# CONTRIBUTING.md asks for every suite before every push, and that is the
# right instruction for a rule nobody can automate away - a push that fails on
# formatting spends a full CI round trip learning something ruff would have
# said in a second. What it costs is 294s, measured, for a change that could
# only have broken one part, which is most changes here. CI already solved
# this: .github/actions/changed-paths asks which files a pull request touches
# and skips the suites none of them reach. This is that same decision, made
# locally, before the push rather than after it.
#
# The same four suites through here are 174s, and a change to one of the
# Python parts is 20 to 50 seconds.
#
# scripts/test.sh the suites this branch's changes affect
# scripts/test.sh --all every suite, the way a push to main runs them
# scripts/test.sh --list what would run and why, without running it
# scripts/test.sh --since X compare against X rather than origin/main
# scripts/test.sh --coverage measure coverage too, as CI does
#
# COVERAGE IS OFF UNLESS ASKED FOR, and that is a saving rather than a
# shortcut: it is visibility-only in all four suites by deliberate decision -
# no threshold, nothing that can fail - so leaving it out cannot change a
# green run into a red one or the reverse. It is not free, though. Measured
# here, as this script runs them: 148s against 100s for the client, 20s
# against 16s for the backend. CI still measures it on every run, which is
# where the report is actually read.
#
# THE SCOPE LISTS ARE READ, NOT COPIED. Each suite's paths come out of its own
# workflow YAML at run time, so this script cannot drift from CI by being
# forgotten - that is CONTRIBUTING.md's one-home-per-item rule applied to the
# one place where a second copy would be invisible until it was wrong. Adding
# a path to a workflow changes what this runs, in the same edit.
#
# THE UNCERTAIN ANSWER IS ALWAYS "RUN". The changed-paths action says why, and
# it holds here for the same reason: running a suite that did not need to run
# costs a minute, and skipping one that did costs a merge, quietly. No git
# base, no PyYAML, an unreadable workflow, a detached head - every one of them
# runs everything rather than guessing.
#
# WHAT IT DOES NOT DO. It does not select individual tests. TESTING.md's CI
# section rules that out on purpose: inferring which test covers which source
# file can be wrong in the direction of not running a test that would have
# failed, and at these suite sizes there is nothing left to win. Per part is
# the whole of the mapping, here as in CI.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
run_all=false
list_only=false
with_coverage=false
base_ref=""
while [ $# -gt 0 ]; do
case "$1" in
--all) run_all=true ;;
--list) list_only=true ;;
--coverage) with_coverage=true ;;
--since) shift; base_ref="${1:-}" ;;
# The header block, however long it happens to be - printed by walking
# from the shebang to the first line that is not a comment, rather than
# from a line range that silently starts truncating the help the next time
# a paragraph is added. It already had.
-h|--help) awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"; exit 0 ;;
*) echo "unknown option: $1 (try --help)" >&2; exit 2 ;;
esac
shift
done
# An explicit --since that names nothing is the one uncertainty this script
# does NOT answer by running everything. The rest are conditions a checkout can
# arrive in on its own; this one is a typo, and quietly running all four suites
# would hide it behind three minutes of green. Checked here rather than inside
# resolve_base, because that is called from a command substitution and an
# `exit` there would end the subshell and let the caller carry on regardless.
if [ -n "$base_ref" ] && ! git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null; then
echo "--since: no such commit: $base_ref" >&2
exit 2
fi
# ---------------------------------------------------------------------------
# What changed
# ---------------------------------------------------------------------------
# Committed work on this branch, plus everything not committed yet. The second
# half is the point of running locally at all: the change being tested is
# usually still in the working tree, and a diff against the merge base alone
# would miss the edit that is about to break something. Untracked files count
# too - a new test file is exactly the kind of thing that decides a suite.
changed_files() {
local base="$1"
{
if [ -n "$base" ]; then
git diff --name-only "$base"...HEAD
fi
git diff --name-only HEAD
git ls-files --others --exclude-standard
} | sort -u
}
# origin/main if it is there, main if not, and nothing if neither - which the
# caller turns into "run everything" rather than into an empty file list. A
# fresh clone with no main, or a repository mid-rebase, must not read as "no
# files changed, nothing to do".
resolve_base() {
if [ -n "$base_ref" ]; then
echo "$base_ref"
return 0
fi
local candidate
for candidate in origin/main main; do
if git rev-parse --verify --quiet "$candidate^{commit}" >/dev/null; then
echo "$candidate"
return 0
fi
done
return 0
}
# ---------------------------------------------------------------------------
# What each suite covers
# ---------------------------------------------------------------------------
# The `paths:` handed to .github/actions/changed-paths in a suite's workflow.
#
# Parsed out of the YAML rather than grepped for, for the reason
# backend/tests/test_ci_scope.py gives about the same parse: the word `paths`
# appears twice in those files, once in the comment explaining why the trigger
# deliberately has NOT got a paths filter, which is the opposite decision and
# a confusing thing to match by accident.
scope_for_workflow() {
local workflow="$1"
python3 - "$workflow" <<'PY' 2>/dev/null || true
import sys
import yaml
with open(sys.argv[1]) as handle:
workflow = yaml.safe_load(handle)
for job in workflow["jobs"].values():
for step in job.get("steps", []):
if ".github/actions/changed-paths" in str(step.get("uses", "")):
print(" ".join(str(step["with"]["paths"]).split()))
sys.exit(0)
PY
}
# The three test workflows carry a machine-readable scope; the settings suite
# does not, because settings-manifest.yml runs on every pull request by design
# (TESTING.md, "Repository settings"). Its reach is still exactly one
# directory - it reads .github/workflows/ and .github/expected-settings.yml and
# nothing else - so that prefix is written here rather than derived. It is the
# one hand-written entry in this file, and it is the one that cannot go stale
# in a way this script would hide: a suite whose whole subject is .github/
# cannot quietly start depending on something outside it.
SETTINGS_SCOPE=".github/"
suite_names=(client backend pipeline settings)
suite_workflow_client=".github/workflows/client-tests.yml"
suite_workflow_backend=".github/workflows/backend-tests.yml"
suite_workflow_pipeline=".github/workflows/pipeline-tests.yml"
suite_workflow_settings=""
scope_for_suite() {
local suite="$1"
if [ "$suite" = "settings" ]; then
echo "$SETTINGS_SCOPE"
return
fi
local workflow_var="suite_workflow_${suite}"
scope_for_workflow "${!workflow_var}"
}
# Which of `files` sit under any prefix in `scope`, as literal prefixes.
matched_files() {
local files="$1" scope="$2" file prefix
while IFS= read -r file; do
[ -n "$file" ] || continue
for prefix in $scope; do
case "$file" in
"$prefix"*) printf '%s\n' "$file"; break ;;
esac
done
done <<< "$files"
}
# ---------------------------------------------------------------------------
# Deciding
# ---------------------------------------------------------------------------
base="$(resolve_base)"
reason=""
selected=()
if $run_all; then
reason="--all"
selected=("${suite_names[@]}")
elif [ -z "$base" ]; then
reason="no base branch to compare against"
selected=("${suite_names[@]}")
else
files="$(changed_files "$base")"
if [ -z "$files" ]; then
# Nothing to test is a real answer, and a different one from "we could not
# tell". Reported rather than turned into a full run.
reason="nothing changed against $base"
else
for suite in "${suite_names[@]}"; do
scope="$(scope_for_suite "$suite")"
if [ -z "$scope" ]; then
# An unreadable scope is not evidence the suite is unnecessary.
echo "warning: could not read the scope list for the $suite suite - running it." >&2
selected+=("$suite")
continue
fi
# Matched as literal prefixes, not as patterns. `grep "^$prefix"` reads
# naturally and is wrong: every scope list here contains `.github/...`,
# whose leading dot is a regex wildcard, so it would also match a file
# called `xgithub/...`. Over-matching only ever adds a suite, so this
# would never have shown up as a failure - it would have shown up as
# this script quietly being less useful than it claims.
if [ -n "$(matched_files "$files" "$scope")" ]; then
selected+=("$suite")
fi
done
reason="changed against $base ($(printf '%s\n' "$files" | wc -l | tr -d ' ') files)"
fi
fi
echo "== $reason"
if [ ${#selected[@]} -eq 0 ]; then
echo "== nothing to run"
exit 0
fi
echo "== running: ${selected[*]}"
echo
if $list_only; then
# The files, not just the scope list. "Why is the backend suite running for
# a client-only change" has a real answer - one of the six contract modules
# it reads as text - and printing the scope list alone leaves the reader to
# find it by eye.
for suite in "${selected[@]}"; do
echo "$suite"
if [ -n "${files:-}" ]; then
matched_files "$files" "$(scope_for_suite "$suite")" | sed 's/^/ /'
else
echo " (everything - $reason)"
fi
done
exit 0
fi
# ---------------------------------------------------------------------------
# Running
# ---------------------------------------------------------------------------
selected_has() {
local needle="$1" item
for item in "${selected[@]}"; do
[ "$item" = "$needle" ] && return 0
done
return 1
}
step() {
local label="$1"; shift
local started=$SECONDS
echo "-- $label"
if ! "$@"; then
echo
echo "!! $label FAILED" >&2
exit 1
fi
echo " ok ($((SECONDS - started))s)"
}
# Suites run one at a time, each using every core internally rather than four
# suites fighting over them. Measured on a four-core machine: run concurrently,
# the three big suites took 100s, 104s and 209s; run one after another with the
# same cores each, 22s, 16s and the client's own pool. Contention is not a
# saving, and interleaved output from four suites is unreadable besides.
#
# `-n auto` rather than a fixed number so this is not tuned to the machine it
# was written on. pytest-xdist reads the physical core count; vitest's pool
# does the same thing for the client without being asked.
PYTEST_PARALLEL=(-n auto)
# Both Python suites put `--cov` in their pyproject addopts, so switching it
# off is an explicit flag rather than an omission.
PYTEST_COVERAGE=(--no-cov)
# A named package script rather than `npm exec -- vitest run`, which was the
# first version of this line and was quietly wrong. `npm --prefix client run`
# executes the script with the working directory set to client/; `npm --prefix
# client exec` does not, so vitest took the repository root as its own root,
# globbed a different set of files and loaded none of client/vite.config.ts -
# no jsdom, no src/test/setup.ts. It reported a pass, on the wrong suite.
# Caught by running this script rather than by reading it, which is the whole
# argument for `--all` existing.
CLIENT_TEST=(npm --prefix client run test:nocov)
if $with_coverage; then
PYTEST_COVERAGE=()
CLIENT_TEST=(npm --prefix client test)
fi
# LINTERS AND FORMATTERS FIRST, ALL OF THEM, BEFORE ANY SUITE RUNS. This is
# the ordering CLAUDE.md asks for and the reason it asks: a quarter of every
# failure in this repository's CI history was formatting alone, and the job
# that catches it runs the formatter before the tests, so the suite never ran
# and the log said nothing about the change being made. Three seconds of ruff
# and prettier ahead of three minutes of tests turns that round trip into a
# line of output.
if selected_has pipeline; then
step "pipeline ruff check" python -m ruff check pipeline
step "pipeline ruff format" python -m ruff format --check pipeline
fi
if selected_has backend; then
step "backend ruff check" python -m ruff check backend
step "backend ruff format" python -m ruff format --check backend
fi
if selected_has settings; then
step "settings ruff check" python -m ruff check .github/tests
step "settings ruff format" python -m ruff format --check .github/tests
fi
if selected_has client; then
step "client lint" npm --prefix client run lint
step "client format:check" npm --prefix client run format:check
step "client typecheck" npm --prefix client run typecheck
fi
# Then the suites, cheapest first, so the common failure arrives soonest.
if selected_has settings; then
step "settings tests" python -m pytest .github/tests -q "${PYTEST_PARALLEL[@]}"
fi
if selected_has pipeline; then
step "pipeline tests" env -C pipeline python -m pytest -q "${PYTEST_PARALLEL[@]}" "${PYTEST_COVERAGE[@]}"
fi
if selected_has backend; then
step "backend tests" env -C backend python -m pytest -q "${PYTEST_PARALLEL[@]}" "${PYTEST_COVERAGE[@]}"
fi
if selected_has client; then
# The build is part of the client's checks rather than an extra: npm run
# build runs scripts/check-build-output.mjs, which is the only layer in this
# repository that can see the class of bug TESTING.md's item 19 describes -
# a suite that passes green while the shipped bundle draws a blank map.
step "client tests" "${CLIENT_TEST[@]}"
step "client build" npm --prefix client run build
fi
echo
echo "== all green: ${selected[*]}"