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 & decoded\n 'title' </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 & B — ☺ </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> 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 < 6 &bogus; A</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&b=2">B <b>bold</b> & "quoted"</a> <small>b.example</small>')" '2' 1015 eq 'bm-html escapes the title' "$(printf '%s\n' "$page" | grep -c '<title>My <marks></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 <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 ]