Recently Written · git

sbm

dmenu bookmarks. Instantly fuzzy search thousands of bookmarks and plumb them. LOOKING FOR SUCKLESS SOFTWARE EDITION? GO TO sbm-suckless INSTEAD

git clone https://github.com/equwal/sbm

Log | Files | Refs


test/run.sh (46713 bytes)

1 #!/bin/sh
2 
3 # Non-interactive tests for bm and the bm-* tools. Menus are scripted through
4 # test/fakemenu and test/fakefzf, and the clipboard and opener are stubs that
5 # write to files.
6 # usage: sh test/run.sh        (SBM_SH=dash sh test/run.sh to pick the shell)
7 
8 # Several groups point BOOKMARKS somewhere else inside a subshell on purpose.
9 # shellcheck disable=SC2030,SC2031
10 
11 here=$(cd "$(dirname "$0")" && pwd)
12 top=$(cd "$here/.." && pwd)
13 BM="${SBM_SH:-sh} $top/bm"
14 MIGRATE="${SBM_SH:-sh} $top/bm-migrate"
15 IMPORT="${SBM_SH:-sh} $top/bm-import"
16 CHECK="${SBM_SH:-sh} $top/bm-check"
17 HTML="${SBM_SH:-sh} $top/bm-html"
18 TITLE="${SBM_SH:-sh} $top/bm-title"
19 TAB=$(printf '\t')
20 
21 work=${TMPDIR:-/tmp}/sbmtest.$$
22 mkdir -m 700 "$work" || exit 1
23 trap 'rm -rf "$work"' EXIT INT TERM
24 
25 export BOOKMARKS="$work/bookmarks"
26 export USERTAGS="$work/usertags"
27 export SBM_TEST_ANSWERS="$work/answers"
28 export SBM_MENU="$here/fakemenu"
29 export SBM_COPY="$work/copy"
30 export SBM_PASTE="$work/paste"
31 export SBM_OPEN="$work/open"
32 export XDG_DATA_HOME="$work/xdg"
33 # Nothing here may touch the network or the user's git setup.
34 export SBM_FETCH=0
35 # bm starts bm-sync after each change when it is installed. This config is
36 # empty, so bm-sync cannot reach the account of the user.
37 export SBM_SYNC_CONFIG="$work/sync.conf"
38 
39 cp "$here/../usertags" "$USERTAGS"
40 
41 printf '#!/bin/sh\ncat > "%s"\n' "$work/clipboard" > "$SBM_COPY"
42 printf '#!/bin/sh\ncat "%s" 2>/dev/null\n' "$work/clipboard" > "$SBM_PASTE"
43 # shellcheck disable=SC2016
44 printf '#!/bin/sh\nprintf "%%s\\n" "$1" > "%s"\n' "$work/opened" > "$SBM_OPEN"
45 mkdir "$work/bin"
46 cp "$here/fakemenu" "$work/bin/dmenu"
47 chmod +x "$SBM_COPY" "$SBM_PASTE" "$SBM_OPEN" "$work/bin/dmenu"
48 
49 pass=0
50 fail=0
51 
52 ok () {
53     pass=$((pass + 1))
54     printf 'ok   %s\n' "$1"
55 }
56 
57 notok () {
58     fail=$((fail + 1))
59     printf 'FAIL %s\n' "$1"
60     [ -z "$2" ] || printf '     %s\n' "$2"
61 }
62 
63 # eq <name> <actual> <expected>
64 eq () {
65     if [ "$2" = "$3" ]; then
66         ok "$1"
67     else
68         notok "$1" "expected [$3] got [$2]"
69     fi
70 }
71 
72 # answers <line>...: script the next menu answers, one per prompt.
73 answers () {
74     : > "$SBM_TEST_ANSWERS"
75     for a in "$@"; do
76         printf '%s\n' "$a" >> "$SBM_TEST_ANSWERS"
77     done
78 }
79 
80 reset () {
81     : > "$BOOKMARKS"
82     rm -f "$work/clipboard" "$work/opened" "$BOOKMARKS.bak"
83 }
84 
85 # ---- add ----
86 
87 reset
88 answers 'Example site' 'sec code sec'
89 $BM --add https://example.com 2>/dev/null
90 eq 'add writes url<tab>desc<tab>sorted unique tags' \
91     "$(cat "$BOOKMARKS")" "https://example.com${TAB}Example site${TAB}code sec"
92 
93 reset
94 answers '100%s | done %d' 'lib'
95 $BM -a https://percent.example 2>/dev/null
96 eq 'add keeps % and | in the description' \
97     "$(cut -f2 "$BOOKMARKS")" '100%s | done %d'
98 
99 reset
100 printf 'https://clip.example/page\n' > "$work/clipboard"
101 answers '' 'From clipboard' 'org'
102 $BM -a 2>/dev/null
103 eq 'add with a closed url prompt adds nothing' "$(cat "$BOOKMARKS")" ''
104 
105 reset
106 printf 'https://clip.example/page\n' > "$work/clipboard"
107 answers 'https://typed.example' 'Typed' 'org'
108 $BM -a 2>/dev/null
109 eq 'add takes the url from the prompt' \
110     "$(cut -f1 "$BOOKMARKS")" 'https://typed.example'
111 
112 reset
113 answers 'No tags' ''
114 $BM -a https://notags.example 2>/dev/null
115 eq 'add without tags leaves the tag field empty' \
116     "$(cat "$BOOKMARKS")" "https://notags.example${TAB}No tags${TAB}"
117 
118 # ---- duplicates ----
119 
120 reset
121 answers 'First' 'code'
122 $BM -a https://www.Example.com/docs/ 2>/dev/null
123 for dup in http://example.com/docs https://example.com/docs/ \
124            https://WWW.EXAMPLE.COM/docs example.com/docs; do
125     answers 'Second' 'code'
126     $BM -a "$dup" 2>/dev/null
127     rc=$?
128     eq "add rejects duplicate $dup with status 3" "$rc $(wc -l < "$BOOKMARKS" | tr -d ' ')" '3 1'
129 done
130 answers 'Other path' 'code'
131 $BM -a https://example.com/Docs 2>/dev/null
132 eq 'add keeps urls differing only in path case apart' \
133     "$(wc -l < "$BOOKMARKS" | tr -d ' ')" '2'
134 
135 # ---- copy, open, list, tag filter, delete ----
136 
137 reset
138 printf '%s\n' \
139     "https://b.example${TAB}Bee${TAB}code lib" \
140     "https://a.example${TAB}Ay${TAB}sec" \
141     "https://c.example${TAB}Sea${TAB}code" > "$BOOKMARKS"
142 
143 answers 'Bee'
144 $BM -c
145 eq 'copy puts only the url on the clipboard' \
146     "$(cat "$work/clipboard")" 'https://b.example'
147 
148 answers ''
149 $BM -c
150 eq 'copy exits 1 when the menu is closed' "$?" '1'
151 
152 answers 'Sea'
153 $BM --open
154 eq 'open hands the url to the opener' "$(cat "$work/opened")" 'https://c.example'
155 
156 answers 'Ay'
157 $BM --plumb
158 eq '--plumb is an alias for --open' "$(cat "$work/opened")" 'https://a.example'
159 
160 eq 'list prints sorted rows' "$($BM -l | cut -f1 | paste -sd ' ' -)" \
161     'https://a.example https://b.example https://c.example'
162 
163 eq 'list honours the tag filter' "$($BM -t code -l | cut -f1 | paste -sd ' ' -)" \
164     'https://b.example https://c.example'
165 
166 eq 'tag filter matches whole tags only' "$($BM -t cod -l)" ''
167 
168 answers 'example' 'copy'
169 $BM -t sec
170 eq 'a tag filter narrows the browser' \
171     "$(cat "$work/clipboard")" 'https://a.example'
172 
173 $BM -t nosuchtag -c 2>/dev/null
174 eq 'copy with an unused tag fails' "$?" '1'
175 
176 answers 'Sea' 'copy'
177 $BM
178 eq 'no arguments: pick the bookmark, then what to do' \
179     "$(cat "$work/clipboard")" 'https://c.example'
180 
181 answers 'Ay'
182 eq '--print prints the url' "$($BM --print)" 'https://a.example'
183 answers 'no such bookmark'
184 $BM -c 2>/dev/null
185 eq 'copy refuses text that is not a bookmark' "$?" '1'
186 
187 answers 'Bee'
188 $BM -d >/dev/null
189 eq 'delete removes only the chosen row' "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" \
190     'https://a.example https://c.example'
191 
192 # ---- dmenu backend: closing the tag menu must not loop forever ----
193 
194 reset
195 answers 'Escaped' 'sec'
196 (
197     PATH="$work/bin:$PATH" SBM_MENU=dmenu $BM -a https://dmenu.example 2>/dev/null
198 ) &
199 pid=$!
200 ( sleep 10; kill "$pid" 2>/dev/null ) &
201 watchdog=$!
202 wait "$pid"
203 kill "$watchdog" 2>/dev/null
204 eq 'dmenu tag loop ends when the menu is closed' \
205     "$(cat "$BOOKMARKS")" "https://dmenu.example${TAB}Escaped${TAB}sec"
206 
207 # ---- bm-migrate ----
208 
209 reset
210 printf '%s\n' 'https://old.example Old style | code sec' > "$BOOKMARKS"
211 eq 'bm warns about an old format file and names the tool' \
212     "$($BM -l 2>&1 >/dev/null | grep -c 'bm-migrate')" '1'
213 
214 # backups: how many backups sit beside the bookmark file.
215 backups () {
216     set -- "$BOOKMARKS".bak*
217     if [ -e "$1" ]; then echo $#; else echo 0; fi
218 }
219 
220 
221 old_file () {
222     rm -f "$BOOKMARKS".bak*
223     printf '%s\n' \
224         '# my bookmarks' \
225         '' \
226         'https://old.example Old style | code sec' \
227         'https://equwal.com  Spenser Truex'"'"'s website.' \
228         'https://pipe.example a | b | lib' \
229         '  https://indented.example   spaced   out  |  org  ' \
230         'https://bare.example' \
231         'https://100.example 100%s done \n | lib' \
232         "https://new.example${TAB}Already TSV${TAB}org" > "$BOOKMARKS"
233 }
234 migrated=$(printf '%s\n' \
235     '# my bookmarks' \
236     '' \
237     "https://old.example${TAB}Old style${TAB}code sec" \
238     "https://equwal.com${TAB}Spenser Truex's website.${TAB}" \
239     "https://pipe.example${TAB}a | b${TAB}lib" \
240     "https://indented.example${TAB}spaced   out${TAB}org" \
241     "https://bare.example${TAB}${TAB}" \
242     "https://100.example${TAB}100%s done \\n${TAB}lib" \
243     "https://new.example${TAB}Already TSV${TAB}org")
244 
245 old_file
246 original=$(cat "$BOOKMARKS")
247 eq 'bm-migrate -n prints the converted file' "$($MIGRATE -n "$BOOKMARKS")" "$migrated"
248 eq 'bm-migrate -n changes nothing' \
249     "$(cat "$BOOKMARKS")|$(backups)" "$original|0"
250 eq 'bm-migrate - is a filter' "$($MIGRATE - < "$BOOKMARKS")" "$migrated"
251 eq 'bm-migrate reports what it did' "$($MIGRATE "$BOOKMARKS")" \
252     "migrated 6 lines in $BOOKMARKS (original kept as $BOOKMARKS.bak)"
253 eq 'bm-migrate converts in place' "$(cat "$BOOKMARKS")" "$migrated"
254 eq 'bm-migrate keeps the original as .bak' "$(cat "$BOOKMARKS.bak")" "$original"
255 eq 'bm-migrate has nothing to do the second time' "$($MIGRATE "$BOOKMARKS")" \
256     "nothing to migrate in $BOOKMARKS"
257 eq 'bm-migrate then leaves file and backup alone' \
258     "$(cat "$BOOKMARKS")|$(cat "$BOOKMARKS.bak")|$(backups)" \
259     "$migrated|$original|1"
260 
261 printf '%s\n' 'https://late.example Added by hand | org' >> "$BOOKMARKS"
262 $MIGRATE "$BOOKMARKS" >/dev/null
263 eq 'bm-migrate never overwrites an earlier backup' \
264     "$(cat "$BOOKMARKS.bak")|$(grep -c late.example "$BOOKMARKS.bak.1")" "$original|1"
265 
266 old_file
267 eq 'bm-migrate defaults to the BOOKMARKS variable' "$($MIGRATE >/dev/null; cat "$BOOKMARKS")" "$migrated"
268 
269 eq 'bm is quiet once the file is migrated' "$($BM -l 2>&1 >/dev/null)" ''
270 
271 old_file
272 ln -s "$BOOKMARKS" "$work/link"
273 chmod 600 "$BOOKMARKS"
274 $MIGRATE "$work/link" >/dev/null
275 eq 'bm-migrate keeps a symlinked file a symlink, and its mode' \
276     "$([ -L "$work/link" ] && echo link) $(find "$BOOKMARKS" -perm 600 | wc -l | tr -d ' ') $(grep -c "$TAB" "$BOOKMARKS")" \
277     'link 1 7'
278 chmod 644 "$BOOKMARKS"
279 
280 $MIGRATE "$work/nosuchfile" 2>/dev/null
281 eq 'bm-migrate fails on a missing file' "$?" '1'
282 $MIGRATE --bogus 2>/dev/null
283 eq 'bm-migrate rejects unknown options' "$?" '2'
284 $MIGRATE a b 2>/dev/null
285 eq 'bm-migrate takes one file' "$?" '2'
286 rm -f "$BOOKMARKS".bak* "$work/link" "$work/link".bak*
287 
288 # ---- argument handling ----
289 
290 reset
291 $BM --bogus 2>/dev/null
292 eq 'unknown flag exits 2' "$?" '2'
293 
294 answers 'x' 'code'
295 $BM -c -a https://two.example 2>/dev/null
296 eq 'two actions exit 2 without side effects' "$? $(cat "$BOOKMARKS")" '2 '
297 
298 $BM -t 2>/dev/null
299 eq '-t without a tag exits 2' "$?" '2'
300 
301 missing=
302 for flag in --copy --open --plumb --print --add --edit --delete --list --merge --tag --sort; do
303     $BM -h | grep -q -e "$flag" || missing="$missing $flag"
304 done
305 eq 'help mentions every flag' "$missing" ''
306 
307 for gone in '--import x' --check --migrate '-s x'; do
308     # shellcheck disable=SC2086
309     $BM $gone 2>/dev/null
310     eq "bm no longer does $gone itself" "$?" '2'
311 done
312 
313 bad=
314 for script in bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch; do
315     [ "$(sed -n 1p "$top/$script")" = '#!/bin/sh' ] || bad="$bad $script"
316 done
317 eq 'every script asks for /bin/sh' "$bad" ''
318 
319 # ---- sorting and use counts ----
320 
321 seed () {
322     reset
323     rm -f "$BOOKMARKS.usage"
324     printf '%s\n' \
325         "https://b.example${TAB}Zed${TAB}code lib" \
326         "https://a.example${TAB}Mid${TAB}sec" \
327         "https://c.example${TAB}Alpha${TAB}code" > "$BOOKMARKS"
328 }
329 
330 # order <bm arguments>: the hosts' first letters, in listed order.
331 order () {
332     $BM "$@" -l | sed 's|https://\(.\).*|\1|' | paste -sd ' ' -
333 }
334 
335 seed
336 eq 'default order without use counts is by url' "$(order)" 'a b c'
337 eq 'sort url'    "$(order -S url)" 'a b c'
338 eq 'sort recent' "$(order --sort recent)" 'c a b'
339 eq 'sort desc'   "$(order -S desc)" 'c a b'
340 eq 'sort tag'    "$(order -S tag)" 'c b a'
341 eq 'SBM_SORT sets the default' "$(SBM_SORT=recent order)" 'c a b'
342 $BM -S bogus -l 2>/dev/null
343 eq 'unknown sort order exits 2' "$?" '2'
344 
345 answers 'Alpha'; $BM -c
346 answers 'Alpha'; $BM -o
347 answers 'Zed';   $BM -c
348 eq 'sort used puts the most used first' "$(order -S used)" 'c b a'
349 eq 'use counts are kept beside the bookmarks' \
350     "$(grep -c "^https://c.example${TAB}2${TAB}[0-9]\{10,\}\$" "$BOOKMARKS.usage")" '1'
351 answers 'Alpha'; $BM -d >/dev/null
352 eq 'delete forgets the use count' "$(grep -c 'c.example' "$BOOKMARKS.usage")" '0'
353 
354 # A writable file in a directory that is not: temporary files go to $TMPDIR.
355 if [ "$(id -u)" -ne 0 ]; then
356     mkdir "$work/locked" "$work/tmp"
357     printf '%s\n' "https://keep.example${TAB}Keep${TAB}" "https://drop.example${TAB}Drop${TAB}" \
358         > "$work/locked/bookmarks"
359     : > "$work/locked/bookmarks.usage"
360     chmod 555 "$work/locked"
361     (
362         BOOKMARKS="$work/locked/bookmarks" TMPDIR="$work/tmp"
363         export BOOKMARKS TMPDIR
364         answers 'Drop'; $BM -d >/dev/null
365         answers 'Keep'; $BM -c
366         printf 'https://merged.example\n' | $BM -m >/dev/null
367     )
368     eq 'a read-only directory does not stop delete, merge or use counts' \
369         "$(cut -f1 "$work/locked/bookmarks" | paste -sd ' ' -) $(cut -f1,2 "$work/locked/bookmarks.usage")" \
370         "https://keep.example https://merged.example https://keep.example${TAB}1"
371     eq 'and no temporary file is left behind' "$(find "$work/tmp" "$work/locked" -name '*sbm*' -o -name '*.tmp.*' | wc -l | tr -d ' ')" '0'
372     ln -s "$work/locked/bookmarks" "$work/tmp/sbm.planted"
373     # tmpfile relies on this: with noclobber, ">" refuses a name that exists,
374     # a symlink included, instead of writing through it.
375     # shellcheck disable=SC2016
376     eq 'a name planted in the temporary directory is refused, not written through' \
377         "$( if ${SBM_SH:-sh} -c 'set -C; : > "$1"' sh "$work/tmp/sbm.planted" 2>/dev/null
378             then echo written; else echo refused; fi) $(grep -c . "$work/locked/bookmarks")" 'refused 2'
379     chmod 755 "$work/locked"
380 fi
381 
382 # ---- bm-title, and add with it installed ----
383 
384 mkdir "$work/net"
385 cat > "$work/net/curl" <<'FAKE'
386 #!/bin/sh
387 printf 'curl %s\n' "$*" >> "$SBM_TEST_CURLLOG"
388 probe= head=
389 for arg in "$@"; do
390     case $arg in
391         -w)  probe=1 ;;
392         -sI) head=1 ;;
393     esac
394     url=$arg
395 done
396 if [ -z "$probe" ]; then
397     case $url in
398         *oneline*)
399             # A minified page: one line, the title beyond the first 64k.
400             awk 'BEGIN { for (i = 0; i < 70000; i++) printf "x"; print "<title>too far</title>" }'
401             exit 0
402             ;;
403         *nearline*)
404             awk 'BEGIN { for (i = 0; i < 60000; i++) printf "x"; print "<title>near enough</title>" }'
405             exit 0
406             ;;
407     esac
408     printf '<html><head>\n<meta charset="utf-8"><TITLE lang="en">\n  Fetched &amp; decoded\n  &#39;title&#39; </Title></head><body><title>no</title>'
409     exit 0
410 fi
411 case $url in
412     *dead*)   printf '404 ' ;;
413     *moved*)  printf '301 https://new.example/' ;;
414     *nohead*) if [ -n "$head" ]; then printf '405 '; else printf '206 '; fi ;;
415     *gone*)   printf '000 ' ;;
416     *)        printf '200 ' ;;
417 esac
418 FAKE
419 chmod +x "$work/net/curl"
420 export SBM_TEST_CURLLOG="$work/curllog"
421 
422 eq 'bm-title prints a decoded, squeezed title' \
423     "$(PATH="$work/net:$PATH" SBM_FETCH=1 $TITLE https://fetch.example)" "Fetched & decoded 'title'"
424 eq 'bm-title stops reading a one-line page at 64k' \
425     "$(PATH="$work/net:$PATH" SBM_FETCH=1 $TITLE https://oneline.example)|$(PATH="$work/net:$PATH" SBM_FETCH=1 $TITLE https://nearline.example)" \
426     '|near enough'
427 $TITLE 2>/dev/null
428 eq 'bm-title wants exactly one url' "$?" '2'
429 
430 reset
431 : > "$SBM_TEST_CURLLOG"
432 answers '<default>' 'code'
433 PATH="$top:$work/net:$PATH" SBM_FETCH=1 $BM -a https://fetch.example 2>/dev/null
434 eq 'add offers the fetched <title> as the description' \
435     "$(cut -f2 "$BOOKMARKS")" "Fetched & decoded 'title'"
436 
437 reset
438 : > "$SBM_TEST_CURLLOG"
439 answers 'Mine' 'code'
440 PATH="$top:$work/net:$PATH" SBM_FETCH=1 $BM -a https://fetch.example 2>/dev/null
441 eq 'a typed description beats the fetched title' "$(cut -f2 "$BOOKMARKS")" 'Mine'
442 
443 reset
444 : > "$SBM_TEST_CURLLOG"
445 answers 'Offline' 'code'
446 PATH="$top:$work/net:$PATH" SBM_FETCH=0 $BM -a https://fetch.example 2>/dev/null
447 eq 'SBM_FETCH=0 never runs curl' "$(cat "$SBM_TEST_CURLLOG")" ''
448 
449 reset
450 cp "$here/../usertags" "$USERTAGS"
451 answers 'New tag' 'code brandnew'
452 $BM -a https://newtag.example 2>/dev/null
453 answers 'Again' 'brandnew'
454 $BM -a https://newtag2.example 2>/dev/null
455 eq 'a new tag is used' "$(sed -n 1p "$BOOKMARKS" | cut -f3)" 'brandnew code'
456 eq 'a new tag is remembered once' "$(grep -c '^brandnew | $' "$USERTAGS")" '1'
457 
458 reset
459 rm -f "$USERTAGS"
460 answers 'No tag file' 'first'
461 $BM -a https://notagfile.example 2>/dev/null
462 eq 'a missing tag file is created from the first new tag' \
463     "$(cat "$USERTAGS")|$(cut -f3 "$BOOKMARKS")" 'first | |first'
464 cp "$here/../usertags" "$USERTAGS"
465 
466 # ---- bm-import and bm --merge ----
467 
468 reset
469 printf '%s\n' "https://old.example${TAB}Kept${TAB}org" > "$BOOKMARKS"
470 eq 'bm-import html prints rows: entities decoded, folders as tags, web urls only' \
471     "$($IMPORT "$here/fixtures/netscape.html")" "$(printf '%s\n' \
472         "https://top.example/${TAB}Top level${TAB}" \
473         "https://git.example/?a=1&b=2${TAB}Git & friends${TAB}dev-tools" \
474         "https://sh.example/posix${TAB}POSIX sh${TAB}dev-tools shell" \
475         "http://www.top.example${TAB}Top level again${TAB}")"
476 eq 'bm-import leaves the bookmark file alone' "$(grep -c . "$BOOKMARKS")" '1'
477 eq 'merge reports its counts' \
478     "$($IMPORT "$here/fixtures/netscape.html" | $BM --merge)" 'added 3, skipped 1 duplicates'
479 eq 'merge appends only what is new' "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" \
480     'https://old.example https://top.example/ https://git.example/?a=1&b=2 https://sh.example/posix'
481 eq 'merge teaches the tags' "$(grep -c -e '^dev-tools | $' -e '^shell | $' "$USERTAGS")" '2'
482 eq 'merging twice adds nothing' \
483     "$($IMPORT "$here/fixtures/netscape.html" | $BM -m)" 'added 0, skipped 4 duplicates'
484 eq 'merge takes a bare list of urls' \
485     "$(printf 'https://bare.example\n\n# note\n' | $BM -m; tail -n 1 "$BOOKMARKS")" \
486     "$(printf 'added 1, skipped 0 duplicates\nhttps://bare.example\t\t')"
487 $IMPORT "$work/nosuchfile" 2>/dev/null
488 eq 'bm-import fails on a missing file' "$?" '1'
489 $IMPORT 2>/dev/null
490 eq 'bm-import wants one source' "$?" '2'
491 
492 if command -v jq >/dev/null 2>&1; then
493     eq 'bm-import json skips the trash and non-web urls, tags by folder' \
494         "$($IMPORT "$here/fixtures/chromium.json")" "$(printf '%s\n' \
495             "https://bar.example/${TAB}Bar site${TAB}" \
496             "https://blog.example/feed${TAB}A blog${TAB}news-blogs" \
497             "https://deep.example/${TAB}Deep${TAB}news-blogs tech" \
498             "http://www.bar.example${TAB}Bar dupe${TAB}")"
499 
500     # profiles <dir> <folder>...: a browser directory with these profiles,
501     # each with the fixture as its bookmarks.
502     profiles () {
503         dir=$1
504         shift
505         for folder; do
506             mkdir -p "$dir/$folder"
507             cp "$here/fixtures/chromium.json" "$dir/$folder/Bookmarks"
508         done
509     }
510     json_rows=$(printf '%s\n' \
511         "https://bar.example/${TAB}Bar site${TAB}" \
512         "https://blog.example/feed${TAB}A blog${TAB}news-blogs" \
513         "https://deep.example/${TAB}Deep${TAB}news-blogs tech" \
514         "http://www.bar.example${TAB}Bar dupe${TAB}")
515     profiles "$work/linux/.config/BraveSoftware/Brave-Browser" Default 'Profile 1'
516     eq 'bm-import brave reads the bookmarks of every profile' \
517         "$(HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT brave)" \
518         "$(printf '%s\n%s\n' "$json_rows" "$json_rows")"
519     profiles "$work/mac/Library/Application Support/Google/Chrome" Default 'Profile 2'
520     eq 'bm-import finds a browser in ~/Library/Application Support' \
521         "$(HOME=$work/mac XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT chrome | wc -l | tr -d ' ')" '8'
522     profiles "$work/win/Microsoft/Edge/User Data" Default
523     eq 'bm-import finds a browser in LOCALAPPDATA' \
524         "$(HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA=$work/win $IMPORT edge)" "$json_rows"
525     HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT brave 2>/dev/null
526     eq 'bm-import fails when it finds no profiles of the browser' "$?" '1'
527     eq 'bm-import --files lists the bookmark file of each profile' \
528         "$(HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT --files brave | sed "s|^$work/linux/||")" \
529         "$(printf '%s\n' '.config/BraveSoftware/Brave-Browser/Default/Bookmarks' \
530             '.config/BraveSoftware/Brave-Browser/Profile 1/Bookmarks')"
531     $IMPORT --files "$here/fixtures/chromium.json" 2>/dev/null
532     eq 'bm-import --files wants a browser name' "$?" '2'
533 
534     # bm-watch runs bm-import and bm by name.
535     WATCH="${SBM_SH:-sh} $top/bm-watch"
536     reset
537     eq 'bm-watch -1 imports the bookmarks of every profile' \
538         "$(PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave)" \
539         'added 3, skipped 5 duplicates'
540     eq 'bm-watch -1 again adds nothing' \
541         "$(PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave)" \
542         'added 0, skipped 8 duplicates'
543     $WATCH -1 2>/dev/null
544     eq 'bm-watch wants a browser' "$?" '2'
545     PATH="$top:$PATH" HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave 2>/dev/null
546     eq 'bm-watch fails when it finds no profiles' "$?" '1'
547 
548     reset
549     PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' \
550         $WATCH -i 1 brave >/dev/null 2>&1 &
551     watcher=$!
552     trap 'kill "$watcher" 2>/dev/null; rm -rf "$work"' EXIT INT TERM
553     i=0
554     while [ $i -lt 30 ] && ! grep -q deep.example "$BOOKMARKS"; do
555         sleep 1
556         i=$((i + 1))
557     done
558     sed 's|https://deep.example/|https://new.example/|' "$here/fixtures/chromium.json" \
559         > "$work/linux/.config/BraveSoftware/Brave-Browser/Profile 1/Bookmarks"
560     i=0
561     while [ $i -lt 30 ] && ! grep -q new.example "$BOOKMARKS"; do
562         sleep 1
563         i=$((i + 1))
564     done
565     kill "$watcher" 2>/dev/null
566     trap 'rm -rf "$work"' EXIT INT TERM
567     eq 'bm-watch imports again when a bookmark file changes' \
568         "$(grep -c new.example "$BOOKMARKS")" '1'
569 else
570     printf 'skip bm-import json: no jq\n'
571 fi
572 cp "$here/../usertags" "$USERTAGS"
573 
574 # ---- bm-check ----
575 
576 
577 reset
578 printf '%s\n' \
579     "https://ok.example${TAB}Fine${TAB}" \
580     "https://dead.example${TAB}Dead${TAB}" \
581     "https://moved.example${TAB}Moved${TAB}" \
582     "https://nohead.example${TAB}Refuses HEAD${TAB}" \
583     "https://gone.example${TAB}No answer${TAB}" > "$BOOKMARKS"
584 out=$(PATH="$work/net:$PATH" $CHECK "$BOOKMARKS" | sort)
585 eq 'bm-check lists dead and moved links only' "$out" "$(printf '%s\n' \
586     "000${TAB}https://gone.example" \
587     "301${TAB}https://moved.example${TAB}https://new.example/" \
588     "404${TAB}https://dead.example")"
589 PATH="$work/net:$PATH" $CHECK "$BOOKMARKS" >/dev/null
590 eq 'bm-check exits 1 when something is wrong' "$?" '1'
591 eq 'bm-check is a filter' \
592     "$($BM -l | PATH="$work/net:$PATH" $CHECK - | cut -f2 | sort | paste -sd ' ' -)" \
593     'https://dead.example https://gone.example https://moved.example'
594 eq 'bm-check takes a plain list of urls' \
595     "$(printf 'https://dead.example\nhttps://ok.example\n' | PATH="$work/net:$PATH" $CHECK -)" \
596     "404${TAB}https://dead.example"
597 many=$(i=0; while [ $i -lt 40 ]; do printf 'https://dead%d.example\n' $i; i=$((i + 1)); done)
598 eq 'bm-check reports every link of a long list exactly once' \
599     "$(printf '%s\n' "$many" | PATH="$work/net:$PATH" $CHECK - | cut -f2 | sort -u | wc -l | tr -d ' ')" '40'
600 eq 'bm-check copes with an empty list' "$(: | PATH="$work/net:$PATH" $CHECK -; echo $?)" '0'
601 eq 'bm-check says nothing about healthy links' \
602     "$(printf 'https://ok.example\n' | PATH="$work/net:$PATH" $CHECK -; echo $?)" '0'
603 
604 # ---- bm-commit ----
605 
606 if command -v git >/dev/null 2>&1; then
607     export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
608     export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
609     repo="$work/repo"
610     git init -q "$repo"
611     mkdir "$repo/nested"
612     commits () { git -C "$repo" rev-list --count HEAD 2>/dev/null || echo 0; }
613     PATH_WAS=$PATH
614     PATH="$top:$PATH"
615 
616     (
617         BOOKMARKS="$repo/bookmarks" USERTAGS="$repo/usertags"
618         cp "$here/../usertags" "$USERTAGS"
619         answers 'In repo' 'code viagit'
620         $BM -a https://git1.example 2>/dev/null
621         answers 'In repo'
622         $BM -c
623         answers 'In repo'
624         $BM -d >/dev/null
625     )
626     eq 'add and delete are committed in a dedicated repository' "$(commits)" '2'
627     eq 'commit messages say what happened' \
628         "$(git -C "$repo" log --format=%s | paste -sd '|' -)" \
629         'bm: delete https://git1.example|bm: add https://git1.example'
630     eq 'bookmarks and tags are tracked, use counts are not' \
631         "$(git -C "$repo" ls-files | paste -sd ' ' -)" 'bookmarks usertags'
632 
633     (
634         BOOKMARKS="$repo/nested/bookmarks"
635         answers 'Nested' 'code'
636         $BM -a https://git2.example 2>/dev/null
637     )
638     eq 'a shared repository is left alone' "$(commits)" '2'
639     (
640         BOOKMARKS="$repo/nested/bookmarks"
641         answers 'Forced' 'code'
642         SBM_GIT=1 $BM -a https://git3.example 2>/dev/null
643     )
644     eq 'SBM_GIT=1 commits in a shared repository' "$(commits)" '3'
645     (
646         BOOKMARKS="$repo/bookmarks" USERTAGS="$repo/usertags"
647         answers 'Off' 'code'
648         SBM_GIT=0 $BM -a https://git4.example 2>/dev/null
649     )
650     eq 'SBM_GIT=0 never commits' "$(commits)" '3'
651     PATH=$PATH_WAS
652     (
653         BOOKMARKS="$repo/bookmarks"
654         answers 'Not installed' 'code'
655         $BM -a https://git5.example 2>/dev/null
656     )
657     eq 'without bm-commit on PATH bm does not touch git' "$(commits)" '3'
658 else
659     printf 'skip git: no git\n'
660 fi
661 
662 # ---- fzf backend: the browser ----
663 
664 mkdir "$work/fzfbin"
665 cp "$here/fakefzf" "$work/fzfbin/fzf"
666 chmod +x "$work/fzfbin/fzf"
667 export SBM_TEST_LOG="$work/menulog"
668 
669 # fz <bm arguments>: run bm against the fake fzf.
670 fz () {
671     PATH="$work/fzfbin:$PATH" SBM_MENU=fzf $BM "$@"
672 }
673 
674 seed
675 answers 'enter:Zed'
676 fz </dev/null
677 eq 'browse: ENTER opens' "$(cat "$work/opened")" 'https://b.example'
678 
679 answers 'ctrl-y:Alpha'
680 fz </dev/null
681 eq 'browse: ctrl-y copies' "$(cat "$work/clipboard")" 'https://c.example'
682 
683 answers ''
684 fz </dev/null
685 eq 'browse: ESC exits 1' "$?" '1'
686 
687 : > "$SBM_TEST_LOG"
688 answers 'ctrl-s:' 'ctrl-s:' ''
689 fz </dev/null
690 eq 'browse: ctrl-s cycles the sort order' \
691     "$(grep -o 'sort \[[a-z]*\]' "$SBM_TEST_LOG" | paste -sd ' ' -)" \
692     'sort [used] sort [recent] sort [url]'
693 
694 # Tags need no key of their own: fzf matches the tags column like the rest.
695 : > "$SBM_TEST_LOG"
696 answers ''
697 fz </dev/null
698 eq 'browse: no tag filter key' "$(grep -c -e 'ctrl-t' -e '\^T' "$SBM_TEST_LOG")" '0'
699 
700 answers 'ctrl-d:Zed' ''
701 printf 'n\n' | fz 2>/dev/null
702 eq 'browse: ctrl-d asks first' "$(grep -c . "$BOOKMARKS")" '3'
703 answers 'ctrl-d:Zed' ''
704 printf 'y\n' | fz 2>/dev/null
705 eq 'browse: ctrl-d deletes after a yes' "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" \
706     'https://a.example https://c.example'
707 
708 answers 'ctrl-a:' 'fresh:' ':code' ''
709 printf 'https://added.example\nAdded here\n' | fz 2>/dev/null
710 eq 'browse: ctrl-a adds, fzf tag picker creates typed tags' \
711     "$(grep added.example "$BOOKMARKS")" "https://added.example${TAB}Added here${TAB}code fresh"
712 
713 answers 'ctrl-a:' 'enter:Mid'
714 out=$(printf 'https://a.example\n\n' | fz 2>&1)
715 eq 'browse: a refused duplicate is shown, then the list comes back' \
716     "$(printf '%s\n' "$out" | grep -c 'already bookmarked; press ENTER') $(cat "$work/opened")" \
717     '1 https://a.example'
718 
719 rm -f "$work/opened"
720 answers 'enter:posix sh printf'
721 fz </dev/null
722 eq 'browse: text that matches nothing is searched for on the web' \
723     "$(cat "$work/opened")" 'https://duckduckgo.com/?q=posix+sh+printf'
724 
725 : > "$SBM_TEST_LOG"
726 answers 'ctrl-s:' ''
727 fz </dev/null
728 eq 'browse: the preview follows the cursor' \
729     "$(grep -c -e "--preview=printf '%s.n%s.ntags: %s.nused: %s.n' {1} {2} {3} {4}" "$SBM_TEST_LOG")" '2'
730 
731 : > "$SBM_TEST_LOG"
732 answers 'Mid'
733 fz -c </dev/null
734 eq 'fzf: rows are listed by their display column, with a live preview' \
735     "$(grep -c -e "--with-nth=5.. --preview-window=down:5:wrap --preview=printf '%s" "$SBM_TEST_LOG")" '1'
736 : > "$SBM_TEST_LOG"
737 answers 'Mid'
738 SBM_FZF_OPTS='--height=100%' fz -c </dev/null
739 eq 'fzf: SBM_FZF_OPTS is passed along' "$(grep -c -e '--height=40% --height=100%' "$SBM_TEST_LOG")" '1'
740 cp "$here/../usertags" "$USERTAGS"
741 
742 # ---- dmenu backend: its behaviour must not change ----
743 
744 # dm <bm arguments>: run bm against the fake dmenu.
745 dm () {
746     PATH="$work/bin:$PATH" SBM_MENU=dmenu $BM "$@"
747 }
748 
749 seed
750 : > "$SBM_TEST_LOG"
751 answers 'Zed' 'copy'
752 dm
753 eq 'dmenu: no arguments asks for the bookmark, then what to do' \
754     "$(cat "$work/clipboard")" 'https://b.example'
755 eq 'dmenu: the two menus' "$(sed 's/.* -p //' "$SBM_TEST_LOG" | paste -sd ' ' -)" 'bookmark: do:'
756 eq 'dmenu: is called with its usual flags' "$(sed 's/ -p .*//' "$SBM_TEST_LOG" | sort -u)" \
757     'dmenu -i -l 15'
758 eq 'dmenu: DMENULINES is honoured' \
759     "$(: > "$SBM_TEST_LOG"; answers 'Zed'; DMENULINES=7 dm -c; cat "$SBM_TEST_LOG")" \
760     'dmenu -i -l 7 -p copy:'
761 
762 answers 'Mid'
763 dm -o
764 eq 'dmenu: open' "$(cat "$work/opened")" 'https://a.example'
765 
766 answers 'Alpha'
767 dm -d >/dev/null
768 eq 'dmenu: delete' "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" 'https://b.example https://a.example'
769 
770 reset
771 printf 'not a url\n' > "$work/clipboard"
772 : > "$SBM_TEST_LOG"
773 answers 'https://typed-dm.example' 'Typed in dmenu' 'code' 'sec' 'typednew' 'done'
774 dm -a 2>/dev/null
775 eq 'dmenu: add loops over tags until done, typed tags included' \
776     "$(cat "$BOOKMARKS")" "https://typed-dm.example${TAB}Typed in dmenu${TAB}code sec typednew"
777 eq 'dmenu: prompts keep their order' \
778     "$(sed 's/.* -p //' "$SBM_TEST_LOG" | paste -sd ' ' -)" \
779     'url: description: tags: tags: tags: tags:'
780 
781 reset
782 printf 'https://selection.example\n' > "$work/clipboard"
783 answers '<default>' 'From selection' 'done'
784 dm -a 2>/dev/null
785 eq 'dmenu: the selection is offered as the url' \
786     "$(cut -f1 "$BOOKMARKS")" 'https://selection.example'
787 cp "$here/../usertags" "$USERTAGS"
788 
789 # Which menu is picked when the user has not said.
790 seed
791 both="$work/bin:$work/fzfbin"
792 : > "$SBM_TEST_LOG"
793 answers 'Zed' 'copy'
794 ( unset SBM_MENU WAYLAND_DISPLAY; PATH="$both:$PATH" DISPLAY=:0 $BM </dev/null )
795 eq 'a display means dmenu even when fzf exists' \
796     "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'dmenu'
797 
798 : > "$SBM_TEST_LOG"
799 answers 'Zed' 'copy'
800 ( unset SBM_MENU DISPLAY; PATH="$both:$PATH" WAYLAND_DISPLAY=wayland-0 $BM </dev/null )
801 eq 'Wayland means dmenu too' "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'dmenu'
802 
803 : > "$SBM_TEST_LOG"
804 answers 'enter:Zed'
805 ( unset SBM_MENU DISPLAY WAYLAND_DISPLAY; PATH="$both:$PATH" $BM </dev/null )
806 eq 'no display falls back to fzf' "$(cut -d' ' -f1 "$SBM_TEST_LOG" | sort -u)" 'fzf'
807 
808 unset SBM_TEST_LOG
809 
810 # ---- one-screen browsing without key bindings (dmenu, custom menus) ----
811 
812 seed
813 export SBM_TEST_SEEN="$work/seen"
814 
815 answers 'Zed' 'open'
816 $BM
817 eq 'browse: pick, then open' "$(cat "$work/opened")" 'https://b.example'
818 
819 : > "$SBM_TEST_SEEN"
820 answers 'Zed' 'copy'
821 $BM
822 eq 'browse: the second menu shows the bookmark in full, below the actions' \
823     "$(sed -n '/^--- pick do:/,$p' "$SBM_TEST_SEEN" | sed 1d | paste -sd '|' -)" \
824     'open|copy|edit|delete|  https://b.example|  Zed|  tags: code lib|  used: 1'
825 eq 'browse: the list starts with [add] and hides the use counts' \
826     "$(sed -n '2,3p' "$SBM_TEST_SEEN" | paste -sd '|' -)" \
827     "[add]|https://b.example${TAB}Zed${TAB}code lib"
828 
829 rm -f "$work/opened"
830 answers 'Zed' '' 'Mid' 'open'
831 $BM
832 eq 'browse: closing the second menu goes back to the list' \
833     "$(cat "$work/opened")" 'https://a.example'
834 
835 answers 'Zed' '  tags: code lib' 'Mid' 'open'
836 $BM
837 eq 'browse: picking a detail line does nothing' "$(cat "$work/opened")" 'https://a.example'
838 
839 answers 'Alpha' 'delete' ''
840 $BM
841 eq 'browse: delete comes back to the list, closing the list leaves' \
842     "$? $(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" '1 https://b.example https://a.example'
843 
844 answers '[add]' 'https://viamenu.example' 'Via menu' 'code' ''
845 $BM 2>/dev/null
846 eq 'browse: [add] adds and comes back' "$(grep -c "^https://viamenu.example${TAB}Via menu${TAB}code$" "$BOOKMARKS")" '1'
847 
848 : > "$SBM_TEST_SEEN"
849 answers '[add]' 'https://www.a.example/' 'seen' ''
850 $BM 2>/dev/null
851 eq 'browse: a refused duplicate is announced in a menu' \
852     "$(grep -c '^--- ask already bookmarked; press ENTER' "$SBM_TEST_SEEN")" '1'
853 
854 : > "$SBM_TEST_SEEN"
855 answers '[add]' '' ''
856 $BM 2>/dev/null
857 eq 'browse: a cancelled add just comes back' \
858     "$(grep -c 'already bookmarked' "$SBM_TEST_SEEN") $(grep -c '^--- pick bookmark:' "$SBM_TEST_SEEN")" '0 2'
859 unset SBM_TEST_SEEN
860 
861 # ---- web search from the bookmark menu ----
862 
863 seed
864 rm -f "$work/opened" "$BOOKMARKS.usage"
865 printf '%s\n' 'w   | https://en.wikipedia.org/w/index.php?search=%s' \
866               'gh | https://github.com/search?q=%s&type=repositories' > "$work/engines"
867 export SBM_ENGINES="$work/engines"
868 
869 # went <typed text> [bm arguments]: where typing that into the menu leads.
870 went () {
871     typed=$1
872     shift
873     rm -f "$work/opened"
874     answers "$typed"
875     $BM "$@" 2>/dev/null
876     cat "$work/opened" 2>/dev/null
877 }
878 
879 eq 'open: a bookmark is opened' "$(went Zed -o)" 'https://b.example'
880 eq 'open: other words are searched for' "$(went 'posix sh printf' -o)" \
881     'https://duckduckgo.com/?q=posix+sh+printf'
882 eq 'open: the query is url-encoded, byte by byte' "$(went 'c++ & más?' -o)" \
883     'https://duckduckgo.com/?q=c%2B%2B+%26+m%C3%A1s%3F'
884 eq 'open: one word is searched for too' "$(went suckless -o)" \
885     'https://duckduckgo.com/?q=suckless'
886 eq 'open: something with a dot is an address' "$(went 'example.org/a?b=c' -o)" \
887     'https://example.org/a?b=c'
888 eq 'open: a url is opened as it is' "$(went 'ftp://files.example/x' -o)" 'ftp://files.example/x'
889 eq 'open: an engine keyword picks the engine' "$(went 'w dynamic menu' -o)" \
890     'https://en.wikipedia.org/w/index.php?search=dynamic+menu'
891 eq 'open: an engine url may go on after the query' "$(went 'gh dmenu' -o)" \
892     'https://github.com/search?q=dmenu&type=repositories'
893 eq 'open: a keyword alone is just a word' "$(went w -o)" 'https://duckduckgo.com/?q=w'
894 eq 'open: an unknown keyword is part of the query' "$(went 'zz top' -o)" \
895     'https://duckduckgo.com/?q=zz+top'
896 eq 'open: SBM_SEARCH sets the default engine' \
897     "$(SBM_SEARCH='https://search.example/?s=%s' went 'two words' -o)" \
898     'https://search.example/?s=two+words'
899 eq 'open: works without an engines file' "$(SBM_ENGINES="$work/none" went 'w dynamic menu' -o)" \
900     'https://duckduckgo.com/?q=w+dynamic+menu'
901 eq 'browse: typed text is searched for as well' "$(went 'w dmenu')" \
902     'https://en.wikipedia.org/w/index.php?search=dmenu'
903 eq 'only bookmarks are counted as used' "$(cut -f1 "$BOOKMARKS.usage")" 'https://b.example'
904 went '' -o >/dev/null
905 eq 'open: closing the menu exits 1' "$?" '1'
906 unset SBM_ENGINES
907 
908 # ---- bm-html ----
909 
910 reset
911 printf '%s\n' \
912     "https://b.example/?a=1&b=2${TAB}B <b>bold</b> & \"quoted\"${TAB}code lib" \
913     "https://a.example${TAB}${TAB}" \
914     '# a comment' \
915     "javascript:alert(1)${TAB}Bookmarklet${TAB}code" \
916     "https://c.example/x${TAB}see${TAB}code" > "$BOOKMARKS"
917 page=$($HTML -t 'My <marks>' "$BOOKMARKS")
918 eq 'bm-html lists a bookmark under each of its tags, and the untagged' \
919     "$(printf '%s\n' "$page" | grep -c '^<li>') $(printf '%s\n' "$page" | grep '<h2' | sed 's/.*id="\([^"]*\)".*/\1/' | paste -sd ' ' -)" \
920     '4 code lib untagged'
921 eq 'bm-html escapes text and attributes' \
922     "$(printf '%s\n' "$page" | grep -c '<li><a href="https://b.example/?a=1&amp;b=2">B &lt;b&gt;bold&lt;/b&gt; &amp; &quot;quoted&quot;</a> <small>b.example</small>')" '2'
923 eq 'bm-html escapes the title' "$(printf '%s\n' "$page" | grep -c '<title>My &lt;marks&gt;</title>')" '1'
924 eq 'bm-html never links to script' "$(printf '%s\n' "$page" | grep -ci 'javascript:')" '0'
925 eq 'bm-html shows the url when there is no description' \
926     "$(printf '%s\n' "$page" | grep -c '<a href="https://a.example">https://a.example</a>')" '1'
927 eq 'bm-html sorts by description within a tag' \
928     "$(printf '%s\n' "$page" | sed -n '/id="code"/,/<\/ul>/p' | grep -c .) $(printf '%s\n' "$page" | sed -n '/id="code"/,/<\/ul>/p' | sed -n 3p | grep -c 'B &lt;b')" '5 1'
929 eq 'bm-html has one inline script, a filter box hidden without it, and ?q=' \
930     "$(printf '%s\n' "$page" | grep -c '<script>') $(printf '%s\n' "$page" | grep -c '<input id=q type=search placeholder="filter 3 bookmarks" hidden>') $(printf '%s\n' "$page" | grep -c 'URLSearchParams(location.search).get("q")')" \
931     '1 1 1'
932 eq 'bm-html loads nothing from elsewhere' "$(printf '%s\n' "$page" | grep -c -e ' src=' -e '<link' -e '@import')" '0'
933 eq 'bm-html is a filter' "$($BM -t lib -l | $HTML - | grep -c '^<li>')" '2'
934 eq 'bm-html reads the bookmark file by default, whatever stdin is' \
935     "$($HTML </dev/null | grep -c '^<li>')" '4'
936 eq 'bm-check reads the bookmark file by default too' \
937     "$(PATH="$work/net:$PATH" $CHECK </dev/null; echo $?)" '0'
938 $HTML --bogus 2>/dev/null </dev/null
939 eq 'bm-html rejects unknown options' "$?" '2'
940 if command -v node >/dev/null 2>&1; then
941     printf '%s\n' "$page" | sed -n '/<script>/,/<\/script>/p' | sed '1d;$d' > "$work/page.js"
942     node --check "$work/page.js" 2>/dev/null
943     eq 'bm-html: the script parses' "$?" '0'
944 fi
945 
946 # ---- bm-sync ----
947 
948 # A stand-in for curl that plays an sbm-sync server. The file of the server
949 # is $srv/file. Lines in $srv/other come from another device, once. With
950 # $srv/code, the server refuses with that status. With $srv/touch, bm adds a
951 # bookmark while the request runs. Without a version, the server deletes
952 # nothing, as the real one: it keeps its file and adds the new lines. With
953 # $srv/broken, the answer is a directory, so bm-sync cannot write it. With
954 # $srv/hold, a process takes the write lock of bm while the request runs,
955 # and keeps it; its pid goes to $srv/holder.
956 srv="$work/srv"
957 mkdir "$srv" "$work/syncnet"
958 cat > "$work/syncnet/curl" <<'FAKE'
959 #!/bin/sh
960 printf '%s\n' "$*" >> "$SBM_TEST_SRV/argv"
961 out= head= data= auth= url= password=
962 while [ $# -gt 0 ]; do
963     case $1 in
964         -o) out=$2; shift ;;
965         -D) head=$2; shift ;;
966         -w|--max-time|-X) shift ;;
967         -H) case $2 in @*) auth=$(cat "${2#@}") ;; esac; shift ;;
968         --data-binary) data=${2#@}; shift ;;
969         --data-urlencode) case $2 in password@-) password=$(cat) ;; esac; shift ;;
970         -*) ;;
971         *) url=$1 ;;
972     esac
973     shift
974 done
975 answer () { printf '%s\n' "$2" > "$out"; printf '%s' "$1"; exit 0; }
976 case $url in
977     */api/login)
978         [ "$password" = 'secret pass ' ] || answer 401 'wrong email or password'
979         answer 200 tok123 ;;
980     */api/logout)
981         echo logout >> "$SBM_TEST_SRV/log"
982         exit 0 ;;
983 esac
984 [ "$auth" = 'Authorization: Bearer tok123' ] || answer 401 'not signed in'
985 [ ! -e "$SBM_TEST_SRV/code" ] || answer "$(cat "$SBM_TEST_SRV/code")" 'sync is paused'
986 base=${url#*base=}
987 printf '%s\n' "$base" >> "$SBM_TEST_SRV/bases"
988 if [ -z "$base" ]; then
989     cat "$SBM_TEST_SRV/file" "$data" 2>/dev/null | awk '!seen[$0]++' > "$SBM_TEST_SRV/union"
990     data=$SBM_TEST_SRV/union
991 fi
992 cat "$data" "$SBM_TEST_SRV/other" > "$SBM_TEST_SRV/file" 2>/dev/null
993 rm -f "$SBM_TEST_SRV/other"
994 version=v$(cksum < "$SBM_TEST_SRV/file" | cut -d' ' -f1)
995 echo "$version" >> "$SBM_TEST_SRV/versions"
996 cp "$SBM_TEST_SRV/file" "$out"
997 [ ! -e "$SBM_TEST_SRV/broken" ] || { rm -f "$out"; mkdir "$out"; }
998 printf 'HTTP/1.1 200 OK\r\nsbm-version: %s\r\n\r\n' "$version" > "$head"
999 if [ -e "$SBM_TEST_SRV/touch" ]; then
1000     rm -f "$SBM_TEST_SRV/touch"
1001     printf 'https://c.example\tC\t\n' >> "$BOOKMARKS"
1002 fi
1003 if [ -e "$SBM_TEST_SRV/hold" ]; then
1004     sleep 30 >/dev/null 2>&1 &
1005     echo $! > "$SBM_TEST_SRV/holder"
1006     mkdir "$BOOKMARKS.lock" && echo $! > "$BOOKMARKS.lock/pid"
1007 fi
1008 printf 200
1009 FAKE
1010 chmod +x "$work/syncnet/curl"
1011 export SBM_TEST_SRV="$srv"
1012 bmsync () {
1013     PATH="$work/syncnet:$PATH" ${SBM_SH:-sh} "$top/bm-sync" "$@"
1014 }
1015 
1016 reset
1017 printf 'https://a.example\tA\t\n' > "$BOOKMARKS"
1018 bmsync 2>"$work/err"
1019 eq 'bm-sync without an account says how to sign in' "$?:$(grep -c 'bm-sync login' "$work/err")" '1:1'
1020 
1021 printf 'me@example.org\nsecret pass \n' | bmsync login https://sync.example/ >/dev/null 2>&1
1022 eq 'bm-sync login keeps the token in a file that only you can read' \
1023     "$(ls -l "$SBM_SYNC_CONFIG" | cut -c1-10) $(paste -sd ' ' - < "$SBM_SYNC_CONFIG")" \
1024     '-rw------- server=https://sync.example token=tok123'
1025 eq 'the password and the token never go on the command line' \
1026     "$(grep -c -e secret -e tok123 "$srv/argv")" '0'
1027 eq 'bm-sync login syncs at once, from no version' \
1028     "$(cat "$srv/file"):$(sed -n 1p "$srv/bases")" "https://a.example${TAB}A${TAB}:"
1029 
1030 printf 'https://b.example\tB\t\n' > "$srv/other"
1031 bmsync -q
1032 eq 'bm-sync brings the bookmarks of other devices' \
1033     "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" 'https://a.example https://b.example'
1034 eq 'bm-sync sends the version of the last sync' "$(sed -n 2p "$srv/bases")" "$(sed -n 1p "$srv/versions")"
1035 
1036 : > "$srv/touch"
1037 bmsync -q
1038 eq 'a bookmark added during a sync gets to the server too' \
1039     "$(grep -c c.example "$srv/file") $(grep -c c.example "$BOOKMARKS")" '1 1'
1040 
1041 echo 402 > "$srv/code"
1042 cp "$BOOKMARKS" "$work/before"
1043 bmsync -q 2>"$work/err"
1044 eq 'bm-sync shows why the server refused, and keeps the file' \
1045     "$?:$(cat "$work/err"):$(cmp -s "$work/before" "$BOOKMARKS" && echo same)" \
1046     '1:bm-sync: sync is paused:same'
1047 rm -f "$srv/code"
1048 
1049 chmod 644 "$SBM_SYNC_CONFIG"
1050 printf 'me@example.org\nsecret pass \n' | bmsync login https://sync.example/ >/dev/null 2>&1
1051 eq 'bm-sync login makes a config file that exists already private too' \
1052     "$(ls -l "$SBM_SYNC_CONFIG" | cut -c1-10)" '-rw-------'
1053 
1054 # kept: the version that bm-sync sent last, then "kept" if the server still
1055 # has all bookmarks, then "back" if the file has all of them again.
1056 cp "$srv/file" "$work/before"
1057 kept () {
1058     printf '%s:%s:%s' "$(sed -n '$p' "$srv/bases")" \
1059         "$(cmp -s "$work/before" "$srv/file" && echo kept)" \
1060         "$(cmp -s "$work/before" "$BOOKMARKS" && echo back)"
1061 }
1062 : > "$BOOKMARKS"
1063 bmsync -q
1064 eq 'bm-sync sends an empty file without a version, so the server deletes nothing' \
1065     "$(kept)" ':kept:back'
1066 rm -f "$BOOKMARKS"
1067 bmsync -q
1068 eq 'bm-sync sends a missing file without a version too' "$(kept)" ':kept:back'
1069 
1070 : > "$srv/broken"
1071 bmsync -q 2>"$work/err"
1072 eq 'when bm-sync cannot write the file, it says so and forgets the version' \
1073     "$?:$(grep -c 'cannot write' "$work/err"):$([ -e "$BOOKMARKS.sync" ] || echo none)" '1:1:none'
1074 rm -f "$srv/broken"
1075 # The write stopped after the first line.
1076 sed -n 1p "$work/before" > "$BOOKMARKS"
1077 bmsync -q
1078 eq 'after a failed write, the next sync has no version, so the server deletes nothing' \
1079     "$(kept)" ':kept:back'
1080 
1081 # While bm holds its write lock, bm-sync neither reads nor writes the file.
1082 export SBM_LOCK_WAIT=1
1083 cp "$BOOKMARKS" "$work/before"
1084 cp "$BOOKMARKS.sync" "$work/state"
1085 printf 'https://other.example\tOther\t\n' > "$srv/other"
1086 sleep 30 >/dev/null 2>&1 &
1087 holder=$!
1088 mkdir "$BOOKMARKS.lock" && echo "$holder" > "$BOOKMARKS.lock/pid"
1089 bmsync -q 2>"$work/err"
1090 rc=$?
1091 kill "$holder"
1092 rm -rf "$BOOKMARKS.lock"
1093 eq 'bm-sync does not read the file while bm holds the lock, and says why' \
1094     "$rc:$(grep -c 'in use' "$work/err"):$(cmp -s "$work/before" "$BOOKMARKS" && echo same)" \
1095     '1:1:same'
1096 # bm takes the lock while the request runs: bm-sync must not write.
1097 : > "$srv/hold"
1098 bmsync -q 2>"$work/err"
1099 rc=$?
1100 kill "$(cat "$srv/holder")"
1101 rm -rf "$BOOKMARKS.lock" "$srv/hold"
1102 eq 'bm-sync does not write while bm holds the lock, and keeps the old version' \
1103     "$rc:$(cmp -s "$work/before" "$BOOKMARKS" && echo same):$(cmp -s "$work/state" "$BOOKMARKS.sync" && echo same)" \
1104     '1:same:same'
1105 printf 'https://other.example\tOther\t\n' > "$srv/other"
1106 bmsync -q
1107 eq 'after the lock is free, the next sync brings the change of the other device' \
1108     "$(grep -c other.example "$BOOKMARKS"):$(ls -d "$BOOKMARKS.lock" 2>/dev/null)" '1:'
1109 unset SBM_LOCK_WAIT
1110 
1111 bmsync -q logout
1112 eq 'bm-sync logout forgets the token and signs out on the server' \
1113     "$([ -e "$SBM_SYNC_CONFIG" ] || echo gone) $(cat "$srv/log")" 'gone logout'
1114 eq 'bm-sync leaves no lock behind' "$(ls -d "$BOOKMARKS.sync.lock" 2>/dev/null)" ''
1115 unset SBM_TEST_SRV
1116 
1117 # bm starts bm-sync after each change. A stand-in bm-sync records the config
1118 # that it gets: it must be the one of the tests, never that of the user.
1119 mkdir "$work/stubsync"
1120 # shellcheck disable=SC2016
1121 printf '#!/bin/sh\nprintf "%%s\\n" "$SBM_SYNC_CONFIG" >> "%s"\n' "$work/stubsync.log" \
1122     > "$work/stubsync/bm-sync"
1123 chmod +x "$work/stubsync/bm-sync"
1124 reset
1125 answers 'Stub' ''
1126 PATH="$work/stubsync:$PATH" $BM -a https://stub.example 2>/dev/null
1127 i=0
1128 while [ ! -s "$work/stubsync.log" ] && [ $i -lt 5 ]; do
1129     sleep 1
1130     i=$((i + 1))
1131 done
1132 eq 'bm starts bm-sync with the config of the tests, never the real one' \
1133     "$(sort -u "$work/stubsync.log")" "$work/sync.conf"
1134 
1135 # ---- the write lock of the bookmark file ----
1136 
1137 # hold: a live process takes the write lock; its pid goes to $holder.
1138 hold () {
1139     sleep 30 >/dev/null 2>&1 &
1140     holder=$!
1141     mkdir "$BOOKMARKS.lock" && echo "$holder" > "$BOOKMARKS.lock/pid"
1142 }
1143 free () {
1144     kill "$holder" 2>/dev/null
1145     rm -rf "$BOOKMARKS.lock"
1146 }
1147 
1148 reset
1149 # The other process adds a line after two seconds, and then frees the lock.
1150 mkdir "$BOOKMARKS.lock"
1151 ( sleep 2; printf 'https://first.example\tFirst\t\n' >> "$BOOKMARKS"
1152   rm -rf "$BOOKMARKS.lock" ) &
1153 first=$!
1154 echo "$first" > "$BOOKMARKS.lock/pid"
1155 answers 'Second' ''
1156 SBM_LOCK_WAIT=10 $BM -a https://second.example 2>/dev/null
1157 wait "$first"
1158 eq 'add waits for the lock, and adds after the change of the other process' \
1159     "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" 'https://first.example https://second.example'
1160 
1161 reset
1162 printf 'https://kept.example\tKept\t\n' > "$BOOKMARKS"
1163 hold
1164 answers 'New' ''
1165 SBM_LOCK_WAIT=1 $BM -a https://new.example 2>"$work/err"
1166 eq 'add stops when the lock stays, says why, and changes nothing' \
1167     "$?:$(grep -c 'in use' "$work/err"):$(cut -f1 "$BOOKMARKS")" '1:1:https://kept.example'
1168 answers 'Kept'
1169 SBM_LOCK_WAIT=1 $BM -d >/dev/null 2>&1
1170 eq 'delete waits for the lock too' "$(cut -f1 "$BOOKMARKS")" 'https://kept.example'
1171 printf 'https://merged.example\n' | SBM_LOCK_WAIT=1 $BM -m >/dev/null 2>&1
1172 eq 'merge waits for the lock too' "$(cut -f1 "$BOOKMARKS")" 'https://kept.example'
1173 free
1174 
1175 reset
1176 sh -c 'exit 0' &
1177 gone=$!
1178 wait "$gone"
1179 mkdir "$BOOKMARKS.lock" && echo "$gone" > "$BOOKMARKS.lock/pid"
1180 answers 'After' ''
1181 SBM_LOCK_WAIT=1 $BM -a https://after.example 2>/dev/null
1182 eq 'add takes over the lock of a process that is gone, and frees it' \
1183     "$(cut -f1 "$BOOKMARKS"):$(ls -d "$BOOKMARKS.lock" 2>/dev/null)" 'https://after.example:'
1184 
1185 reset
1186 printf 'https://ed.example\tEd\t\n' > "$BOOKMARKS"
1187 printf '#!/bin/sh\n[ -d "%s" ] && echo locked > "%s"\n' \
1188     "$BOOKMARKS.lock" "$work/edit.log" > "$work/editor"
1189 chmod +x "$work/editor"
1190 answers 'Ed'
1191 VISUAL="$work/editor" $BM -e 2>/dev/null
1192 eq 'edit holds the lock while the editor runs, and frees it after' \
1193     "$(cat "$work/edit.log" 2>/dev/null):$(ls -d "$BOOKMARKS.lock" 2>/dev/null)" 'locked:'
1194 
1195 # ---- make install ----
1196 
1197 if command -v make >/dev/null 2>&1; then
1198     make -s -C "$top" install DESTDIR="$work/dest" PREFIX=/usr TOOLS='bm bm-html' >/dev/null
1199     eq 'make install installs what TOOLS names, and only that' \
1200         "$(cd "$work/dest/usr/bin" && for f in *; do [ -x "$f" ] && printf '%s ' "$f"; done)" 'bm bm-html '
1201 fi
1202 
1203 # ---- defaults ----
1204 
1205 (
1206     unset BOOKMARKS
1207     $BM -l
1208 )
1209 eq 'default bookmark file is created under XDG_DATA_HOME' \
1210     "$([ -f "$work/xdg/sbm/bookmarks" ] && echo yes)" 'yes'
1211 
1212 printf '\n%d passed, %d failed\n' "$pass" "$fail"
1213 [ "$fail" -eq 0 ]