Files
omarchycn/test/shell.d/clipboard-test.sh
T
David Heinemeier HanssonandClaude Opus 5 234ac7b3b6 Bring back clipboard watchers that die
Clipboard history is fed by two wl-paste --watch processes started once
when the shell loads. When one dies, nothing notices: copying still
works, the picker still opens, and the old entries are all still there,
so history just silently stops recording until the next shell reload.

Respawn them on exit instead. The one-shot timer both coalesces the case
where both watchers die together and keeps a wl-paste that cannot start
at all from spinning as fast as fork allows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 08:19:57 -07:00

408 lines
16 KiB
Bash

#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const clipboard = requireFromRoot('shell/plugins/clipboard/ClipboardHistory.js')
const clipboardQml = fs.readFileSync(path.join(root, 'shell/plugins/clipboard/Clipboard.qml'), 'utf8')
assertDeepEqual(
clipboard.normalizeEntry('hello'),
{ type: 'text', text: 'hello' },
'clipboard normalizes string entries'
)
assertDeepEqual(
clipboard.normalizeEntry({ kind: 'image', path: '/tmp/a.png' }),
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' },
'clipboard normalizes image entries with default mime'
)
assertDeepEqual(
clipboard.normalizeEntry({ type: 'image', path: '/tmp/a.png', mime: 'image/png', capturedAt: 'Friday 14:42' }),
{ type: 'image', path: '/tmp/a.png', mime: 'image/png', capturedAt: 'Friday 14:42' },
'clipboard keeps image capture timestamps'
)
assertDeepEqual(
clipboard.parseHistory(JSON.stringify(['one', '', { type: 'text', text: 'two' }, { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }])),
[
{ type: 'text', text: 'one' },
{ type: 'text', text: 'two' },
{ type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }
],
'clipboard history parser drops invalid entries'
)
assertDeepEqual(clipboard.parseHistory(JSON.stringify([' ', '\n', { type: 'text', text: '\t' }])), [], 'clipboard history parser drops whitespace-only text')
const history = [
{ type: 'text', text: 'old' },
{ type: 'text', text: 'new' },
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' }
]
assertDeepEqual(
clipboard.addEntry(history, { type: 'text', text: 'new' }, 100),
[
{ type: 'text', text: 'new' },
{ type: 'text', text: 'old' },
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' }
],
'clipboard addEntry moves duplicate text to front'
)
assertDeepEqual(
clipboard.removeEntryAt(history, 1),
[
{ type: 'text', text: 'old' },
{ type: 'image', path: '/tmp/a.png', mime: 'image/png' }
],
'clipboard removeEntryAt removes the requested entry'
)
assertDeepEqual(clipboard.removeEntryAt(history, 10), history, 'clipboard removeEntryAt ignores invalid indexes')
assertDeepEqual(clipboard.clearHistory(), [], 'clipboard clearHistory returns an empty history')
assertDeepEqual(
clipboard.displayRows(history, 'image', 50).map(row => ({ type: row.entryType, preview: row.previewText, mime: row.mime })),
[{ type: 'image', preview: 'Image', mime: 'image/png' }],
'clipboard display rows search image metadata'
)
assertDeepEqual(
clipboard.displayRows([{ type: 'image', path: '/tmp/a.png', mime: 'image/png', capturedAt: 'Friday 14:42' }], '', 50)[0].previewText,
'Screenshot from Friday 14:42',
'clipboard labels timestamped png image entries as screenshots'
)
assertDeepEqual(
clipboard.displayRows([{ type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg', capturedAt: 'Friday 14:42' }], '', 50)[0].previewText,
'Image from Friday 14:42',
'clipboard labels timestamped non-png image entries as images'
)
assertDeepEqual(
clipboard.displayRows(history, 'image', 50).map(row => row.index),
[2],
'clipboard display rows preserve original history indexes'
)
assertDeepEqual(
clipboard.displayRows([{ type: 'text', text: 'line one\nline two' }], '', 50)[0].previewText,
'line one line two',
'clipboard display rows collapse text whitespace'
)
assertDeepEqual(
clipboard.displayRows([{ type: 'text', text: 'file:///home/dhh/Videos/screenrecording-2026-05-29_13-56-43-720p.gif\n' }], '', 50)[0],
{
entryType: 'file',
fullText: '/home/dhh/Videos/screenrecording-2026-05-29_13-56-43-720p.gif',
previewText: 'screenrecording-2026-05-29_13-56-43-720p.gif',
previewImage: '/home/dhh/Videos/screenrecording-2026-05-29_13-56-43-720p.gif',
path: '/home/dhh/Videos/screenrecording-2026-05-29_13-56-43-720p.gif',
mime: 'text/plain',
index: 0
},
'clipboard display rows show file uri entries as files'
)
assertDeepEqual(
clipboard.displayRows([{ type: 'text', text: 'file:///home/dhh/One.txt\nfile:///home/dhh/Two.txt\n' }], '', 50)[0].previewText,
'2 files',
'clipboard display rows summarize multiple file uri entries'
)
assertDeepEqual(
clipboard.displayRows([{ type: 'text', text: 'file:///home/dhh/Videos/demo.mp4\n' }], '', 50)[0].previewImage,
'',
'clipboard display rows do not preview video file uri entries inline'
)
assertDeepEqual(clipboard.displayRows(history, '', 0), [], 'clipboard display rows supports zero result limit')
assertDeepEqual(clipboard.addEntry(history, 'next', 0), [], 'clipboard addEntry supports zero history limit')
assert(
/function select\(delta\)[\s\S]*root\.disarmPointer\(\)[\s\S]*selectedIndex =/.test(clipboardQml),
'clipboard keyboard navigation disarms pointer selection'
)
assert(
/function selectAbsolute\(index\)[\s\S]*root\.disarmPointer\(\)[\s\S]*root\.selectedIndex = Math\.max\(0, Math\.min\(index, displayModel\.count - 1\)\)/.test(clipboardQml),
'clipboard absolute navigation disarms pointer selection'
)
assert(
/event\.key === Qt\.Key_Home[\s\S]*root\.selectAbsolute\(0\)[\s\S]*event\.accepted = true/.test(clipboardQml),
'clipboard Home selects the first entry'
)
assert(
/event\.key === Qt\.Key_End[\s\S]*root\.selectAbsolute\(displayModel\.count - 1\)[\s\S]*event\.accepted = true/.test(clipboardQml),
'clipboard End selects the last entry'
)
assert(
/PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(clipboardQml),
'clipboard uses shared pointer movement gate in card coordinates'
)
assert(
/function disarmPointer\(\)[\s\S]*pointerGate\.reset\(\)/.test(clipboardQml),
'clipboard resets pointer movement gate when pointer selection is disarmed'
)
assert(
/function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(clipboardQml),
'clipboard only selects from pointer after real movement'
)
assert(
/onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(clipboardQml),
'clipboard row hover routes through pointer movement gate'
)
assert(
!/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(clipboardQml),
'clipboard does not select rows from containsMouse'
)
assert(
clipboardQml.includes('command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "text", "--watch", root.captureScript, "text"]'),
'clipboard text watcher dies with the shell via pdeathsig'
)
assert(
clipboardQml.includes('command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "image/png", "--watch", root.captureScript, "image/png"]'),
'clipboard image watcher dies with the shell via pdeathsig'
)
assert(
clipboardQml.includes('command: ["pkill", "-f", "wl-paste .*--watch .*/shell/plugins/clipboard/capture\\\\.sh"]'),
'clipboard init reaps stale watchers before starting new ones'
)
assertEqual(
(clipboardQml.match(/onExited: watchRestartTimer\.restart\(\)/g) || []).length,
2,
'clipboard respawns both watchers when they die'
)
JS
TMPDIR=$(mktemp -d)
PIDS_TO_KILL=()
cleanup() {
local pid
for pid in "${PIDS_TO_KILL[@]}"; do
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
done
rm -rf "$TMPDIR"
}
trap cleanup EXIT
process_gone() {
local pid=$1
local stat
for _ in {1..40}; do
stat=$(ps -o stat= -p "$pid" 2>/dev/null || true)
if [[ -z $stat || $stat == Z* ]]; then
return 0
fi
sleep 0.1
done
return 1
}
process_alive() {
local pid=$1
local stat
stat=$(ps -o stat= -p "$pid" 2>/dev/null || true)
[[ -n $stat && $stat != Z* ]]
}
mkdir -p "$TMPDIR/bin" "$TMPDIR/home/.local/state/omarchy"
cat >"$TMPDIR/bin/wl-copy" <<'SH'
#!/bin/bash
cat >"$WL_COPY_OUT"
SH
cat >"$TMPDIR/bin/wl-paste" <<'SH'
#!/bin/bash
if [[ $1 == "--list-types" ]]; then
printf '%b' "${WL_PASTE_TYPES:-text/plain\n}"
elif [[ $1 == "--type" && $2 == "text" ]]; then
printf '%s' "${WL_PASTE_TEXT:-terminal copy}"
fi
SH
cat >"$TMPDIR/bin/wtype" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >"$WTYPE_OUT"
SH
cat >"$TMPDIR/bin/omarchy-launch-browser" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >"$BROWSER_OUT"
SH
cat >"$TMPDIR/bin/omarchy-launch-editor" <<'SH'
#!/bin/bash
printf '%s\n' "$1" >"$EDITOR_PATH_OUT"
cat "$1" >"$EDITOR_TEXT_OUT"
SH
cat >"$TMPDIR/bin/tensaku-edit" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >"$TENSAKU_OUT"
SH
chmod +x "$TMPDIR/bin/wl-copy" "$TMPDIR/bin/wl-paste" "$TMPDIR/bin/wtype" "$TMPDIR/bin/omarchy-launch-browser" "$TMPDIR/bin/omarchy-launch-editor" "$TMPDIR/bin/tensaku-edit"
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh")
[[ $capture_output == '{"type":"text","text":"terminal copy"}' ]] || fail "clipboard capture records normal text events"
pass "clipboard capture records normal text events"
capture_output=$(printf 'closing app copy' | WL_PASTE_TEXT="stale read" XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ $capture_output == '{"type":"text","text":"closing app copy"}' ]] || fail "clipboard capture records watched text from stdin"
pass "clipboard capture records watched text from stdin"
capture_output=$(printf 'png-data' | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" image/png)
image_path=$(jq -r '.path' <<<"$capture_output")
jq -e '.type == "image" and .mime == "image/png" and (.capturedAt | type == "string")' <<<"$capture_output" >/dev/null || fail "clipboard capture records watched png images"
[[ -s $image_path && $(<"$image_path") == "png-data" ]] || fail "clipboard capture stores watched png image data"
pass "clipboard capture records watched png images"
capture_output=$(printf 'jpg-data' | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" image/jpeg)
image_path=$(jq -r '.path' <<<"$capture_output")
jq -e '.mime == "image/jpeg"' <<<"$capture_output" >/dev/null && [[ $image_path == *.jpg ]] || fail "clipboard capture stores watched jpeg images with jpg extension"
pass "clipboard capture stores watched jpeg images with jpg extension"
capture_output=$(printf 'secret' | CLIPBOARD_STATE=sensitive XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
[[ -z $capture_output ]] || fail "clipboard capture ignores sensitive watched text"
pass "clipboard capture ignores sensitive watched text"
capture_output=$(CLIPBOARD_STATE=sensitive XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh")
[[ -z $capture_output ]] || fail "clipboard capture ignores sensitive clipboard events"
pass "clipboard capture ignores sensitive clipboard events"
capture_output=$(WL_PASTE_TYPES="text/plain\nx-kde-passwordManagerHint\n" XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh")
[[ -z $capture_output ]] || fail "clipboard capture ignores password manager hint"
pass "clipboard capture ignores password manager hint"
cat >"$TMPDIR/bin/wl-paste" <<'SH'
#!/bin/bash
printf '%s\t%s\n' "$BASHPID" "$*" >>"${WL_PASTE_LOG:-/dev/null}"
sleep_pid=""
cleanup() {
[[ -n $sleep_pid ]] && kill "$sleep_pid" 2>/dev/null || true
exit 0
}
trap cleanup HUP INT TERM
while true; do
sleep 10 &
sleep_pid=$!
wait "$sleep_pid"
done
SH
chmod +x "$TMPDIR/bin/wl-paste"
clipboard_lifecycle_dir="$TMPDIR/clipboard-lifecycle"
current_script="$clipboard_lifecycle_dir/current/shell/plugins/clipboard/capture.sh"
mkdir -p "$(dirname "$current_script")"
cp "$ROOT/shell/plugins/clipboard/capture.sh" "$current_script"
chmod +x "$current_script"
PATH="$TMPDIR/bin:$PATH" wl-paste --type text --watch "$current_script" text &
stale_pid=$!
PIDS_TO_KILL+=("$stale_pid")
sleep 0.2
pgrep -f 'wl-paste .*--watch .*/shell/plugins/clipboard/capture\.sh' | grep -qx "$stale_pid" || fail "clipboard reaper pattern matches running watchers"
kill "$stale_pid" 2>/dev/null || true
wait "$stale_pid" 2>/dev/null || true
pass "clipboard reaper pattern matches running watchers"
watch_owner="$clipboard_lifecycle_dir/watch-owner.sh"
watch_pid_file="$clipboard_lifecycle_dir/watch.pid"
cat >"$watch_owner" <<SH
#!/bin/bash
PATH="$TMPDIR/bin:\$PATH" setpriv --pdeathsig TERM wl-paste --type text --watch "$current_script" text &
printf '%s\n' "\$!" >"$watch_pid_file"
wait
SH
chmod +x "$watch_owner"
"$watch_owner" &
owner_pid=$!
PIDS_TO_KILL+=("$owner_pid")
for _ in {1..40}; do
[[ -s $watch_pid_file ]] && break
sleep 0.1
done
watch_pid=$(<"$watch_pid_file")
[[ -n $watch_pid ]] && process_alive "$watch_pid" || fail "clipboard watcher starts under setpriv"
PIDS_TO_KILL+=("$watch_pid")
kill "$owner_pid" 2>/dev/null || true
process_gone "$watch_pid" || fail "clipboard watcher dies with its owner via pdeathsig"
pass "clipboard watcher dies with its owner via pdeathsig"
jq -n --arg text "$(printf 'large block line 1\nlarge block line 2\n')" '[{type:"text", text:"ignored"}, {type:"text", text:$text}]' >"$TMPDIR/home/.local/state/omarchy/clipboard-history.json"
WL_COPY_OUT="$TMPDIR/copied" WTYPE_OUT="$TMPDIR/wtype" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \
"$ROOT/bin/omarchy-clipboard-paste-text" --shift-insert --history-index 1
[[ $(<"$TMPDIR/copied") == "$(printf 'large block line 1\nlarge block line 2')" ]] || fail "clipboard paste helper copies history entry text"
pass "clipboard paste helper copies history entry text"
[[ $(<"$TMPDIR/wtype") == "-M shift -k Insert -m shift" ]] || fail "clipboard paste helper pastes history entries with shift insert"
pass "clipboard paste helper pastes history entries with shift insert"
rm -f "$TMPDIR/wtype"
WL_COPY_OUT="$TMPDIR/copied" WTYPE_OUT="$TMPDIR/wtype" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \
"$ROOT/bin/omarchy-clipboard-paste-text" --copy-only --history-index 1
[[ $(<"$TMPDIR/copied") == "$(printf 'large block line 1\nlarge block line 2')" ]] || fail "clipboard paste helper copy-only copies history entry text"
pass "clipboard paste helper copy-only copies history entry text"
[[ ! -e "$TMPDIR/wtype" ]] || fail "clipboard paste helper copy-only skips typing"
pass "clipboard paste helper copy-only skips typing"
printf 'image-data' >"$TMPDIR/image.png"
rm -f "$TMPDIR/wtype"
WL_COPY_OUT="$TMPDIR/copied" WTYPE_OUT="$TMPDIR/wtype" PATH="$TMPDIR/bin:$PATH" \
"$ROOT/bin/omarchy-clipboard-paste-file" --copy-only image/png "$TMPDIR/image.png"
[[ $(<"$TMPDIR/copied") == "image-data" ]] || fail "clipboard file paste helper copy-only copies file content"
pass "clipboard file paste helper copy-only copies file content"
[[ ! -e "$TMPDIR/wtype" ]] || fail "clipboard file paste helper copy-only skips paste keystroke"
pass "clipboard file paste helper copy-only skips paste keystroke"
jq -n --arg url 'https://example.com/docs' --arg text "$(printf 'plain text\nsecond line')" --arg image "$TMPDIR/image.png" \
'[{type:"text", text:$url}, {type:"text", text:$text}, {type:"image", mime:"image/png", path:$image}]' >"$TMPDIR/home/.local/state/omarchy/clipboard-history.json"
BROWSER_OUT="$TMPDIR/browser" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \
"$ROOT/bin/omarchy-clipboard-open" --history-index 0
[[ $(<"$TMPDIR/browser") == "https://example.com/docs" ]] || fail "clipboard open helper opens URL entries in browser"
pass "clipboard open helper opens URL entries in browser"
EDITOR_PATH_OUT="$TMPDIR/editor-path" EDITOR_TEXT_OUT="$TMPDIR/editor-text" HOME="$TMPDIR/home" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" \
"$ROOT/bin/omarchy-clipboard-open" --history-index 1
[[ $(<"$TMPDIR/editor-text") == "$(printf 'plain text\nsecond line')" ]] || fail "clipboard open helper opens text entries in editor"
pass "clipboard open helper opens text entries in editor"
[[ $(<"$TMPDIR/editor-path") == "$TMPDIR"/state/omarchy/clipboard-open/clipboard.*.txt ]] || fail "clipboard open helper writes text entries to a temporary file"
pass "clipboard open helper writes text entries to a temporary file"
TENSAKU_OUT="$TMPDIR/tensaku" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \
"$ROOT/bin/omarchy-clipboard-open" --history-index 2
[[ $(<"$TMPDIR/tensaku") == "$TMPDIR/image.png" ]] || fail "clipboard open helper opens image entries in Tensaku"
pass "clipboard open helper opens image entries in Tensaku"