Setting the file. One moment.
E2e · Graphos Factory · apollographql/skills · Skills Docs
ContentsBack to the top of the page 10.20
Release
graphos-factory-core/scripts/e2e.sh
graphos-factory-core/scripts/ e2e.sh
Shell · 489 lines · 25 KB
15 # former per-case reset-and-reload. Two stubs whose
16 # `request` matcher is identical can then only ever be answered by one of
17 # them, WireMock's own tie-break deciding which; that is a fixture defect
18 # any flat-mounting runner downstream would also hit, so it must fail here
19 # first.
20 # Only the request journal and WireMock's scenario state reset between
21 # cases (`DELETE $ADMIN/requests`, `POST $ADMIN/scenarios/reset`; neither
22 # touches loaded mappings), so per-case stub-hit accounting is unaffected
23 # by every other case's stubs also being loaded. A case whose own stub
24 # declares a `requiredScenarioState` other than `Started` (references/
25 # testing.md — a WireMock Scenario, the only way to disambiguate a request
26 # that is identical by definition, e.g. an operation with no body) has that
27 # state set directly before its request fires. Every mapping must still be
28 # classifiable — by `metadata."x-cases"`, by a basename matching a case
29 # name (hyphens and underscores interchangeable), or by `metadata."x-shared"`
30 # — or the run fails before any case executes.
31 # Stub-hit accounting after each case: every upstream request matched a
32 # loaded stub and at least one was made, unless the .graphql declares
33 # `# expect-unmatched-upstream`. Every stub that answered must also be the
34 # case's own (its case list or shared.list): with every stub loaded at once,
35 # another case's stub can answer a request the connector got wrong (a
36 # dropped query parameter matching a sibling's `absent` matcher), and that
37 # must fail the case, as per-case loading did. The request
38 # journal names the stub that answered each request (`stubMapping.name`,
39 # the fixture's filename, set at load). A stub of the case's own marked
40 # `metadata."x-required": true` must also have answered at least one request,
41 # or the case fails naming it: a $batch case's lookup, which a
42 # planner that resolves the fields locally never calls.
43 #
44 # Upstream status, before the snapshot diff: a case declaring
45 # `# expect-upstream-status: CODE` fails unless one of its own stubs
46 # answered CODE. A router that redacts subgraph errors renders every error
47 # status alike, so the snapshot alone cannot tell a 401 case from a 404 one.
48 # A case without the directive whose stub answered a non-2xx status, calling
49 # a root field whose operation documents more than one, reports UNPROVEN:
50 # counted apart, never as a pass, and not a failure of the run.
51 #
52 # --only PATTERN restricts which cases actually run (each is the 3-file unit
53 # of tests/cases/NAME.graphql, its .expected.json, and its
54 # mapping) to those whose NAME contains PATTERN as a literal substring,
55 # hyphens and underscores interchangeable — same normalisation the mapping
56 # classifier below already uses. Classification still runs over every
57 # mapping regardless of --only, so a filtered run cannot mask an
58 # unclassifiable fixture. A PATTERN matching no case fails closed (exit 1)
59 # before WireMock or the router starts.
60 #
61 # Exit 127 = a tool is missing (java, curl, jq): the layer is `skipped` in
62 # evidence with that reason, never `pass`. Exit 3 = the user has not accepted
63 # the Elastic License v2 of the composition plugin and the Router
64 # (APOLLO_ELV2_LICENSE, see elv2.sh): the layer is `not_run` with that
65 # reason, never `pass`, and no Router is downloaded.
66 set -euo pipefail
67
68 WORKSPACE = "."
69 GENERATE = 0
70 ONLY = ""
71 while [ $# -gt 0 ]; do
72 case " $1 " in
73 --generate ) GENERATE = 1 ; shift ;;
74 --only )
75 if [ $# -lt 2 ] || [ -z " $2 " ]; then echo "e2e: --only requires a non-empty pattern" >&2 ; exit 1 ; fi
76 ONLY = " $2 " ; shift 2 ;;
77 *) WORKSPACE = " $1 " ; shift ;;
78 esac
79 done
80 WIREMOCK_VERSION = "${ WIREMOCK_VERSION :- 3 . 13 . 2 }"
81 ROUTER_VERSION = "${ ROUTER_VERSION :- 2 . 17 . 0 }"
82 WIREMOCK_PORT = "${ WIREMOCK_PORT :- 8080 }"
83 ROUTER_PORT = "${ ROUTER_PORT :- 4000 }"
84 ROUTER_HEALTH_PORT = "${ ROUTER_HEALTH_PORT :- 8088 }"
85 ADMIN = "http://localhost:${ WIREMOCK_PORT }/__admin"
86
87 for tool in java curl jq ; do
88 command -v " $tool " > /dev/null 2>&1 || { echo "e2e: $tool is not installed" >&2 ; exit 127 ; }
89 done
90 command -v rover > /dev/null 2>&1 || { echo "e2e: rover is not installed" >&2 ; exit 127 ; }
91
92 # shellcheck source=SCRIPTDIR/cache.sh
93 . "$( dirname " $0 ")/cache.sh"
94 CACHE = " $GRAPHOS_FACTORY_CORE_CACHE_DIR "
95 # The product binary, and a refusal (exit 78) when it is older than
96 # the version these scripts pin — see resolve-bin.sh for the order and why.
97 # shellcheck source=SCRIPTDIR/resolve-bin.sh
98 . "$( dirname " $0 ")/resolve-bin.sh"
99 # shellcheck source=SCRIPTDIR/elv2.sh
100 . "$( dirname " $0 ")/elv2.sh"
101 elv2_require e2e
102
103 OUT = "$( mktemp -d )"
104 WIREMOCK_PID = ""
105 ROUTER_PID = ""
106 cleanup () {
107 if [ -n " $ROUTER_PID " ]; then kill " $ROUTER_PID " 2> /dev/null || true ; fi
108 if [ -n " $WIREMOCK_PID " ]; then kill " $WIREMOCK_PID " 2> /dev/null || true ; fi
109 rm -rf " $OUT "
110 }
111 trap cleanup EXIT
112
113 CASES_DIR = " $WORKSPACE /tests/cases"
114 MAPPINGS_DIR = " $WORKSPACE /tests/fixtures/mappings"
115 [ -f " $WORKSPACE /tests/router.yaml" ] || { echo "e2e: $WORKSPACE /tests/router.yaml is missing" >&2 ; exit 1 ; }
116 shopt -s nullglob
117 case_files = ( " $CASES_DIR "/ * .graphql )
118 mappings = ( " $MAPPINGS_DIR "/ * .json )
119 shopt -u nullglob
120 # Byte order, never the shell's locale: a glob and `sort` collate
121 # by LC_COLLATE, and under en_US.UTF-8 `X_minimal.json` sorts before
122 # `X.json` while under C it sorts after. Stubs load in this order, and
123 # WireMock answers a request two stubs match with the one loaded last, so
124 # the locale once decided which stub answered. Only these sorts are pinned:
125 # exporting LC_ALL=C would reach WireMock's JVM, whose default charset
126 # before Java 18 follows it.
127 if [ ${ # case_files[ @ ]} -gt 0 ]; then
128 sorted = (); while IFS = read -r f ; do sorted += ( " $f " ); done < <( printf '%s\n' "${ case_files [ @ ]}" | LC_ALL = C sort )
129 case_files = ( "${ sorted [ @ ]}" )
130 fi
131 if [ ${ # mappings[ @ ]} -gt 0 ]; then
132 sorted = (); while IFS = read -r f ; do sorted += ( " $f " ); done < <( printf '%s\n' "${ mappings [ @ ]}" | LC_ALL = C sort )
133 mappings = ( "${ sorted [ @ ]}" )
134 fi
135 [ ${ # case_files[ @ ]} -gt 0 ] || { echo "e2e: no tests/cases/*.graphql" >&2 ; exit 1 ; }
136
137 run_cases = ( "${ case_files [ @ ]}" )
138 if [[ -n " $ONLY " ]]; then
139 run_cases = ()
140 norm_only = "$( printf '%s' " $ONLY " | tr '-' '_')"
141 for f in "${ case_files [ @ ]}" ; do
142 norm_b = "$( printf '%s' "$( basename "${ f % . graphql }")" | tr '-' '_')"
143 [[ " $norm_b " == * " $norm_only " * ]] && run_cases += ( " $f " )
144 done
145 if [[ ${ # run_cases[ @ ]} -eq 0 ]]; then
146 echo "e2e: --only ' $ONLY ' matched no case in $CASES_DIR " >&2
147 exit 1
148 fi
149 fi
150
151 for port in " $WIREMOCK_PORT " " $ROUTER_PORT " " $ROUTER_HEALTH_PORT " ; do
152 if curl -s "http://localhost: $port /" > /dev/null 2>&1 ; then
153 echo "e2e: port $port is already in use" >&2
154 exit 1
155 fi
156 done
157
158 # ── Toolchain: fetch once into the cache ─────────────────────────────────────
159 mkdir -p " $CACHE "
160 WIREMOCK_JAR = " $CACHE /wiremock-standalone- $WIREMOCK_VERSION .jar"
161 ROUTER_BIN = " $CACHE /router- $ROUTER_VERSION "
162 if [ ! -f " $WIREMOCK_JAR " ]; then
163 echo "e2e: downloading WireMock $WIREMOCK_VERSION "
164 curl -sfL "https://repo1.maven.org/maven2/org/wiremock/wiremock-standalone/ $WIREMOCK_VERSION /wiremock-standalone- $WIREMOCK_VERSION .jar" -o " $WIREMOCK_JAR "
165 fi
166 # The Router is ELv2-licensed too: this download sits after elv2_require.
167 if [ ! -x " $ROUTER_BIN " ]; then
168 echo "e2e: downloading Apollo Router $ROUTER_VERSION "
169 case "$( uname -s )-$( uname -m )" in
170 Linux-x86_64 ) platform = "x86_64-unknown-linux-gnu" ;;
171 Linux-aarch64 ) platform = "aarch64-unknown-linux-gnu" ;;
172 Darwin-arm64 ) platform = "aarch64-apple-darwin" ;;
173 Darwin-x86_64 ) platform = "x86_64-apple-darwin" ;;
174 *) echo "e2e: unsupported platform $( uname -s )-$( uname -m )" >&2 ; exit 127 ;;
175 esac
176 curl -sfL "https://github.com/apollographql/router/releases/download/v $ROUTER_VERSION /router-v $ROUTER_VERSION - $platform .tar.gz" \
177 | tar xzf - -C " $OUT " --strip-components=1 dist/router
178 mv " $OUT /router" " $ROUTER_BIN "
179 chmod +x " $ROUTER_BIN "
180 fi
181
182 # ── Classify every mapping before anything starts ────────────────────────────
183 INDEX = " $OUT /index"
184 mkdir -p " $INDEX "
185 for q in "${ case_files [ @ ]}" ; do
186 b = "$( basename " $q " .graphql)"
187 printf '%s\t%s\n' "$( printf '%s' " $b " | tr '-' '_')" " $b "
188 done > " $INDEX /cases.tsv"
189 case_for_name () {
190 awk -F '\t' -v n="$( printf '%s' " $1 " | tr '-' '_')" '$1==n{print $2; exit}' " $INDEX /cases.tsv"
191 }
192 unclassified = ""
193 for m in "${ mappings [ @ ]}" ; do
194 if jq -e '.persistent == true' " $m " > /dev/null 2>&1 ; then
195 echo "e2e: $( basename " $m ") sets \" persistent \" : true — WireMock would restore it after every reset and it would answer other cases' requests. Remove it." >&2
196 exit 1
197 fi
198 # A scenario's state is set through `PUT $ADMIN/scenarios/<name>/state`,
199 # and WireMock 3.13.2 does not decode a percent-encoded name there
200 # (measured: `%20`, `%23`, `%C3%A9` all 404). A name that needs encoding
201 # could never be set, so refuse it before any case runs.
202 scenario = "$( jq -r '.scenarioName // empty' " $m ")"
203 # `.` and `..` alone are refused too: curl collapses them as path
204 # dot-segments, so the PUT would never reach the scenario.
205 if [[ -n " $scenario " && ( ! " $scenario " =~ ^[A - Za - z0 - 9 ._ ~- ] + $ || " $scenario " == "." || " $scenario " == ".." ) ]]; then
206 echo "e2e: $( basename " $m ") scenarioName ' $scenario ' cannot be addressed in WireMock's scenario admin API (it does not decode URL-encoding, and '.' or '..' alone is a path dot-segment) — use only letters, digits, '.', '_', '~' and '-', and not '.' or '..' alone" >&2
207 exit 1
208 fi
209 has_xcases = "$( jq -r '(.metadata // {}) | has("x-cases")' " $m ")"
210 xcases = "$( jq -r '(.metadata["x-cases"] // [])[]' " $m ")"
211 xshared = "$( jq -r '.metadata["x-shared"] // false' " $m ")"
212 if [[ " $has_xcases " == "true" && -z " $xcases " ]]; then
213 continue # recorded for an operation with no case yet: classified, loaded for nothing
214 fi
215 if [[ -n " $xcases " ]]; then
216 while IFS = read -r c ; do
217 target = "$( case_for_name " $c ")"
218 [[ -n " $target " ]] || { echo "e2e: $( basename " $m ") metadata.x-cases names unknown case ' $c '" >&2 ; exit 1 ; }
219 echo " $m " >> " $INDEX /case- $target .list"
220 done <<< " $xcases "
221 elif [[ " $xshared " == "true" ]]; then
222 echo " $m " >> " $INDEX /shared.list"
223 else
224 target = "$( case_for_name "$( basename " $m " .json)")"
225 if [[ -n " $target " ]]; then echo " $m " >> " $INDEX /case- $target .list" ; else unclassified = " $unclassified $( basename " $m ")" ; fi
226 fi
227 done
228 if [[ -n " $unclassified " ]]; then
229 echo "e2e: mappings not classifiable to any case: $unclassified " >&2
230 echo "e2e: name the file after its case, list cases in metadata. \" x-cases \" , or set metadata. \" x-shared \" : true" >&2
231 exit 1
232 fi
233
234 # Every classified mapping (shared plus every case's), once, deduplicated —
235 # a mapping named in more than one case's `metadata."x-cases"` appears in
236 # more than one list file but must still load only once.
237 # A case whose own stub(s) declare `requiredScenarioState` (WireMock
238 # Scenarios — the only way to answer two byte-identical requests
239 # differently once every stub loads together) must be run from exactly
240 # that state; scenarios reset to `Started` before every case, so a case
241 # needing anything else sets it directly, derived from its own stub's own
242 # `requiredScenarioState` — no separate case-file declaration exists or is
243 # needed (references/testing.md).
244 set_scenario_states () {
245 local list = " $INDEX /case- $1 .list" m name state
246 [[ -f " $list " ]] || return 0
247 while IFS = read -r m ; do
248 name = "$( jq -r '.scenarioName // empty' " $m ")"
249 state = "$( jq -r '.requiredScenarioState // empty' " $m ")"
250 if [[ -n " $name " && -n " $state " && " $state " != "Started" ]]; then
251 # The name is a URL path segment here; it needs no encoding because
252 # names outside [A-Za-z0-9._~-] are refused before any case runs.
253 curl -sf -X PUT " $ADMIN /scenarios/ $name /state" -H 'Content-Type: application/json' \
254 -d "$( jq -n --arg s " $state " '{state: $s}')" > /dev/null \
255 || { echo "e2e: could not set scenario $name to $state " >&2 ; return 1 ; }
256 fi
257 done < " $list "
258 }
259
260 load_all_stubs () {
261 curl -sf -X POST " $ADMIN /reset" > /dev/null || return 1
262 local list m loaded = ()
263 for list in " $INDEX "/shared.list " $INDEX "/case-*.list ; do
264 [[ -f " $list " ]] || continue
265 while IFS = read -r m ; do loaded += ( " $m " ); done < " $list "
266 done
267 while IFS = read -r m ; do
268 [[ -n " $m " ]] || continue
269 # Name each stub after its file: WireMock echoes `name` in the request
270 # journal, which is the only stable way to see which fixture answered.
271 jq -c --arg n "$( basename " $m ")" '.name = $n' " $m " \
272 | curl -sf -X POST " $ADMIN /mappings" -H 'Content-Type: application/json' --data-binary @- > /dev/null \
273 || { echo "e2e: WireMock rejected stub $m " >&2 ; return 1 ; }
274 done < <( printf '%s\n' "${ loaded [ @ ] - }" | LC_ALL = C sort -u )
275 }
276
277 # ── Render, credential, compose ──────────────────────────────────────────────
278 # The rendered router config carries this run's ports, so two suites can run
279 # side by side (WIREMOCK_PORT / ROUTER_PORT / ROUTER_HEALTH_PORT) without
280 # touching the committed tests/router.yaml.
281 eval "$( " $RC " render " $WORKSPACE " --out " $OUT " --wiremock-port " $WIREMOCK_PORT " --router-port " $ROUTER_PORT " --health-port " $ROUTER_HEALTH_PORT ")"
282 [ -n "${ ROUTER_CONFIG :- }" ] || { echo "e2e: render produced no ROUTER_CONFIG" >&2 ; exit 1 ; }
283
284 # Fixtures assert the forwarded credential, so the router must see one. The
285 # env var name comes from AUTH_EXPR's test_default; the value is fixed so
286 # fixtures can match `… test-token` exactly.
287 AUTH_VAR = "$( " $RC " auth-env " $WORKSPACE ")"
288 if [ -n " $AUTH_VAR " ]; then
289 # Always the test token, even when the real credential is in the
290 # environment (CI carries it for the live layer): WireMock's fixtures match
291 # `… test-token` exactly, and a real secret must never reach a stub server
292 # or its request log.
293 export " $AUTH_VAR =test-token"
294 fi
295
296 SUPERGRAPH = " $OUT /supergraph.graphql"
297 NO_COLOR = 1 rover supergraph compose --config " $COMPOSE_CONFIG " > " $SUPERGRAPH " 2> " $OUT /compose.err" \
298 || { cat " $OUT /compose.err" >&2 ; echo "e2e: compose failed" >&2 ; exit 1 ; }
299
300 # ── Start WireMock and the router ────────────────────────────────────────────
301 WM_ROOT = " $OUT /wiremock"
302 mkdir -p " $WM_ROOT /mappings" " $WM_ROOT /__files"
303 [ -d " $WORKSPACE /tests/fixtures/__files" ] && cp -r " $WORKSPACE /tests/fixtures/__files/." " $WM_ROOT /__files/"
304 java -jar " $WIREMOCK_JAR " --port " $WIREMOCK_PORT " --root-dir " $WM_ROOT " --disable-banner > " $OUT /wiremock.log" 2>&1 &
305 WIREMOCK_PID = $!
306 deadline = $(( $(date +%s ) + 30 ))
307 until curl -sf " $ADMIN /" > /dev/null 2>&1 ; do
308 [ "$( date +%s)" -ge " $deadline " ] && { echo "e2e: WireMock did not start" ; tail -20 " $OUT /wiremock.log" >&2 ; exit 1 ; }
309 sleep 0.5
310 done
311
312 env -u OTEL_EXPORTER_OTLP_ENDPOINT " $ROUTER_BIN " --supergraph " $SUPERGRAPH " --config " $ROUTER_CONFIG " > " $OUT /router.log" 2>&1 &
313 ROUTER_PID = $!
314 # A large supergraph takes the router longer than 30s to load (Databricks:
315 # 152.6s to healthy); E2E_ROUTER_START_SECONDS raises the wait, the default
316 # stays 30.
317 router_wait = ${E2E_ROUTER_START_SECONDS :- 30}
318 router_started = $( date +%s )
319 deadline = $(( router_started + router_wait ))
320 until curl -sf "http://localhost:${ ROUTER_HEALTH_PORT }/health" > /dev/null 2>&1 ; do
321 # A router that exits (a bad config, a supergraph it rejects) is not waited
322 # out to the deadline.
323 kill -0 " $ROUTER_PID " 2> /dev/null || { echo "e2e: router exited before it reported healthy" ; tail -30 " $OUT /router.log" >&2 ; exit 1 ; }
324 [ "$( date +%s)" -ge " $deadline " ] && { echo "e2e: router did not start within ${ router_wait }s (set E2E_ROUTER_START_SECONDS to wait longer)" ; tail -30 " $OUT /router.log" >&2 ; exit 1 ; }
325 sleep 1
326 done
327 echo "e2e: WireMock $WIREMOCK_VERSION (java), Apollo Router $ROUTER_VERSION , $( rover --version | head -1 ), ${ # run_cases [ @ ]} cases"
328 if [[ -n " $ONLY " ]]; then
329 echo "e2e: --only ' $ONLY ' matched ${ # run_cases [ @ ]} of ${ # case_files [ @ ]} case(s)"
330 fi
331 load_all_stubs || { echo "e2e: could not load stubs" >&2 ; exit 1 ; }
332 # One line per included operation, tab-separated, `-` for an empty column:
333 # root field, the non-2xx statuses it documents, the ones a recorded run
334 # already exercised (unused here), and the operation key (`get:/repos/{o}`).
335 ERROR_STATUSES = "$( " $RC " error-statuses " $WORKSPACE ")" || { echo "e2e: could not read the documented error statuses" >&2 ; exit 1 ; }
336 # The statuses the journal shows answering the operation's OWN request: a
337 # matched entry with the operation's method whose URL path (query dropped)
338 # ends with its path template, segment by segment, a {parameter} standing
339 # for exactly one non-empty segment. A status served to any other request
340 # in the case (a nested lookup, a side call) is not the operation's.
341 # shellcheck disable=SC2016
342 OWN_STATUSES_JQ = '
343 def segs: split("/");
344 def ends_with_template($t):
345 segs as $p | ($t | ltrimstr("/") | segs) as $q
346 | ($p | length) >= ($q | length)
347 and ([range(0; $q | length) as $i
348 | ($p[($p | length) - ($q | length) + $i]) as $ps
349 | (if ($q[$i] | test("^\\{.*\\}$")) then ($ps | length) > 0 else $ps == $q[$i] end)]
350 | all);
351 [.requests[] | select(.wasMatched)
352 | select((.request.method | ascii_upcase) == ($m | ascii_upcase))
353 | select((.request.url | split("?")[0]) | ends_with_template($t))
354 | .response.status | tostring] | unique | join(",")'
355
356 # ── Cases ────────────────────────────────────────────────────────────────────
357 PASS = 0 ; FAIL = 0 ; UNPROVEN = 0
358 for query_file in "${ run_cases [ @ ]}" ; do
359 name = "$( basename "${ query_file % . graphql }")"
360 expected = "${ query_file % . graphql }.expected.json"
361
362 # Only the journal and scenario state reset — every stub loaded above
363 # stays in place for every case: every stub is mounted flat.
364 if ! curl -sf -X POST " $ADMIN /scenarios/reset" > /dev/null ; then echo "FAIL: $name — could not reset scenario state" ; FAIL = $(( FAIL+1 )); continue ; fi
365 if ! curl -sf -X DELETE " $ADMIN /requests" > /dev/null ; then echo "FAIL: $name — could not reset the request journal" ; FAIL = $(( FAIL+1 )); continue ; fi
366 if ! set_scenario_states " $name " ; then echo "FAIL: $name — could not set its stub's scenario state" ; FAIL = $(( FAIL+1 )); continue ; fi
367
368 actual = "$( curl -sf -X POST "http://localhost:${ ROUTER_PORT }/" -H 'Content-Type: application/json' \
369 -d "$( jq -n --rawfile query " $query_file " '{query: $query}')")" || { echo "FAIL: $name — router request failed" ; FAIL = $(( FAIL+1 )); continue ; }
370
371 # One read of each journal per case, each guarded, so an admin API that
372 # fails fails this case with a line saying so instead of aborting the run.
373 journal = "$( curl -sf " $ADMIN /requests")" || { echo "FAIL: $name — could not read the request journal" ; FAIL = $(( FAIL+1 )); continue ; }
374 unmatched_journal = "$( curl -sf " $ADMIN /requests/unmatched")" || { echo "FAIL: $name — could not read the unmatched-request journal" ; FAIL = $(( FAIL+1 )); continue ; }
375 unmatched = "$( jq '.requests | length' <<< " $unmatched_journal ")"
376 matched = "$( jq '[.requests[] | select(.wasMatched)] | length' <<< " $journal ")"
377 if grep -q 'expect-unmatched-upstream' " $query_file " ; then
378 if [[ " $unmatched " == "0" ]]; then echo "FAIL: $name — expected an unmatched upstream request, saw none (matched: $matched )" ; FAIL = $(( FAIL+1 )); continue ; fi
379 else
380 if [[ " $unmatched " != "0" ]]; then
381 echo "FAIL: $name — $unmatched upstream request(s) matched no loaded stub:"
382 jq -r '.requests[] | " \(.method) \(.url)\(if .body != "" then "\n body: \(.body)" else "" end)"' <<< " $unmatched_journal "
383 FAIL = $(( FAIL+1 )); continue
384 fi
385 if [[ " $matched " == "0" ]]; then echo "FAIL: $name — case made no upstream request (dead stubs?)" ; FAIL = $(( FAIL+1 )); continue ; fi
386 fi
387 own = ""
388 for list in " $INDEX /shared.list" " $INDEX /case- $name .list" ; do
389 [[ -f " $list " ]] || continue
390 while IFS = read -r m ; do own += "$( basename " $m ")"$' \n ' ; done < " $list "
391 done
392 foreign = "$( jq -r '.requests[] | select(.wasMatched) | (.stubMapping.name // "<unnamed stub>")' <<< " $journal " \
393 | while IFS = read -r n; do grep -qxF -- " $n " <<< " $own " || echo " $n "; done | LC_ALL = C sort -u | paste -sd, -)"
394 if [[ -n " $foreign " ]]; then
395 echo "FAIL: $name — answered by another case's stub: $foreign (the connector's request matched a fixture this case does not own)"
396 FAIL = $(( FAIL+1 )); continue
397 fi
398 # A stub of the case's own marked `metadata."x-required": true` must have
399 # answered at least one request: "some stub matched" cannot tell a $batch
400 # case whose lookup ran from one the planner resolved without it.
401 answered = "$( jq -r '.requests[] | select(.wasMatched) | (.stubMapping.name // empty)' <<< " $journal " | LC_ALL = C sort -u )"
402 uncalled = ""
403 if [[ -f " $INDEX /case- $name .list" ]]; then
404 while IFS = read -r m ; do
405 if jq -e '.metadata["x-required"] == true' " $m " > /dev/null 2>&1 \
406 && ! grep -qxF -- "$( basename " $m ")" <<< " $answered " ; then
407 uncalled = " $uncalled $( basename " $m ")"
408 fi
409 done < " $INDEX /case- $name .list"
410 fi
411 if [[ -n " $uncalled " ]]; then
412 echo "FAIL: $name — required stub(s) never called: $uncalled (metadata. \" x-required \" : the case passes only when the router sends that request)"
413 FAIL = $(( FAIL+1 )); continue
414 fi
415 # Every answering stub is the case's own by now. A status counts for an
416 # operation only when the journal shows it answering that operation's own
417 # request: the statuses a nested lookup or side call was served
418 # belong to no operation under test. `OWN` holds one line per operation
419 # whose root field the case calls: key, own statuses, documented statuses.
420 served = "$( jq -r '[.requests[] | select(.wasMatched) | .response.status] | unique | map(tostring) | join(",")' <<< " $journal ")"
421 OWN = ""
422 while IFS = $' \t ' read -r field statuses _executed opkey ; do
423 [[ -n " $field " && -n " $opkey " ]] || continue
424 grep -qw -- " $field " " $query_file " || continue
425 own = "$( jq -r --arg m "${ opkey %%:* }" --arg t "${ opkey #*: }" " $OWN_STATUSES_JQ " <<< " $journal ")" \
426 || { own = "" ; echo "e2e: could not read $opkey 's own statuses from the journal" >&2 ; }
427 OWN += " $opkey "$' \t '"${ own :- - }"$' \t '" $statuses "$' \n '
428 done <<< " $ERROR_STATUSES "
429 expect_status = "$( sed -n 's/^#[[:space:]]*expect-upstream-status:[[:space:]]*\([0-9][0-9][0-9]\).*/\1/p' " $query_file " | head -1 )"
430 unproven = ""
431 credit = ""
432 if [[ -n " $expect_status " ]]; then
433 hit = ""
434 while IFS = $' \t ' read -r opkey own _documented ; do
435 [[ -n " $opkey " ]] || continue
436 if grep -qxF -- " $expect_status " <<< "${ own // , / $' \n '}" ; then hit = 1 ; credit += " $opkey $expect_status "$' \n ' ; fi
437 done <<< " $OWN "
438 if [[ -z " $OWN " ]]; then
439 # No root field of the case names an included operation: nothing to
440 # attribute a status to, so the case's own stubs are all there is.
441 if grep -qxF -- " $expect_status " <<< "${ served // , / $' \n '}" ; then hit = 1 ; fi
442 fi
443 if [[ -z " $hit " ]]; then
444 answered = "$( awk -F '\t' 'NF && $1 != "" {printf "%s answered %s; ", $1, ($2 == "-" ? "nothing" : $2)}' <<< " $OWN ")"
445 echo "FAIL: $name — expected the operation's own request to answer $expect_status (expect-upstream-status); ${ answered :- its stubs answered ${ served :- nothing }}${ served : +( every request in the case : $served )}"
446 FAIL = $(( FAIL+1 )); continue
447 fi
448 else
449 while IFS = $' \t ' read -r opkey own documented ; do
450 [[ -n " $opkey " && " $own " != "-" ]] || continue
451 nonok = "$( tr ',' '\n' <<< " $own " | grep -E '^[13-9][0-9][0-9]$' | paste -sd, - || true )"
452 [[ -n " $nonok " ]] || continue
453 if [[ " $documented " == * , * ]]; then
454 unproven = " $opkey 's own request was answered $nonok , and it documents $documented : add \` # expect-upstream-status: CODE \` "
455 break
456 fi
457 for c in ${nonok // , / }; do credit += " $opkey $c "$' \n ' ; done
458 done <<< " $OWN "
459 [[ -z " $unproven " ]] || credit = ""
460 fi
461
462 if [[ " $GENERATE " -eq 1 ]]; then
463 echo " $actual " | jq -S . > " $expected "
464 echo "WROTE: $( basename " $expected ")" ; PASS = $(( PASS+1 ))
465 [[ -z " $unproven " ]] || echo " unproven: $unproven "
466 elif [[ ! -f " $expected " ]]; then
467 echo "FAIL: $name — no expected file (run with --generate, then audit the snapshot)" ; FAIL = $(( FAIL+1 ))
468 elif diff <( echo " $actual " | jq -S .) <( jq -S . " $expected ") > /dev/null ; then
469 if [[ -n " $unproven " ]]; then
470 echo "UNPROVEN: $name — $unproven " ; UNPROVEN = $(( UNPROVEN+1 ))
471 else
472 echo "PASS: $name " ; PASS = $(( PASS+1 ))
473 # What this passing case proved, for evidence's per-status coverage:
474 # a status served to an operation's own request, never to a side call.
475 while read -r opkey code ; do
476 if [[ -n " $opkey " ]]; then echo "SERVED: $name $opkey $code " ; fi
477 done <<< " $credit "
478 fi
479 else
480 echo "FAIL: $name " ; diff <( echo " $actual " | jq -S .) <( jq -S . " $expected ") || true ; FAIL = $(( FAIL+1 ))
481 fi
482 done
483
484 if [[ " $GENERATE " -eq 1 ]]; then
485 echo "e2e: $PASS expected files written, $FAIL failed — audit every snapshot for \" redacted \" , \" valueCompletion \" and errors before committing"
486 else
487 echo "e2e: $PASS passed, $FAIL failed$([[ " $UNPROVEN " -eq 0 ]] || echo ", $UNPROVEN unproven")"
488 fi
489 [ " $FAIL " -eq 0 ]