Files
Panama/tests/setup/launcher-search-contract
T

305 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# Searching the web from the launcher, in both the forms Panama ships it.
#
# There are two, deliberately: a script command that needs nothing but bash, and
# an extension that adds live suggestions but has to be compiled. That is also
# the risk this pins -- the search engine is written down twice, and two copies
# of a URL drift.
#
# `vicinae script check` is the validator for the first, and it exits 0 even when
# it rejects a file. Checking its exit status would pass on a script Vicinae
# refuses to load, so its output is what counts.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
script="$repo_dir/config/local/share/vicinae/scripts/search-web"
extension="$repo_dir/config/local/share/vicinae/extensions/panama-search"
stage="$repo_dir/setup/scripts/link-vicinae-scripts"
findings=()
note() { findings+=("$1"); }
# ── The script command ───────────────────────────────────────────────────────
if [[ ! -x "$script" ]]; then
note 'search-web is missing or not executable, so Vicinae will not load it'
else
if command -v vicinae >/dev/null 2>&1; then
output="$(vicinae script check "$script" 2>&1)"
[[ "$output" == *Error* ]] && note "Vicinae rejects search-web: $output"
fi
# Exactly one argument. Vicinae only offers a no-view command as a fallback
# -- type anything, get "Search" at the bottom -- when it takes a single
# text argument, which is the whole point of shipping this one.
arguments="$(grep -c '@vicinae.argument' "$script" || true)"
(( arguments == 1 )) \
|| note "search-web declares $arguments arguments; a fallback command takes exactly 1"
grep -q '"percentEncoded": true' "$script" \
|| note 'the argument is not percent-encoded, so a query containing & or # is truncated'
grep -qE '@vicinae\.mode silent' "$script" \
|| note 'search-web is not a silent command, so it would render a view it has nothing to put in'
# The default browser is a setting this desktop already owns. Naming a
# browser here would quietly outrank the Applications page.
grep -q 'xdg-open' "$script" \
|| note 'search-web does not open through xdg-open, so it ignores the default browser'
grep -qE '\b(helium|firefox|chromium|google-chrome)\b' "$script" \
&& note 'search-web names a specific browser instead of following the default'
fi
# ── The extension ────────────────────────────────────────────────────────────
manifest="$extension/package.json"
lockfile="$extension/package-lock.json"
if [[ ! -f "$manifest" ]]; then
note 'the panama-search extension has no manifest'
else
if command -v jq >/dev/null 2>&1; then
jq -e . "$manifest" >/dev/null 2>&1 || note 'the extension manifest is not valid JSON'
for field in name title description commands; do
jq -e "has(\"$field\")" "$manifest" >/dev/null 2>&1 \
|| note "the extension manifest has no \"$field\", which Vicinae requires"
done
# A command's name is its source file. Getting this wrong builds an
# extension with a command that cannot be opened.
while read -r command_name; do
[[ -n "$command_name" ]] || continue
[[ -f "$extension/src/$command_name.tsx" || -f "$extension/src/$command_name.ts" ]] \
|| note "the manifest declares command \"$command_name\" with no matching file in src/"
done < <(jq -r '.commands[]?.name // empty' "$manifest" 2>/dev/null)
fi
fi
if [[ ! -f "$lockfile" ]]; then
note 'the panama-search extension has no package-lock.json, so npm ci cannot install a fresh clone'
elif git -C "$repo_dir" check-ignore -q "$lockfile" 2>/dev/null; then
note 'the panama-search package-lock.json is ignored, so a fresh clone cannot use npm ci'
elif ! git -C "$repo_dir" ls-files --error-unmatch -- \
'config/local/share/vicinae/extensions/panama-search/package-lock.json' >/dev/null 2>&1; then
note 'the panama-search package-lock.json is not tracked, so a fresh clone cannot use npm ci'
fi
# ── One engine, written twice ────────────────────────────────────────────────
#
# The script command and the extension both have to know where a search goes.
# Nothing makes them agree, so this does: searching from the fallback and
# searching from the suggestions list must not reach different places.
engine_in_script="$(grep -oE 'https://[^"]+\?q=' "$script" 2>/dev/null | head -1)"
engine_in_extension="$(grep -oE 'https://[^"]+\?q=' "$extension/src/search.tsx" 2>/dev/null | head -1)"
if [[ -z "$engine_in_script" ]]; then
note 'no search engine URL found in search-web'
elif [[ -z "$engine_in_extension" ]]; then
note 'no search engine URL found in the extension'
elif [[ "$engine_in_script" != "$engine_in_extension" ]]; then
note "the script searches $engine_in_script but the extension searches $engine_in_extension"
fi
# ── Provisioning ─────────────────────────────────────────────────────────────
#
# An extension is compiled, so unlike a script command it cannot simply be
# linked. If nothing builds it, it ships as source nobody can run.
grep -q 'npm run build' "$stage" \
|| note 'no stage builds the Vicinae extensions, so they never reach the launcher'
grep -q 'command -v npm' "$stage" \
|| note 'the extension build does not check for npm, so a machine without it fails the stage'
# Checking for npm is not enough. Node comes from nvm, whose script only an
# interactive shell sources -- and a stage is not one, so npm is absent unless
# the stage goes and gets it. This machine hid that: a system npm existed only
# because the Claude Desktop of the day pulled in nodejs. It no longer does, so
# nothing hides it any more. Without this line, a fresh install builds no
# extension and says so in one line nobody reads.
grep -q '/etc/profile.d/nvm.sh' "$stage" \
|| note 'the extension build never sources nvm, so npm is missing on any machine without a system node'
# node_modules is a dependency tree, not configuration.
git -C "$repo_dir" check-ignore -q "$extension/node_modules/" 2>/dev/null \
|| note 'the extension node_modules is not gitignored'
# npm must honour the committed dependency graph. This disposable fixture
# simulates npm rejecting a mismatched lockfile, which must leave that lockfile
# untouched and keep the launcher stage nonfatal.
fixture_root="$(mktemp -d -t panama-vicinae-lock.XXXXXX)"
trap 'rm -rf -- "$fixture_root"' EXIT
mkdir -p "$fixture_root/config/local/share/vicinae/scripts" \
"$fixture_root/config/local/share/vicinae/extensions/panama-search/src" \
"$fixture_root/bin"
fixture_extension="$fixture_root/config/local/share/vicinae/extensions/panama-search"
cp -- "$manifest" "$fixture_extension/package.json"
cp -- "$lockfile" "$fixture_extension/package-lock.json"
cmp -s -- "$lockfile" "$fixture_extension/package-lock.json" \
|| note 'the fresh-clone fixture did not consume the repository package-lock.json'
sed -i 's/"dependencies": {/"dependencies": {"fixture-mismatch": "1.0.0",/' \
"$fixture_extension/package.json"
lockfile="$fixture_extension/package-lock.json"
lock_before="$fixture_root/package-lock.before"
cp -- "$lockfile" "$lock_before"
repository_lock_sha256="$(sha256sum -- "$lock_before" | awk '{ print $1 }')"
cat >"$fixture_root/bin/npm" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"${NPM_LOG:?}"
[[ "${1:-}" == ci ]] || exit 64
[[ "$(sha256sum -- package-lock.json | awk '{ print $1 }')" == "${NPM_EXPECTED_LOCK_SHA256:?}" ]] || exit 65
exit 1
EOF
chmod +x "$fixture_root/bin/npm"
stage_status=0
stage_output="$(PATH="$fixture_root/bin:$PATH" PANAMA_PATH="$fixture_root" \
VICINAE_DATA_DIR="$fixture_root/vicinae-data" NPM_LOG="$fixture_root/npm.log" \
NPM_EXPECTED_LOCK_SHA256="$repository_lock_sha256" \
bash "$stage" 2>&1)" || stage_status=$?
[[ "$stage_status" -eq 0 ]] \
|| note "the Vicinae stage returned $stage_status for a lockfile mismatch instead of remaining nonfatal"
[[ "$(<"$fixture_root/npm.log")" == 'ci --silent' ]] \
|| note 'the Vicinae extension dependency command was not npm ci --silent'
[[ "$stage_output" == *'Vicinae extension panama-search did not build; skipping'* ]] \
|| note 'a nonzero Vicinae extension dependency install did not fail the extension build'
cmp -s -- "$lock_before" "$lockfile" \
|| note 'a rejected Vicinae lockfile mismatch changed package-lock.json'
# Successful builds carry a digest receipt over both manifests and every
# source file. Directory mtimes do not change when an existing file is edited,
# so each byte class must independently invalidate the build.
digest_root="$fixture_root/digest"
digest_extension="$digest_root/config/local/share/vicinae/extensions/panama-search"
digest_data="$digest_root/vicinae-data"
mkdir -p "$digest_root/config/local/share/vicinae/scripts" \
"$digest_extension/src" "$digest_extension/assets" "$digest_root/bin"
cp -- "$manifest" "$digest_extension/package.json"
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/package-lock.json" \
"$digest_extension/package-lock.json"
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/src/search.tsx" \
"$digest_extension/src/search.tsx"
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/tsconfig.json" \
"$digest_extension/tsconfig.json"
cp -- "$repo_dir/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg" \
"$digest_extension/assets/extension_icon.svg"
cat >"$digest_root/bin/npm" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"${NPM_LOG:?}"
case "${1:-}:${2:-}" in
ci:--silent) exit 0 ;;
run:build)
mkdir -p "$VICINAE_DATA_DIR/extensions/$(basename "$PWD")"
printf 'built\n' >"$VICINAE_DATA_DIR/extensions/$(basename "$PWD")/bundle"
;;
*) exit 64 ;;
esac
EOF
cat >"$digest_root/bin/find" <<'EOF'
#!/usr/bin/env bash
set -uo pipefail
status=0
/usr/bin/find "$@" || status=$?
[[ "${STUB_FIND_FAIL:-0}" != 1 ]] || exit 74
exit "$status"
EOF
chmod +x "$digest_root/bin/npm" "$digest_root/bin/find"
run_digest_stage() {
: >"$digest_root/npm.log"
PATH="$digest_root/bin:$PATH" PANAMA_PATH="$digest_root" \
VICINAE_DATA_DIR="$digest_data" NPM_LOG="$digest_root/npm.log" \
STUB_FIND_FAIL="${STUB_FIND_FAIL:-0}" \
bash "$stage" >"$digest_root/stage.out" 2>&1
}
run_digest_stage || note 'the Vicinae digest fixture initial build failed'
cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \
|| note 'the Vicinae digest fixture did not perform its initial locked build'
run_digest_stage || note 'the unchanged Vicinae digest fixture failed'
[[ ! -s "$digest_root/npm.log" ]] \
|| note 'an unchanged Vicinae extension rebuilt despite its matching receipt'
for digest_input in src/search.tsx package.json package-lock.json tsconfig.json \
assets/extension_icon.svg; do
printf '\n// digest mutation: %s\n' "$digest_input" >>"$digest_extension/$digest_input"
run_digest_stage || note "the Vicinae digest fixture failed after changing $digest_input"
cmp -s <(printf 'ci --silent\nrun build\n') "$digest_root/npm.log" \
|| note "changing existing $digest_input bytes did not rebuild the Vicinae extension"
done
# A traversal can emit valid-looking partial output and still fail. Sorting
# that output must not hide find's producer status or replace the successful
# build receipt with a digest over an incomplete source tree.
digest_receipt="$digest_data/extensions/panama-search/.panama-source-sha256"
cp -- "$digest_receipt" "$digest_root/receipt.before-find-failure"
STUB_FIND_FAIL=1 run_digest_stage \
|| note 'the Vicinae stage made a digest traversal failure fatal'
[[ ! -s "$digest_root/npm.log" ]] \
|| note 'a failed Vicinae digest traversal still rebuilt the extension'
grep -q 'inputs could not be verified; skipping' "$digest_root/stage.out" \
|| note 'a failed Vicinae digest traversal was accepted as verified input'
cmp -s -- "$digest_root/receipt.before-find-failure" "$digest_receipt" \
|| note 'a failed Vicinae digest traversal replaced the successful receipt'
# Helper writes run in conditional contexts in production, where Bash disables
# implicit errexit inside the whole function. Each producer therefore has to
# return its own write/publication failure and remove its temporary receipt.
vicinae_helpers="$digest_root/vicinae-helpers"
sed '/^panama_path=/,$d' "$stage" >"$vicinae_helpers"
: >"$digest_root/empty-inputs"
mkdir "$digest_root/manifest-output-directory"
manifest_status=0
bash -c 'source "$1"; set +e; _write_vicinae_manifest "$2" "$3" "$4"' bash \
"$vicinae_helpers" "$digest_extension" "$digest_root/empty-inputs" \
"$digest_root/manifest-output-directory" >/dev/null 2>&1 \
|| manifest_status=$?
[[ "$manifest_status" -ne 0 ]] \
|| note 'a failed Vicinae manifest initialization returned success'
receipt_failure_root="$digest_root/receipt-publication-failure"
mkdir -p "$receipt_failure_root/built" "$receipt_failure_root/bin"
printf 'prior receipt\n' >"$receipt_failure_root/built/.panama-source-sha256"
cat >"$receipt_failure_root/bin/mv" <<'EOF'
#!/usr/bin/env bash
destination="${!#}"
[[ "$destination" != */.panama-source-sha256 ]] || exit 75
exec /usr/bin/mv "$@"
EOF
chmod +x "$receipt_failure_root/bin/mv"
receipt_status=0
PATH="$receipt_failure_root/bin:$PATH" bash -c \
'source "$1"; set +e; _record_vicinae_digest "$2" "$3"' bash \
"$vicinae_helpers" "$receipt_failure_root/built" "$(printf 'a%.0s' {1..64})" \
>/dev/null 2>&1 || receipt_status=$?
[[ "$receipt_status" -ne 0 ]] \
|| note 'a failed Vicinae receipt publication returned success'
cmp -s <(printf 'prior receipt\n') \
"$receipt_failure_root/built/.panama-source-sha256" \
|| note 'a failed Vicinae receipt publication replaced the prior receipt'
[[ -z "$(find "$receipt_failure_root/built" \
-name '.panama-source-sha256.*' -print -quit)" ]] \
|| note 'a failed Vicinae receipt publication left a temporary receipt'
# Prove the directory-only ignore rule in a repository where node_modules does
# not already exist. The trailing slash is part of the query contract.
ignore_root="$fixture_root/ignore-repository"
mkdir -p "$ignore_root/config/local/share/vicinae/extensions/panama-search"
cp -- "$repo_dir/.gitignore" "$ignore_root/.gitignore"
git -C "$ignore_root" init -q
git -C "$ignore_root" check-ignore -q \
'config/local/share/vicinae/extensions/panama-search/node_modules/' \
|| note 'a fresh clone with no node_modules directory does not match the ignore rule'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'launcher search contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'launcher search contract: PASS\n'