From 86c108f6eb0689ff7af0da21264ee89ebc29d04d Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 14 Oct 2026 07:45:51 +0000 Subject: [PATCH] test: replace mock smoke matrix with a live-only drift check The 43-case mock smoke duplicated tests/cli.rs, which already runs the real binary against fixture providers. Replace the flake's smoke-mock check with a small packaged-cli check (version JSON, bash completions, one mock quote), and cut scripts/live-smoke.sh from 672 to ~170 lines: it now only runs shipped commands against live MSN/Yahoo/IDX and validates output shape. Rewrite docs/SMOKE.md around the Nix gate and the drift check. Co-Authored-By: Claude Opus 5.5 --- .codex/skills/idx-release/SKILL.md | 4 +- AGENTS.md | 6 +- README.md | 18 +- docs/CONVENTIONS.md | 2 +- docs/SMOKE.md | 154 ++---- flake.nix | 16 +- scripts/live-smoke.sh | 755 +++++------------------------ 7 files changed, 186 insertions(+), 769 deletions(-) diff --git a/.codex/skills/idx-release/SKILL.md b/.codex/skills/idx-release/SKILL.md index 035ebe4..9803504 100644 --- a/.codex/skills/idx-release/SKILL.md +++ b/.codex/skills/idx-release/SKILL.md @@ -60,13 +60,13 @@ curl -fsSL https://crates.io/api/v1/crates/idx-cli For a real release or release-hygiene fix, verify the repo state first. Run the canonical gate (fmt, clippy, tests, release build, `cargo package` -verification, and mock smoke): +verification, and a packaged-binary check): ```bash nix flake check -L ``` -Run targeted smoke when the release changes user-facing CLI behavior. For +Run `scripts/live-smoke.sh` when the release changes provider-facing behavior. For ownership-sync changes, re-verify a clean-machine flow: ```bash diff --git a/AGENTS.md b/AGENTS.md index 001441e..f4367cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Docs: - `docs/ARCHITECTURE.md`: providers, ownership ingest/discovery, error and output contract - `docs/CONVENTIONS.md`: types, naming, testing, and git rules - `docs/OWNERSHIP_SYNC.md` / `docs/OWNERSHIP_PUBLISH.md`: snapshot consumer contract / maintainer publishing -- `docs/SMOKE.md`: smoke runner and Nix check coverage +- `docs/SMOKE.md`: Nix checks, test layout, and the live drift check - `ROADMAP.md`: open work ## Rules @@ -36,7 +36,7 @@ Docs: ## Verify ```bash -nix flake check -L # canonical gate (fmt, clippy, tests, package, smoke) +nix flake check -L # canonical gate (fmt, clippy, tests, package) nix develop --command cargo test # focused loop -scripts/live-smoke.sh --mode full # opt-in live providers +scripts/live-smoke.sh # opt-in live provider drift check ``` diff --git a/README.md b/README.md index 744a049..6f2cde1 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Precedence order: - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): providers, ownership ingest, and discovery - [docs/OWNERSHIP_SYNC.md](docs/OWNERSHIP_SYNC.md): snapshot manifest and sync rules - [docs/OWNERSHIP_PUBLISH.md](docs/OWNERSHIP_PUBLISH.md): maintainer snapshot publishing -- [docs/SMOKE.md](docs/SMOKE.md): smoke tests against live providers +- [docs/SMOKE.md](docs/SMOKE.md): test layout and the live provider drift check - [ROADMAP.md](ROADMAP.md): planned work ## Development @@ -157,10 +157,10 @@ The canonical local and CI verification command is: nix flake check -L ``` -It covers Rust formatting, Clippy, tests, crate package verification, release -builds, offline CLI smoke, publisher regressions, installer tests with `sh` and -`dash`, and ShellCheck for installer/smoke scripts. The checks use pinned tools and cached Nix -outputs; an unchanged successful check is reused rather than rerun. +It covers Rust formatting, Clippy, unit and CLI integration tests, crate +package verification, the packaged binary, publisher regressions, installer +tests with `sh` and `dash`, and ShellCheck. The checks use pinned tools and +cached Nix outputs; an unchanged successful check is reused rather than rerun. For a focused edit/debug loop, use the same pinned toolchain: @@ -172,10 +172,10 @@ Configured Prek hooks keep whitespace/conflict checks at pre-commit and run `nix flake check -L` at pre-push. There is no separate full Cargo test/lint gate to repeat after a successful flake check. -CI additionally checks the installer with native macOS tools for portability. -Live-provider smoke remains opt-in; see [docs/SMOKE.md](docs/SMOKE.md) for check -selection and caching details. The npm distribution harness remains available -separately as `scripts/npm-smoke.sh`. +CI additionally checks the installer with native macOS tools. The live +provider drift check (`scripts/live-smoke.sh`) is opt-in; see +[docs/SMOKE.md](docs/SMOKE.md). The npm wrapper has its own harness, +`scripts/npm-smoke.sh`. ## License diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index d50ebff..9bae104 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -119,7 +119,7 @@ let conn = Connection::open(path) - **Unit tests:** retain precise parser, normalization, calculation, and boundary regressions; avoid pinning incidental wording, enum counts, or internal wiring. - **Integration tests:** prefer CLI workflows, real temporary/in-memory SQLite, and deterministic local provider fixtures for user-visible contracts and state transitions. - **Output contracts:** parse JSON from stdout on success and stderr on failure; errors must leave stdout empty. Assert meaningful values, types, and stable error codes rather than substrings or diagnostic wording. -- **Stateful workflows:** compare cached data across offline/failure transitions, and verify failed snapshot updates preserve both database bytes and query results. Keep packaged-binary smoke representative; put exhaustive boundaries in integration tests. +- **Stateful workflows:** compare cached data across offline/failure transitions, and verify failed snapshot updates preserve both database bytes and query results. Put behavioral coverage in `tests/cli.rs`, not shell smoke. - **Fixtures:** `tests/fixtures/*.json` — real API responses, sanitized - **No live API calls in CI** — `IDX_USE_MOCK_PROVIDER=1` - **Test naming:** `test__` (e.g., `test_parse_id_number_with_dots`) diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 267b58e..562f8ee 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -1,135 +1,55 @@ -# Smoke Checks +# Testing and Smoke Checks -Use `scripts/live-smoke.sh` as the reusable smoke runner for shipped CLI surfaces. +## Automated gate -The script: -- builds `target/debug/idx` once by default -- isolates config, cache, and data under `tmp/live-smoke//` -- records separate `.stdout` and `.stderr` files per case under `tmp/live-smoke//logs/`, plus `.validation` diagnostics for JSON checks -- supports fast mock-backed runs and slower live-network passes; `jq` is required and provided by `nix develop` +`nix flake check -L` is the canonical local, pre-push, and CI gate. It runs +offline with pinned tools: -## Common Runs +| Check | Covers | +| --- | --- | +| `fmt`, `clippy` | formatting and lints (`--all-targets`) | +| `test` | unit/module tests and `tests/cli.rs` CLI integration tests | +| `default`, `package` | release build and `cargo package` verification | +| `packaged-cli` | the wrapped Nix binary starts, emits JSON, and generates completions | +| `ownership-publisher`, `publisher-test` | packaged publisher entry point and its regression scenarios | +| `install-script-sh`, `install-script-dash`, `shellcheck` | installer regressions and shell lint | -```bash -scripts/live-smoke.sh -scripts/live-smoke.sh --mode full -scripts/live-smoke.sh --mode mock -scripts/live-smoke.sh --group live-table --group live-json -scripts/live-smoke.sh --group live-nonfinite -scripts/live-smoke.sh --group cache --symbol BBRI -scripts/live-smoke.sh --dry-run --mode full -scripts/live-smoke.sh --bin ./tmp/release-install/bin/idx --no-build --mode mock -scripts/audit-msn-fundamentals.sh --tickers BUMI,ADRO,AIMS -``` +`tests/cli.rs` is the behavioral suite: it runs the real `idx` binary against +fixture-backed mock providers (`IDX_USE_MOCK_PROVIDER=1`) and covers output +contracts, cache/offline transitions, provider routing, error codes, ownership +imports and queries, and snapshot sync. -## Nix CI Checks - -`nix flake check -L` is the canonical local, pre-push, and Linux CI gate. Its -eleven checks cover: - -- Rust formatting, Clippy (`--all-targets`), and all unit/CLI tests -- the optimized application build and verified crate packaging -- the packaged ownership publisher's `--help` entry point -- the offline mock CLI smoke matrix -- publisher regression scenarios, including the SQLite artifact builder -- installer regressions under `sh` and `dash`, plus installer and smoke-runner ShellCheck - -All check tools are pinned by `flake.lock`. Installer fixtures use resolved -shell paths, and generated publisher stubs use the running Bash interpreter, -so the regression suites also work inside the Nix sandbox. - -Crane shares dev-profile dependency artifacts between tests, Clippy, and -`cargo package` verification; the shipped application uses a separate release -dependency cache. Package verification still builds the packaged crate. -Integration-test edits do not invalidate the application build, and script -checks consume only their relevant source files. README/license edits affect -the packaging check, not the Rust test/application derivations. - -CI cache keys include platform, dependency/toolchain inputs, and checked source -contents. Source changes can therefore save new outputs; restore prefixes first -reuse the same dependency/toolchain cache, then another cache for the platform. -The GitHub cache is opportunistic and subject to eviction, not a shared public -binary cache. Obsolete PR runs are cancelled; main-branch runs are not. - -Use individual checks when investigating a failure (replace the system as -needed), or targeted Cargo commands inside the pinned development shell: +For a focused loop, run one check or filter Cargo tests: ```bash nix build .#checks.x86_64-linux.publisher-test -L nix develop --command cargo test full_screener ``` -The native macOS installer job remains separate to exercise BSD/macOS tools. -Live-network smoke is deliberately outside the sandboxed offline gate. +CI cache keys include platform, dependency/toolchain inputs, and checked source +contents. The native macOS installer job runs separately to exercise BSD tools. -For a live full-list screener check, use an isolated config/cache and run: +## Live drift check + +Fixtures cannot tell when MSN, Yahoo, or IDX change their responses. +`scripts/live-smoke.sh` runs shipped commands against the real upstreams and +validates each JSON output shape. It is opt-in and never part of the gate. ```bash -idx -o json stocks screen --region us --filter top-performers --limit 600 -idx --offline -o json stocks screen --region us --filter top-performers --limit 3 -idx --offline -o json stocks screen --region us --filter top-performers --limit 25 +nix develop --command scripts/live-smoke.sh # stocks + ownership +nix develop --command scripts/live-smoke.sh --group stocks --symbol BBRI +nix develop --command scripts/live-smoke.sh --bin ./result/bin/idx +scripts/live-smoke.sh --dry-run ``` -The first command should not stop at the old 500-row cap when MSN reports more -candidates. The two offline results must match prefixes of the same cached full -list. Provider counts and prices vary; live HTTP failures are not offline-test -failures. +- `stocks`: every `stocks` command for one ticker, plus `compare BUMI,ADRO,AIMS`, + whose MSN key ratios contain non-finite values. +- `ownership`: `discover`, then imports the newest supported report into a temp + DB and lists `releases`. IDX sits behind Cloudflare and returns `403` to many + datacenter IPs, including GitHub-hosted runners; run it from a network IDX accepts. -## Coverage Boundaries +Each run isolates config, cache, and data under `tmp/live-smoke//` +and keeps per-case `.stdout`, `.stderr`, and `.validation` logs there. -The mock preset has 43 cases: 7 general, 19 stock, 9 cache, 2 routing, 4 error, -and 2 empty/unsupported ownership checks. This is a packaged-binary safety net, -not a claim that every important behavior is covered by shell smoke. - -- All fifteen stock commands run in JSON mode with structural and fixture-value - assertions. Four representative table renders check displayed data. -- JSON checks require exactly one document in the correct stream. Failed - commands must leave stdout empty and expose the expected stable error code. -- Cache scenarios use zero TTL, force provider failures, and compare parsed - offline/stale results with the warmed data; diagnostics must stay on stderr. -- Redundant table launches, duplicate routing cases, and repeated setup checks - are omitted rather than counted as extra correctness coverage. - -The CLI integration suite carries deeper workflows under the same Nix gate: -cache misses, provider isolation, filtered reports, non-finite values, -XLSX/ZIP import and ownership queries, snapshot install/no-op/force, and rejected -updates preserving database bytes and query results. - -Screener regressions generate 503 distinct candidates, with ranking leaders -beyond the first 500. They exercise top/worst performers, volume, and market-cap -ordering, a small first request followed by complete offline reads, and rejected -missing/incomplete totals without cache poisoning. The raw fixture override -`IDX_MOCK_MSN_SCREENER_FIXTURE` is honored only with `IDX_USE_MOCK_PROVIDER`; -responses honor the requested batch size so the complete-fetch path is exercised. - -Mocks do not verify current upstream availability, authentication, or response -drift. Live provider checks remain a separate, opt-in operation. - -## Modes - -- `live` runs the default real-network baseline: `general`, `live-table`, and `ownership` -- `mock` runs the deterministic baseline: `general`, `mock`, `cache`, `routing`, `errors`, and `ownership` -- `full` runs every group, including live JSON output checks and ownership-import verification - -## Groups - -- `general`: `version`, `completions`, `config`, and `cache` command basics -- `live-table`: all shipped `stocks` commands in live table mode -- `live-json`: all shipped `stocks` commands in live JSON mode -- `mock`: all shipped `stocks` commands against the mock provider in JSON mode, plus representative quote/history/fundamental/financials table renders -- `cache`: exact data equality across warm, `--offline`, and stale-cache fallback reads for quote, technical, and MSN `profile` -- `routing`: Yahoo/MSN provider routing plus explicit MSN history behavior -- `errors`: JSON error contract and invalid flag/input checks -- `live-nonfinite`: opt-in live MSN fundamentals checks for known non-finite ticker payloads (`BUMI`, `ADRO`, `AIMS`) -- `ownership`: safe ownership smoke checks that do not require imported ownership data -- `ownership-import`: live discovery/import hardening checks for supported `above1` import plus expected unsupported legacy-family failures - -## Notes - -- The runner forces `IDX_OUTPUT=table` so table cases stay stable; JSON checks pass `-o json` explicitly. -- Cache-group warm cases clear the smoke cache first so warm/offline/stale sequences start clean. -- Use `--bin --no-build` to validate an installed binary instead of `target/debug/idx`. -- `ownership-import` and `live-nonfinite` are opt-in (explicit `--group` or `--mode full`). Known non-finite repro tickers as of `2026-04-13`: `BUMI`, `ADRO`, `AIMS`. -- The baseline `ownership` group needs no imported data; `ownership sync` is covered by fixture-backed CLI tests instead. -- `scripts/audit-msn-fundamentals.sh` is a heavier provider-health sweep over the IDX MSN symbol map, not a baseline check. -- When a case fails, inspect its `.stdout`, `.stderr`, and `.validation` files under `tmp/live-smoke/.../logs/`. +`scripts/audit-msn-fundamentals.sh` is a heavier sweep of valuation output +across the whole IDX MSN symbol map, for provider-health audits. diff --git a/flake.nix b/flake.nix index a1a3a32..7f5d170 100644 --- a/flake.nix +++ b/flake.nix @@ -189,19 +189,19 @@ doInstallCargoArtifacts = false; installPhaseCommand = "touch $out"; }); - smoke-mock = pkgs.runCommand "idx-smoke-mock" - { nativeBuildInputs = with pkgs; [ bash coreutils diffutils findutils gnugrep gnused gawk jq ]; } + # Sanity check of the packaged (wrapped) binary; CLI behavior is + # covered by tests/cli.rs. + packaged-cli = pkgs.runCommand "idx-packaged-cli" + { nativeBuildInputs = with pkgs; [ bash jq ]; } '' - mkdir -p scripts - cp ${./scripts/live-smoke.sh} scripts/live-smoke.sh - chmod u+w scripts/live-smoke.sh - patchShebangs scripts/live-smoke.sh # The mock provider reads fixtures relative to the working dir. mkdir -p tests cp -r ${./tests/fixtures} tests/fixtures export HOME="$TMPDIR" - ${idxPackage}/bin/idx version - bash scripts/live-smoke.sh --bin ${idxPackage}/bin/idx --no-build --mode mock + ${idxPackage}/bin/idx -o json version | jq -e '.version | type == "string"' + ${idxPackage}/bin/idx completions bash | bash -n + IDX_USE_MOCK_PROVIDER=1 ${idxPackage}/bin/idx -o json stocks quote BBCA \ + | jq -e '.[0].price | type == "number"' touch "$out" ''; install-script-sh = installerCheck "sh"; diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index ac61574..1bd4d3f 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -1,672 +1,169 @@ #!/usr/bin/env bash +# Live provider drift check: runs shipped commands against real MSN, Yahoo, +# and IDX endpoints and validates the JSON output shape. Deterministic CLI +# behavior is covered by tests/cli.rs; this only answers "do the upstreams +# still look the way our parsers expect?" set -u set -o pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -MODE="live" -BIN_PATH="" -WORKDIR="" SYMBOL="BBCA" -COMPARE_SYMBOLS="BBCA,BBRI" -NEWS_LIMIT="5" -SCREEN_LIMIT="10" +BIN_PATH="" DRY_RUN=0 -BUILD=1 - -declare -a GROUP_FILTERS=() -declare -a CASES=() +declare -a GROUPS_SELECTED=() declare -a FAILURES=() - -TOTAL_CASES=0 -PASSED_CASES=0 -FAILED_CASES=0 -SEP=$'\x1f' - -CONFIG_HOME="" -CACHE_HOME="" -DATA_HOME="" -OWNERSHIP_DB="" -LOG_DIR="" -OWNERSHIP_ABOVE1_URL="" -OWNERSHIP_ABOVE5_URL="" -OWNERSHIP_INVESTOR_TYPE_URL="" - -declare -a BASE_ENV=() +TOTAL=0 +PASSED=0 usage() { cat <<'EOF' Usage: scripts/live-smoke.sh [options] -Reusable smoke runner for shipped idx-cli command surfaces. - Options: - --mode Preset case selection. Default: live - --group Restrict to one or more groups. Repeatable. - --symbol Primary ticker for stock cases. Default: BBCA - --compare-symbols Compare symbols. Default: BBCA,BBRI - --news-limit News limit. Default: 5 - --screen-limit Screener limit. Default: 10 - --workdir Artifact root. Default: tmp/live-smoke/ - --bin Use an existing idx binary instead of target/debug/idx - --no-build Skip cargo build - --dry-run Print selected cases without running them - --help Show this help - -Groups: - general version/completions/config/cache basics - live-table live network stock commands in table mode - live-json live network stock commands in JSON mode - mock mock-backed stock JSON contracts + representative table checks - cache deterministic cache/offline/stale-cache checks - routing provider routing and explicit unsupported checks - errors JSON error contract and invalid-flag checks - live-nonfinite opt-in live MSN fundamentals checks for known non-finite tickers - ownership ownership commands that are safe without imported data - ownership-import live ownership discovery/import hardening checks - -Examples: - scripts/live-smoke.sh - scripts/live-smoke.sh --mode full - scripts/live-smoke.sh --mode mock --group cache - scripts/live-smoke.sh --group live-table --group live-json --symbol BBRI - scripts/live-smoke.sh --group live-nonfinite + --group Restrict to a group. Repeatable. Default: both + --symbol Primary ticker. Default: BBCA + --bin Use an existing idx binary (skips cargo build) + --dry-run Print cases without running them + --help Show this help EOF } -add_group_filters() { - local raw="$1" - local part - local old_ifs="$IFS" - IFS=',' - for part in $raw; do - if [[ -n "$part" ]]; then - GROUP_FILTERS+=("$part") - fi - done - IFS="$old_ifs" -} - -mode_allows_group() { - local group="$1" - case "$MODE" in - live) - case "$group" in - general|live-table|ownership) return 0 ;; - *) return 1 ;; - esac - ;; - mock) - case "$group" in - general|mock|cache|routing|errors|ownership) return 0 ;; - *) return 1 ;; - esac - ;; - full) - return 0 - ;; - *) - return 1 - ;; +while [[ $# -gt 0 ]]; do + case "$1" in + --group) GROUPS_SELECTED+=("${2:-}"); shift 2 ;; + --symbol) SYMBOL="${2:-}"; shift 2 ;; + --bin) BIN_PATH="${2:-}"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + --help|-h) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; esac -} +done +[[ ${#GROUPS_SELECTED[@]} -eq 0 ]] && GROUPS_SELECTED=(stocks ownership) -group_requested() { - local group="$1" - local selected +for group in "${GROUPS_SELECTED[@]}"; do + case "$group" in + stocks|ownership) ;; + *) echo "invalid --group '$group' (expected stocks or ownership)" >&2; exit 2 ;; + esac +done - if [[ ${#GROUP_FILTERS[@]} -eq 0 ]]; then - return 0 - fi - - for selected in "${GROUP_FILTERS[@]}"; do - if [[ "$selected" == "$group" ]]; then - return 0 - fi +selected() { + local group + for group in "${GROUPS_SELECTED[@]}"; do + [[ "$group" == "$1" ]] && return 0 done - return 1 } -should_run_group() { - local group="$1" - if [[ ${#GROUP_FILTERS[@]} -gt 0 ]]; then - group_requested "$group" - return - fi +WORKDIR="$ROOT_DIR/tmp/live-smoke/$(date +%Y%m%d-%H%M%S)" +LOG_DIR="$WORKDIR/logs" +BASE_ENV=( + "XDG_CONFIG_HOME=$WORKDIR/config" + "XDG_CACHE_HOME=$WORKDIR/cache" + "XDG_DATA_HOME=$WORKDIR/data" + "IDX_PROVIDER=msn" + "IDX_HISTORY_PROVIDER=auto" + "IDX_NO_COLOR=1" +) - mode_allows_group "$group" -} - -add_case() { - CASES+=("$1$SEP$2$SEP$3$SEP$4$SEP$5$SEP$6$SEP${7:-}") -} - -register_cases() { - local live_env="IDX_PROVIDER=msn IDX_HISTORY_PROVIDER=auto" - local mock_env="IDX_PROVIDER=msn IDX_HISTORY_PROVIDER=auto IDX_USE_MOCK_PROVIDER=1" - local yahoo_mock_env="IDX_PROVIDER=yahoo IDX_HISTORY_PROVIDER=auto IDX_USE_MOCK_PROVIDER=1" - local quote_zero_ttl_env="IDX_PROVIDER=msn IDX_USE_MOCK_PROVIDER=1 IDX_CACHE_QUOTE_TTL=0" - local profile_zero_ttl_env="IDX_PROVIDER=msn IDX_USE_MOCK_PROVIDER=1 IDX_CACHE_FUNDAMENTAL_TTL=0" - local live_symbol="$SYMBOL" - local compare_args="$COMPARE_SYMBOLS" - local history_args="stocks history $live_symbol --period 3mo" - local screen_args="stocks screen --filter top-performers --limit $SCREEN_LIMIT" - local news_args="stocks news $live_symbol --limit $NEWS_LIMIT" - - add_case "general" "version" "0" "" "-o json version" "" '.version | type == "string" and test("^[0-9]+\\.[0-9]+\\.[0-9]+")' - add_case "general" "completions-bash" "0" "" "completions bash" "" - add_case "general" "config-set-output-json" "0" "" "config set general.output json" "" - add_case "general" "config-get-output" "0" "" "config get general.output" "json" - add_case "general" "config-set-output-table" "0" "" "config set general.output table" "" - add_case "general" "cache-info" "0" "" "-o json cache info" "" '.files | type == "number" and . >= 0' - add_case "general" "cache-clear" "0" "" "-o json cache clear" "" '.removed | type == "number" and . >= 0' - - add_case "live-table" "quote" "0" "$live_env" "stocks quote $live_symbol" "" - add_case "live-table" "history" "0" "$live_env" "$history_args" "" - add_case "live-table" "technical" "0" "$live_env" "stocks technical $live_symbol" "" - add_case "live-table" "growth" "0" "$live_env" "stocks growth $live_symbol" "" - add_case "live-table" "valuation" "0" "$live_env" "stocks valuation $live_symbol" "" - add_case "live-table" "risk" "0" "$live_env" "stocks risk $live_symbol" "" - add_case "live-table" "fundamental" "0" "$live_env" "stocks fundamental $live_symbol" "" - add_case "live-table" "compare" "0" "$live_env" "stocks compare $compare_args" "" - add_case "live-table" "profile" "0" "$live_env" "stocks profile $live_symbol" "" - add_case "live-table" "financials" "0" "$live_env" "stocks financials $live_symbol" "" - add_case "live-table" "earnings" "0" "$live_env" "stocks earnings $live_symbol" "" - add_case "live-table" "sentiment" "0" "$live_env" "stocks sentiment $live_symbol" "" - add_case "live-table" "insights" "0" "$live_env" "stocks insights $live_symbol" "" - add_case "live-table" "news" "0" "$live_env" "$news_args" "" - add_case "live-table" "screen" "0" "$live_env" "$screen_args" "" - - add_case "live-json" "quote" "0" "$live_env" "-o json stocks quote $live_symbol" "" - add_case "live-json" "history" "0" "$live_env" "-o json $history_args" "" - add_case "live-json" "technical" "0" "$live_env" "-o json stocks technical $live_symbol" "" - add_case "live-json" "growth" "0" "$live_env" "-o json stocks growth $live_symbol" "" - add_case "live-json" "valuation" "0" "$live_env" "-o json stocks valuation $live_symbol" "" - add_case "live-json" "risk" "0" "$live_env" "-o json stocks risk $live_symbol" "" - add_case "live-json" "fundamental" "0" "$live_env" "-o json stocks fundamental $live_symbol" "" - add_case "live-json" "compare" "0" "$live_env" "-o json stocks compare $compare_args" "" - add_case "live-json" "profile" "0" "$live_env" "-o json stocks profile $live_symbol" "" - add_case "live-json" "financials" "0" "$live_env" "-o json stocks financials $live_symbol" "" - add_case "live-json" "earnings" "0" "$live_env" "-o json stocks earnings $live_symbol" "" - add_case "live-json" "sentiment" "0" "$live_env" "-o json stocks sentiment $live_symbol" "" - add_case "live-json" "insights" "0" "$live_env" "-o json stocks insights $live_symbol" "" - add_case "live-json" "news" "0" "$live_env" "-o json $news_args" "" - add_case "live-json" "screen" "0" "$live_env" "-o json $screen_args" "" - - add_case "live-nonfinite" "valuation-bumi" "0" "$live_env" "-o json stocks valuation BUMI" "" - add_case "live-nonfinite" "valuation-adro" "0" "$live_env" "-o json stocks valuation ADRO" "" - add_case "live-nonfinite" "valuation-aims" "0" "$live_env" "-o json stocks valuation AIMS" "" - add_case "live-nonfinite" "compare-bad-tickers" "0" "$live_env" "-o json stocks compare BUMI,ADRO,AIMS" "" 'any(.[]; .symbol == "BUMI.JK")' - - add_case "mock" "quote-table" "0" "$mock_env" "stocks quote $live_symbol" "9,875" - add_case "mock" "quote-json" "0" "$mock_env" "-o json stocks quote $live_symbol" "" '.[0].price == 9875 and .[0].change == 117' - add_case "mock" "history-table" "0" "$mock_env" "$history_args" "2024-03-03" - add_case "mock" "history-json" "0" "$mock_env" "-o json $history_args" "" '.[-1].close == 9925' - add_case "mock" "technical-json" "0" "$mock_env" "-o json stocks technical $live_symbol" "" '.current_price == 9925 and .sma200 == null' - add_case "mock" "growth-json" "0" "$mock_env" "-o json stocks growth $live_symbol" "" '.revenue_growth_pct == 8.1 and .earnings_growth_pct == 12.1' - add_case "mock" "valuation-json" "0" "$mock_env" "-o json stocks valuation $live_symbol" "" '.pe_trailing == 25.4 and .roe_pct == 19.8' - add_case "mock" "risk-json" "0" "$mock_env" "-o json stocks risk $live_symbol" "" '.debt_to_equity == 0.42 and .roa_pct == 3.1' - add_case "mock" "fundamental-table" "0" "$mock_env" "stocks fundamental $live_symbol" "25.40" - add_case "mock" "fundamental-json" "0" "$mock_env" "-o json stocks fundamental $live_symbol" "" '.growth.revenue_growth_pct == 8.1 and .valuation.pe_trailing == 25.4 and .risk.debt_to_equity == 0.42' - add_case "mock" "compare-json" "0" "$mock_env" "-o json stocks compare $compare_args" "" 'length > 0 and all(.[]; .growth.revenue_growth_pct == 8.1 and .valuation.pe_trailing == 25.4)' - add_case "mock" "profile-json" "0" "$mock_env" "-o json stocks profile $live_symbol" "" '.country == "Indonesia" and .employees == 27937' - add_case "mock" "financials-table" "0" "$mock_env" "stocks financials $live_symbol" "400,000,000" - add_case "mock" "financials-json" "0" "$mock_env" "-o json stocks financials $live_symbol" "" '.income_statement.values.netIncome == 400000000 and .cash_flow.values.operatingCashFlow == 600000000' - add_case "mock" "earnings-json" "0" "$mock_env" "-o json stocks earnings $live_symbol" "" '.eps_last_year == 1200 and .forecast[0].eps_forecast == 1300 and .history[0].eps_actual == 1250' - add_case "mock" "sentiment-json" "0" "$mock_env" "-o json stocks sentiment $live_symbol" "" '.statistics[0] == {"time_range":"1D","bullish":10,"bearish":2,"neutral":3}' - add_case "mock" "insights-json" "0" "$mock_env" "-o json stocks insights $live_symbol" "" '.last_updated == "2026-03-26T04:14:57.9197955Z"' - add_case "mock" "news-json" "0" "$mock_env" "-o json $news_args" "" "map(.id) == ([\"news-1\"][:$NEWS_LIMIT])" - add_case "mock" "screen-json" "0" "$mock_env" "-o json $screen_args" "" "map(.symbol) == ([\"BBRI.JK\", \"BBCA.JK\"][:$SCREEN_LIMIT])" - - add_case "cache" "quote-warm" "0" "$quote_zero_ttl_env" "-o json stocks quote $live_symbol" "" - add_case "cache" "quote-offline" "0" "$quote_zero_ttl_env IDX_MOCK_ERROR=1" "-o json --offline stocks quote $live_symbol" "" - add_case "cache" "quote-stale-fallback" "0" "$quote_zero_ttl_env IDX_MOCK_ERROR=1" "-o json stocks quote $live_symbol" "" - add_case "cache" "technical-warm" "0" "$quote_zero_ttl_env" "-o json stocks technical $live_symbol" "" - add_case "cache" "technical-offline" "0" "$quote_zero_ttl_env IDX_MOCK_ERROR=1" "-o json --offline stocks technical $live_symbol" "" - add_case "cache" "technical-stale-fallback" "0" "$quote_zero_ttl_env IDX_MOCK_ERROR=1" "-o json stocks technical $live_symbol" "" - add_case "cache" "profile-warm" "0" "$profile_zero_ttl_env" "-o json stocks profile $live_symbol" "" - add_case "cache" "profile-offline" "0" "$profile_zero_ttl_env IDX_MOCK_ERROR=1" "-o json --offline stocks profile $live_symbol" "" - add_case "cache" "profile-stale-fallback" "0" "$profile_zero_ttl_env IDX_MOCK_ERROR=1" "-o json stocks profile $live_symbol" "" - - add_case "routing" "yahoo-quote" "0" "$yahoo_mock_env" "-o json --no-cache stocks quote $live_symbol" "" '.[0].price == 9875' - add_case "routing" "explicit-msn-history" "0" "$mock_env" "-o json --no-cache stocks history $live_symbol --period 3mo --history-provider msn" "" '.[0].close == 8000 and all(.[]; .volume == 0 and .open == .close and .high == .close and .low == .close)' - - add_case "errors" "invalid-provider-json" "1" "IDX_PROVIDER=bogus" "-o json version" "" '.code == "CONFIGERROR"' - add_case "errors" "profile-provider-gate-json" "1" "IDX_PROVIDER=yahoo" "-o json stocks profile $live_symbol" "" '.code == "UNSUPPORTED"' - add_case "errors" "invalid-screen-region-json" "1" "$mock_env" "-o json stocks screen --region eu" "" '.code == "INVALIDINPUT"' - add_case "errors" "offline-no-cache" "1" "IDX_USE_MOCK_PROVIDER=1" "-o json --offline --no-cache stocks quote $live_symbol" "" '.code == "INVALIDINPUT"' - - add_case "ownership" "releases-empty" "0" "" "-o json ownership releases" "" '. == []' - add_case "ownership" "fetch-bing-unsupported" "1" "" "-o json ownership import --fetch-bing $live_symbol" "" '.code == "UNSUPPORTED"' - - add_case "ownership-import" "discover-default-above1" "0" "" "ownership discover --limit 1" "supported" - add_case "ownership-import" "import-supported-above1" "0" "" "ownership import --url $OWNERSHIP_ABOVE1_URL" "Imported " - add_case "ownership-import" "releases-after-import" "0" "" "ownership releases" "$OWNERSHIP_ABOVE1_URL" - add_case "ownership-import" "import-legacy-above5" "1" "" "ownership import --url $OWNERSHIP_ABOVE5_URL" "legacy IDX \`above5\` ownership PDFs are not supported for import" - add_case "ownership-import" "import-legacy-investor-type" "1" "" "ownership import --url $OWNERSHIP_INVESTOR_TYPE_URL" "legacy IDX \`investor-type\` ownership PDFs are not supported for import" -} - -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --mode) - MODE="${2:-}" - shift 2 - ;; - --group) - add_group_filters "${2:-}" - shift 2 - ;; - --symbol) - SYMBOL="${2:-}" - shift 2 - ;; - --compare-symbols) - COMPARE_SYMBOLS="${2:-}" - shift 2 - ;; - --news-limit) - NEWS_LIMIT="${2:-}" - shift 2 - ;; - --screen-limit) - SCREEN_LIMIT="${2:-}" - shift 2 - ;; - --workdir) - WORKDIR="${2:-}" - shift 2 - ;; - --bin) - BIN_PATH="${2:-}" - BUILD=0 - shift 2 - ;; - --no-build) - BUILD=0 - shift - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --help|-h) - usage - exit 0 - ;; - *) - echo "unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac - done - - case "$MODE" in - live|mock|full) ;; +# jq filter describing the JSON shape each command must keep. +contract() { + case "$1" in + "stocks quote "*|"stocks screen"*) + echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.price | type == "number"))' ;; + "stocks history "*) + echo 'type == "array" and length > 0 and all(.[]; (.date | type == "string") and (.close | type == "number"))' ;; + "stocks technical "*) + echo '(.current_price | type == "number") and (.signals.overall | type == "string")' ;; + "stocks growth "*|"stocks valuation "*|"stocks risk "*) + echo '.overall_signal | type == "string"' ;; + "stocks fundamental "*) + echo '(.growth.overall_signal | type == "string") and (.valuation.overall_signal | type == "string") and (.risk.overall_signal | type == "string")' ;; + "stocks compare "*) + echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.valuation | type == "object"))' ;; + "stocks profile "*) + echo '(.symbol | type == "string") and has("long_name") and (.officers | type == "array")' ;; + "stocks financials "*) + echo '(.instrument.symbol | type == "string") and has("income_statement") and has("balance_sheet") and has("cash_flow")' ;; + "stocks earnings "*) + echo '(.history | type == "array") and (.forecast | type == "array")' ;; + "stocks sentiment "*) + echo '.statistics | type == "array"' ;; + "stocks insights "*) + echo '(.highlights | type == "array") and (.risks | type == "array")' ;; + "stocks news "*) + echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;; + "ownership discover"*) + echo 'type == "array" and length > 0 and (.[0].pdf_url | type == "string")' ;; + "ownership releases") + echo 'type == "array" and length == 1' ;; *) - echo "invalid --mode '$MODE' (expected live, mock, or full)" >&2 - exit 2 - ;; + echo 'true' ;; esac } -prepare_paths() { - if [[ -z "$WORKDIR" ]]; then - WORKDIR="$ROOT_DIR/tmp/live-smoke/$(date +%Y%m%d-%H%M%S)" +run_case() { + local name="$1" cmd="$2" filter log + local -a args + TOTAL=$((TOTAL + 1)) + printf '[%02d] %s\n' "$TOTAL" "$name" + if (( DRY_RUN )); then + printf ' idx -o json %s\n' "$cmd" + return fi - CONFIG_HOME="$WORKDIR/config" - CACHE_HOME="$WORKDIR/cache" - DATA_HOME="$WORKDIR/data" - OWNERSHIP_DB="$WORKDIR/ownership/ownership.db" - LOG_DIR="$WORKDIR/logs" - - BASE_ENV=( - "XDG_CONFIG_HOME=$CONFIG_HOME" - "XDG_CACHE_HOME=$CACHE_HOME" - "XDG_DATA_HOME=$DATA_HOME" - "IDX_OUTPUT=table" - "IDX_NO_COLOR=1" - ) - - if (( DRY_RUN == 0 )); then - mkdir -p "$CONFIG_HOME" "$CACHE_HOME" "$DATA_HOME" "$(dirname "$OWNERSHIP_DB")" "$LOG_DIR" + log="$LOG_DIR/$(printf '%02d' "$TOTAL")_$name" + read -r -a args <<< "$cmd" + if ! env "${BASE_ENV[@]}" "$BIN_PATH" -o json "${args[@]}" >"$log.stdout" 2>"$log.stderr"; then + FAILURES+=("$name: non-zero exit (see $log.stderr)") + echo " FAIL exit" + return fi + filter="$(contract "$cmd")" + if ! jq -e -s "length == 1 and (.[0] | $filter)" "$log.stdout" >/dev/null 2>"$log.validation"; then + FAILURES+=("$name: output shape changed (see $log.stdout)") + echo " FAIL shape" + return + fi + PASSED=$((PASSED + 1)) + echo " PASS" } -build_binary() { - local build_log - local stale_input - +if (( ! DRY_RUN )); then + command -v jq >/dev/null 2>&1 || { echo "jq is required; run inside nix develop" >&2; exit 1; } + mkdir -p "$LOG_DIR" if [[ -z "$BIN_PATH" ]]; then BIN_PATH="$ROOT_DIR/target/debug/idx" + (cd "$ROOT_DIR" && cargo build --quiet --bin idx) || { echo "cargo build failed" >&2; exit 1; } fi + env "${BASE_ENV[@]}" "$BIN_PATH" config set ownership.db_path "$WORKDIR/ownership.db" >/dev/null +fi - if (( DRY_RUN )); then - return 0 - fi - - if (( BUILD )); then - if ! command -v cargo >/dev/null 2>&1; then - if [[ -x "$BIN_PATH" ]]; then - printf 'info: cargo not found; reusing existing binary at %s\n' "$BIN_PATH" - BUILD=0 - else - echo "cargo not found; run inside nix develop or pass --bin/--no-build with an existing idx binary" >&2 - exit 1 - fi - fi - fi - - if (( BUILD == 0 )) && [[ "$BIN_PATH" == "$ROOT_DIR/target/debug/idx" && -x "$BIN_PATH" ]]; then - stale_input="$( - find \ - "$ROOT_DIR/src" \ - "$ROOT_DIR/tests" \ - "$ROOT_DIR/Cargo.toml" \ - "$ROOT_DIR/Cargo.lock" \ - "$ROOT_DIR/scripts/live-smoke.sh" \ - -type f \ - -newer "$BIN_PATH" \ - -print \ - -quit \ - 2>/dev/null - )" - if [[ -n "$stale_input" ]]; then - echo "refusing to run smoke checks against stale $BIN_PATH; newer input detected at $stale_input" >&2 - echo "rebuild first or omit --no-build so the runner refreshes target/debug/idx" >&2 - exit 1 - fi - fi - - if (( BUILD )); then - build_log="$LOG_DIR/build.log" - if ! ( - cd "$ROOT_DIR" || exit 1 - cargo build --quiet --bin idx - ) >"$build_log" 2>&1; then - echo "build failed; see $build_log" >&2 - exit 1 - fi - fi - - if [[ ! -x "$BIN_PATH" ]]; then - echo "idx binary not found or not executable: $BIN_PATH" >&2 - exit 1 - fi -} - -bootstrap_case() { - local label="$1" - shift - local log_file="$LOG_DIR/bootstrap-${label}.log" - - if (( DRY_RUN )); then - printf 'bootstrap %-18s %s\n' "$label" "$*" - return 0 - fi - - if ! ( - cd "$ROOT_DIR" || exit 1 - env "${BASE_ENV[@]}" "$BIN_PATH" "$@" - ) >"$log_file" 2>&1; then - echo "bootstrap failed for $label; see $log_file" >&2 - exit 1 - fi -} - -prepare_environment() { - bootstrap_case "config-init" config init - bootstrap_case "ownership-db" config set ownership.db_path "$OWNERSHIP_DB" -} - -discover_ownership_import_url() { - local family="$1" - local log_file="$LOG_DIR/bootstrap-discover-${family}.log" - local url - - if (( DRY_RUN )); then - case "$family" in - above1) printf 'https://example.invalid/above1-lamp1.pdf' ;; - above5) printf 'https://example.invalid/above5-lamp1.pdf' ;; - investor-type) printf 'https://example.invalid/investor-type-lamp1.pdf' ;; - *) return 1 ;; - esac - return 0 - fi - - if ! ( - cd "$ROOT_DIR" || exit 1 - env "${BASE_ENV[@]}" "$BIN_PATH" -o json ownership discover --family "$family" --limit 1 - ) >"$log_file" 2>&1; then - echo "bootstrap failed for ownership-discover-$family; see $log_file" >&2 - exit 1 - fi - - url="$(grep -m1 '"pdf_url"' "$log_file" | sed -E 's/.*"pdf_url": "([^"]+)".*/\1/')" - if [[ -z "$url" ]]; then - echo "bootstrap failed for ownership-discover-$family: could not parse pdf_url from $log_file" >&2 - exit 1 - fi - - printf '%s' "$url" -} - -prepare_ownership_import_inputs() { - if ! should_run_group "ownership-import"; then - return 0 - fi - - OWNERSHIP_ABOVE1_URL="$(discover_ownership_import_url above1)" - OWNERSHIP_ABOVE5_URL="$(discover_ownership_import_url above5)" - OWNERSHIP_INVESTOR_TYPE_URL="$(discover_ownership_import_url investor-type)" -} - -cache_case_starts_fresh() { - local group="$1" - local label="$2" - - [[ "$group" == "cache" && "$label" == *"-warm" ]] -} - -reset_cache_for_case() { - local label="$1" - - # Cache checks need a clean starting point so the warm/offline/stale trio - # validates TTL-sensitive fallback behavior instead of reusing prior group state. - bootstrap_case "cache-reset-${label}" cache clear -} - -sanitize_name() { - printf '%s' "$1" | tr -cs 'A-Za-z0-9._-' '_' -} - -command_display() { - local env_spec="$1" - local cmd_spec="$2" - - printf 'env %s' "${BASE_ENV[*]}" - if [[ -n "$env_spec" ]]; then - printf ' %s' "$env_spec" - fi - printf ' %s %s' "$BIN_PATH" "$cmd_spec" -} - -json_contract() { - if [[ "$2" -ne 0 ]]; then - printf '%s\n' '.error == true and (.code | type == "string") and (.message | type == "string")' - return - fi - - case "$1" in - *"stocks quote "*|*"stocks screen"*) - printf '%s\n' 'type == "array" and all(.[]; (.symbol | type == "string") and (.price | type == "number") and has("change_pct"))' ;; - *"stocks history "*) - printf '%s\n' 'type == "array" and all(.[]; (.date | type == "string") and (.close | type == "number") and (.volume | type == "number"))' ;; - *"stocks technical "*) - printf '%s\n' '(.symbol | type == "string") and (.current_price | type == "number") and has("sma200") and (.signals.overall | type == "string")' ;; - *"stocks growth "*) - printf '%s\n' 'has("revenue_growth_pct") and has("earnings_growth_pct") and (.overall_signal | type == "string")' ;; - *"stocks valuation "*) - printf '%s\n' 'has("pe_trailing") and has("roe_pct") and (.overall_signal | type == "string")' ;; - *"stocks risk "*) - printf '%s\n' 'has("debt_to_equity") and has("roa_pct") and (.overall_signal | type == "string")' ;; - *"stocks fundamental "*) - printf '%s\n' '(.symbol | type == "string") and (.growth.overall_signal | type == "string") and (.valuation.overall_signal | type == "string") and (.risk.overall_signal | type == "string")' ;; - *"stocks compare "*) - printf '%s\n' 'type == "array" and all(.[]; (.symbol | type == "string") and (.growth | type == "object") and (.valuation | type == "object") and (.risk | type == "object"))' ;; - *"stocks profile "*) - printf '%s\n' '(.symbol | type == "string") and has("long_name") and has("country") and (.officers | type == "array")' ;; - *"stocks financials "*) - printf '%s\n' '(.instrument.symbol | type == "string") and has("income_statement") and has("balance_sheet") and has("cash_flow")' ;; - *"stocks earnings "*) - printf '%s\n' '(.symbol | type == "string") and (.history | type == "array") and (.forecast | type == "array")' ;; - *"stocks sentiment "*) - printf '%s\n' '(.symbol | type == "string") and (.statistics | type == "array") and all(.statistics[]; (.bullish | type == "number") and (.bearish | type == "number") and (.neutral | type == "number"))' ;; - *"stocks insights "*) - printf '%s\n' '(.symbol | type == "string") and (.highlights | type == "array") and (.risks | type == "array")' ;; - *"stocks news "*) - printf '%s\n' 'type == "array" and all(.[]; (.symbol | type == "string") and (.title | type == "string") and (.url | type == "string"))' ;; - *) - printf '%s\n' 'type == "object" or type == "array"' ;; - esac -} - -run_case_line() { - local line="$1" - local group label expected_exit env_spec cmd_spec expect_spec json_expect - local safe_name log_file stdout_file stderr_file output_file status contract baseline_file - local failure="" - local -a env_parts cmd_parts - - IFS="$SEP" read -r group label expected_exit env_spec cmd_spec expect_spec json_expect <<< "$line" - - if ! should_run_group "$group"; then - return 0 - fi - - TOTAL_CASES=$((TOTAL_CASES + 1)) - safe_name="$(sanitize_name "${group}-${label}")" - log_file="$(printf '%s/%03d_%s' "$LOG_DIR" "$TOTAL_CASES" "$safe_name")" - stdout_file="$log_file.stdout" - stderr_file="$log_file.stderr" - - printf '[%02d] %-10s %s\n' "$TOTAL_CASES" "$group" "$label" - - if cache_case_starts_fresh "$group" "$label"; then - reset_cache_for_case "$label" - fi - - if (( DRY_RUN )); then - printf ' %s\n' "$(command_display "$env_spec" "$cmd_spec")" - return 0 - fi - - env_parts=() - cmd_parts=() - - if [[ -n "$env_spec" ]]; then - read -r -a env_parts <<< "$env_spec" - fi - read -r -a cmd_parts <<< "$cmd_spec" - - ( - cd "$ROOT_DIR" || exit 1 - env "${BASE_ENV[@]}" "${env_parts[@]}" "$BIN_PATH" "${cmd_parts[@]}" - ) >"$stdout_file" 2>"$stderr_file" - status=$? - - output_file="$stdout_file" - if [[ "$status" -ne 0 ]]; then - output_file="$stderr_file" - fi - - if [[ "$status" -ne "$expected_exit" ]]; then - failure="exit $status, expected $expected_exit" - elif [[ "$status" -ne 0 && -s "$stdout_file" ]]; then - failure="error command wrote to stdout" - elif [[ -n "$expect_spec" ]] && ! grep -Fq -- "$expect_spec" "$output_file"; then - failure="missing '$expect_spec' in expected output stream" - elif [[ " $cmd_spec " == *" -o json "* ]]; then - contract="$(json_contract "$cmd_spec" "$status")" - if ! jq -e -s "length == 1 and (.[0] | ($contract) and (${json_expect:-true}))" \ - "$output_file" > /dev/null 2>"$log_file.validation"; then - failure="invalid JSON document or output contract" - fi - elif [[ "$label" == "completions-bash" ]] && ! bash -n "$stdout_file"; then - failure="invalid Bash completion script" - fi - - if [[ -z "$failure" && "$group" == "cache" ]]; then - baseline_file="$LOG_DIR/cache-${label%%-*}-baseline.json" - if [[ "$label" == *"-warm" ]]; then - jq -S . "$stdout_file" >"$baseline_file" - elif ! jq -S . "$stdout_file" | cmp -s "$baseline_file" -; then - failure="offline/stale data differs from the warmed cache" - elif [[ ! -s "$stderr_file" ]]; then - failure="expired-cache diagnostic missing from stderr" - fi - fi - - if [[ -n "$failure" ]]; then - FAILED_CASES=$((FAILED_CASES + 1)) - FAILURES+=("$group/$label ($failure) -> $log_file.{stdout,stderr,validation}") - printf ' FAIL %s logs=%s.{stdout,stderr,validation}\n' "$failure" "$log_file" - return 0 - fi - - PASSED_CASES=$((PASSED_CASES + 1)) - printf ' PASS logs=%s.{stdout,stderr}\n' "$log_file" -} - -main() { - local case_line - - parse_args "$@" - prepare_paths - if (( DRY_RUN == 0 )) && ! command -v jq >/dev/null 2>&1; then - echo "jq is required for smoke output validation; run inside nix develop" >&2 - exit 1 - fi - - printf 'mode: %s\n' "$MODE" - printf 'workdir: %s\n' "$WORKDIR" - printf 'symbol: %s\n' "$SYMBOL" - printf 'compare symbols: %s\n' "$COMPARE_SYMBOLS" - if [[ ${#GROUP_FILTERS[@]} -gt 0 ]]; then - printf 'group filter: %s\n' "${GROUP_FILTERS[*]}" - fi - - build_binary - prepare_environment - prepare_ownership_import_inputs - register_cases - - if should_run_group "ownership-import"; then - printf 'ownership above1 url: %s\n' "$OWNERSHIP_ABOVE1_URL" - printf 'ownership above5 url: %s\n' "$OWNERSHIP_ABOVE5_URL" - printf 'ownership investor-type url: %s\n' "$OWNERSHIP_INVESTOR_TYPE_URL" - fi - - for case_line in "${CASES[@]}"; do - run_case_line "$case_line" +if selected stocks; then + for cmd in quote technical growth valuation risk fundamental profile financials earnings sentiment insights; do + run_case "$cmd" "stocks $cmd $SYMBOL" done + run_case history "stocks history $SYMBOL --period 3mo" + run_case news "stocks news $SYMBOL --limit 5" + run_case screen "stocks screen --filter top-performers --limit 10" + run_case compare "stocks compare $SYMBOL,BBRI" + # BUMI/ADRO/AIMS return non-finite MSN key ratios (Infinity/NaN). + run_case compare-nonfinite "stocks compare BUMI,ADRO,AIMS" +fi - if (( DRY_RUN )); then - printf '\ndry-run complete: %d case(s) selected\n' "$TOTAL_CASES" - exit 0 +if selected ownership; then + run_case discover "ownership discover --limit 1" + if (( ! DRY_RUN )) && [[ -s "$LOG_DIR/$(printf '%02d' "$TOTAL")_discover.stdout" ]]; then + url="$(jq -r '.[0].pdf_url' "$LOG_DIR/$(printf '%02d' "$TOTAL")_discover.stdout")" + run_case import "ownership import --url $url" + run_case releases "ownership releases" fi +fi - printf '\nsummary: %d passed, %d failed, %d total\n' "$PASSED_CASES" "$FAILED_CASES" "$TOTAL_CASES" - printf 'logs: %s\n' "$LOG_DIR" - - if (( FAILED_CASES > 0 )); then - printf 'failures:\n' - printf ' - %s\n' "${FAILURES[@]}" - exit 1 - fi -} - -main "$@" +(( DRY_RUN )) && { echo "dry-run: $TOTAL case(s)"; exit 0; } +printf '\nsummary: %d passed, %d failed\nlogs: %s\n' "$PASSED" "$((TOTAL - PASSED))" "$LOG_DIR" +if (( ${#FAILURES[@]} > 0 )); then + printf ' - %s\n' "${FAILURES[@]}" + exit 1 +fi