#!/usr/bin/env bash # LLVM source-coverage for the Spreadsheet engine and UI controller logic. # # Everything lives in one temporary directory — toolchain, cargo caches, crate # copies, build artefacts, profiles and the report — and it is removed on any # exit path. Nothing is installed into the host, and the repository's own # `target/` is untouched. # # Usage: # ./tools/test-spreadsheet-coverage.sh # engine + ui # COVERAGE_TARGET=engine ./tools/test-spreadsheet-coverage.sh # COVERAGE_TARGET=ui ./tools/test-spreadsheet-coverage.sh # KEEP_COVERAGE=1 ./tools/test-spreadsheet-coverage.sh # keeps the report # # `KEEP_COVERAGE=1` additionally writes an uncovered-line listing, which is # what makes "add tests for uncovered branches" a directed activity rather # than guesswork. set -Eeuo pipefail IFS=$'\n\t' ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" COVERAGE_TARGET="${COVERAGE_TARGET:-all}" KEEP_COVERAGE="${KEEP_COVERAGE:-0}" ENGINE_FLOOR="${ENGINE_FLOOR:-96}" UI_FLOOR="${UI_FLOOR:-96}" case "$COVERAGE_TARGET" in all | engine | ui) ;; *) echo "error: COVERAGE_TARGET must be all, engine or ui" >&2 exit 2 ;; esac # Two constraints pull against each other here. # # `/tmp` is a small tmpfs on some hosts and an instrumented build plus a # toolchain will not fit, so the default must be somewhere roomier. But the # work directory must *not* sit inside the repository: Cargo would then treat # the copied crates as workspace members and refuse to build them. # # `$HOME/.cache` satisfies both — same roomy filesystem, outside the # workspace. A caller-supplied TMPDIR wins, and is assumed to be sane. DEFAULT_TMP="${HOME:-/var/tmp}/.cache/nigig-coverage" mkdir -p "${TMPDIR:-$DEFAULT_TMP}" WORK="$(mktemp -d "${TMPDIR:-$DEFAULT_TMP}/spreadsheet-coverage.XXXXXXXX")" cleanup() { local status=$? if [[ "$KEEP_COVERAGE" == "1" ]]; then echo "coverage environment retained: $WORK" >&2 else rm -rf -- "$WORK" # Only remove the default parent, and only when we created it and it # is empty: a caller-supplied TMPDIR is not ours to delete. rmdir "$DEFAULT_TMP" 2>/dev/null || true rmdir "${HOME:-/var/tmp}/.cache" 2>/dev/null || true echo "cleaned isolated coverage environment" >&2 fi exit "$status" } trap cleanup EXIT HUP INT TERM TOOLCHAIN="$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \ "$ROOT/rust-toolchain.toml" | head -n 1)" if [[ -z "$TOOLCHAIN" ]]; then echo "error: could not read channel from rust-toolchain.toml" >&2 exit 2 fi export RUSTUP_HOME="$WORK/rustup" export CARGO_HOME="$WORK/cargo" export CARGO_TARGET_DIR="$WORK/target" export PATH="$CARGO_HOME/bin:$PATH" export CARGO_NET_GIT_FETCH_WITH_CLI=true export CARGO_INCREMENTAL=0 export LLVM_PROFILE_FILE="$WORK/profiles/%p-%m.profraw" # `-C opt-level=0` keeps line mapping honest: optimisation merges and drops # lines, which makes a coverage report describe a program nobody wrote. export RUSTFLAGS="-C instrument-coverage -C codegen-units=1 -C opt-level=0" mkdir -p "$WORK/profiles" echo "creating isolated coverage toolchain ($TOOLCHAIN) in $WORK" >&2 curl --fail --location --proto '=https' --tlsv1.2 https://sh.rustup.rs \ -o "$WORK/rustup-init" chmod 700 "$WORK/rustup-init" "$WORK/rustup-init" -y --profile minimal --default-toolchain "$TOOLCHAIN" \ --component llvm-tools-preview --no-modify-path LLVM_BIN="$RUSTUP_HOME/toolchains/$TOOLCHAIN-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/bin" # ── Exclusions (step 4) ────────────────────────────────────────────────────── # # Two categories are excluded, for different reasons. # # 1. **Not our code.** Registry and git dependencies, the Rust standard # library, and the copy path itself. Including them measures other # people's tests. # # 2. **Generated Makepad DSL and platform startup.** `script_mod! { ... }` # expands to widget-registration code that no unit test can reach without # a live `Cx` and a window, and `src/bin/` is the desktop entry point. # Counting them does not report "untested logic", it reports "logic that # cannot be reached from a test", which drags the number down while # telling you nothing actionable. They are excluded by *file*, and the # files that hold them are named here rather than pattern-matched, so # adding a new one is a deliberate act. # # **This exclusion has to be audited, not trusted.** `grid.rs` is 2,700 # lines and only its last ~30 are `script_mod!`; of its 59 functions, 36 # took no `cx`, no `Event` and no `Scope`. Those were pure arithmetic — # hit testing, cell rectangles, frozen panes, autofill handles — hidden # behind a file-level exclusion and carrying no tests at all. They now # live in `geometry.rs`, which is measured, and `grid.rs` delegates to # it. An exclusion that quietly grows to cover real logic is worse than # no exclusion, because the number stays green while the coverage goes # away. Before widening this list, check what is actually inside. # # Everything else in the UI crate — the controller modules — is measured. # Only foreign code is filtered. The copied crates live *under* the work # directory, so matching the work path here would exclude the sources being # measured — which is exactly what an earlier version of this script did, # reporting a confident 0%. IGNORE_COMMON='(/cargo/registry|/cargo/git|/rustc/)' # grid.rs, ui.rs and workspace.rs are the three files carrying `script_mod!`. # Their *testable* logic belongs in a measured module — see geometry.rs — # rather than behind this exclusion. IGNORE_UI="$IGNORE_COMMON"'|spreadsheet-ui/src/(grid|ui|workspace|trend_chart|virtual_grid)\.rs|spreadsheet-ui/src/bin/' # Collect every instrumented test binary as `-object` arguments. # # One binary is not enough, and the shortfall is silent. Cargo builds each # integration test into its *own* executable, so measuring only the lib-test # binary discards everything `tests/` exercised. That is not a rounding # error: `persistence.rs` reported 41.77% with 14 of its 17 functions # apparently never called, while `tests/sync_flow.rs` was calling # `save_spreadsheet_state` and `load_saved_spreadsheet_state` on every run # and passing. The functions were covered; the report was reading the wrong # object. Same class of defect as the two already fixed in this script. collect_objects() { local pattern="$1" OBJECTS=() local bin first=1 while IFS= read -r bin; do # llvm-cov takes the first binary positionally and the rest through # `-object`. Passing every one as `-object` makes it read the first # *source* path as the positional binary and fail with "not a valid # object file", so the split matters. if (( first )); then OBJECTS+=("$bin") first=0 else OBJECTS+=(-object "$bin") fi done < <(find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f \ -executable ! -name '*.d' ! -name '*.so' -name "$pattern" | sort) } report_for() { local label="$1" profdata="$2" ignore="$3" floor="$4" shift 4 local sources=("$@") echo >&2 echo "── $label ─────────────────────────────────────────────" >&2 "$LLVM_BIN/llvm-cov" report "${OBJECTS[@]}" \ -instr-profile="$profdata" \ -ignore-filename-regex="$ignore" \ "${sources[@]}" local covered covered="$("$LLVM_BIN/llvm-cov" export "${OBJECTS[@]}" \ -instr-profile="$profdata" \ -ignore-filename-regex="$ignore" \ -summary-only "${sources[@]}" \ | grep -o '"lines":{[^}]*}' | tail -1 \ | grep -o '"percent":[0-9.]*' | cut -d: -f2)" covered="${covered%%.*}" if [[ -z "$covered" ]]; then echo "error: could not read $label line coverage" >&2 exit 1 fi if (( covered < floor )); then echo "FAIL: $label line coverage ${covered}% is below the ${floor}% floor" >&2 exit 1 fi echo "$label line coverage ${covered}% meets the ${floor}% floor" >&2 } uncovered_listing() { local label="$1" profdata="$2" ignore="$3" shift 3 "$LLVM_BIN/llvm-cov" show "${OBJECTS[@]}" \ -instr-profile="$profdata" \ -ignore-filename-regex="$ignore" \ "$@" \ | awk ' # `llvm-cov show` prints a bare path line before each file. Match # any line that is not a numbered source line and ends in `.rs` # or `.rs:` — an earlier version required a trailing colon and # silently produced an empty listing. /^[^ 0-9]/ && /\.rs:?$/ { file = $0; sub(/:$/, "", file); next } /^ *[0-9]+\| *0\|/ { print file ": " $0 } ' > "$WORK/uncovered-$label.txt" || true echo "uncovered lines: $WORK/uncovered-$label.txt" >&2 } # ── Engine ─────────────────────────────────────────────────────────────────── if [[ "$COVERAGE_TARGET" == "all" || "$COVERAGE_TARGET" == "engine" ]]; then ENGINE="$WORK/spreadsheet-engine" cp -a "$ROOT/crates/apps/spreadsheet/spreadsheet-engine" "$ENGINE" # `--all-targets` picks up tests/sync_flow.rs as well as the unit tests. cargo test --manifest-path "$ENGINE/Cargo.toml" --all-targets "$LLVM_BIN/llvm-profdata" merge -sparse "$WORK/profiles"/*.profraw \ -o "$WORK/engine.profdata" # Every engine test binary: the lib tests plus each file in tests/. collect_objects '*' if [[ "${#OBJECTS[@]}" -eq 0 ]]; then echo "error: no instrumented engine test binaries were produced" >&2 exit 1 fi # A directory is accepted as a source filter only with a single # `-object`; with several, llvm-cov rejects it. Expand to the files. ENGINE_SOURCES=() while IFS= read -r f; do ENGINE_SOURCES+=("$f") done < <(find "$ENGINE/src" -name '*.rs' | sort) report_for "engine" "$WORK/engine.profdata" \ "$IGNORE_COMMON" "$ENGINE_FLOOR" "${ENGINE_SOURCES[@]}" if [[ "$KEEP_COVERAGE" == "1" ]]; then uncovered_listing "engine" "$WORK/engine.profdata" \ "$IGNORE_COMMON" "${ENGINE_SOURCES[@]}" fi fi # ── UI controller logic ────────────────────────────────────────────────────── if [[ "$COVERAGE_TARGET" == "all" || "$COVERAGE_TARGET" == "ui" ]]; then # Makepad's Linux backend needs native libraries to link a test binary. bash "$ROOT/tools/makepad-native-libs.sh" --check rm -f "$WORK/profiles"/*.profraw mkdir -p "$WORK/spreadsheet" cp -a "$ROOT/crates/apps/spreadsheet/spreadsheet-engine" \ "$WORK/spreadsheet/spreadsheet-engine" cp -a "$ROOT/crates/apps/spreadsheet/spreadsheet-ui" \ "$WORK/spreadsheet/spreadsheet-ui" UI="$WORK/spreadsheet/spreadsheet-ui" # Measure the headless controller/unit suite only. The makepad-test # integration binary requires a supported Studio runtime and is kept in # the separate spreadsheet-ui test target. cargo test --manifest-path "$UI/Cargo.toml" --lib "$LLVM_BIN/llvm-profdata" merge -sparse "$WORK/profiles"/*.profraw \ -o "$WORK/ui.profdata" collect_objects '*' if [[ "${#OBJECTS[@]}" -eq 0 ]]; then echo "error: no instrumented UI test binaries were produced" >&2 exit 1 fi # Only the controller modules are measured; see the exclusion note above. report_for "ui-controllers" "$WORK/ui.profdata" \ "$IGNORE_UI" "$UI_FLOOR" \ "$UI/src/button.rs" "$UI/src/chart.rs" "$UI/src/checkbox.rs" "$UI/src/clipboard.rs" "$UI/src/dropdown.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \ "$UI/src/formula_bar.rs" \ "$UI/src/geometry.rs" "$UI/src/input.rs" "$UI/src/market.rs" "$UI/src/markdown.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \ "$UI/src/selection.rs" "$UI/src/slider.rs" "$UI/src/sort_state.rs" "$UI/src/sparkline.rs" "$UI/src/text_measure.rs" "$UI/src/zoom.rs" if [[ "$KEEP_COVERAGE" == "1" ]]; then uncovered_listing "ui" "$WORK/ui.profdata" "$IGNORE_UI" \ "$UI/src/button.rs" "$UI/src/chart.rs" "$UI/src/checkbox.rs" "$UI/src/clipboard.rs" "$UI/src/dropdown.rs" "$UI/src/edit.rs" "$UI/src/event_router.rs" \ "$UI/src/formula_bar.rs" \ "$UI/src/geometry.rs" "$UI/src/input.rs" "$UI/src/market.rs" "$UI/src/markdown.rs" "$UI/src/model.rs" "$UI/src/render_cache.rs" \ "$UI/src/selection.rs" "$UI/src/slider.rs" "$UI/src/sort_state.rs" "$UI/src/sparkline.rs" "$UI/src/text_measure.rs" "$UI/src/zoom.rs" fi fi echo >&2 echo "spreadsheet coverage run complete" >&2