From 26ffd445e8a1a4ce71e0a65a0980cca507ad6251 Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 3 Oct 2026 16:22:11 +0000 Subject: [PATCH] ci(nix): unify cached verification and strengthen packaged smoke Run deterministic installer and publisher checks in Nix, align dev and release dependency artifacts, narrow derivation inputs, and use content-aware CI cache keys. Replace duplicate Cargo gates with a Nix pre-push gate; validate JSON streams and cache data in representative packaged-binary smoke cases. --- .github/workflows/ci.yml | 44 ++-- .pre-commit-config.yaml | 22 +- flake.nix | 95 +++++++-- scripts/install-sh-test.sh | 7 +- scripts/live-smoke.sh | 226 +++++++++++++-------- scripts/publish-ownership-snapshot-test.sh | 12 +- 6 files changed, 248 insertions(+), 158 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef6e4fe..7ee893e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,15 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: - # Everything that compiles Rust runs as a `nix flake check` derivation - # (fmt, clippy, tests, package verify, build, offline smoke). Crane splits - # the crate dependency build into its own derivation, and the Nix store is - # restored from the GitHub Actions cache, so unchanged derivations are - # substituted instead of rebuilt. + # The same complete offline gate is used locally and in CI. nix: runs-on: ubuntu-latest @@ -24,44 +27,27 @@ jobs: - name: Restore and save Nix store uses: nix-community/cache-nix-action@v7 with: - primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }} - restore-prefixes-first-match: nix-${{ runner.os }}- + primary-key: nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}-${{ hashFiles('src/**', 'tests/**', 'scripts/**', 'install.sh', 'README.md', 'LICENSE') }} + restore-prefixes-first-match: | + nix-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'Cargo.lock', 'Cargo.toml') }}- + nix-v2-${{ runner.os }}-${{ runner.arch }}- gc-max-store-size-linux: 8G - name: Flake check run: nix flake check -L - - name: Run default app - run: nix run .#default -- version - - - name: Publish ownership snapshot tests - run: scripts/publish-ownership-snapshot-test.sh - - - name: Build ownership publisher - run: | - nix build .#ownership-publisher - ./result/bin/idx-ownership-publish --help - + # Keep native BSD/macOS tools in the installer portability check; the Linux + # sh/dash scenarios and ShellCheck are covered by the flake. install-script: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-14] + os: [macos-14] steps: - name: Checkout uses: actions/checkout@v4 - - name: ShellCheck - if: runner.os == 'Linux' - run: | - shellcheck -s sh install.sh - shellcheck scripts/install-sh-test.sh - - name: Test install.sh with sh run: scripts/install-sh-test.sh - - - name: Test install.sh with dash - if: runner.os == 'Linux' - run: INSTALL_SH_SHELL=dash scripts/install-sh-test.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4a4a19..e9b1d3c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,24 +7,10 @@ repos: - repo: local hooks: - - id: cargo-fmt - name: cargo fmt - entry: cargo fmt --check + - id: nix-flake-check + name: nix flake check + entry: nix flake check -L language: system - types: [rust] - pass_filenames: false - - - id: cargo-clippy - name: cargo clippy - entry: cargo clippy -- -D warnings - language: system - types: [rust] - pass_filenames: false - - - id: cargo-test - name: cargo test - entry: cargo nextest run - language: system - types: [rust] pass_filenames: false + always_run: true stages: [pre-push] diff --git a/flake.nix b/flake.nix index 8a9d2e5..a1a3a32 100644 --- a/flake.nix +++ b/flake.nix @@ -26,14 +26,18 @@ curl-impersonate mupdf ]; - # Only the crate itself (manifests, src/, tests/), so doc or script - # edits do not invalidate the Rust derivations. + # Keep integration-test edits out of the installed package's inputs. + # Fixtures stay here because provider code embeds some of them. rustFiles = [ ./Cargo.toml ./Cargo.lock ./src - ./tests + ./tests/fixtures ]; + testSrc = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions (rustFiles ++ [ ./tests ]); + }; src = lib.fileset.toSource { root = ./.; fileset = lib.fileset.unions rustFiles; @@ -42,15 +46,21 @@ inherit src; pname = cargoManifest.package.name; version = cargoManifest.package.version; + CARGO_PROFILE = "dev"; strictDeps = true; nativeBuildInputs = with pkgs; [ pkg-config ]; buildInputs = with pkgs; [ openssl ]; }; - # Dependency-only build keyed on Cargo.toml/Cargo.lock: reused from the - # Nix store (or binary cache) until the dependency graph changes. - cargoArtifacts = craneLib.buildDepsOnly commonArgs; + # Checks and cargo package verification use dev artifacts; the shipped + # application has a separate release dependency cache. + cargoArtifacts = craneLib.buildDepsOnly (commonArgs // { src = testSrc; }); + releaseCargoArtifacts = craneLib.buildDepsOnly (commonArgs // { + CARGO_PROFILE = "release"; + doCheck = false; + }); idxPackage = craneLib.buildPackage (commonArgs // { - inherit cargoArtifacts; + cargoArtifacts = releaseCargoArtifacts; + CARGO_PROFILE = "release"; doCheck = false; nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ pkgs.makeWrapper ]; postInstall = '' @@ -106,6 +116,30 @@ ''; meta.mainProgram = "idx-ownership-publish"; }; + installerSrc = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ ./install.sh ./scripts/install-sh-test.sh ]; + }; + installerCheck = shell: pkgs.runCommand "idx-installer-${shell}" { + nativeBuildInputs = with pkgs; [ + bash coreutils curl dash findutils gnugrep gnused gawk python3 + ]; + } '' + cp -r ${installerSrc}/. . + chmod -R u+w scripts + patchShebangs scripts + export HOME="$TMPDIR" + INSTALL_SH_SHELL=${shell} bash scripts/install-sh-test.sh + touch "$out" + ''; + publisherTestSrc = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./scripts/publish-ownership-snapshot-test.sh + ./scripts/publish-ownership-snapshot.sh + ./scripts/build-ownership-snapshot.sh + ]; + }; in { packages.default = idxPackage; @@ -117,14 +151,23 @@ }; checks = { default = idxPackage; - ownership-publisher = ownershipPublisher; - fmt = craneLib.cargoFmt { inherit src; }; + ownership-publisher = pkgs.runCommand "idx-ownership-publisher-smoke" { } '' + ${ownershipPublisher}/bin/idx-ownership-publish --help + touch "$out" + ''; + fmt = craneLib.cargoFmt { src = testSrc; }; clippy = craneLib.cargoClippy (commonArgs // { inherit cargoArtifacts; + src = testSrc; + doInstallCargoArtifacts = false; + installPhaseCommand = "touch $out"; cargoClippyExtraArgs = "--all-targets -- -D warnings"; }); test = craneLib.cargoTest (commonArgs // { inherit cargoArtifacts; + src = testSrc; + doInstallCargoArtifacts = false; + installPhaseCommand = "touch $out"; # Ownership discover tests point IDX_CURL_IMPERSONATE_BIN at plain # `curl` against a local fixture server. nativeCheckInputs = [ pkgs.curl ]; @@ -137,6 +180,7 @@ src = lib.fileset.toSource { root = ./.; fileset = lib.fileset.unions (rustFiles ++ [ + ./tests ./LICENSE ./README.md ]); @@ -146,11 +190,12 @@ installPhaseCommand = "touch $out"; }); smoke-mock = pkgs.runCommand "idx-smoke-mock" - { nativeBuildInputs = with pkgs; [ bash coreutils findutils gnugrep gnused gawk ]; } + { nativeBuildInputs = with pkgs; [ bash coreutils diffutils findutils gnugrep gnused gawk jq ]; } '' - cp -r ${./scripts} scripts - chmod -R u+w scripts - patchShebangs scripts + 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 @@ -159,6 +204,28 @@ bash scripts/live-smoke.sh --bin ${idxPackage}/bin/idx --no-build --mode mock touch "$out" ''; + install-script-sh = installerCheck "sh"; + install-script-dash = installerCheck "dash"; + shellcheck = pkgs.runCommand "idx-shellcheck" { + nativeBuildInputs = [ pkgs.shellcheck ]; + } '' + shellcheck -s sh ${installerSrc}/install.sh + shellcheck ${installerSrc}/scripts/install-sh-test.sh + shellcheck ${./scripts/live-smoke.sh} + touch "$out" + ''; + publisher-test = pkgs.runCommand "idx-publisher-test" { + nativeBuildInputs = with pkgs; [ + bash coreutils findutils gawk gnugrep gnused jq sqlite + ]; + } '' + cp -r ${publisherTestSrc}/. . + chmod -R u+w scripts + patchShebangs scripts + export HOME="$TMPDIR" + bash scripts/publish-ownership-snapshot-test.sh + touch "$out" + ''; }; devShells.default = pkgs.mkShell { @@ -169,6 +236,8 @@ cargo-watch cargo-nextest prek + jq + diffutils ] ++ runtimeDeps; shellHook = '' diff --git a/scripts/install-sh-test.sh b/scripts/install-sh-test.sh index b4ab006..4bcff0e 100755 --- a/scripts/install-sh-test.sh +++ b/scripts/install-sh-test.sh @@ -7,6 +7,7 @@ set -Eeuo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" installer="${repo_root}/install.sh" test_shell="${INSTALL_SH_SHELL:-sh}" +fixture_shell="$(command -v sh)" work="$(mktemp -d "${TMPDIR:-/tmp}/idx-install-test.XXXXXX")" server_pid= @@ -73,7 +74,7 @@ EOF make_release() { local tag="$1" dir="${root}/gh/0xrsydn/idx-cli/releases/download/$1" mkdir -p "$dir" - printf '#!/bin/sh\necho "idx fake %s"\n' "$tag" >"${dir}/${asset}" + printf '#!%s\necho "idx fake %s"\n' "$fixture_shell" "$tag" >"${dir}/${asset}" chmod +x "${dir}/${asset}" (cd "$dir" && echo "$(sha256 "$asset") ${asset}" >SHA256SUMS) } @@ -83,7 +84,7 @@ make_release v0.2.4 # v0.0.9: checksum does not match the binary. bad="${root}/gh/0xrsydn/idx-cli/releases/download/v0.0.9" mkdir -p "$bad" -printf '#!/bin/sh\necho tampered\n' >"${bad}/${asset}" +printf '#!%s\necho tampered\n' "$fixture_shell" >"${bad}/${asset}" printf '%064d %s\n' 0 "$asset" >"${bad}/SHA256SUMS" port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')" @@ -151,7 +152,7 @@ fi shim="${work}/shim" mkdir -p "$shim" # shellcheck disable=SC2016 # literal $1 belongs to the shim -printf '#!/bin/sh\ncase "$1" in -s) echo FreeBSD ;; *) echo amd64 ;; esac\n' >"${shim}/uname" +printf '#!%s\ncase "$1" in -s) echo FreeBSD ;; *) echo amd64 ;; esac\n' "$fixture_shell" >"${shim}/uname" chmod +x "${shim}/uname" if run_installer env PATH="${shim}:${PATH}" "$test_shell" "$installer" --dir "${work}/bsd" >"${work}/out6" 2>&1; then fail "rejects unsupported OS (installer succeeded)" diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 18b928a..ac61574 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -57,7 +57,7 @@ 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 commands in table + 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 @@ -139,7 +139,7 @@ should_run_group() { } add_case() { - CASES+=("$1$SEP$2$SEP$3$SEP$4$SEP$5$SEP$6") + CASES+=("$1$SEP$2$SEP$3$SEP$4$SEP$5$SEP$6$SEP${7:-}") } register_cases() { @@ -154,19 +154,13 @@ register_cases() { 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" "" "version" "" + 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" "completions-zsh" "0" "" "completions zsh" "" - add_case "general" "completions-fish" "0" "" "completions fish" "" - add_case "general" "config-path" "0" "" "config path" "" - add_case "general" "config-init" "0" "" "config init" "" 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" "config-set-db-path" "0" "" "config set ownership.db_path $OWNERSHIP_DB" "" - add_case "general" "config-get-db-path" "0" "" "config get ownership.db_path" "$OWNERSHIP_DB" - add_case "general" "cache-info" "0" "" "cache info" "" - add_case "general" "cache-clear" "0" "" "cache clear" "" + 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" "" @@ -200,64 +194,51 @@ register_cases() { 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" "\"overall_signal\"" - add_case "live-nonfinite" "valuation-adro" "0" "$live_env" "-o json stocks valuation ADRO" "\"overall_signal\"" - add_case "live-nonfinite" "valuation-aims" "0" "$live_env" "-o json stocks valuation AIMS" "\"overall_signal\"" - add_case "live-nonfinite" "compare-bad-tickers" "0" "$live_env" "-o json stocks compare BUMI,ADRO,AIMS" "\"symbol\": \"BUMI.JK\"" + 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" "" - add_case "mock" "quote-json" "0" "$mock_env" "-o json stocks quote $live_symbol" "" - add_case "mock" "history-table" "0" "$mock_env" "$history_args" "" - add_case "mock" "history-json" "0" "$mock_env" "-o json $history_args" "" - add_case "mock" "technical-table" "0" "$mock_env" "stocks technical $live_symbol" "" - add_case "mock" "technical-json" "0" "$mock_env" "-o json stocks technical $live_symbol" "" - add_case "mock" "growth-table" "0" "$mock_env" "stocks growth $live_symbol" "" - add_case "mock" "growth-json" "0" "$mock_env" "-o json stocks growth $live_symbol" "" - add_case "mock" "valuation-table" "0" "$mock_env" "stocks valuation $live_symbol" "" - add_case "mock" "valuation-json" "0" "$mock_env" "-o json stocks valuation $live_symbol" "" - add_case "mock" "risk-table" "0" "$mock_env" "stocks risk $live_symbol" "" - add_case "mock" "risk-json" "0" "$mock_env" "-o json stocks risk $live_symbol" "" - add_case "mock" "fundamental-table" "0" "$mock_env" "stocks fundamental $live_symbol" "" - add_case "mock" "fundamental-json" "0" "$mock_env" "-o json stocks fundamental $live_symbol" "" - add_case "mock" "compare-table" "0" "$mock_env" "stocks compare $compare_args" "" - add_case "mock" "compare-json" "0" "$mock_env" "-o json stocks compare $compare_args" "" - add_case "mock" "profile-table" "0" "$mock_env" "stocks profile $live_symbol" "" - add_case "mock" "profile-json" "0" "$mock_env" "-o json stocks profile $live_symbol" "" - add_case "mock" "financials-table" "0" "$mock_env" "stocks financials $live_symbol" "" - add_case "mock" "financials-json" "0" "$mock_env" "-o json stocks financials $live_symbol" "" - add_case "mock" "earnings-table" "0" "$mock_env" "stocks earnings $live_symbol" "" - add_case "mock" "earnings-json" "0" "$mock_env" "-o json stocks earnings $live_symbol" "" - add_case "mock" "sentiment-table" "0" "$mock_env" "stocks sentiment $live_symbol" "" - add_case "mock" "sentiment-json" "0" "$mock_env" "-o json stocks sentiment $live_symbol" "" - add_case "mock" "insights-table" "0" "$mock_env" "stocks insights $live_symbol" "" - add_case "mock" "insights-json" "0" "$mock_env" "-o json stocks insights $live_symbol" "" - add_case "mock" "news-table" "0" "$mock_env" "$news_args" "" - add_case "mock" "news-json" "0" "$mock_env" "-o json $news_args" "" - add_case "mock" "screen-table" "0" "$mock_env" "$screen_args" "" - add_case "mock" "screen-json" "0" "$mock_env" "-o json $screen_args" "" + 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" "stocks quote $live_symbol" "" - add_case "cache" "quote-offline" "0" "$quote_zero_ttl_env" "--offline stocks quote $live_symbol" "" - add_case "cache" "quote-stale-fallback" "0" "$quote_zero_ttl_env IDX_MOCK_ERROR=1" "stocks quote $live_symbol" "serving stale cache" - add_case "cache" "technical-warm" "0" "$quote_zero_ttl_env" "stocks technical $live_symbol" "" - add_case "cache" "technical-offline" "0" "$quote_zero_ttl_env" "--offline stocks technical $live_symbol" "" - add_case "cache" "technical-stale-fallback" "0" "$quote_zero_ttl_env IDX_MOCK_ERROR=1" "stocks technical $live_symbol" "serving stale cache" - add_case "cache" "profile-warm" "0" "$profile_zero_ttl_env" "stocks profile $live_symbol" "" - add_case "cache" "profile-offline" "0" "$profile_zero_ttl_env" "--offline stocks profile $live_symbol" "" - add_case "cache" "profile-stale-fallback" "0" "$profile_zero_ttl_env IDX_MOCK_ERROR=1" "stocks profile $live_symbol" "serving stale cache" + 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" "stocks quote $live_symbol" "" - add_case "routing" "auto-history-fallback" "0" "$mock_env" "$history_args" "" - add_case "routing" "auto-technical-fallback" "0" "$mock_env" "stocks technical $live_symbol" "" - add_case "routing" "explicit-msn-history" "0" "$mock_env" "stocks history $live_symbol --period 3mo --history-provider msn" "History for" + 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" "\"error\": true" - add_case "errors" "profile-provider-gate-json" "1" "IDX_PROVIDER=yahoo" "-o json stocks profile $live_symbol" "requires --provider msn" - add_case "errors" "invalid-screen-region-json" "1" "$mock_env" "-o json stocks screen --region eu" "invalid screener region" - add_case "errors" "offline-no-cache" "1" "IDX_USE_MOCK_PROVIDER=1" "--offline --no-cache stocks quote $live_symbol" "cannot combine --offline with --no-cache" + 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" "" "ownership releases" "No ownership releases imported yet." - add_case "ownership" "fetch-bing-unsupported" "1" "" "ownership import --fetch-bing $live_symbol" "--fetch-bing import is not implemented yet" + 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 " @@ -402,11 +383,10 @@ build_binary() { if (( BUILD )); then build_log="$LOG_DIR/build.log" - ( + if ! ( cd "$ROOT_DIR" || exit 1 cargo build --quiet --bin idx - ) >"$build_log" 2>&1 - if [[ $? -ne 0 ]]; then + ) >"$build_log" 2>&1; then echo "build failed; see $build_log" >&2 exit 1 fi @@ -428,12 +408,10 @@ bootstrap_case() { return 0 fi - ( + if ! ( cd "$ROOT_DIR" || exit 1 env "${BASE_ENV[@]}" "$BIN_PATH" "$@" - ) >"$log_file" 2>&1 - - if [[ $? -ne 0 ]]; then + ) >"$log_file" 2>&1; then echo "bootstrap failed for $label; see $log_file" >&2 exit 1 fi @@ -459,12 +437,10 @@ discover_ownership_import_url() { 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 - - if [[ $? -ne 0 ]]; then + ) >"$log_file" 2>&1; then echo "bootstrap failed for ownership-discover-$family; see $log_file" >&2 exit 1 fi @@ -518,13 +494,54 @@ command_display() { 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 - local safe_name log_file status + 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 <<< "$line" + 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 @@ -532,7 +549,9 @@ run_case_line() { TOTAL_CASES=$((TOTAL_CASES + 1)) safe_name="$(sanitize_name "${group}-${label}")" - log_file="$(printf '%s/%03d_%s.log' "$LOG_DIR" "$TOTAL_CASES" "$safe_name")" + 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" @@ -556,25 +575,50 @@ run_case_line() { ( cd "$ROOT_DIR" || exit 1 env "${BASE_ENV[@]}" "${env_parts[@]}" "$BIN_PATH" "${cmd_parts[@]}" - ) >"$log_file" 2>&1 + ) >"$stdout_file" 2>"$stderr_file" status=$? - if [[ "$status" -ne "$expected_exit" ]]; then - FAILED_CASES=$((FAILED_CASES + 1)) - FAILURES+=("$group/$label (exit $status, expected $expected_exit) -> $log_file") - printf ' FAIL exit=%s expected=%s log=%s\n' "$status" "$expected_exit" "$log_file" - return 0 + output_file="$stdout_file" + if [[ "$status" -ne 0 ]]; then + output_file="$stderr_file" fi - if [[ -n "$expect_spec" ]] && ! grep -Fq -- "$expect_spec" "$log_file"; then + 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 (missing '$expect_spec') -> $log_file") - printf ' FAIL missing=%s log=%s\n' "$expect_spec" "$log_file" + 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 log=%s\n' "$log_file" + printf ' PASS logs=%s.{stdout,stderr}\n' "$log_file" } main() { @@ -582,6 +626,10 @@ main() { 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" diff --git a/scripts/publish-ownership-snapshot-test.sh b/scripts/publish-ownership-snapshot-test.sh index 6e0a53e..3226c7e 100755 --- a/scripts/publish-ownership-snapshot-test.sh +++ b/scripts/publish-ownership-snapshot-test.sh @@ -32,8 +32,8 @@ cp "$repo_root/scripts/publish-ownership-snapshot.sh" "$publisher" chmod +x "$publisher" # --- fake gh --------------------------------------------------------------- -cat >"$bin_dir/gh" <<'FAKE_GH' -#!/usr/bin/env bash +printf '#!%s\n' "$BASH" >"$bin_dir/gh" +cat >>"$bin_dir/gh" <<'FAKE_GH' set -euo pipefail state="${FAKE_GH_STATE:?}" remote="$state/remote" @@ -144,8 +144,8 @@ FAKE_GH chmod +x "$bin_dir/gh" # --- fake idx -------------------------------------------------------------- -cat >"$bin_dir/idx" <<'FAKE_IDX' -#!/usr/bin/env bash +printf '#!%s\n' "$BASH" >"$bin_dir/idx" +cat >>"$bin_dir/idx" <<'FAKE_IDX' set -euo pipefail if [[ "${1:-}" == "version" ]]; then exit 0 @@ -160,8 +160,8 @@ FAKE_IDX chmod +x "$bin_dir/idx" # --- stub builder ---------------------------------------------------------- -cat >"$scripts_dir/build-latest-ownership-snapshot.sh" <<'STUB_BUILDER' -#!/usr/bin/env bash +printf '#!%s\n' "$BASH" >"$scripts_dir/build-latest-ownership-snapshot.sh" +cat >>"$scripts_dir/build-latest-ownership-snapshot.sh" <<'STUB_BUILDER' set -euo pipefail out="" history="0"