From 7ac25c7e3f8f28b0012b99b8fbb1f47adfb1e5bf Mon Sep 17 00:00:00 2001 From: Bernardo Pinto Gomes Date: Mon, 26 Oct 2026 20:49:05 -0300 Subject: [PATCH 1/4] fix(dash): fit provider rows into the card's content area The row count divided the whole card height by the row height, but the card pads its content, so the last row ran into the bottom edge. The usage bars also hardcoded a 4 px height, 2 px radius and 3 px spacing; they now use Theme.spacingS, height / 2 and Theme.spacingXS like the DMS example cards. The runtime test shrinks the card to the smallest height that lists every row and checks the rows keep the card's padding. Signed-off-by: Bernardo Pinto Gomes --- AiOverviewDashCard.qml | 13 +++++++------ tests/test-widget-runtime.sh | 31 ++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/AiOverviewDashCard.qml b/AiOverviewDashCard.qml index 4ac7b03..2fa08d6 100644 --- a/AiOverviewDashCard.qml +++ b/AiOverviewDashCard.qml @@ -25,11 +25,12 @@ DashCardComponent { // A square card shows one large number; larger cards list as many // providers as fit under the heading. readonly property bool compact: !wide && !tall - readonly property real rowHeight: Theme.fontSizeSmall * 1.4 + 3 + 4 + Theme.spacingS + readonly property real rowHeight: Theme.fontSizeSmall * 1.4 + Theme.spacingXS + Theme.spacingS + Theme.spacingS readonly property int rows: { if (compact) return 1; - const fit = Math.max(1, Math.floor((height - heading.height) / rowHeight)); + // The card pads its content, so fit rows into the content area. + const fit = Math.max(1, Math.floor((heading.parent.height - heading.height) / rowHeight)); const wanted = Number(options.count ?? 0); return wanted > 0 ? Math.min(wanted, fit) : fit; } @@ -150,7 +151,7 @@ DashCardComponent { Column { required property var modelData width: parent.width - spacing: 3 + spacing: Theme.spacingXS Item { width: parent.width @@ -187,14 +188,14 @@ DashCardComponent { Rectangle { width: parent.width - height: 4 - radius: 2 + height: Theme.spacingS + radius: height / 2 color: root.chipColor Rectangle { width: modelData.error ? 0 : Math.max(height, parent.width * (Number(modelData.percent) || 0) / 100) height: parent.height - radius: 2 + radius: height / 2 color: root.usageColor(modelData.percent) } } diff --git a/tests/test-widget-runtime.sh b/tests/test-widget-runtime.sh index 2ad9d1e..c03c291 100755 --- a/tests/test-widget-runtime.sh +++ b/tests/test-widget-runtime.sh @@ -439,11 +439,40 @@ ShellRoot { if (dashCard.items.length === 0 || dashCard.items[0].id !== "claude" || dashCard.rows < 2) { console.error("SMOKE FAIL: dash card did not read the widget snapshot " + JSON.stringify(dashCard.snapshot)); return; } + // Shrink the card to the smallest height that still lists every + // provider; the next tick checks the rows once they are laid out. + const listed = dashCard.snapshot.items.length; + let h = dashCard.height; + while (h > 1 && dashCard.rows >= listed) + dashCard.height = --h; + dashCard.height = h + 1; + root.step = 15; + } else if (root.step === 15) { + // With the theme's bar height and spacing, every row still fits. + // The offscreen window never polishes, so lay the columns out first. + const layout = item => { for (const c of item.children) layout(c); if (typeof item.forceLayout === "function") item.forceLayout(); }; + layout(dashCard); + // Content leaves (not the full-size card background) must keep the + // same margin at the bottom as the card's padding at the top. + const leaves = []; + const collect = item => { + if (!item.visible) return; + const shown = Array.from(item.children).filter(c => c.visible); + if (shown.length === 0 && item.height > 0 && item.height < dashCard.height) leaves.push(item); + shown.forEach(collect); + }; + collect(dashCard); + const top = Math.min(...leaves.map(l => l.mapToItem(dashCard, 0, 0).y)); + const bottom = Math.max(...leaves.map(l => l.mapToItem(dashCard, 0, l.height).y)); + if (dashCard.items.length !== dashCard.snapshot.items.length || bottom > dashCard.height - top + 0.5) { + console.error("SMOKE FAIL: dash card rows overflow at height " + dashCard.height + ": " + bottom + " > " + (dashCard.height - top)); return; + } + dashCard.height = 300; widget.confirmResetProviderId = ""; widget.allExpanded = false; widget.providers = [{provider: "codex", usage: {primary: {usedPercent: 25}}}]; console.warn("FULL_UI_SMOKE_OK"); - root.step = 15; + root.step = 16; } } } From cbea63a4fc2ee567469e471c6c4ded99591c3f20 Mon Sep 17 00:00:00 2001 From: Bernardo Pinto Gomes Date: Mon, 26 Oct 2026 20:49:06 -0300 Subject: [PATCH 2/4] test(coverage): raise statement coverage to 97.9% and gate it at 95% kcov 43 loses bash line tracking after a multi-line quoted argument with a single quote, which hid most of the Hermes and OpenCode SQL paths. CI now builds kcov from a pinned, checksum-verified commit that fixes it. The coverage report no longer counts lines bash never traces: empty case branches, closing fi/esac/done/} lines and array continuations. New tests cover limit-reset sign-in and HTTP errors, Hermes and pi aggregation failures, OpenCode and local analytics errors, quota-alert icons, history export destinations, 9Router's SQLite ledger, package refusals, qmlls-setup and the remaining provider status branches. The files below 80% are now at 100%. development-gates.md drops a stray merge marker. Signed-off-by: Bernardo Pinto Gomes --- .github/workflows/ci.yml | 31 +++++++++++++++--- AGENTS.md | 2 +- docs/development-gates.md | 33 ++++++++++++------- docs/repository-rules.md | 2 +- scripts/coverage | 2 +- scripts/coverage-report | 19 +++++++++-- tests/test-cov-a-exchange.sh | 7 ++++ tests/test-cov-b-9router.sh | 16 ++++++++++ tests/test-cov-b-codex.sh | 6 ++++ tests/test-cov-b-hermes.sh | 27 +++++++++++++++- tests/test-cov-c2-cline.sh | 2 ++ tests/test-cov-c2-kimi-code.sh | 4 +++ tests/test-cov-c2-zai-glm.sh | 5 +++ tests/test-cov-d-simple-api.sh | 6 ++++ tests/test-cov-d2-fireworks.sh | 7 +++- tests/test-coverage-report.sh | 34 ++++++++++++++------ tests/test-history-export.sh | 17 ++++++++++ tests/test-limit-resets.sh | 32 ++++++++++++++++++- tests/test-local-analytics.sh | 7 ++++ tests/test-metadata.sh | 2 +- tests/test-opencode.sh | 7 +++- tests/test-package-release.sh | 58 ++++++++++++++++++++++++++++++++++ tests/test-pi-analytics.sh | 14 ++++++++ tests/test-qmlls-setup.sh | 30 ++++++++++++++++++ tests/test-quota-alert.sh | 24 ++++++++++++++ tests/test-spend-providers.sh | 8 +++++ 26 files changed, 366 insertions(+), 36 deletions(-) create mode 100755 tests/test-package-release.sh create mode 100755 tests/test-qmlls-setup.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4ee984..17970eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,10 @@ env: SHELLCHECK_SHA256: "b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6" CROWDIN_CLI_VERSION: "5.3.0" CROWDIN_CLI_SHA256: "d3f8e74471b98d47c0964e85c992a04eb411d06138c4239118a4c94d29e3e9e4" + # kcov master after v43: v43 loses bash line tracking after a multi-line + # quoted argument that contains a single quote (fixed upstream in ba175851). + KCOV_COMMIT: "460cd988eaaa2271b682bbf1702443e90fb45c5e" + KCOV_SHA256: "9fdf40cca84ec358f515a90d69b20e8523821cf222f1af3f4c9d17c70b7d862b" jobs: changelog: @@ -122,27 +126,38 @@ jobs: coverage: name: Statement coverage runs-on: ubuntu-latest - # Ubuntu 24.04 does not package kcov; Arch ships it. + # Arch ships the current build toolchain and kcov's libraries. container: archlinux:base timeout-minutes: 40 steps: - - name: Install kcov and test prerequisites + - name: Install build and test prerequisites # Retried: a single flaky Arch mirror read otherwise fails the job. run: | for attempt in 1 2 3; do - pacman -Syu --noconfirm bash curl diffutils git jq kcov python shellcheck sqlite unzip zip && exit 0 + pacman -Syu --noconfirm bash binutils cmake curl diffutils elfutils gcc git jq make openssl \ + python shellcheck sqlite unzip zip zlib && exit 0 sleep $((attempt * 10)) done exit 1 + - name: Build kcov (verified checksum) + run: | + TARBALL="/tmp/kcov.tar.gz" + curl -fsSL "https://github.com/SimonKagstrom/kcov/archive/${KCOV_COMMIT}.tar.gz" -o "$TARBALL" + echo "${KCOV_SHA256} ${TARBALL}" | sha256sum --check --strict + tar -xzf "$TARBALL" -C /tmp + cmake -S "/tmp/kcov-${KCOV_COMMIT}" -B /tmp/kcov-build -DCMAKE_BUILD_TYPE=Release >/dev/null + cmake --build /tmp/kcov-build --parallel "$(nproc)" >/dev/null + install -m 0755 /tmp/kcov-build/src/kcov /usr/local/bin/kcov + kcov --version - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # Statements of the shell adapters and scripts, measured by kcov over every # non-QML suite; scripts/coverage-report drops the bodies of embedded jq # programs and heredocs, which kcov reports as unexecuted lines. - - name: Measure coverage (minimum 80%) + - name: Measure coverage (minimum 95%) env: - AIOC_COVERAGE_MIN: "80" + AIOC_COVERAGE_MIN: "95" run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" scripts/coverage /tmp/coverage @@ -765,6 +780,12 @@ jobs: - name: Integration test — Codex/OpenCode local analytics run: tests/test-local-analytics.sh + - name: Integration test — release package refusals + run: tests/test-package-release.sh + + - name: Integration test — qmlls import tree + run: tests/test-qmlls-setup.sh + - name: Branch coverage suites (pi analytics and per-adapter edge cases) run: | tests/test-coverage-report.sh diff --git a/AGENTS.md b/AGENTS.md index 6453dc6..bda4137 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ find providers -type f -print0 | xargs -0 shellcheck -S warning shellcheck -S warning tests/*.sh scripts/check-metadata scripts/check-dco scripts/package-release \ scripts/qmlls-setup scripts/reload-plugin scripts/render-contributors .githooks/pre-push for test in tests/test-*.sh; do bash "$test"; done -AIOC_COVERAGE_MIN=80 scripts/coverage # kcov statement coverage (CI: Statement coverage) +AIOC_COVERAGE_MIN=95 scripts/coverage # kcov statement coverage (CI: Statement coverage) QT_FORCE_STDERR_LOGGING=1 qmllint *.qml # exit code is the signal; without a TTY # messages go to journald, not stderr actionlint diff --git a/docs/development-gates.md b/docs/development-gates.md index c9bb790..1a37f93 100644 --- a/docs/development-gates.md +++ b/docs/development-gates.md @@ -206,7 +206,6 @@ docker build -t aioc-cflite -f .clusterfuzzlite/Dockerfile . docker run --rm -e SANITIZER=address -e FUZZING_LANGUAGE=python -e FUZZING_ENGINE=libfuzzer \ -e ARCHITECTURE=x86_64 -v "$PWD/out:/out" aioc-cflite compile ``` -||||||| parent of 2bcc33c (test: raise shell statement coverage to 80% and enforce it in CI) ## Statement coverage @@ -214,7 +213,7 @@ docker run --rm -e SANITIZER=address -e FUZZING_LANGUAGE=python -e FUZZING_ENGIN under [kcov](https://github.com/SimonKagstrom/kcov), merges the runs and prints statement coverage of `providers/` and `scripts/`, with a per-file table in `OUTPUT_DIR/files.tsv` (default `./coverage/`, ignored by git). Set -`AIOC_COVERAGE_MIN=80` to fail below 80%, as the **Statement coverage** CI job +`AIOC_COVERAGE_MIN=95` to fail below 95%, as the **Statement coverage** CI job does. kcov's own percentage counts every physical line, including the body of a @@ -222,15 +221,27 @@ multi-line jq or awk program, a heredoc body and a backslash-continued line. Those lines belong to the statement that starts above them and are never reported as executed, so `scripts/coverage-report` drops them and counts a statement as one group, covered when any of its lines ran: kcov records the -hit of a multi-line quoted command on its closing line -(`tests/test-coverage-report.sh` pins this). The jq programs themselves are -exercised by the fixture assertions, not measured line by line. - -kcov also loses line tracking after a command whose multi-line quoted argument -contains a single quote, such as SQL with `''` or `'$WEEK_EPOCH'` literals: -the statements after it read as unexecuted until a later line resyncs, even -though the suites run them. The Hermes and OpenCode analytics queries are -affected, so their per-file percentages understate what the tests exercise. +hit of a multi-line quoted command on its closing line. The elements of a +multi-line array assignment are grouped the same way. Lines that run no +command (an empty `200) ;;` case branch, a `fi ;;` or `esac ;;` closing a +branch, a `}` or `done` that redirects a block) are never traced by bash, so +they join the statement above them too. `tests/test-coverage-report.sh` pins +these rules. The jq programs themselves are exercised by the fixture +assertions, not measured line by line. + +kcov 43 loses line tracking after a command whose multi-line quoted argument +contains a single quote, such as SQL with `''` literals: the statements after +it read as unexecuted. kcov fixed this after 43 (commit `ba175851`), so CI +builds kcov from a pinned, checksum-verified commit (`KCOV_COMMIT` in +`ci.yml`). Locally, build the same commit when the packaged kcov is 43 or +older: + +```bash +git clone https://github.com/SimonKagstrom/kcov /tmp/kcov && cd /tmp/kcov +git checkout 460cd988eaaa2271b682bbf1702443e90fb45c5e +cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel +PATH="/tmp/kcov/build/src:$PATH" scripts/coverage +``` Coverage suites must not clear the environment (`env -i`) or unset `BASH_ENV`: kcov traces child `bash` processes through it. Capture a diff --git a/docs/repository-rules.md b/docs/repository-rules.md index bf05801..e40f845 100644 --- a/docs/repository-rules.md +++ b/docs/repository-rules.md @@ -16,7 +16,7 @@ committed policy and the live GitHub policy after any administrative change. - The branch must be up to date and pass the fourteen named checks in the policy, including **Changelog integrity**, **DCO sign-off** (see [CONTRIBUTING.md](../CONTRIBUTING.md#developer-certificate-of-origin)), **Secret scan** (gitleaks and verified - TruffleHog over the full history), **Statement coverage** (80% minimum), + TruffleHog over the full history), **Statement coverage** (95% minimum), **Fuzz provider responses** and **Workflow security audit** (zizmor). Checks are bound to the GitHub Actions app (integration ID 15368), not a same-named status posted by an arbitrary actor. - Required approving-review count is **zero**: this repository permits a sole diff --git a/scripts/coverage b/scripts/coverage index a1b909e..0b31786 100755 --- a/scripts/coverage +++ b/scripts/coverage @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Measure statement coverage of the shell adapters and scripts with kcov. -# Usage: scripts/coverage [OUTPUT_DIR]; AIOC_COVERAGE_MIN=80 fails below 80%, +# Usage: scripts/coverage [OUTPUT_DIR]; AIOC_COVERAGE_MIN=95 fails below 95%, # AIOC_COVERAGE_JOBS sets the parallel suites (default: CPU count). # Requires kcov and python3. set -euo pipefail diff --git a/scripts/coverage-report b/scripts/coverage-report index 31bdba0..0f7ec64 100755 --- a/scripts/coverage-report +++ b/scripts/coverage-report @@ -6,7 +6,13 @@ body of a multi-line quoted string (an embedded jq, awk or Python program), a heredoc body and a backslash-continued argument line. Those lines belong to the statement that starts above them and are not separate statements. They are grouped with that starting line, and a statement counts as covered when any -line in the group has hits. +line in the group has hits. The elements of a multi-line array assignment are +grouped the same way. + +Bash never traces a line that runs no command: an empty case branch +(`200) ;;`), a `fi ;;` or `esac ;;` closing a branch, or the `}` or `done` that +redirects a block. kcov still lists those lines, so they are grouped with the +statement above them too. Usage: scripts/coverage-report CODECOV_JSON ROOT [--min PERCENT] [--files TSV] """ @@ -17,6 +23,10 @@ import sys from pathlib import Path HEREDOC = re.compile(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2") +NO_COMMAND = re.compile( + r"\s*(?:\(?[^\s()#;][^()#;]*\)\s*;;" # empty case branch: pattern) ;; + r"|(?:fi|done|esac|\})\s*;;" # block closed inside a branch + r"|(?:done|\})\s*[0-9]*[<>].*)\s*(?:#.*)?") # block closed with a redirection def continuation_lines(text): @@ -28,6 +38,7 @@ def continuation_lines(text): heredocs = [] # pending (terminator, strip_tabs) for the next lines in_heredoc = None continued = False + array_depth = 0 # open parentheses of a NAME=( array assignment for number, line in enumerate(text.split("\n"), start=1): if in_heredoc: skip.add(number) @@ -35,7 +46,7 @@ def continuation_lines(text): if (line.lstrip("\t") if strip else line) == terminator: in_heredoc = heredocs.pop(0) if heredocs else None continue - if quote or continued or substitution_depth: + if quote or continued or substitution_depth or array_depth or NO_COMMAND.fullmatch(line): skip.add(number) continued = False i = 0 @@ -77,6 +88,10 @@ def continuation_lines(text): i += 1 elif char in "'\"": quote = char + elif char == "(" and (array_depth or (i > 0 and line[i - 1] == "=")): + array_depth += 1 + elif char == ")" and array_depth: + array_depth -= 1 elif char == "#" and (i == 0 or line[i - 1] in " \t;|&("): break elif char == "<" and line.startswith("<<", i) and not line.startswith("<<<", i): diff --git a/tests/test-cov-a-exchange.sh b/tests/test-cov-a-exchange.sh index 587dbbb..5770458 100755 --- a/tests/test-cov-a-exchange.sh +++ b/tests/test-cov-a-exchange.sh @@ -29,4 +29,11 @@ jq -e '.base == "USD" and .currency == "EUR" and .rate == 0.92 and .stale == fal cached="$(PATH="/nonexistent:/usr/bin:/bin" bash "$ROOT/providers/get-exchange-rates" EUR)" jq -e '.currency == "EUR" and .rate == 0.92' <<<"$cached" >/dev/null +# Opted out with a cache older than a day: served as stale, never refreshed. +cache="$XDG_CACHE_HOME/AiOverviewControl/exchange-rate-EUR.json" +jq --argjson at "$(( $(date +%s) - 2 * 86400 ))" '.fetchedAt = $at' "$cache" > "$TMP/old.json" +mv "$TMP/old.json" "$cache" +stale="$(AIOC_NO_FX=1 PATH="$TMP/bin:$PATH" bash "$ROOT/providers/get-exchange-rates" EUR)" +jq -e '.rate == 0.92 and .stale == true' <<<"$stale" >/dev/null + echo "exchange-rate coverage OK" diff --git a/tests/test-cov-b-9router.sh b/tests/test-cov-b-9router.sh index 733cc53..df060de 100755 --- a/tests/test-cov-b-9router.sh +++ b/tests/test-cov-b-9router.sh @@ -22,6 +22,22 @@ jq -e '.provider=="9router" and .usage.primary.displayValue=="$0.50 · 2 req" an out="$(fetch_9router_native openrouter openrouter)" jq -e '.provider=="openrouter" and (.usage.primary.resetDescription|contains("not openrouter quota"))' <<<"$out" >/dev/null || fail "native provider alias labeling" +# A provider 9router never routed is an error card, or a quiet decline when it +# only backs another adapter. +out="$(fetch_9router_native xai xai)" +jq -e '.provider=="xai" and .error.code==2 and (.error.message|contains("no recorded usage for xai"))' <<<"$out" >/dev/null || fail "unrouted provider error" +if fetch_9router_native xai xai soft; then fail "soft fallback should decline an unrouted provider"; fi + +# The SQLite ledger wins over usage.json when both exist. +if command -v sqlite3 >/dev/null 2>&1; then + mkdir -p "$HOME/.9router/db" + sqlite3 "$HOME/.9router/db/data.sqlite" "CREATE TABLE usageDaily(dateKey TEXT, data TEXT); + INSERT INTO usageDaily VALUES('$TODAY', '{\"requests\":7,\"cost\":3,\"promptTokens\":10,\"cachedTokens\":0,\"completionTokens\":5}');" + out="$(fetch_9router_native)" + jq -e '.usage.primary.displayValue=="$3.00 · 7 req" and .usage.secondary.displayValue=="$3.00 · 7 req"' <<<"$out" >/dev/null || fail "SQLite ledger: $out" + rm -rf "$HOME/.9router/db" +fi + mv "$HOME/.9router/usage.json" "$TMP/usage.json" out="$(fetch_9router_native)" jq -e '.error.code==2 and (.error.message|contains("database not found"))' <<<"$out" >/dev/null || fail "missing data error" diff --git a/tests/test-cov-b-codex.sh b/tests/test-cov-b-codex.sh index 0d13a3b..56b1aef 100755 --- a/tests/test-cov-b-codex.sh +++ b/tests/test-cov-b-codex.sh @@ -59,4 +59,10 @@ export CODEX_SCRIPT="$TMP/helper" out="$(fetch_codex_native)" jq -e '.provider == "codex" and .source == "fixture"' <<<"$out" >/dev/null || fail "Codex native forwarding" +# Without the Codex CLI the card says so instead of starting anything. +mkdir -p "$TMP/no-codex" +ln -s "$(command -v jq)" "$TMP/no-codex/jq" +out="$(PATH="$TMP/no-codex" "$BASH" "$ROOT/providers/get-codex-usage")" +jq -e '.error.code == 127 and (.error.message | contains("not installed"))' <<<"$out" >/dev/null || fail "missing Codex CLI: $out" + echo 'OK: Codex refresh, cache, and native coverage' diff --git a/tests/test-cov-b-hermes.sh b/tests/test-cov-b-hermes.sh index c617e6a..1faa54c 100755 --- a/tests/test-cov-b-hermes.sh +++ b/tests/test-cov-b-hermes.sh @@ -58,11 +58,36 @@ cached="$(bash "$ROOT/providers/get-hermes-analytics")" [ "$cached" = "$analytics" ] || fail "cache round-trip changed analytics JSON" [ "$(jq -r '.cached_at' "$cache")" = "$first" ] || fail "fresh analytics cache was rewritten" -# Drop the cache and point at an invalid schema to cover the honest query failure. +# A price table jq cannot apply fails the aggregation instead of printing zeros. rm -f "$cache" +prices="$XDG_CACHE_HOME/AiOverviewControl/litellm-prices.json" +cp "$prices" "$TMP/prices.json" +jq -n --arg today "$(date +%F)" '{updated:$today,models:{"not-listed":["a","b","c","d"]}}' > "$prices" +out="$(bash "$ROOT/providers/get-hermes-analytics")" +jq -e '.error|contains("failed to aggregate")' <<<"$out" >/dev/null || fail "aggregation failure response: $out" +[ ! -e "$cache" ] || fail "failed aggregation was cached" +mv "$TMP/prices.json" "$prices" + +# The native card reads today and the week separately; a database that locks +# between the two reads is an error, not a week of zeros. +mkdir -p "$TMP/locking-bin" +real_sqlite3="$(command -v sqlite3)" +cat > "$TMP/locking-bin/sqlite3" <="*) echo 'Error: database is locked' >&2; exit 5 ;; esac +exec "$real_sqlite3" "\$@" +SH +chmod +x "$TMP/locking-bin/sqlite3" +out="$(PATH="$TMP/locking-bin:$PATH" bash "$ROOT/providers/get-provider-usage" hermes '' 2>/dev/null)" +jq -e '.[0].error.message|contains("could not be read")' <<<"$out" >/dev/null || fail "week query failure: $out" + +# Point at an invalid schema to cover the honest query failure. mv "$HERMES_HOME/state.db" "$TMP/state.db" : > "$HERMES_HOME/state.db" out="$(bash "$ROOT/providers/get-hermes-analytics")" jq -e '.error|contains("could not be read")' <<<"$out" >/dev/null || fail "unsupported schema response" +rm -f "$HERMES_HOME/state.db" +out="$(bash "$ROOT/providers/get-hermes-analytics")" +jq -e '.error|contains("state database not found")' <<<"$out" >/dev/null || fail "missing database response" echo 'OK: Hermes SQLite analytics, status pricing, and native coverage' diff --git a/tests/test-cov-c2-cline.sh b/tests/test-cov-c2-cline.sh index 71ef4e2..439ae03 100755 --- a/tests/test-cov-c2-cline.sh +++ b/tests/test-cov-c2-cline.sh @@ -8,6 +8,7 @@ out=""; url=""; a=("$@"); for ((i=0;i<${#a[@]};i++)); do case "${a[$i]}" in -o) case "$CASE:$url" in reject:*) printf '%s' '{"message":"denied"}' >"$out"; printf 403;; error:*) printf '%s' '{}' >"$out"; printf 502;; + odd:*/users/me) printf '%s' '{"success":true,"data":{}}' >"$out"; printf 200;; *:*/users/me) printf '%s' '{"success":true,"data":{"id":"u1","email":"x@example.test","balance":0,"organizations":[],"plan":null}}' >"$out"; printf 200;; *:*/balance) printf '%s' '{"balance":0}' >"$out"; printf 200;; *:*/plan) printf '%s' '{}' >"$out";; @@ -18,5 +19,6 @@ chmod +x "$TMP/bin/curl"; fail(){ echo "FAIL: $1" >&2; exit 1; }; run(){ CASE="$ out="$(run reject)"; jq -e '.[0].error.code == 403 and (.[0].error.message | contains("rejected"))' <<<"$out" >/dev/null || fail reject out="$(run error)"; jq -e '.[0].error.code == 502' <<<"$out" >/dev/null || fail server-error out="$(run ok)"; jq -e '.[0].usage.primary.usedPercent == 100' <<<"$out" >/dev/null || fail empty-balance +out="$(run odd)"; jq -e '.[0].error.code == 200 and (.[0].error.message | contains("unexpected payload"))' <<<"$out" >/dev/null || fail unexpected-payload out="$(env -u CLINE_API_KEY PATH="$TMP/bin:$PATH" "$ROOT/providers/get-provider-usage" cline 2>/dev/null)"; jq -e '.[0].error' <<<"$out" >/dev/null || fail missing echo 'OK: test-cov-c2-cline' diff --git a/tests/test-cov-c2-kimi-code.sh b/tests/test-cov-c2-kimi-code.sh index f1e4eea..2b56d98 100755 --- a/tests/test-cov-c2-kimi-code.sh +++ b/tests/test-cov-c2-kimi-code.sh @@ -5,6 +5,7 @@ export HOME="$TMP/home" XDG_CONFIG_HOME="$TMP/config" XDG_CACHE_HOME="$TMP/cache cat >"$TMP/bin/curl" <<'STUB' #!/usr/bin/env bash out=""; url=""; a=("$@"); for ((i=0;i<${#a[@]};i++)); do case "${a[$i]}" in -o) out="${a[$((i+1))]}";; http*) url="${a[$i]}";; esac; done +case "$CASE" in gone) printf '' >"$out"; printf 404; exit;; down) printf '' >"$out"; printf 503; exit;; esac if [ "$CASE" = fail ]; then printf '%s' '{"message":"bad key"}' >"$out"; [ "${url%/usages}" != "$url" ] && { printf 404; exit; }; printf 401; exit; fi if [[ "$url" == */usages ]]; then printf '%s' '{}' >"$out"; printf 404; exit; fi printf '%s' '{"usage":{"limit":"20","remaining":"5","reset_in":20},"limits":[{"detail":{"limit_amount":10,"used_amount":2,"name":"weekly"},"window":{"duration":1,"timeUnit":"DAY"}},{"detail":{"limit":5,"used":1,"title":"month"},"window":{"duration":1,"time_unit":"MONTH"}}],"usages":{"limit_5h":{"used_ratio":0.5,"reset_time":1793000000},"limit_7d":{"usedRatio":0.4},"limit_month_total":{"used_ratio":0.3},"limit_month_code":{"used_ratio":0.9}}}' >"$out"; printf 200 @@ -13,4 +14,7 @@ chmod +x "$TMP/bin/curl"; fail(){ echo "FAIL: $1" >&2; exit 1; }; run(){ CASE="$ out="$(run ok)"; jq -e '.[0].provider == "kimi-code" and .[0].usage.identity.providerID == "kimi-code" and .[0].usage.primary != null and .[0].usage.tertiary != null' <<<"$out" >/dev/null || fail mappings out="$(run fail)"; jq -e '.[0].error.code == 401 and .[0].error.message == "bad key"' <<<"$out" >/dev/null || fail auth out="$(env -u KIMI_CODING_API_KEY -u KIMI_API_KEY -u MOONSHOT_API_KEY PATH="$TMP/bin:$PATH" "$ROOT/providers/get-provider-usage" kimi-code 2>/dev/null)"; jq -e '.[0].error' <<<"$out" >/dev/null || fail missing-key +# Without a message in the body, the status picks the hint. +out="$(run gone)"; jq -e '.[0].error.code == 404 and (.[0].error.message | contains("KIMI_BASE_URL"))' <<<"$out" >/dev/null || fail not-found +out="$(run down)"; jq -e '.[0].error.code == 503 and .[0].error.message == "Kimi Code quota API returned HTTP 503."' <<<"$out" >/dev/null || fail server-error echo 'OK: test-cov-c2-kimi-code' diff --git a/tests/test-cov-c2-zai-glm.sh b/tests/test-cov-c2-zai-glm.sh index 3f16a3a..6de8c4a 100755 --- a/tests/test-cov-c2-zai-glm.sh +++ b/tests/test-cov-c2-zai-glm.sh @@ -21,4 +21,9 @@ for p in zai z.ai glm zhipu; do if [[ "$p" == glm || "$p" == zhipu ]]; then jq -e '.[0].error.code == 500' <<<"$out" >/dev/null || fail "$p fallback failure" else jq -e '.[0].usage.accountEmail == "Z.ai account"' <<<"$out" >/dev/null || fail "$p fallback note"; fi done +# Without any key the card says which variables to set, and never calls out. +for p in zai glm; do + out="$(PATH="$TMP/bin:$PATH" env -u ZAI_API_KEY -u GLM_API_KEY -u ZHIPU_API_KEY "$ROOT/providers/get-provider-usage" "$p" 2>/dev/null)" + jq -e '.[0].error.code == 2 and (.[0].error.message | contains("is not set"))' <<<"$out" >/dev/null || fail "$p missing key: $out" +done echo 'OK: test-cov-c2-zai-glm' diff --git a/tests/test-cov-d-simple-api.sh b/tests/test-cov-d-simple-api.sh index cd9dfb0..40cbca3 100755 --- a/tests/test-cov-d-simple-api.sh +++ b/tests/test-cov-d-simple-api.sh @@ -86,5 +86,11 @@ for provider in cloudflare deepseek fireworks replicate gemini kilo together coh done export COV_STATUS=500 COV_BODY='{"message":"offline"}' result="$(run deepseek)"; jq -e '.[0].error.code == 500' <<<"$result" >/dev/null +# Vertex AI without a gcloud sign-in says how to sign in. +mkdir -p "$TMP/gcloud-out" +printf '#!/usr/bin/env bash\nexit 1\n' > "$TMP/gcloud-out/gcloud" +chmod +x "$TMP/gcloud-out/gcloud" +result="$(PATH="$TMP/gcloud-out:$PATH" run vertexai)" +jq -e '.[0].error.code == 2 and (.[0].error.message | contains("gcloud auth login"))' <<<"$result" >/dev/null export COV_STATUS=200 COV_BODY='not-json' result="$(run replicate)"; jq -e '.[0].provider == "replicate"' <<<"$result" >/dev/null 2>&1 diff --git a/tests/test-cov-d2-fireworks.sh b/tests/test-cov-d2-fireworks.sh index d7849d8..74b4175 100755 --- a/tests/test-cov-d2-fireworks.sh +++ b/tests/test-cov-d2-fireworks.sh @@ -10,7 +10,7 @@ cat >"$TMP/bin/curl" <<'STUB' #!/usr/bin/env bash out= url= headers= write=0 while (($#)); do case "$1" in -o) out="$2"; shift 2;; -D) headers="$2"; shift 2;; -w) write=1; shift 2;; http*) url="$1"; shift;; *) shift;; esac; done -status="${COV_STATUS:-200}"; body="${COV_BODY-}"; [ -n "$body" ] || body='{"quotas":[{"name":"/model/day","usage":25,"value":100,"maxValue":200}]}' +status="${COV_STATUS:-200}"; [[ "$url" != */inference/v1/models ]] || status="${COV_MODELS_STATUS:-$status}"; body="${COV_BODY-}"; [ -n "$body" ] || body='{"quotas":[{"name":"/model/day","usage":25,"value":100,"maxValue":200}]}' case "$url" in */api/tags) body='{"models":[{"name":"llama:latest"}]}';; */api/ps) body='{"models":[{"name":"llama:latest"}]}';; https://api.cloudflare.com/client/v4/graphql) body="${COV_GRAPH_BODY:-{"data":{"viewer":{"accounts":[{"aiInferenceAdaptiveGroups":[]}]}}}}";; @@ -48,3 +48,8 @@ export FIREWORKS_API_KEY=sk-test-0000 FIREWORKS_ACCOUNT_ID=acct-test COV_STATUS= result="$(run)"; jq -e '.[0].credits.remaining == "No quotas returned"' <<<"$result" >/dev/null export COV_STATUS=500 COV_BODY='{}' result="$(run)"; jq -e '.[0].source == "fireworks-api"' <<<"$result" >/dev/null +# Quota API down but the key works: an authenticated note, with or without an account id. +result="$(COV_MODELS_STATUS=200 run)" +jq -e '.[0].error == null and .[0].usage.accountEmail == "Fireworks AI account acct-test"' <<<"$result" >/dev/null +result="$(COV_MODELS_STATUS=200 FIREWORKS_ACCOUNT_ID= run)" +jq -e '.[0].error == null and (.[0].usage.primary.resetDescription | contains("set FIREWORKS_ACCOUNT_ID"))' <<<"$result" >/dev/null diff --git a/tests/test-coverage-report.sh b/tests/test-coverage-report.sh index 7349139..a1319dc 100755 --- a/tests/test-coverage-report.sh +++ b/tests/test-coverage-report.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # scripts/coverage-report counts shell statements only: the bodies of -# multi-line quoted programs, heredocs and backslash continuations are not -# statements, and the --min gate fails below the threshold. +# multi-line quoted programs, heredocs, backslash continuations and array +# assignments are not statements, nor are lines that run no command, and the +# --min gate fails below the threshold. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP="$(mktemp -d)" @@ -27,19 +28,32 @@ q="$(printf '%s' " WHERE a = 'b' ")" echo "$q" +arr=(-H "a (b)" + -H c) +case "$q" in + a) ;; + *) if true; then + echo b + fi ;; +esac +{ + echo c +} >"$TMP/out" SAMPLE -# kcov marks every physical line; only lines 1, 2, 5, 8, 10, 11 and 14 are -# statement starts. Lines 2 and 11 were not executed. -jq -n --arg f "$TMP/sample.bash" --argjson h '{"1":1,"2":0,"3":0,"4":0,"5":1,"6":0,"7":0,"8":1,"9":0,"10":1,"11":0,"12":0,"13":0,"14":1,"15":0,"16":0,"17":1,"18":1}' '{coverage: {($f): $h}}' > "$TMP/codecov.json" +# kcov marks every physical line; only lines 1, 2, 5, 8, 10, 11, 14, 17, 18, +# 19, 21, 23, 24 and 28 are statement starts. Lines 2 and 11 were not executed. +# Line 20 continues the array; lines 22, 25 and 29 run no command and are +# never traced. +jq -n --arg f "$TMP/sample.bash" --argjson h '{"1":1,"2":0,"3":0,"4":0,"5":1,"6":0,"7":0,"8":1,"9":0,"10":1,"11":0,"12":0,"13":0,"14":1,"15":0,"16":0,"17":1,"18":1,"19":1,"20":0,"21":1,"22":0,"23":1,"24":1,"25":0,"28":1,"29":0}' '{coverage: {($f): $h}}' > "$TMP/codecov.json" out="$("$ROOT/scripts/coverage-report" "$TMP/codecov.json" "$TMP" --files "$TMP/files.tsv")" -[ "$out" = "Statement coverage: 77.78% (7/9 statements in 1 files)" ] || fail "multiline quote was not covered by its closing line: $out" -[ "$(cat "$TMP/files.tsv")" = $'77.78%\t7/9\tsample.bash' ] || fail "wrong per-file row" +[ "$out" = "Statement coverage: 85.71% (12/14 statements in 1 files)" ] || fail "statement grouping: $out" +[ "$(cat "$TMP/files.tsv")" = $'85.71%\t12/14\tsample.bash' ] || fail "wrong per-file row" -if "$ROOT/scripts/coverage-report" "$TMP/codecov.json" "$TMP" --min 80 >/dev/null 2>"$TMP/err"; then +if "$ROOT/scripts/coverage-report" "$TMP/codecov.json" "$TMP" --min 90 >/dev/null 2>"$TMP/err"; then fail "accepted coverage below --min" fi -grep -q 'below the 80% minimum' "$TMP/err" || fail "no minimum message" -"$ROOT/scripts/coverage-report" "$TMP/codecov.json" "$TMP" --min 70 >/dev/null || fail "rejected coverage above --min" +grep -q 'below the 90% minimum' "$TMP/err" || fail "no minimum message" +"$ROOT/scripts/coverage-report" "$TMP/codecov.json" "$TMP" --min 80 >/dev/null || fail "rejected coverage above --min" echo "OK: coverage-report statement counting and minimum gate" diff --git a/tests/test-history-export.sh b/tests/test-history-export.sh index 8d5f870..300f593 100755 --- a/tests/test-history-export.sh +++ b/tests/test-history-export.sh @@ -18,6 +18,7 @@ mkdir -p "$HISTORY_DIR" "$TMP/out" export_history() { "$ROOT/providers/export-usage-history" "$@"; } # ── nothing recorded yet ──────────────────────────────────────────────────── +[ "$("$ROOT/providers/get-usage-history")" = '{}' ] || { echo "history without a store is not {}" >&2; exit 1; } status=0 export_history csv "$TMP/out" >/dev/null 2>&1 || status=$? [ "$status" -eq 3 ] || { echo "expected exit 3 with no history, got $status" >&2; exit 1; } @@ -88,6 +89,12 @@ second="$(export_history csv "$TMP/out")" status=0 export_history csv /proc/definitely-not-writable >/dev/null 2>&1 || status=$? [ "$status" -eq 4 ] || { echo "expected exit 4 for a bad destination, got $status" >&2; exit 1; } +# /proc exists but takes no new files, even for root. +status=0 +export_history csv /proc >/dev/null 2>"$TMP/err" || status=$? +if [ "$status" -ne 4 ] || ! grep -q 'cannot write to destination' "$TMP/err"; then + echo "expected exit 4 for a read-only destination, got $status" >&2; exit 1 +fi # ── default destination follows XDG_DOWNLOAD_DIR from user-dirs.dirs ──────── mkdir -p "$XDG_CONFIG_HOME" "$TMP/downloads" @@ -100,4 +107,14 @@ esac # Sourcing user-dirs.dirs must not leak into this shell. [ -z "${XDG_DOWNLOAD_DIR:-}" ] || { echo "XDG_DOWNLOAD_DIR leaked" >&2; exit 1; } +# ── without user-dirs.dirs: ~/Downloads, else the home directory ──────────── +mkdir -p "$TMP/home/Downloads" "$TMP/bare-home" +defaulted="$(HOME="$TMP/home" XDG_CONFIG_HOME="$TMP/no-config" XDG_DOWNLOAD_DIR='' export_history csv)" +case "$defaulted" in + "$TMP/home/Downloads"/*) ;; + *) echo "default destination is not ~/Downloads: $defaulted" >&2; exit 1 ;; +esac +defaulted="$(HOME="$TMP/bare-home" XDG_CONFIG_HOME="$TMP/no-config" XDG_DOWNLOAD_DIR='' export_history csv)" +[ "$(dirname "$defaulted")" = "$TMP/bare-home" ] || { echo "default destination is not ~: $defaulted" >&2; exit 1; } + echo "OK: test-history-export" diff --git a/tests/test-limit-resets.sh b/tests/test-limit-resets.sh index f7eeb70..b6e8a29 100755 --- a/tests/test-limit-resets.sh +++ b/tests/test-limit-resets.sh @@ -41,9 +41,11 @@ printf '%s %s %s\n' "$method" "$url" "$body" >> "$CURL_LOG" case "${GRANTS:-ready}" in ready) grants='{"eligible":true,"at_limit":true,"next_grant_id":"g_two","grants":[{"id":"g_one","label":"Session reset","type":"session","resets_left":1,"usable_now":false,"use_requires_limit":true},{"id":"g_two","label":"Full reset","type":"full","resets_left":2,"usable_now":true,"ends_at":"2030-01-02T00:00:00Z"}]}' ;; none) grants='{"eligible":false,"ineligible_reason":"no_grant","grants":[]}' ;; + malformed) grants='{"eligible":true,"grants":[{"id":"G ONE; rm","resets_left":1,"usable_now":true}]}' ;; esac case "$url" in *"/api/oauth/usage"*) + if [ -n "${USAGE_STATUS:-}" ]; then printf '{}' > "$out"; printf '%s' "$USAGE_STATUS"; exit 0; fi printf '%s' "{\"limits\":[{\"kind\":\"session\",\"percent\":91},{\"kind\":\"weekly_all\",\"percent\":40}], \"extra_usage\":{\"is_enabled\":true,\"monthly_limit\":2500,\"used_credits\":1250,\"currency\":\"BRL\",\"spend_limit_reached\":false}, \"spend\":{\"enabled\":true,\"used\":{\"amount_minor\":1250,\"currency\":\"BRL\",\"exponent\":2},\"limit\":{\"amount_minor\":2500,\"currency\":\"BRL\",\"exponent\":2},\"percent\":50}, @@ -113,12 +115,33 @@ done out="$(GRANTS=none bash "$ROOT/providers/reset-provider-limits" claude)" jq -e '.result == "unavailable"' <<<"$out" >/dev/null || fail "no grant: $out" ! grep -q '^POST ' "$CURL_LOG" || fail "claimed without a usable grant" +for case in 401:auth_error 503:error; do + out="$(USAGE_STATUS="${case%%:*}" bash "$ROOT/providers/reset-provider-limits" claude)" + jq -e --arg r "${case##*:}" '.result == $r' <<<"$out" >/dev/null || fail "grant read HTTP ${case%%:*}: $out" +done +out="$(GRANTS=malformed bash "$ROOT/providers/reset-provider-limits" claude)" +jq -e '.result == "error" and (.message | test("malformed"))' <<<"$out" >/dev/null || fail "malformed grant: $out" +! grep -q '^POST ' "$CURL_LOG" || fail "claimed a malformed grant" +out="$(CLAIM=paused bash "$ROOT/providers/reset-provider-limits" claude)" +jq -e '.result == "unavailable" and .resetsLeft == 1' <<<"$out" >/dev/null || fail "unknown claim result: $out" + +# --- Claude: no sign-in or no organization never reaches the API --- +: > "$CURL_LOG" +mkdir -p "$TMP/signed-out" "$TMP/no-org" +cp "$CLAUDE_CONFIG_DIR/.credentials.json" "$TMP/no-org/" +out="$(CLAUDE_CONFIG_DIR="$TMP/signed-out" bash "$ROOT/providers/reset-provider-limits" claude)" +jq -e '.result == "auth_error" and (.message | test("not signed in"))' <<<"$out" >/dev/null || fail "signed out: $out" +out="$(CLAUDE_CONFIG_DIR="$TMP/no-org" bash "$ROOT/providers/reset-provider-limits" claude)" +jq -e '.result == "unavailable" and (.message | test("organization"))' <<<"$out" >/dev/null || fail "no organization: $out" +[ ! -s "$CURL_LOG" ] || fail "reached the API without a sign-in or organization" # --- Claude profiles resolve their own config dir --- export AIOC_CLAUDE_PROFILES AIOC_CLAUDE_PROFILES="$(jq -nc --arg d "$CLAUDE_CONFIG_DIR" '{"claude-work":{configDir:$d}}')" out="$(env -u CLAUDE_CONFIG_DIR bash "$ROOT/providers/reset-provider-limits" claude-work)" jq -e '.provider == "claude-work" and .result == "reset"' <<<"$out" >/dev/null || fail "profile reset: $out" +out="$(AIOC_CLAUDE_PROFILES='{"claude-rel":{"configDir":"relative/dir"}}' bash "$ROOT/providers/reset-provider-limits" claude-rel)" +jq -e '.result == "error" and (.message | test("absolute"))' <<<"$out" >/dev/null || fail "relative profile dir: $out" out="$(bash "$ROOT/providers/reset-provider-limits" gemini)" jq -e '.result == "error"' <<<"$out" >/dev/null || fail "unsupported provider accepted" @@ -137,9 +160,16 @@ jq -e '.result == "reset"' <<<"$out" >/dev/null || fail "codex reset: $out" [ "$(grep -c 'rateLimitResetCredit/consume' "$CODEX_LOG")" -eq 1 ] || fail "expected one consume request" grep 'rateLimitResetCredit/consume' "$CODEX_LOG" | jq -e '.params.idempotencyKey | length > 8' >/dev/null || fail "consume without idempotency key" [ ! -e "$XDG_CACHE_HOME/AiOverviewControl/codex-usage.json" ] || fail "codex cache kept after a reset" -for case in nothingToReset:nothing_to_reset noCredit:no_credit alreadyRedeemed:reset error:error; do +for case in nothingToReset:nothing_to_reset noCredit:no_credit alreadyRedeemed:reset error:error pending:error; do out="$(CODEX_OUTCOME="${case%%:*}" bash "$ROOT/providers/reset-provider-limits" codex)" jq -e --arg r "${case##*:}" '.result == $r' <<<"$out" >/dev/null || fail "codex ${case%%:*}: $out" done +# --- Codex: a missing CLI is reported, not run --- +mkdir -p "$TMP/no-codex" +ln -s "$(command -v jq)" "$TMP/no-codex/jq" +ln -s "$(command -v dirname)" "$TMP/no-codex/dirname" +out="$(PATH="$TMP/no-codex" "$BASH" "$ROOT/providers/reset-provider-limits" codex)" +jq -e '.result == "unavailable" and (.message | test("not installed"))' <<<"$out" >/dev/null || fail "missing codex: $out" + echo "OK: Claude/Codex credits, extra usage and consumable limit resets" diff --git a/tests/test-local-analytics.sh b/tests/test-local-analytics.sh index d8993fa..02f6833 100755 --- a/tests/test-local-analytics.sh +++ b/tests/test-local-analytics.sh @@ -155,5 +155,12 @@ CODEX_HOME=/nonexistent bash "$ROOT/providers/get-local-analytics" codex \ | check '.error == "Codex local sessions not found"' 'Codex missing source is not served from cache' OPENCODE_DATA_DIR=/nonexistent bash "$ROOT/providers/get-local-analytics" opencode \ | check '.error == "OpenCode local database not found"' 'OpenCode missing source is not served from cache' +bash "$ROOT/providers/get-local-analytics" claude \ + | check '.error == "Unsupported local analytics provider"' 'Unsupported local analytics provider' +# A database without OpenCode tables is an error card, not empty telemetry. +mkdir -p "$TMP/opencode-empty" +sqlite3 "$TMP/opencode-empty/opencode.db" "CREATE TABLE unrelated(id TEXT);" +OPENCODE_DATA_DIR="$TMP/opencode-empty" bash "$ROOT/providers/get-provider-usage" opencode \ + | check '.[0].source == "opencode-local" and (.[0].error.message | contains("could not be read"))' 'OpenCode unreadable database card' echo 'OK: local harness analytics and cost semantics' diff --git a/tests/test-metadata.sh b/tests/test-metadata.sh index e79b84d..37eee3f 100755 --- a/tests/test-metadata.sh +++ b/tests/test-metadata.sh @@ -40,7 +40,7 @@ jq '.providers += 1' "$TMP/docs/badges.backup" > "$TMP/docs/badges.json" reject 'stale provider badge count' mv "$TMP/docs/badges.backup" "$TMP/docs/badges.json" cp "$TMP/README.md" "$TMP/README.backup" -sed -i 's/usage for [0-9]* AI providers/usage for 7 AI providers/' "$TMP/README.md" +sed -i 's/usage for [0-9+]* AI providers/usage for 30+ AI providers/' "$TMP/README.md" reject 'stale README provider count' mv "$TMP/README.backup" "$TMP/README.md" chmod -x "$TMP/tests/test-example.sh" diff --git a/tests/test-opencode.sh b/tests/test-opencode.sh index 4eced8d..b73f273 100755 --- a/tests/test-opencode.sh +++ b/tests/test-opencode.sh @@ -51,7 +51,7 @@ case "$url" in ;; *zen/go/v1/models*) [ -n "$out" ] && cp "${OC_FIXTURE_DIR}/opencode-models.json" "$out" - status=200 + status="${OC_MODELS_STATUS:-200}" ;; esac if [ "$write_code" -eq 1 ]; then printf '%s' "$status"; fi @@ -143,4 +143,9 @@ out="$(OC_USAGE_MODE=down run env -u OPENCODE_API_KEY OPENCODE_API_KEY=user_test [ "$(jq -r '.[0].source' <<<"$out")" = "opencode-models" ] || fail "fallback source" grep -q '^Authorization: Bearer user_test$' "$HDR_LOG" || fail "auth header forwarded to models fallback" +# 10. Both endpoints down → the key is reported as set, never as invalid. +out="$(OC_USAGE_MODE=down OC_MODELS_STATUS=503 run env -u OPENCODE_API_KEY OPENCODE_API_KEY=user_test)" +jq -e '.[0].source == "opencode-api" and .[0].error == null + and .[0].usage.primary.resetDescription == "API key set; OpenCode Zen unreachable"' <<<"$out" >/dev/null || fail "both endpoints down: $out" + echo "OK: test-opencode" diff --git a/tests/test-package-release.sh b/tests/test-package-release.sh new file mode 100755 index 0000000..e86d739 --- /dev/null +++ b/tests/test-package-release.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# scripts/package-release refuses an archive that would not install: a missing +# runtime file or native adapter, an uncommitted version bump and a provider +# script without its executable bit. Each case runs the real script, through a +# symlink, against a throwaway repository built from HEAD. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +VERSION="$(jq -r .version "$ROOT/plugin.json")" + +# fixture NAME: a committed copy of HEAD whose package-release is the real one. +fixture() { + local repo="$TMP/$1" + mkdir -p "$repo" + git -C "$ROOT" archive HEAD | tar -x -C "$repo" + git -C "$repo" init -q + git -C "$repo" -c core.fileMode=true add -A + git -C "$repo" -c user.name=Fixture -c user.email=fixture@example.invalid commit -qm fixture + ln -sf "$ROOT/scripts/package-release" "$repo/scripts/package-release" + printf '%s' "$repo" +} +commit() { git -C "$1" -c user.name=Fixture -c user.email=fixture@example.invalid commit -qam "$2"; } +# refuse REPO MESSAGE: packaging fails and says why. +refuse() { + if "$1/scripts/package-release" "v$VERSION" "$TMP/dist" >"$TMP/out" 2>&1; then + fail "packaged despite: $2" + fi + grep -qF -- "$2" "$TMP/out" || fail "expected '$2', got: $(tail -n 3 "$TMP/out")" +} + +repo="$(fixture missing-file)" +git -C "$repo" rm -q qmldir +commit "$repo" 'drop qmldir' +refuse "$repo" 'Release archive is missing qmldir' + +repo="$(fixture missing-native)" +: > "$repo/providers/native/untracked.bash" +refuse "$repo" 'Release archive missing native adapter providers/native/untracked.bash' + +repo="$(fixture uncommitted-version)" +jq '.version = "99.0.0"' "$repo/plugin.json" > "$TMP/plugin.json" +mv "$TMP/plugin.json" "$repo/plugin.json" +printf '# Changelog\n\n## 99.0.0\n\n- Fixture release.\n' > "$repo/CHANGELOG.md" +if "$repo/scripts/package-release" v99.0.0 "$TMP/dist" >"$TMP/out" 2>&1; then + fail "packaged an uncommitted version bump" +fi +grep -qF "Archived plugin.json version is $VERSION but the working tree says 99.0.0" "$TMP/out" \ + || fail "uncommitted version message: $(tail -n 3 "$TMP/out")" + +repo="$(fixture non-executable)" +chmod -x "$repo/providers/get-ai21-usage" +git -C "$repo" -c core.fileMode=true -c user.name=Fixture -c user.email=fixture@example.invalid \ + commit -qam 'drop the executable bit' +refuse "$repo" 'Release archive contains a non-executable provider script: providers/get-ai21-usage' + +echo 'OK: package-release refuses incomplete archives' diff --git a/tests/test-pi-analytics.sh b/tests/test-pi-analytics.sh index 2c7f9eb..f1b1939 100755 --- a/tests/test-pi-analytics.sh +++ b/tests/test-pi-analytics.sh @@ -68,4 +68,18 @@ echo '{"cached_at":0,"data":[]}' > "$CACHE" jq -e '.[0].error == null and .[0].usage.primary.usedPercent == 0' <<<"$(card)" >/dev/null \ || fail "invalid cache not handled" +# An aggregation jq cannot finish is an error, never a week of zeros. +rm -f "$CACHE" +mkdir -p "$TMP/badjq" +real_jq="$(command -v jq)" +cat > "$TMP/badjq/jq" </dev/null || fail "aggregation failure: $out" +[ ! -e "$CACHE" ] || fail "failed aggregation was cached" + echo "OK: pi analytics aggregation, cache and card" diff --git a/tests/test-qmlls-setup.sh b/tests/test-qmlls-setup.sh new file mode 100755 index 0000000..97e344b --- /dev/null +++ b/tests/test-qmlls-setup.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# scripts/qmlls-setup copies a DMS shell tree, writes one qmldir per QML +# directory (singletons marked) and points .qmlls.ini at the copy. The script +# runs through a symlink so .qmlls.ini lands in a scratch tree, not the repo. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +export XDG_CACHE_HOME="$TMP/cache" +mkdir -p "$TMP/plugin/scripts" "$TMP/shell/Services" "$TMP/shell/Widgets/Empty" +ln -s "$ROOT/scripts/qmlls-setup" "$TMP/plugin/scripts/qmlls-setup" +printf 'pragma Singleton\nimport QtQuick\nQtObject {}\n' > "$TMP/shell/Services/PluginService.qml" +printf 'import QtQuick\nItem {}\n' > "$TMP/shell/Services/Helper.qml" +chmod -w "$TMP/shell/Services/Helper.qml" + +out="$("$TMP/plugin/scripts/qmlls-setup" "$TMP/shell")" +tree="$XDG_CACHE_HOME/dms-qmlls" +[ "$out" = "qmlls import tree: $tree" ] || fail "output: $out" +[ "$(cat "$tree/qs/Services/qmldir")" = $'module qs.Services\nHelper 1.0 Helper.qml\nsingleton PluginService 1.0 PluginService.qml' ] \ + || fail "qmldir: $(cat "$tree/qs/Services/qmldir")" +[ ! -e "$tree/qs/Widgets/Empty/qmldir" ] || fail "qmldir written for a directory without QML" +[ -w "$tree/qs/Services/Helper.qml" ] || fail "copied tree is not writable" +grep -qx "importPaths=\"$tree\"" "$TMP/plugin/.qmlls.ini" || fail ".qmlls.ini: $(cat "$TMP/plugin/.qmlls.ini")" + +# A shell tree that does not exist is an error. +if "$TMP/plugin/scripts/qmlls-setup" "$TMP/missing" 2>"$TMP/err"; then fail "accepted a missing shell dir"; fi +grep -q 'DMS shell dir not found' "$TMP/err" || fail "missing dir message" + +echo 'OK: qmlls-setup builds the qs import tree' diff --git a/tests/test-quota-alert.sh b/tests/test-quota-alert.sh index c53961d..dfecc1e 100755 --- a/tests/test-quota-alert.sh +++ b/tests/test-quota-alert.sh @@ -178,4 +178,28 @@ touch -d '-5 minutes' "$cache_dir/.notify-waiter.orphan" "$cache_dir/.notify-wai kill "$holder"; wait "$holder" 2>/dev/null || true rm -f "$cache_dir/.notify-waiter.held" "$cache_dir/.notify-waiter.fresh" +# Icons: a provider SVG is recoloured opaque on a padded square box and cached +# by source and colour; anything else falls back to the theme icon. Invalid +# urgency and cooldown values fall back to their defaults. +rm -f "$CLICK_FILE" +printf '\n' > "$TMP/logo.svg" +printf '\n' > "$TMP/no-box.svg" +: > "$TMP/logo.png" +icon_of() { + : > "$NOTIFY_LOG" + "$ROOT/providers/send-quota-alert" "icon:$3" "${5:-0}" "${4:-normal}" "$1" "$2" 'Quota test' 'Fixture body' + sed -E 's/.* -i ([^ ]+) .*/\1/' "$NOTIFY_LOG" +} +icon="$(icon_of "$TMP/logo.svg" '#FF6750A4' rgb)" +case "$icon" in "$cache_dir"/notification-icons/*.svg) ;; *) echo "icon not rendered: $icon" >&2; exit 1 ;; esac +grep -q 'viewBox="-3.600000 -9.600000 31.200000 31.200000"' "$icon" || { echo 'icon box not square and padded' >&2; exit 1; } +grep -q 'values="0 0 0 0 0.403922 0 0 0 0 0.313725 0 0 0 0 0.643137 0 0 0 1 0"' "$icon" || { echo 'icon colour matrix' >&2; exit 1; } +grep -qx '' "$icon" && [ "$(tail -n 1 "$icon")" = '' ] || { echo 'icon body' >&2; exit 1; } +[ "$(icon_of "$TMP/logo.svg" '#6750A4' cached)" = "$icon" ] || { echo 'icon not reused from cache' >&2; exit 1; } +for bad in "$TMP/logo.png:#6750A4" "$TMP/logo.svg:red" "$TMP/logo.svg:#12345" "$TMP/logo.svg:#GG50A4" "$TMP/no-box.svg:#6750A4"; do + [ "$(icon_of "${bad%:*}" "${bad##*:}" bad)" = dialog-warning ] || { echo "icon fallback for $bad" >&2; exit 1; } +done +icon_of dialog-warning '#6750A4' defaults bogus soon >/dev/null +grep -q -- '-u normal' "$NOTIFY_LOG" || { echo 'invalid urgency kept' >&2; exit 1; } + echo 'Quota alert deduplication and click action: OK' diff --git a/tests/test-spend-providers.sh b/tests/test-spend-providers.sh index 6fea0d8..8f12db0 100755 --- a/tests/test-spend-providers.sh +++ b/tests/test-spend-providers.sh @@ -128,6 +128,10 @@ out="$(run anthropic "$TMP/anthropic-err.json" 403)" check "anthropic 403" '.[0].error.code == 403 and .[0].error.message == "Admin key required"' "$out" out="$(run anthropic "$TMP/odd.json")" check "anthropic schema" '.[0].error.code == 200 and (.[0].error.message | contains("unexpected"))' "$out" +out="$(run anthropic "$TMP/odd.json" 429)" +check "anthropic 429" '.[0].error.code == 429 and (.[0].error.message | contains("rate limited"))' "$out" +out="$(run anthropic "$TMP/odd.json" 502)" +check "anthropic 502" '.[0].error.code == 502 and .[0].error.message == "Anthropic cost report returned HTTP 502."' "$out" missing anthropic ANTHROPIC_ADMIN_KEY # --- OpenAI API ----------------------------------------------------------- @@ -160,6 +164,10 @@ out="$(run openai "$TMP/openai-err.json" 401)" check "openai 401" '.[0].error.code == 401 and (.[0].error.message | contains("Admin API key"))' "$out" out="$(run openai "$TMP/openai-err.json" 429)" check "openai 429" '.[0].error.code == 429 and (.[0].error.message | contains("rate limited"))' "$out" +out="$(run openai "$TMP/odd.json")" +check "openai schema" '.[0].error.code == 200 and (.[0].error.message | contains("unexpected"))' "$out" +out="$(run openai "$TMP/odd.json" 502)" +check "openai 502" '.[0].error.code == 502 and .[0].error.message == "OpenAI costs API returned HTTP 502."' "$out" missing openai OPENAI_ADMIN_KEY # --- Vercel AI Gateway ---------------------------------------------------- From f3816777e463334347805bed0d7eee42ffba8fa7 Mon Sep 17 00:00:00 2001 From: Bernardo Pinto Gomes Date: Mon, 26 Oct 2026 20:49:06 -0300 Subject: [PATCH 3/4] docs: refresh the README and describe 40+ providers The README prose rounds the provider count down to a multiple of ten, so it changes only at the next ten; the badge keeps the exact count. Provider examples follow the coverage matrix (Cursor reports quota, Amp is informational), the docs section points at the 2.1 notes and links the development gates. TODO and roadmap record the 95% gate and the pinned kcov build. Signed-off-by: Bernardo Pinto Gomes --- CHANGELOG.md | 5 ++++ README.md | 59 +++++++++++++++++++++++-------------------- TODO.md | 31 +++++++++++++---------- docs/README.pt-BR.md | 16 +++++++----- docs/roadmap.md | 9 +++---- scripts/render-badges | 13 +++++++--- 6 files changed, 75 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c786437..1c71610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,18 @@ - **Vercel AI Gateway** (`AI_GATEWAY_API_KEY`): AI Gateway Credits balance and total used from the official `/v1/credits`. - **Gemini**: with a Gemini CLI sign-in, the card shows per-model daily quota from the Code Assist API the CLI uses, for accounts with a Code Assist Standard or Enterprise project (`GOOGLE_CLOUD_PROJECT` selects one). Google no longer offers Code Assist for individuals to the Gemini CLI; such an account now shows Google's reason instead of "Gemini CLI authenticated". An expired CLI token is renewed in memory and never written back. A configured API key still produces the key-check card. +### Fixes +- The DankDash card fits its provider rows into the padded content area, so the last row no longer runs into the bottom edge, and its usage bars use the theme's height, rounding and spacing like the DMS example cards. + ### Documentation +- The README states "40+ providers", which only changes at the next ten, lists provider examples that match the coverage matrix, points at the 2.1 notes and links the development gates. - `TODO.md` is grouped into the next release, partial work with its follow-up, blocked and candidate providers, CI and project health, and the roadmap adds live verification of the new spend providers and the kcov upstream report. - `TODO.md` lists only open work again, with the date of the last upstream check for each blocked provider and the measured coverage gaps. The roadmap now plans the ClinePass usage windows, which the Cline dashboard reads from a read-only endpoint. - Troubleshooting covers a missing or empty DankDash card and a refused limit reset, and the architecture guide describes the DankDash snapshot, the reset helper and the shared helper scripts added in 2.1. The NVIDIA notes reflect that build.nvidia.com replaced trial credits with rate limits. ### Quality - Statement coverage counts a multi-line command as covered when kcov records its hit on the closing line, and new tests cover the missing-prerequisite results of every provider health check, the Copilot helper hand-off, a failed Antigravity token refresh (the refresh token never reaches the output) and Hermes analytics cache recovery. `docs/development-gates.md` notes the kcov limitation that still understates the Hermes and OpenCode analytics files. +- Statement coverage rises to 97.8% and the CI gate to 95%. The Hermes analytics and summary, limit resets, OpenCode, local analytics, quota alert, history export, 9Router and package checks now reach 100%. CI builds kcov from a pinned, checksum-verified commit that keeps line tracking after multi-line SQL, and the coverage report no longer counts lines bash never traces (empty case branches, array continuations, redirected block ends). ## 2.1.1 - 2026-10-26 diff --git a/README.md b/README.md index aa0f58c..fb48aa7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Every AI quota you pay for, live in your DankBar.** A [DankMaterialShell](https://github.com/duxlabs-dev/dux-shell) plugin that tracks -quota, billing, authentication, and local usage for 43 AI providers — locally, honestly, +quota, billing, authentication, and local usage for 40+ AI providers — locally, honestly, with no external service. [![Release](https://img.shields.io/github/v/release/bernardopg/AiOverviewControl?color=8B5CF6&label=release)](https://github.com/bernardopg/AiOverviewControl/releases/latest) @@ -55,7 +55,7 @@ invented percentages. -**📊 One dashboard, 43 providers**
+**📊 One dashboard, 40+ providers**
Quota, balance, analytics, and auth status side by side, with a fleet overview of average load, hottest provider, and next reset. @@ -73,7 +73,7 @@ and reset time. Crowded bar? Drop the names and tighten the spacing. **🤖 Deep local telemetry**
-Claude, Codex, 9Router, pi, and Hermes add token, cost, model, and project +Claude, Codex, OpenCode, 9Router, pi, and Hermes add token, cost, model, and project analytics with a seven-day chart — read from data already on your disk. Track several Claude accounts side by side, each from its own `CLAUDE_CONFIG_DIR`. Claude and Codex also show credits, extra usage and limit resets, and can @@ -152,14 +152,14 @@ Prefer a release archive or a git checkout? See [Installation](./docs/installati ## 🧩 Providers -| Coverage | What you get | Examples | -| ------------------ | ---------------------------------------- | ------------------------------------------------------- | -| **Quota** | Real usage % and reset time | Codex, Copilot, Antigravity, OpenRouter, Z.ai, GLM, xAI, Gemini CLI | -| **Balance** | Remaining prepaid credit | Kimi, DeepSeek, Neuralwatt, Cline, Ollama Cloud, Vercel AI Gateway | -| **Analytics** | Tokens, cost, and models from local data or spend reports | Claude, 9Router, pi, Hermes, Cloudflare, Anthropic API, OpenAI API | -| **Authentication** | Credential check, no usage numbers | Gemini, Mistral, Qwen, Groq, Cohere | -| **Local runtime** | Local models and sessions | Ollama, Vertex AI | -| **Informational** | A link to the official usage page | Cursor, Kiro, Warp, Perplexity | +| Coverage | What you get | Examples | +| ------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------- | +| **Quota** | Real usage % and reset time | Codex, Copilot, Cursor, Antigravity, OpenRouter, Z.ai, xAI, Gemini CLI | +| **Balance** | Remaining prepaid credit | Kimi, DeepSeek, Neuralwatt, Cline, Ollama Cloud, Vercel AI Gateway | +| **Analytics** | Tokens, cost, and models from local data or spend reports | Claude, 9Router, pi, Hermes, Cloudflare, Anthropic API, OpenAI API | +| **Authentication** | Credential check, no usage numbers | Mistral, Qwen, Groq, Cohere, NVIDIA | +| **Local runtime** | Local models and sessions | Ollama, Vertex AI | +| **Informational** | A link to the official usage page | Kiro, Warp, Perplexity, Amp | The full list, data sources, and credentials are in [Providers](./docs/providers.md). @@ -172,26 +172,29 @@ The full list, data sources, and credentials are in [Providers](./docs/providers ## 📚 Documentation -For the latest release, start with the [2.0.0 changes guide](./docs/releases/2.0.0.md). +New in 2.1: credits, extra usage, limit resets and the DankDash card — see the +[2.1.0 notes](./docs/releases/2.1.0.md). Coming from DMS 1.6? Read the +[2.0.0 upgrade guide](./docs/releases/2.0.0.md). See [cost currency and privacy](./docs/currency.md), [runtime module boundaries](./docs/refactoring.md), and [protected PR/release rules](./docs/repository-rules.md). -| | | -| -------------------------------------------- | ---------------------------------------------------- | -| [Usage](./docs/usage.md) | Pill, popout, cards, keyboard, IPC, history, privacy | -| [Installation](./docs/installation.md) | Store, archive, and git installs; upgrades | -| [Configuration](./docs/configuration.md) | Every setting, environment variables, health checks | -| [Providers](./docs/providers.md) | Coverage matrix and per-provider data sources | -| [Troubleshooting](./docs/troubleshooting.md) | Missing cards, credentials, notifications | -| [Architecture](./docs/architecture.md) | Runtime flow and the adapter contract | -| [Translations](./docs/i18n-crowdin.md) | Locales and Crowdin | -| [Contributing](./CONTRIBUTING.md) | Local checks and conventions | -| [Governance](./GOVERNANCE.md) | Decisions, roles, code review, continuity | -| [Roadmap](./docs/roadmap.md) | What is planned for the next year, and what is not | -| [Security model](./docs/security.md) | Security requirements and assurance case | -| [Security policy](./.github/SECURITY.md) | Reporting vulnerabilities, verifying releases | -| [Code of conduct](./CODE_OF_CONDUCT.md) | Contributor Covenant 2.1 | -| [Changelog](./CHANGELOG.md) | Release history | +| | | +| ------------------------------------------------ | ---------------------------------------------------- | +| [Usage](./docs/usage.md) | Pill, popout, cards, keyboard, IPC, history, privacy | +| [Installation](./docs/installation.md) | Store, archive, and git installs; upgrades | +| [Configuration](./docs/configuration.md) | Every setting, environment variables, health checks | +| [Providers](./docs/providers.md) | Coverage matrix and per-provider data sources | +| [Troubleshooting](./docs/troubleshooting.md) | Missing cards, credentials, notifications | +| [Architecture](./docs/architecture.md) | Runtime flow and the adapter contract | +| [Translations](./docs/i18n-crowdin.md) | Locales and Crowdin | +| [Contributing](./CONTRIBUTING.md) | Local checks and conventions | +| [Development gates](./docs/development-gates.md) | Tests, statement coverage, fuzzing and CI gates | +| [Governance](./GOVERNANCE.md) | Decisions, roles, code review, continuity | +| [Roadmap](./docs/roadmap.md) | What is planned for the next year, and what is not | +| [Security model](./docs/security.md) | Security requirements and assurance case | +| [Security policy](./.github/SECURITY.md) | Reporting vulnerabilities, verifying releases | +| [Code of conduct](./CODE_OF_CONDUCT.md) | Contributor Covenant 2.1 | +| [Changelog](./CHANGELOG.md) | Release history | ## 💜 Support the project diff --git a/TODO.md b/TODO.md index a098f9a..daded3e 100644 --- a/TODO.md +++ b/TODO.md @@ -27,8 +27,8 @@ the coverage-measurement fix and the 2.1 documentation. `plugins/bernardopg-aioverviewcontrol.json`, so DMS 1.6 users keep 1.22.0. Open as [duxlabs-dev/dux-plugin-registry#1095](https://github.com/duxlabs-dev/dux-plugin-registry/pull/1095), - waiting for the registry maintainers. It states 39 providers: update the - description to 43 after 2.2.0 ships. `S · ★★★` + waiting for the registry maintainers. The description says "40+ AI + providers", so it only changes when the catalog reaches 50. `S · ★★★` - [~] **Live check of the new spend providers** — Anthropic API, OpenAI API, Ollama Cloud and Vercel AI Gateway are tested against the documented example responses only; no account was available. Run each card once with a real key @@ -38,18 +38,11 @@ the coverage-measurement fix and the 2.1 documentation. accounts since 2026), but the per-model buckets of a Code Assist Standard or Enterprise project are only fixture-tested from the CLI's `RetrieveUserQuotaResponse` type. Verify with a licensed account. `S · ★★` -- [~] **Weakest coverage files** — total shell statement coverage is 90.13% - (gate 80%). Below 80%, measured 2026-10-26: - `providers/get-hermes-analytics` 66.67%, `providers/native/hermes.bash` - 71.88%, `providers/reset-provider-limits` 71.88% (Claude sign-in and HTTP - error branches), `providers/native/opencode.bash` 72.22%, - `providers/get-local-analytics` 75.00%. The Hermes and OpenCode figures are - understated by the kcov limitation below. `M · ★★` -- [ ] **Report the kcov line-tracking bug upstream** — kcov loses line - tracking after a command whose multi-line quoted argument contains a single - quote; reproducers are in `docs/development-gates.md`. File it at - SimonKagstrom/kcov, then drop the caveat when a fixed kcov reaches Arch. - `S · ★` +- [~] **Last uncovered statements** — total statement coverage is 97.8% + (gate 95%). The rest are hard-to-reach paths: killing a stuck Codex + app-server, a 10 s exchange-rate lock timeout, curl network failures in + `get-claude-usage`, and Antigravity and xAI account-error branches. Cover + them when a test can fake the condition cheaply. `M · ★` ## Providers — Data & Auth @@ -98,6 +91,11 @@ No work starts until a read-only source exists. Last check: 2026-10-26. "Handler was registered but will not be used" for the extra ones. IPC works; register the handler from one instance only to silence it. `S · ★` +- [ ] **Packaged kcov** — CI builds kcov from a pinned commit after 43, + which fixed lost bash line tracking after a multi-line quoted argument with + a single quote. Switch back to the Arch package once kcov 44 ships, and + update the local build note in `docs/development-gates.md`. `S · ★` + ## Project health - [ ] **Account successor** — designate a GitHub account successor for the @@ -115,6 +113,11 @@ No work starts until a read-only source exists. Last check: 2026-10-26. refresh and Hermes cache tests (#81). - [x] **New providers** — Anthropic API, OpenAI API, Ollama Cloud and Vercel AI Gateway, plus Gemini CLI quota and the ineligible-account reason (#82). +- [x] **Coverage to 97.8%** — the files below 80% (Hermes analytics and + summary, limit resets, OpenCode, local analytics) now at 100%; CI measures + with a kcov build that tracks multi-line SQL, and the gate rises to 95%. +- [x] **README refresh** — "40+ providers" prose, provider examples matching + the coverage matrix, 2.1 notes and the development gates. - [x] **Dash plugin surface** — `AiOverviewDashCard.qml`, shipped in 2.1.0. ## Decided against diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index fd38899..cea12cc 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -7,7 +7,7 @@ **Todas as cotas de IA que você paga, ao vivo na sua DankBar.** Um plugin para o [DankMaterialShell](https://github.com/duxlabs-dev/dux-shell) que acompanha -cota, cobrança, autenticação e uso local de 43 provedores de IA — localmente, com honestidade +cota, cobrança, autenticação e uso local de 40+ provedores de IA — localmente, com honestidade e sem nenhum serviço externo. [![Release](https://img.shields.io/github/v/release/bernardopg/AiOverviewControl?color=8B5CF6&label=release)](https://github.com/bernardopg/AiOverviewControl/releases/latest) @@ -55,7 +55,7 @@ painéis. Nada de porcentagens inventadas. -**📊 Um painel, 43 provedores**
+**📊 Um painel, 40+ provedores**
Cota, saldo, analytics e autenticação lado a lado, com visão geral da frota: carga média, provedor mais usado e próximo reset. @@ -73,7 +73,7 @@ reset. Barra lotada? Esconda os nomes e compacte o espaçamento. **🤖 Telemetria local detalhada**
-Claude, Codex, 9Router, pi e Hermes mostram tokens, custo, modelos e projetos +Claude, Codex, OpenCode, 9Router, pi e Hermes mostram tokens, custo, modelos e projetos com gráfico de sete dias — lidos de dados que já estão no seu disco. Acompanhe várias contas Claude lado a lado, cada uma com seu próprio `CLAUDE_CONFIG_DIR`. Claude e Codex também mostram créditos, uso extra e resets de limite, e podem @@ -154,18 +154,19 @@ Prefere um arquivo de release ou um checkout do git? Veja [Instalação](./insta | Cobertura | O que você recebe | Exemplos | | --- | --- | --- | -| **Cota** | Uso real em % e horário do reset | Codex, Copilot, Antigravity, OpenRouter, Z.ai, GLM, xAI, Gemini CLI | +| **Cota** | Uso real em % e horário do reset | Codex, Copilot, Cursor, Antigravity, OpenRouter, Z.ai, xAI, Gemini CLI | | **Saldo** | Crédito pré-pago restante | Kimi, DeepSeek, Neuralwatt, Cline, Ollama Cloud, Vercel AI Gateway | | **Analytics** | Tokens, custo e modelos a partir de dados locais ou relatórios de gasto | Claude, 9Router, pi, Hermes, Cloudflare, Anthropic API, OpenAI API | -| **Autenticação** | Checagem de credencial, sem números de uso | Gemini, Mistral, Qwen, Groq, Cohere | +| **Autenticação** | Checagem de credencial, sem números de uso | Mistral, Qwen, Groq, Cohere, NVIDIA | | **Runtime local** | Modelos e sessões locais | Ollama, Vertex AI | -| **Informativo** | Link para a página oficial de uso | Cursor, Kiro, Warp, Perplexity | +| **Informativo** | Link para a página oficial de uso | Kiro, Warp, Perplexity, Amp | A lista completa, as fontes de dados e as credenciais estão em [Provedores](./providers.md). ## 📚 Documentação -O [guia do release 2.0.0](./releases/2.0.0.md) reúne mudanças, privacidade e limitações (em inglês). +Novo na 2.1: créditos, uso extra, resets de limite e o card do DankDash — veja as +[notas da 2.1.0](./releases/2.1.0.md). Vindo do DMS 1.6? Leia o [guia de atualização 2.0.0](./releases/2.0.0.md) (em inglês). Veja também [conversão de custos e privacidade](./currency.md), [módulos de runtime](./refactoring.md) e [regras de PR e publicação](./repository-rules.md). @@ -181,6 +182,7 @@ A documentação detalhada está em inglês. | [Arquitetura](./architecture.md) | Fluxo de execução e contrato dos adaptadores | | [Traduções](./i18n-crowdin.md) | Idiomas e Crowdin | | [Contribuindo](../CONTRIBUTING.md) | Checagens locais e convenções | +| [Gates de desenvolvimento](./development-gates.md) | Testes, cobertura de statements, fuzzing e gates de CI (em inglês) | | [Governança](../GOVERNANCE.md) | Decisões, papéis, revisão de código, continuidade | | [Roadmap](./roadmap.md) | O que está planejado para o próximo ano, e o que não está | | [Modelo de segurança](./security.md) | Requisitos de segurança e assurance case | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4d55db7..130a1d4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,11 +42,10 @@ of its own in between. ### Quality and security -- Keep total shell statement coverage at 90% or more (the CI gate stays at - 80%), and raise the files still below 80% (Hermes analytics, OpenCode, limit - resets, local analytics) toward 90%. -- Report kcov's lost line tracking after multi-line quoted arguments upstream, - and drop the documented caveat once a fixed kcov ships. +- Keep total shell statement coverage at 95% or more, enforced by CI, and + cover each new adapter's error paths with fixture tests. +- Return to the packaged kcov once a release after 43 ships; CI builds a + pinned, checksum-verified commit until then. - Keep the supply-chain controls current: SHA-pinned actions, checksum-verified CI tools, signed release provenance, secret scanning and the OpenSSF Scorecard. diff --git a/scripts/render-badges b/scripts/render-badges index 2285991..c78bb79 100755 --- a/scripts/render-badges +++ b/scripts/render-badges @@ -17,13 +17,18 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent # Prose that states the provider count, per README. Each pattern has exactly -# one group: the number. +# one group: the count. Prose rounds down to a multiple of ten ("40+"), so it +# only changes when the catalog reaches the next ten; the badge stays exact. PROSE = { - 'README.md': [r'usage for (\d+) AI providers', r'One dashboard, (\d+) providers'], - 'docs/README.pt-BR.md': [r'uso local de (\d+) provedores de IA', r'Um painel, (\d+) provedores'], + 'README.md': [r'usage for (\d+\+?) AI providers', r'One dashboard, (\d+\+?) providers'], + 'docs/README.pt-BR.md': [r'uso local de (\d+\+?) provedores de IA', r'Um painel, (\d+\+?) provedores'], } +def prose_count(providers): + return f'{providers // 10 * 10}+' if providers >= 10 else str(providers) + + def counts(): settings = (ROOT / 'AiOverviewControlSettings.qml').read_text() try: @@ -58,7 +63,7 @@ def main(): for pattern in patterns: if not re.search(pattern, updated): sys.exit(f'render-badges: {name} no longer matches {pattern!r}') - updated = re.sub(pattern, lambda m: m.group(0).replace(m.group(1), str(data['providers'])), updated) + updated = re.sub(pattern, lambda m: m.group(0).replace(m.group(1), prose_count(data['providers'])), updated) if updated != text: stale.append(name) if not check: From bacd3972facb164d65f37313b79dc23a2e060a1a Mon Sep 17 00:00:00 2001 From: Bernardo Pinto Gomes Date: Mon, 26 Oct 2026 20:55:26 -0300 Subject: [PATCH 4/4] test(coverage): quote the empty FIREWORKS_ACCOUNT_ID override shellcheck -S warning flags a bare VAR= prefix (SC1007). Signed-off-by: Bernardo Pinto Gomes --- tests/test-cov-d2-fireworks.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test-cov-d2-fireworks.sh b/tests/test-cov-d2-fireworks.sh index 74b4175..17af62b 100755 --- a/tests/test-cov-d2-fireworks.sh +++ b/tests/test-cov-d2-fireworks.sh @@ -51,5 +51,5 @@ result="$(run)"; jq -e '.[0].source == "fireworks-api"' <<<"$result" >/dev/null # Quota API down but the key works: an authenticated note, with or without an account id. result="$(COV_MODELS_STATUS=200 run)" jq -e '.[0].error == null and .[0].usage.accountEmail == "Fireworks AI account acct-test"' <<<"$result" >/dev/null -result="$(COV_MODELS_STATUS=200 FIREWORKS_ACCOUNT_ID= run)" +result="$(COV_MODELS_STATUS=200 FIREWORKS_ACCOUNT_ID='' run)" jq -e '.[0].error == null and (.[0].usage.primary.resetDescription | contains("set FIREWORKS_ACCOUNT_ID"))' <<<"$result" >/dev/null