-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathtest-docs-preview-mapper.sh
More file actions
executable file
·503 lines (446 loc) · 20.3 KB
/
Copy pathtest-docs-preview-mapper.sh
File metadata and controls
executable file
·503 lines (446 loc) · 20.3 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
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
#!/bin/bash
# Regression tests for the path-mapping logic in docs-preview.yaml.
# The mapper converts a repo-relative docs path into the URL path
# used by the docs site preview. Five distinct branches exist in the
# case block; every branch must be covered here.
#
# Also covers the other logic-dense pieces of docs-preview.yaml:
# extracting page paths from docs/manifest.json, filtering the PR's
# changed files, intersecting the two into the eligible set, parsing
# checkbox state out of the rendered checklist, and the checked-state
# carryover. Where the workflow runs jq, these tests run the same jq
# against fixtures rather than a shell mirror. Keep them in sync with
# docs-preview.yaml.
set -euo pipefail
# map_doc_path replicates the case block from docs-preview.yaml so
# we can exercise it without running the full workflow.
map_doc_path() {
local doc_path="$1"
local rel="${doc_path#docs/}"
local page_path
case "$rel" in
README.md)
page_path=""
;;
*)
local base dir stripped
base="$(basename "$rel")"
dir="$(dirname "$rel")"
if [ "$dir" = "." ]; then
dir=""
fi
case "$base" in
index.md | README.md)
page_path="$dir"
;;
*)
stripped="${base%.md}"
if [ -z "$dir" ]; then
page_path="$stripped"
else
page_path="${dir}/${stripped}"
fi
;;
esac
;;
esac
printf '%s' "$page_path"
}
failures=0
assert_maps_to() {
local input="$1"
local expected="$2"
local actual
actual="$(map_doc_path "$input")"
if [ "$actual" = "$expected" ]; then
echo "PASS: $input -> \"$expected\""
else
echo "FAIL: $input -> \"$actual\" (expected \"$expected\")"
failures=$((failures + 1))
fi
}
# Branch 1: top-level README maps to the docs root.
assert_maps_to "docs/README.md" ""
# Branch 2: nested index.md strips the filename, leaving the dir.
assert_maps_to "docs/install/index.md" "install"
# Branch 3: nested README.md behaves the same as index.md.
assert_maps_to "docs/admin/README.md" "admin"
# Branch 4: nested regular file strips .md and keeps the dir prefix.
assert_maps_to "docs/ai-coder/tasks.md" "ai-coder/tasks"
# Branch 5: top-level non-README file strips .md with no dir prefix.
assert_maps_to "docs/CHANGELOG.md" "CHANGELOG"
# Additional coverage for edge cases and deeper nesting.
assert_maps_to "docs/index.md" ""
assert_maps_to "docs/about/contributing/CONTRIBUTING.md" "about/contributing/CONTRIBUTING"
assert_maps_to "docs/admin/groups.md" "admin/groups"
assert_maps_to "docs/tutorials/best-practices/index.md" "tutorials/best-practices"
# normalize_manifest_path replicates the sed pipeline docs-preview.yaml
# runs over `jq -r '[.. | objects | select(has("path")) | .path]'`
# output. manifest.json paths are written either "./foo/bar.md" or
# "foo/bar.md" relative to docs/; both forms must normalize to the
# same "docs/foo/bar.md" so they compare directly against the
# filenames returned by the PR-files API.
normalize_manifest_path() {
printf '%s' "$1" | sed -E 's#^\./##; s#^#docs/#'
}
assert_normalizes_to() {
local input="$1"
local expected="$2"
local actual
actual="$(normalize_manifest_path "$input")"
if [ "$actual" = "$expected" ]; then
echo "PASS: normalize($input) -> \"$expected\""
else
echo "FAIL: normalize($input) -> \"$actual\" (expected \"$expected\")"
failures=$((failures + 1))
fi
}
# Branch A: manifest path with the "./" prefix most entries use.
assert_normalizes_to "./about/screenshots.md" "docs/about/screenshots.md"
# Branch B: manifest path with no prefix, as some entries have (for
# example everything under reference/cli/ in the real manifest).
assert_normalizes_to "reference/cli/whoami.md" "docs/reference/cli/whoami.md"
# Branch C: top-level README, no subdirectory.
assert_normalizes_to "./README.md" "docs/README.md"
# parse_checkbox_line replicates the sed extraction docs-preview.yaml
# runs over the existing comment body to recover the *live* checked
# state a reviewer's clicks land in (GitHub persists a checkbox toggle
# as a comment-body edit). Emits "<x-or-space>\t<path>", matching the
# workflow's intermediate TSV format.
parse_checkbox_line() {
# shellcheck disable=SC2016 # backticks are literal Markdown code-span delimiters, not command substitution.
printf '%s\n' "$1" | grep -oE '^[[:space:]]*- \[[ xX]\] \[`[^`]+`\]' | sed -E 's/^[[:space:]]*- \[([ xX])\] \[`([^`]+)`\]/\1\t\2/' || true
}
assert_checkbox_parses_to() {
local input="$1"
local expected="$2"
local actual
actual="$(parse_checkbox_line "$input")"
if [ "$actual" = "$expected" ]; then
echo "PASS: parse_checkbox($input) -> \"$expected\""
else
echo "FAIL: parse_checkbox($input) -> \"$actual\" (expected \"$expected\")"
failures=$((failures + 1))
fi
}
# Branch A: a checked page.
# shellcheck disable=SC2016 # backtick-quoted path in the fixture is literal Markdown, not command substitution.
assert_checkbox_parses_to '- [x] [`docs/foo/bar.md`](https://coder.com/docs/@b/foo/bar)' "$(printf 'x\tdocs/foo/bar.md')"
# Branch B: an unchecked page.
# shellcheck disable=SC2016
assert_checkbox_parses_to '- [ ] [`docs/foo/baz.md`](https://coder.com/docs/@b/foo/baz)' "$(printf ' \tdocs/foo/baz.md')"
# Branch C: an uppercase X, which GitHub also renders as checked.
# shellcheck disable=SC2016
assert_checkbox_parses_to '- [X] [`docs/foo/qux.md`](https://coder.com/docs/@b/foo/qux)' "$(printf 'X\tdocs/foo/qux.md')"
# Branch D: a non-checklist line (prose, a header, the hidden markers)
# must not match at all.
assert_checkbox_parses_to '## Docs preview' ""
# decide_checked removed: round_trip_state below covers the carryover
# rule through the workflow's real jq, so the hand-written shell mirror
# only added a green check that guarded nothing.
# round_trip_state exercises the *actual* jq/grep/sed/base64 expressions
# from docs-preview.yaml end to end, which a hand-written shell mirror of
# the carryover rule could not: it drives the jq null-coalescing
# (// false, // null) and the base64 state marker directly. A path->sha
# map is encoded into the hidden marker, read back, the live checkbox
# glyphs are parsed, and the carryover jq decides each page's final
# checked state.
STATE_PREFIX='docs-preview-state:'
# Recovers the {path: sha} state map from the hidden marker, a faithful
# copy of the guarded block in docs-preview.yaml: decode under
# `2>/dev/null || true` and adopt the result only if it is non-empty and
# parses as a JSON object, else degrade to {} so a corrupt marker can't
# kill the run. The non-empty check keeps the guard's outcome the same on
# jq < 1.7, where `jq -e` exits 0 on empty input.
recover_old_state() {
local body="$1" b64 decoded
b64=$(printf '%s\n' "$body" | grep -oE "${STATE_PREFIX}[A-Za-z0-9+/=]+" | sed "s/^${STATE_PREFIX}//") || true
if [ -n "$b64" ]; then
decoded=$(printf '%s' "$b64" | base64 -d 2>/dev/null || true)
if [ -n "$decoded" ] && printf '%s' "$decoded" | jq -e 'type == "object"' >/dev/null 2>&1; then
printf '%s' "$decoded"
return
fi
fi
printf '{}'
}
# Recovers the {path: checked} map from the rendered checklist,
# replicating the grep|sed|jq pipeline in docs-preview.yaml.
recover_old_checked() {
# shellcheck disable=SC2016 # backticks are literal Markdown code-span delimiters, not command substitution.
printf '%s\n' "$1" |
grep -oE '^[[:space:]]*- \[[ xX]\] \[`[^`]+`\]' |
sed -E 's/^[[:space:]]*- \[([ xX])\] \[`([^`]+)`\]/\1\t\2/' |
jq -R -s '[splits("\n") | select(length > 0) | split("\t") | {(.[1]): (.[0] | test("x"; "i"))}] | add // {}'
}
# Runs the carryover jq from docs-preview.yaml over the recovered maps.
decide_rows() {
jq -n \
--argjson eligible "$1" \
--argjson old_state "$2" \
--argjson old_checked "$3" \
'[
$eligible[] | . as $f |
($old_state[$f.filename] // null) as $prev_sha |
(if $prev_sha != null and $prev_sha == $f.sha
then ($old_checked[$f.filename] // false)
else false
end) as $checked |
{filename: $f.filename, sha: $f.sha, checked: $checked}
] | sort_by(.filename)' | jq -c .
}
assert_round_trip_state() {
local old_state_json='{"docs/a.md":"sha1","docs/b.md":"sha1","docs/c.md":"sha1","docs/e.md":"sha1"}'
local state_b64
state_b64=$(printf '%s' "$old_state_json" | base64 -w0)
# A rendered comment body with the hidden state marker: a.md checked,
# b.md and c.md unchecked, and no checklist line for e.md (it is in
# the state marker but absent from the list).
local body
# shellcheck disable=SC2016 # backtick-quoted paths are literal Markdown.
body=$(printf '%s\n' \
'## Docs preview' \
'' \
'- [x] [`docs/a.md`](https://coder.com/docs/@b/a)' \
'- [ ] [`docs/b.md`](https://coder.com/docs/@b/b)' \
'- [x] [`docs/c.md`](https://coder.com/docs/@b/c)' \
'<!-- docs-preview -->' \
"<!-- ${STATE_PREFIX}${state_b64} -->")
# a.md: sha unchanged, was checked -> stays checked.
# b.md: sha unchanged, was unchecked -> stays unchecked.
# c.md: sha changed, was checked -> resets to unchecked.
# d.md: brand-new, absent from state -> // null -> unchecked.
# e.md: sha unchanged, absent from list -> // false -> unchecked.
local eligible_json='[{"filename":"docs/a.md","sha":"sha1"},{"filename":"docs/b.md","sha":"sha1"},{"filename":"docs/c.md","sha":"sha2"},{"filename":"docs/d.md","sha":"sha9"},{"filename":"docs/e.md","sha":"sha1"}]'
local rec_state rec_checked actual expected
rec_state=$(recover_old_state "$body")
rec_checked=$(recover_old_checked "$body")
actual=$(decide_rows "$eligible_json" "$rec_state" "$rec_checked")
expected='[{"filename":"docs/a.md","sha":"sha1","checked":true},{"filename":"docs/b.md","sha":"sha1","checked":false},{"filename":"docs/c.md","sha":"sha2","checked":false},{"filename":"docs/d.md","sha":"sha9","checked":false},{"filename":"docs/e.md","sha":"sha1","checked":false}]'
if [ "$actual" = "$expected" ]; then
echo "PASS: round_trip_state carryover"
else
echo "FAIL: round_trip_state carryover -> $actual (expected $expected)"
failures=$((failures + 1))
fi
}
assert_round_trip_state
# The malformed-marker path the decode guard added must recover to {} with
# the run surviving. Feed markers that clear the charset grep but fail the
# decode or the object-type gate.
assert_marker_recovers() {
local marker="$1" expected="$2" desc="$3" body actual
body=$(printf '## Docs preview\n<!-- docs-preview -->\n<!-- %s%s -->' "$STATE_PREFIX" "$marker")
actual=$(recover_old_state "$body")
if [ "$actual" = "$expected" ]; then
echo "PASS: recover_old_state ($desc) -> $expected"
else
echo "FAIL: recover_old_state ($desc) -> $actual (expected $expected)"
failures=$((failures + 1))
fi
}
# A valid object marker recovers to the object verbatim.
assert_marker_recovers "$(printf '{"docs/a.md":"sha1"}' | base64 -w0)" '{"docs/a.md":"sha1"}' "valid object"
# Charset-valid but undecodable base64 (odd length) degrades to {}.
assert_marker_recovers "A" "{}" "undecodable base64"
# Valid base64 of a non-object (a JSON string) fails the type gate -> {}.
assert_marker_recovers "$(printf '"hello"' | base64 -w0)" "{}" "valid base64 non-object"
# Valid base64 that decodes to non-JSON bytes fails the parse gate -> {}.
assert_marker_recovers "$(printf '\xff\xfe\xfd' | base64 -w0)" "{}" "valid base64 non-JSON bytes"
# extract_manifest_paths runs the real jq + sed pipeline from
# docs-preview.yaml against manifest JSON on stdin, emitting one
# normalized repo-relative path per line. Guards the recursive
# `[.. | objects | select(has("path")) | .path]` extraction that
# normalize_manifest_path above does not reach.
extract_manifest_paths() {
jq -r '[.. | objects | select(has("path")) | .path] | .[]' |
sed -E 's#^\./##; s#^#docs/#'
}
# Manifest fixture in the real schema: "./"-prefixed and bare paths, a
# nested child, and an object with only icon_path (no "path" key) that
# must not be collected.
manifest_fixture='{"versions":["main"],"routes":[
{"title":"Home","path":"./README.md","icon_path":"./images/home.svg"},
{"title":"Install","path":"./install/index.md","children":[
{"title":"CLI","path":"reference/cli/whoami.md"}
]},
{"title":"IconOnly","icon_path":"./images/x.svg"}
]}'
actual_paths=$(printf '%s' "$manifest_fixture" | extract_manifest_paths | LC_ALL=C sort | tr '\n' ' ')
expected_paths="docs/README.md docs/install/index.md docs/reference/cli/whoami.md "
if [ "$actual_paths" = "$expected_paths" ]; then
echo "PASS: extract_manifest_paths (icon_path-only object excluded)"
else
echo "FAIL: extract_manifest_paths -> \"$actual_paths\" (expected \"$expected_paths\")"
failures=$((failures + 1))
fi
# filter_changed_files runs the real pulls/files filter jq from
# docs-preview.yaml: keep non-removed docs/*.md outside docs/.style/,
# emitting <filename>\t<sha>.
filter_changed_files() {
jq -r '.[] | select(.status != "removed") | select(.filename | test("^docs/.*\\.md$")) | select((.filename | test("^docs/\\.style/")) | not) | [.filename, .sha] | @tsv'
}
files_fixture='[
{"filename":"docs/admin/index.md","sha":"aaa","status":"modified"},
{"filename":"docs/ai-coder/tasks.md","sha":"bbb","status":"added"},
{"filename":"docs/old.md","sha":"ccc","status":"removed"},
{"filename":"docs/.style/word-list.txt","sha":"ddd","status":"modified"},
{"filename":"docs/images/diagram.png","sha":"eee","status":"added"},
{"filename":"site/README.md","sha":"fff","status":"modified"},
{"filename":"docs/.style/rules.md","sha":"ggg","status":"modified"}
]'
actual_changed=$(printf '%s' "$files_fixture" | filter_changed_files | LC_ALL=C sort | tr '\n' '|')
expected_changed="$(printf 'docs/admin/index.md\taaa\ndocs/ai-coder/tasks.md\tbbb\n' | tr '\n' '|')"
if [ "$actual_changed" = "$expected_changed" ]; then
echo "PASS: filter_changed_files (removed/.style/non-md/non-docs excluded)"
else
echo "FAIL: filter_changed_files -> \"$actual_changed\" (expected \"$expected_changed\")"
failures=$((failures + 1))
fi
# intersect_eligible replicates the grep -qxF intersection from
# docs-preview.yaml: keep only changed files whose path is in the
# manifest allowlist. This is the single decision the feature exists to
# make, so cover it directly.
intersect_eligible() {
local changed="$1" allowed="$2"
printf '%s\n' "$changed" | while IFS=$'\t' read -r filename sha; do
[ -z "$filename" ] && continue
if printf '%s\n' "$allowed" | grep -qxF "$filename"; then
printf '%s\t%s\n' "$filename" "$sha"
fi
done
}
changed_tsv_fixture="$(printf 'docs/admin/index.md\taaa\ndocs/ai-coder/tasks.md\tbbb\ndocs/not-in-manifest.md\tccc')"
allowed_fixture="$(printf 'docs/admin/index.md\ndocs/ai-coder/tasks.md\ndocs/install/index.md')"
actual_eligible=$(intersect_eligible "$changed_tsv_fixture" "$allowed_fixture" | LC_ALL=C sort | tr '\n' '|')
expected_eligible="$(printf 'docs/admin/index.md\taaa\ndocs/ai-coder/tasks.md\tbbb\n' | tr '\n' '|')"
if [ "$actual_eligible" = "$expected_eligible" ]; then
echo "PASS: intersect_eligible (drops paths not in the manifest)"
else
echo "FAIL: intersect_eligible -> \"$actual_eligible\" (expected \"$expected_eligible\")"
failures=$((failures + 1))
fi
# build_comment_body mirrors the body assembler in docs-preview.yaml:
# it renders the first N pages of $final_rows into the exact
# comment body the workflow posts, so the comment can be sized by
# measuring the real bytes instead of estimating a per-page cost. Reads
# the $final_rows, $total_pages, $url_prefix, $DOCS_PREVIEW_MARKER, and
# $STATE_PREFIX globals set before each case below. Keep in sync with
# docs-preview.yaml.
DOCS_PREVIEW_MARKER='<!-- docs-preview -->'
STATE_PREFIX='docs-preview-state:'
# Representative values for the Files-tab link in the omitted-pages
# summary; the workflow supplies these from the GitHub Actions env.
REPO='owner/repo'
PR_NUMBER='123'
build_comment_body() {
local n="$1" rows state_json state_b64 checklist="" intro
local filename checked page_path url box omitted
rows=$(printf '%s' "$final_rows" | jq -c --argjson n "$n" '.[:$n]')
state_json=$(printf '%s' "$rows" | jq -c 'map({(.filename): .sha}) | add // {}')
state_b64=$(printf '%s' "$state_json" | base64 -w0)
while IFS=$'\t' read -r filename checked; do
[ -z "$filename" ] && continue
page_path=$(map_doc_path "$filename")
url="$url_prefix"
if [ -n "$page_path" ]; then
url="${url}/${page_path}"
fi
box=" "
if [ "$checked" = "true" ]; then
box="x"
fi
checklist="${checklist}- [${box}] [\`${filename}\`](${url})"$'\n'
done < <(printf '%s' "$rows" | jq -r '.[] | [.filename, (.checked | tostring)] | @tsv')
omitted=$((total_pages - n))
if [ "$omitted" -gt 0 ]; then
checklist="${checklist}"$'\n'"_and ${omitted} more changed page(s) not listed to stay under GitHub's comment size limit. See the [Files tab](https://github.com/${REPO}/pull/${PR_NUMBER}/files) for the full list._"$'\n'
fi
intro="Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here."
printf '## Docs preview\n\n%s\n\n%s\n%s\n<!-- %s%s -->' \
"$intro" "$checklist" "$DOCS_PREVIEW_MARKER" "$STATE_PREFIX" "$state_b64"
}
# cap_pages mirrors the measure-and-binary-search cap in docs-preview.yaml:
# keep every page if the whole body fits, else the largest leading prefix
# whose rendered body stays under $budget.
cap_pages() {
local budget="$1" keep lo hi mid
if [ "$(build_comment_body "$total_pages" | LC_ALL=C wc -c)" -le "$budget" ]; then
printf '%s' "$total_pages"
return
fi
lo=0
hi=$((total_pages - 1))
keep=0
while [ "$lo" -le "$hi" ]; do
mid=$(((lo + hi) / 2))
if [ "$(build_comment_body "$mid" | LC_ALL=C wc -c)" -le "$budget" ]; then
keep=$mid
lo=$((mid + 1))
else
hi=$((mid - 1))
fi
done
printf '%s' "$keep"
}
budget=65000
# GitHub's hard comment-body limit; the budget above leaves headroom under it.
github_comment_limit=65536
# Repo-scale worst case: a docs migration touching 400 pages on a long
# ticket-prefixed branch, ~60-char paths, the shape reviewers measured
# overflowing the old per-page estimate. The cap must keep the real body
# under GitHub's 65536-char limit while still listing as many pages as fit.
url_prefix="https://coder.com/docs/@feature-team-very-long-branch-name-docs-migration-2024"
final_rows=$(jq -nc '[range(400) | {
filename: ("docs/reference/generated/section-\(. + 1000)/really-long-page-name-\(. + 1000).md"),
sha: ("0123456789abcdef0123456789abcdef" + (. + 100000 | tostring)),
checked: false
}]')
total_pages=$(printf '%s' "$final_rows" | jq 'length')
keep=$(cap_pages "$budget")
final_body_bytes=$(build_comment_body "$keep" | LC_ALL=C wc -c)
if [ "$keep" -lt "$total_pages" ] && [ "$final_body_bytes" -le "$github_comment_limit" ]; then
echo "PASS: repo-scale cap keeps $keep/$total_pages pages, body ${final_body_bytes}B <= ${github_comment_limit}"
else
echo "FAIL: repo-scale cap keeps $keep/$total_pages pages, body ${final_body_bytes}B (want < total and <= ${github_comment_limit})"
failures=$((failures + 1))
fi
# Tightness: one page past the cap must exceed the budget, proving the
# cap doesn't leave usable space on the table.
over_body_bytes=$(build_comment_body "$((keep + 1))" | LC_ALL=C wc -c)
if [ "$over_body_bytes" -gt "$budget" ]; then
echo "PASS: cap is tight (keep+1 body ${over_body_bytes}B > ${budget})"
else
echo "FAIL: cap is not tight (keep+1 body ${over_body_bytes}B <= ${budget})"
failures=$((failures + 1))
fi
# A small PR keeps every page and renders no omitted-pages summary line.
url_prefix="https://coder.com/docs/@short-branch"
final_rows=$(jq -nc '[range(5) | {filename: ("docs/page-\(.).md"), sha: "abc", checked: false}]')
total_pages=$(printf '%s' "$final_rows" | jq 'length')
keep=$(cap_pages "$budget")
small_body=$(build_comment_body "$keep")
if [ "$keep" -eq 5 ] && ! printf '%s' "$small_body" | grep -q "more changed page"; then
echo "PASS: small PR keeps all 5 pages with no summary line"
else
echo "FAIL: small PR keep=$keep (expected 5) or unexpected summary line"
failures=$((failures + 1))
fi
# Round-trip build_comment_body's *own emitted* marker back through
# recover_old_state, proving the producer and consumer marker formats agree
# (a drift would silently reset every checkbox on every push).
emitted_state=$(recover_old_state "$small_body")
expected_state=$(printf '%s' "$final_rows" | jq -c 'map({(.filename): .sha}) | add // {}')
if [ "$emitted_state" = "$expected_state" ]; then
echo "PASS: emitted marker round-trips through recovery"
else
echo "FAIL: emitted marker round-trip -> $emitted_state (expected $expected_state)"
failures=$((failures + 1))
fi
if [ "$failures" -gt 0 ]; then
echo ""
echo "$failures test(s) failed."
exit 1
fi
echo ""
echo "All tests passed."