Setting the file. One moment.
Daily Recap Test · Daily Recap · bitwarden/ai-plugins · Skills Docs
ContentsBack to the top of the page 46.10
README
This file
Number 46.3
Position 3 of 10
Type Shell
Size 62 KB
Lines 1,308 scripts/ daily-recap.test.sh
Shell · 1,308 lines · 62 KB
15
16 set -uo pipefail
17
18 SCRIPT_DIR = "$( cd "$( dirname "${ BASH_SOURCE [0]}")" && pwd )"
19 GATHER = "${ SCRIPT_DIR }/gather-gh-events.sh"
20 GATHER_ATLASSIAN = "${ SCRIPT_DIR }/gather-atlassian.sh"
21 GATHER_SEARCH = "${ SCRIPT_DIR }/gather-gh-search.sh"
22 FIND = "${ SCRIPT_DIR }/find-sessions.sh"
23 TEMPLATE = "${ SCRIPT_DIR }/../assets/template.html"
24
25 command -v jq > /dev/null 2>&1 || {
26 echo "Error: jq is required to run these tests"
27 exit 1
28 }
29
30 WORK_DIR = "$( mktemp -d )"
31 trap 'rm -rf "${WORK_DIR}"' EXIT
32
33 PASSED = 0
34 FAILED = 0
35
36 pass () {
37 echo " ✅ $1 "
38 PASSED = $(( PASSED + 1 ))
39 }
40
41 fail () {
42 echo " ❌ $1 "
43 FAILED = $(( FAILED + 1 ))
44 }
45
46 # assert_eq <case> <expected> <actual>
47 assert_eq () {
48 if [[ " $2 " == " $3 " ]]; then
49 pass " $1 "
50 else
51 fail " $1 "
52 echo " expected: $2 "
53 echo " got: $3 "
54 fi
55 }
56
57 # assert_contains <case> <needle> <haystack>
58 assert_contains () {
59 if grep -qF -- " $2 " <<< " $3 " ; then
60 pass " $1 "
61 else
62 fail " $1 "
63 echo " expected to contain: $2 "
64 echo " got:"
65 while IFS = read -r line ; do echo " ${ line }" ; done <<< " $3 "
66 fi
67 }
68
69 # Runs a helper from date-utils.sh in a subshell, since the file is sourced, not executed.
70 date_utils () {
71 (
72 # shellcheck source=SCRIPTDIR/date-utils.sh
73 source "${ SCRIPT_DIR }/date-utils.sh"
74 " $@ "
75 )
76 }
77
78 # ---------------------------------------------------------------------------
79 # A stub `gh` that answers the two calls gather-gh-events.sh makes: the login lookup
80 # and the events feed, which is empty unless GH_STUB_FEED names a directory of
81 # page-<n>.json files. Each call is logged so cases can assert that input validation
82 # fails before anything would reach GitHub. GH_STUB_FAIL makes every call fail, as an
83 # unauthenticated `gh` does.
84 # ---------------------------------------------------------------------------
85 STUB_BIN = "${ WORK_DIR }/stub-bin"
86 GH_LOG = "${ WORK_DIR }/gh-calls.log"
87 mkdir -p "${ STUB_BIN }"
88 cat > "${ STUB_BIN }/gh" << 'EOF'
89 #!/bin/sh
90 echo "$*" >>"${GH_LOG}"
91 [ -n "${GH_STUB_FAIL:-}" ] && exit 1
92 case "$2" in
93 user) echo "recap-tester" ;;
94 users/*)
95 page_file="${GH_STUB_FEED:-/nonexistent}/page-${2##*page=}.json"
96 if [ -f "$page_file" ]; then cat "$page_file"; else echo "[]"; fi
97 ;;
98 *) exit 1 ;;
99 esac
100 EOF
101 chmod +x "${ STUB_BIN }/gh"
102
103 OUT = ""
104 ERR = ""
105 STATUS = 0
106
107 # gather [VAR=value ...] -- [args...]
108 # Runs gather-gh-events.sh with the stub gh first on PATH and the given environment.
109 # Sets OUT, ERR, and STATUS.
110 gather () {
111 local env_args = ()
112 while [[ $# -gt 0 && " $1 " != "--" ]]; do
113 env_args += ( " $1 " )
114 shift
115 done
116 shift
117 : > "${ GH_LOG }"
118 OUT = "$( env PATH="${ STUB_BIN }:${ PATH }" GH_LOG="${ GH_LOG }" ${ env_args [ @ ]+"${ env_args [ @ ]}"} \
119 bash "${ GATHER }" " $@ " 2> "${ WORK_DIR }/stderr")"
120 STATUS = $?
121 ERR = "$( cat "${ WORK_DIR }/stderr")"
122 }
123
124 # window_of <case-prefix> <expected-start-utc> <expected-end-utc>
125 window_of () {
126 assert_eq " $1 : exits 0" 0 "${ STATUS }"
127 assert_eq " $1 : start_utc" " $2 " "$( jq -r .window.start_utc <<< "${ OUT }")"
128 assert_eq " $1 : end_utc" " $3 " "$( jq -r .window.end_utc <<< "${ OUT }")"
129 }
130
131 echo "=== daily-recap script tests ==="
132 echo ""
133
134 # ---------------------------------------------------------------------------
135 # date-utils.sh helpers
136 # ---------------------------------------------------------------------------
137 echo "[1] date-utils.sh conversions"
138 assert_eq "utc_to_epoch parses an ISO timestamp" 1791889200 "$( date_utils utc_to_epoch 2026-10-13T11:00:00Z)"
139 assert_eq "epoch_fmt_utc formats it back" 2026-10-13T11:00:00Z "$( date_utils epoch_fmt_utc 1791889200 '%Y-%m-%dT%H:%M:%SZ')"
140 assert_eq "local_to_epoch honors TZ" 1791889200 "$( TZ = America/New_York date_utils local_to_epoch '2026-10-13 07:00:00')"
141 assert_eq "epoch_fmt honors TZ" "2026-10-13 07:00 -0400" "$( TZ = America/New_York date_utils epoch_fmt 1791889200 '%Y-%m-%d %H:%M %z')"
142 # 02:00 on 2026-03-08 is skipped in New York. Both date flavors read it as 03:00 EDT.
143 assert_eq "local_to_epoch reads a skipped time forward" 1772953200 "$( TZ = America/New_York date_utils local_to_epoch '2026-03-08 02:00:00')"
144 echo ""
145
146 echo "[2] date-utils.sh add_days"
147 assert_eq "next day" 2026-10-14 "$( date_utils add_days 2026-10-13 1 )"
148 assert_eq "across a month end" 2026-02-28 "$( date_utils add_days 2026-03-01 -1 )"
149 assert_eq "into a leap day" 2028-02-29 "$( date_utils add_days 2028-03-01 -1 )"
150 assert_eq "across a year end" 2027-01-01 "$( date_utils add_days 2026-12-31 1 )"
151 assert_eq "into spring forward" 2026-03-08 "$( TZ = America/New_York date_utils add_days 2026-03-07 1 )"
152 assert_eq "out of spring forward" 2026-03-09 "$( TZ = America/New_York date_utils add_days 2026-03-08 1 )"
153 assert_eq "back across fall back" 2026-10-31 "$( TZ = America/New_York date_utils add_days 2026-11-01 -1 )"
154 assert_eq "two days over fall back" 2026-11-02 "$( TZ = America/New_York date_utils add_days 2026-10-31 2 )"
155 echo ""
156
157 echo "[3] date-utils.sh is_valid_date"
158 for d in 2026-10-13 2028-02-29 2026-12-31 ; do
159 if date_utils is_valid_date " $d " ; then pass "accepts ${ d }" ; else fail "accepts ${ d }" ; fi
160 done
161 for d in 2026-02-29 2026-02-30 2026-04-31 2026-13-01 10/13/2026 2026-1-5 "2026-10-13 " "" yesterday ; do
162 if date_utils is_valid_date " $d " ; then fail "rejects '${ d }'" ; else pass "rejects '${ d }'" ; fi
163 done
164 echo ""
165
166 # ---------------------------------------------------------------------------
167 # gather-gh-events.sh window computation. The window runs from the cutoff hour on the
168 # given date to the cutoff hour on the next date, in local time.
169 # ---------------------------------------------------------------------------
170 echo "[4] window for an ordinary date"
171 gather TZ=America/New_York -- 2026-10-13
172 window_of "2026-10-13 New York" 2026-10-13T11:00:00Z 2026-10-14T11:00:00Z
173 assert_eq "local start keeps the offset" 2026-10-13T07:00:00-0400 "$( jq -r .window.start_local <<< "${ OUT }")"
174 assert_eq "timezone label" EDT "$( jq -r .window.timezone <<< "${ OUT }")"
175 assert_eq "default cutoff hour is 7" 7 "$( jq -r .window.cutoff_hour <<< "${ OUT }")"
176 assert_eq "login comes from gh" recap-tester "$( jq -r .login <<< "${ OUT }")"
177 assert_eq "empty feed gives no events" 0 "$( jq -r .event_count <<< "${ OUT }")"
178 assert_contains "window line goes to stderr" "# Window: 2026-10-13T11:00:00Z -> 2026-10-14T11:00:00Z (07:00 EDT boundary)" "${ ERR }"
179 echo ""
180
181 echo "[5] windows around DST transitions in New York"
182 gather TZ=America/New_York -- 2026-03-08
183 window_of "2026-03-08 (spring forward day)" 2026-03-08T11:00:00Z 2026-03-09T11:00:00Z
184 gather TZ=America/New_York -- 2026-03-07
185 window_of "2026-03-07 (23-hour window)" 2026-03-07T12:00:00Z 2026-03-08T11:00:00Z
186 gather TZ=America/New_York -- 2026-11-01
187 window_of "2026-11-01 (fall back day)" 2026-11-01T12:00:00Z 2026-11-02T12:00:00Z
188 gather TZ=America/New_York -- 2026-10-31
189 window_of "2026-10-31 (25-hour window)" 2026-10-31T11:00:00Z 2026-11-01T12:00:00Z
190 # A 02:00 cutoff falls in the skipped hour on 2026-03-08 and moves to 03:00 EDT.
191 gather TZ=America/New_York DAILY_RECAP_CUTOFF_HOUR= 2 -- 2026-03-08
192 window_of "cutoff in the skipped hour, start" 2026-03-08T07:00:00Z 2026-03-09T06:00:00Z
193 gather TZ=America/New_York DAILY_RECAP_CUTOFF_HOUR= 2 -- 2026-03-07
194 window_of "cutoff in the skipped hour, end" 2026-03-07T07:00:00Z 2026-03-08T07:00:00Z
195 echo ""
196
197 echo "[6] non-default DAILY_RECAP_CUTOFF_HOUR"
198 gather TZ=America/New_York DAILY_RECAP_CUTOFF_HOUR= 0 -- 2026-10-13
199 window_of "midnight cutoff" 2026-10-13T04:00:00Z 2026-10-14T04:00:00Z
200 gather TZ=America/New_York DAILY_RECAP_CUTOFF_HOUR= 09 -- 2026-10-13
201 window_of "leading zero is decimal, not octal" 2026-10-13T13:00:00Z 2026-10-14T13:00:00Z
202 assert_eq "cutoff hour reported as a number" 9 "$( jq -r .window.cutoff_hour <<< "${ OUT }")"
203 gather TZ=America/New_York DAILY_RECAP_CUTOFF_HOUR= 23 -- 2026-10-13
204 window_of "late cutoff" 2026-10-14T03:00:00Z 2026-10-15T03:00:00Z
205 echo ""
206
207 echo "[7] other timezones"
208 gather TZ=UTC -- 2026-10-13
209 window_of "UTC" 2026-10-13T07:00:00Z 2026-10-14T07:00:00Z
210 gather TZ=Asia/Kolkata -- 2026-10-13
211 window_of "half-hour offset" 2026-10-13T01:30:00Z 2026-10-14T01:30:00Z
212 gather TZ=Australia/Sydney -- 2026-10-13
213 window_of "southern hemisphere summer time" 2026-10-12T20:00:00Z 2026-10-13T20:00:00Z
214 echo ""
215
216 echo "[8] rejected inputs fail before calling GitHub"
217 for cutoff in 7am 24 -1 " 7" ; do
218 gather TZ=America/New_York "DAILY_RECAP_CUTOFF_HOUR=${ cutoff }" -- 2026-10-13
219 assert_eq "cutoff '${ cutoff }': exits 1" 1 "${ STATUS }"
220 assert_contains "cutoff '${ cutoff }': explains the range" "DAILY_RECAP_CUTOFF_HOUR must be a whole hour from 0 to 23 (got '${ cutoff }')" "${ ERR }"
221 assert_eq "cutoff '${ cutoff }': no gh call" "" "$( cat "${ GH_LOG }")"
222 done
223 for d in 2026-02-30 10/13/2026 2026-13-01 2026-10-1 ; do
224 gather TZ=America/New_York -- " $d "
225 assert_eq "date '${ d }': exits 1" 1 "${ STATUS }"
226 assert_contains "date '${ d }': explains the form" "date must be a real calendar date in YYYY-MM-DD form (got '${ d }')" "${ ERR }"
227 assert_eq "date '${ d }': nothing on stdout" "" "${ OUT }"
228 assert_eq "date '${ d }': no gh call" "" "$( cat "${ GH_LOG }")"
229 done
230 echo ""
231
232 echo "[9] gh missing or signed out"
233 # A PATH holding only the tools the script needs to compute the window, so no installed
234 # gh can be found.
235 NO_GH_BIN = "${ WORK_DIR }/no-gh-bin"
236 mkdir -p "${ NO_GH_BIN }"
237 for tool in jq dirname date ; do
238 ln -s "$( command -v "${ tool }")" "${ NO_GH_BIN }/${ tool }"
239 done
240 OUT = "$( env PATH="${ NO_GH_BIN }" TZ=America/New_York "${ BASH }" "${ GATHER }" 2026-10-13 2> "${ WORK_DIR }/stderr")"
241 STATUS = $?
242 ERR = "$( cat "${ WORK_DIR }/stderr")"
243 assert_eq "without gh: exits 1" 1 "${ STATUS }"
244 assert_contains "without gh: names the missing tool" "'gh' is required but not installed" "${ ERR }"
245 assert_eq "without gh: nothing on stdout" "" "${ OUT }"
246 assert_contains "without gh: still prints the window" "# Window: 2026-10-13T11:00:00Z -> 2026-10-14T11:00:00Z" "${ ERR }"
247 gather TZ=America/New_York GH_STUB_FAIL= 1 -- 2026-10-13
248 assert_eq "signed out: exits 1" 1 "${ STATUS }"
249 assert_contains "signed out: points at gh auth login" "run 'gh auth login' and try again" "${ ERR }"
250 assert_contains "signed out: still prints the window" "# Window: 2026-10-13T11:00:00Z -> 2026-10-14T11:00:00Z" "${ ERR }"
251 assert_eq "signed out: stops after the login lookup" "api user --jq .login" "$( cat "${ GH_LOG }")"
252 echo ""
253
254 # ---------------------------------------------------------------------------
255 # Events feed paging. GitHub returns the feed in delivery order rather than by
256 # created_at, so an event with a long-past timestamp can sit anywhere in it, and a page
257 # can come back short mid-feed. The window under test is 2026-10-13T11:00:00Z to
258 # 2026-10-14T11:00:00Z.
259 # ---------------------------------------------------------------------------
260 FEED = "${ WORK_DIR }/feed"
261 mkdir -p "${ FEED }"
262
263 # feed <events> <oldest-utc> [stray-utc]
264 # Writes <events> events an hour apart, newest first and ending at <oldest-utc>, as pages
265 # of 100. A stray timestamp replaces the 51st event, in the middle of page 1.
266 feed () {
267 rm -f "${ FEED }"/page- * .json
268 jq -n --argjson n " $1 " --arg oldest " $2 " --arg stray " ${3 :- } " '
269 [range(0; $n) as $i
270 | {type: "PushEvent", repo: {name: "bitwarden/x"}, payload: {},
271 created_at: ($oldest | fromdate + ($n - 1 - $i) * 3600 | todate)}]
272 | if $stray != "" then .[50].created_at = $stray else . end' > "${ FEED }/all.json"
273 local p
274 for p in 1 2 3 ; do
275 jq ".[$((( p - 1 ) * 100 )):$(( p * 100 ))]" "${ FEED }/all.json" > "${ FEED }/page-${ p }.json"
276 done
277 }
278
279 echo "[10] events feed paging"
280 feed 300 2026-10-12T13:00:00Z 2026-09-23T13:09:38Z
281 jq 'del(.[0])' "${ FEED }/page-2.json" > "${ FEED }/short.json" && mv "${ FEED }/short.json" "${ FEED }/page-2.json"
282 gather TZ=America/New_York "GH_STUB_FEED=${ FEED }" -- 2026-10-13
283 assert_eq "stray old event and short page 2: still reads all three pages" 3 "$( grep -c '/events' "${ GH_LOG }")"
284 assert_eq "stray old event and short page 2: keeps every in-window event" 24 "$( jq .event_count <<< "${ OUT }")"
285 assert_eq "stray old event and short page 2: feed reaching past the window is complete" true "$( jq .feed_complete <<< "${ OUT }")"
286 feed 300 2026-10-14T00:00:00Z 2026-09-23T13:09:38Z
287 gather TZ=America/New_York "GH_STUB_FEED=${ FEED }" -- 2026-10-13
288 assert_eq "feed ends inside the window: incomplete despite the stray" false "$( jq .feed_complete <<< "${ OUT }")"
289 assert_contains "feed ends inside the window: warns" "events early in this window may be missing" "${ ERR }"
290 feed 40 2026-10-13T00:00:00Z
291 gather TZ=America/New_York "GH_STUB_FEED=${ FEED }" -- 2026-10-13
292 assert_eq "short feed: stops at the first empty page" 2 "$( grep -c '/events' "${ GH_LOG }")"
293 assert_eq "short feed: keeps every in-window event" 24 "$( jq .event_count <<< "${ OUT }")"
294 echo ""
295
296 # ---------------------------------------------------------------------------
297 # gather-atlassian.sh. A stub `curl` answers /myself, /serverInfo, /search/jql, and
298 # per-issue /comment requests (Jira) plus /rest/api/user/current, /rest/api/search,
299 # /api/v2/spaces, and /api/v2/pages/<id>/versions (Confluence) by matching the URL, the
300 # same way `gh` is stubbed above. Every call is logged (args only, never response
301 # bodies) so cases can assert the token value never reaches the log, and that
302 # credentials-unset/failed-identity cases never call curl at all beyond what the case
303 # expects.
304 # ---------------------------------------------------------------------------
305 ATLASSIAN_STUB_BIN = "${ WORK_DIR }/atlassian-stub-bin"
306 CURL_LOG = "${ WORK_DIR }/curl-calls.log"
307 REQUEST_BODY_LOG = "${ WORK_DIR }/curl-request-bodies.log"
308 ATLASSIAN_FIXTURES = "${ WORK_DIR }/atlassian-fixtures"
309 mkdir -p "${ ATLASSIAN_STUB_BIN }" "${ ATLASSIAN_FIXTURES }"
310 cat > "${ ATLASSIAN_STUB_BIN }/curl" << 'EOF'
311 #!/bin/sh
312 # Logs every call (args only) and answers by matching the request URL against fixture
313 # files under CURL_FIXTURES. CURL_STUB_FAIL makes every call a transport failure.
314 # Also logs each --data-binary request body's content (not the argv, the file content)
315 # to REQUEST_BODY_LOG, one JSON line per call, so tests can assert on request shape
316 # (e.g. that "expand" is sent as the string form Jira's gateway requires, not an array).
317 printf '%s\n' "$*" >>"${CURL_LOG}"
318 [ -n "${CURL_STUB_FAIL:-}" ] && exit 7
319
320 out_file=""
321 url=""
322 prev=""
323 data_file=""
324 for arg in "$@"; do
325 if [ "$prev" = "-o" ]; then
326 out_file="$arg"
327 fi
328 if [ "$prev" = "--data-binary" ]; then
329 data_file="${arg#@}"
330 fi
331 case "$arg" in
332 http*) url="$arg" ;;
333 esac
334 prev="$arg"
335 done
336 if [ -n "${data_file}" ] && [ -f "${data_file}" ]; then
337 cat "${data_file}" >>"${REQUEST_BODY_LOG}"
338 printf '\n' >>"${REQUEST_BODY_LOG}"
339 fi
340
341 FIX="${ATLASSIAN_FIXTURES}"
342 case "$url" in
343 *"/myself"*)
344 status=200
345 [ -f "${FIX}/myself_status" ] && status="$(cat "${FIX}/myself_status")"
346 if [ -f "${FIX}/myself.json" ]; then cat "${FIX}/myself.json" >"$out_file"; else echo '{}' >"$out_file"; fi
347 printf '%s' "$status"
348 ;;
349 *"/serverInfo"*)
350 if [ -f "${FIX}/serverinfo.json" ]; then
351 cat "${FIX}/serverinfo.json" >"$out_file"
352 printf '200'
353 else
354 echo '{}' >"$out_file"
355 printf '404'
356 fi
357 ;;
358 *"/search/jql"*)
359 # If the request body carries a nextPageToken, this is a follow-up page request --
360 # serve search-page2.json when present (falls back to search.json otherwise, which
361 # keeps every single-page fixture above working unchanged).
362 if [ -n "${data_file}" ] && [ -f "${data_file}" ] && grep -q '"nextPageToken"' "${data_file}" && [ -f "${FIX}/search-page2.json" ]; then
363 cat "${FIX}/search-page2.json" >"$out_file"
364 elif [ -f "${FIX}/search-page1.json" ]; then
365 cat "${FIX}/search-page1.json" >"$out_file"
366 elif [ -f "${FIX}/search.json" ]; then
367 cat "${FIX}/search.json" >"$out_file"
368 else
369 echo '{"issues":[],"isLast":true}' >"$out_file"
370 fi
371 printf '200'
372 ;;
373 *"/comment"*)
374 key=$(echo "$url" | sed -n 's#.*/issue/\([^/]*\)/comment.*#\1#p')
375 if [ -f "${FIX}/comments-${key}.json" ]; then
376 cat "${FIX}/comments-${key}.json" >"$out_file"
377 else
378 echo '{"comments": []}' >"$out_file"
379 fi
380 printf '200'
381 ;;
382 *"/rest/api/user/current"*)
383 status=200
384 [ -f "${FIX}/confluence_myself_status" ] && status="$(cat "${FIX}/confluence_myself_status")"
385 if [ -f "${FIX}/confluence_myself.json" ]; then cat "${FIX}/confluence_myself.json" >"$out_file"; else echo '{}' >"$out_file"; fi
386 printf '%s' "$status"
387 ;;
388 *"/rest/api/search"*)
389 if [ -f "${FIX}/confluence_search.json" ]; then cat "${FIX}/confluence_search.json" >"$out_file"; else echo '{"results":[]}' >"$out_file"; fi
390 printf '200'
391 ;;
392 *"/api/v2/spaces"*)
393 key=$(echo "$url" | sed -n 's#.*[?&]keys=\([^&]*\).*#\1#p')
394 if [ -f "${FIX}/confluence_space-${key}.json" ]; then
395 cat "${FIX}/confluence_space-${key}.json" >"$out_file"
396 else
397 echo '{"results":[]}' >"$out_file"
398 fi
399 printf '200'
400 ;;
401 *"/api/v2/pages/"*"/versions"*)
402 page_id=$(echo "$url" | sed -n 's#.*/api/v2/pages/\([^/]*\)/versions.*#\1#p')
403 if [ -f "${FIX}/confluence_versions-${page_id}.json" ]; then
404 cat "${FIX}/confluence_versions-${page_id}.json" >"$out_file"
405 else
406 echo '{"results":[]}' >"$out_file"
407 fi
408 printf '200'
409 ;;
410 *)
411 echo '{}' >"$out_file"
412 printf '404'
413 ;;
414 esac
415 EOF
416 chmod +x "${ ATLASSIAN_STUB_BIN }/curl"
417
418 # gather_atlassian [VAR=value ...] -- [args...]
419 # Runs gather-atlassian.sh with the stub curl first on PATH. Sets OUT, ERR, and STATUS.
420 # Starts from all four Atlassian env vars unset (-u) regardless of what the ambient
421 # shell exports (a dev machine configured for bitwarden-atlassian-tools has real
422 # credentials in its environment), then layers the case's explicit env_args on top, so
423 # a case that omits a var is testing "unset", never an accidental leak of a real token.
424 gather_atlassian () {
425 local env_args = ()
426 while [[ $# -gt 0 && " $1 " != "--" ]]; do
427 env_args += ( " $1 " )
428 shift
429 done
430 shift
431 : > "${ CURL_LOG }"
432 : > "${ REQUEST_BODY_LOG }"
433 OUT = "$( env -u ATLASSIAN_CLOUD_ID -u ATLASSIAN_EMAIL -u ATLASSIAN_JIRA_READ_ONLY_TOKEN -u ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN \
434 PATH="${ ATLASSIAN_STUB_BIN }:${ PATH }" CURL_LOG="${ CURL_LOG }" REQUEST_BODY_LOG="${ REQUEST_BODY_LOG }" \
435 ATLASSIAN_FIXTURES="${ ATLASSIAN_FIXTURES }" \
436 ${ env_args [ @ ]+"${ env_args [ @ ]}"} bash "${ GATHER_ATLASSIAN }" " $@ " 2> "${ WORK_DIR }/stderr")"
437 STATUS = $?
438 ERR = "$( cat "${ WORK_DIR }/stderr")"
439 }
440
441 TOKEN_VALUE = "sh0uldNeverLeak-tok3n"
442
443 echo "[A1] gather-atlassian.sh: credentials unset"
444 rm -f "${ ATLASSIAN_FIXTURES }"/ *
445 : > "${ CURL_LOG }"
446 OUT = "$( env -u ATLASSIAN_CLOUD_ID -u ATLASSIAN_EMAIL -u ATLASSIAN_JIRA_READ_ONLY_TOKEN -u ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN \
447 PATH="${ ATLASSIAN_STUB_BIN }:${ PATH }" CURL_LOG="${ CURL_LOG }" ATLASSIAN_FIXTURES="${ ATLASSIAN_FIXTURES }" \
448 TZ=America/New_York bash "${ GATHER_ATLASSIAN }" 2026-10-13 2> "${ WORK_DIR }/stderr")"
449 STATUS = $?
450 ERR = "$( cat "${ WORK_DIR }/stderr")"
451 assert_eq "credentials unset: exits 0" 0 "${ STATUS }"
452 assert_eq "credentials unset: emits jira and confluence null" '{"jira":null,"confluence":null}' "${ OUT }"
453 assert_contains "credentials unset: Jira stderr notice" "Jira credentials not set; skipping Jira" "${ ERR }"
454 assert_contains "credentials unset: Confluence stderr notice" "Confluence credentials not set; skipping Confluence" "${ ERR }"
455 assert_eq "credentials unset: curl never called" "" "$( cat "${ CURL_LOG }")"
456 echo ""
457
458 echo "[A2] gather-atlassian.sh: /myself returns 401"
459 cat > "${ ATLASSIAN_FIXTURES }/myself.json" << 'EOF'
460 {"errorMessages": ["Unauthorized"]}
461 EOF
462 echo 401 > "${ ATLASSIAN_FIXTURES }/myself_status"
463 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
464 "ATLASSIAN_JIRA_READ_ONLY_TOKEN=${ TOKEN_VALUE }" -- 2026-10-13
465 assert_eq "401 identity: exits 0" 0 "${ STATUS }"
466 assert_eq "401 identity: emits jira null, confluence null (no Confluence token given)" '{"jira":null,"confluence":null}' "${ OUT }"
467 assert_contains "401 identity: stderr notice" "Jira identity check failed (HTTP 401); skipping Jira" "${ ERR }"
468 assert_contains "401 identity: Confluence independently skipped for missing credentials" "Confluence credentials not set; skipping Confluence" "${ ERR }"
469 assert_eq "401 identity: stops after the identity check (one curl call)" 1 "$( wc -l < "${ CURL_LOG }")"
470 assert_contains "401 identity: that call is /myself" "/myself" "$( cat "${ CURL_LOG }")"
471 echo ""
472
473 echo "[A3] gather-atlassian.sh: happy path"
474 rm -f "${ ATLASSIAN_FIXTURES }/myself_status"
475 cat > "${ ATLASSIAN_FIXTURES }/myself.json" << 'EOF'
476 {"accountId": "acct-123", "displayName": "Recap Tester"}
477 EOF
478 cat > "${ ATLASSIAN_FIXTURES }/serverinfo.json" << 'EOF'
479 {"baseUrl": "https://example.atlassian.net"}
480 EOF
481 # Window under test (TZ=America/New_York, date 2026-10-13) is
482 # 2026-10-13T11:00:00Z -> 2026-10-14T11:00:00Z.
483 cat > "${ ATLASSIAN_FIXTURES }/search.json" << 'EOF'
484 {
485 "isLast": true,
486 "issues": [
487 {
488 "key": "PM-1",
489 "fields": {
490 "summary": "In window issue",
491 "status": {"name": "In Progress", "statusCategory": {"name": "In Progress"}},
492 "created": "2026-10-10T09:00:00.000+0000",
493 "updated": "2026-10-13T12:00:00.000+0000",
494 "reporter": {"accountId": "acct-123"},
495 "assignee": {"accountId": "acct-123"}
496 },
497 "changelog": {
498 "histories": [
499 {
500 "author": {"accountId": "acct-123"},
501 "created": "2026-10-13T12:30:00.000+0000",
502 "items": [{"field": "status", "fromString": "In Review", "toString": "In Progress"}]
503 },
504 {
505 "author": {"accountId": "acct-123"},
506 "created": "2026-10-15T12:30:00.000+0000",
507 "items": [{"field": "status", "fromString": "To Do", "toString": "In Progress"}]
508 }
509 ]
510 }
511 },
512 {
513 "key": "PM-2",
514 "fields": {
515 "summary": "Blocked issue",
516 "status": {"name": "Blocked", "statusCategory": {"name": "To Do"}},
517 "created": "2026-10-01T09:00:00.000+0000",
518 "updated": "2026-10-13T13:00:00.000+0000",
519 "reporter": {"accountId": "other"},
520 "assignee": {"accountId": "acct-123"}
521 },
522 "changelog": {"histories": []}
523 }
524 ]
525 }
526 EOF
527 cat > "${ ATLASSIAN_FIXTURES }/comments-PM-1.json" << 'EOF'
528 {"comments": [
529 {"author": {"accountId": "acct-123"}, "created": "2026-10-13T14:00:00.000+0000"},
530 {"author": {"accountId": "other"}, "created": "2026-10-13T14:30:00.000+0000"}
531 ]}
532 EOF
533 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
534 "ATLASSIAN_JIRA_READ_ONLY_TOKEN=${ TOKEN_VALUE }" -- 2026-10-13
535 assert_eq "happy path: exits 0" 0 "${ STATUS }"
536 assert_eq "happy path: account_id" acct-123 "$( jq -r .jira.account_id <<< "${ OUT }")"
537 assert_eq "happy path: site_url" https://example.atlassian.net "$( jq -r .jira.site_url <<< "${ OUT }")"
538 assert_eq "happy path: window start_utc" 2026-10-13T11:00:00Z "$( jq -r .jira.window.start_utc <<< "${ OUT }")"
539 assert_eq "happy path: window end_utc" 2026-10-14T11:00:00Z "$( jq -r .jira.window.end_utc <<< "${ OUT }")"
540 assert_eq "happy path: two issues carry involvement" 2 "$( jq '.jira.issues | length' <<< "${ OUT }")"
541 assert_eq "happy path: PM-1 involvement" "transitioned,commented,assigned" \
542 "$( jq -r '.jira.issues[] | select(.key == "PM-1") | .involvement | join(",")' <<< "${ OUT }")"
543 assert_eq "happy path: in-window transition captured" 1 \
544 "$( jq -r '.jira.issues[] | select(.key == "PM-1") | .transitions | length' <<< "${ OUT }")"
545 assert_eq "happy path: out-of-window transition excluded" "In Review" \
546 "$( jq -r '.jira.issues[] | select(.key == "PM-1") | .transitions[0].from' <<< "${ OUT }")"
547 assert_eq "happy path: user-only comment count" 1 \
548 "$( jq -r '.jira.issues[] | select(.key == "PM-1") | .comment_count' <<< "${ OUT }")"
549 assert_eq "happy path: jira.complete is true when pagination is exhausted" true "$( jq -r .jira.complete <<< "${ OUT }")"
550 assert_eq "happy path: PM-1's in-window backward transition sets returned:true" true \
551 "$( jq -r '.jira.issues[] | select(.key == "PM-1") | .returned' <<< "${ OUT }")"
552 assert_eq "happy path: PM-2 has no transitions so returned:false" false \
553 "$( jq -r '.jira.issues[] | select(.key == "PM-2") | .returned' <<< "${ OUT }")"
554 assert_eq "happy path: token not in stdout" "" "$( grep -F -- "${ TOKEN_VALUE }" <<< "${ OUT }" || true )"
555 assert_eq "happy path: token not in stderr" "" "$( grep -F -- "${ TOKEN_VALUE }" <<< "${ ERR }" || true )"
556 assert_eq "happy path: token not in curl argv log" "" "$( grep -F -- "${ TOKEN_VALUE }" "${ CURL_LOG }" || true )"
557 assert_eq "happy path: token not in request bodies" "" "$( grep -F -- "${ TOKEN_VALUE }" "${ REQUEST_BODY_LOG }" || true )"
558 # Regression check for the "oldest comments" bug: the comment-fetch request must ask
559 # for newest-first ordering, otherwise an issue with >100 comments would return its
560 # first page ever (oldest), silently missing the user's in-window comments. The stub
561 # always serves the whole comments-<key>.json fixture regardless of query params, so
562 # we can't simulate truncation at >100 comments through the fixture pipeline; asserting
563 # the request URL itself is the meaningful check here.
564 assert_contains "happy path: comment fetch requests newest-first ordering" \
565 "orderBy=-created" "$( grep '/comment' "${ CURL_LOG }")"
566 assert_eq "happy path: search request sends expand as a string, not an array" " \" changelog \" " \
567 "$( jq -c 'select(.expand != null) | .expand' "${ REQUEST_BODY_LOG }")"
568 assert_contains "happy path: JQL covers transitions the user made without being assignee/reporter" \
569 "status CHANGED BY" "$( jq -r '.jql' "${ REQUEST_BODY_LOG }")"
570 assert_contains "happy path: JQL covers other activity (e.g. comments) via updatedBy()" \
571 "issuekey IN updatedBy(" "$( jq -r '.jql' "${ REQUEST_BODY_LOG }")"
572 assert_eq "happy path: Confluence independently null (no Confluence token given)" null "$( jq -r '.confluence' <<< "${ OUT }")"
573 assert_contains "happy path: Confluence stderr notice despite Jira succeeding" "Confluence credentials not set; skipping Confluence" "${ ERR }"
574 assert_eq "happy path: no Confluence endpoints called" "" "$( grep -E '/wiki|user/current|api/v2' "${ CURL_LOG }" || true )"
575 echo ""
576
577 echo "[A3b] gather-atlassian.sh: large per-issue comment payload (regression for jq argv E2BIG overflow)"
578 # Regression check for the argv-overflow bug: the per-issue comment merge used to pass
579 # the comment blob through a single argv string via `jq --argjson comments "$comments"`.
580 # Linux caps a single argv string at MAX_ARG_STRLEN (131072 bytes); macOS has no
581 # per-string cap but caps the whole exec (argv + environ combined) at ARG_MAX
582 # (getconf ARG_MAX, typically 1048576 bytes). A real issue's comment history (with body
583 # text, unlike this script's slimmed-down view of it) can exceed either easily. The
584 # fixture below is sized past both -- 6000 bulk comments by a different author (pure
585 # padding, ~300 bytes of body each, ~2MB total) plus the one comment that actually
586 # matters: acct-123's own, inside the window -- so this test fails loudly (exec
587 # "Argument list too long") on both platforms under the old --argjson approach, not just
588 # in theory on Linux. Reuses the search fixtures A3 left in place (PM-1, PM-2) and
589 # only replaces comments-PM-1.json. Under the old code this would have failed execve
590 # with E2BIG inside collect_jira's $(...) subshell (where errexit is disabled), silently
591 # dropping PM-1 from the output with no warning. The fix slims comments down to
592 # {created, author.accountId} and passes them via --slurpfile, so the payload never
593 # touches argv at all.
594 jq -n '
595 { comments: (
596 [range(0; 6000) | {
597 author: {accountId: "other"},
598 created: "2026-10-13T14:30:00.000+0000",
599 body: ("x" * 300)
600 }]
601 + [{author: {accountId: "acct-123"}, created: "2026-10-13T14:00:00.000+0000", body: "short"}]
602 )
603 }
604 ' > "${ ATLASSIAN_FIXTURES }/comments-PM-1.json"
605 assert_eq "large comment payload: fixture exceeds the host's ARG_MAX (sanity check on the test itself)" "over" \
606 "$(if [ "$( wc -c < "${ ATLASSIAN_FIXTURES }/comments-PM-1.json")" -gt "$( getconf ARG_MAX)" ]; then echo over; else echo under; fi)"
607 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
608 "ATLASSIAN_JIRA_READ_ONLY_TOKEN=${ TOKEN_VALUE }" -- 2026-10-13
609 assert_eq "large comment payload: exits 0" 0 "${ STATUS }"
610 assert_eq "large comment payload: PM-1 is still present in the output" 1 \
611 "$( jq '[.jira.issues[] | select(.key == "PM-1")] | length' <<< "${ OUT }")"
612 assert_eq "large comment payload: PM-1 still reflects only its own in-window comment" 1 \
613 "$( jq -r '.jira.issues[] | select(.key == "PM-1") | .comment_count' <<< "${ OUT }")"
614 assert_eq "large comment payload: PM-1 involvement still includes commented" "transitioned,commented,assigned" \
615 "$( jq -r '.jira.issues[] | select(.key == "PM-1") | .involvement | join(",")' <<< "${ OUT }")"
616 echo ""
617
618 echo "[A3c] gather-atlassian.sh: In Progress -> Blocked transition does not set returned:true"
619 # Regression check: rank("Blocked") used to equal rank("To Do"), so a transition from
620 # In Progress into Blocked looked "backward" and set returned:true. Blocking your own
621 # ticket isn't the same as a reviewer bouncing it back, so this must stay false.
622 cat > "${ ATLASSIAN_FIXTURES }/search.json" << 'EOF'
623 {
624 "isLast": true,
625 "issues": [
626 {
627 "key": "PM-3",
628 "fields": {
629 "summary": "Self-blocked issue",
630 "status": {"name": "Blocked", "statusCategory": {"name": "To Do"}},
631 "created": "2026-10-01T09:00:00.000+0000",
632 "updated": "2026-10-13T12:00:00.000+0000",
633 "reporter": {"accountId": "other"},
634 "assignee": {"accountId": "acct-123"}
635 },
636 "changelog": {
637 "histories": [
638 {
639 "author": {"accountId": "acct-123"},
640 "created": "2026-10-13T12:30:00.000+0000",
641 "items": [{"field": "status", "fromString": "In Progress", "toString": "Blocked"}]
642 }
643 ]
644 }
645 }
646 ]
647 }
648 EOF
649 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
650 "ATLASSIAN_JIRA_READ_ONLY_TOKEN=${ TOKEN_VALUE }" -- 2026-10-13
651 assert_eq "In Progress -> Blocked: exits 0" 0 "${ STATUS }"
652 assert_eq "In Progress -> Blocked: transition captured" 1 \
653 "$( jq -r '.jira.issues[] | select(.key == "PM-3") | .transitions | length' <<< "${ OUT }")"
654 assert_eq "In Progress -> Blocked: does not set returned:true" false \
655 "$( jq -r '.jira.issues[] | select(.key == "PM-3") | .returned' <<< "${ OUT }")"
656 echo ""
657
658 # ---------------------------------------------------------------------------
659 # gather-atlassian.sh Confluence collection. Same stub curl, same window
660 # (2026-10-13T11:00:00Z -> 2026-10-14T11:00:00Z for TZ=America/New_York, date
661 # 2026-10-13). Confluence credentials are independent of Jira's: these cases omit the
662 # Jira token so Confluence behavior can be tested without Jira also running.
663 # ---------------------------------------------------------------------------
664 CONFLUENCE_TOKEN_VALUE = "c0nflu3nce-sh0uldNeverLeak"
665
666 echo "[A4] gather-atlassian.sh: Confluence credentials unset (Jira present)"
667 rm -f "${ ATLASSIAN_FIXTURES }"/ *
668 cat > "${ ATLASSIAN_FIXTURES }/myself.json" << 'EOF'
669 {"accountId": "acct-123", "displayName": "Recap Tester"}
670 EOF
671 cat > "${ ATLASSIAN_FIXTURES }/search.json" << 'EOF'
672 {"isLast": true, "issues": []}
673 EOF
674 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
675 "ATLASSIAN_JIRA_READ_ONLY_TOKEN=${ TOKEN_VALUE }" -- 2026-10-13
676 assert_eq "Confluence creds unset: exits 0" 0 "${ STATUS }"
677 assert_eq "Confluence creds unset: confluence null despite Jira succeeding" null "$( jq -r '.confluence' <<< "${ OUT }")"
678 assert_eq "Confluence creds unset: jira still present" acct-123 "$( jq -r '.jira.account_id' <<< "${ OUT }")"
679 assert_contains "Confluence creds unset: stderr notice" "Confluence credentials not set; skipping Confluence" "${ ERR }"
680 echo ""
681
682 echo "[A5] gather-atlassian.sh: Confluence identity check returns 401"
683 rm -f "${ ATLASSIAN_FIXTURES }"/ *
684 cat > "${ ATLASSIAN_FIXTURES }/confluence_myself.json" << 'EOF'
685 {"message": "Unauthorized"}
686 EOF
687 echo 401 > "${ ATLASSIAN_FIXTURES }/confluence_myself_status"
688 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
689 "ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN=${ CONFLUENCE_TOKEN_VALUE }" -- 2026-10-13
690 assert_eq "Confluence 401 identity: exits 0" 0 "${ STATUS }"
691 assert_eq "Confluence 401 identity: emits jira null (no Jira token given), confluence null" \
692 '{"jira":null,"confluence":null}' "${ OUT }"
693 assert_contains "Confluence 401 identity: stderr notice" "Confluence identity check failed (HTTP 401); skipping Confluence" "${ ERR }"
694 assert_contains "Confluence 401 identity: calls /rest/api/user/current" "/rest/api/user/current" "$( cat "${ CURL_LOG }")"
695 echo ""
696
697 echo "[A6] gather-atlassian.sh: Confluence happy path"
698 rm -f "${ ATLASSIAN_FIXTURES }"/ *
699 cat > "${ ATLASSIAN_FIXTURES }/confluence_myself.json" << 'EOF'
700 {"accountId": "acct-123", "displayName": "Recap Tester"}
701 EOF
702 # Window under test is 2026-10-13T11:00:00Z -> 2026-10-14T11:00:00Z.
703 cat > "${ ATLASSIAN_FIXTURES }/confluence_search.json" << 'EOF'
704 {
705 "_links": {"base": "https://example.atlassian.net/wiki"},
706 "results": [
707 {
708 "content": {
709 "id": "111",
710 "title": "Edited page",
711 "space": {"key": "ENG"},
712 "version": {"number": 3},
713 "_links": {"webui": "/spaces/ENG/pages/111/Edited+page"}
714 }
715 },
716 {
717 "content": {
718 "id": "222",
719 "title": "Created page",
720 "space": {"key": "ENG"},
721 "version": {"number": 1},
722 "_links": {"webui": "/spaces/ENG/pages/222/Created+page"}
723 }
724 },
725 {
726 "content": {
727 "id": "333",
728 "title": "Stale contributor match (out of window)",
729 "space": {"key": "DOCS"},
730 "version": {"number": 5},
731 "_links": {"webui": "/spaces/DOCS/pages/333/Stale"}
732 }
733 }
734 ]
735 }
736 EOF
737 cat > "${ ATLASSIAN_FIXTURES }/confluence_space-ENG.json" << 'EOF'
738 {"results": [{"id": "900100", "key": "ENG"}]}
739 EOF
740 cat > "${ ATLASSIAN_FIXTURES }/confluence_space-DOCS.json" << 'EOF'
741 {"results": [{"id": "900200", "key": "DOCS"}]}
742 EOF
743 cat > "${ ATLASSIAN_FIXTURES }/confluence_versions-111.json" << 'EOF'
744 {"results": [
745 {"number": 1, "authorId": "other", "createdAt": "2026-10-01T09:00:00.000Z"},
746 {"number": 2, "authorId": "acct-123", "createdAt": "2026-10-05T09:00:00.000Z"},
747 {"number": 3, "authorId": "acct-123", "createdAt": "2026-10-13T12:00:00.000Z"}
748 ]}
749 EOF
750 cat > "${ ATLASSIAN_FIXTURES }/confluence_versions-222.json" << 'EOF'
751 {"results": [
752 {"number": 1, "authorId": "acct-123", "createdAt": "2026-10-13T13:00:00.000Z"}
753 ]}
754 EOF
755 cat > "${ ATLASSIAN_FIXTURES }/confluence_versions-333.json" << 'EOF'
756 {"results": [
757 {"number": 5, "authorId": "acct-123", "createdAt": "2026-09-01T09:00:00.000Z"}
758 ]}
759 EOF
760 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
761 "ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN=${ CONFLUENCE_TOKEN_VALUE }" -- 2026-10-13
762 assert_eq "Confluence happy path: exits 0" 0 "${ STATUS }"
763 assert_eq "Confluence happy path: account_id" acct-123 "$( jq -r .confluence.account_id <<< "${ OUT }")"
764 assert_eq "Confluence happy path: site_url" https://example.atlassian.net/wiki "$( jq -r .confluence.site_url <<< "${ OUT }")"
765 assert_eq "Confluence happy path: window start_utc" 2026-10-13T11:00:00Z "$( jq -r .confluence.window.start_utc <<< "${ OUT }")"
766 assert_eq "Confluence happy path: window end_utc" 2026-10-14T11:00:00Z "$( jq -r .confluence.window.end_utc <<< "${ OUT }")"
767 assert_eq "Confluence happy path: confluence.complete is true when pagination is exhausted" true "$( jq -r .confluence.complete <<< "${ OUT }")"
768 assert_eq "Confluence happy path: stale contributor match (out of window) excluded" 2 "$( jq '.confluence.pages | length' <<< "${ OUT }")"
769 assert_eq "Confluence happy path: edited page flagged as edited, not created" false \
770 "$( jq -r '.confluence.pages[] | select(.title == "Edited page") | .created' <<< "${ OUT }")"
771 assert_eq "Confluence happy path: edited page version count within window" 1 \
772 "$( jq -r '.confluence.pages[] | select(.title == "Edited page") | .version_count' <<< "${ OUT }")"
773 assert_eq "Confluence happy path: created page flagged as created" true \
774 "$( jq -r '.confluence.pages[] | select(.title == "Created page") | .created' <<< "${ OUT }")"
775 assert_eq "Confluence happy path: space key carried through" ENG \
776 "$( jq -r '.confluence.pages[] | select(.title == "Edited page") | .space_key' <<< "${ OUT }")"
777 assert_eq "Confluence happy path: space key resolved to spaceId via /api/v2/spaces" 900100 \
778 "$( jq -r '.confluence.pages[] | select(.title == "Edited page") | .space_id' <<< "${ OUT }")"
779 assert_eq "Confluence happy path: URL built from site base + webui" \
780 "https://example.atlassian.net/wiki/spaces/ENG/pages/111/Edited+page" \
781 "$( jq -r '.confluence.pages[] | select(.title == "Edited page") | .url' <<< "${ OUT }")"
782 assert_eq "Confluence happy path: token not in stdout" "" "$( grep -F -- "${ CONFLUENCE_TOKEN_VALUE }" <<< "${ OUT }" || true )"
783 assert_eq "Confluence happy path: token not in stderr" "" "$( grep -F -- "${ CONFLUENCE_TOKEN_VALUE }" <<< "${ ERR }" || true )"
784 assert_eq "Confluence happy path: token not in curl argv log" "" "$( grep -F -- "${ CONFLUENCE_TOKEN_VALUE }" "${ CURL_LOG }" || true )"
785 assert_eq "Confluence happy path: space lookup is resolved once per key (cached)" 1 \
786 "$( grep -c 'keys=ENG' "${ CURL_LOG }")"
787 echo ""
788
789 echo "[A7] gather-atlassian.sh: Jira search pagination follows isLast:false and keeps both pages' issues"
790 rm -f "${ ATLASSIAN_FIXTURES }"/ *
791 cat > "${ ATLASSIAN_FIXTURES }/myself.json" << 'EOF'
792 {"accountId": "acct-123", "displayName": "Recap Tester"}
793 EOF
794 cat > "${ ATLASSIAN_FIXTURES }/serverinfo.json" << 'EOF'
795 {"baseUrl": "https://example.atlassian.net"}
796 EOF
797 # Window under test (TZ=America/New_York, date 2026-10-13) is
798 # 2026-10-13T11:00:00Z -> 2026-10-14T11:00:00Z.
799 # Page 1 reports isLast:false with a nextPageToken -- the regression this guards against is
800 # jq's `.isLast // true`, which turns an explicit `false` into `true` and stops the loop here.
801 cat > "${ ATLASSIAN_FIXTURES }/search-page1.json" << 'EOF'
802 {
803 "isLast": false,
804 "nextPageToken": "TOKEN-PAGE-2",
805 "issues": [
806 {
807 "key": "PM-10",
808 "fields": {
809 "summary": "Page 1 issue",
810 "status": {"name": "In Progress", "statusCategory": {"name": "In Progress"}},
811 "created": "2026-10-10T09:00:00.000+0000",
812 "updated": "2026-10-13T12:00:00.000+0000",
813 "reporter": {"accountId": "acct-123"},
814 "assignee": {"accountId": "acct-123"}
815 },
816 "changelog": {"histories": []}
817 }
818 ]
819 }
820 EOF
821 cat > "${ ATLASSIAN_FIXTURES }/search-page2.json" << 'EOF'
822 {
823 "isLast": true,
824 "issues": [
825 {
826 "key": "PM-11",
827 "fields": {
828 "summary": "Page 2 issue",
829 "status": {"name": "In Progress", "statusCategory": {"name": "In Progress"}},
830 "created": "2026-10-10T09:00:00.000+0000",
831 "updated": "2026-10-13T13:00:00.000+0000",
832 "reporter": {"accountId": "acct-123"},
833 "assignee": {"accountId": "acct-123"}
834 },
835 "changelog": {"histories": []}
836 }
837 ]
838 }
839 EOF
840 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
841 "ATLASSIAN_JIRA_READ_ONLY_TOKEN=${ TOKEN_VALUE }" -- 2026-10-13
842 assert_eq "pagination: exits 0" 0 "${ STATUS }"
843 assert_eq "pagination: second page is requested with the token from the first" 1 \
844 "$( grep -c '"nextPageToken"' "${ REQUEST_BODY_LOG }")"
845 assert_eq "pagination: both pages' issues appear" "PM-10,PM-11" \
846 "$( jq -r '[.jira.issues[].key] | sort | join(",")' <<< "${ OUT }")"
847 echo ""
848
849 echo "[A7b] gather-atlassian.sh: Jira search hitting the page cap warns and flags complete:false"
850 # Reuses the A7 fixtures: page 1 reports isLast:false with a nextPageToken, i.e. more
851 # pages remain. DAILY_RECAP_MAX_SEARCH_PAGES=1 forces the loop to stop after page 1
852 # without ever requesting page 2, simulating the cap being hit on a busy window without
853 # needing ten-plus pages of fixture data.
854 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
855 "ATLASSIAN_JIRA_READ_ONLY_TOKEN=${ TOKEN_VALUE }" DAILY_RECAP_MAX_SEARCH_PAGES= 1 -- 2026-10-13
856 assert_eq "cap hit: exits 0" 0 "${ STATUS }"
857 assert_eq "cap hit: page 2 is never requested" 0 \
858 "$( grep -c '"nextPageToken"' "${ REQUEST_BODY_LOG }")"
859 assert_eq "cap hit: jira.complete is false" false "$( jq -r .jira.complete <<< "${ OUT }")"
860 assert_contains "cap hit: stderr warns about the Jira page cap" \
861 "Jira search hit the 1-page safety cap with more results remaining" "${ ERR }"
862 echo ""
863
864 echo "[A8] gather-atlassian.sh: Confluence search hitting the page cap warns and flags complete:false"
865 rm -f "${ ATLASSIAN_FIXTURES }"/ *
866 cat > "${ ATLASSIAN_FIXTURES }/confluence_myself.json" << 'EOF'
867 {"accountId": "acct-123", "displayName": "Recap Tester"}
868 EOF
869 # "_links.next" present means more results remain beyond this page. The stub curl always
870 # serves this same fixture regardless of which page URL is requested, but
871 # DAILY_RECAP_MAX_SEARCH_PAGES=1 forces the loop to stop after page 1 without ever
872 # requesting the next page, so that is not exercised here.
873 cat > "${ ATLASSIAN_FIXTURES }/confluence_search.json" << 'EOF'
874 {
875 "_links": {"base": "https://example.atlassian.net/wiki", "next": "/rest/api/search?cql=...&start=25"},
876 "results": []
877 }
878 EOF
879 gather_atlassian TZ=America/New_York ATLASSIAN_CLOUD_ID=cloud1 ATLASSIAN_EMAIL=me@example.com \
880 "ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN=${ CONFLUENCE_TOKEN_VALUE }" DAILY_RECAP_MAX_SEARCH_PAGES= 1 -- 2026-10-13
881 assert_eq "Confluence cap hit: exits 0" 0 "${ STATUS }"
882 assert_eq "Confluence cap hit: confluence.complete is false" false "$( jq -r .confluence.complete <<< "${ OUT }")"
883 assert_contains "Confluence cap hit: stderr warns about the Confluence page cap" \
884 "Confluence search hit the 1-page safety cap with more results remaining" "${ ERR }"
885 echo ""
886
887 # ---------------------------------------------------------------------------
888 # gather-gh-search.sh. A stub `gh` answers `api user` and `api graphql`, choosing a
889 # fixture by which search string the `q` GraphQL variable carries (author:.../
890 # reviewed-by:.../commenter:...) and by whether a `cursor` variable was also sent
891 # (page1 vs page2 fixture). GH_SEARCH_STUB_FAIL makes every call a transport failure,
892 # matching an unauthenticated `gh`; GH_SEARCH_LOGIN_FAIL fails only the login lookup.
893 # ---------------------------------------------------------------------------
894 GH_SEARCH_STUB_BIN = "${ WORK_DIR }/gh-search-stub-bin"
895 GH_SEARCH_LOG = "${ WORK_DIR }/gh-search-calls.log"
896 GH_SEARCH_FIXTURES = "${ WORK_DIR }/gh-search-fixtures"
897 mkdir -p "${ GH_SEARCH_STUB_BIN }" "${ GH_SEARCH_FIXTURES }"
898 cat > "${ GH_SEARCH_STUB_BIN }/gh" << 'EOF'
899 #!/bin/sh
900 echo "$*" >>"${GH_SEARCH_LOG}"
901 [ -n "${GH_SEARCH_STUB_FAIL:-}" ] && exit 1
902
903 if [ "$1" = "api" ] && [ "$2" = "user" ]; then
904 [ -n "${GH_SEARCH_LOGIN_FAIL:-}" ] && exit 1
905 echo "recap-tester"
906 exit 0
907 fi
908
909 q=""
910 cursor=""
911 prev=""
912 for arg in "$@"; do
913 if [ "$prev" = "-f" ]; then
914 case "$arg" in
915 q=*) q="${arg#q=}" ;;
916 cursor=*) cursor="${arg#cursor=}" ;;
917 esac
918 fi
919 prev="$arg"
920 done
921
922 label=""
923 case "$q" in
924 author:*) label="authored" ;;
925 reviewed-by:*) label="reviewed" ;;
926 commenter:*) label="commented" ;;
927 esac
928
929 if [ -n "$cursor" ]; then
930 fixture="${GH_SEARCH_FIXTURES}/${label}-page2.json"
931 else
932 fixture="${GH_SEARCH_FIXTURES}/${label}-page1.json"
933 fi
934 if [ -f "$fixture" ]; then
935 cat "$fixture"
936 else
937 echo '{"data":{"search":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[]}}}'
938 fi
939 EOF
940 chmod +x "${ GH_SEARCH_STUB_BIN }/gh"
941
942 # gather_search [VAR=value ...] -- [args...]
943 # Runs gather-gh-search.sh with the stub gh first on PATH. Sets OUT, ERR, and STATUS.
944 gather_search () {
945 local env_args = ()
946 while [[ $# -gt 0 && " $1 " != "--" ]]; do
947 env_args += ( " $1 " )
948 shift
949 done
950 shift
951 : > "${ GH_SEARCH_LOG }"
952 OUT = "$( env PATH="${ GH_SEARCH_STUB_BIN }:${ PATH }" GH_SEARCH_LOG="${ GH_SEARCH_LOG }" \
953 GH_SEARCH_FIXTURES="${ GH_SEARCH_FIXTURES }" ${ env_args [ @ ]+"${ env_args [ @ ]}"} \
954 bash "${ GATHER_SEARCH }" " $@ " 2> "${ WORK_DIR }/stderr")"
955 STATUS = $?
956 ERR = "$( cat "${ WORK_DIR }/stderr")"
957 }
958
959 echo "[S1] gather-gh-search.sh: client-side window filtering (the updated:>= caveat)"
960 rm -f "${ GH_SEARCH_FIXTURES }"/ *
961 cat > "${ GH_SEARCH_FIXTURES }/authored-page1.json" << 'EOF'
962 {"data":{"search":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[
963 {
964 "__typename": "PullRequest",
965 "number": 100,
966 "title": "Old activity PR",
967 "url": "https://github.com/o/r/pull/100",
968 "state": "OPEN",
969 "isDraft": false,
970 "createdAt": "2026-09-01T00:00:00Z",
971 "mergedAt": null,
972 "author": {"login": "recap-tester"},
973 "repository": {"nameWithOwner": "o/r"},
974 "timelineItems": {"nodes": [
975 {"__typename": "PullRequestCommit", "commit": {"committedDate": "2026-09-02T00:00:00Z", "author": {"user": {"login": "recap-tester"}}}},
976 {"__typename": "IssueComment", "createdAt": "2026-10-13T12:00:00Z", "author": {"login": "someone-else"}},
977 {"__typename": "PullRequestReview", "submittedAt": "2026-10-13T13:00:00Z", "author": {"login": "someone-else"}}
978 ]}
979 },
980 {
981 "__typename": "PullRequest",
982 "number": 101,
983 "title": "Boundary PR",
984 "url": "https://github.com/o/r/pull/101",
985 "state": "OPEN",
986 "isDraft": false,
987 "createdAt": "2026-10-01T00:00:00Z",
988 "mergedAt": null,
989 "author": {"login": "recap-tester"},
990 "repository": {"nameWithOwner": "o/r"},
991 "timelineItems": {"nodes": [
992 {"__typename": "IssueComment", "createdAt": "2026-10-13T11:00:00Z", "author": {"login": "recap-tester"}},
993 {"__typename": "IssueComment", "createdAt": "2026-10-14T11:00:00Z", "author": {"login": "recap-tester"}}
994 ]}
995 }
996 ]}}}
997 EOF
998 gather_search TZ=America/New_York -- 2026-10-13
999 assert_eq "exits 0" 0 "${ STATUS }"
1000 assert_eq "PR with only out-of-window and other-users' activity is dropped" "" \
1001 "$( jq -r '.prs[] | select(.number == 100) | .number' <<< "${ OUT }")"
1002 assert_eq "PR with in-window activity by the user survives" 101 \
1003 "$( jq -r '.prs[] | select(.number == 101) | .number' <<< "${ OUT }")"
1004 echo ""
1005
1006 echo "[S2] gather-gh-search.sh: half-open window boundary"
1007 assert_eq "activity exactly at start_utc is included" 1 \
1008 "$( jq -r '[.prs[] | select(.number == 101) | .activity[] | select(.time == "2026-10-13T11:00:00Z")] | length' <<< "${ OUT }")"
1009 assert_eq "activity exactly at end_utc is excluded" 0 \
1010 "$( jq -r '[.prs[] | select(.number == 101) | .activity[] | select(.time == "2026-10-14T11:00:00Z")] | length' <<< "${ OUT }")"
1011 assert_eq "boundary PR carries exactly its one in-window comment" 1 \
1012 "$( jq -r '.prs[] | select(.number == 101) | .activity | length' <<< "${ OUT }")"
1013 echo ""
1014
1015 echo "[S3] gather-gh-search.sh: a PR found by both the reviewed and commented searches merges to one entry"
1016 rm -f "${ GH_SEARCH_FIXTURES }"/ *
1017 cat > "${ GH_SEARCH_FIXTURES }/reviewed-page1.json" << 'EOF'
1018 {"data":{"search":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[
1019 {
1020 "__typename": "PullRequest",
1021 "number": 200,
1022 "title": "Reviewed and commented PR",
1023 "url": "https://github.com/o/r/pull/200",
1024 "state": "OPEN",
1025 "isDraft": false,
1026 "createdAt": "2026-10-01T00:00:00Z",
1027 "mergedAt": null,
1028 "author": {"login": "someone-else"},
1029 "repository": {"nameWithOwner": "o/r"},
1030 "reviews": {"nodes": [
1031 {"state": "APPROVED", "submittedAt": "2026-10-13T12:00:00Z", "author": {"login": "recap-tester"}}
1032 ]}
1033 }
1034 ]}}}
1035 EOF
1036 cat > "${ GH_SEARCH_FIXTURES }/commented-page1.json" << 'EOF'
1037 {"data":{"search":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[
1038 {
1039 "__typename": "PullRequest",
1040 "number": 200,
1041 "title": "Reviewed and commented PR",
1042 "url": "https://github.com/o/r/pull/200",
1043 "state": "OPEN",
1044 "isDraft": false,
1045 "createdAt": "2026-10-01T00:00:00Z",
1046 "mergedAt": null,
1047 "author": {"login": "someone-else"},
1048 "repository": {"nameWithOwner": "o/r"},
1049 "comments": {"nodes": [
1050 {"createdAt": "2026-10-13T13:00:00Z", "author": {"login": "recap-tester"}}
1051 ]}
1052 }
1053 ]}}}
1054 EOF
1055 gather_search TZ=America/New_York -- 2026-10-13
1056 assert_eq "exits 0" 0 "${ STATUS }"
1057 assert_eq "one PR entry, not two" 1 "$( jq -r '[.prs[] | select(.number == 200)] | length' <<< "${ OUT }")"
1058 assert_eq "roles are reviewed and commented, in that order" "reviewed,commented" \
1059 "$( jq -r '.prs[] | select(.number == 200) | .roles | join(",")' <<< "${ OUT }")"
1060 assert_eq "both role's activity are present" 2 \
1061 "$( jq -r '.prs[] | select(.number == 200) | .activity | length' <<< "${ OUT }")"
1062 echo ""
1063
1064 echo "[S4] gather-gh-search.sh: pagination follows the cursor and keeps both pages' PRs"
1065 rm -f "${ GH_SEARCH_FIXTURES }"/ *
1066 cat > "${ GH_SEARCH_FIXTURES }/authored-page1.json" << 'EOF'
1067 {"data":{"search":{"pageInfo":{"hasNextPage":true,"endCursor":"CURSOR1"},"nodes":[
1068 {
1069 "__typename": "PullRequest",
1070 "number": 300,
1071 "title": "Page 1 PR",
1072 "url": "https://github.com/o/r/pull/300",
1073 "state": "OPEN",
1074 "isDraft": false,
1075 "createdAt": "2026-10-13T12:00:00Z",
1076 "mergedAt": null,
1077 "author": {"login": "recap-tester"},
1078 "repository": {"nameWithOwner": "o/r"},
1079 "timelineItems": {"nodes": []}
1080 }
1081 ]}}}
1082 EOF
1083 cat > "${ GH_SEARCH_FIXTURES }/authored-page2.json" << 'EOF'
1084 {"data":{"search":{"pageInfo":{"hasNextPage":false,"endCursor":null},"nodes":[
1085 {
1086 "__typename": "PullRequest",
1087 "number": 301,
1088 "title": "Page 2 PR",
1089 "url": "https://github.com/o/r/pull/301",
1090 "state": "OPEN",
1091 "isDraft": false,
1092 "createdAt": "2026-10-13T13:00:00Z",
1093 "mergedAt": null,
1094 "author": {"login": "recap-tester"},
1095 "repository": {"nameWithOwner": "o/r"},
1096 "timelineItems": {"nodes": []}
1097 }
1098 ]}}}
1099 EOF
1100 gather_search TZ=America/New_York -- 2026-10-13
1101 assert_eq "exits 0" 0 "${ STATUS }"
1102 assert_eq "second page is requested with the cursor from the first" 1 \
1103 "$( grep -c 'cursor=CURSOR1' "${ GH_SEARCH_LOG }")"
1104 assert_eq "both pages' PRs appear" "300,301" \
1105 "$( jq -r '[.prs[].number] | sort | join(",")' <<< "${ OUT }")"
1106 assert_eq "search_complete is true once pagination is exhausted" true "$( jq -r .search_complete <<< "${ OUT }")"
1107 echo ""
1108
1109 echo "[S5] gather-gh-search.sh: a two-date window runs from START's cutoff to the day after END's"
1110 rm -f "${ GH_SEARCH_FIXTURES }"/ *
1111 gather_search TZ=America/New_York -- 2026-10-13 2026-10-14
1112 assert_eq "exits 0" 0 "${ STATUS }"
1113 assert_eq "window.start_date is START-DATE" 2026-10-13 "$( jq -r .window.start_date <<< "${ OUT }")"
1114 assert_eq "window.end_date is END-DATE" 2026-10-14 "$( jq -r .window.end_date <<< "${ OUT }")"
1115 assert_eq "window.start_utc is START-DATE at the cutoff" 2026-10-13T11:00:00Z "$( jq -r .window.start_utc <<< "${ OUT }")"
1116 assert_eq "window.end_utc is the day after END-DATE at the cutoff" 2026-10-15T11:00:00Z "$( jq -r .window.end_utc <<< "${ OUT }")"
1117 echo ""
1118
1119 echo "[S6] gather-gh-search.sh: a GraphQL errors response fails, naming the query"
1120 rm -f "${ GH_SEARCH_FIXTURES }"/ *
1121 cat > "${ GH_SEARCH_FIXTURES }/authored-page1.json" << 'EOF'
1122 {"errors":[{"message":"Something went wrong executing your query"}]}
1123 EOF
1124 gather_search TZ=America/New_York -- 2026-10-13
1125 assert_eq "exits non-zero" 1 "${ STATUS }"
1126 assert_contains "names the failing query" "authored" "${ ERR }"
1127 echo ""
1128
1129 echo "[S7] gather-gh-search.sh: END-DATE before START-DATE is rejected"
1130 rm -f "${ GH_SEARCH_FIXTURES }"/ *
1131 gather_search TZ=America/New_York -- 2026-10-14 2026-10-13
1132 assert_eq "exits non-zero" 1 "${ STATUS }"
1133 assert_eq "never calls gh" "" "$( cat "${ GH_SEARCH_LOG }")"
1134 echo ""
1135
1136 echo "[S8] gather-gh-search.sh: a missing login fails after the window is still printed"
1137 rm -f "${ GH_SEARCH_FIXTURES }"/ *
1138 gather_search TZ=America/New_York GH_SEARCH_LOGIN_FAIL= 1 -- 2026-10-13
1139 assert_eq "exits non-zero" 1 "${ STATUS }"
1140 assert_contains "window line is still printed to stderr" "# Window:" "${ ERR }"
1141 echo ""
1142
1143 echo "[S9] gather-gh-search.sh: search queries have no updated: upper bound (regression guard)"
1144 rm -f "${ GH_SEARCH_FIXTURES }"/ *
1145 gather_search TZ=America/New_York -- 2026-10-13
1146 assert_eq "exits 0" 0 "${ STATUS }"
1147 assert_eq "no query carries a .. range" "" "$( grep -o 'updated:[^ ]*\.\.[^ ]*' "${ GH_SEARCH_LOG }")"
1148 assert_eq "authored query uses a floor-only updated:>= filter" 1 \
1149 "$( grep -c -- "-f q=author:recap-tester type:pr updated:>=2026-10-13" "${ GH_SEARCH_LOG }")"
1150 assert_eq "reviewed query uses a floor-only updated:>= filter" 1 \
1151 "$( grep -c -- "-f q=reviewed-by:recap-tester type:pr -author:recap-tester updated:>=2026-10-13" "${ GH_SEARCH_LOG }")"
1152 assert_eq "commented query uses a floor-only updated:>= filter" 1 \
1153 "$( grep -c -- "-f q=commenter:recap-tester type:pr -author:recap-tester updated:>=2026-10-13" "${ GH_SEARCH_LOG }")"
1154 echo ""
1155
1156 # ---------------------------------------------------------------------------
1157 # find-sessions.sh. The window under test is 2026-01-05T11:00:00Z to
1158 # 2026-01-06T11:00:00Z. HOME is a path that does not exist, so its project slug is not
1159 # mistaken for a temp directory; the logs live under CLAUDE_CONFIG_DIR.
1160 # ---------------------------------------------------------------------------
1161 START = 2026-01-05T11:00:00Z
1162 END = 2026-01-06T11:00:00Z
1163 FAKE_HOME = /Users/recap-tester
1164 CONFIG_DIR = "${ WORK_DIR }/claude"
1165 PROJECTS = "${ CONFIG_DIR }/projects"
1166 APP = "${ PROJECTS }/-Users-recap-tester-dev-app"
1167 mkdir -p "${ APP }"
1168
1169 # session_file <project-dir> <session> <first-timestamp>
1170 # Writes a minimal in-window session so the skip rules have something to skip.
1171 session_file () {
1172 mkdir -p " $1 "
1173 printf '{"type":"user","timestamp":"%s","cwd":"/x","message":{"role":"user","content":"hello"}}\n' " $3 " > " $1 / $2 .jsonl"
1174 }
1175
1176 # The main fixture: out-of-window entries on both sides, boundary entries with
1177 # milliseconds, harness-injected user entries mixed with real prompts, a tool call, and
1178 # a half-written final line.
1179 cat > "${ APP }/s-window.jsonl" << 'EOF'
1180 {"type":"user","timestamp":"2026-01-05T10:59:59.999Z","cwd":"/Users/recap-tester/dev/app","gitBranch":"old-branch","message":{"role":"user","content":"Prompt from before the window"}}
1181 {"type":"user","timestamp":"2026-01-05T11:00:00.250Z","cwd":"/Users/recap-tester/dev/app","gitBranch":"main","message":{"role":"user","content":"Fix the flaky login test"}}
1182 {"type":"user","timestamp":"2026-01-05T12:00:00.000Z","gitBranch":"main","message":{"role":"user","content":"<command-name>/clear</command-name>"}}
1183 {"type":"user","timestamp":"2026-01-05T12:01:00.000Z","gitBranch":"main","message":{"role":"user","content":"<local-command-stdout>cleared</local-command-stdout>"}}
1184 {"type":"user","timestamp":"2026-01-05T12:02:00.000Z","gitBranch":"main","message":{"role":"user","content":"<system-reminder>Background note</system-reminder>"}}
1185 {"type":"user","timestamp":"2026-01-05T12:03:00.000Z","gitBranch":"main","message":{"role":"user","content":" <task-notification>Task finished</task-notification>"}}
1186 {"type":"user","timestamp":"2026-01-05T12:04:00.000Z","gitBranch":"main","message":{"role":"user","content":"<agent-message from=\"helper\">status update</agent-message>"}}
1187 {"type":"user","timestamp":"2026-01-05T12:05:00.000Z","gitBranch":"main","message":{"role":"user","content":"Another Claude session sent a message: ping"}}
1188 {"type":"user","timestamp":"2026-01-05T12:06:00.000Z","gitBranch":"main","isMeta":true,"message":{"role":"user","content":"Caveat: injected meta entry"}}
1189 {"type":"user","timestamp":"2026-01-05T12:07:00.000Z","gitBranch":"main","isSidechain":true,"message":{"role":"user","content":"Subagent task prompt"}}
1190 {"type":"user","timestamp":"2026-01-05T12:08:00.000Z","gitBranch":"main","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}
1191 {"type":"user","timestamp":"2026-01-05T12:09:00.000Z","gitBranch":"main","message":{"role":"user","content":" "}}
1192 {"type":"user","timestamp":"2026-01-05T13:00:00.000Z","gitBranch":"feature/sync","message":{"role":"user","content":[{"type":"text","text":"Refactor the sync service"},{"type":"image"}]}}
1193 {"type":"assistant","timestamp":"2026-01-05T13:01:00.000Z","gitBranch":"feature/sync","message":{"role":"assistant","content":[{"type":"text","text":"On it."},{"type":"tool_use","id":"t2","name":"Edit","input":{"file_path":"/Users/recap-tester/dev/app/sync.ts"}},{"type":"tool_use","id":"t3","name":"Bash","input":{"command":"npm test"}}]}}
1194 {"type":"user","timestamp":"2026-01-05T14:00:00.000Z","gitBranch":"HEAD","message":{"role":"user","content":"Explain what a <system-reminder> tag is"}}
1195 {"type":"user","timestamp":"2026-01-06T10:59:59.900Z","gitBranch":"main","message":{"role":"user","content":"Last prompt of the workday"}}
1196 {"type":"user","timestamp":"2026-01-06T11:00:00.000Z","gitBranch":"main","message":{"role":"user","content":"Prompt at the window end"}}
1197 {"type":"user","timestamp":"2026-01-06T11:00:00.500Z","gitBranch":"main","message":{"role":"user","content":"Prompt just after the window end"}}
1198 {"type":"user","timestamp":"2026-01-06T12:00:00.000Z","gitBranch":"main","message":{"role":"user","content":"Prompt from the next day"}}
1199 EOF
1200 # A last line cut off mid-string with no newline, as a live session can leave it.
1201 printf '%s' '{"type":"user","timestamp":"2026-01-06T10:30:00.000Z","message":{"role":"user","content":"half-writ' >> "${ APP }/s-window.jsonl"
1202
1203 cat > "${ APP }/s-outside.jsonl" << 'EOF'
1204 {"type":"user","timestamp":"2026-01-04T09:00:00.000Z","message":{"role":"user","content":"Two days early"}}
1205 {"type":"user","timestamp":"2026-01-07T09:00:00.000Z","message":{"role":"user","content":"A day late"}}
1206 EOF
1207
1208 session_file "${ PROJECTS }/-Users-recap-tester" s-home 2026-01-05T15:00:00.000Z
1209 session_file "${ PROJECTS }/-tmp-scratch" s-tmp 2026-01-05T15:00:00.000Z
1210 session_file "${ PROJECTS }/-private-tmp-scratch" s-private-tmp 2026-01-05T15:00:00.000Z
1211 session_file "${ PROJECTS }/-var-folders-ab-cd1234-T-tmp-x" s-var-folders 2026-01-05T15:00:00.000Z
1212 session_file "${ PROJECTS }/-private-var-folders-ab-cd1234-T-tmp-x" s-private-var-folders 2026-01-05T15:00:00.000Z
1213
1214 # find_sessions [args...] with the fixture environment. Sets OUT, ERR, and STATUS.
1215 find_sessions () {
1216 OUT = "$( env HOME="${ FAKE_HOME }" CLAUDE_CONFIG_DIR="${ CONFIG_DIR }" \
1217 bash "${ FIND }" " $@ " 2> "${ WORK_DIR }/stderr")"
1218 STATUS = $?
1219 ERR = "$( cat "${ WORK_DIR }/stderr")"
1220 }
1221
1222 sessions_found () {
1223 jq -r '[.[].session] | join(",")' <<< "${ OUT }"
1224 }
1225
1226 echo "[11] find-sessions.sh session selection"
1227 find_sessions "${ START }" "${ END }"
1228 assert_eq "exits 0" 0 "${ STATUS }"
1229 assert_eq "keeps only the project session with in-window activity" s-window "$( sessions_found )"
1230 assert_eq "no session reported unreadable" "" "${ ERR }"
1231 find_sessions "${ START }" "${ END }" --include-home
1232 assert_eq "--include-home adds the home session, ordered by first activity" s-window,s-home "$( sessions_found )"
1233 assert_eq "home digest names its project" -Users-recap-tester "$( jq -r '.[1].project' <<< "${ OUT }")"
1234 echo ""
1235
1236 echo "[12] find-sessions.sh window boundaries and the half-written line"
1237 find_sessions "${ START }" "${ END }"
1238 DIGEST = "$( jq '.[0]' <<< "${ OUT }")"
1239 assert_eq "first activity is the entry 250 ms into the window" 2026-01-05T11:00:00.250Z "$( jq -r .first_activity <<< "${ DIGEST }")"
1240 assert_eq "last activity is the final in-window entry" 2026-01-06T10:59:59.900Z "$( jq -r .last_activity <<< "${ DIGEST }")"
1241 assert_eq "counts in-window entries only, without the cut-off line" 15 "$( jq -r .entries_in_window <<< "${ DIGEST }")"
1242 assert_eq "cwd comes from the first in-window entry" /Users/recap-tester/dev/app "$( jq -r .cwd <<< "${ DIGEST }")"
1243 assert_eq "branches skip HEAD and out-of-window entries" feature/sync,main "$( jq -r '.branches | join(",")' <<< "${ DIGEST }")"
1244 assert_eq "path points at the session log" "${ APP }/s-window.jsonl" "$( jq -r .path <<< "${ DIGEST }")"
1245 echo ""
1246
1247 echo "[13] find-sessions.sh prompt filter"
1248 assert_eq "keeps only real prompts" 4 "$( jq -r .prompt_count <<< "${ DIGEST }")"
1249 assert_eq "real prompts in order" \
1250 "Fix the flaky login test|Refactor the sync service|Explain what a <system-reminder> tag is|Last prompt of the workday" \
1251 "$( jq -r '.prompts | join("|")' <<< "${ DIGEST }")"
1252 for injected in "<command-name>" "<local-command-stdout>" "<system-reminder>Background" "<task-notification>" "<agent-message" "Another Claude session" "Caveat:" "Subagent task" ; do
1253 if grep -qF -- "${ injected }" <<< "$( jq -r '.prompts[]' <<< "${ DIGEST }")" ; then
1254 fail "drops '${ injected }' entries"
1255 else
1256 pass "drops '${ injected }' entries"
1257 fi
1258 done
1259 assert_eq "counts tool calls" 2 "$( jq -r .tool_calls <<< "${ DIGEST }")"
1260 assert_eq "lists edited files" /Users/recap-tester/dev/app/sync.ts "$( jq -r '.files_edited | join(",")' <<< "${ DIGEST }")"
1261 echo ""
1262
1263 echo "[14] find-sessions.sh default log location"
1264 DEFAULT_HOME = "${ WORK_DIR }/home"
1265 session_file "${ DEFAULT_HOME }/.claude/projects/-Users-someone-dev-api" s-default 2026-01-05T15:00:00.000Z
1266 OUT = "$( env -u CLAUDE_CONFIG_DIR HOME="${ DEFAULT_HOME }" bash "${ FIND }" "${ START }" "${ END }" 2> /dev/null)"
1267 assert_eq "reads ~/.claude/projects when CLAUDE_CONFIG_DIR is unset" s-default "$( sessions_found )"
1268 echo ""
1269
1270 echo "[15] find-sessions.sh argument checks"
1271 find_sessions "${ START }"
1272 assert_eq "one timestamp: exits 2" 2 "${ STATUS }"
1273 assert_contains "one timestamp: prints usage" "Usage: find-sessions.sh" "${ ERR }"
1274 find_sessions "${ START }" "${ END }" --everything
1275 assert_eq "unknown flag: exits 2" 2 "${ STATUS }"
1276 find_sessions 2026-01-05 "${ END }"
1277 assert_eq "date without time: exits 1" 1 "${ STATUS }"
1278 assert_contains "date without time: shows the expected form" "timestamps must look like 2026-10-13T11:00:00Z" "${ ERR }"
1279 find_sessions "${ END }" "${ START }"
1280 assert_eq "reversed window: exits 1" 1 "${ STATUS }"
1281 OUT = "$( env HOME="${ FAKE_HOME }" CLAUDE_CONFIG_DIR="${ WORK_DIR }/missing" bash "${ FIND }" "${ START }" "${ END }" 2>&1 )"
1282 assert_contains "missing log directory: says where it looked" "no Claude Code session logs found at ${ WORK_DIR }/missing/projects" "${ OUT }"
1283 echo ""
1284
1285 # ---------------------------------------------------------------------------
1286 # template.html CSP script hash. The Content-Security-Policy pins the inline
1287 # <script> by its SHA-256 hash; if the script is edited without recomputing the
1288 # hash, the page still renders but filtering and Copy silently stop working.
1289 # ---------------------------------------------------------------------------
1290 echo "[16] template.html CSP script hash"
1291 if ! command -v perl > /dev/null 2>&1 || ! command -v openssl > /dev/null 2>&1 ; then
1292 echo " (skipped: perl and openssl are required for this case)"
1293 else
1294 SCRIPT_HASH = "$( perl -0777 -ne 'print $1 if /<script>(.*?)<\/script>/s' "${ TEMPLATE }" | openssl dgst -sha256 -binary | openssl base64)"
1295 CSP_HASH = "$( grep -o "sha256-[^']*" "${ TEMPLATE }" | head -1 | sed 's/^sha256-//')"
1296 if [[ "${ SCRIPT_HASH }" == "${ CSP_HASH }" ]]; then
1297 pass "CSP sha256 hash matches the <script> block"
1298 else
1299 fail "CSP sha256 hash matches the <script> block"
1300 echo " expected (from <script>): ${ SCRIPT_HASH }"
1301 echo " got (from CSP meta): ${ CSP_HASH }"
1302 echo " see README.md 'Maintaining the Template' to regenerate the hash"
1303 fi
1304 fi
1305 echo ""
1306
1307 echo "=== ${ PASSED } passed, ${ FAILED } failed ==="
1308 [[ "${ FAILED }" -eq 0 ]]