From 8d59969d661276f4523386f49892f46b11442a94 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Wed, 21 Oct 2026 01:26:49 +0100 Subject: [PATCH 1/4] ci(stellar): make testnet snippet validation blocking (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate deterministic content checks from live provider availability so that real doc regressions block merges while Horizon outages do not. Exit-code contract (check-stellar-testnet-snippets.ts): 0 – all checks passed 1 – content regression (always blocks): file unreadable, snippet count below threshold, reachable endpoint returned wrong shape or 4xx 2 – provider outage (never blocks): DNS/TCP failure, timeout, HTTP 429/5xx from Horizon testnet MODE env-var selects which checks run: content – deterministic file checks only (blocking CI step) provider – live Horizon calls only (non-blocking CI step) all – both (default, used locally) Workflow changes (snippets.yml): - Remove top-level continue-on-error: true from stellar-testnet-snippets - Add blocking 'content' step (MODE=content, no continue-on-error) - Add non-blocking 'provider' step (MODE=provider, continue-on-error: true) emits ::warning:: annotation on outage, ::error:: on content regression - Add guard step that re-fails the job if the provider step found a content regression that continue-on-error would otherwise swallow Closes #187 --- .github/workflows/snippets.yml | 38 ++- scripts/check-stellar-testnet-snippets.ts | 270 ++++++++++++++++++---- 2 files changed, 262 insertions(+), 46 deletions(-) diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml index 2b09c8a..7b85719 100644 --- a/.github/workflows/snippets.yml +++ b/.github/workflows/snippets.yml @@ -137,10 +137,20 @@ jobs: - name: Check extended snippets (Rust / Python / shell / JSON / TOML / YAML) run: pnpm run check:snippets-extended + # --------------------------------------------------------------------------- + # Stellar testnet snippet validation — split into two steps so that + # deterministic content regressions block merges while provider outages + # are surfaced as annotations without blocking. + # + # Exit codes from check-stellar-testnet-snippets.ts: + # 0 – passed + # 1 – content regression → always a hard failure + # 2 – provider outage → warning annotation, does NOT block merge + # --------------------------------------------------------------------------- stellar-testnet-snippets: name: Stellar snippet testnet validation runs-on: ubuntu-latest - continue-on-error: true + # No top-level continue-on-error — individual steps control leniency. needs: extract-sdk-version steps: - name: Checkout @@ -173,9 +183,33 @@ jobs: run: | echo "Testing against SDK version: ${{ needs.extract-sdk-version.outputs.sdk-version }}" - - name: Validate Stellar testnet snippets + # BLOCKING: deterministic content checks only (snippet count, file + # existence). A failure here means a real doc regression. Exit 1. + - name: Validate Stellar snippet content (blocking) + env: + MODE: content + run: pnpm run check:stellar-testnet + + # NON-BLOCKING: live provider availability checks. Failures here are + # provider outages (exit 2) and emit a ::warning:: annotation without + # failing the job. A genuine content-regression finding from a reachable + # provider still exits 1 and fails the job. + - name: Validate Stellar testnet provider availability (non-blocking) + id: provider-check + continue-on-error: true + env: + MODE: provider run: pnpm run check:stellar-testnet + # Distinguish an outage (exit 2, warn only) from a real failure (exit 1, + # which continue-on-error already surfaced as a warning annotation in the + # previous step — re-fail the job here so the PR is blocked). + - name: Fail job on content regression from provider step + if: steps.provider-check.outcome == 'failure' && steps.provider-check.conclusion == 'failure' + run: | + echo "::error::Provider step exited with a content regression (exit 1). Failing the job." + exit 1 + playground-smoke: name: Playground smoke test runs-on: ubuntu-latest diff --git a/scripts/check-stellar-testnet-snippets.ts b/scripts/check-stellar-testnet-snippets.ts index fb11a2c..2efb08d 100644 --- a/scripts/check-stellar-testnet-snippets.ts +++ b/scripts/check-stellar-testnet-snippets.ts @@ -1,7 +1,39 @@ +/** + * check-stellar-testnet-snippets.ts + * + * Two distinct responsibilities, deliberately separated so CI can treat them + * with different blocking semantics: + * + * EXIT 0 – everything passed + * EXIT 1 – CONTENT REGRESSION: deterministic failure (wrong snippet count, + * file unreadable, Horizon response shape mismatch when the + * provider WAS reachable). These BLOCK merges. + * EXIT 2 – PROVIDER OUTAGE: horizon-testnet is unreachable or returned a + * 5xx / rate-limit response. These are reported as a warning + * annotation but do NOT block merges. + * + * The split lets CI run two steps from this same entry-point by passing a + * MODE environment variable: + * + * MODE=content – run only the deterministic content checks (exits 0 or 1) + * MODE=provider – run only the live provider checks (exits 0, 1, or 2) + * MODE=all – run both (default, used locally) + */ + import { readFile } from "node:fs/promises"; -const stellarDocs = "sdk/chains/stellar.mdx"; -const testnetBaseUrl = "https://horizon-testnet.stellar.org"; +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const STELLAR_DOCS = "sdk/chains/stellar.mdx"; +const TESTNET_BASE_URL = "https://horizon-testnet.stellar.org"; +const FETCH_TIMEOUT_MS = 15_000; +const MIN_SNIPPETS = 5; + +/** HTTP status codes that indicate a provider outage rather than a content + * regression. 429 (rate-limit) and 5xx are transient; 4xx < 429 are not. */ +const PROVIDER_OUTAGE_STATUSES = new Set([429, 500, 502, 503, 504]); type HorizonCheck = { name: string; @@ -9,7 +41,7 @@ type HorizonCheck = { validate: (payload: unknown) => boolean; }; -const checks: HorizonCheck[] = [ +const HORIZON_CHECKS: HorizonCheck[] = [ { name: "root", path: "/", @@ -18,29 +50,67 @@ const checks: HorizonCheck[] = [ { name: "latest ledger", path: "/ledgers?order=desc&limit=1", - validate: (payload) => Array.isArray(readPath(payload, ["_embedded", "records"])), + validate: (payload) => + Array.isArray(readPath(payload, ["_embedded", "records"])), }, { name: "fee stats", path: "/fee_stats", - validate: (payload) => hasObject(payload, "fee_charged") && hasObject(payload, "max_fee"), + validate: (payload) => + hasObject(payload, "fee_charged") && hasObject(payload, "max_fee"), }, { name: "native asset", path: "/assets?asset_type=native&limit=1", - validate: (payload) => Array.isArray(readPath(payload, ["_embedded", "records"])), + validate: (payload) => + Array.isArray(readPath(payload, ["_embedded", "records"])), }, { name: "operations", path: "/operations?order=desc&limit=1", - validate: (payload) => Array.isArray(readPath(payload, ["_embedded", "records"])), + validate: (payload) => + Array.isArray(readPath(payload, ["_embedded", "records"])), }, ]; -async function main() { - const markdown = await readFile(stellarDocs, "utf8"); +// --------------------------------------------------------------------------- +// Sentinel error classes +// --------------------------------------------------------------------------- + +/** Thrown when a failure is clearly a content regression (always blocks). */ +class ContentError extends Error { + constructor(message: string) { + super(message); + this.name = "ContentError"; + } +} + +/** Thrown when a failure is a provider outage (never blocks merges). */ +class ProviderOutageError extends Error { + constructor(message: string) { + super(message); + this.name = "ProviderOutageError"; + } +} + +// --------------------------------------------------------------------------- +// Deterministic content checks (exit 1 on failure) +// --------------------------------------------------------------------------- + +async function checkContent(): Promise { + let markdown: string; + try { + markdown = await readFile(STELLAR_DOCS, "utf8"); + } catch (err) { + throw new ContentError( + `Cannot read ${STELLAR_DOCS}: ${(err as Error).message}`, + ); + } + const snippets = Array.from( - markdown.matchAll(/^```(?:ts|tsx|typescript|js|javascript)([^\n]*)\n([\s\S]*?)^```/gm), + markdown.matchAll( + /^```(?:ts|tsx|typescript|js|javascript)([^\n]*)\n([\s\S]*?)^```/gm, + ), ) .map((match, index) => ({ attrs: match[1] ?? "", @@ -50,58 +120,170 @@ async function main() { })) .filter((snippet) => !/\bno-check\b/.test(snippet.attrs)); - if (snippets.length < 5) { - throw new Error(`Expected at least 5 checkable Stellar snippets, found ${snippets.length}.`); + if (snippets.length < MIN_SNIPPETS) { + throw new ContentError( + `Expected at least ${MIN_SNIPPETS} checkable Stellar snippets in ` + + `${STELLAR_DOCS}, found ${snippets.length}. ` + + `If snippets were intentionally removed, update MIN_SNIPPETS in this script.`, + ); } - const selected = snippets.slice(0, 5); - const results = []; + const selected = snippets.slice(0, MIN_SNIPPETS); + return selected.map((s) => `${STELLAR_DOCS}:${s.line}`); +} + +// --------------------------------------------------------------------------- +// Live provider checks (exit 2 on outage, exit 1 on reachable-but-wrong-shape) +// --------------------------------------------------------------------------- + +async function checkProvider(): Promise { + const passed: string[] = []; + + for (const check of HORIZON_CHECKS) { + const url = `${TESTNET_BASE_URL}${check.path}`; + let response: Response; + + try { + response = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + } catch (err) { + // Network-level failure (DNS, TCP, timeout) → outage + throw new ProviderOutageError( + `[provider-outage] "${check.name}" – network error fetching ${url}: ${ + (err as Error).message + }`, + ); + } + + if (PROVIDER_OUTAGE_STATUSES.has(response.status)) { + throw new ProviderOutageError( + `[provider-outage] "${check.name}" – ${url} returned HTTP ${response.status} ` + + `(treated as transient provider outage, not a content regression).`, + ); + } - for (const check of checks) { - const response = await fetch(`${testnetBaseUrl}${check.path}`); if (!response.ok) { - throw new Error(`${check.name} returned HTTP ${response.status}`); + // 4xx other than 429 — the endpoint itself is broken; this is a content issue. + throw new ContentError( + `[content-regression] "${check.name}" – ${url} returned HTTP ${response.status}. ` + + `This is unexpected for a stable Horizon endpoint and indicates a ` + + `documentation or configuration error.`, + ); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (err) { + // Malformed JSON from a 2xx — content regression (Horizon changed shape). + throw new ContentError( + `[content-regression] "${check.name}" – ${url} returned non-JSON body: ${ + (err as Error).message + }`, + ); } - const payload = await response.json(); if (!check.validate(payload)) { - throw new Error(`${check.name} response did not match the expected Horizon shape.`); + throw new ContentError( + `[content-regression] "${check.name}" – response from ${url} did not ` + + `match the expected Horizon shape. ` + + `This suggests the Horizon API changed in a way that breaks our docs.`, + ); } - results.push(check.name); + + passed.push(check.name); } - console.log( - [ - `Stellar snippets selected: ${selected.map((snippet) => `${stellarDocs}:${snippet.line}`).join(", ")}`, - `Stellar testnet checks passed: ${results.join(", ")}`, - ].join("\n"), - ); + return passed; } -function hasString(payload: unknown, key: string) { - return typeof payload === "object" - && payload !== null - && typeof (payload as Record)[key] === "string"; -} +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- -function hasObject(payload: unknown, key: string) { - return typeof payload === "object" - && payload !== null - && typeof (payload as Record)[key] === "object" - && (payload as Record)[key] !== null; +const MODE = (process.env["MODE"] ?? "all").toLowerCase(); +const VALID_MODES = new Set(["all", "content", "provider"]); + +if (!VALID_MODES.has(MODE)) { + console.error( + `Unknown MODE="${MODE}". Valid values: all | content | provider`, + ); + process.exit(1); } -function readPath(payload: unknown, path: string[]) { - return path.reduce((value, key) => { - if (typeof value !== "object" || value === null) { - return undefined; +async function main() { + const runContent = MODE === "all" || MODE === "content"; + const runProvider = MODE === "all" || MODE === "provider"; + + // --- content checks --- + if (runContent) { + let snippetLocations: string[]; + try { + snippetLocations = await checkContent(); + } catch (err) { + if (err instanceof ContentError) { + console.error(`::error::${err.message}`); + process.exit(1); + } + throw err; } + console.log(`Stellar snippets selected: ${snippetLocations.join(", ")}`); + } - return (value as Record)[key]; - }, payload); + // --- provider checks --- + if (runProvider) { + let passedChecks: string[]; + try { + passedChecks = await checkProvider(); + } catch (err) { + if (err instanceof ProviderOutageError) { + // Emit a GitHub Actions warning annotation so it's visible in the PR + // without blocking the merge. + console.warn(`::warning::${err.message}`); + process.exit(2); + } + if (err instanceof ContentError) { + console.error(`::error::${err.message}`); + process.exit(1); + } + throw err; + } + console.log( + `Stellar testnet checks passed: ${passedChecks.join(", ")}`, + ); + } } -main().catch((error) => { - console.error(error); +main().catch((err: unknown) => { + console.error(err); process.exit(1); }); + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function hasString(payload: unknown, key: string): boolean { + return ( + typeof payload === "object" && + payload !== null && + typeof (payload as Record)[key] === "string" + ); +} + +function hasObject(payload: unknown, key: string): boolean { + return ( + typeof payload === "object" && + payload !== null && + typeof (payload as Record)[key] === "object" && + (payload as Record)[key] !== null + ); +} + +function readPath(payload: unknown, path: string[]): unknown { + return path.reduce((value, key) => { + if (typeof value !== "object" || value === null) return undefined; + return (value as Record)[key]; + }, payload); +} From 3f1747016be7859f04fc82918efdf6a2ab51f958 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Wed, 21 Oct 2026 01:30:31 +0100 Subject: [PATCH 2/4] fix(deps): pin follow-redirects >=1.16.1 (GHSA-q3jh-wrc6-jgq8, GHSA-8r9p-f939-6h3c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two high-severity advisories in follow-redirects affect the transitive path @stellar/stellar-sdk > axios > follow-redirects: GHSA-q3jh-wrc6-jgq8 – array-form headers bypass cross-origin credential stripping (<1.16.1) GHSA-8r9p-f939-6h3c – sensitiveHeaders regex escaping can expose credential headers (<1.16.1) @stellar/stellar-sdk@^13.3.0 has not yet released a version that pulls in a patched axios; add a pnpm.overrides entry to force follow-redirects to >=1.16.1 across the whole install until the parent ships a fix. Update DEPENDENCY-POLICY.md to record the override. --- DEPENDENCY-POLICY.md | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/DEPENDENCY-POLICY.md b/DEPENDENCY-POLICY.md index 8c878c2..53ee1bf 100644 --- a/DEPENDENCY-POLICY.md +++ b/DEPENDENCY-POLICY.md @@ -38,7 +38,7 @@ bar as shipped code. | [GHSA-hqr4-qq8f-hg3x](https://github.com/advisories/GHSA-hqr4-qq8f-hg3x) | `stream-json` | `@solana/web3.js` → `jayson` | Same compatibility constraint as above. Affects the JSONC parser and verifier, which `jayson` does not use. | | [GHSA-mjw6-4jj6-33hc](https://github.com/advisories/GHSA-mjw6-4jj6-33hc) | `stream-json` | `@solana/web3.js` → `jayson` | Same compatibility constraint as above. Affects the `Assembler` convenience stream, which `jayson` does not use. | -The high-severity Stellar path findings (`axios`, `toml`) and the Solana path +The high-severity Stellar path findings (`axios`, `follow-redirects`, `toml`) and the Solana path `uuid` finding are fixed by the `pnpm.overrides` entries in `package.json`; see the pull request that added them for the resolution details. diff --git a/package.json b/package.json index 958c5d0..3173bc5 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "pnpm": { "overrides": { "axios@<1.20.0": ">=1.20.0", + "follow-redirects@<1.16.1": ">=1.16.1", "toml@<4.2.0": "^4.2.0", "uuid@<11.1.1": "^11.1.1" }, From b9cb314e44d89bed4b23a3e68406f7335be144e0 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Wed, 21 Oct 2026 01:36:15 +0100 Subject: [PATCH 3/4] fix(deps): update pnpm-lock.yaml for follow-redirects override The previous commit added follow-redirects@<1.16.1 to pnpm.overrides in package.json but did not update pnpm-lock.yaml, causing frozen-lockfile to fail with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. Surgically update the lockfile: - Add follow-redirects@<1.16.1 to the overrides block - Bump resolution from 1.16.0 to 1.16.1 (updated integrity hash) - Update axios snapshot dependency reference to 1.16.1 - Update follow-redirects snapshot entry key to 1.16.1 --- pnpm-lock.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43c3d3..0ed5023 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: axios@<1.20.0: '>=1.20.0' + follow-redirects@<1.16.1: '>=1.16.1' toml@<4.2.0: ^4.2.0 uuid@<11.1.1: ^11.1.1 @@ -498,8 +499,8 @@ packages: feaxios@0.0.23: resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -1042,7 +1043,7 @@ snapshots: axios@1.20.0: dependencies: - follow-redirects: 1.16.0 + follow-redirects: 1.16.1 form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 @@ -1207,7 +1208,7 @@ snapshots: dependencies: is-retry-allowed: 3.0.0 - follow-redirects@1.16.0: {} + follow-redirects@1.16.1: {} for-each@0.3.5: dependencies: From 72fffc9ffd520ad8ffbd380a045f413b1fac04f0 Mon Sep 17 00:00:00 2001 From: biafra877-web Date: Thu, 22 Oct 2026 04:53:08 +0100 Subject: [PATCH 4/4] fix(ci): distinguish exit 1 from exit 2 in stellar provider step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With continue-on-error, GitHub Actions always sets conclusion=success, making the old outcome==failure && conclusion==failure condition permanently false. Exit 1 (content regression) was silently swallowed just like exit 2 (provider outage), leaving the job green on regressions. Fix: capture the raw exit status explicitly via $GITHUB_OUTPUT and branch on it in a separate classify step (if: always()): exit 0 → pass exit 1 → hard fail, blocks merge exit 2 → ::warning:: annotation, non-blocking other → hard fail Also add scripts/check-stellar-testnet-snippets.test.mjs with six node:test cases covering all classification paths, the core exit-1 vs exit-2 invariant, and a MODE=content integration smoke test against the real stellar.mdx. Wired as test:stellar-testnet in package.json and as a workflow step after classification. Closes #187 --- .github/workflows/snippets.yml | 52 +++++++- package.json | 1 + .../check-stellar-testnet-snippets.test.mjs | 115 ++++++++++++++++++ 3 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 scripts/check-stellar-testnet-snippets.test.mjs diff --git a/.github/workflows/snippets.yml b/.github/workflows/snippets.yml index 81b4dec..64b071e 100644 --- a/.github/workflows/snippets.yml +++ b/.github/workflows/snippets.yml @@ -190,16 +190,58 @@ jobs: MODE: content run: pnpm run check:stellar-testnet - # NON-BLOCKING: live provider availability checks. Failures here are - # provider outages (exit 2) and emit a ::warning:: annotation without - # failing the job. A genuine content-regression finding from a reachable - # provider still exits 1 and fails the job. + # NON-BLOCKING: live provider availability checks. + # + # continue-on-error keeps the job alive regardless of exit status, but it + # collapses outcome/conclusion so we cannot distinguish exit 1 from exit 2 + # through those fields alone. We therefore capture the raw exit status + # explicitly and write it to GITHUB_OUTPUT before the shell exits, giving + # the next step a reliable signal to branch on. + # + # Exit codes (defined in check-stellar-testnet-snippets.ts): + # 0 – all checks passed + # 1 – content regression → hard failure, blocks merge + # 2 – provider outage → warning annotation only, does NOT block - name: Validate Stellar testnet provider availability (non-blocking) id: provider-check continue-on-error: true env: MODE: provider - run: pnpm run check:stellar-testnet + run: | + set +e + pnpm run check:stellar-testnet + STATUS=$? + set -e + echo "exit-status=$STATUS" >> "$GITHUB_OUTPUT" + exit $STATUS + + # Exit 1 → content regression from a reachable provider; hard-fail. + # Exit 2 → provider outage; emit a warning annotation and continue. + # Exit 0 → all good; nothing to do. + - name: Classify provider-check exit status + if: always() + env: + PROVIDER_EXIT: ${{ steps.provider-check.outputs.exit-status }} + run: | + case "$PROVIDER_EXIT" in + 0) + echo "Provider check passed." + ;; + 1) + echo "::error::Provider step detected a content regression (exit 1). Failing the job." + exit 1 + ;; + 2) + echo "::warning::Provider step reported a testnet outage (exit 2). Non-blocking." + ;; + *) + echo "::error::Provider step exited with unexpected status '$PROVIDER_EXIT'. Failing the job." + exit 1 + ;; + esac + + - name: Test exit-status classification logic + run: pnpm run test:stellar-testnet mint-validation: name: Mint config validation and preview redirect checks diff --git a/package.json b/package.json index 81d81d2..f9b40e0 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:contract-registry": "node --test scripts/check-contract-registry.test.mjs", "test:preview-redirect": "node --test scripts/check-preview-redirect.test.mjs", "check:stellar-testnet": "tsx scripts/check-stellar-testnet-snippets.ts", + "test:stellar-testnet": "node --test scripts/check-stellar-testnet-snippets.test.mjs", "generate:stellar-reference": "tsx scripts/generate-stellar-reference.ts", "check:stellar-reference": "tsx scripts/generate-stellar-reference.ts --check", "generate:playground-fixtures": "node scripts/playground/generate-fixtures.mjs", diff --git a/scripts/check-stellar-testnet-snippets.test.mjs b/scripts/check-stellar-testnet-snippets.test.mjs new file mode 100644 index 0000000..acfa033 --- /dev/null +++ b/scripts/check-stellar-testnet-snippets.test.mjs @@ -0,0 +1,115 @@ +/** + * check-stellar-testnet-snippets.test.mjs + * + * Verifies the exit-status classification logic that the CI workflow relies on + * to distinguish a content regression (exit 1, blocks merge) from a provider + * outage (exit 2, non-blocking warning). + * + * The workflow step "Classify provider-check exit status" uses a shell `case` + * statement that reads the captured exit status from GITHUB_OUTPUT. These + * tests replicate that logic in JS so it can be run with `node --test` without + * requiring a live runner environment, providing the regression-test coverage + * requested in https://github.com/wraith-protocol/docs/issues/187. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; + +// --------------------------------------------------------------------------- +// Mirror of the workflow's case-statement classification logic. +// --------------------------------------------------------------------------- + +/** + * Classifies a provider-step exit status the same way the CI workflow does. + * + * Returns: + * { action: "pass" } – exit 0, nothing to do + * { action: "fail", reason } – exit 1 or unexpected, job should fail + * { action: "warn", reason } – exit 2, non-blocking warning + * + * @param {number | string} exitStatus + */ +function classifyProviderExit(exitStatus) { + const code = Number(exitStatus); + switch (code) { + case 0: + return { action: "pass" }; + case 1: + return { + action: "fail", + reason: "Provider step detected a content regression (exit 1). Failing the job.", + }; + case 2: + return { + action: "warn", + reason: "Provider step reported a testnet outage (exit 2). Non-blocking.", + }; + default: + return { + action: "fail", + reason: `Provider step exited with unexpected status '${exitStatus}'. Failing the job.`, + }; + } +} + +// --------------------------------------------------------------------------- +// Classification unit tests +// --------------------------------------------------------------------------- + +test("exit 0 → pass, job continues normally", () => { + const result = classifyProviderExit(0); + assert.equal(result.action, "pass"); +}); + +test("exit 1 → fail, content regression blocks the job", () => { + const result = classifyProviderExit(1); + assert.equal(result.action, "fail"); + assert.match(result.reason, /content regression/); +}); + +test("exit 2 → warn, provider outage is non-blocking", () => { + const result = classifyProviderExit(2); + assert.equal(result.action, "warn"); + assert.match(result.reason, /outage/); +}); + +test("unexpected exit code → fail, not silently swallowed", () => { + for (const code of [3, 127, 255, -1]) { + const result = classifyProviderExit(code); + assert.equal(result.action, "fail", `expected fail for exit code ${code}`); + assert.match(result.reason, /unexpected status/); + } +}); + +test("exit 1 and exit 2 produce different actions (core invariant)", () => { + const regression = classifyProviderExit(1); + const outage = classifyProviderExit(2); + assert.notEqual( + regression.action, + outage.action, + "exit 1 (content regression) and exit 2 (provider outage) must be classified differently", + ); +}); + +// --------------------------------------------------------------------------- +// Integration smoke test: the TypeScript script itself exits with the right +// codes when run in content mode against the real docs file. +// --------------------------------------------------------------------------- + +test("MODE=content exits 0 when stellar.mdx has enough snippets", () => { + const result = spawnSync( + "pnpm", + ["run", "check:stellar-testnet"], + { + cwd: new URL("..", import.meta.url).pathname, + env: { ...process.env, MODE: "content" }, + encoding: "utf8", + }, + ); + assert.equal( + result.status, + 0, + `expected exit 0 in content mode, got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); +});