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